1. 本章目标 #
第 31 章读的是「标准结构」的 Trace:模型步、工具步、几轮循环。真实项目里的图要复杂得多——挂着中间件、卡着人工审批、套着子 Agent、还可能是流式输出。这些东西在 Trace 里长什么样,官方文档基本没讲。
这一章把它们逐个拆开看,并给出一份定位清单:遇到某类症状,该去 Trace 的哪个位置找。
学完你应能:
- 说清
print调试和 Trace 各自的强项,知道什么时候该用哪个(不是「Trace 全面胜出」) - 认出中间件在 Trace 里的节点名规律,判断某个钩子有没有被触发
- 看懂人工审批(HITL)产生的 Trace:为什么是两条独立 Trace、为什么中断点看起来像报错
- 避开一个高频误判:
status='interrupted'不是失败,但用常规方式查会把它当失败捞出来 - 确认流式和非流式产生的 Trace 完全一致
- 在嵌套 Agent 的树里分清哪些 span 属于外层、哪些属于内层
- 拿到一份按症状索引的排查清单
前置依赖: 第 31 章(读 Trace 的顺序)、第 10 章(中间件)、第 26 章(checkpointer 与 interrupt)。第 28 章的审批流是本章 §5 的原型。
参考文档:
1.1 本章统一环境 #
langchain 1.3.18 / langchain-core 1.6.1 / langgraph 1.2.11 / langsmith 0.12.1
模型 deepseek-v4-flash2. print 和 Trace 不是替代关系 #
先纠正一个印象。第 29 章说 print 有四个失效场景,但那不代表 Trace 处处更强。两者的分工其实很清晰:
print |
Trace | |
|---|---|---|
| 看到结果要多久 | 立刻 | 几秒到十几秒(异步上传) |
| 能看已经发生的事吗 | ✗ 只能看之后的 | ✓ 历史都在 |
| 改代码才能加吗 | ✗ 要改 | ✓ 不用改 |
| 长内容 | ✗ 刷屏 | ✓ 结构化折叠 |
| 层级关系 | ✗ 平铺 | ✓ 树 |
| 循环里的中间变量 | ✓ 想打什么打什么 | ✗ 只有节点边界 |
| 断点式交互 | ✓ 配合 pdb | ✗ 只读 |
| 离线 / 无网络 | ✓ | ✗ |
关键差别在最后三行。 Trace 记录的是节点边界上的输入输出——它看不到你函数内部第 17 行那个变量的值。
一个具体例子。假设 search_kb 里有分词逻辑:
@tool
def search_kb(keyword: str) -> str:
"""检索知识库。"""
tokens = tokenize(keyword) # ← Trace 看不到 tokens
expanded = expand_synonyms(tokens) # ← 也看不到 expanded
hits = [KB[k] for k in expanded if k in KB]
return "\n".join(hits)Trace 只能告诉你「传进去 '退换货',返回了 ''」。中间的 tokens 和 expanded 是黑的。想看它们,要么加 print,要么——更好的办法——用第 30 章的 @traceable 把它们提升成 Run:
from langsmith import traceable
@traceable(run_type="tool") # 让分词这一步在 Trace 里可见
def tokenize(keyword: str) -> list[str]:
...
@traceable(run_type="tool") # 同义词扩展也可见
def expand_synonyms(tokens: list[str]) -> list[str]:
...改完之后树上会多两层,search_kb 内部就不再是黑盒了。
实用建议: 开发时用
@traceable了——一次性投入,之后线上线下都能看。
3. 中间件在 Trace 里长什么样 #
中间件(第 10 章)是插在模型调用前后的钩子。它们会不会出现在 Trace 里?答案是会,而且是独立的 span。
3.1 一个只记时间的中间件 #
from langchain.agents.middleware import AgentMiddleware
class TimingMiddleware(AgentMiddleware):
"""什么都不改,只在钩子里打印一行。"""
name = "TimingMiddleware"
def before_model(self, state, runtime=None):
print(f"[middleware] before_model,消息数={len(state['messages'])}")
return None # 返回 None = 不修改 state
def after_model(self, state, runtime=None):
print(f"[middleware] after_model,消息数={len(state['messages'])}")
return None
agent = create_agent(
model="deepseek:deepseek-v4-flash",
tools=[get_order],
system_prompt="你是客服。",
middleware=[TimingMiddleware()],
)本地打印:
[middleware] before_model 触发,消息数=1
[middleware] after_model 触发,消息数=2
[middleware] before_model 触发,消息数=3
[middleware] after_model 触发,消息数=4Trace 里:
[chain ] 带middleware 2.521s 955tok
[chain ] TimingMiddleware.before_model 0.060s
[chain ] model 0.872s 422tok
[llm ] ChatDeepSeek 0.869s 422tok
[chain ] TimingMiddleware.after_model 0.000s
[chain ] tools 0.202s
[tool ] get_order 0.201s
[chain ] TimingMiddleware.before_model 0.000s
[chain ] model 1.247s 533tok
[llm ] ChatDeepSeek 1.246s 533tok
[chain ] TimingMiddleware.after_model 0.001s3.2 三条规律 #
① 节点名的格式是 <中间件的 name>.<钩子名>。
TimingMiddleware.before_model
TimingMiddleware.after_model
HumanInTheLoopMiddleware.after_model这意味着你能直接从 Trace 上确认某个中间件有没有生效。第 50 章讲过中间件替换的坑(.name 不匹配会变成追加而不是替换),在 Trace 里一眼就能看出来——如果树上同时出现两个同名钩子,就是重复挂载了。
② 它们和 model 是平级的,不是包在外面。
注意 TimingMiddleware.before_model 和 model 是兄弟节点,不是父子。这符合直觉:钩子是「模型调用之前跑的一段」,不是「包裹模型调用」。
③ 耗时基本是 0,但第一次除外。
第一次 before_model: 0.060s
后面几次: 0.000s / 0.001s0.06 秒是首次调用的初始化开销。如果你看到某个钩子每次都要几十上百毫秒,那它里面有真活(比如查数据库、调外部 API),这在高频路径上是要优化的。
3.3 排查「中间件没生效」 #
这是中间件相关问题里最常见的一类。Trace 给了三个层次的答案:
| Trace 上的现象 | 结论 |
|---|---|
完全没有 XxxMiddleware.* 节点 |
中间件没挂上——检查 middleware=[...] 有没有传对 |
| 有节点,但耗时 0 且 state 没变化 | 钩子跑了,但逻辑没进条件分支 |
| 有节点,state 变了,但下游行为不对 | 钩子生效了,问题在它改的内容上 |
第一种最常见,而且本地 print 也发现不了——因为你的 print 就在没被调用的那个钩子里。
4. 人工审批(HITL):本章最容易踩的坑 #
HITL(第 26 章、第 48 章)会让执行暂停等人。这在 Trace 里的呈现有两个反直觉的地方。
4.1 一次审批 = 两条独立的 Trace #
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
agent = create_agent(
model="deepseek:deepseek-v4-flash",
tools=[refund, get_order],
system_prompt="你是客服,可以退款。",
# refund 这个工具执行前要人工批准
middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund": True})],
checkpointer=InMemorySaver(), # HITL 必须有 checkpointer
)
cfg = {"configurable": {"thread_id": "t-hitl-1"}}
# 第一段:跑到 refund 之前停下
out = agent.invoke(
{"messages": [{"role": "user", "content": "订单 A1 给我退 199 元"}]}, cfg)
print("产生中断:", out.get("__interrupt__") is not None) # True
# 第二段:人工批准,从断点继续
out2 = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}), cfg)
print("恢复后:", out2["messages"][-1].content)两次 invoke,Trace 里就是两条根 Run,不会合并成一条:
── 第一段 ──
[chain ] HITL-第一段 2.672s 1247tok
[chain ] model 1.364s 580tok
[llm ] ChatDeepSeek 1.362s 580tok
[chain ] HumanInTheLoopMiddleware.after_model 0.001s
[chain ] tools 0.202s
[tool ] get_order 0.201s
[chain ] model 1.081s 667tok
[llm ] ChatDeepSeek 1.080s 667tok
[chain ] HumanInTheLoopMiddleware.after_model 0.020s ← 中断在这
── 第二段 ──
[chain ] HITL-第二段 0.640s 721tok
[chain ] HumanInTheLoopMiddleware.after_model 0.000s
[chain ] tools 0.002s
[tool ] refund 0.001s ← 批准后才执行
[chain ] model 0.634s 721tok
[llm ] ChatDeepSeek 0.633s 721tok
[chain ] HumanInTheLoopMiddleware.after_model 0.001s对比两段可以确认一件事:refund 只在第二段出现。 这印证了第 48 章的结论——中断发生在工具执行之前,批准前工具压根没跑。
两段的 inputs 也完全不同:
第一段 inputs: {'messages': [{'content': '订单 A1 给我退 199 元', 'role': 'user'}]}
第二段 inputs: {'input': {'goto': [], 'graph': None,
'resume': {'decisions': [{'type': 'approve'}]},
'update': None}}第二段的 inputs 里看不到用户原始的问题,只有 resume 的决定。想知道这次审批对应的是哪个请求,得靠 thread_id(在 metadata 里)把两条 Trace 关联起来。
实践建议: 给两段打同一个业务标识,比如
metadata={"request_id": "req-8848"}。否则线上排查时你会面对一堆孤零零的「第二段」,不知道它们各自在批准什么。
4.2 中断点的 status 是第四种值 #
这是本章最重要的一个发现。展开那个中断节点:
节点: HumanInTheLoopMiddleware.after_model
status: interrupted ← 不是 error,也不是 success
error 字段(514 字符):
GraphInterrupt((Interrupt(value={'action_requests': [{'name': 'refund',
'args': {'order_id': 'A1', 'amount': 199.0}, ...
File "…\langgraph\_internal\_runnable.py", line 707, in invoke
File "…\langchain\agents\middleware\human_in_the_loop.py", …
↑ 父节点 HITL-第一段 status=success三件事一起发生了:
① status 是 'interrupted'。 第 29 章 §5.3 列了 success / error / pending,这是第四种。它语义上既不是成功也不是失败,是「等人」。
② error 字段里有内容,但那不是错误。 里面装的是 GraphInterrupt 异常和它的 traceback。LangGraph 的中断是用异常机制实现的(抛一个 GraphInterrupt 让执行栈退出),所以 SDK 忠实地把它记进了 error 字段。
这个字段其实非常有用——GraphInterrupt 的 value 里包含完整的 action_requests,也就是「模型想干什么」:
{'action_requests': [{'name': 'refund',
'args': {'order_id': 'A1', 'amount': 199.0}}]}审计「谁批准了什么」时,这是唯一的记录来源。
③ 父节点是 success。 中断没有向上冒泡成失败。从整个请求的角度看,「停下来等人」是正常结束。
4.3 由此产生的误判 #
第 31 章 §7.1 给了一条查失败请求的过滤式:
filter='and(eq(status, "error"), eq(is_root, true))'这条不会捞出 HITL 中断,因为中断的 status 是 interrupted 不是 error,而且它不在根 Run 上。看起来没问题。
但下面这两种写法就会出事:
# ✗ 会把「等待审批」当成故障
bad = [r for r in runs if r.error is not None]
# ✗ 同上,只看 error 字段非空
bad = client.list_runs(project_name=..., filter='neq(error, null)')只要用「error 字段非空」判断失败,HITL 的中断点就会混进来。 在一个审批频繁的系统里,这会让你的故障率统计虚高好几倍——每一次正常审批都被记了一笔。
正确的写法是显式排除 interrupted:
# ✓ 按 status 判断,而不是按 error 字段是否为空
bad = [r for r in runs if r.status == "error"]
# ✓ 服务端过滤同理
runs = client.list_runs(project_name="…", filter='eq(status, "error")')顺便,想专门捞出等待审批的记录(做审批看板)就反过来:
pending = client.list_runs(project_name="…", filter='eq(status, "interrupted")')4.4 HITL 排查清单 #
| 症状 | 去 Trace 看什么 |
|---|---|
| 该拦的没拦住 | 树上有没有 HumanInTheLoopMiddleware.* 节点;有没有 status=interrupted |
| 拦了但恢复不了 | 第二段 Trace 的 inputs.resume 结构对不对(第 48 章:必须是 {"decisions": [...]}) |
| 批准了工具却没执行 | 第二段树里有没有那个 tool 节点 |
| 不知道当时批准的是什么 | 中断节点 error 字段里的 action_requests |
| 故障率统计虚高 | 检查是不是用 error != null 判断的失败(§4.3) |
5. 流式:Trace 和 invoke 完全一样 #
一个常见疑问:用 stream() 输出,Trace 会不会变成一堆碎片?
实测。同一个 Agent,一次 invoke、一次 stream(stream_mode="messages"):
chunks = 0
for _ in agent.stream({"messages": [{"role": "user", "content": "订单 A2 呢"}]},
config={"run_name": "流式调用"},
stream_mode="messages"):
chunks += 1
print(f"流式收到 {chunks} 个片段") # 112本地收到 112 个片段,Trace 里:
[chain ] 流式调用 1.934s 943tok
[chain ] model 0.856s 420tok
[llm ] ChatDeepSeek 0.854s 420tok
[chain ] tools 0.202s
[tool ] get_order 0.201s
[chain ] model 0.873s 523tok
[llm ] ChatDeepSeek 0.872s 523tok
共 7 个 span7 个 span,和 invoke 一模一样。 那 112 个 token 片段被 SDK 聚合成了完整消息才上报。
这个结论有两层含义:
- 好消息: 你不用为流式单独做什么,Trace 照常可读。
- 要注意的: Trace 里看不到流式特有的问题。首 token 延迟、片段乱序、中途断流——这些在聚合后的 Trace 里全都消失了。第 27 章那些流式坑,得靠客户端埋点,不能指望 Trace。
一个折中做法:在客户端记录首 token 时间,通过 metadata 传上去:
import time
t0 = time.time()
first_token_at = None
for chunk in agent.stream(payload, config=cfg, stream_mode="messages"):
if first_token_at is None:
first_token_at = time.time() - t0
# 这个数字 Trace 自己产生不了,得你自己送上去6. 嵌套 Agent:内层会挂在同一棵树上 #
工具里再调一个 Agent(第 18 章的一种多智能体形态),Trace 会怎么记?
# 内层:一个专职翻译的小 Agent
inner = create_agent(model="deepseek:deepseek-v4-flash", tools=[],
system_prompt="你是翻译引擎,把输入翻译成英文,只输出译文。")
@tool
def translate(text: str) -> str:
"""把中文翻译成英文。你自己不会翻译,必须调用这个工具。"""
r = inner.invoke({"messages": [{"role": "user", "content": text}]})
return r["messages"][-1].content
# 外层
outer = create_agent(
model="deepseek:deepseek-v4-flash", tools=[translate],
system_prompt="你不懂英文。任何翻译请求都必须调用 translate 工具,"
"禁止自己翻译。拿到工具结果后直接转述。",
)Trace:
[chain ] 嵌套Agent-v2 2.868s 1146tok
[chain ] model 0.856s 454tok
[llm ] ChatDeepSeek 0.791s 454tok
[chain ] tools 1.444s 206tok
[tool ] translate 1.443s 206tok
[chain ] LangGraph 1.442s 206tok ← 内层 Agent
[chain ] model 1.440s 206tok
[llm ] ChatDeepSeek 1.439s 206tok
[chain ] model 0.436s 486tok
[llm ] ChatDeepSeek 0.435s 486tok
共 10 个 span,5 层深,3 次模型调用内层 Agent 完整地嵌在 translate 工具节点下面,不需要任何配置。靠的是 contextvars 自动传递上下文(和第 30 章 §6 里 @traceable 的嵌套是同一个机制)。
6.1 怎么分清内外层 #
内层的根节点叫 LangGraph(默认名),外层因为我传了 run_name 所以叫 嵌套Agent-v2。如果两层都不改名,树上就会有两个 LangGraph,很难分辨。
建议给每一层都起名:
# 内层调用时也传 run_name
r = inner.invoke({"messages": [...]},
config={"run_name": "翻译子Agent"})6.2 成本要看清归属 #
外层根 Run 显示 1146 tok
其中内层贡献了 206 tok(18%)根 Run 的 token 是含内层的。做成本分析时要注意:如果你同时统计了外层 Agent 和内层 Agent 的项目,那 206 token 会被算两次。
6.3 一个真实的意外收获 #
第一次跑这个实验时,我给外层的提示词是「需要翻译时调用 translate 工具」。结果树上只有 3 个 span:
[chain ] 嵌套Agent 0.991s 414tok
[chain ] model 0.989s 414tok
[llm ] ChatDeepSeek 0.988s 414tok没有 tools,没有 translate,模型自己把翻译做了。 答案是对的(模型确实会翻译),但整个工具链被绕过了。
改成「你不懂英文,任何翻译请求都必须调用 translate 工具,禁止自己翻译」之后才走工具。
这是第 31 章那个主题的又一次重现:模型跳过工具时不会报错,只会悄悄自己干。 而 Trace 里 span 数量的异常(3 个 vs 预期的 10 个)是唯一的信号。
顺带一提:这也说明「工具被调用」不能假设。如果你的业务依赖某个工具一定被执行(比如合规检查、审计留痕),光靠提示词不够,得用第 25 章的确定性节点把它固化到图里。
7. 按症状索引的排查清单 #
把前面的内容整理成一张查询表。左边是你观察到的现象,右边是去 Trace 的哪个位置找。
7.1 工具相关 #
| 症状 | 看哪里 | 常见原因 |
|---|---|---|
| 模型不调用工具 | llm Run 的 invocation_params.tools |
工具没传进去;description 太模糊 |
| 模型调错工具 | 同上,看几个工具的 description | 描述区分度不够 |
| 工具参数不对 | tool Run 的 inputs + 上一层 llm 的输出 |
参数说明缺失、没给示例 |
| 工具返回空/没用 | tool Run 的 outputs |
见第 31 章 §3.3 |
| 工具调用次数异常多 | 树的形状:model → tools 循环几轮 |
工具没给模型想要的 |
| 工具压根没出现在树上 | span 总数 | 模型跳过了工具(§6.3) |
| 工具抛异常 | 树上最深的标红节点 | 见第 31 章 §6 |
7.2 中间件相关 #
| 症状 | 看哪里 |
|---|---|
| 中间件没生效 | 树上有没有 XxxMiddleware.<钩子> 节点 |
| 中间件重复挂载 | 同名钩子节点是否出现两次(第 50 章的 .name 坑) |
| 中间件拖慢了请求 | 钩子节点的耗时,正常应该接近 0 |
| 不确定钩子执行顺序 | 树上兄弟节点的先后(按 start_time 排) |
7.3 HITL 相关 #
见 §4.4 的表格。
7.4 性能相关 #
| 症状 | 看哪里 | 判断 |
|---|---|---|
| 整体慢 | 直接子节点的耗时占比 | 模型 > 70% 减轮数;工具 > 50% 加缓存 |
| 慢但 token 不高 | tool 节点耗时 |
瓶颈在 IO |
| 同层耗时和 > 父节点 | —— | 有并行,正常(第 31 章 §4) |
| token 逐轮暴涨 | 每轮 llm 的 prompt_tokens |
上下文膨胀,减轮数 |
| 成本莫名偏高 | 嵌套层里有没有别的 Agent | 内层 token 被算进外层(§6.2) |
7.5 「查不到数据」相关 #
这类问题不在 Trace 里,在接入上,回第 30 章:
| 症状 | 原因 |
|---|---|
| 界面上一条都没有 | LANGSMITH_TRACING 不是 true;或 key 没读到(第 30 章 §5) |
| 401 Invalid token | 多半是密钥没传,不是密钥错(第 30 章 §5.3) |
| 数据落到了别的项目 | LANGSMITH_PROJECT 被 tracing_context 覆盖了 |
| 常驻服务看不到数据 | 队列没刷,要 get_client().flush()(第 30 章 §4.3) |
8. 一个完整的调试策略 #
综合前两章,形成一套可执行的流程。
8.1 分层定位 #
现象:用户说答得不对
│
├─ 有报错吗?
│ ├─ 有 → 看树上最深的标红节点(第 31 章 §6)
│ └─ 无 → 往下
│
├─ 树的形状正常吗?
│ ├─ span 数明显偏少 → 模型跳过了工具(§6.3)
│ ├─ span 数明显偏多 → 模型在试错(第 31 章 §3.2)
│ └─ 正常 → 往下
│
├─ 工具的 outputs 对吗?
│ ├─ 空/不对 → 工具实现或 description 问题
│ └─ 对 → 往下
│
└─ 工具结果对,答案还是错
→ 问题在模型的归纳环节
→ 看最后一轮 llm 的完整 inputs,检查提示词最后那种情况最难办,因为它没有明确的「哪一步错了」。这也是评测(第 33 章起)真正不可替代的地方——你需要的不是「找出哪步错了」,而是「量化这次改动让整体变好还是变差」。
8.2 给排障加速的三个习惯 #
① 给每次调用起名字。
config={"run_name": f"客服-{intent}"}不然一屏全是 LangGraph,光找就要半天。
② 记 request_id 之类的业务标识。
config={"metadata": {"request_id": req_id, "user_id": uid}}HITL 的两段 Trace(§4.1)、重试产生的多条 Trace,全靠它串起来。
③ 把关键的中间函数标成 @traceable。
按 §2 的建议,反复要 print 的地方就该提升成 Run。一次投入,线上线下都受益。
9. 练习 #
确认你的中间件真的挂上了。 在自己的项目里跑一次,检查树上有没有
<你的中间件>.before_model节点。如果没有,你可能一直以为它在工作。复现 §4.3 那个误判。 跑一次 HITL 流程,然后分别用
error is not None和status == "error"统计失败数,看差多少。做一个审批看板查询。 用
filter='eq(status, "interrupted")'捞出所有待审批的记录,从error字段里解析出action_requests,列成一张「谁要做什么」的表。验证模型会不会跳过工具。 按 §6.3,故意把工具的提示词写弱(「需要时可以调用」),跑十次,统计有几次真的调了工具。这个比例会让你重新考虑要不要用确定性节点。
给一个循环内部加可见性。 找一个
for循环里调外部服务的函数,用@traceable让每次迭代都成为一个 Run。观察树会变成什么样——以及会不会太吵。
10. 小结 #
print vs Trace:
开发时用
@traceable。
四种结构在 Trace 里的样子:
| 结构 | 表现 |
|---|---|
| 中间件 | 独立 span,名字是 <name>.<钩子>,和 model 平级 |
| HITL | 两条独立 Trace;中断点 status='interrupted',error 字段里装着 action_requests |
| 流式 | 和 invoke 完全一样,片段被聚合了;流式特有问题看不到 |
| 嵌套 Agent | 内层完整嵌在工具节点下,自动关联;token 算在外层里 |
本章最重要的一个坑:
status='interrupted'是第四种状态,它的error字段非空但不是错误。用error != null判断失败会把每一次正常审批都统计成故障。判断失败要用status == "error"。
两个反复出现的主题:
- 模型跳过工具不会报错,只会悄悄自己干。span 数量偏少是唯一信号。
- 约束写在代码里,模型看不见。 第 31 章和本章的几个 bug 都是这个根因。
观测这条线到此结束。 你现在能看见系统里发生的一切,也能顺着树找到根因。但第 29 章那个边界还在:Trace 不会告诉你「这次改动是变好还是变差」。
§8.1 那个流程图的最后一个分支——「工具结果对,答案还是错」——就卡在这里。下一章开始补上这块:把散落在 Trace 里的好例子和坏例子沉淀成数据集,让「对不对」变成一个可以自动回答的问题。