把 LangChain 用得越熟,我心里越慌。写个 Agent Demo 半天就能跑通,prompt 里塞几句工具描述,再挂两层 memory,看起来什么都会。可真要让它稳定处理线上业务,比如订单查询、退款申请这种带状态、带分支、还带风险动作的场景,LangChain 默认的链式封装就开始露怯:状态靠聊天记录隐式维护,路由靠模型临场发挥,节点之间没有强制边界,更别提交互中断和恢复。折腾了几轮之后,我彻底转向 LangGraph,把 Agent 从一条 prompt 链改造成一个有状态、可路由、可暂停、可恢复的图计算过程。这篇文章就把我实际走下来的路线、代码和一些坑完整写一遍,给同样想构建可控 Agent 的朋友做个参考。
1. 为什么要从 LangChain 切到 LangGraph:一次工程化的自我审视
我先说结论:LangChain 本身并没有错,它是非常优秀的模型调用和工具组装层。真正的问题在于,很多人(包括我)拿它来承担了超出它能力范围的事——把 Agent 的完整生命周期都压在一套链式抽象上。
1.1 原先那种“链式 Agent”到底哪里不可控
我第一次写 Agent,思路非常朴素:用户输入进来,先让 LLM 做意图判断,判断完再走对应工具链,工具结果返回后拼接 prompt 让 LLM 生成回答。这套流程公开代码里一大把,跑起来也确实能用。但用一段时间后你会发现,所有“看似智能”的决策,实际都依赖两样东西:大模型的理解能力和 prompt 的严格程度。而这两样恰恰都是软性的,根本不具备工程上的强约束。
举个例子,我让模型判断用户意图是“查询订单”还是“申请退款”,模型正常时候会输出query_order,但临场抽风时可能输出用户想要退款这种自由文本,也可能输出refund、order_refund这些同义但非约定的值。这时候我的代码里往往还要再写一堆 if-else 做字符串匹配兜底,等于把模型输出的不确定性传导到业务代码里,越往后越混乱。
更难受的是流程控制。链式框架天然适合“顺序执行”,可真实 Agent 场景长这样:用户说一句“查一下订单,顺便问问如果我不满意能不能退”,意图可能有两个,状态要共享,工具调用之间还要有条件跳转。在链式封装下,这种逻辑只能写成大段 if-else 包裹的 branch 逻辑,而且这些分支的判定者仍然是 LLM,不是代码。等于把状态机最关键的路径决策权,交给了每次输出都可能不一样的那个概率模型。
我还特别想吐槽状态管理。LangChain 提供的 memory 机制,本质是往上下文里塞历史消息,它不区分“用户说了什么”和“Agent 当前处于什么阶段”。比如一个订单 Agent 已经确认了用户要退款的意图,接下来还需要收集订单号、确认操作、执行退款,每一步都有中间状态。在链式模型里,这些状态散落在各个局部变量和 memory 回调里,一旦某一步出问题,你根本不知道当时的完整上下文长什么样。
1.2 我对“可控 Agent”的重新定义:五条硬标准
在动手迁移之前,我先逼着自己把“可控”两个字拆成了五个可以落地的标准,后续所有方案都围绕这五条做:
- 状态显式化:Agent 每一步运行需要的所有信息,都要有明确的数据结构承载,能看到、能打印、能存档。
- 路由代码化:从“当前节点”走到“下一个节点”的决策,必须由代码逻辑控制,模型只负责它擅长的理解与生成。
- 执行可审计:每个节点的输入、输出、耗时、调用参数,都要能事后回溯。
- 流程可中断:遇到高风险动作或外部确认时,Agent 可以在指定节点暂停,等人或外部系统处理后再恢复。
- 错误可恢复:节点抛出异常、解析失败、外部 API 超时后,流程能够有定义好的回退路径,而不是整条链直接挂掉。
拿这五条标准回去对照 LangChain 默认的链式写法,每一条都很难完整满足。而 LangGraph 的图模型,恰恰把这五个要点从框架层面变成了基础能力。所以对我来说,从 LangChain 到 LangGraph 不是赶时髦,是按照工程要求做的一次结构性调整。
1.3 LangGraph 是什么:不是替代品,而是下一层抽象
简单说,LangGraph 把 Agent 定义成一张有向图:节点(Node)表示一段处理逻辑(可以调用 LLM、调用工具、执行代码),边(Edge)表示节点之间的流转关系,支持条件边(Conditional Edge)根据当前状态决定跳转方向,全局共享一个状态对象(State)在各节点间传递。
这听起来只是换个写法,但实际效果是革命性的。你可以把 Agent 想象成一个工厂流水线,LangChain 的做法是写一张长长的工序单交给工人(LLM)自己照着干,工人心情好就干得标准,心情差就跳步、错步;LangGraph 的做法是你在流水线上装了硬质轨道和传感器,每一步走完都必须上报状态,轨道上的岔路器由你的代码控制,而不是工人自己决定往哪转。
带上这个心智模型,我们再去看它的核心概念和实操细节,很多选择就顺理成章了。
2. LangGraph 的“可控”机制详解:状态、节点、边与图的全链路设计
LangGraph 的核心概念只有几个,但每一个都对应一类工程问题的解法。我按实际设计顺序逐个讲清楚,包括代码长什么样和为什么要这么写。
2.1 State:把所有“上下文”变成看得见的字段
在 LangGraph 里,State 是贯穿整个图运行过程的数据对象。相比 LangChain 的 memory,它最大的区别是:State 里的字段必须显式声明,节点只能修改自己声明过的字段,图运行期间任何时刻你都可以把这个对象完整地打印出来。
我用 TypedDict 来定义 Agent 的状态:
from typing import TypedDict, Annotated, Optional from langgraph.graph.message import add_messages class OrderAgentState(TypedDict): # 对话消息,利用 add_messages 做增量合并,而不是覆盖 messages: Annotated[list, add_messages] # 模型解析出来的用户意图,限制为受控枚举 intent: Optional[str] # 用户提供的订单号 ticket_id: Optional[str] # 查询订单后拿到的业务状态 order_status: Optional[str] # 当前重试次数,防止模型死循环 retry_count: int # 退款审批结果 refund_approved: Optional[bool]这里有几个容易踩坑的地方,我单独多说几句。
第一,Annotated[list, add_messages]里的add_messages是 reducer。reducer 的作用是定义“当节点返回新值时,旧值怎么合并”。默认情况下,节点返回的新值会直接覆盖旧值,这会导致多轮对话上下文被打丢;加上add_messages后,新消息会追加到已有消息列表尾部,符合聊天场景的直觉。
第二,intent字段我故意设计成Optional[str],同时约定只有有限几个取值:query_order、apply_refund、transfer_human、unknown。这个约定很重要,后面条件路由全靠它驱动。如果让意图字段变成自由文本,路由函数就被迫去做模糊匹配,等于又回到了 LangChain 的老路。
第三,retry_count这种字段看着不起眼,却是防止死循环的关键。模型调用工具和 LLM 都有概率失败,一个节点失败了要允许重试,但必须规定重试上限,否则图会无限转圈。这个思想跟写普通后端重试机制一模一样,只是很多人迁移到 Agent 后反而忘了。
State 定义好了以后,节点函数的签名就变成:接收整个 State,返回一个 dict,里面只包含要修改的字段。框架会自动把返回的 dict 合并到 State 中。这层设计让每个节点的输入输出都变得非常干净,天然可测试。
2.2 节点:每个节点只做一件事,并且明确输入输出契约
节点的设计原则,我建议直接照搬函数设计的单一职责原则。一个节点负责“把用户输入解析成结构化意图”,另一个节点负责“调用订单查询 API”,再一个节点负责“执行退款动作”。不要想着写一个万能 Agent 节点,什么都在里面做,那样只是把原来的大 prompt 换了个地方放而已。
我比较常用的节点写法:
from langchain_core.messages import HumanMessage def intent_parse_node(state: OrderAgentState) -> dict: """从用户输入中解析意图和订单号,属于第一阶段。""" # 实际可以用 with_structured_output 让 LLM 直接输出 JSON resp = llm_with_schema.invoke( [ {"role": "system", "content": INTENT_PARSE_PROMPT}, *state["messages"], ] ) return { "intent": resp.intent, "ticket_id": resp.ticket_id, "messages": [HumanMessage(content=resp.raw_text)], }注意这里我没有真把一万字 prompt 写进去,核心在于节点函数的返回值结构要与 State 字段严格对应,框架靠这个契约把数据传递到下一个节点。你不需要在节点内部关心上一个节点是谁、下一个节点是谁,只需要关心 State 的输入输出。这种“无状态函数 + 全局状态对象”的组合,让每个节点都可以单独拿出来单测,这是我现阶段最喜欢的调试方式。
做节点时有一个细节必须强调:所有需要跨步骤传递的业务值(比如ticket_id、order_status),一定要放进 State 的显式字段,而不是塞在messages里让模型从历史对话中猜测。显式字段的意义在于,它是强类型的、可直接读取的;messages里的信息是自然语言层面的,读取时又得靠模型提取一次,既不靠谱又浪费 token。
2.3 条件边:把路径决策权从模型手里拿回来
LangGraph 里边的类型很多,最实用的是条件边。条件边的作用是:在一个节点执行结束后,调用一个路由函数,根据当前 State 的内容决定下一步去哪。
def route_after_intent(state: OrderAgentState) -> str: """根据解析出的意图,决定走订单查询、退款审批还是兜底。""" intent = state.get("intent") if intent == "query_order": return "query_order_node" if intent == "apply_refund": return "refund_review_node" return "fallback_node"这段代码就是“可控”的最佳体现。以前在 LangChain 里,意图判断之后走哪条路通常由模型自由发挥,或者靠 prompt 里写“请根据以上对话选择合适工具”来约束;现在路由逻辑完全在自己的代码里,你可以单测它,也可以在产品评审时直接拿给业务方看——他们不需要理解大模型,也能看懂“检测到退款意图就会进入审批节点”这个事实。
条件边的返回值是节点的名称,因此 LangGraph 允许你实现循环。比如一个“意图解析失败则可以回到用户澄清节点,但最多澄清两次”的逻辑:
def route_after_fallback(state: OrderAgentState) -> str: if state["retry_count"] >= 2: return "end" return "clarify_node"这个循环能力是 LangChain 链式结构做不到的。链式结构本质是有向无环的,一个输入走完一条链就结束;而真实 Agent 经常需要“追问-回答-再追问-再回答”的循环,LangGraph 在这里的自然程度比任何链式封装都好。
2.4 Checkpointer:不只用来存档,更是人机协同的基石
如果说 State 解决了“记忆”,Checkpointer 解决的则是“跨会话恢复”。默认情况下,图跑完就结束了,状态对象随之丢弃。但如果你给图配置了 checkpointer,LangGraph 会在每一步执行后把 State 和位置快照保存下来。下次用同一个thread_id再运行时,它能从上次中断的位置继续。
from langgraph.checkpoint.memory import InMemorySaver checkpointer = InMemorySaver() graph = workflow.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "order-agent-001"}}这里我简单解释一下 checkpoint 里的thread_id。它相当于一个会话维度的标识,同一个thread_id的所有执行共享一份状态历史。很多朋友刚接触时把它当成“用户 ID”,其实是有点偏差。一个用户可以同时开多个会话,建议拿业务里的会话 ID(如conversation_id)来做thread_id,而不是拿user_id。
有了 checkpointer,你就能做到:Agent 走到退款确认节点时暂停,等用户或客服点完“批准”按钮,再通过同一个thread_id继续图。这个模式在 LangGraph 文档里叫 human-in-the-loop,是我认为最重要的一个 Agent 工程能力。没有这个能力,退款这种高风险动作基本只敢让 Agent 停在“生成退款话术”这一步,再后面的动作还是得靠人来手动执行。
3. 从链式封装到图状态机:一个订单助手 Agent 的完整改造实录
理论讲再多,不如完整走一遍改造过程。下面我用一个叫“模拟项目 X”的订单助手 Agent Demo 做例子,先概括原来的 LangChain 实现存在什么问题,再贴出 LangGraph 版本的完整核心代码,最后对比两个版本在几个典型场景下的行为差异。
3.1 改造前的问题清单
这个订单助手 Agent 最早是用 LangChain 的LLMChain加ConversationBufferMemory写的,功能包括:识别意图、查订单状态、回答退款政策问题、触发退款工单。表面看着挺完整,但生产化测试时暴露出一堆问题:
- 用户说“我要退单号 10086 的东西”,模型能识别出退款意图,但把“10086”识别成了“订单号”还是“用户 ID”全看模型心情,参数传递不稳定。
- 一旦模型给出的意图值不在预设范围内,代码就走到了默认分支,但默认分支只会输出一句“我不明白你在说什么”,完全不会追问用户。
- 执行退款请求后,Agent 不能向用户确认“是否确认退款”,也没有任何确认机制,相当于后台直接发起了高风险操作。
- 对话超过几轮后,memory 会变得很长,模型开始丢失早期提取的订单号,需要靠用户重新说一次。
- 整个流程无法暂停,也无法在第二天恢复继续处理同一个退款单。
我把这些问题直接写成了验收用例,作为 LangGraph 改造的通过标准。
3.2 第一步:把流程拆成一张图
改造的第一步不是写代码,而是画流程。我把整个 Agent 的行为画成了下面这样(这里文字描述,不用画图工具):
用户消息进来 → 节点1 意图解析 → 条件边判断意图 → 如果是“查询订单”,进入节点2 查询订单API → 生成回复 → 结束;如果是“申请退款”,进入节点3 退款审批(暂停点) → 人工确认通过则进入节点4 执行退款 → 生成回复 → 结束;如果确认拒绝则进入节点5 拒绝话术 → 结束;如果意图未知,进入节点6 追问澄清 → 回到意图解析,同时重试计数加一,超过2次直接转人工客服。
这个结构的好处是,目标用户或者产品经理看一眼就能理解 Agent 的行为,不需要读任何代码。实际上我们团队后来做安全评审,直接拿这张图讲的,比拿旧版 prompt 讲效率高得多。
接着把它翻译成 LangGraph 代码:
from typing import Literal from langgraph.graph import StateGraph, START, END from langgraph.types import Command, interrupt # 1. 意图解析节点 def intent_parse_node(state: OrderAgentState) -> dict: # ... 调用结构化输出模型,省略 prompt 细节 pass # 2. 查询订单节点 def query_order_node(state: OrderAgentState) -> dict: # ... 调用订单 API,返回 order_status pass # 3. 退款审批节点(核心:interrupt 暂停) def refund_review_node(state: OrderAgentState) -> Command: decision = interrupt( { "ticket_id": state.get("ticket_id"), "amount": state.get("refund_amount"), "message": "用户申请退款,请人工确认", } ) if decision and decision.get("approved"): return Command(goto="do_refund_node") return Command(goto="reject_refund_node") # 4. 执行退款节点 def do_refund_node(state: OrderAgentState) -> dict: # ... 调用退款 API,携带幂等键 pass # 5. 拒绝退款节点 def reject_refund_node(state: OrderAgentState) -> dict: # ... 生成拒绝话术 pass # 6. 追问澄清节点 def clarify_node(state: OrderAgentState) -> dict: # ... 生成澄清问题 return {"retry_count": state.get("retry_count", 0) + 1} # 7. 兜底节点 def fallback_node(state: OrderAgentState) -> dict: # ... 转人工或生成兜底消息 pass workflow = StateGraph(OrderAgentState) # 添加所有节点 workflow.add_node("intent_parse", intent_parse_node) workflow.add_node("query_order", query_order_node) workflow.add_node("refund_review", refund_review_node) workflow.add_node("do_refund", do_refund_node) workflow.add_node("reject_refund", reject_refund_node) workflow.add_node("clarify", clarify_node) workflow.add_node("fallback", fallback_node) # 入口 workflow.add_edge(START, "intent_parse") # 根据意图做条件跳转 workflow.add_conditional_edges( "intent_parse", route_after_intent, { "query_order": "query_order", "apply_refund": "refund_review", "unknown": "clarify", "transfer_human": "fallback", }, ) # 其余普通边 workflow.add_edge("query_order", END) workflow.add_edge("clarify", "intent_parse") workflow.add_edge("do_refund", END) workflow.add_edge("reject_refund", END) workflow.add_edge("fallback", END) # 编译 graph = workflow.compile(checkpointer=checkpointer)代码里的interrupt值得单独解释。它是 LangGraph 提供的一个特殊函数,作用类似于生成器里的yield:图执行到这里时,会先把传入的数据(这里是退款审批单)抛出,然后暂停整个执行过程。外部程序收到这个中断后,可以把审批单展示给人工客服,等客服点击“通过”或“拒绝”,再调用Command(resume=...)把决定送回来,图继续执行。
这个机制最稳定的用法是配合 checkpointer:只要thread_id没变,图可以在任意时刻暂停,甚至服务重启后也能通过持久化 checkpoint 恢复。我在实际项目里就是把这个流程封装成一个异步接口:Agent 需要人审时返回pending_approval状态,前端渲染审批卡片,用户在卡片上操作后调接口恢复执行。
3.3 第二步:控制模型输出的“越界”行为
图把流程框架搭好了,接下来要把模型的行为约束住。我在节点里给 LLM 使用了with_structured_output,让它直接输出符合 schema 的 JSON,约束意图字段只能取枚举值:
from typing import Optional from pydantic import BaseModel, Field from langchain_core.utils.function_calling import convert_to_openai_tool class IntentResult(BaseModel): intent: Literal["query_order", "apply_refund", "unknown", "transfer_human"] = Field( ..., description="用户意图,必须从枚举中选择" ) ticket_id: Optional[str] = Field(None, description="用户提到的订单号") refund_amount: Optional[float] = Field(None, description="退款金额,仅在退款意图时填充")这样,即使模型的真实意图模糊,它也必须从四个枚举中选一个输出。我们还在 prompt 里明确写了“没有把握时选择 unknown”,这样模型的输出空间是封闭的,路由函数永远可以对齐。
同样的思路适用于工具调用参数。比如查询订单 API 需要一个严格的ticket_id字符串格式,不要指望模型能自行格式化,应该在节点里对解析结果做校验,不合法就回到澄清节点。我加了一个简单的“参数卡控”逻辑:
def query_order_node(state: OrderAgentState) -> dict: ticket_id = state.get("ticket_id") if not ticket_id or not ticket_id.isdigit(): # 参数不合格,转回澄清 return Command(goto="clarify") # 调用订单 API ...这里我要特别提一个理念:模型能做的是“理解”和“生成”,但所有跟外部系统交互的边界,都应该由代码校验。比如退款金额不能为负数、订单状态为“已发货”时不允许直接退款、幂等键必须由代码生成而不是让模型编一个。这些约束如果交给模型靠自觉,迟早会出事。
3.4 第三步:恢复、重试与幂等
人工审批只是中断的一种场景,日常还会遇到 API 超时、第三方接口抖动的状况。LangGraph 节点里可以正常使用 try-except,在异常路径里返回降级结果或调用条件边进入重试节点。我通常把重试设计为独立的节点:
def query_order_retry_node(state: OrderAgentState) -> dict: try: result = order_api.query(state["ticket_id"]) return {"order_status": result.status} except OrderApiTimeout: if state["retry_count"] < 2: return Command(goto="query_order_retry") return {"order_status": "unknown", "messages": [HumanMessage(content="订单查询暂时不可用,请稍后再试")]}注意这个节点里用的还是同一个retry_count字段,但是它的递增逻辑应该放在哪?我是推荐在重试路由里递增,或者在节点返回前自增。不管放哪,约定必须一致,否则测试时很难排查。
关于幂等,我想多说两句。退款、发消息、创建工单这种对外部系统有副作用的操作,Agent 重试时很容易发出重复请求。我遇到过一个实际问题:Agent 第一次调用退款 API 时超时了,它自动重试,结果后台创建了两张退款工单。教训就一条:凡是 Agent 要执行的高风险动作,节点的输入参数里必须带一个幂等键,由代码生成并存入 State,真实请求带上这个幂等键,后端做去重。而这个键的生成、存储、传递必须完全跟模型无关,模型只负责决定“要不要做”,不负责“怎么保证做一次”。
3.5 改造前后行为对比
下面这张表是我自己测试时整理的行为对比,可以帮助你直观看到“可控”到底换来了什么:
| 场景 | 旧 LangChain 链式实现 | LangGraph 图实现 |
|---|---|---|
| 用户表达模糊 | 模型自由发挥,可能走错分支 | 枚举约束,走 unknown 分支并追问 |
| 退款申请 | 识别后直接调退款接口 | 先进入审批节点,人工确认后才执行 |
| 意图解析失败两次 | 无法收敛,可能反复循环 | 重试计数达到阈值,强制转人工 |
| 多轮对话上下文 | 消息一长模型就丢信息 | 显式字段保存订单号,不依赖上下文 |
| 外部 API 超时 | 整条链抛出异常 | 节点内捕获并走重试/降级路径 |
| 服务重启恢复 | 无法恢复,会话丢失 | checkpointer 恢复中断状态,继续执行 |
4. 从 LangChain 迁移到 LangGraph 时的典型问题与排查实录
迁移不是复制粘贴改个 API 名那么轻松,我在实操过程中踩过不少坑。下面按常见程度列几个问题,每个都包含现象、原因和排查思路。
4.1 问题一:节点返回的 dict 把整个 State 覆盖了
这是最常遇到的问题。我刚写完第一个图时,节点函数返回{"order_status": "xxx", "messages": [HumanMessage(content="...")]},看起来没毛病,结果跑了几轮后发现历史消息全没了,messages只剩最后一句。
原因就是 State 里messages字段被默认的覆盖 reducer 处理了。解决方法是给字段加Annotated[list, add_messages],或者把新消息通过 retry 之类的方式手动合并。这里提醒一句:add_messages同时会处理消息 ID,如果你自己构造消息对象时习惯性不传 ID,多次运行后消息列表里可能堆积重复项。建议在构造消息时明确指定id,让 reducer 能做去重合并。
4.2 问题二:图进入了死循环,token 消耗疯狂上涨
条件边能实现循环后,我就开始放飞。做意图澄清时,我写了“识别失败就继续追问”,但漏了重试上限。结果模型连续几次都输出unknown,图就一直绕着clarify → intent_parse → clarify转圈,token 消耗表看得我想哭。
排查方式很直接:打开 LangGraph Studio 或者打印每一步的 State,观察到底是哪个节点在循环。修复方式有两个层面:第一,也是最有用的,在所有可能重复进入的节点设计重试次数上限;第二,在编译图的 invoke 参数里配置recursion_limit,超过步数直接抛出异常。我建议两个都用,前者保证业务逻辑优雅,后者作为兜底防线。
4.3 问题三:checkpointer 序列化报错,状态存不进去
配置好 checkpointer 后,某次运行时抛了序列化异常,后来查到是 State 里存了一个无法序列化的自定义对象(某个第三方库的 datetime 类型)。LangGraph 默认会把 State 做序列化持久化,任何无法被标准序列化的字段都会报错。
处理办法很简单:所有自定义对象在写入 State 前转换成 JSON 兼容的基本类型,比如datetime转成 ISO 字符串,结构体转成 dict。如果需要保留自定义对象用于节点内部计算,就把它放在图的config或其他非持久化区域,或者在节点内再构造出来,不要让不可序列化对象长期留在 State 里。
4.4 问题四:迁移后模型的行为漂移,旧用例过不去了
这不是 LangGraph 的问题,是我的迁移方式问题。旧实现里意图判断靠自然语言输出,新实现换成了结构化输出with_structured_output,虽然输出更稳定,但 prompt 措辞变了,模型的行为也变了:有些原来能识别的说法,新 prompt 下被识别成了unknown。这类“迁移期行为漂移”很容易被忽略,因为代码逻辑看起来没变。
我的建议是迁移时不要只测新流程,要做一边回归:把旧实现的对话日志和用例完整保存下来,逐条回放到新图里,跑完对比路由结果和最终回复。我就是在这样的回归里发现了一个说法漂移问题,然后通过补充 prompt 里的枚举示例解决。这个步骤千万别省,Agent 的行为测试要做到和代码测试同等认真。
4.5 排查速查表
| 现象 | 可能原因 | 排查切入点 |
|---|---|---|
| 消息历史丢失 | 缺少 add_messages reducer | 检查 State 字段定义 |
| 节点没按预期执行 | 条件边路由函数返回值与节点名不一致 | 打印 route 函数返回值 |
| 图无限循环 | 缺少重试上限 | 检查 retry_count 逻辑 |
| checkpointer 报错 | State 存在不可序列化对象 | 检查所有自定义类型字段 |
| 恢复后状态不对 | thread_id 设置不正确 | 确认 thread_id 与业务会话对应 |
| 模型输出格式不对 | 节点 prompt 与 schema 不一致 | 用 with_structured_output 强约束 |
| 工具副作用重复执行 | 缺少幂等键 | 在高风险节点增加幂等参数 |
5. 工程落地时的一些实际体会与选型建议
最后分享几个我在真实项目里积累的判断,不一定适合所有人,但至少能帮你少走弯路。
第一个体会是,不要为了“先进”而把所有 Agent 都改成图。如果你的场景就是简单的“单轮问题回答、没有状态、没有分支、没有风险动作”,直接用 LangChain 做 prompt 编排完全够用。例如文档问答、通用闲聊,这些场景的输入输出相对孤立,图的优势发挥不出来,反而引入多余的心智负担。我从 LangChain 切到 LangGraph,前提是先列清楚了要满足那五条可控标准。如果你列不出这种需求,说明当前场景不需要动手术。
第二个体会是,图的梳理过程比图本身更有价值。实际改造时,我先画流程、拆节点、定 State 字段,这一套下来花了大量时间,但带来的收益是团队所有成员终于能用同一种语言讨论 Agent 行为了。产品、测试、后端同事不再需要理解 prompt,他们只要看那张图和几个表,就能准确说出“用户在哪个环节会被追问”“退款在哪个环节被拦截”。我认为这种沟通上的收益,甚至比运行效率提升更重要。
第三个体会是,LangGraph 的“可控”不是银弹,它把不确定性从流程层面推到了更细的节点内部。你永远不可能让模型 100% 输出正确,图能保证的是:模型输错了,它会被限制在一个明确的分支里;模型失败了,它有重试和降级路径;模型运行到高风险步骤,它会被卡住等待人工确认。这五个字的含义不是说模型不会犯错,而是说错误会被拦在业务受影响之前。
第四个体会是关于测试的。以前测 LangChain 链,我只能端到端地跑几个对话样例,出了问题很难定位。现在测 LangGraph 图,我可以直接把每个节点当成独立函数做单测,再对条件边写路由矩阵测试;端到端测试反而变成最后一道防线。这种从“黑盒测试”变成“面向节点和边测试”的转变,是代码结构带来的红利,值得在团队里推广。
如果你也打算迁移,我个人建议的顺序是:先花一天时间把你现有 Agent 的流程手动画出来,明确状态字段和边界;再挑一个非高风险场景(比如纯查询类)做试点,熟悉 StateGraph 和条件边;最后才把退款、删除这类高风险动作用 interrupt 接进来。别一上来就搞全套交互式审批,那样你的调试点太多,反而不知道哪一步有问题。
写到这里差不多接近尾声,最后再分享一个小技巧。LangGraph 编译出来的对象,携带了很多可观测信息,我习惯在每个节点入口加一行结构化日志,把当前 State 的关键字段和节点名打印出来。调试图的时候,直接看日志流就能复现整个决策路径,配合 checkpointer 还能回到任意一个节点重跑。这个习惯让我在排查线上问题时省下了大量时间。希望这些实战经验对你的迁移之路有帮助。