How a language model that can only produce text is given hands — the ability to hit a database, call an API, and run exact calculations — through the tool-calling contract, plus the graph-compilation rules that make the agent safe to run in production.
A language model is read-only: it turns tokens into tokens and can touch nothing else. Tool calling is the architecture that fixes this — the model requests a function by name and arguments, a framework (the tool node) actually runs it, and the result flows back into the model's context. The model is always the passenger, never the driver.
Hand-copy this. Telegraphic recall notes — every point is derivable from the detailed notes below.
| Party | Job |
|---|---|
| Python fn | does the real work (DB/API/calc), returns value |
| LLM | decides when, which, what args |
| Tool node | intercepts, validates, executes, packages result |
Your support agent reads a ticket — "I was charged twice this month" — and correctly labels it billing. Then it does nothing. It can't open the customer's account, can't check whether a payment really landed twice, can't issue the refund. It classified a problem it has no way to act on. That gap — between a model that describes and a system that does — is the whole subject of this lesson.
Compiling a graph turns your definition into a frozen, validated, runnable object. In
LangGraph you first describe an agent as a set of nodes and edges on a builder. Calling
builder.compile() walks that whole definition once, checks it, and hands back an executable graph
you can no longer edit.
Analogy. Compiling is the pre-flight check. The ground crew verifies every door is wired to a real destination and no gangway leads off a cliff — and once the plane pushes back, nobody rewires it mid-air.
The compile step performs several structural checks: the declared entry point really points at a registered node; every node named in an edge actually exists; and every conditional edge's routing map covers all the values its router function can return. Miss a return value and there's a path through your graph with no destination. After these pass, the graph becomes immutable — you cannot add nodes, remove edges, or change routing at runtime.
Immutability is a deliberate design choice, not a limitation. Because the compiled graph can't change, its
behaviour is fully determined by the definition, which makes it thread-safe: one compiled graph
can be shared across many concurrent requests, and each call to graph.invoke() spins up its own
isolated state. This is the "compile once, invoke many" principle.
Defining and compiling the graph inside your request handler looks harmless but recompiles the entire graph on every single call — roughly 50–200 ms of validation work thrown away each time, for nothing. Define and compile the graph once at module level so it's built at startup and reused. In-function definition is the classic beginner mistake here.
Graph is rebuilt and re-validated per request. Expensive, unnecessary, and no state is shared.
Compiled once when the app starts; the same immutable object serves every request.
# ✓ compiled ONCE at import — reused across all requests
graph = build_graph().compile()
def handle_request(ticket: str):
# ✗ do NOT put build_graph().compile() in here
return graph.invoke({"raw_input": ticket})
Two more production failure modes came up alongside compilation. An infinite loop happens when
a conditional router keeps returning the same node with no exit — fixed by wiring at least one branch to the
terminal END state and adding a retry-count field to state that caps iterations (e.g. at three). A
state-mutation bug happens when a node edits the state dict in place
(state["k"] = v) instead of returning a fresh dictionary.
A plain pipeline blindly runs fixed steps; an agent inspects what happened and decides what to do next. A directed acyclic graph (a DAG) is a static list of steps that assumes each one succeeds. A LangGraph agent adds decisions and loops on top of that skeleton.
A DAG is a recipe; an agent is a chef. The recipe lists steps in order and assumes nothing goes wrong. The chef reads the state of the dish, tastes it, decides it needs more salt, and loops back if a step failed. In LangGraph the typed state is the chef's working memory, nodes are the actions, and conditional edges are the decisions.
The support-ticket agent from Session 7.1 makes this concrete. A typed SupportState dictionary is
the central contract that flows through every node. A classifier node reads the raw ticket and,
via Gemini 2.5 Flash [!], sorts it into one of four categories; a router then sends it to
the matching handler.
The auto-generated summary at the top of the source names Gemini 1.5 Flash, but the instructor states Gemini 2.5 Flash throughout the live walkthrough (temperature 0). The spoken lesson is the authority here, so these notes use 2.5 Flash.
The classifier is a small defensive pipeline: send the prompt, take the raw model output,
normalize it (strip whitespace/punctuation, l-case), validate it against the
four allowed categories, and fall back to general if it's anything else. The
router then maps the category to a handler node — technical, billing,
fraud, or general — and that routing is a conditional edge.
At its core a language model is one function: tokens in, tokens out. Every capability it seems to have — reasoning, summarizing, writing code — is an emergent property of that single token-to-token transform. That architecture has three consequences every engineer must internalize before shipping.
Knowledge is a frozen snapshot at the training cutoff. It cannot know your customer's payment failed this morning.
No file system, no network sockets, no DB connections. It runs in a sealed box and cannot reach out to touch anything.
It can describe, recommend, or generate code for an action — but it never performs one. It reacts to input and emits output.
Passivity is not a bug; it's a deliberate design choice that makes the model safe to run in shared, multi-tenant cloud environments. But it's a limitation every agent architect has to work around. The practical upshot is a short debug checklist: when a production AI system misbehaves, run through the four things a tool-free model simply cannot do, and you'll immediately know whether the root cause is a missing tool or something else.
| Tool-free limit | What it can't do |
|---|---|
| No DB access | Can't issue a SQL query, a Mongo find, or read a key from a cache. |
| No API access | Can't make an HTTP request — no Stripe, Salesforce, Slack, or email send. |
| No real-time data | Prices, exchange rates, weather, system health, incident status — all static. |
| No deterministic execution | It's a probabilistic text generator, not a reliable calculator. A stated total can simply be wrong. |
In 2023 a lawyer filed a brief citing six court cases that an LLM had fabricated; none existed, and the lawyer was sanctioned (the Mata v. Avianca [+] case). The lesson: never let the model compute or invent a value that must be exact — money, IDs, lookups. Route it to deterministic tool code.
A tool is a plain Python function the model can ask to have run, whose result is fed back into the model's context. Nothing exotic — the function can query a database, call an API, do a calculation, read a file. What makes it a tool is the contract around it: three parties, each with one job.
A brilliant consultant who can't get into your building can only give advice. Tools are the specialists inside the building the consultant can phone. The consultant (the LLM) decides who to call and what to ask; the specialists (your functions) do the actual work.
Without the Python function, no real-world data ever reaches the model and it may hallucinate. Without the model, nothing decides which tool to call. Without the tool node, an error inside a tool crashes the whole graph. The contract only works when all three hold.
The @tool decorator turns an ordinary function into something the model can understand and
the framework can run. Imported from langchain_core.tools, it does three things to the
function it wraps.
Inspects the signature — parameter names, type hints, defaults — and builds a JSON schema.
Reads the docstring and uses it as the tool's description inside that schema.
Returns a StructuredTool (a runnable) that carries its own name, schema, and
description. The original fn is stored as an attribute.
Type hints are the schema contract. Every parameter's annotation becomes the type the model is told to send. Get it right and the model passes a value the function can use; leave it off and things break silently.
Any parameter without a type annotation defaults to string in the schema. So the model is told "send a string," it sends a string, and if your function then hands that string to a query expecting an integer you get a type error at runtime — inside the tool executor, silently — and the call crashes. Annotate every parameter.
The docstring is the intelligence layer — and here's the insight that reframes tool design.
The model never sees your Python source and can't observe what the function does at runtime. Its entire understanding comes from the JSON schema — parameters, types, and the description lifted straight from your docstring. If the consultant and the specialist speak different languages, the docstring is the interpreter between them. A vague docstring means the model doesn't know when to call the tool; the whole thing breaks down. Write the docstring for the model, not for yourself.
A production-grade docstring answers four questions, in the order the model needs them:
| Question | How to answer it |
|---|---|
| What does it do? | Describe the action, not the implementation. "Retrieves a customer's billing info" — not "queries the Postgres customers table." |
| When should the LLM call it? | State the triggering conditions explicitly: "Call this when the query involves billing, invoices, or payment status." |
| What does each parameter mean? | Give the semantic meaning and format, with an example: "customer_id — unique id in the form C-XXXX, e.g. C-1042." |
| What does it return? | Name the fields the model should expect back, so it doesn't have to guess. |
Return a dictionary, not a formatted string: it serializes to JSON, so the model can pull
out individual fields (billing_status, last_payment_date) instead of parsing prose.
And return only the fields the answer needs. A tool that dumps an entire customer row spends
hundreds of tokens to deliver three facts — every wasted token is stolen from the model's reasoning budget.
Filter the noise inside the tool.
from langchain_core.tools import tool
@tool
def get_customer_details(customer_id: str) -> dict:
"""Retrieve a customer's billing info and purchase history.
Call this when the user's query involves billing, invoices,
payment status, or subscription management.
Args:
customer_id: unique customer id, format C-XXXX (e.g. C-1042).
Returns:
dict with billing_status, last_payment_date, purchase_history.
"""
record = crm.lookup(customer_id) # the real work
return { # only what the LLM needs
"billing_status": record.status,
"last_payment_date": record.last_paid,
"purchase_history": record.purchases[:3],
}
Defining a tool and binding a tool are two different acts. Defining it with @tool
creates the object. Binding tells a specific model "these tools are available to you."
llm.bind_tools([...]) is a Python-to-JSON handshake: it calls each tool's schema method, collects
the JSON schemas, and returns a new model object. It sends no API request — it just produces a model
that will include those schemas in every future call.
A common mix-up. You can define ten tools and the model still won't use any of them until you
bind_tools them onto the LLM you actually invoke. Binding is what puts the schemas into the
request.
When a bound model decides to use a tool, it doesn't emit normal text. It returns an AIMessage
whose .tool_calls attribute is a list of dictionaries, each with three fields:
Which tool to run.
A dict of arguments matching the schema.
A unique id used to match the tool's response back.
An AIMessage can carry both text and tool calls (the model reasons aloud, then requests a
tool), or only tool calls with empty content (a pure request), or plain text with an empty tool-calls
list (it can answer from context alone). It may also request several tools at once.
| tool_calls | content | meaning |
|---|---|---|
| empty | text | Model answers from context; no tool needed. |
| 1 call | empty | Pure request — no answer possible without data. |
| 1 call | text | "Let me look that up…" then the request. |
| many calls | either | Several tools requested in one turn. |
llm_with_tools = llm.bind_tools([get_customer_details])
resp = llm_with_tools.invoke("check account C-1042")
print(type(resp)) # AIMessage
print(resp.tool_calls)
# [{'name': 'get_customer_details',
# 'args': {'customer_id': 'C-1042'},
# 'id': 'call_abc123'}]
For Gemini models the same protocol runs over Google's function-calling API. LangChain's
bind_tools transparently translates between its own tool-schema format and Google's
function-declaration format, so from your side the API is identical regardless of provider — one of LangChain's
core value propositions. (This standardized tool-calling shape was pioneered by OpenAI and later adopted by
Google and Anthropic.)
The tool node is LangChain's built-in component that actually runs the requested tools and catches
their failures. It sits between the LLM node and the rest of the graph: it intercepts the tool-call
request, runs the matching Python function, and packages the return value as a ToolMessage appended
to the conversation. Its existence is a deliberate principle — separation of reasoning from execution.
The LLM decides what; the tool node does it.
Because execution is isolated, you can unit-test the function directly, and mock the tool node in integration tests.
A failing tool raises at the node boundary, not deep in the LLM step — so it can be caught and handled.
One central place to log which tool ran, with what args, when, with what result, in how many ms.
The tool node is instantiated with the list of tool objects — the same @tool functions you bound
to the LLM. That shared list is the link between the names the model uses and the functions the node runs.
By default the tool name is the function name. Rename the function but forget to update what's bound to the LLM, and you get a silent failure: the model emits a tool call, but the node finds no tool by that name. Nothing crashes loudly — the call just goes nowhere.
Error handling is not optional. A real CRM lookup can fail many ways: the DB is briefly down, the connection times out, the model passed a malformed id, the id is valid but the record was deleted, or the tool has a bug. In every case an unhandled exception propagates up through the tool node and crashes the graph.
Let an exception escape the tool and it takes the whole graph down; the user sees an HTTP 500. The production
pattern is to catch it at the node and convert it into an error ToolMessage
passed back to the model — which can then retry with different arguments, tell the user the data is
unavailable, or escalate to a human. That containment is what makes tool-using agents resilient.
Compiling validates the entire graph and produces the immutable executable — real work that costs roughly 50–200 ms. Defining it inside the handler repeats that on every request for no benefit. At module level it's compiled once at startup and the same thread-safe object serves every call ("compile once, invoke many").
Temporal — its knowledge is frozen at the training cutoff. Environment — no file system, sockets, or DB connections; it runs in a sealed box. Deterministic passivity — it can describe or recommend an action but never performs one. Tools are how you work around all three.
The Python function does the real work and returns a value. The LLM
decides when to call a tool, which one, and with what arguments. The tool node intercepts
the request, validates and executes it, and packages the result as a ToolMessage.
It can't. The model never sees the Python source and can't observe runtime behaviour. Everything it knows comes from the JSON schema, whose description is lifted from the docstring. The docstring is the translation layer between the model and the function; a vague one means the model won't know when or how to call the tool.
It defaults to string in the schema. The model dutifully sends a string; if your function
then feeds it to something expecting another type (say an integer query), you hit a type error at runtime
inside the tool executor — a silent crash rather than a clear validation message.
A dict serializes to JSON, so the model can extract specific fields cleanly rather than parsing prose. It also lets you return only the fields the answer needs — keeping the context window lean, since every wasted token is stolen from reasoning.
bind_tools actually do, and does it call the API?It converts each tool's signature, type hints, and docstring into a JSON schema and returns a new model object that will include those schemas in every request. It sends no API call itself — it's a local Python-to-JSON handshake. Defining a tool is separate from binding it.
Separating reasoning from execution buys three things: testability (test the function
alone, mock the node), error containment (a failure is caught at the boundary and turned
into an error ToolMessage instead of crashing the graph), and observability
(one central place to log and trace every tool call).
Run the four-limit checklist: it likely needs a tool to look up the account (no DB access) or hit an API. If instead it's returning a confidently wrong number, that's the deterministic-execution limit — move the calculation into tool code. If it invents facts about recent events, that's temporal isolation.
The reading proves you recognize the ideas; these prove you can use them. Attempt each before opening the walkthrough.
Write a LangChain tool check_order_status the support agent can call. Requirements:
@tool from langchain_core.tools.order_id, explicitly typed as str.status, carrier, and
eta_days — not the whole order record, not a formatted string.
Expected result: check_order_status.name == "check_order_status", and invoking
it returns a dict of exactly those three keys.
Step 1 — decorate and type. The @tool decorator runs its three
operations on the function; the : str annotation is what tells the model the argument is a
string. Skip it and the schema silently defaults to string anyway, but you lose the habit that saves you on
an int parameter later.
Step 2 — write the docstring for the model. This is the tool's entire interface. Describe the action ("look up shipping status"), the trigger ("when the user asks where their order is"), the parameter's meaning and format, and the return shape.
Step 3 — return a minimal dict. Three keys, serializable, no prose. The model
extracts eta_days directly instead of parsing a sentence, and you don't burn tokens on fields
nobody asked for.
from langchain_core.tools import tool
@tool
def check_order_status(order_id: str) -> dict:
"""Look up the shipping status of a customer's order.
Call this when the user asks where their order is, whether it
has shipped, or when it will arrive.
Args:
order_id: unique order id, format ORD-XXXXX (e.g. ORD-90421).
Returns:
dict with status, carrier, and eta_days.
"""
order = orders.lookup(order_id)
return {
"status": order.status,
"carrier": order.carrier,
"eta_days": order.eta_days,
}
# check_order_status.name -> "check_order_status"
# check_order_status.description -> the docstring above
Why: type hints plus a four-question docstring are the schema the model reasons from — the tool's usefulness is decided before a single line of its body runs.
A teammate defined a tool and wired up the model, but at runtime the model just answers in prose and never calls the tool. Find the root cause, predict the exact behaviour, and fix it.
from langchain_core.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
@tool
def get_balance(account):
"""Return the account balance."""
return {"balance": bank.balance(account)}
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
def answer(question: str):
return llm.invoke(question) # "what's the balance on A-77?"
Root cause. The tool is defined but never bound.
answer calls the bare llm, not a tool-bound one, so the model's request never
carries any tool schema — it has no idea get_balance exists.
Predicted behaviour. No crash. resp.tool_calls is an empty list and
the model returns plain text — very likely a hallucinated or hedged balance, since it can't reach the bank.
That silence is the whole lesson: a missing bind fails quietly, not loudly.
Second defect. account has no type annotation, so it defaults to
string in the schema. Harmless while it happens to be a string, but it's the exact setup for a
silent runtime type error the day the function expects something else. Annotate it.
@tool
def get_balance(account: str) -> dict:
"""Return the balance for a bank account.
Call this whenever the user asks about a balance.
Args:
account: account id, format A-XX (e.g. A-77).
"""
return {"balance": bank.balance(account)}
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
llm_with_tools = llm.bind_tools([get_balance]) # ← the missing step
def answer(question: str):
return llm_with_tools.invoke(question) # now emits tool_calls
Why: defining a tool and binding it are two acts — the schema only reaches the model through
bind_tools, and an unbound tool is invisible.
Field notes · LangGraph · Tool Binding & Execution — end of note