LangGraph 与 ReAct Agent 调试技巧:从日志到可视化全解析
引言:为什么 Agent 最难的不是“写出来”,而是“知道它为什么错”?
很多人第一次做 ReAct Agent,都会有一种挫败感:
- 代码能跑,但结果不对
- Tool 明明定义了,Agent 却不调用
- Graph 明明连上了,却在某一步开始死循环
- 最终答案错了,但你根本不知道是 Prompt、Tool、State,还是模型出了问题
这和普通 CRUD 系统很不一样。
传统后端开发里,你通常面对的是:
输入 → 业务逻辑 → 输出而 Agent 的真实执行轨迹更像:
用户问题 → LLM 思考 → 选择 Tool → Tool 执行 → Observation 写回 State → LLM 再思考 → 决定继续还是结束也就是说,Agent 出错时,问题往往不在“最终答案”本身,而在它的执行路径。
它可能在第 1 步做对了,第 2 步偏了,第 3 步又把偏差放大,最后你只看到一个错误答案。
所以,真正的 Agent 调试,不是只看最终输出,而是要同时看:
- 每一步输入了什么
- 每一步走到了哪个节点
- 每一步 State 如何变化
- Tool 返回了什么
- Agent 为什么没有停下来
这也是 LangGraph 特别适合做复杂 Agent 的一个重要原因:
它不只是让你“能写 Agent”,还让你“能看见 Agent 是怎么跑的”。
这篇文章就专门解决这个问题。我们会从最基础的print/logging开始,一直讲到:
stream_mode=["debug", "updates", "values"]graph.get_graph().draw_mermaid_png()interrupt_before/interrupt_after- time-travel 回放
- 一个失败的 ReAct Agent 实战排障
- 以及 Java / j-langchain 与 LangGraph 的调试差异
如果你已经会写简单 Agent,这篇文章会帮你从“能跑”进入“能排障”。
一、为什么 ReAct Agent 特别需要“过程级调试”?
ReAct 的核心不是一次性回答,而是:
边思考、边行动、边观察。
这意味着,一个 ReAct Agent 至少包含四类状态:
- 用户原始问题
- LLM 当前的推理结果
- Tool 调用结果
- 下一步路由决策
任何一个环节出问题,最终答案都会偏。
例如:
- Tool 选错 → 后面全错
- Observation 没写回 messages → 模型下一轮相当于“没看到工具结果”
- State 字段名错了 → 节点之间信息断裂
- 停止条件没触发 → 进入死循环
所以,调试 ReAct Agent 的关键不是问:
“为什么答案错了?”
而是问:
“它从哪一步开始偏了?”
一旦你习惯了这种思路,Agent 调试就会比之前清晰很多。
二、第一层调试:先用最朴素的方法把每一步打出来
很多人一上来就想找可视化平台、Tracing 平台、LangSmith。
这些工具当然很好,但真正排障时,最先能救命的,往往是最简单的三件事:
printverboselogging
1.print:先确认节点到底有没有执行
例如你写了一个 LangGraph 节点:
fromtypingimportTypedDictclassAgentState(TypedDict):question:strweather:stranswer:strdefweather_node(state:AgentState):print("[weather_node] input:",state)result="北京今天晴天,25度"print("[weather_node] output:",result)return{"weather":result}这种写法虽然原始,但它特别适合回答最基础的问题:
- 节点有没有真正执行
- 传进来的 State 到底长什么样
- 节点返回的字段是不是你以为的那个字段
很多问题在这一步就能发现。
例如你原本以为state["question"]一定存在,结果打印出来根本没有;或者你以为自己返回的是{"weather": ...},结果实际上返回了{"result": ...},后面节点当然读不到。
2.verbose:先把 ReAct 的轨迹暴露出来
如果你还在用 LangChain 的 agent 封装,而不是完全手写 Graph,那一定先打开:
verbose=True它最大的价值不是“多打印一点日志”,而是让你看到 Agent 的执行轨迹,例如:
Thought: 我需要先查询天气 Action: get_weather Action Input: 北京 Observation: 北京今天晴天,25度 Thought: 现在我可以计算昨天的温度 Action: calculator Action Input: 25-3 Observation: 22 Final Answer: 昨天是22度一旦这条链露出来,你就能立刻判断:
- 模型有没有调用 Tool
- 调的是不是正确的 Tool
- Observation 是否合理
- 最终答案是不是基于 Observation 得出的
3.logging:从 demo 走向工程化的最低要求
当节点越来越多以后,只靠print很快会乱掉。
更稳妥的方式是统一用logging:
importlogging logging.basicConfig(level=logging.INFO)logger=logging.getLogger(__name__)defcalculator_node(state):logger.info("calculator_node input=%s",state)result={"answer":"22"}logger.info("calculator_node output=%s",result)returnresult这样做的好处是:
- 后面接日志采集平台更方便
- 可以按级别区分 info / warning / error
- 方便定位是哪一个 node 出的问题
一句话说:
logging适合长期维护。
三、LangGraph 内置调试:stream_mode=["debug", "updates", "values"]
当你的 Agent 进入 LangGraph 这一层后,只靠print已经不够了。
因为你真正想知道的是:
Graph 在每一步到底改了什么?
当前完整 State 长什么样?
它到底是怎么沿着边走的?
这时候,LangGraph 的stream_mode就非常关键。
1.stream_mode="updates":看每一步改了什么
这是最适合排查“节点返回值不对”的模式。
forchunkingraph.stream({"question":"北京天气怎么样?"},stream_mode="updates"):print(chunk)你看到的通常像这样:
{"weather_node":{"weather":"北京今天晴天,25度"}}{"answer_node":{"answer":"北京今天晴天,25度。"}}它回答的是:
每个节点,刚刚往 State 里写了什么?
如果你预期tool_node应该写回messages,结果这里根本没有messages,问题就已经很清楚了。
2.stream_mode="values":看每一步之后完整 State 长什么样
如果updates像看 diff,那么values就像看快照。
forchunkingraph.stream({"question":"北京天气怎么样?"},stream_mode="values"):print(chunk)这时候你看到的是完整 State,例如:
{"question":"北京天气怎么样?","weather":"北京今天晴天,25度","answer":""}下一步又变成:
{"question":"北京天气怎么样?","weather":"北京今天晴天,25度","answer":"北京今天晴天,25度。"}它特别适合排查:
- 某个字段是不是被覆盖掉了
- 某一步是不是把旧数据清空了
- 多个节点是不是在写同一个字段
3.stream_mode="debug":看更细的执行轨迹
当你已经知道“结果不对”,但还不知道“到底从哪一步开始偏”,就要上debug:
forchunkingraph.stream({"question":"北京天气怎么样?"},stream_mode="debug"):print(chunk)debug模式的价值在于:
- 能更细粒度地看到当前运行的是哪个节点
- 更接近真实的执行轨迹
- 更容易理解 Graph 的控制流
尤其在 ReAct Agent 里,debug模式非常适合观察:
LLM 节点 → Tool 节点 → 再回 LLM 节点这条循环到底有没有按预期发生。
4. 三种模式怎么选?
最实用的顺序是:
先用 updates 看“写了什么” 再用 values 看“全局状态还好吗” 最后用 debug 看“图到底怎么走的”这样排查起来最快。
四、可视化工具:graph.get_graph().draw_mermaid_png()把图画出来
很多 LangGraph 问题,根本不是节点代码问题,而是图连错了。
例如:
- 明明应该
llm_call → tool_node - 结果却直接
llm_call → END - 或者
tool_node → tool_node - 或者条件边永远走同一个分支
这时候,最有效的方法不是继续读代码,而是先把图画出来。
最常见写法
fromIPython.displayimportImage,display display(Image(graph.get_graph().draw_mermaid_png()))如果你在 Notebook 环境里,这张图会直接显示出来。
如果你想看得更细,也可以打开 xray:
display(Image(graph.get_graph(xray=True).draw_mermaid_png()))为什么流程图这么重要?
因为它直接帮你看清三件事:
- 节点有没有连对
- 循环是不是合理
- 条件路由是不是按预期存在
例如你原本想做一个最基本的 ReAct:
llm_call → tool_node → llm_call → END结果画出来一看居然是:
llm_call → END那问题根本就不是 Prompt,而是边没有连上。
对于复杂 Graph 来说,流程图几乎就是最便宜、最快速的排错方法。
五、中断调试:interrupt_before/interrupt_after
当你已经能看到日志、能看到 State、能看到图,下一步最有价值的能力就是:
在关键节点前后“停下来”。
这就是中断调试。
1. 为什么中断很重要?
因为很多问题不是“最后错了”,而是:
在某一步,参数就已经错了。
例如:
- Tool 调用前,参数提取错了
- Tool 返回后,Observation 没写回 State
- 路由决策前,某个标志位不对
如果你只能看最终日志,你往往只能猜。
而如果你能在节点前后停下来,就能直接看现场。
2.interrupt_before
顾名思义,就是在某个节点执行前停住。
特别适合:
- 想确认进入这个节点前的 State 是否正确
- 想检查 Tool 调用参数是否已经准备好
- 想看分支路由之前的判断依据
3.interrupt_after
就是在某个节点执行后停住。
特别适合:
- 想确认节点执行结果写回了什么
- 想看 Tool 的 Observation 有没有落到正确字段
- 想看某个节点是不是覆盖了旧 State
一句话说:
interrupt_before看输入,interrupt_after看输出。
六、时间旅行(time-travel)与回放:不是炫技,而是最强复盘工具
当你已经有 checkpoint / persistence 后,LangGraph 一个非常强大的能力就是:
time-travel
你可以把它理解成:
- 回到过去某个执行点
- 从那里重新跑
- 或者改一点 State,再从那里分叉重跑
这对 ReAct Agent 来说特别有价值。
因为很多 Agent 失败不是偶发,而是:
某一步开始偏了,后面一路错到底。
如果你只能从头重跑,每次都要把整个输入重新构造一遍,非常低效。
而有了 time-travel 以后,你可以:
- 回到出问题前的 checkpoint
- 重新执行后续节点
- 验证修复有没有生效
它最适合什么场景?
特别适合:
- 线上失败复盘
- 修完 bug 后验证是否真的解决
- 比较两个 Prompt 改动是否影响轨迹
- 尝试不同分支,而不是每次从头跑
对 Agent 来说,这比普通日志强很多,因为它不是“回忆发生了什么”,而是:
把失败现场真正还原出来。
七、实战:一步步调试一个失败的 ReAct Agent
下面做一个最典型的失败案例。
目标问题是:
北京今天 25 度,比昨天高 3 度,那昨天多少度?理论上,正确轨迹应该是:
1. 调 get_weather 2. 得到 25 度 3. 调 calculator(25-3) 4. 输出 22 度但现在 Agent 给出的结果却是:
昨天可能是 20 到 22 度左右。明显在猜。
第一步:先看它有没有调用 Tool
先打开verbose=True或在 Graph 相关节点里加print。
日志显示:
Thought: 我可以直接估算昨天温度。 Final Answer: 昨天可能是20到22度左右。这说明第一步问题已经非常明确:
模型根本没调 Tool。
第二步:检查 Tool 定义是否清楚
你再去看工具代码:
@tooldefweather(x:str)->str:"""获取信息"""问题马上暴露了:
- 工具名太模糊
- 描述太模糊
于是改成:
@tooldefget_weather(city:str)->str:"""查询指定城市今天的天气和温度"""然后再跑。
这次它开始调用 Tool 了,但结果还是错。
第三步:用stream_mode="updates"看每步更新
forchunkingraph.stream(input_data,stream_mode="updates"):print(chunk)结果你发现工具节点只写回了:
{"weather":"北京今天晴天,25度"}但你的 LLM 节点并没有读weather字段,而是只读messages。
问题就清楚了:
Tool 虽然执行了,但 Observation 没进入下一轮上下文。
第四步:用stream_mode="values"看完整 State
继续看完整状态:
forchunkingraph.stream(input_data,stream_mode="values"):print(chunk)你会看到:
weather字段有值messages没追加 ToolMessage
这说明错不在模型,而在 Tool 节点返回值设计。
第五步:把图画出来,确认边没连错
这时你再画一下流程图:
display(Image(graph.get_graph().draw_mermaid_png()))图显示:
llm_call → tool_node → llm_call → END说明边本身没问题。
所以问题进一步收敛为:
node 逻辑没把 Observation 正确写回 State。
第六步:在tool_node后中断
这时候最有效的做法,就是interrupt_after=["tool_node"]。
停下来一看,当前 State 是:
{"messages":[...原始对话...],"weather":"北京今天晴天,25度"}但没有 ToolMessage。
于是你修改工具节点:
return{"messages":state["messages"]+[tool_message]}第七步:用 time-travel 回到失败现场再跑一次
现在不需要从头重新构造整个输入。
直接回到工具节点前的 checkpoint,再 replay。
这次日志变成:
Action: get_weather Observation: 北京今天晴天,25度 Action: calculator Observation: 22 Final Answer: 昨天是22度。到这里,问题才真正修复。
这个案例最重要的不是代码,而是顺序
这套顺序非常值得记住:
先看有没有调 Tool → 再看 Tool 定义 → 再看 updates → 再看 values → 再看 graph → 再 interrupt → 最后 replay 验证这就是调试 ReAct Agent 最有效的一条路径。
八、Python / LangGraph 与 Java / j-langchain 的调试差异
这一部分非常值得写,因为它能让 Java 工程师马上理解:
为什么 LangGraph 的调试体验和 j-langchain 不一样。
1. j-langchain 更像“链路事件调试”
在 j-langchain 里,你更常见的调试方式是:
- 看
streamEvent()返回的事件流 - 看
on_chain_start - 看
on_chain_end - 看 Prompt、LLM、Parser 的输入输出
这种方式非常像 Java 后端熟悉的:
- 责任链
- Filter
- Interceptor
- AOP 日志
也就是说,j-langchain 更适合回答:
这一条 AI 链上,每个组件做了什么?
对于:
- Prompt → LLM → Parser
- 简单 Tool 调用
- 线性工作流
它的调试体验非常顺手。
2. LangGraph 更像“状态机 / 图执行调试”
而 LangGraph 不只是看“经过了哪些节点”,它更强调:
- 当前 State 是什么
- 走的是哪条边
- 为什么进入这个节点
- checkpoint 存在哪里
- 能不能回到中间再跑一次
也就是说,LangGraph 更适合回答:
这个 Agent 为什么会沿着这条路径走?
这对于:
- ReAct
- 多 Tool 循环
- 条件路由
- 中断恢复
- time-travel 回放
特别重要。
3. 两者最本质的差别
一句话概括:
j-langchain 更像“调用链调试”;
LangGraph 更像“状态机调试”。
所以如果你做的是:
- Java 后端里的简单 AI 链
- Prompt → 模型 → Parser
- 少量 Tool
j-langchain 的事件流就已经很好用。
但如果你做的是:
- ReAct
- 多工具循环
- 中断与恢复
- 多分支状态流
- 可回放的 Agent
那 LangGraph 的调试能力会明显更强。
这不是谁更先进,而是它们处理的问题层级不同。
九、最实用的一套 LangGraph 调试顺序
如果你只想记住一套最实用的方法,那就记下面这套:
1. 先确认模型和 Tool 单独可用 2. 用 print / logging 看节点输入输出 3. 用 stream_mode="updates" 看每步改了什么 4. 用 stream_mode="values" 看完整 State 有没有坏 5. 用 stream_mode="debug" 看执行轨迹 6. 用 draw_mermaid_png() 看图是不是连错了 7. 用 interrupt_before / interrupt_after 卡住关键节点 8. 用 checkpoint + time-travel 回放失败现场这套顺序的好处是:
- 从最简单的方法开始
- 每一步都回答更具体的问题
- 不会一开始就陷入复杂工具
- 真遇到复杂 bug,也有更强手段接上
很多 Agent bug,用不到 time-travel 就能解决;
但真正复杂的 bug,没有 time-travel 又很难彻底定位。
所以它们不是替代关系,而是层层递进的关系。
十、给工程师的调试建议
最后给你几条非常实用的经验。
1. 不要一上来就改 Prompt
很多人结果一错,第一反应就是改 Prompt。
但真正的问题,可能根本不在 Prompt,而在:
- Tool 名字写错
- Observation 没写回 messages
- 边连错了
- State 字段丢了
所以排查一定要先看轨迹,再改 Prompt。
2. 先确认 Tool 能单独运行
不要把模型、Graph、Tool 全部绑在一起调。
先单独运行 Tool:
print(get_weather.invoke({"city":"北京"}))如果 Tool 自己都不稳定,后面调 Graph 只会更乱。
3. 对 Agent 来说,State 设计和日志一样重要
很多人把全部精力放在 Prompt 上,却忽略了 State 设计。
实际上,LangGraph 里最常见的问题之一就是:
State 定义不清,导致节点之间信息断裂。
所以每个字段最好都明确:
- 谁负责写
- 谁负责读
- 是否允许覆盖
4. 调试时,temperature 尽量设低
如果你在调试阶段还把 temperature 设得很高,轨迹会很不稳定。
建议调试期尽量:
temperature=0这样更容易复现问题。
结语
一句话总结:
调试 Agent,最怕的不是报错,而是“看起来能跑,但你不知道它为什么这么跑”。
LangGraph 真正强大的地方,不只是能写 Agent,而是它提供了一整套把 Agent 执行过程掰开看的能力:
print/logging:看节点输入输出stream_mode="updates":看每一步更新stream_mode="values":看完整 State 快照stream_mode="debug":看执行细节draw_mermaid_png():看图结构interrupt_before/interrupt_after:在关键点停下来- time-travel:回到失败现场回放
当你把这些能力串起来以后,ReAct Agent 就不再是一个黑盒。
它会变成一个你可以:
- 看见
- 暂停
- 回放
- 修改
- 再验证
的可调试系统。
而这,正是 Agent 从 Demo 走向生产级系统的关键一步。
📎 相关资源
- j-langchain GitHub:https://github.com/flower-trees/j-langchain
