Deep Agents、create_agent、LangGraph 怎么选:同一任务的实测对比

官方把 LangChain 的三类产品分成运行时(LangGraph)、框架(LangChain 的 create_agent)和 Harness(Deep Agents)。这个说法很清楚,但落到选型上还是会卡住:Deep Agents 到底多给了什么?多给的东西要付出什么代价?什么时候该从 create_agent 换过去,什么时候反而该退回 LangGraph?

如果你正在为 Agent 项目选起点,下面用同一组工具、同一个脚本化模型,把三种实现各跑一遍,只看框架行为——模型看到了什么、状态放在哪、在哪里停下来。结论汇总成一张决策表。已经读过 create_agent 与 LangGraph 的选型的话,可以把这篇当作它的第三个选项补充。

先说结论

  1. 三者是三层,不是三选一。 create_deep_agent 的返回值是 create_agent 编译出的 LangGraph 图,只是在外面叠了一组中间件和内置工具(实验 1)。选型的问题是“你要在哪一层开始,以及愿不愿意为下一层的默认行为买单”。
  2. 起点怎么选。 一个“模型 + 工具”循环,用 create_agent;固定阶段、并行分支、确定性路由,用 LangGraph;工具结果动辄几万字符、任务里有互相独立的资料调研子任务,Deep Agents 才开始回本。
  3. Deep Agents 的主要价值是上下文管理,而不是“更聪明”。 超大工具结果自动落到文件(实验 2),子 Agent 把中间过程关在自己的上下文里(实验 3)。这两件事用 create_agent 也能做,但要自己写。
  4. 代价同样具体。 模型每次都要多带 8 个内置工具的 schema(本版本约 10 KB 字符,实验 1);文件默认存在图状态里,没有 checkpointer 就读不回来(实验 4);审批、循环上限仍然要你自己配(实验 5、6)。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.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:模型到底看到了什么

三种实现放进同一个循环,比较编译对象、图节点、递给模型的工具和最终状态:

surface.py
"""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 字符,看模型在下一次调用里收到的工具消息有多大:

big_result.py
"""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/c1
  • create_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。

subagent.py
"""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 各跑一遍:

persistence.py
"""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 配置。分别批准和拒绝:

approval.py
"""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:模型停不下来时,在哪里被拦住

用一个永远只调用工具的脚本化模型,看各实现的循环上限:

runaway.py
"""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”的那一个函数:

  1. 保持工厂函数不变。 把 create_agent(...) 换成 create_deep_agent(model=..., tools=..., system_prompt=...);middleware、response_format、checkpointer、store 等常用参数在两个函数里同名;
  2. 先在脚本化模型上跑一遍实验 1 的写法。 确认工具名没有和内置工具冲突,状态里多出的 files 没有被你的调用方当作异常字段;
  3. 加上 checkpointer,并规定 thread_id 的来源。 否则实验 4 的“读不回来”会在线上第二轮对话里出现;
  4. 设置 recursion_limit 和审批。 给 write_file、edit_file、delete 配置 interrupt_on;
  5. 用真实模型重新量一次。 工具 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,再回头查提示词和工具返回是否让模型无法判断“已经完成”;
  • 工具面变大后,模型调用了不该调用的内置工具。 真实模型的行为不在验证范围。保守做法是对改动类工具审批,并在评测集里加入“不应该调用某工具”的用例。

相关阅读

依据与复现

验证记录

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26(官方来源同日核对);
  • 输出范围:全部输出来自站点仓库 labs/deep-agents-selection/ 在“适用版本与验证信息”所列版本下的实际运行;
  • 未覆盖项见页首“验证边界”;
  • 发现错误请按联系我们反馈。