1. 本章目标 #

第 31 章读的是「标准结构」的 Trace:模型步、工具步、几轮循环。真实项目里的图要复杂得多——挂着中间件、卡着人工审批、套着子 Agent、还可能是流式输出。这些东西在 Trace 里长什么样,官方文档基本没讲。

这一章把它们逐个拆开看,并给出一份定位清单:遇到某类症状,该去 Trace 的哪个位置找。

学完你应能:

前置依赖: 第 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-flash

2. 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 内部就不再是黑盒了。

实用建议: 开发时用 print(快),排查线上问题用 Trace(有历史)。当你发现自己反复在同一个函数里加 print,那就该给它加 @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 触发,消息数=4

Trace 里:

[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.001s

3.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.001s

0.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 个 span

7 个 span,和 invoke 一模一样。 那 112 个 token 片段被 SDK 聚合成了完整消息才上报。

这个结论有两层含义:

一个折中做法:在客户端记录首 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. 练习 #

  1. 确认你的中间件真的挂上了。 在自己的项目里跑一次,检查树上有没有 <你的中间件>.before_model 节点。如果没有,你可能一直以为它在工作。

  2. 复现 §4.3 那个误判。 跑一次 HITL 流程,然后分别用 error is not None 和 status == "error" 统计失败数,看差多少。

  3. 做一个审批看板查询。 用 filter='eq(status, "interrupted")' 捞出所有待审批的记录,从 error 字段里解析出 action_requests,列成一张「谁要做什么」的表。

  4. 验证模型会不会跳过工具。 按 §6.3,故意把工具的提示词写弱(「需要时可以调用」),跑十次,统计有几次真的调了工具。这个比例会让你重新考虑要不要用确定性节点。

  5. 给一个循环内部加可见性。 找一个 for 循环里调外部服务的函数,用 @traceable 让每次迭代都成为一个 Run。观察树会变成什么样——以及会不会太吵。


10. 小结 #

print vs Trace:

开发时用 print(快、能看循环内部),线上排查用 Trace(有历史、有层级)。反复在同一处加 print,就该改成 @traceable。

四种结构在 Trace 里的样子:

结构 表现
中间件 独立 span,名字是 <name>.<钩子>,和 model 平级
HITL 两条独立 Trace;中断点 status='interrupted',error 字段里装着 action_requests
流式 和 invoke 完全一样,片段被聚合了;流式特有问题看不到
嵌套 Agent 内层完整嵌在工具节点下,自动关联;token 算在外层里

本章最重要的一个坑:

status='interrupted' 是第四种状态,它的 error 字段非空但不是错误。用 error != null 判断失败会把每一次正常审批都统计成故障。判断失败要用 status == "error"。

两个反复出现的主题:

观测这条线到此结束。 你现在能看见系统里发生的一切,也能顺着树找到根因。但第 29 章那个边界还在:Trace 不会告诉你「这次改动是变好还是变差」。

§8.1 那个流程图的最后一个分支——「工具结果对,答案还是错」——就卡在这里。下一章开始补上这块:把散落在 Trace 里的好例子和坏例子沉淀成数据集,让「对不对」变成一个可以自动回答的问题。