Agents with create_agent
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12 and
langchain-ollama 1.1.0 on Python 3.12, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions
over this site’s documentation using Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model
over the pgvector store built in Integrating with LangChain.
Ollama was not available while the snippets were checked, so every output shown here was produced by running the
code against the real packages with a scripted fake chat model (a small subclass of GenericFakeChatModel) and a
one-line stub in place of the pgvector retriever; no output comes from a live model. The fake model replays the
messages it was given, so the outputs prove the wiring (which hook ran, which message was appended, which state key
was filled), not the quality of any model’s answers.
An agent is a model that calls tools in a loop until the task is done. create_agent, imported from
langchain.agents, is the LangChain 1.x factory that builds that loop as a compiled
LangGraph graph. You supply a model, tools and a system prompt; you
extend the behaviour with middleware instead of writing graph code. The
framework-neutral theory of the loop (planning, reflection, when a workflow beats an agent) is in
Agent Loop & Architectures and is not repeated here. The tools the
agent calls are built in Tools, and the model classes in
Chat Models & Messages.
Your first agent
create_agent takes the model as its first argument and the tools as a list. A system prompt is a keyword
argument. The snippet below builds DocsAssistant’s smallest agent. The ScriptedChat helper is the fake model used
for every example on this page: it replays a list of AIMessage objects, one per model call, and its bind_tools
returns itself because the agent calls bind_tools on the model to register the tools.
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
class ScriptedChat(GenericFakeChatModel):
"""Replays scripted AIMessages and tolerates bind_tools()."""
def bind_tools(self, tools, **kwargs):
return self
def scripted(*messages):
return ScriptedChat(messages=iter(messages))
@tool
def search_docs(query: str) -> str:
"""Search the documentation site and return matching sections."""
return "ann-indexes.adoc :: HNSW :: HNSW ef_search trades recall for latency." # pgvector in DocsAssistant
call = {"name": "search_docs", "args": {"query": "HNSW ef_search"}, "id": "call_1", "type": "tool_call"}
model = scripted(
AIMessage(content="", tool_calls=[call]),
AIMessage(content="ef_search sets how many candidates HNSW explores per query; higher values raise recall and latency."),
)
agent = create_agent(
model,
tools=[search_docs],
system_prompt="You are DocsAssistant. Answer only from search_docs results and name the page.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
for m in result["messages"]:
print(type(m).__name__, "|", m.content or m.tool_calls)
HumanMessage | What does ef_search do?
AIMessage | [{'name': 'search_docs', 'args': {'query': 'HNSW ef_search'}, 'id': 'call_1', 'type': 'tool_call'}]
ToolMessage | ann-indexes.adoc :: HNSW :: HNSW ef_search trades recall for latency.
AIMessage | ef_search sets how many candidates HNSW explores per query; higher values raise recall and latency.
The returned dictionary holds the whole conversation under messages: the human message, the model’s tool call, the
ToolMessage the agent created by running search_docs, and the final answer. Nobody wrote the loop that ran the
tool and called the model a second time; that is what create_agent provides. The agent returned by the factory
is a CompiledStateGraph, so every LangGraph method (invoke, stream, get_state, get_graph) works on it. See
LangChain — Agents and the
reference for create_agent.
The parameters that matter for most agents are model, tools, system_prompt, middleware, response_format,
state_schema, context_schema, checkpointer, store and name. The sections below take them in turn.
The model
The model argument is either a model instance (a BaseChatModel such as the ChatOllama used in
Chat Models & Messages) or a string of the form
"provider:model", which create_agent resolves the same way init_chat_model does. Constructing an agent does
not contact the provider, so the following runs without an Ollama server:
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
agent = create_agent("ollama:llama3.1:8b", tools=[search_docs])
print(type(agent).__name__)
model = init_chat_model("ollama:llama3.1:8b", temperature=0)
print(type(model).__name__)
CompiledStateGraph
ChatOllama
The string form is convenient, but it uses the provider’s defaults. When you need temperature=0, a timeout or a
rate limiter, build the instance yourself and pass it in. For a hosted deployment the same call takes
"anthropic:claude-sonnet-5" or "openai:gpt-6-sol" (each needs its provider package, such as
langchain-anthropic or langchain-openai, and an API key in the provider’s environment variable; these two were
not run; model ids change, so check your provider for current ones). The model must support tool calling, otherwise the loop has nothing to loop over. The agent binds the tools
itself, so pass an unbound model. See LangChain — Models.
Tools and the system prompt
tools accepts LangChain tools, plain Python callables (converted for you) and provider tool dictionaries. Building
tools, injecting runtime data and returning artifacts are covered in Tools; the agent
adds a tool node that runs the calls the model emits and appends each result as a ToolMessage.
system_prompt sets the agent’s behaviour and accepts a string or a SystemMessage. Both forms produced a
working agent when tried against the installed version:
from langchain_core.messages import SystemMessage
a = create_agent(scripted(AIMessage("ok")), tools=[], system_prompt="Be terse.")
b = create_agent(scripted(AIMessage("ok")), tools=[], system_prompt=SystemMessage(content="Be terse."))
print(a.invoke({"messages": [{"role": "user", "content": "x"}]})["messages"][-1].content)
print(b.invoke({"messages": [{"role": "user", "content": "x"}]})["messages"][-1].content)
ok
ok
A fixed string is the right default. When the prompt has to depend on who is asking, use the @dynamic_prompt
middleware decorator, which receives the model request and returns the system prompt for this call. The request
carries the agent’s runtime, so the prompt can read the per-run context. DocsAssistant adapts its register to the
audience declared in the context; the recording model below stores the system message it received so the
result is visible:
from dataclasses import dataclass
from langchain.agents.middleware import ModelRequest, dynamic_prompt
@dataclass
class Context:
audience: str
@dynamic_prompt
def audience_prompt(request: ModelRequest) -> str:
base = "You are DocsAssistant. Answer from search_docs results."
if request.runtime.context.audience == "beginner":
return base + " Explain terms simply."
return base
seen = {}
class Recording(ScriptedChat):
def _generate(self, messages, *args, **kwargs):
seen["system"] = messages[0].content # the first message sent to the model
return super()._generate(messages, *args, **kwargs)
agent = create_agent(
Recording(messages=iter([AIMessage("ok")])),
tools=[search_docs],
middleware=[audience_prompt],
context_schema=Context,
)
agent.invoke({"messages": [{"role": "user", "content": "x"}]}, context=Context(audience="beginner"))
print(seen)
{'system': 'You are DocsAssistant. Answer from search_docs results. Explain terms simply.'}
Middleware writing and the other prompt-shaping patterns are on Middleware; the reasoning behind adapting the prompt per request is in Context Engineering for Agents. See LangChain — Custom middleware.
The agent loop
The compiled graph has two working nodes, model and tools, between start and end. The model node
calls the chat model with the system prompt plus the conversation so far. If the reply contains tool_calls, control
goes to the tools node, which runs them, appends the ToolMessage results and returns to model. If the reply
has no tool calls, the run ends and the last message is the answer. Asking the compiled agent for its graph
confirms this:
print(list(agent.get_graph().nodes))
['__start__', 'model', 'tools', '__end__']
Every middleware you add that defines a hook adds a node or wraps one of these two (see LangChain reference — create_agent). The lifecycle is:
-
before_agentruns once, when a run starts. -
before_modelruns before every model call. -
wrap_model_callwraps each model call; it can change the request, call the handler more than once, or skip it. -
after_modelruns after every model reply, before any tool runs. -
wrap_tool_callwraps each tool call. -
after_agentruns once, when the run ends.
before_agent, before_model, after_model and after_agent are node-style hooks (they receive the state and return updates); wrap_model_call
and wrap_tool_call are wrap-style hooks that surround the call and can retry or short-circuit it.
The order is easy to check rather than trust. The next agent has one middleware per hook, each appending its name to a list, and runs DocsAssistant’s search once:
from langchain.agents.middleware import (
after_agent, after_model, before_agent, before_model, wrap_model_call, wrap_tool_call,
)
log = []
@before_agent
def ba(state, runtime):
log.append("before_agent")
@before_model
def bm(state, runtime):
log.append("before_model")
@wrap_model_call
def wm(request, handler):
log.append("wrap_model_call:enter")
response = handler(request)
log.append("wrap_model_call:exit")
return response
@after_model
def am(state, runtime):
log.append("after_model")
@wrap_tool_call
def wt(request, handler):
log.append(f"wrap_tool_call:{request.tool_call['name']}")
return handler(request)
@after_agent
def aa(state, runtime):
log.append("after_agent")
model = scripted(AIMessage(content="", tool_calls=[call]), AIMessage("Done."))
agent = create_agent(model, tools=[search_docs], middleware=[ba, bm, wm, am, wt, aa])
agent.invoke({"messages": [{"role": "user", "content": "q"}]})
print(log)
print(list(agent.get_graph().nodes))
['before_agent', 'before_model', 'wrap_model_call:enter', 'wrap_model_call:exit', 'after_model', 'wrap_tool_call:search_docs', 'before_model', 'wrap_model_call:enter', 'wrap_model_call:exit', 'after_model', 'after_agent']
['__start__', 'model', 'tools', 'ba.before_agent', 'bm.before_model', 'am.after_model', 'aa.after_agent', '__end__']
before_agent and after_agent appear once; everything between them repeats for each trip round the loop, which
here is two model calls with one tool call in between. Node-style hooks became real graph nodes (bm.before_model
and so on), while the wrap-style hooks did not, because they run inside the model and tools nodes. See
LangChain — Middleware overview.
A loop needs a brake. LangGraph counts every node execution as a step and stops the run with GraphRecursionError
when a limit is passed, by default 10007 steps in the installed langgraph 1.2.12, or whatever recursion_limit the run’s config
sets. A model that keeps requesting the same tool is stopped by it. The scripted model below asks for search_docs
fifty times and the run is capped at six steps:
from langgraph.errors import GraphRecursionError
repeat = scripted(*[
AIMessage("", tool_calls=[{"name": "search_docs", "args": {"query": "x"}, "id": f"l{i}", "type": "tool_call"}])
for i in range(50)
])
agent = create_agent(repeat, tools=[search_docs])
try:
agent.invoke({"messages": [{"role": "user", "content": "loop"}]}, config={"recursion_limit": 6})
except GraphRecursionError as exc:
print("GraphRecursionError:", str(exc).split(". ")[0])
GraphRecursionError: Recursion limit of 6 reached without hitting a stop condition
recursion_limit is a blunt safety net. Limits that end the run cleanly (call-count limits, retries, fallbacks)
are middleware and are covered in Middleware. See
LangChain — Agents.
Dynamic model selection
The model given to create_agent is the default. To choose a different one per call, for example a cheaper model for
short conversations and a stronger one for long ones, write a @wrap_model_call middleware that overrides the model
on the request. request.override(model=…) returns a modified copy of the request, and passing it to the handler
performs the call with that model:
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
small = scripted(AIMessage("small model answer")) # in production: ChatOllama(model="llama3.1:8b")
large = scripted(AIMessage("large model answer")) # in production: a hosted model such as claude-sonnet-5
@wrap_model_call
def pick_model(request: ModelRequest, handler) -> ModelResponse:
turns = len(request.state["messages"])
return handler(request.override(model=large if turns > 2 else small))
agent = create_agent(small, tools=[], middleware=[pick_model])
short = agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
long = agent.invoke({"messages": [
{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, {"role": "user", "content": "c"},
]})
print(short["messages"][-1].content)
print(long["messages"][-1].content)
small model answer
large model answer
Two caveats from the official page. A model that already went through bind_tools cannot be used with structured
output, so the models handed to the middleware must be unbound; and the routing rule sees the request, so it can
just as well use request.runtime.context (for example the user’s plan) as the message count. See
LangChain — Models for the section on dynamic model
selection and Chat Models & Messages for ModelFallbackMiddleware,
which handles the related case of switching model on failure.
Structured responses with response_format
When the caller needs data rather than prose, pass response_format. The agent still runs its loop, tools included,
and adds a validated object to the final state under the structured_response key next to messages. A bare
Pydantic class works; wrap it in ToolStrategy or ProviderStrategy (from langchain.agents.structured_output)
to choose how the schema is requested and how errors are handled. The mechanics of those strategies, validation
retries and the strategy that a bare class resolves to are the subject of
Structured Output; this snippet shows only the agent-level contract.
from typing import Literal
from pydantic import BaseModel, Field
class DocsAnswer(BaseModel):
answer: str
pages: list[str] = Field(description="Pages the answer is based on")
confidence: Literal["low", "high"]
search = {"name": "search_docs", "args": {"query": "HNSW"}, "id": "c1", "type": "tool_call"}
final = {"name": "DocsAnswer", "id": "c2", "type": "tool_call", "args": {
"answer": "ef_search trades recall for latency.", "pages": ["ann-indexes.adoc"], "confidence": "high"}}
agent = create_agent(
scripted(AIMessage("", tool_calls=[search]), AIMessage("", tool_calls=[final])),
tools=[search_docs],
response_format=DocsAnswer,
)
result = agent.invoke({"messages": [{"role": "user", "content": "What is ef_search?"}]})
print(repr(result["structured_response"]))
print(list(result.keys()))
DocsAnswer(answer='ef_search trades recall for latency.', pages=['ann-indexes.adoc'], confidence='high')
['messages', 'structured_response']
Because the scripted model has no native structured-output support, the bare class was handled as a tool call named
after the schema, which is why the second scripted message calls DocsAnswer. With a hosted model whose profile
advertises native support, the same line of code asks the provider to enforce the schema instead. See
LangChain — Structured output.
State, threads and context
Three separate things travel with a run, and confusing them is a common source of bugs:
| Piece | What it holds | How you supply it |
|---|---|---|
State ( |
The |
The |
Thread ( |
Which conversation the checkpointed state belongs to, so a second call continues the first. |
|
Context ( |
Immutable per-run data such as the user id or feature flags, readable in tools and middleware as |
The |
The snippet combines all three. A tool reads the user id from the context, a custom topics field lives in the
state, and InMemorySaver keeps the thread between two calls:
from dataclasses import dataclass
from langchain.agents import AgentState, create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
@dataclass
class Context:
user_id: str
class DocsState(AgentState):
topics: list[str]
@tool
def whoami(runtime: ToolRuntime[Context]) -> str:
"""Return the id of the current user."""
return runtime.context.user_id
who = {"name": "whoami", "args": {}, "id": "w1", "type": "tool_call"}
agent = create_agent(
scripted(AIMessage("", tool_calls=[who]), AIMessage("You are u-42."), AIMessage("Second turn, history kept.")),
tools=[whoami],
context_schema=Context,
state_schema=DocsState,
checkpointer=InMemorySaver(),
name="docs_assistant",
)
config = {"configurable": {"thread_id": "t-1"}}
ctx = Context(user_id="u-42")
first = agent.invoke({"messages": [{"role": "user", "content": "who am I?"}], "topics": ["auth"]},
config=config, context=ctx)
print(first["messages"][-1].content, first["topics"])
second = agent.invoke({"messages": [{"role": "user", "content": "and again?"}]}, config=config, context=ctx)
print(len(second["messages"]), second["messages"][-1].content)
print(agent.get_state(config).values["topics"], agent.name)
You are u-42. ['auth']
6 Second turn, history kept.
['auth'] docs_assistant
The second call sent only one new message, yet the result holds six: the four from the first run plus the new human
and AI messages, and the topics value from the first call is still in the checkpoint. Without a checkpointer
each call starts empty; in production it is a Postgres-backed saver rather than InMemorySaver, a topic of
Short-Term Memory & Checkpointers. Cross-conversation
memory keyed by user is the store= argument, covered in
Long-Term Memory Stores. The optional name matters when the
agent is embedded as a node in a larger graph, as in
Multi-Agent & Deep Agents. Keep the user id in the context, not
in the prompt or in a tool argument, so the model cannot alter it. See
LangChain — Runtime and
LangChain — Short-term memory.
Streaming a run
invoke returns only when the loop ends. To show progress, stream yields one item per step. With
stream_mode="updates" each item maps the name of the node that just ran to the state update it produced:
agent = create_agent(
scripted(AIMessage("", tool_calls=[search]), AIMessage("HNSW trades recall for latency.")),
tools=[search_docs],
)
for step in agent.stream({"messages": [{"role": "user", "content": "q"}]}, stream_mode="updates"):
for node, update in step.items():
print(node, [type(m).__name__ for m in update["messages"]])
model ['AIMessage']
tools ['ToolMessage']
model ['AIMessage']
The node names are model and tools; code written for create_react_agent that filtered on the node name
"agent" must be updated. The other stream modes, token streaming and serving a stream over HTTP are on
Streaming. See
LangChain — Streaming.
Replacing create_react_agent
Before LangChain and LangGraph 1.0 the recommended way to build this loop was
langgraph.prebuilt.create_react_agent, and many tutorials and books still show it. It still exists in
langgraph 1.2.12 but is deprecated: constructing one emits a LangGraphDeprecatedSinceV10 warning whose message
points to from langchain.agents import create_agent. The v1 migration guide lists the mapping:
| create_react_agent | create_agent | Note |
|---|---|---|
|
|
Import path and function name change. |
|
|
Renamed. A callable |
|
|
Runs before each model call. |
|
|
Runs after each model reply. |
Pausing before tool execution for approval |
|
The migration guide recommends it for tool approval, configured per tool. See Human-in-the-Loop & Time Travel. |
|
|
A |
Prompted output |
|
Prompted output was removed. |
Streamed node name |
|
Update any filter on node names. |
Static values in |
|
Typed runtime context. |
Migrating DocsAssistant is a matter of swapping the import and the keyword. The old form is shown so the deprecation and the renamed node are visible; the new form is the agent used throughout this page:
import warnings
from langchain.agents import create_agent
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
from langgraph.prebuilt import create_react_agent # v0 style, deprecated
old = create_react_agent(
scripted(AIMessage("", tool_calls=[search]), AIMessage("done")), [search_docs], prompt="Be terse.")
print(caught[0].category.__name__)
new = create_agent(
scripted(AIMessage("", tool_calls=[search]), AIMessage("done")), tools=[search_docs], system_prompt="Be terse.")
stream = lambda a: [list(s)[0] for s in a.stream({"messages": [("user", "q")]}, stream_mode="updates")]
print(stream(old))
print(stream(new))
LangGraphDeprecatedSinceV10
['agent', 'tools', 'agent']
['model', 'tools', 'model']
Behaviour is otherwise the same loop, so an existing agent is normally migrated by editing these lines and moving
any hook logic into middleware. Legacy chains, retrievers and the hub module moved to langchain-classic; the migration digest is in
Ecosystem & Packages. See
LangChain — v1 migration guide and
LangChain — v1 release notes.
Where to go next
An agent is only as safe as the tools it holds. Before DocsAssistant is allowed to open a GitHub issue it needs an
approval gate and a least-privilege token; the design rules are in Tool Design and
the mechanics in Human-in-the-Loop & Time Travel. When a
single agent is not enough, Multi-Agent & Deep Agents builds on
create_agent; a comparison with other frameworks is in
Frameworks Landscape.
Summary
| Concern | What to use | Executed here |
|---|---|---|
Build an agent |
|
First agent, message trace |
Choose the model |
Instance or |
String ids; dynamic model |
Shape the prompt |
String or |
Both static forms; audience prompt |
The loop |
|
Hook order log; graph nodes |
Typed result |
|
|
Keep history and per-run data |
|
Thread and context run |
Stop a runaway loop |
|
Limit of six steps |
Migrate |
|
Deprecation warning and node names |