Tool Calling with LangChain
How create_agent and the @tool decorator collapse a hand-rolled tool-calling loop into a few lines, and what that trade-off actually costs you.
· 8 min read
The problem #
Last chapter we hand-rolled the agent loop: check response.tool_calls, execute each one, wrap the result in a ToolMessage with the right tool_call_id, append it to the messages list, and call the model again until it stops asking for tools. Even with bind_tools doing the schema work, that loop is still boilerplate you write yourself, and it is easy to get subtly wrong: forget to append the assistant’s tool-call message before the tool results, mismatch a tool_call_id, or skip a stop condition and loop forever. None of that logic is specific to any one agent. It is the same handful of steps every tool-calling agent needs, rewritten from scratch each time.
The concept #
create_agent packages that exact loop, but “packages” undersells what is actually stacked underneath it. Three distinct pieces are layered on top of each other here: a schema generator (@tool), a stateless binding step (bind_tools), and a small orchestration graph (create_agent itself). Each one solves exactly one problem and hands its output up to the next.
How @tool turns a function into a schema #
A model cannot call a Python function directly. It only ever sees text and returns text, or, with tool calling enabled, a structured request to call something by name with some arguments. What actually crosses the wire is a JSON schema describing the tool’s name, its description, and the shape of its parameters, and that schema is what @tool builds.
It does this by inspecting the decorated function’s signature: each parameter’s type hint feeds into a Pydantic model that LangChain constructs dynamically to mirror the function’s argument list, and it is that model’s own JSON Schema output that becomes the tool’s parameters block. A hint of str becomes a string field in the schema; anything a type hint cannot express (what the string should contain, when a field can be left empty, valid ranges) has to come from documentation instead, which is why the docstring is not just a style nicety here, it is load-bearing.
The first line of the docstring becomes the tool’s top-level description, the field the model reads before it even looks at parameters to decide whether the tool is relevant to the current turn at all. A vague description like """Search stuff.""" gives the model nothing to reason about, and it will guess badly under ambiguity. The Args: block is a separate, easier trap: by default @tool treats the whole docstring as one opaque description string, so a nicely formatted Args: section with a description per parameter does not automatically end up in the schema. Getting that requires passing parse_docstring=True, which tells LangChain to run the docstring through a Google-style parser, split out each name: description pair, and attach it to the matching field. Skip that flag, as the example below does, and the parameter-level text stays documentation for humans reading the source, not information the model ever sees.
The binding layer: what bind_tools actually attaches #
bind_tools, which we used last chapter, is the layer directly underneath this. It takes the schemas that @tool (or a raw Pydantic model, or a plain JSON schema) produced, converts them into whatever wire format the underlying provider’s API expects for tool calling, and returns a new Runnable wrapping the model with those tools attached. That is the entire scope of the operation: one model, one set of schemas, one call in, one AIMessage out that may or may not carry tool_calls. bind_tools has no concept of a loop. It does not know or care what happens after the response comes back, that part was entirely on us in chapter 2.
What create_agent actually builds #
create_agent takes that same bound model and wraps it in a small LangGraph StateGraph, conceptually built from two nodes and one conditional edge:
- A model node, which calls the bound model with the current message history.
- A tool node, which takes any
tool_callson the latestAIMessage, dispatches each one to the matching function by name, and wraps every result in aToolMessagetagged with the originaltool_call_id, the exact bookkeeping we did by hand last chapter. - A conditional edge after the model node that inspects the latest message: if it carries
tool_calls, route to the tool node; if not, route to the end.
The tool node routes back to the model node once it finishes, closing the loop. This two-node cycle is the graph form of the reason-act-observe pattern usually shortened to ReAct: the model reasons about what to do, the graph acts by running a tool, the result is observed by feeding it back into the next model call, and the cycle repeats until the model stops asking for tools.
graph LR
A[Model node] -->|AIMessage| B{tool_calls?}
B -->|yes| C[Tool node]
B -->|no| D[END]
C -->|ToolMessages| A
That is the same shape as the while-loop from chapter 2, expressed as a graph instead of Python control flow, with the message-list bookkeeping moved inside the tool node instead of sitting inline in our own code.
Walking through the example #
This is trimmed from a small search agent that calls a real search API, simplified down to the shape that matters:
1from dotenv import load_dotenv
2from langchain.agents import create_agent
3from langchain_core.messages import HumanMessage
4from langchain_core.tools import tool
5from langchain_openai import ChatOpenAI
6from tavily import TavilyClient
7
8load_dotenv()
9tavily = TavilyClient()
10
11
12@tool
13def search(query: str) -> str:
14 """Search the internet for the given query.
15
16 Args:
17 query: The search query.
18
19 Returns:
20 The search result as a JSON string.
21 """
22 return tavily.search(query=query)
23
24
25agent = create_agent(model=ChatOpenAI(model="gpt-5", temperature=0), tools=[search])
26
27result = agent.invoke({"messages": HumanMessage(content="What's the weather in Tokyo now?")})Every layer described above is present in this handful of lines. @tool turns search into a schema built from its single query: str parameter and its docstring; because parse_docstring=True is not passed here, the Args: block reads well to a human but never reaches the model as structured, per-parameter guidance, only the top-level description does. create_agent(model=..., tools=[search]) is where binding and orchestration actually happen: internally it binds search’s schema to the ChatOpenAI model, the same operation bind_tools performs explicitly, and wires the bound model into the two-node graph described above. agent.invoke(...) is what runs it: the model node fires with the human message, decides the query needs a live search, and emits a tool_calls entry; the conditional edge routes that to the tool node, which calls tavily.search and appends the result as a ToolMessage; control returns to the model node, which now has the search result in context and produces a final answer. No explicit loop, no manual ToolMessage construction, no schema written by hand, all of it is the two-node graph from the previous section running to completion.
Trade-offs: why the abstraction is shaped this way #
The shape of create_agent is not arbitrary. It encodes the single most common agent pattern (bind tools, call the model, run whatever it asks for, repeat until it stops asking) as a default, so the overwhelming majority of tool-calling agents never need to write that graph by hand or get the message bookkeeping right themselves. That is a genuine correctness win: the bug classes from chapter 2 (a missing tool-call message, a mismatched tool_call_id, a forgotten stop condition) become structurally hard to hit through this interface, because the tool node and conditional edge are the only things that touch that bookkeeping.
What you give up in exchange is visibility and control, not correctness. create_agent does not hide the message flow, it just moves it one layer down. When an agent misbehaves (loops, calls the wrong tool, ignores a tool’s result) debugging it still means reasoning about the exact same messages list, ToolMessage role, and stop condition from chapter 2, just via streamed intermediate steps or graph state instead of a print statement in your own loop. The graph is opinionated about its own shape, too: it is a two-node cycle, not a general-purpose one. Adding a human-approval step between “model requested a tool call” and “tool actually runs,” branching to something other than the model’s own tool choice, or carrying extra state beyond the message list all sit outside what create_agent’s constructor exposes.
That is exactly the point where dropping back to bind_tools, or to the raw loop from chapter 2, is the right call, not because create_agent is broken for those cases, but because it deliberately trades the ability to customize the loop’s shape for not having to write the loop at all. Once you need to control that shape (insert a pause, add a branch, track state the message list cannot hold) you need the same primitives create_agent is built from, exposed directly instead of wrapped up.
Summary #
Chapter 2 built the tool-calling loop by hand: inspect response.tool_calls, execute each one, construct a ToolMessage with the matching tool_call_id, append it, call the model again. This chapter took that same loop and showed where each piece of it went once LangChain packaged it: @tool replaces hand-written JSON schemas with ones derived from type hints and, optionally, a parsed docstring; bind_tools is the exact attachment step from chapter 2 made explicit and reusable on its own; and create_agent wraps the whole cycle, model node, tool node, conditional edge, into a graph you invoke rather than a loop you maintain. None of the underlying mechanics changed. What changed is who is responsible for getting them right, and by default that trade favors create_agent: less code, fewer places for the bookkeeping to drift out of sync with reality.
The exception is whenever the loop’s shape itself needs to change: a pause for approval, a branch that is not “did the model ask for a tool,” extra state beyond a message list. That gap is precisely what chapter 4 covers with LangGraph, which exposes the same graph create_agent builds automatically, so you can assemble it yourself with whatever shape the problem actually needs, and control the loop explicitly again.