Deep Agents、create_agent、LangGraph 怎么选:同一任务的实测对比
官方把 LangChain 的三类产品分成运行时(LangGraph)、框架(LangChain 的 create_agent)和 Harness(Deep Agents)。这个说法很清楚,但落到选型上还是会卡住:Deep Agents 到底多给了什么?多给的东西要付出什么代价?什么时候该从 create_agent 换过去,什么时候反而该退回 LangGraph?
如果你正在为 Agent 项目选起点,下面用同一组工具、同一个脚本化模型,把三种实现各跑一遍,只看框架行为——模型看到了什么、状态放在哪、在哪里停下来。结论汇总成一张决策表。已经读过 create_agent 与 LangGraph 的选型的话,可以把这篇当作它的第三个选项补充。
先说结论
- 三者是三层,不是三选一。
create_deep_agent的返回值是create_agent编译出的 LangGraph 图,只是在外面叠了一组中间件和内置工具(实验 1)。选型的问题是“你要在哪一层开始,以及愿不愿意为下一层的默认行为买单”。 - 起点怎么选。 一个“模型 + 工具”循环,用
create_agent;固定阶段、并行分支、确定性路由,用 LangGraph;工具结果动辄几万字符、任务里有互相独立的资料调研子任务,Deep Agents 才开始回本。 - Deep Agents 的主要价值是上下文管理,而不是“更聪明”。 超大工具结果自动落到文件(实验 2),子 Agent 把中间过程关在自己的上下文里(实验 3)。这两件事用
create_agent也能做,但要自己写。 - 代价同样具体。 模型每次都要多带 8 个内置工具的 schema(本版本约 10 KB 字符,实验 1);文件默认存在图状态里,没有 checkpointer 就读不回来(实验 4);审批、循环上限仍然要你自己配(实验 5、6)。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | deepagents==0.7.32、langchain==1.4.7、langchain-core==1.7.0、langgraph==1.2.17 |
| 模型 | 脚本化模型:按预设顺序回放固定的 AIMessage,不联网、不需要 API Key;同时记录框架递给它的工具和每条消息的字符数 |
| 官方来源 | Runtimes, frameworks, and harnesses (opens in a new tab)、Deep Agents 概览 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16) |
| 实验代码 | 站点仓库 labs/deep-agents-selection/,依赖锁定在 labs/requirements/v1da.txt |
验证边界
- 已验证:框架行为,即工具面、上下文里放了什么、状态与文件的生命周期、中断与限制。体积一律是字符数,不是 token,更不是费用。
- 未覆盖:真实模型的规划质量、成本与延迟;真实提供商的 harness profile;
execute与沙箱、技能与长期记忆、摘要压缩、FilesystemBackend与其他后端、异步子 Agent。
实验用的是自定义模型实例,没有匹配任何按模型提供商注册的 harness profile。换成真实提供商的模型后,系统提示词和工具可能被 profile 调整,上线前要用真实模型把实验 1 再测一遍。
任务设定
“客服助手”:lookup_order 查询订单状态,fetch_report 返回一份事故报告全文(字符数可调,用来模拟日志、网页、检索结果这类大返回值)。所有实验都用脚本化模型,每次运行输出完全一致。公共模块 shared.py 里有脚本化模型、两个工具,以及手写的 LangGraph 循环 build_state_graph(一个模型节点、一个工具节点、一条条件边)。
实验 1:模型到底看到了什么
三种实现放进同一个循环,比较编译对象、图节点、递给模型的工具和最终状态:
"""One support loop, three implementations: compare what each puts in front of the model."""
import json
from deepagents import create_deep_agent
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from langchain_core.utils.function_calling import convert_to_openai_tool
from shared import ScriptedChatModel, build_state_graph, lookup_order
PROMPT = "You are a support assistant."
IMPLEMENTATIONS = {
"create_agent": lambda m: create_agent(m, tools=[lookup_order], system_prompt=PROMPT),
"StateGraph": lambda m: build_state_graph(m, [lookup_order], PROMPT),
"create_deep_agent": lambda m: create_deep_agent(model=m, tools=[lookup_order], system_prompt=PROMPT),
}
for name, build in IMPLEMENTATIONS.items():
model = ScriptedChatModel(script=[AIMessage(content="Order A1 is shipped.")])
agent = build(model)
result = agent.invoke({"messages": [("user", "Where is order A1?")]})
specs = [convert_to_openai_tool(t) for t in model.bound_tools]
system_chars = next(size for kind, size in model.calls[0] if kind == "system")
print(name)
print(f" compiled object: {type(agent).__name__}")
print(f" graph nodes: {', '.join(n for n in sorted(agent.nodes) if n != '__start__')}")
print(f" tools shown to the model: {len(specs)} ({', '.join(s['function']['name'] for s in specs)})")
print(f" tool schema size: {sum(len(json.dumps(s)) for s in specs)} chars")
print(f" system message size: {system_chars} chars")
print(f" state keys after one run: {', '.join(sorted(result))}")create_agent
compiled object: CompiledStateGraph
graph nodes: model, tools
tools shown to the model: 1 (lookup_order)
tool schema size: 225 chars
system message size: 28 chars
state keys after one run: messages
StateGraph
compiled object: CompiledStateGraph
graph nodes: model, tools
tools shown to the model: 1 (lookup_order)
tool schema size: 225 chars
system message size: 28 chars
state keys after one run: messages
create_deep_agent
compiled object: CompiledStateGraph
graph nodes: PatchToolCallsMiddleware.before_agent, _ParentNotifierMiddleware.after_agent, model, tools
tools shown to the model: 9 (ls, read_file, write_file, edit_file, delete, glob, grep, task, lookup_order)
tool schema size: 10736 chars
system message size: 28 chars
state keys after one run: files, messages读这份输出要抓住四点:
- 同一类对象。 三者编译出来都是
CompiledStateGraph,Deep Agents 比create_agent多出两个中间件节点。这与源码一致:deepagents/graph.py的最后一步就是return create_agent(...)。 - 工具面从 1 个变成 9 个。 多出来的 8 个是
ls、read_file、write_file、edit_file、delete、glob、grep和用来委派子 Agent 的task。本版本默认没有暴露execute,也没有write_todos(官方把任务规划列为可选能力)。 - 工具 schema 从 225 字符变成 10,736 字符。 这部分每次模型调用都会随请求发出。对简单任务,这是纯粹的额外成本。
- 状态里多了
files。 后面的实验 2 到 4 都围绕它展开。系统消息在三者中都是 28 个字符,这是前面说过的自定义模型带来的限制,不代表真实提供商下同样如此。
实验 2:工具返回了一大坨文本,谁来管
fetch_report 分别返回 60,000 和 90,000 字符,看模型在下一次调用里收到的工具消息有多大:
"""A tool returns a large result: how many characters does the model see on its next call?"""
from deepagents import create_deep_agent
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from shared import ScriptedChatModel, build_state_graph, last_chars, make_fetch_report, tool_call
IMPLEMENTATIONS = {
"create_agent": lambda m, t: create_agent(m, tools=t),
"StateGraph": build_state_graph,
"create_deep_agent": lambda m, t: create_deep_agent(model=m, tools=t),
}
print("chars of the tool message the model sees on its second call")
print(f"{'result size':>12} " + " ".join(f"{name:>17}" for name in IMPLEMENTATIONS))
for size in (60_000, 90_000):
seen, offloaded = [], []
for build in IMPLEMENTATIONS.values():
tools = [make_fetch_report(size)]
model = ScriptedChatModel(script=[tool_call("fetch_report", {"report_id": "R1"}, "c1"), AIMessage(content="summary")])
result = build(model, tools).invoke({"messages": [("user", "Summarize report R1")]})
seen.append(last_chars(model, 1, "tool"))
offloaded.append(sorted(result.get("files", {})))
print(f"{size:>12} " + " ".join(f"{n:>17}" for n in seen))
print(f"{'files in state':>12} " + " ".join(f"{', '.join(f) or '-':>17}" for f in offloaded))chars of the tool message the model sees on its second call
result size create_agent StateGraph create_deep_agent
60000 60000 60000 60000
files in state - - -
90000 90000 90000 1580
files in state - - /large_tool_results/c1create_agent和手写StateGraph的行为一样:返回多大,模型就收到多大,之后的每一轮都要带着它。- Deep Agents 在 60,000 字符时原样放行,90,000 字符时把全文写进
/large_tool_results/c1,只给模型一条 1,580 字符的占位消息(带文件路径和预览)。分界线与官方文档和源码吻合:默认 20,000 token,按每 token 4 个字符折算,约 80,000 字符。 - 这不是“信息丢了”:模型可以用
read_file分段读回。区别是读多少由模型决定,而且后续轮次里留下的是它实际读过的那部分。
实验 3:把重活交给子 Agent,主对话留下什么
A 组:create_agent 自己调用 fetch_report(30,000 字符,低于卸载阈值,所以全文会留在谁的上下文里就一目了然)。B 组:Deep Agents 通过内置的 task 工具,把同样的调研交给一个子 Agent。
"""Delegating a heavy lookup to a subagent: what does the main conversation keep?"""
from deepagents import create_deep_agent
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from shared import ScriptedChatModel, last_chars, make_fetch_report, tool_call
REPORT_CHARS = 30_000 # below the offload threshold, so the full text stays in whichever context calls the tool
SUMMARY = "R1: database failover at 02:14, root cause was an expired certificate."
fetch_report = make_fetch_report(REPORT_CHARS)
# A. create_agent calls the tool itself: the whole report lands in the main conversation.
direct = ScriptedChatModel(script=[tool_call("fetch_report", {"report_id": "R1"}, "c1"), AIMessage(content=SUMMARY)])
create_agent(direct, tools=[fetch_report]).invoke({"messages": [("user", "What happened in R1?")]})
# B. create_deep_agent delegates to a subagent through the built-in `task` tool.
child = ScriptedChatModel(script=[tool_call("fetch_report", {"report_id": "R1"}, "k1"), AIMessage(content=SUMMARY)])
parent = ScriptedChatModel(
script=[
tool_call("task", {"description": "Read report R1 and summarise the root cause", "subagent_type": "analyst"}, "p1"),
AIMessage(content=SUMMARY),
]
)
agent = create_deep_agent(
model=parent,
subagents=[
{
"name": "analyst",
"description": "Reads incident reports and returns a short summary",
"system_prompt": "You read incident reports.",
"tools": [fetch_report],
"model": child,
}
],
)
result = agent.invoke({"messages": [("user", "What happened in R1?")]})
print(f"report size: {REPORT_CHARS} chars")
print(f"A. create_agent, tool called directly: main model sees a {last_chars(direct, 1, 'tool')}-char tool message")
print(f"B. deep agent, tool called by a subagent: main model sees a {last_chars(parent, 1, 'tool')}-char tool message")
print(f" subagent model saw a {last_chars(child, 1, 'tool')}-char tool message in its own context")
print(f" main conversation kept {len(result['messages'])} messages, subagent model made {len(child.calls)} calls")
print(f" largest message kept in the main state: {max(len(m.content) for m in result['messages'])} chars")report size: 30000 chars
A. create_agent, tool called directly: main model sees a 30000-char tool message
B. deep agent, tool called by a subagent: main model sees a 70-char tool message
subagent model saw a 30000-char tool message in its own context
main conversation kept 4 messages, subagent model made 2 calls
largest message kept in the main state: 70 chars子 Agent 自己仍然完整读了 30,000 字符,所以总开销没有省,还多了子 Agent 的两次模型调用。省下的是主对话:之后的每一轮都不必再带着这份报告,主状态里只有 4 条消息,最大的一条 70 字符。适合用子 Agent 的,是“读很多资料、只要一个结论”的调研型子任务;如果结论本身还要被后续步骤反复引用,隔离就没有意义。
实验 4:文件放在哪里,能活多久
Deep Agents 默认把文件存在图状态里(官方称为 StateBackend,作用范围是当前线程)。下面写一个文件,再分别在同一线程和另一线程里读它,有无 checkpointer 各跑一遍:
"""Deep agent files live in graph state: how long do they survive?"""
from deepagents import create_deep_agent
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from shared import ScriptedChatModel, tool_call
WRITE = tool_call("write_file", {"file_path": "/notes.md", "content": "refund A1 after approval"}, "w1")
READ = "read_file"
def read_script(call_id: str):
return [tool_call(READ, {"file_path": "/notes.md"}, call_id), AIMessage(content="done")]
def last_tool_text(result) -> str:
return [m.content for m in result["messages"] if m.type == "tool"][-1].replace("\n", " | ")
def run(checkpointer):
model = ScriptedChatModel(script=[WRITE, AIMessage(content="saved")] + read_script("r1") + read_script("r2"))
agent = create_deep_agent(model=model, checkpointer=checkpointer)
def turn(text: str, thread: str):
config = {"configurable": {"thread_id": thread}} if checkpointer else {}
return agent.invoke({"messages": [("user", text)]}, config)
first = turn("Save a note", "t1")
print(f" turn 1 wrote: {sorted(first['files'])}")
print(f" turn 2, same thread: {last_tool_text(turn('Read the note', 't1'))}")
print(f" turn 3, other thread: {last_tool_text(turn('Read the note', 't2'))}")
print("with a checkpointer:")
run(InMemorySaver())
print("without a checkpointer:")
run(None)with a checkpointer:
turn 1 wrote: ['/notes.md']
turn 2, same thread: @@ lines 1-1 of 1 @@ | refund A1 after approval
turn 3, other thread: Error: File '/notes.md' not found
without a checkpointer:
turn 1 wrote: ['/notes.md']
turn 2, same thread: Error: File '/notes.md' not found
turn 3, other thread: Error: File '/notes.md' not found文件只在“有 checkpointer 且是同一个 thread_id”时读得回来。这有两个后果:一是用 Deep Agents 做多轮对话,checkpointer 是必选项,而不是上线时才补的优化;二是不要把默认的文件空间当成共享存储或长期记忆。需要跨线程共享,要换后端,官方的 backends 文档 (opens in a new tab)列出了几种选择,不在本次验证范围。
实验 5:审批能力还在吗
对写文件加人工审批,用的仍是 HumanInTheLoopMiddleware,通过 interrupt_on 配置。分别批准和拒绝:
"""Deep agent file writes behind human approval (HumanInTheLoopMiddleware via interrupt_on)."""
from deepagents import create_deep_agent
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from shared import ScriptedChatModel, tool_call
def run(decision: dict) -> None:
model = ScriptedChatModel(
script=[tool_call("write_file", {"file_path": "/plan.md", "content": "refund A1"}, "w1"), AIMessage(content="finished")]
)
agent = create_deep_agent(model=model, checkpointer=InMemorySaver(), interrupt_on={"write_file": True})
config = {"configurable": {"thread_id": "review"}}
paused = agent.invoke({"messages": [("user", "Write the refund plan")]}, config)
request = paused["__interrupt__"][0].value["action_requests"][0]
print(f" paused before: {request['name']} {request['args']}")
print(f" files while paused: {sorted(paused['files']) or 'none'}")
done = agent.invoke(Command(resume={"decisions": [decision]}), config)
print(f" decision: {decision['type']}")
print(f" files after: {sorted(done['files']) or 'none'}")
print(f" tool message: {[m.content for m in done['messages'] if m.type == 'tool'][0].splitlines()[0]}")
print("approve:")
run({"type": "approve"})
print("reject:")
run({"type": "reject", "message": "not allowed"})approve:
paused before: write_file {'file_path': '/plan.md', 'content': 'refund A1'}
files while paused: none
decision: approve
files after: ['/plan.md']
tool message: Updated file /plan.md
reject:
paused before: write_file {'file_path': '/plan.md', 'content': 'refund A1'}
files while paused: none
decision: reject
files after: none
tool message: User rejected the tool call for `write_file` with reason: not allowed暂停发生在写入之前(暂停时没有文件),批准后文件写入,拒绝后没有文件,并且拒绝的理由会作为工具消息回到模型。机制和 create_agent 完全一致,所以审批不是选型的分水岭。内置工具多了,要审批的对象也多了:除了 write_file,edit_file 和 delete 这类会改动文件的工具同样应该纳入 interrupt_on。
实验 6:模型停不下来时,在哪里被拦住
用一个永远只调用工具的脚本化模型,看各实现的循环上限:
"""A model that never stops calling tools: where does each implementation cut it off?"""
from deepagents import create_deep_agent
from langchain.agents import create_agent
from langgraph._internal._config import DEFAULT_RECURSION_LIMIT # private module: fine for a pinned lab
from langgraph.errors import GraphRecursionError
from shared import ScriptedChatModel, build_state_graph, lookup_order, tool_call
CAP = 25
TOOLS = [lookup_order]
IMPLEMENTATIONS = {
"create_agent": lambda m: create_agent(m, tools=TOOLS),
"StateGraph": lambda m: build_state_graph(m, TOOLS),
"create_deep_agent": lambda m: create_deep_agent(model=m, tools=TOOLS),
}
def looping_model() -> ScriptedChatModel:
return ScriptedChatModel(script=[tool_call("lookup_order", {"order_id": "A1"}, "c")], loop=True)
def model_calls_before_stop(build, limit: int) -> int:
model = looping_model()
try:
build(model).invoke({"messages": [("user", "Check order A1 forever")]}, {"recursion_limit": limit})
except GraphRecursionError:
return len(model.calls)
raise AssertionError("the loop was expected to hit the recursion limit")
print("recursion_limit each implementation starts with")
print(f" create_agent: {create_agent(looping_model(), tools=TOOLS).config['recursion_limit']}")
print(f" create_deep_agent: {create_deep_agent(model=looping_model(), tools=TOOLS).config['recursion_limit']}")
print(f" StateGraph (LangGraph default): {int(DEFAULT_RECURSION_LIMIT)}")
print(f"model calls before the loop is stopped with recursion_limit={CAP} in the call config")
for name, build in IMPLEMENTATIONS.items():
print(f" {name}: {model_calls_before_stop(build, CAP)}")recursion_limit each implementation starts with
create_agent: 9999
create_deep_agent: 9999
StateGraph (LangGraph default): 10007
model calls before the loop is stopped with recursion_limit=25 in the call config
create_agent: 13
StateGraph: 13
create_deep_agent: 12没有一种实现自带“收紧”的预算:create_agent 和 Deep Agents 的默认上限都是 9,999,手写图用 LangGraph 的默认值 10,007。每一次“模型调用 + 工具调用”占两步,所以默认配置下,一个停不下来的循环会跑到大约 5,000 次模型调用才报 GraphRecursionError。把调用配置里的 recursion_limit 设成 25,三者分别在 13、13、12 次模型调用后停下,Deep Agents 因为多了中间件节点而少一次。结论很实际:换成 Deep Agents 不会让失控循环更便宜,上限要自己设,设多少取决于你愿意为一次任务付出的最大模型调用次数。
选型决策表
| 你的任务特征 | 建议起点 | 依据 | 什么时候换层 |
|---|---|---|---|
| 单一目标、工具少、一次会话内完成 | create_agent | 工具面 225 对 10,736 字符(实验 1) | 工具返回体开始失控,或出现下面任一行 |
| 工具返回体经常超过几万字符(日志、网页、检索全文) | Deep Agents,或 create_agent 加一个自写的截断中间件 | 实验 2:只有 Deep Agents 默认卸载 | 卸载后模型总要把全文读回来时,回头优化工具本身 |
| 一次任务里有多段互相独立、各自要读大量资料的调研 | Deep Agents 的子 Agent | 实验 3:主对话 70 字符对 30,000 字符 | 子 Agent 结论还要被后续步骤反复引用时,不要拆 |
| 固定阶段、并行分支、业务字段作为状态、确定性路由 | LangGraph | 见实验对照里的并行与审核回路 | 想要自动卸载与子 Agent 时,可以在 LangGraph 里自己组合 |
| 敏感工具需要人工审批、暂停恢复 | 三者都可以,需要 checkpointer | 实验 5 与中间件护栏 | 内置工具多了,要把会改文件的工具都列进 interrupt_on |
| 多轮对话、需要文件或记忆跨轮保留 | Deep Agents 时必须配 checkpointer,跨线程要换后端 | 实验 4 | 需要共享给其他线程或用户时,评估官方后端选项(不在本次验证范围) |
| 对单次任务成本有硬上限 | 任何一层都要显式设置 recursion_limit | 实验 6 | 同时配合超时与调用次数监控,见延迟预算 |
从 create_agent 升级到 Deep Agents 的最小步骤
两者的调用接口一致:invoke({"messages": [...]}),返回值里都有 messages。所以升级不需要改调用方,只改“构造 Agent”的那一个函数:
- 保持工厂函数不变。 把
create_agent(...)换成create_deep_agent(model=..., tools=..., system_prompt=...);middleware、response_format、checkpointer、store等常用参数在两个函数里同名; - 先在脚本化模型上跑一遍实验 1 的写法。 确认工具名没有和内置工具冲突,状态里多出的
files没有被你的调用方当作异常字段; - 加上 checkpointer,并规定
thread_id的来源。 否则实验 4 的“读不回来”会在线上第二轮对话里出现; - 设置
recursion_limit和审批。 给write_file、edit_file、delete配置interrupt_on; - 用真实模型重新量一次。 工具 schema 体积、系统提示词和 profile 的影响,都要在真实提供商模型上重新测,不要沿用文中的字符数。
回滚时只改工厂函数,把它切回 create_agent。唯一需要同步处理的是 files 里已经存下的内容。
失败与排障
- 模型说文件不存在。 先看是不是没有 checkpointer,或者两次调用的
thread_id不同(实验 4)。默认文件空间只属于当前线程; - 工具结果“消失”,变成一段带路径的占位消息。 这是实验 2 的卸载行为,不是故障。全文在
/large_tool_results/<tool_call_id>,让模型分段read_file,或者回头把工具改成只返回需要的字段; - 审批没有触发。
interrupt_on的键必须和工具名完全一致。在create_agent上,键名写错会让工具被静默放行,已在create_agent 与 LangGraph 的选型的失败实验里复现过;Deep Agents 用的是同一个中间件。上线前给每个高风险工具写一条“调用它必然产生中断”的测试; - 循环不收敛。 实验 6 说明默认上限很高。先设置
recursion_limit,再回头查提示词和工具返回是否让模型无法判断“已经完成”; - 工具面变大后,模型调用了不该调用的内置工具。 真实模型的行为不在验证范围。保守做法是对改动类工具审批,并在评测集里加入“不应该调用某工具”的用例。
相关阅读
- 先把一个最小 Agent 跑通:在本机运行第一个 Agent;
- 需要给 Agent 加护栏和中间件:Agent 中间件与护栏;
- 要上线并能从故障中恢复:LangGraph 检查点与故障恢复,以及上线前核对清单。
依据与复现
- Runtimes, frameworks, and harnesses (opens in a new tab):运行时、框架与 Harness 的分工,以及各自的适用场景;
- Deep Agents 概览 (opens in a new tab):内置能力与它构建在 LangChain 和 LangGraph 之上的关系;
- Deep Agents 上下文工程 (opens in a new tab):大结果卸载的默认阈值;
- Deep Agents 子 Agent (opens in a new tab):子 Agent 用于隔离上下文;
- Deep Agents 后端 (opens in a new tab):
StateBackend与其他后端; - Deep Agents 人工审批 (opens in a new tab):
interrupt_on; - 源码核对(固定版本):
deepagents==0.7.32的graph.py(返回create_agent的结果并设置recursion_limit)和middleware/filesystem.py(卸载阈值),langchain==1.4.7的agents/factory.py(recursion_limit为 9999),langgraph==1.2.17的_internal/_config.py(默认上限); - 复现:先执行
labs/setup-envs.sh v1da重建环境,再执行python3 labs/run.py --lab deep-agents-selection。