解决方案
Agent 与 LangGraph 选型

create_agent 还是 LangGraph?同一任务两种实现的选型决策表

LangChain v1 之后,“做一个 Agent”有两条路:用 create_agent 一行搭好模型循环,或者直接用 LangGraph 一个节点一个节点地画图。网上常见的说法是“简单用前者、复杂用后者”,但“复杂”到底指什么?本页写给正在做技术选型的开发者:用同一个任务把两种实现都跑一遍,再把结论整理成可以逐条对照的决策表。

适用版本与验证信息

项目内容
验证日期2026-10-25
Python3.12.12
包版本langchain==1.4.7、langchain-core==1.7.0、langgraph==1.2.17、langgraph-checkpoint==4.3.0
模型脚本化模型:按预设顺序回放固定的 AIMessage,不联网、不需要 API Key
官方来源LangChain 概览 (opens in a new tab)、Human-in-the-loop (opens in a new tab)、LangChain 设计理念 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/agent-vs-langgraph/,依赖锁定在 labs/requirements/v1.txt

脚本化模型能证明的是图结构和控制流的差异,不涉及任何真实模型的效果。官方还提供了 Deep Agents(“开箱即用”的更高层封装),本页没有验证它,不做评价。

先说结论

  1. 默认从 create_agent 开始。 只要流程本质上是“模型决定调用哪些工具、循环到完成”,审批、审核回路、重试、限流这些需求都可以用中间件表达,本页的实验 2、3 已经验证。
  2. 需要这几件事时,再下沉到 LangGraph: 并行分支并合并结果、固定的多阶段流程(每个阶段职责不同)、把非消息的业务数据作为一等状态、在每条边上手写确定性路由。
  3. 持久化和人工介入不是分水岭。 create_agent 同样接受 checkpointer,并且有现成的 HumanInTheLoopMiddleware。
  4. 两者不是对立关系。 官方在设计理念里说明,create_agent 构建在 LangGraph 之上。选型不是“用不用 LangGraph”,而是“要不要自己画图”。

任务设定

“客服退款助手”:用户询问订单状态,必要时发起退款。两个工具来自实验的共享模块:lookup_order 查询订单,refund 是有副作用的退款(调用会记录到 REFUNDS 列表)。所有实验都用脚本化模型,所以每次运行输出完全一致。

实验 1:同一个循环,两种写法

先看 create_agent 的实现:

agent_create.py
"""Implementation A: the support lookup as create_agent (one call)."""
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
 
from shared import ScriptedChatModel, lookup_order, summarize
 
 
def build():
    model = ScriptedChatModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "lookup_order", "args": {"order_id": "A1"}, "id": "c1"}]),
            AIMessage(content="Order A1 is shipped, total 99.0."),
        ]
    )
    return create_agent(model, tools=[lookup_order], system_prompt="You are a support assistant.")
 
 
def run():
    result = build().invoke({"messages": [("user", "Where is order A1?")]})
    return summarize(result["messages"])
 
 
if __name__ == "__main__":
    print("\n".join(run()))

再看手写的 LangGraph 图——模型节点、工具节点和一条条件边:

agent_graph.py
"""Implementation B: the same loop written by hand as a LangGraph StateGraph."""
from langchain_core.messages import AIMessage, SystemMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
 
from shared import ScriptedChatModel, lookup_order, summarize
 
 
def build():
    model = ScriptedChatModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "lookup_order", "args": {"order_id": "A1"}, "id": "c1"}]),
            AIMessage(content="Order A1 is shipped, total 99.0."),
        ]
    )
    llm = model.bind_tools([lookup_order])
 
    def call_model(state: MessagesState):
        prompt = [SystemMessage(content="You are a support assistant.")] + state["messages"]
        return {"messages": [llm.invoke(prompt)]}
 
    builder = StateGraph(MessagesState)
    builder.add_node("model", call_model)
    builder.add_node("tools", ToolNode([lookup_order]))
    builder.add_edge(START, "model")
    builder.add_conditional_edges("model", tools_condition, {"tools": "tools", END: END})
    builder.add_edge("tools", "model")
    return builder.compile()
 
 
