做一个能跑的智能体演示,大概一个下午就够了:一个大模型接口、一段提示词、一个工具调用循环,屏幕上就能看到它自己思考、自己搜资料、自己给出答案。但把同样的东西交给真实业务去跑,问题会以另一种密度出现——进程被重启之后任务从哪继续、一个用户会话和另一个会话的状态怎么隔离、模型连续两轮判断失误怎么把它拉回来、人想在中间拦一刀审批怎么办、前端要实时看到每一步在干什么。这些都不是提示词工程能解决的,它们属于编排层的事情。LangGraph 就是冲着这层来的:它把生产级 Agent 从"一段对话脚本"重构成"一张带状态、可持久化、可中断、可回溯的执行图"。这篇内容面向已经写过几个 AI Demo、但一往真实流量上压就开始出问题的开发者,也适合正在评估 Agent 编排方案的技术负责人。下面我会按自己落地的顺序,从抽象模型、状态设计、图结构、持久化、人在回路、流式与观测、成本调优一路拆过去,中间夹杂大量我踩过的坑。
1. 从Demo到生产的断层:Agent真正缺的不是模型能力
1.1 一个典型Demo的脆弱边界
先复盘一下绝大多数 Demo 的形态:一个while循环,里面messages.append(assistant_msg),判断有没有tool_calls,有就执行工具、把结果塞回消息列表,没有就退出。这段代码在笔记本里跑得行云流水,但它隐含了三个强假设:进程不会挂、会话只有一条、流程永远是线性的。
只要有一个假设被打破,代码就会以非常难看的方式崩掉。进程挂掉,内存里的消息列表全没了,用户得从头描述一遍需求,十几轮的上下文归零;会话并发,两个用户共用一个全局变量,甲的订单信息被塞进乙的回复里;流程需要回退,比如模型在第三步发现第一步的检索方向错了,想重来,而线性脚本只能一路往前,只能靠提示词求它"重新思考",效果全凭运气。
我见过最典型的翻车场景是"长任务 + 人工审批"。一个合同审阅 Agent,跑到一半需要业务方确认某条风险条款的处理方式,Demo 里的做法是input()阻塞等输入。本地跑没问题,部署到服务端瞬间变成事故:一个请求把整个 worker 线程占死,超时配置也没法生效,人不在工位上这条会话就永远挂着。
判断一个 Agent 是否还停留在 Demo 阶段,有个很省事的标准:把服务进程
kill -9之后重启,它能不能接着上次的位置继续跑。不能,说明它还没有编排层。
1.2 生产级的四个硬需求
把这些故障归归类,其实就四件事,而它们恰好都不归模型管:
状态管理。Agent 的执行状态不止是消息列表。检索到的文档、已生成的中间产物、重试计数、当前走到的分支、用户权限上下文,这些都算状态。它们需要有明确的 schema、明确的合并规则、明确的读写边界,而不是散落在十几个闭包和全局变量里。
控制流。真实任务里循环、分支、并行、回退是常态。线性链只能表达 A→B→C,而 Agent 需要"A 之后看情况走 B 还是 C,B 失败回 A 但最多三次,D 要同时跑三份子任务再汇总"。用if/else硬写当然可以,但复杂度是随分支数指数上升的,两个月后没人敢改。
持久化。每一个执行步骤的输入输出都得能落盘,落盘之后还得能按会话 ID 精确取回。这不只是为了容错,更是为了支持"时间旅行"——产品经理说"它第三步的判断不对,如果当时换个工具会怎样",你能不能在不重跑前两步的情况下重放。
可观测性。前端要显示"正在检索资料…""正在撰写…",运维要能看到每次调用的 token 消耗和耗时,出问题时要能定位到具体哪个节点、哪次模型调用。这些信息必须在编排层埋点,指望在业务代码里手写日志会漏得惨不忍睹。
1.3 编排层为什么值得单独引入
有人会问:我自己写个状态机加 Redis 存状态,不也能实现?能,而且我确实这么干过一版。那版的结局是:四个月后代码里出现了三套自定义的消息合并逻辑、两套重试机制,以及一个没人说得清为什么存在的status = 7。
自研编排层最大的隐性成本不在写,在于变更。你每加一个分支节点,就要同时改状态结构、改持久化序列化、改前端事件格式、改重试白名单。每次改动都要重新验证全部路径,因为没有统一的、可声明的方式来表达"从这个节点出发有哪几种可能"。
LangGraph 的价值就在这里:它把"节点做什么"和"流程怎么走"分离开了。节点就是一个纯函数,输入状态、输出状态增量;流程用边来声明,包括条件边和并行分发。你的业务逻辑改动被限制在节点内部,流程改动被限制在图的拓扑里,两者的序列化、持久化、重放、流式事件由框架统一处理。说得再直白些,它让你在改第五版需求的时候,不用把前四版的设计推翻重来。
2. LangGraph的核心抽象:把Agent当成一张有状态的图
2.1 State、Node、Edge三个原语
LangGraph 的心智模型非常简单,简单到第一次接触会觉得"就这?":
- State:一个共享的、带类型的字典,是所有节点读写的数据总线。
- Node:一个可调用对象,签名为
(state) -> dict,返回的是对状态的增量更新,不是完整状态。 - Edge:节点之间的连接。普通边表示"必然跳转",条件边表示"根据状态的某个值决定去哪"。
代码骨架长这样:
from typing import TypedDict, Annotated import operator from langgraph.graph import StateGraph, START, END class AgentState(TypedDict): messages: Annotated[list, operator.add] plan: list[str] retry: int def planner(state: AgentState) -> dict: # 只返回增量,不碰 state["messages"] return {"plan": ["search", "write"]} builder = StateGraph(AgentState) builder.add_node("planner", planner) builder.add_edge(START, "planner") builder.add_edge("planner", END) graph = builder.compile()注意add_edge(START, ...)这个写法,START和END是两个虚拟节点,代表图的入口和出口。刚上手时容易写成set_entry_point,两种都行,但新版里显式用START更清楚,也便于以后在入口前插节点。
2.2 节点返回增量这件事,是整套设计的基石
如果只有一个概念必须吃透,我选"增量更新"。节点返回{"plan": [...]},框架会把这份字典按reducer合并进主状态。默认的 reducer 是覆盖,也就是state["plan"] = 新值。如果某个字段的 reducer 是operator.add,那就是追加。
这个设计带来两个直接好处。第一,节点变成了纯函数,可以单测——给它一个 dict,断言它返回的 dict,不需要启动整个图,也不需要模拟模型。第二,持久化变得天然可行:checkpoint 里存的是每一步的增量快照,恢复时按顺序重放即可。
反过来说,新手最常犯的错误就是在节点里直接修改state:
def bad_node(state: AgentState) -> dict: state["retry"] += 1 # 不要这么干 return state这样写在小规模测试里"看起来能用",因为传进来的是同一个对象引用。但它会破坏 checkpoint 的增量语义,也会让并行分支互相踩踏——两个并行节点同时改同一个字段,结果取决于谁后执行,这种 bug 极难复现。规矩就是规矩:只读state,只返回增量。
2.3 与链式编排的差异在哪里
很多人是从链式编排(LCEL 那种prompt | model | parser)转过来的,容易把 LangGraph 也当成"更长一点的链"。两者的区别用一张表能说清:
| 维度 | 链式编排 | 图编排 |
|---|---|---|
| 控制流 | 单向线性,DAG | 支持环、分支、动态并行 |
| 状态 | 数据在管道里流动,无共享状态 | 全局 State,所有节点读写 |
| 中断恢复 | 无原生支持 | checkpointer + interrupt |
| 回退重试 | 需要外层包重试逻辑 | 条件边直接连回上游节点 |
| 可观测 | 按链路段落埋点 | 按节点事件埋点,粒度更细 |
| 适用场景 | 单次调用、无交互 | 多轮、带工具、需人介入 |
链式编排擅长"一次请求一次响应"的场景,比如文档摘要、分类打标。图上手门槛高一点,但一旦你会画环了,很多原本要靠提示词硬凑的东西就能用代码表达——"最多重试三次""检索不到就转人工""三个子任务跑完再汇总",这些在图里是拓扑,在链里是玄学。
2.4 环、分支、并行在图里的表达
环其实就是一条指回上游的边。比如反思型 Agent:生成 → 评审 → 不通过则回到生成。在图里是generator -> reviewer,加一条条件边从reviewer回到generator。这里必须计数器兜底,后面第 8 节细说。
分支是条件边,从源节点出发,根据路由函数返回的字符串决定下一个节点。并行有两种:静态的 fan-out(一个节点连多条边到不同节点,框架在同一步内并发执行)、动态的 fan-out(用Send按运行时数据决定并发的份数)。动态并行是很多人忽略的能力,做"一个查询拆成 N 个子问题分别检索再汇总"这类任务时,它比串行快一个数量级。
3. State设计:Schema定得好,后面少改三版代码
3.1 Reducer决定了合并语义
状态字段的 reducer 有两类用法,搞混了会出大问题。覆盖型 reducer(默认)适合"当前值"语义的字段,比如current_step、retry_count、approved。追加型 reducer(operator.add)适合"累积历史"语义的字段,比如消息列表、日志、工具调用记录。
这里有一个非常容易踩的坑:并行节点写同一个字段时,必须用追加型 reducer。假设你有三个并行的检索节点,都要往sources里塞结果,如果sources是默认覆盖型,最后一个执行完的节点会把前面两个的结果全冲掉。改成Annotated[list, operator.add]之后,三条结果会合并成一个列表,顺序按完成时间排列,内容不丢。
3.2 消息列表该怎么定义
最常见的是直接用消息累加:
from typing import Annotated, TypedDict import operator class State(TypedDict): messages: Annotated[list, operator.add]但如果你要做上下文裁剪(第 8 节会讲),就不能只用一个扁平列表。我现在的习惯是拆成两个字段:
class State(TypedDict): messages: Annotated[list, operator.add] # 完整历史,落盘用 working_messages: list # 每次进模型前裁剪出来的窗口messages只增不减,方便审计和回放;working_messages是覆盖型,每次调模型前由专门的trim节点重新计算。这样即使裁剪逻辑改了三版,历史记录一条不少。代价是存储空间翻倍,但对绝大多数场景来说这点成本远比"线上出问题捞不到原始数据"便宜。
3.3 状态里该放什么、不该放什么
这条经验是用血的教训换来的:状态里只放可序列化的、跨节点必须共享的东西。
不该放的:数据库连接、HTTP 客户端、模型实例、文件句柄。这些是"资源",应该通过闭包或者依赖注入在节点定义时绑定,而不是塞进状态里。因为状态要落盘,序列化一个连接对象会直接报错;就算你用了弱引用绕过,恢复的时候对象也没了。
也不该放:大块二进制。PDF 原文、图片 base64 这些塞进状态,一个 checkpoint 就能到几十兆,写入延迟立刻上来了。正确做法是存对象存储的 key,节点里按 key 去取,取回来之后可以放一个小缓存。
该放的:业务标识(订单号、用户 ID)、流程控制字段(下一步、重试计数、审批结果)、结构化的中间产物(抽取出的字段、生成的提纲)、以及审计需要的证据链(引用了哪些文档片段)。判断标准很简单——如果你希望"重放这一步时它和当时一模一样",那它就该进状态。
3.4 TypedDict 还是 Pydantic
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
TypedDict | 轻量、序列化无负担、框架原生支持好 | 无运行时校验,字段错了要跑起来才知道 | 大多数场景首选 |
PydanticBaseModel | 有运行时校验、类型转换、默认值 | 每次状态合并都有序列化开销,嵌套深时明显 | 状态字段少且强校验需求明确 |
我自己的选择是:状态主体用TypedDict,但节点之间的业务数据(比如抽取结果、审批单)用 Pydantic 模型,存进状态时.model_dump()成 dict。这样框架层的状态合并保持轻量,业务层该有的校验一点不少。
一个具体的数字感受:一个二十来个字段的状态,如果全用 Pydantic 嵌套模型,单次合并的序列化耗时能到毫秒级;在高并发下这个开销会被放大,而它换来的校验收益其实用一次.model_validate()就够了,没必要每步都做。
4. 节点与边的落地写法
4.1 节点函数的三条纪律
节点的签名是(state) -> dict,但实际写的时候有几条纪律能省掉大量调试时间。
第一条,节点要能容忍状态缺字段。状态是逐步累积的,第一个节点执行时,后面节点需要的字段根本不存在。用state.get("plan", [])而不是state["plan"],能避免大量KeyError。
第二条,节点内不要做跨节点的判断。经常看到有人在节点里写"如果我看到 messages 里已经有搜索结果,就跳过检索",这会和图的拓扑打架——该不该跳过应该由条件边决定,节点只管做一件事。业务判断和图结构混在一起,最后一定会出现"图说该走 A,节点内部觉得自己该走 B"的分裂状态。
第三条,耗时操作要有超时。模型调用、外部 API、检索服务都可能卡住,节点里不给超时,整个图就跟着挂。我习惯在节点外层包一个统一的with_timeout装饰器,默认 30 秒,特殊节点单独配。
4.2 条件路由的三种写法和各自的坑
最常见的写法是路由函数返回下一个节点名:
def route_after_review(state: AgentState) -> str: if state.get("score", 0) >= 0.8: return "publish" if state.get("retry", 0) >= 3: return "fallback" return "revise" builder.add_conditional_edges("review", route_after_review)第二种是显式映射表,好处是能一眼看出所有可能去向,也便于做静态检查:
builder.add_conditional_edges( "review", route_after_review, {"publish": "publish", "fallback": "fallback", "revise": "generator"}, )坑在于:路由函数返回的字符串如果没有对应节点,报错发生在运行时,且错误信息不一定直白。加了映射表,链式配置错的时候编译阶段就能发现。我现在的做法是强制加映射表,多写六行,省下无数次排查。
第三种是路由函数里直接返回END。这没问题,但要注意END是导入的常量,不是字符串"END",写成字符串会被当成一个不存在的节点名。
4.3 子图:什么时候该拆
当一个图超过十来个节点,或者某一段逻辑在多个流程里复用(比如"检索 + 相关性打分 + 重排"这个三件套),就该考虑拆子图了。子图本身也是一个编译好的图,可以像节点一样加到父图里:
sub = sub_builder.compile() builder.add_node("retrieval", sub)关键点在于状态的传递。父图和子图的状态 schema 不一定要一致:如果子图的状态是父图的子集,框架自动做字段映射;如果不一致,需要写一个转换函数在进出子图时做适配。这一点很多人第一次用会困惑——为什么子图里读不到父图的某个字段?因为 schema 不匹配,它被过滤掉了。
拆子图的收益是可测试性。子图可以独立编译、独立跑测试,不用构造整个大流程的上下文。我一般会给高频复用的子图配一套自己的测试用例,改完立刻验证,比跑端到端快得多。
4.4 Send API:动态并行的正确姿势
静态并行是"一个节点连出三条边",适合固定份数。但很多任务份数是运行期才知道的,比如"把用户这句话拆成 N 个子查询,每个查询单独检索"。这时用Send:
from langgraph.types import Send def dispatch(state: AgentState): return [ Send("search_worker", {"query": q, "parent_id": state["task_id"]}) for q in state["sub_queries"] ] builder.add_conditional_edges("decompose", dispatch)这里有三个必须注意的点。第一,Send的第二个参数是传给目标节点的独立状态,不是完整父状态,目标节点拿到的就是这个 dict(加上全局的共享字段,取决于你的 schema 设计),别指望它能读到父状态里的所有东西。第二,并行节点的结果合并依赖 reducer,往列表字段写必须用operator.add,往标量字段写会丢数据。第三,并发的份数没有内建上限,一次性发出两百个Send会直接把模型接口打爆,自己加一个分批逻辑。
5. 持久化:Checkpointer撑起断点续跑与时间旅行
5.1 thread_id 是一切的前提
编译图的时候传入 checkpointer,调用时传入thread_id,状态就会自动落盘:
from langgraph.checkpoint.memory import InMemorySaver graph = builder.compile(checkpointer=InMemorySaver()) config = {"configurable": {"thread_id": "order-20240513-8871"}} result = graph.invoke({"messages": [...]}, config)thread_id的语义是"一条独立的执行主线"。同一thread_id的多次调用会接着上次的状态继续,不同thread_id之间完全隔离。这个设计非常关键,它把"会话隔离"这件事从业务代码里拿掉了。
关于thread_id怎么生成,我有一套固定规则:业务主键 + 会话序号,比如订单号_第几轮。用 UUID 也能跑,但线上排查的时候你拿到一个 UUID 完全不知道它对应谁,得再去查一次映射表。用业务主键可读性好得多,代价是要注意别让同一业务主键的不同任务串到一个 thread 里。
提示:
InMemorySaver只适合本地开发,它存在进程内存里,重启即失。上线必须换成持久化后端,不然你以为做了断点续跑,实际上只是做了个寂寞。
5.2 后端选择与配置要点
| 后端 | 部署成本 | 写入延迟 | 适用阶段 | 备注 |
|---|---|---|---|---|
| 内存 | 无 | 微秒级 | 本地开发、单元测试 | 进程重启即丢 |
| SQLite 文件 | 低 | 毫秒级 | 单机小流量、离线任务 | 并发写有锁竞争 |
| 关系型数据库 | 中 | 毫秒到十毫秒 | 中等流量、要事务保证 | 需要建索引,注意清理策略 |
| Redis 类缓存 | 中 | 亚毫秒 | 高并发、短会话 | 需要配持久化策略 |
| 文档型数据库 | 中 | 十毫秒级 | 状态结构变动频繁 | 单条记录别太大 |
选型的核心考量不是性能,而是清理策略。checkpoint 是只增不减的,一条会话跑二十轮,每轮存一次快照,加上工具返回的大块内容,单个 thread 攒到几十兆是常事。必须有一套归档或截断机制,比如只保留最近 N 个 checkpoint,历史快照转冷存储。我见过一个项目上线三个月数据库涨到 T 级,排查半天发现是 checkpoint 没清理。
5.3 时间旅行:回放与分支
checkpointer 还有个常被低估的能力:读取历史状态并从中重新出发。
history = list(graph.get_state_history(config)) # 找到想回到的那个快照 target = history[3] graph.invoke(None, target.config)invoke传None表示"不提供新输入,从该快照继续"。这在调试时极其好用:产品说"第三步那个判断有问题",你把快照捞出来,改一下当时的某个字段,重新往下跑,看看结果会不会不同,完全不用重跑前面的模型调用——省的不只是时间,还有 token 钱。
线上也有实际用途。比如审批场景,业务方对某个中间结果不满意,想换个方向重来,你可以回滚到那个节点,追加一条修正指令,让流程从那里分叉出去。用户体验上就是"改一下再试一次",背后是一次状态回滚,前面的工作全保留。
5.4 状态膨胀的三个对策
第一条,大对象外置。前面提过,文档原文、图片、模型生成的附件都存外部,状态里只留引用。这是收益最大的一条,能把单次 checkpoint 从几十兆压到几 KB。
第二条,字段级裁剪。用一个专门的节点在关键节点之后清理中间产物,比如把已经合并进最终答案的临时片段删掉。注意要在 reducer 层面处理,直接del是不生效的。
第三条,快照粒度控制。不是每个节点都需要留快照。轻量节点(字段透传、格式转换)可以配置成不产生 checkpoint,只在关键决策点留。这样既保留了回溯能力,又控制了增长速度。
6. 人在回路:中断不是加个input就完事
6.1 interrupt 与 Command(resume) 的配对
LangGraph 的原生中断机制大概是这样的:
from langgraph.types import interrupt, Command def approval_node(state: AgentState) -> dict: decision = interrupt({ "type": "approval", "payload": state["draft"], "options": ["approve", "reject", "edit"], }) return {"approved": decision["action"] == "approve"} # 第一次调用,会在 approval_node 处挂起 result = graph.invoke(input_data, config) # 中断信息在 result 里,前端展示给用户 # 用户做出选择后恢复 graph.invoke(Command(resume={"action": "approve"}), config)interrupt()的返回值就是恢复时传进来的resume值。这个语义很干净:中断时往外抛一个 payload,恢复时把用户的决定塞回去,节点从原地继续。挂起期间进程不占资源,状态在 checkpointer 里躺着,等多久都行。
6.2 三种审批模式的实现差异
实际业务里的"人介入"不止一种形态,实现上要区别对待。
单点审批:流程走到某一步停下,等人点头。用interrupt直接实现,最简单。
编辑后继续:人不只是同意或拒绝,还要改内容。做法是把用户的编辑结果合并回状态,再继续往下走。注意这里要处理好字段的 reducer——如果草稿字段是覆盖型,直接把编辑后的版本写回去就行;如果是追加型,得用特殊的标记区分。
选择性回退:人看完结果说"第二步重做"。这种需要在图的某个节点检查一个rollback_to字段,用条件边跳回指定上游节点,而不是继续往下。实现时要注意清空被回退区间内产生的中间状态,不然会出现新旧数据混在一起的脏状态。
6.3 恢复时节点会重跑,这是最大的坑
这个必须单独强调:interrupt恢复时,包含它的那个节点会从头重新执行。
这意味着如果节点里interrupt()之前有副作用操作(写数据库、发消息、调外部接口),恢复时会重复执行。我第一次遇到这个问题是在一个通知节点里,中断前后各发一次通知,用户收到了两条一模一样的消息。
解决办法有三条。第一,把副作用放到interrupt()之后,或者拆成独立的节点。第二,副作用操作加幂等键,重复执行不产生新效果。第三,如果副作用必须在中断之前发生,把它挪到之前的一个专门节点里,那个节点不带中断,就不会重跑。
6.4 超时与兜底设计
中断挂起是没有超时的,理论上可以挂一辈子。业务上不能这样。我的做法是在状态里记录interrupted_at时间戳,外部有一个定时任务扫描超过阈值(比如 24 小时)仍未恢复的会话,走一个"自动拒绝/转人工"的兜底分支。
这个兜底分支要小心设计。直接调Command(resume=...)塞一个默认决定是可以的,但要确保下游节点能处理这个默认值。更稳妥的方式是走一条独立的路:把该会话标记为超时,通知相关人,然后从入口重新发起。多花点 token,但状态是干净的。
7. 流式输出与可观测性
7.1 stream_mode 的四种模式怎么选
| 模式 | 输出内容 | 前端用途 | 注意 |
|---|---|---|---|
values | 每步之后的完整状态 | 需要完整快照的场景 | 数据量大,别频繁推前端 |
updates | 每步的增量 | 显示"正在做某事" | 最常用,体积小 |
messages | 模型 token 流 | 打字机效果 | 只有模型节点产出 |
debug | 详尽执行细节 | 开发排查 | 生产别开 |
实际项目里通常是组合使用:updates推给前端做进度条,messages单独起一条通道做正文流式,debug只在灰度或者带调试头的请求里打开。
for chunk in graph.stream(input_data, config, stream_mode=["updates", "messages"]): mode, payload = chunk if mode == "messages": yield_token(payload) else: yield_progress(payload)注意多模式返回的是元组,不是单个字典,第一次用容易漏掉解包。
7.2 前端进度信息的可读性处理
直接把节点名推给前端会很难看。retrieval_worker_2这种东西用户看不懂。我一般在图外维护一张映射表,把节点名翻译成人话:planner -> "正在拆解任务"、retrieval -> "正在查资料"、review -> "正在自我检查"。
更进一步,可以按节点组合出有意义的阶段提示。比如连续三个检索节点并发执行,前端不该显示三次"正在查资料",应该聚合成一次并显示进度2/3。这需要在服务端做一层事件聚合,简单来说就是维护一个"正在执行的节点集合",有新节点进来就更新集合,全部完成才推"检索完成"。
7.3 埋点该记哪些字段
每个节点执行至少要记:节点名、开始时间、结束时间、输入状态的字段名列表(不是内容,避免日志爆量)、输出增量的字段名、是否发生错误、错误类型、本次涉及模型的 token 数、工具调用的耗时。
这些数据合起来能回答大部分运营问题:哪一步最慢(看耗时分布)、哪一步最容易失败(看错误率)、成本花在哪(看 token 分布)、用户在哪一步流失(看中断未恢复的比例)。
工具调用要单独记,因为它的耗时波动最大。外部 API 偶尔抽风到十几秒,如果你只看总耗时,会误以为是模型慢,然后去优化提示词,方向全错。
8. 成本与性能:让Agent跑得久也跑得起
8.1 死循环的三道防线
带环的图最怕死循环。模型每次都说"还需要再检索一次",图就真的转下去了。三道防线缺一不可。
计数器:状态里放loop_count,每次进入环里的节点加一,条件边判断超过阈值就强制跳出。阈值设多少?我的经验是检索类循环 3 次,生成-评审类循环 4 次,超过基本就是模型判断有问题,再转也没用。
预算上限:累计 token 数超过阈值直接走 fallback。这个比计数器更本质,因为有些循环转得很快但每次调用都很贵。
收敛检测:比较连续两轮的输出相似度,如果高度相似说明在原地打转,直接跳出。实现上做个简单的字符级相似度就够,不用上嵌入模型。
三条防线都要有,但优先级不同。计数器是必须的,预算是强烈建议,收敛检测是锦上添花。我见过只有计数器没有预算的,结果三次循环每次塞了五万字上下文,一次请求烧掉的钱比一整天的平均成本还高。
8.2 上下文裁剪的实操
到第三轮对话时,messages里已经塞了历史提问、工具调用、工具返回、模型回复。工具返回往往是最占地方的——一份检索结果动辄几千字。全塞进上下文,token 成本飙升,而且模型注意力会被稀释,回答质量反而下降。
我的裁剪策略是分层处理。系统提示和最近的用户提问必留;最近两轮的完整对话必留;更早的工具返回只保留摘要(第一次生成结果时就顺手让模型压一句);更早的对话保留用户提问和助手结论,中间的思考过程丢掉。
实现上放在一个独立节点里,在每次调用模型之前执行。注意这个节点的输出必须是覆盖型的working_messages,不能污染原始的messages。这个分离设计在第 3 节提过,现在能看出它的价值了:裁剪逻辑随便改,历史记录始终完整。
8.3 并行化能省多少
一个具体的例子:一个任务要查三个数据源。串行执行,每次检索 2 秒,加上模型调用 1.5 秒,总共 7.5 秒。用Send并行,三次检索同时发出,总耗时 2 + 1.5 = 3.5 秒,时间省一半以上。
但并行不是免费的。第一,并发会打满下游接口的限流,需要做批处理或者信号量控制。第二,并行节点的结果合并会引入顺序不确定性,如果下游依赖顺序(比如要"最新的那条"),得在合并逻辑里自己排序。第三,调试变难——并行节点的日志交错在一起,出问题时要靠 trace_id 串起来。
9. 常见问题排查速查表
下面这些是我在自己和别人的项目里反复遇到的,按出现频率排:
| 现象 | 可能原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 恢复后从头开始跑 | 没传 thread_id 或没配 checkpointer | 检查config结构 | 确认configurable.thread_id存在且稳定 |
| 状态字段莫名丢失 | 用了覆盖型 reducer 且有多节点写 | 打印每步增量 | 改成Annotated[list, operator.add] |
| 并行结果只留一份 | 同上 | 同上 | 同上 |
| 路由报找不到节点 | 路由返回值与节点名不一致 | 检查字符串拼写 | 改用映射表显式声明 |
| 中断恢复发了重复通知 | 副作用写在interrupt之前 | 检查节点内顺序 | 挪到中断之后或拆节点 |
| checkpoint 表暴涨 | 大对象进了状态 | 看单条记录大小 | 大对象外置,加清理任务 |
| 流式输出卡顿 | 用了values模式 | 看单条事件体积 | 改用updates+messages |
| 偶发超时 | 循环次数过多或上下文过大 | 看单次 token 数 | 加循环上限和裁剪 |
| 子图读不到父状态 | schema 不匹配 | 对比两个 TypedDict | 写转换函数或统一 schema |
还有一个不在表里的问题值得单独说:状态 schema 变更后的兼容性。你在第一版定义了plan: list,第二版想改成plan: dict,那些已经在 checkpointer 里的老会话怎么办?恢复的时候字段类型对不上,节点直接崩。我的处理是加版本号,状态里放schema_version,新节点读老数据时做一次转换。麻烦,但比线上大面积报错强。
另外一个高频困惑是"为什么我的图编译通过了但跑起来说找不到状态字段"。大概率是TypedDict里用了total=False或者某些字段被Annotated包裹后名字写错了。编译期只做结构校验,字段级的问题要到运行时才暴露,所以节点里统一用.get()是保命的习惯。
10. 我对这套东西的一点实际体会
用了一段时间下来,最深的感受是:LangGraph 把"写 Agent"这件事从"调提示词"往"设计状态机"推了一大步。这个转变一开始有点不适应,因为你会发现自己大部分时间不在写模型调用,而在想状态怎么组织、边怎么连、失败怎么兜。但恰恰是这些工作,决定了一个 Agent 能不能扛住真实用户。
如果只能给三条建议,我会给这三条。第一,先把状态 schema 想清楚再动手写节点,字段定下来之后,图怎么连往往就水到渠成了,反过来先画图再补字段,改起来非常痛苦。第二,每个节点都当成可以独立跑的函数来写,输入一个 dict,输出一个 dict,不依赖外部隐式状态,这样单测覆盖率能上去,迭代速度会快很多。第三,别急着上复杂拓扑,一开始就是三五个节点的直线加一两个条件分支,跑通了再考虑加环、加并行、拆子图。我见过一上来就画十几个节点的大图,跑了三天没跑通,最后退回三节点版本重来的项目。
还有一个小技巧可以分享:本地调试的时候,把stream_mode="debug"打开,把每一步的增量和当前状态打到日志里,再配一个能把日志按 thread 聚合的小脚本。调试效率的提升非常明显,尤其是排查"这个字段为什么在这步变成空"这类问题时,比断点还好用。等你的图稳定了,把这个开关收成配置项,只在调试请求里打开,避免生产环境的日志量失控。
这个领域变化很快,框架的 API 也在迭代,但底层那套"状态、边、持久化、中断"的模型是稳的。把模型吃透,换个框架也能迁移过去,这才是真正属于自己的东西。