TL;DR: 错误处理的常见死法是两个极端:裸奔(一个异常整个图崩)或者一把抓(全部 try/except 吞掉)。正确起手式是先分类。LangGraph 的设计把错误分成四类,每类有不同的处理位置:瞬时错误给 RetryPolicy,模型可恢复的写回状态回环,需要人的走 interrupt,耗尽重试的给 error_handler 做补偿。
两种死法:裸奔与一把抓
假设两条时间线。第一条:一个批处理 agent 跑着几百个任务,中途某个外部接口抖了一下,抛了个超时异常,没有重试、没有捕获,整个 run 直接崩掉,前面跑完的成果留在数据库里,剩下的任务一个没动,值班的人半夜被叫起来手动重启。第二条:同一个系统,吸取教训之后在每个节点外面包了层大 try/except,异常是吞了,但吞掉之后节点返回了个空结果,下游节点拿着空结果继续跑,最后产出了一份看起来完整、实际一半是空壳的报告,两周后客户发现,已经没人记得那天哪些环节失败过。
两条时间线是同一个错误的两种死法。裸奔的问题是把所有异常当成同一种东西处理(全炸);一把抓的问题是把所有异常当成同一种东西处理(全吞)。共同点是没有分类。agent 系统里的异常天然异质:网络抖一下和权限不足是两种完全不同的事件,前者重试就好,后者重试一百次也没用,该去问人。
顺带排除第三种常见做法:在系统提示词里写「遇到错误请自行重试」。这不是错误处理,是把错误处理的责任口头委托给一个没有重试能力的角色。模型输出的是文本,它「决定重试」和系统真的再执行一次之间隔着整套执行机制,而且它看不到异常对象、拿不到错误码、控制不了次数。提示词里唯一有意义的错误相关内容,是告诉模型「什么样的工具返回值代表业务失败、你修正时有哪些选项」,机制层面的事必须由机制层承担,这正是这篇的分工前提。
分类不是给异常贴标签的仪式,它直接决定异常出现在哪一层、由谁处理。LangGraph 把这四层的处理位置都给了原生出口,这篇按四类逐一过:每类的判别特征、对应的机制、机制背后的语义,以及用错位置时的典型事故形态。
顺带说清楚为什么 agent 系统比普通服务更需要错误纪律。三个放大器:一是失败面更大,一个任务要串起模型、工具、外部 API,任何一环都可能失败,长流程跑下来总会撞上几次瞬时错误;二是执行更长,一次 run 几分钟到几天,时间越长瞬时故障的命中概率越高;三是副作用更真,agent 会写数据库、发消息、动钱,错误的处理方式直接决定产生多少需要人收拾的烂摊子。普通服务里一个未处理的异常多半是 500 加一条日志,agent 系统里同一个异常可能是几十封发出去的邮件。
先分类:四类错误,四个处理位置
先分类:四类错误,四个处理位置
拿到一个异常,先问一个比「这是什么错」更有用的问题:谁能修好它? 答案只有四种:机器时间能修、模型能修、人能修、没人能修。四问四答对应四类:
- 瞬时错误(机器时间能修)。 网络抖动、接口限流、超时。重试大概率成功,不值得让模型知道,更不值得让用户知道。
- 模型可恢复的错误(模型能修)。 工具返回了业务报错(查无此单、库存不足)、输出格式不合法。把错误信息写回状态让模型重试,它能自己修正,这本来就是 agent 循环的意义所在。
- 用户可修复的错误(人能修)。 参数缺失、权限不足、余额不够。模型重试一百次也没用,该暂停问人,这是
interrupt()的活。 - 不可恢复的错误(没人能修)。 重试耗尽、外部系统确认宕机。框架层面要的是优雅收场:记录、补偿、通知,而不是把异常抛穿整个进程。
分类的价值在于让异常出现在正确的层。最常见的生产 bug 是层级错位,而且两个方向都有:本该重试的瞬时错误直接炸了图(第一类被当第四类处理),半夜报警;本该问人的业务错误被自动重试刷了一百次(第三类被当第一类处理),一百封重复邮件发出去。第二种比第一种疼得多,因为它产生了真实的副作用,还要人去对账清理。
所以分类要在写代码之前做,产出物是一张小表:系统的每个外部依赖、每个工具调用,分别落在哪一类、用什么机制。拿第 01 篇出现过的报销审批 agent 做个示范:
| 环节的错误 | 类别 | 处理 |
|---|---|---|
| 发票 OCR 接口超时 | 瞬时 | RetryPolicy 重试 |
| 模型抽出的金额格式不合法 | 模型可恢复 | 写回状态让模型重抽 |
| 发票抬头与公司名不符 | 需要人 | interrupt 等提交人确认 |
| 金额超限触发总监审批,人不批 | 需要人 | interrupt 走驳回分支 |
| 支付系统升级,确认宕机 | 不可恢复 | error_handler 记录 + 通知财务 |
| 附带:审批人三天未处理 | 不算错误 | 超时兜底策略(第 06 篇) |
这张表的价值不在答案本身,在它逼你逐环节想一遍「谁修」。你会发现很多环节的直觉答案和表格答案不一致,那些不一致的地方就是将来的事故点。这张表进代码评审,比评审 try/except 的写法有用得多。
瞬时错误:RetryPolicy 和 timeout
from langgraph.pregel import RetryPolicy
g.add_node(
"call_payment_api", call_payment_api,
retry_policy=RetryPolicy(max_attempts=3, initial_interval=1.0,
backoff_factor=2.0, jitter=True),
)
RetryPolicy 挂在 add_node 上,参数语义都是经典重试理论:max_attempts 是最多试几次;initial_interval 是第一次重试前等多久;backoff_factor 是指数退避的倍数(1 秒、2 秒、4 秒这样翻倍,给对方系统喘息时间);jitter 是在间隔上加随机量,防止几百个节点同时重试把刚恢复的下游再次打垮(所谓惊群)。挂上它之后,第一类错误就不再出现在你的日志里,它们被运行时消化成多花几秒的延迟,这是四类机制里唯一一类「处理完用户无感」的错误,也是最值得优先配上的。重试期间节点的写入会被丢弃重来,checkpoint 语义保证这个重试是干净的:上一次尝试的半成品状态不会漏进快照,恢复和重试都从已确认的边界开始。
1.2 版本给 add_node 加了 timeout 和 error_handler 参数。一个必须知道的限制:timeout 只支持 async 节点,同步节点不生效。这是踩过才知道的坑:给同步节点配上超时,行为正常,你以为有保护,实际没有,直到某个同步节点挂死把整个 run 卡住才发现。超时触发 NodeTimeoutError,已产生的写入被清理,然后交给 retry policy 决定要不要重试,所以超时和重试是配合关系:超时负责发现「卡住了」,重试负责决定「再来一次还是放弃」。整张图的默认值可以用 set_node_defaults 统一设,不用每个节点重复写。
配置重试次数时想两件事。一是副作用幂等性:重试意味着节点函数可能执行多次,纯读操作随便重试,写操作要先确认幂等(第 06 篇讲过幂等的做法)。二是成本:每次重试都是真实的计算和 API 调用,max_attempts 不是越大越保险,三次起步、按失败率观测调整,比一次配到十次合理。
超时值的配置有个常被忽略的层级关系:HTTP 客户端的单次请求超时,应该小于节点的 timeout,节点的 timeout 应该小于调用方对整个 run 的预期。三层各管一段,任何一层超时都意味着外层还有一层兜底。常见事故是只配了外层:HTTP 客户端没设超时,一个挂死的连接把节点拖到天荒地老,外层等得心灰意冷。另外 RetryPolicy 支持 retry_on 指定只重试哪些异常,把它当成白名单用:明确「这类异常值得再试」,其余的直接失败进入下一层处理,比「什么异常都重试三遍」更接近意图。
模型可恢复:写回状态回环
工具报错不抛异常,作为数据返回。校验节点发现格式不合法,也走同一条路:把错误写进状态,条件边把流程导回上一个节点,让模型带着报错重试。
def validate(state: S) -> dict:
problems = check(state["draft"])
if problems:
return {"errors": problems} # 错误是数据,不是异常
return {"errors": []}
def route(state: S) -> str:
return "revise" if state["errors"] else "publish"
g.add_conditional_edges("validate", route)
g.add_edge("revise", "validate") # 回环
这是 agent 循环的本质:把「失败」变成「上下文里的一条反馈」。模型的强大之处在于看到具体报错能自我修正,前提是报错真的到了它面前。异常机制做不到这一点:异常向上抛,穿过模型调用栈,模型根本不知道发生过什么。
这个模式有三个工程要点。第一,错误信息写给模型看,不是写给人看:「字段 price 缺失,请按 schema 补全」比「ValidationError: line 42」有用得多,报错信息本身就是提示词。第二,回环必须带退出条件:在状态里放重试计数,超过上限走别的分支。不设退出条件和 recursion_limit(默认 25)撞上就是 GraphRecursionError,而且撞上之前模型可能已经在同一个坑里来回跳了二十次,每次都烧一遍 token(第 04 篇的账)。第三,写进状态的错误要做成只增不减的列表,而不是覆盖,最后报错时你能看到「它试了三次分别错在哪」,对定位模型行为极有价值。每条错误建议带上结构化字段(错误码、人话描述、第几次尝试),错误码给程序路由用,人话描述给模型用,一次写入两份消费者各取所需。
注意这类错误的边界:它只适用于「模型确实能修」的错误。格式不对、参数不全、第一次方案被校验打回,模型能修;权限不够、实体不存在、余额不足,模型修不了,硬塞回给它只会得到幻觉式的「修复」。
还有一个设计选择值得想清楚:修不好的部分,让模型修还是让代码修。校验失败的修正有两档,简单的(缺个字段、类型不对)写个确定性修复函数直接改,比扔回模型快且省;复杂的(内容性错误、语义不合规)才值得走回环让模型重写。原则是能用代码修的别用 token 修,回环是给「需要智能才能修」的错误留的。
需要人的:interrupt,不要用异常模拟
权限不足、需要业务确认这类错误,正确动作是暂停等人,也就是 interrupt() 这个原语(第 06 篇是完整的使用手册)。反模式是抛异常让上游接口返回 500,用户看到「系统错误」,刷新重试,状态全丢,从头再来一遍。错误分类的第三类和第四类的分界线就在这:人能修的走 interrupt,人都修不了的才走补偿。
实践中这一类最容易和第二类混淆,因为表现都像「工具报错了」。区分的办法是问「如果重试,条件变了吗」:格式错误,模型改了参数,条件变了,重试有意义;权限不足,重试时条件一点没变,重试纯粹是浪费。条件没变而模型修不了的,归第三类,停下来,把「缺什么、谁有权补」作为 payload 交给前端或审批流。
interrupt 的暂停是带存档的暂停:状态进了 checkpoint,人可以一小时后处理,也可以三天后处理(超时兜底是你要自己加的业务逻辑)。这和「接口报 500 让用户重试」的本质区别是:前者把修复动作变成流程的一部分,后者把修复动作变成用户的运气。权限类错误还有一个上游形态:在第 16 篇的体系里,工具越界最好在可见性层就被拦住(用户根本看不到无权工具),错误路径上的权限处理是给「漏网」情况兜底的,两层都在,权限模型才算完整。
错误触发的 interrupt,payload 要按「帮人快速决策」设计,而不是只抛一句报错:缺什么(具体字段和格式)、谁有权补(提交人还是管理员)、有哪些选项(补全后继续、放弃、转人工通道)。人补完的信息通过 Command(resume=...) 送回来,节点的恢复逻辑校验后继续走。前端拿到这类 payload 的展示方式在第 10 篇的 useStream 部分讲过,协议上是现成的。另外这类「缺信息」型人审和「高风险确认」型人审在前端值得用两种面板,前者是表单,后者是按钮,混用一种交互会让审批人把「补个发票号」当成「批准扣款」,反过来亦然。
耗尽之后:error_handler 与补偿
重试次数用完、外部系统确认宕机,此时图挂掉之前还能做最后一件事:补偿。1.2 的 error_handler 参数给节点挂一个收尾函数,做三件事之一。一是写一条「降级结果」进状态让流程继续:比如返回缓存值、标记「该环节跳过、人工后补」,让批处理的其余任务不被一个失败拖死。二是执行反向操作:这个节点已经创建了外部资源(开了单、发了消息、占了库存),确认流程走不下去时把这些清掉。三是把现场(thread_id、checkpoint 位置、错误详情)投给监控系统再失败:让人接手时不用从日志考古。
error_handler 和在节点函数里写 try/except 的区别是位置和语义:它由运行时在节点最终失败后调用,拿到的是「这个节点确实不行了」的确定性结论,而节点内的 try/except 在每次尝试时都会执行,分不清「这次失败但还能重试」和「彻底失败」。补偿逻辑放在 error_handler 里,才不会和重试机制打架。
设计补偿时记住三条。补偿本身也会失败,写它的时候按「尽力而为」设计,补偿失败只能靠人来对账,别设计成递归补偿;补偿要有记录,每一笔反向操作留痕,对账才有依据;需要强一致的业务动作(扣款、发货),把它设计成「人工触发」而不是「自动补偿」,自动化的边界止步于可逆操作。
多步外部副作用的长流程,补偿要按 saga 的思路组织。比如一个订票流程:锁座、收款、出票,三步都是外部副作用。出票失败时,补偿要逆序回放:先退款、再解锁座位,顺序反了会出现「座位还锁着、钱已经退了」的中间态留在外部系统里。把每一步的反向操作和它的正向步骤定义在一起(哪个节点创建的,由哪个 handler 负责撤销),补偿逻辑才不会随着流程膨胀而漏项。这个模式和具体框架无关,LangGraph 里它落在 error_handler 和状态标记的组合上,模式本身是分布式的老朋友。补偿操作自己也要幂等:error_handler 触发补偿、监控脚本重放补偿、人工手动补偿,三种路径可能对同一笔交易都执行一遍,补偿带上业务单号做去重键,才不会把「补偿」变成「二次伤害」。
don't catch what you can't handle
官方设计文档里有一条原则值得刻在脑子里:don't catch what you can't handle。不能处理的异常就让它炸,炸在 checkpoint 边界意味着恢复后从干净状态重跑;吞掉它才会留下半损坏的状态。
这条原则的直觉基础是 LangGraph 的执行模型(第 02、03 篇):节点写入在 superstep 边界才合并生效,一个节点炸了,它这次的写入本来就没进快照,图停在炸之前的一致状态。所以「崩溃」在这里不是一个需要不惜代价避免的结果,它是安全的,框架已经把现场保存好了。真正危险的是「捕获了但没处理」:异常被吞,节点返回了空值或半成品,这个坏结果作为正常数据进了状态、进了快照,错误被固化成数据,污染后续所有节点。
由此可以推出 try/except 的合法使用标准:except 块里必须写得出具体的恢复动作(重试、降级值、状态标记、re-raise),写不出就别捕获,让它炸,炸给 RetryPolicy 或 error_handler 接。捕获范围和你能提供的恢复动作一一对应,这是把数据库事务里「出错就回滚」的直觉平移到 agent 执行上:不同的是这里不需要你写回滚,框架的快照机制已经替你回滚了,你要做的只是别把脏数据放进去。
把「半损坏状态」的传染路径走一遍,能看到吞异常的代价有多隐蔽:节点返回了空字符串作为「降级值」,下一个节点把空字符串当正常输入,检索出无关结果;再下一个节点基于无关结果生成了一份看似合理的回答;这份回答进了状态、进了 checkpoint,用户看到的是一本正经的错误答案,而系统里没有任何报错记录。对比「让它炸」的版本:run 停在出错边界,恢复后重跑,用户晚拿到结果几秒,但拿到的是对的。错误处理的全部设计,本质上就是在这两种结局之间做选择。
错误与持久化的协同
四类机制都不是孤立工作的,它们背后共用同一套 checkpoint 语义(第 03 篇),把协同关系理清,很多行为就不用背了。裸奔的崩溃,恢复时从最后一个 checkpoint 重跑,因为失败节点的写入从未生效;RetryPolicy 的重试是运行时在同一边界内的自动重跑,语义上等于「帮你恢复了 N 次」;interrupt 的暂停是人控制的边界停留,恢复是带上人给的输入继续;补偿发生在确认「这条流程到此为止」之后,是失败边界的最后动作。四件事是同一个边界模型的四种用法。
一个常见误区要单独说:撞上 GraphRecursionError 时,把 recursion_limit 调大不是修复,是把退出条件推迟。回环深度超限几乎总是意味着模型在重复同样的失败(错误信息它已经看不懂或者修不动),正确的响应是查「模型到底在循环什么」,修提示词、修工具描述、或者给回环加更好的退出分支。默认 25 这个数字不是给你的流程准备的跑道长度,是防止失控的护栏,护栏应该偶尔被撞到,不应该被日常依赖。
崩溃和恢复的循环还藏着一种生产事故形态:恢复后立刻再次失败,调度器又恢复,如此往复,一个坏 thread 消耗掉不成比例的资源,监控上只看到莫名其妙的调用量。error_handler 在这里的额外价值是把 thread 标记成终态(failed、需人工介入),打破「恢复即失败」的死循环。凡是自动恢复机制,都要配一个「放弃自动恢复」的出口,这条原则和重试上限、回环上限一脉相承:所有循环都需要边界,包括恢复本身。
让错误策略可测试
错误处理写得对不对,不能等生产报警才知道。四类错误各有便宜的可测法:瞬时错误,写一个假工具,前两次抛超时第三次成功,断言 RetryPolicy 生效、最终成功、重试没留脏数据;模型可恢复错误,假工具固定返回格式错误的数据,断言回环修正成功、重试计数递增、超限后走退出分支;用户可修复错误,断言 interrupt 触发、恢复值正确回流(第 06 篇的测试思路);不可恢复错误,假工具永远失败,断言 error_handler 执行、补偿落地、状态终态正确。
这套假件都是十几行的普通函数,放进 CI 之后,错误路径从「上线后用事故验证」变成「每次提交都验证」。第 14 篇的节点单测和第 15 篇的回归框架是这套测试的宿主,错误策略是其中最先该覆盖的部分,因为它出错的概率最高、修复的成本最大。测试之外,预发环境值得做一次手工演练:把某个外部依赖真的停掉,看整条链路的降级和补偿按设计运转。演练暴露的问题几乎总是「设计了但没接线」的类型,比如补偿函数写了但 error_handler 忘了挂,这种错误只有真停一次才现形。
权衡
这套机制的运维含义:错误策略是图定义的一部分,跟着代码走版本管理。改重试策略要过评审、要回归测试,和改业务逻辑同级。它换来的是错误行为可预期、可测试、可回放,回放(第 03 篇的 replay)让你能拿着历史 checkpoint 精确重现一次失败的前因后果,这比任何日志都完整,因为连当时的模型输入都在快照里。另一个提醒:补偿逻辑本身也会失败,写它的时候按「尽力而为」设计,补偿失败只能靠人来对账,别设计成递归补偿。
监控指标按四类分别建:第一类看重试率(哪条边在频繁重试,说明下游不稳或阈值不对);第二类看回环深度分布(修正一次是正常,普遍修正三四次说明提示词或工具描述有问题);第三类看人审队列长度和滞留时间(第 06 篇的超时问题在这里显形);第四类看补偿触发率和补偿成功率。四条曲线放一块面板上,系统的健康度比任何单一成功率指标都直观。
在这套指标之上可以给每类错误定错误预算:比如第一类周重试率高于某个值要查依赖,第二类平均回环深度高于 2 要改提示词,第四类补偿触发一次就要人工复盘。预算的意义是把「错误处理」从被动响应变成有阈值的运营指标,超标即行动,而不是等到某天报表难看了才想起来看。
最后一个判断:不是所有节点都值得配齐四层机制。读缓存的重试一次就够,转账节点的错误处理值得花一个下午设计。把错误处理的预算按「副作用的大小」分配,和第 01 篇的判断线是同一个思想:机制的重量要匹配事件的代价。
还有一条组织层面的提醒:错误处理代码是评审里最容易被「看起来差不多就过了」的部分,因为它不承载业务功能,评审人没有动力细看。对抗这个倾向的办法是前面那张分类表:评审时对着表问「这一环说好的机制实现了吗」,错误处理的评审就从读代码变成对清单,敷衍的空间小得多。分类表本身随事故复盘更新:每次线上错误处理失效的事故,问一句「它当时被分到了哪类、该分到哪类」,表修正一次,同类型的事故就不会有第二次。
延伸阅读
- Fault Tolerance 文档:RetryPolicy、timeout、error_handler 的官方说明
- Fault Tolerance in LangGraph(官方博客):1.2 容错机制的设计动机
- Human-in-the-loop 文档:第三类错误的处理原语