def run():
    result = build().invoke({"messages": [("user", "Where is order A1?")]})
    return summarize(result["messages"])
 
 
if __name__ == "__main__":
    print("\n".join(run()))

compare_traces.py 同时运行两者,比较完整的消息轨迹,并统计代码规模:

compare_traces.py
"""Run both implementations and compare their message traces and code size."""
from pathlib import Path
 
import agent_create
import agent_graph
 
 
def code_lines(name: str) -> int:
    lines = Path(__file__).with_name(name).read_text(encoding="utf-8").splitlines()
    return sum(1 for line in lines if line.strip() and not line.strip().startswith("#"))
 
 
trace_a = agent_create.run()
trace_b = agent_graph.run()
print("trace A (create_agent):")
print("\n".join(f"  {line}" for line in trace_a))
print("identical to trace B (StateGraph):", trace_a == trace_b)
print(f"non-blank lines: agent_create.py={code_lines('agent_create.py')}, agent_graph.py={code_lines('agent_graph.py')}")
trace A (create_agent):
  human: Where is order A1?
  ai: tool_calls=[('lookup_order', {'order_id': 'A1'})]
  tool: A1: shipped, total 99.0
  ai: Order A1 is shipped, total 99.0.
identical to trace B (StateGraph): True
non-blank lines: agent_create.py=17, agent_graph.py=28

轨迹逐条相同,两种写法对外的调用方式也一致:invoke({"messages": [...]}) 进,messages 出。差别在代码量:create_agent 17 行,手写图 28 行,而且手写版需要自己决定条件边、系统提示的拼接方式。结论:对单个模型循环,没有理由手写图。

实验 2:人工审批——create_agent 一个中间件就够

退款是高风险动作,需要在执行前暂停,等人批准。create_agent 通过 HumanInTheLoopMiddleware 和 checkpointer 完成:

hitl_agent.py
"""create_agent + HumanInTheLoopMiddleware: pause before a side-effecting tool, then approve or reject."""
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
 
from shared import REFUNDS, ScriptedChatModel, lookup_order, refund
 
 
def build():
    model = ScriptedChatModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "refund", "args": {"order_id": "A1", "amount": 99.0}, "id": "c1"}]),
            AIMessage(content="Handled."),
        ]
    )
    return create_agent(
        model,
        tools=[lookup_order, refund],
        middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund": True, "lookup_order": False})],
        checkpointer=InMemorySaver(),  # interrupts need a checkpointer to persist the paused state
    )
 
 
def scenario(name: str, decision: dict) -> None:
    REFUNDS.clear()
    agent = build()
    config = {"configurable": {"thread_id": name}}
    paused = agent.invoke({"messages": [("user", "Refund order A1")]}, config)
    request = paused["__interrupt__"][0].value["action_requests"][0]
    print(f"[{name}] paused before: {request['name']}({request['args']}); refunds so far: {REFUNDS}")
    print(f"[{name}] saved next step: {agent.get_state(config).next}")
    done = agent.invoke(Command(resume={"decisions": [decision]}), config)
    print(f"[{name}] after resume: refunds={REFUNDS}; last message: {done['messages'][-1].content!r}")
 
 
scenario("approve", {"type": "approve"})
scenario("reject", {"type": "reject", "message": "Refunds over 50 need a manager."})
[approve] paused before: refund({'order_id': 'A1', 'amount': 99.0}); refunds so far: []
[approve] saved next step: ('HumanInTheLoopMiddleware.after_model',)
[approve] after resume: refunds=[('A1', 99.0)]; last message: 'Handled.'
[reject] paused before: refund({'order_id': 'A1', 'amount': 99.0}); refunds so far: []
[reject] saved next step: ('HumanInTheLoopMiddleware.after_model',)
[reject] after resume: refunds=[]; last message: 'Handled.'

关键观察:

  • 暂停时 REFUNDS 为空——工具没有被执行;
  • 暂停点被保存下来(next 指向中间件节点),因此可以隔一段时间再恢复;
  • 批准后退款发生;拒绝后 REFUNDS 仍为空,模型只收到拒绝说明。

中间件需要 checkpointer 才能暂停恢复,生产环境应换成持久化的实现;本实验用内存版只是为了可复现。

