TL;DR: 循环图难调试的根源是模型错配:主流 tracing 工具假设执行是一棵 span 树,LangGraph 的执行是一个会回到同一节点的环,报错堆栈在节点边界断掉。对策分三层:排查用 debug 流和快照历史,日常测试用节点级单测加假模型,运行期用步数计数做优雅降级而不是等 GraphRecursionError。这篇文章把三层各自的工具、代码和一条固定的排查路径讲完。
一个线上问题,先看它长什么样
假设你的客服 agent 上线第三天,值班群收到告警:某个 thread 的响应时间从平时的 8 秒涨到 90 秒,最后超时。你打开公司的 APM 搜这次请求,看到十几张同名 span 叠在一起:call_model 出现了五次,run_tools 出现了四次,谁先谁后、第几圈开始变慢、模型每次进来时看到了什么,树里都看不出来。你再去翻日志,异常堆栈的第一帧是框架的调度器代码,往下翻两屏才见到自己的业务函数,中间还断了一段。你试着在模型节点里加一行 print 重启服务,问题却复现不出来了,因为线上那次的状态和本地造的状态根本不是同一份。
这个场景不是你工具选得不对,是两类系统的执行形状根本不同。主流可观测设施为「一次请求走一条链」的系统设计,而 agent 图是「一次请求在几个节点之间转圈」。先把形状差异讲透,后面三层的对策都是从它推导出来的,不需要背。
根源:环和树对不上
一次典型的 web 请求,在 APM 里长成一棵树:请求 span 在顶层,它调用的下游服务、查询的数据库是下一层的子 span,每个环节只进一次,树的深度就是调用深度。这套模型隐含了一个前提:执行路径无环。有了这个前提,「span 的父子关系等于调用关系,调用关系等于时间包含关系」才成立,火焰图、瀑布图、按接口聚合的 p99,全部建立在这上面。
LangGraph 的执行不满足这个前提。同一个节点在一次 invoke 里可能进入五次,工具节点的下游可能绕回模型节点,模型节点又可能再次调工具。span 树模型遇到环,只能把每次进入展开成一个新 span,于是「第几圈」这个最关键的语义丢了:五次 call_model 各自的父子关系都对,但你没法回答「模型是从哪一圈开始跑偏的」,因为树里没有「圈」这个概念,只有五棵长得一样的子树。
堆栈是第二个失效的东西。普通 Python 程序里,异常的 traceback 就是执行路径,谁调用了谁一目了然。图执行里,节点是被运行时从任务队列里取出来调用的,你的业务函数栈帧上面直接是框架的调度帧,和隔壁节点的调用在栈上没有任何关系。异常从节点 A 传到节点 B,堆栈在边界处断开,你看到的是「B 里抛了错」,看不到「B 拿到的 state 是 A 刚改过的那份」。数据流的问题拿调用栈去查,方向从一开始就错了。
传统调试器在这里也帮不上太多忙。断点当然可以打在节点函数里,停下来的那一次你能看到局部变量,但循环图真正的问题是「这一圈的输入是怎么被前几圈改出来的」,断点一次只停一圈,你要手动放行三四轮才能拼出完整过程,并行分支里还会漏掉另一条线。print 调试同理:单圈的信息有了,跨圈的因果链没有。
并行再放大一格。superstep 机制让多个节点在同一轮里并发执行,合并语义在第 02 篇讲过,这里只说它对排查的影响:日志里两条相邻的记录可能来自两个并行分支,时间戳只差几毫秒,谁产生的数据被谁消费,光看日志顺序定不了,要靠快照。
循环图调试还有一条普通应用没有的放大器:每一圈都花钱。普通服务复现一万次也不心疼,agent 图每复现一次都是真实的模型调用费,一个疑难问题排查下来烧掉几十美元很平常。事后取证优先于反复复现,在这个领域不只是方法论偏好,是成本结构决定的。
把上面几条失效合起来看,会得到一个有用的等价转换:调试循环图,本质上是调试状态演化。你关心的每一个问题,模型为什么这么答、工具为什么没被调、哪一圈开始变慢,最终都要落到同一个更小的问题上:第 N 步时 state 里有什么,它是被哪个节点改成这样的。后面所有工具的取舍标准就这一条:能不能回答状态演化的问题。debug 流答得粗但实时,快照历史答得准但要查库,商业 trace 答得全但要钱,结构化日志介于两者之间。
承认这个根源还有一个实际好处:你不会继续浪费时间找一个「完美的 tracing」把循环图变回普通应用的形状。它不存在。务实的问题变成「环状执行下,哪些替代手段能回答我要的问题」,下面三层就是答案,按排查、测试、运行期组织。
排查层:三件现成的武器
排查针对的是已经出事的某一次运行,目标是还原现场。这件事和普通 web 应用有个时序上的差别:web 应用的现场(请求日志、堆栈)通常在出错瞬间就落了盘,而图运行的现场有一半在数据库的快照里,一半在运行进程的内存里,进程一重启,内存那一半就没了。所以排查的第一原则是「先取现场,再动系统」:重启、扩容、改配置这些动作放后面,快照和日志先落袋。三件工具按成本从低到高排。
debug 流。 stream_mode="debug" 输出运行时最详细的事件流:
# 排查时短开,平时不开:长对话里事件量很可观
for event in app.stream(inputs, config, stream_mode="debug"):
print(event) # 事件里带类型、任务与写入详情
任务调度、状态写入、中断各有一条事件。配合 tasks 相关的模式,能看清并行分支里具体哪个任务卡住:多分支的 superstep 里两条分支一快一慢,普通日志混在一起分不清归属,事件流里有任务标识可以拆开。它的定位类似 SQL 里的 EXPLAIN:排查时开,平时不开。生产上长期全量开不现实,事件量跟步数和分支数相乘,认真算过账再决定要不要采样开。七种流模式的完整对照在第 10 篇,这里只取 debug 这一种的排查用法。
快照历史。 出过事的 thread 用 get_state_history 做事后取证:
# 每张快照都有完整状态和「下一步要跑什么」
for snap in app.get_state_history(config):
print(snap.next, list(snap.values))
这是循环图调试里最被低估的一件。它回答的正是 span 树回答不了的问题:执行到每一步时 state 长什么样、接下来要进哪个节点。第 03 篇讲过,checkpointer 把每个边界都存了下来,所以出错的现场被持久化保留着,不用复现,不用靠猜。
它有两个前提要在工程上先保证。第一,图挂了 checkpointer,且生产用的是持久化实现,内存版的快照随进程消失,等于没有。第二,出事的时候你得知道查哪个 thread。这件事靠约定:thread_id 和业务单据号的绑定关系写进日志和告警,告警里永远带上 thread_id,否则值班的人面对的不是一个坏掉的运行,而是几万个运行里的一根针。
还有一件事现在不想,出事时会踩:快照有保留期。第 13 篇讲过 checkpoint 表的清理策略和 TTL,如果清理窗口设得太短,三天后才开始查的问题,现场已经被清掉了。把「排障需要的最短回看窗口」当成保留期设置的下限,和表膨胀的治理目标放在一起权衡,两边是拉扯关系,拍脑袋定任何一边都会在另一边出事。
拿它走一遍开头那个案例,感受一下排查的实际样子。第一步,拿告警里的 thread_id 拉快照历史,从最新往回看,每张快照只扫两个东西:next 指向哪个节点、关键业务字段的值。第二步,找到最后一个「还正常」的边界,比如前四张快照里 quotes 字段都是七个元素的列表,第五张变成了空列表。第三步,嫌疑人锁定:产生第五张快照的那次节点执行,也就是把 quotes 覆盖成空的那个节点。第四步,把第五张快照的 state 原样复制出来,写成一个单测的输入,问题从此可以在本地毫秒级复现。整个过程没有碰过线上环境,也不需要等用户配合重现。它快的原因值得点破:每一步都在缩小范围(哪一步、哪个字段、哪个节点),没有一步在猜。习惯了对着树形 trace 猜的团队,切到这种「按边界二分」的思路,磨合期主要花在相信数据而不是相信直觉上。
Studio 与 LangSmith。 Studio 的图视图加时间旅行,适合本地开发期拖动回放,肉眼看着状态在图上一步步流动;LangSmith 把每步 trace、输入输出、token 计费都记下来,是目前体验最完整的方案。它的位置要说清楚:商业 SaaS,免费额度有限,生产全量接要算钱。不买的替代路径是自建 OpenTelemetry 上报(1.x 支持 telemetry 导出),或者退回 debug 流加结构化日志,能覆盖大部分排查需求,损失的是检索体验和跨运行聚合。社区里「没有 LangSmith 活不下去」的说法,反映的是默认可观测确实薄,不是必须买。
自己补一层结构化日志。 无论买不买商业方案,节点里打结构化日志都值得做成约定。一条合格的节点日志至少有四个字段:thread_id、节点名、步数、state 摘要。摘要不用全量,放关键业务字段和你怀疑的字段就够了;拿不准就放字段名列表加值的类型,它至少能告诉你「字段在但变空了」和「字段压根没出现」这两种完全不同的病。这样即使日志系统里只有平铺的行,按 thread_id 一过滤,也能看到一条粗粒度的状态演化线。它和快照历史的关系是互补:快照在数据库里,查它要跑脚本;日志在采集系统里,值班的人顺手就能看。出事的第一分钟,先看日志线的形状,再决定要不要拉快照。
有一个安全上的注意别省:日志里的 state 摘要会跟着日志走一整条采集链路,state 里的敏感字段(用户凭证、个人数据)进日志等于复制了一份出去。第 16 篇的原则在这里同样适用:摘要先脱敏,字段白名单放在一个公共函数里统一维护,别让每个节点自己拼。
三层合起来,一次生产问题的标准排查路径是:拿 thread_id 拉快照历史缩小范围,本地开 debug 流复现那一小段,定位到节点后写一个单测把问题固化,修复后用同一张快照回归验证(第 15 篇的回放正好用在这里)。这条链路每一环都有免费工具,LangSmith 只是让它更顺。
测试层:把 LLM 从回路里拿掉
LLM 调用又慢又不确定:一次几百毫秒到几秒,输出还不稳定。直接测整张图等于买彩票,跑十次十个样,断言写不出来;就算勉强写出来,它也会在模型微调一次之后全线变红,红到没人再看,测试体系就名存实亡了。可行的思路是把不确定的部分隔离掉,分层测确定的部分。
- 节点单测。 节点是普通函数,输入 state 输出增量,直接调用断言:
def test_extract_amount():
state = {"messages": [("user", "退款 88 元")], "draft": {}}
out = extract_amount(state) # 直接调函数,不起图
assert out["draft"]["amount"] == 88
路由函数(条件边)同样直接测:构造几种 state,断言返回的分支名。这一层毫秒级,能覆盖绝大部分业务逻辑,是整个测试体系的基座。它对代码结构有一个隐含要求:节点函数里不要藏 IO 和全局状态,数据库连接、模型客户端从参数或依赖注入进来,否则「直接调用」就快不起来。这个要求顺手让节点更好维护,不算纯成本。
单测还有一条容易漏的断言:节点返回增量,不该原地改传入的 state。LangGraph 的合并机制建立在返回值上,谁在函数里偷偷改了入参,单节点测试能过,进图之后行为就说不清了。对策是测试里构造好输入后先存一份副本,调用后断言副本和入参相等。一行断言,把「增量语义」焊死在每个节点的测试里。
- 假模型集成测试。 用 fake model 返回预设的工具调用序列,图的拓扑、回环、状态合并都能测。替身可以自己写,十几行就够:
class ScriptedModel:
def __init__(self, script):
self.script = list(script) # 预设的工具调用剧本
def invoke(self, messages):
return self.script.pop(0) # 按剧本依次返回
# 第一轮让它调查询工具,第二轮让它直接收尾
fake = ScriptedModel([tool_call_msg("query_order"), final_answer_msg("已查到")])
LangChain 的测试工具里也有 GenericFakeChatModel 这类现成替身,思路相同:把模型的不确定性换成剧本的确定性。这一层的价值在测「编排」本身:分支走向对不对、循环会不会终止、并行合并后的 state 对不对,这些恰恰是节点单测盖不住的部分。断言抓三样就够:state 的最终形状、工具被调用的顺序、运行是否在预期步数内终止。测循环终止特别值得:把剧本写成永远要工具的模型,断言图在步数上限处优雅退出而不是报错,运行层要的降级行为就这样顺便被测住了。
- 少量真实冒烟。 十几条核心场景用真模型,定时跑而不是每次提交跑,容忍偶发失败但盯趋势。真模型的不确定性决定了它只能当冒烟:某天通过率从 95% 掉到 70%,比任何单次失败都值得警觉,那通常意味着模型侧或上游依赖变了,你的代码一行没动。
三层之外还有半层:工具本身要有自己的测试。工具函数是普通代码,连着真实后端,在预发环境里用真实依赖测它的契约(给定参数返回什么形状、后端拒绝时抛什么),比在图里隔着模型测它便宜得多。图这边的假模型替掉了模型的随机性,工具这边的契约测试替掉了后端的波动,两个确定性叠起来,图集成测试才稳。
节点单测的投入顺序也有讲究。优先测三类节点:路由函数(一个错分支会让整张图白跑)、做算术和解析的节点(金额计算、日期解析、参数抽取,错值会顺着 state 传很远)、以及所有在回环里的节点(每多一圈,错误放大一次)。最后才是薄封装节点,比如「把两个字段拼成一段提示词」的节点,测它性价比很低,通常并入集成层顺带覆盖。
一个测试设计上的坑值得单独说:状态是逐节点变异的,节点 2 的 bug 常常到节点 5 才暴露。比如抽取节点把金额写成了字符串,它自己不出错,到几步之后做数值比较才炸,报错还指向比较的那个节点。只给下游节点写单测,怎么都复现不出来。对策是在单测里把「给定输入 state 的合法性」也断言上:
def summarize(state: S) -> dict:
# 入口先验输入,坏上游在这里就失败,不往下传
assert state.get("docs"), "上游必须先填充 docs"
...
节点入口先校验自己依赖的字段存在且类型正确,不合法直接失败。多几行断言,失败定位从查半天变成一眼。校验逻辑重了之后可以抽成共享函数,但「每个节点对自己的输入负责」这个原则不要让步。
运行层:优雅降级,别等递归爆炸
recursion_limit 默认 25,超限抛 GraphRecursionError。这个限制是安全网,防的是路由条件写错导致的死循环:条件边永远返回同一个分支,图就在两个节点之间转到天荒地老。安全网的问题在它兜底的方式:等它炸不是方案,用户看到的是 500,前 24 步花掉的 token 和已经做完的工作全部作废。
正确做法是让图自己知道还剩几步,提前走进降级分支:
def call_model(state: S) -> dict:
# 回环节点里自增,步数是 state 的一部分
return {"steps": state["steps"] + 1, "messages": [...]}
def route(state: S) -> str:
if state["steps"] >= MAX_STEPS - 1:
return "graceful_exit" # 留一步余量,带着已有结果收场
return "continue"
步数计数自己维护,在回环节点里自增。用 create_agent 时不用自己数,它的 state 自带 remaining_steps 字段,官方文档建议直接检查它,而不是捕获递归异常。检查的位置放在路由函数里集中做,不要散在每个节点里各写一份,否则改上限的时候要改到处。临时把上限调大也可以(invoke 的 config 里改 recursion_limit),但它只是延后问题:模型如果陷入绕圈,多给十步就多烧十步的钱,降级分支才是真正的出口。
值得把视角再拧一下:MAX_STEPS 不只是正确性护栏,也是成本上限。每一次回环都是一次模型调用,步数上限乘上单步 token,就是单次运行的最坏账单。按预算反推上限,比拍脑袋写个 25 更有依据;触发降级的信号也可以不止步数一种,工具连续失败 N 次、单次运行 token 累计超预算,都可以走同一个降级出口,错误分类和重试的完整框架在第 12 篇。
降级出口要有内容,这是最容易偷懒的地方。「出错了,请重试」在合格线以下:跑到这里已经花了真金白银的 token,拿到的部分结果就该还给用户。
def graceful_exit(state: S) -> dict:
# 有什么就整理什么,不返回一句空话
return {"messages": [("assistant",
f"已检索到 {len(state['docs'])} 份资料,综述未完成,先给出资料列表")]},
查到了三份资料但综述没写完,就把三份资料给出去,说明综述没完成;比对做了七家报价,就把七家列出来。降级节点拿到的是当前 state,里面有什么就整理什么。措辞上有一个小原则:说清「完成了什么、没完成什么、接下来怎么办」,比道歉管用,用户真正关心的是能不能拿着半成品继续干活。顺手把 thread 标记成待跟进,人工接手时还能从断点继续,这部分第 06 篇的 interrupt 机制直接可用。
这套运行期护栏有一个隐蔽的依赖:它建立在快照可用之上。步数计数在 state 里,降级要从当前 state 出发,出事后要能取证,全都要 checkpointer 正确落地。第 13 篇讲的那些运维事故(表膨胀、连接打满)一旦发生,最先失灵的就是这篇文章里的取证手段,两篇要当成一对来读。
一份可以照抄的排查手册
把前面三层压成一条固定动作序列,出事时照着走,不要临场发挥:
- 先固定「哪一次」:记下 thread_id 和当时的代码版本、模型版本。三个都对不上后面的分析就会串台,这是最容易被跳过也最常坑人的一步。
- 先看现象分类:用户报的是「慢」「错」还是「停住」,三种对应不同的第一站。
- 慢:拉快照历史看每步的时间间隔,找出变慢的那一圈;同时确认是不是工具侧慢,模型慢常常只是放大器。
- 错:拉快照历史,从最新往回找关键业务字段第一次变成坏值的边界,锁定产生它的节点。
- 停住:大概率在等人审(第 06 篇)或并行分支有一个任务未完成,开 debug 流看任务列表就能分辨。
- 本地复现:把出事边界的快照 state 复制成单测输入,验证坏值能在本地造出来。
- 修复,然后用同一张快照回归,确认修复没有改变其他字段的演化。
- 把这次的 state 样例沉淀进测试集,同类问题第二次出现时应该被单测拦住;手册本身如果缺了一类情况,顺手补上。
这套手册的价值不在每一步多聪明,在于它是固定的:值班的人不需要理解整张图也能走到第 6 步,把现场完整交到写代码的人手里。第 8 步是它和普通运维手册的区别:每次事故都让测试集和手册变厚一点,半年下来,新问题的占比会明显下降。
观测手段选型小结
| 手段 | 成本 | 回答的问题 | 局限 |
|---|---|---|---|
| debug 流 | 零,事件量大 | 此刻任务调度和写入的顺序 | 只在开的时段有数据 |
| 快照历史 | 零,占存储 | 第 N 步 state 是什么、下一步去哪 | 依赖 checkpointer 和保留期 |
| 结构化日志 | 低 | 粗粒度的状态演化线 | 摘要要脱敏,信息量靠自觉 |
| OTel 自建 | 中 | 跨运行的聚合与告警 | 环语义要自己约定 |
| LangSmith | 按用量付费 | 以上全部,加检索和计费 | 数据出环境,账单随量涨 |
| Studio 图视图 | 免费 | 拓扑长什么样、本地回放 | 验证拓扑,不验证行为 |
这张表的读法是从下往上排除:先确认自己要用它回答什么问题,再挑能回答它的最便宜一行。多数团队的终态是快照历史加结构化日志打底,商业方案按预算选择性加。选型时把第 17 篇的成本账一起看,观测的开销是部署成本的一部分,不是独立决定。
权衡
这套分层不是免费的,成本列出来:节点要写成能独立调用的纯函数,依赖不能藏在闭包里,构造 state 的测试代码要维护;假模型要跟着真实工具的返回格式更新,工具接口一变剧本就得改;降级分支是产品决策(给用户看什么),不是纯技术兜底,要和需求方对齐。换来的确定性也是实的:绝大多数失败在提交前被毫秒级单测拦住,漏网的失败有快照可取证,死循环降级成一次可预期的绕行而不是 500。反过来的账也要算:不做这套的团队不是省下了成本,是把成本挪到了出事的凌晨,挪到了「本地复现不出来」的悬案上,挪到每次发版前全员盯盘的焦虑里。
投入也可以按场景打折。内部工具、低频使用、错了重跑不花钱的,debug 流加日志加几条节点单测就够,假模型和降级分支可以先不建;对外服务、单次运行成本高、出错有真实损失的,三层都要拉满。判断的尺子还是第 01 篇那句:看任务形状,不看技术时髦。
另外提醒一句团队成本:图的可视化(Studio 的图视图)对沟通很有用,新同事十分钟就能看懂流程,但它验证的是拓扑,不是行为。图长得对不代表跑得对,路由条件里的一个差一错误在图上看不出来。拿图视图当评审材料可以,拿它当测试通过的证据不行。
最后是交接成本。这套手段分散在「跑脚本查快照、开流看事件、翻日志过滤」三个地方,新接手的人第一次遇到事故多半不知道从哪下手。把排查手册放进团队 wiki,把拉快照的脚本放进仓库,把「thread_id 进告警」写成 code review 的检查项,三件小事做完,这套能力才从「会的人很快」变成「谁值班都行」。观测体系的建设成本大头不在代码,在这些约定能不能活过三次人事变动。
延伸阅读
- Test 指南:官方测试建议
- Streaming 文档:debug 与 tasks 模式
- Use Time Travel 指南:快照取证的操作细节