实验 3:回答后的确定性审核,在 create_agent 里同样可行

审核规则是代码而不是模型:回答里必须包含订单号,不满足就让模型重写,最多重试一次。用 after_model 钩子加 jump_to 实现:

review_loop_middleware.py
"""The same review loop inside create_agent: an after_model hook that jumps back to the model."""
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import after_model
from langchain_core.messages import AIMessage, HumanMessage
 
from shared import ScriptedChatModel, lookup_order
 
MAX_RETRIES = 1
 
 
@after_model(can_jump_to=["model"])
def require_order_id(state: AgentState, runtime):
    """Deterministic review: the final answer must mention the order id, retry at most once."""
    last = state["messages"][-1]
    humans = [m for m in state["messages"] if isinstance(m, HumanMessage) and m.content.startswith("REVIEW:")]
    if last.tool_calls or "A1" in last.content or len(humans) >= MAX_RETRIES:
        return None
    return {"messages": [HumanMessage(content="REVIEW: mention the order id.")], "jump_to": "model"}
 
 
def run(drafts):
    model = ScriptedChatModel(script=[AIMessage(content=d) for d in drafts])
    agent = create_agent(model, tools=[lookup_order], middleware=[require_order_id])
    result = agent.invoke({"messages": [("user", "Can I get a refund?")]})
    return model.cursor, result["messages"][-1].content
 
 
for label, drafts in [("first draft ok", ["Order A1 is shipped."]), ("retry once", ["Sure.", "Order A1 is shipped."]), ("retries exhausted", ["No id.", "Still no id."])]:
    calls, answer = run(drafts)
    print(f"[{label}] model calls: {calls}; answer: {answer!r}")
[first draft ok] model calls: 1; answer: 'Order A1 is shipped.'
[retry once] model calls: 2; answer: 'Order A1 is shipped.'
[retries exhausted] model calls: 2; answer: 'Still no id.'

三种情形下模型调用次数分别是 1、2、2,符合“最多重试一次”的规则。这说明**“有审核回路”本身不是必须手写图的理由**。

实验 4:并行取数、合并、再审核——图更自然的形状

现在把流程改成固定的多阶段:先并行取订单信息和退款政策,合并之后再让模型起草,最后由代码审核、必要时回到起草。这个形状在图里是显式的:

pipeline_graph.py
"""A staged workflow that is natural as a graph: parallel fetches, one model step, a code-based review loop.
 
fetch_order and fetch_policy run in the same step and merge into `facts` through a reducer.
The review node is plain code (no model) and routes back to `draft` at most once.
"""
import operator
from typing import Annotated, TypedDict
 
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, StateGraph
 
from shared import ORDERS, ScriptedChatModel
 
MAX_RETRIES = 1
 
 
class State(TypedDict):
    question: str
    facts: Annotated[list, operator.add]  # reducer: parallel branches append instead of overwrite
    trace: Annotated[list, operator.add]
    answer: str
    attempts: int
    approved: bool
 
 
def build(drafts):
    model = ScriptedChatModel(script=[AIMessage(content=d) for d in drafts])
 
    def fetch_order(state: State):
        order = ORDERS["A1"]
        return {"facts": [f"order A1 is {order['status']}"], "trace": ["fetch_order"]}
 
    def fetch_policy(state: State):
        return {"facts": ["refund window is 30 days"], "trace": ["fetch_policy"]}
 
    def draft(state: State):
        reply = model.invoke(state["question"] + " | " + "; ".join(sorted(state["facts"])))
        return {"answer": reply.content, "attempts": state.get("attempts", 0) + 1, "trace": ["draft"]}
 
    def review(state: State):
        # Deterministic rule: the answer must mention the order id.
        return {"approved": "A1" in state["answer"], "trace": ["review"]}
 
    def route(state: State):
        if state["approved"] or state["attempts"] > MAX_RETRIES:
            return END
        return "draft"
 
    builder = StateGraph(State)
    for name, fn in [("fetch_order", fetch_order), ("fetch_policy", fetch_policy), ("draft", draft), ("review", review)]:
        builder.add_node(name, fn)
    builder.add_edge(START, "fetch_order")
    builder.add_edge(START, "fetch_policy")
    builder.add_edge(["fetch_order", "fetch_policy"], "draft")  # join: wait for both branches
    builder.add_edge("draft", "review")
    builder.add_conditional_edges("review", route, {END: END, "draft": "draft"})
    return builder.compile(), model
 
 
def run(label, drafts):
    graph, model = build(drafts)
    out = graph.invoke({"question": "Can I get a refund?", "facts": [], "trace": [], "attempts": 0})
    print(f"[{label}] trace: {' -> '.join(out['trace'])}")
    print(f"[{label}] model calls: {model.cursor}; approved: {out['approved']}; answer: {out['answer']!r}")
 
 
def show_supersteps(drafts):
    graph, _ = build(drafts)
    steps: dict = {}
    for event in graph.stream({"question": "Can I get a refund?", "facts": [], "trace": [], "attempts": 0}, stream_mode="debug"):
        if event["type"] == "task":
            steps.setdefault(event["step"], []).append(event["payload"]["name"])
    print("supersteps:", {step: sorted(names) for step, names in steps.items()})
 
 
if __name__ == "__main__":
    run("first draft ok", ["Order A1 is shipped; refunds are allowed within 30 days."])
    run("retry once", ["Sure, refunds are allowed.", "Order A1 is shipped; refunds are allowed within 30 days."])
    run("retries exhausted", ["No id here.", "Still no id."])
    show_supersteps(["Order A1 is shipped; refunds are allowed within 30 days."])
[first draft ok] trace: fetch_order -> fetch_policy -> draft -> review
[first draft ok] model calls: 1; approved: True; answer: 'Order A1 is shipped; refunds are allowed within 30 days.'
[retry once] trace: fetch_order -> fetch_policy -> draft -> review -> draft -> review
[retry once] model calls: 2; approved: True; answer: 'Order A1 is shipped; refunds are allowed within 30 days.'
[retries exhausted] trace: fetch_order -> fetch_policy -> draft -> review -> draft -> review
[retries exhausted] model calls: 2; approved: False; answer: 'Still no id.'
supersteps: {1: ['fetch_order', 'fetch_policy'], 2: ['draft'], 3: ['review']}

最后一行的 supersteps 是用调试流记录下来的:fetch_order 和 fetch_policy 在同一个超步里执行,draft、review 依次在后面的超步。三次运行的输出说明:

  • 两个并行分支的结果通过 facts 上的 reducer(operator.add)合并,没有丢失;
  • 审核节点是纯代码,不消耗模型调用;重试次数用 attempts 计数,耗尽后图正常结束,approved 为 False,由调用方决定如何兜底。

这类“固定阶段 + 并行 + 确定性路由”的流程,用 create_agent 也能做出近似行为,但流程本身不再显式可见,要靠中间件和提示词间接约束。

选型决策表

需求create_agent手写 LangGraph本页证据
单个“模型 + 工具”循环首选;17 行即可可行,代码量约 1.6 倍实验 1
敏感工具执行前的人工审批、暂停与恢复HumanInTheLoopMiddleware + checkpointer需要自己用中断实现(本页未验证)实验 2
回答后的确定性审核与有限重试after_model + jump_to条件边实验 3、4
并行取数、合并结果单个模型循环没有内置的并行分支合并;需要时在工具内部自行并发(本页未验证)同一超步内并行,用 reducer 合并实验 4
固定的多阶段流程,每个阶段职责不同可以用工具或子 Agent 拼装,但流程不显式每个阶段一个节点,边就是流程实验 4
业务数据作为状态(而不只是消息)自定义状态只支持 TypedDict,可通过 state_schema 或中间件扩展(官方迁移指南)状态是一等公民,可按键配置 reducer实验 4;迁移指南 (opens in a new tab)
可读性与维护成本低高实验 1

怎么选:三步判断

  1. 流程能否写成“模型循环 + 若干中间件”? 能,就用 create_agent,把审批、审核、限流放进中间件;
  2. 是否需要并行分支、固定阶段或自定义业务状态? 需要,就直接用 LangGraph;
  3. 拿不准? 先用 create_agent 上线。两种实现的调用接口一致(实验 1 的 run() 对两者完全相同),所以以后换实现不需要改调用方。

失败与回滚

  • 审批配置写错,要么恢复不了、要么直接放行。 下面的实验复现了两种最常见的错误:
hitl_misconfig.py
"""Two common HITL misconfigurations: no checkpointer, and an interrupt_on key that matches no tool."""
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
 
from shared import REFUNDS, ScriptedChatModel, refund
 
 
def model():
    return ScriptedChatModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "refund", "args": {"order_id": "A1", "amount": 99.0}, "id": "c1"}]),
            AIMessage(content="Handled."),
        ]
    )
 
 
print("1) interrupt_on={'refund': True} but no checkpointer")
REFUNDS.clear()
agent = create_agent(model(), tools=[refund], middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund": True})])
config = {"configurable": {"thread_id": "t"}}
paused = agent.invoke({"messages": [("user", "Refund A1")]}, config)
print(f"   interrupted: {'__interrupt__' in paused}; refunds executed: {REFUNDS}")
try:
    agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)
except Exception as exc:
    print(f"   resume fails: {type(exc).__name__}: {exc}")
 
print("2) key 'refunds' (typo) does not match the tool name 'refund'")
REFUNDS.clear()
agent = create_agent(
    model(),
    tools=[refund],
    middleware=[HumanInTheLoopMiddleware(interrupt_on={"refunds": True})],
    checkpointer=InMemorySaver(),
)
result = agent.invoke({"messages": [("user", "Refund A1")]}, {"configurable": {"thread_id": "t"}})
print(f"   interrupted: {'__interrupt__' in result}")
print(f"   refunds executed: {REFUNDS}")
1) interrupt_on={'refund': True} but no checkpointer
   interrupted: True; refunds executed: []
   resume fails: RuntimeError: Cannot use Command(resume=...) without checkpointer
2) key 'refunds' (typo) does not match the tool name 'refund'
   interrupted: False
   refunds executed: [('A1', 99.0)]

第 1 种:只配了中间件、没配 checkpointer。第一次调用正常返回并带着中断,看上去一切正常,等到真的恢复时才抛 RuntimeError,审批流程在这里断掉;第 2 种:interrupt_on 的键和工具名对不上(把 refund 写成了 refunds),没有任何中断,退款被直接执行,这是更危险的静默失败。上线前务必写一条测试:对每个高风险工具,断言调用它时一定会产生中断;

  • 并行节点写同一个状态键时报错:没有给该键配置 reducer,LangGraph 会拒绝同一步里的多次写入,报错如下:
no_reducer_error.py
"""Two parallel nodes write the same state key. Without a reducer LangGraph refuses the update."""
from typing import TypedDict
 
from langgraph.graph import END, START, StateGraph
 
 
class State(TypedDict):
    facts: list  # no reducer on purpose
 
 
def fetch_order(state: State):
    return {"facts": ["order A1 is shipped"]}
 
 
def fetch_policy(state: State):
    return {"facts": ["refund window is 30 days"]}
 
 
builder = StateGraph(State)
builder.add_node("fetch_order", fetch_order)
builder.add_node("fetch_policy", fetch_policy)
builder.add_edge(START, "fetch_order")
builder.add_edge(START, "fetch_policy")
builder.add_edge("fetch_order", END)
builder.add_edge("fetch_policy", END)
 
try:
    builder.compile().invoke({"facts": []})
except Exception as exc:
    print(type(exc).__name__)
    print(str(exc).splitlines()[0])
InvalidUpdateError
At key 'facts': Can receive only one value per step. Use an Annotated key to handle multiple values.

处理方式:把该键声明为 Annotated[list, operator.add] 之类带 reducer 的类型(实验 4 的 facts);

  • 循环不收敛、抛出 GraphRecursionError:见 LangChain v1 迁移 Runbook 中关于 recursion_limit 的实验;
  • 回滚:把“构造 Agent”封装在一个返回可 invoke 对象的工厂函数里。选错实现时,只改工厂函数,调用方不动。

相关阅读

来源

以上来源均于 2026-10-25 核对。本页所有输出来自站点仓库 labs/agent-vs-langgraph/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-25;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab agent-vs-langgraph;
  • 发现错误请按联系我们反馈。