1. 本章目标 #

数据流起来了,界面上一堆 Trace。现在的问题是:该看哪一条,看到哪一层,看什么字段。

新手打开界面最常见的状态是「什么都看得见,但不知道看什么」。一棵树展开二十几个节点,每个都能点进去看一大堆 JSON,看了半小时还是不知道问题在哪。这一章给出一套有固定顺序的读法,最后走一遍完整的排障。

学完你应能:

前置依赖: 第 29 章(Trace / Run / Span、三类故障)、第 30 章(接入、tags / metadata)。

参考文档:

1.1 本章统一环境 #

langchain 1.3.18 / langchain-core 1.6.1 / langgraph 1.2.11 / langsmith 0.12.1
模型 deepseek-v4-flash

本章所有树、数字、错误栈都是实测输出。


2. 本章的病人 #

排障要有病人。我们做一个客服 Agent,它有一个很隐蔽的 bug——先别急着找,等会儿用 Trace 把它揪出来。

import time

from langchain.agents import create_agent
from langchain_core.tools import tool

# 一个「知识库」
KB = {
    "退货政策": "签收后 7 天内可无理由退货,商品需保持完好。",
    "换货政策": "质量问题 30 天内可换货,非质量问题不支持换货。",
    "发票": "支持开具电子发票,可在订单页自助申请。",
    "运费": "订单满 99 元包邮,未满收取 10 元运费。",
}


@tool
def search_kb(keyword: str) -> str:
    """在知识库中检索。keyword 是要查的关键词。"""
    time.sleep(0.25)                   # 模拟检索延迟
    return KB.get(keyword, "")         # 查不到就返回空字符串


@tool
def get_order(order_id: str) -> str:
    """按订单号查询订单状态。"""
    time.sleep(0.4)
    return {"A1001": "已发货", "A1002": "待付款"}.get(order_id, "订单不存在")


agent = create_agent(
    model="deepseek:deepseek-v4-flash",
    tools=[search_kb, get_order],
    system_prompt="你是电商客服。回答政策问题必须先调用 search_kb 检索,"
                  "严格按检索结果回答,不要自己发挥。",
)

跑三个用例,每个都打上标签方便之后筛选(第 30 章 §8 的用法):

CASES = [
    ("q1-正常",     "退货政策是什么?",              ["ch31", "case-ok"]),
    ("q2-答非所问", "我买的鞋子不合脚,能退吗?",     ["ch31", "case-bad"]),
    ("q3-多步",     "订单 A1001 到哪了?顺便说下运费怎么算", ["ch31", "case-multi"]),
]

for name, q, tags in CASES:
    agent.invoke(
        {"messages": [{"role": "user", "content": q}]},
        # run_name 让根 Run 有个能认出来的名字,别都叫 LangGraph
        config={"run_name": name, "tags": tags, "metadata": {"case": name}},
    )

三个用例的最终答案都是对的。 站在用户角度,这个 Agent 工作正常。所以你不会收到投诉,也不会有报警——正是第 29 章 §4.1 里最难发现的那种情况。


3. 读 Trace 的固定顺序 #

别一上来就展开树。按这个顺序走,能少走很多弯路:

第一步  看数字   ── 一屏之内比较多条 trace,选出可疑的那条
   ↓
第二步  展开树   ── 看形状:几轮模型、几次工具、有没有反常的重复
   ↓
第三步  钻字段   ── 只钻可疑的那两三个节点,看 inputs / outputs
   ↓
第四步  往上追   ── 找到「谁让它这么干的」,通常是上一层的模型调用

关键在于第一步不要跳过。数字能在几秒内把范围从几百条缩到一两条,而展开树是个耗时的动作。

3.1 第一步:看数字 #

不用打开界面,拉回来列个表就够了。这是三条用例的总览:

用例            状态         耗时   token  span   模型步  工具步
q1-正常         success     2.15s    1112     7      2      1
q2-答非所问     success     5.51s    2966    19      4      5
q3-多步         success     2.42s    1277     9      2      2

三条都是 success。 如果只看状态,这一屏毫无信息量。

但横向一比,q2 立刻跳出来了:

用户问的是「鞋子不合脚能退吗」,一个再普通不过的问题,凭什么要 5 次检索?

这就是「看数字」的意义:它不告诉你哪里错了,但它告诉你先看哪一条。

值得横向比较的指标就这几个,记住它们:

指标 异常信号 通常意味着
total_tokens 明显高于同类请求 上下文膨胀、反复重试
模型调用次数 > 3 模型在「试错」,工具没给它想要的
工具调用次数 远多于问题涉及的实体数 工具返回了模型不满意的结果
latency 高但 token 不高 瓶颈在工具(IO),不在模型
status error 直接跳到 §6

3.2 第二步:展开树 #

现在才展开 q2:

[chain ] q2-答非所问                    5.510s   2966tok
    [chain ] model                       1.199s    529tok
        [llm   ] ChatDeepSeek                1.198s    529tok
    [chain ] tools                       0.252s
        [tool  ] search_kb                   0.251s
    [chain ] model                       1.032s    631tok
        [llm   ] ChatDeepSeek                1.030s    631tok
    [chain ] tools                       0.253s
        [tool  ] search_kb                   0.251s
    [chain ] tools                       0.253s
        [tool  ] search_kb                   0.251s
    [chain ] model                       1.029s    809tok
        [llm   ] ChatDeepSeek                1.028s    809tok
    [chain ] tools                       0.254s
        [tool  ] search_kb                   0.252s
    [chain ] tools                       0.253s
        [tool  ] search_kb                   0.251s
    [chain ] model                       1.486s    997tok
        [llm   ] ChatDeepSeek                1.485s    997tok

看形状,不要急着看内容。 这棵树的形状告诉了我们三件事:

  1. model → tools 交替了四轮。 模型每次拿到工具结果都不满意,又发起了新的调用。
  2. 中间有两处「一个 model 后面跟两个 tools」。 那是模型在同一轮里并发调了两个工具。
  3. 每次 model 的 token 在涨:529 → 631 → 809 → 997。上下文在膨胀。

到这一步,怀疑对象已经很明确了:search_kb 没有给模型想要的东西。

3.3 第三步:钻字段 #

只钻 search_kb 这五个节点,看每次传了什么、返回了什么:

1. search_kb({'keyword': '退换货政策 鞋子 不合脚'})
   → ''  ← 空返回!
2. search_kb({'keyword': '退换货'})
   → ''  ← 空返回!
3. search_kb({'keyword': '退货政策'})
   → '签收后 7 天内可无理由退货,商品需保持完好。'
4. search_kb({'keyword': '鞋子'})
   → ''  ← 空返回!
5. search_kb({'keyword': '尺码不合'})
   → ''  ← 空返回!

统计:5 次工具调用,4 次返回空(空返回率 80%)

病因暴露了。 search_kb 只做完全匹配:

return KB.get(keyword, "")     # 只有 keyword 和 key 一字不差才命中

模型很自然地用了口语化的关键词(「退换货」「鞋子」「尺码不合」),全部落空。只有第三次瞎猫碰上死耗子,用了和 key 完全一致的「退货政策」,才拿到内容。

回头看答案对不对:答案是对的,但纯属侥幸。 如果模型第三次没试出「退货政策」这个词,用户就会得到一句「抱歉没查到相关政策」。

这就是第 29 章 §4.3 说的那类故障:没有任何报错信号,答案还碰巧是对的,但系统本身是坏的。

3.4 第四步:往上追因 #

病因找到了,但还有个问题没答:模型为什么用那些关键词?

打开第一次 llm 调用的 inputs:

第一轮模型调用收到 2 条消息:
  [SystemMessage ] '你是电商客服。回答政策问题必须先调用 search_kb 检索,严格按检索结果回答,不要自己发挥。'
  [HumanMessage  ] '我买的鞋子不合脚,能退吗?'

再看 invocation_params 里发给模型的工具清单:

- search_kb: 在知识库中检索。keyword 是要查的关键词。
  参数: {'keyword': {'type': 'string'}}
- get_order: 按订单号查询订单状态。
  参数: {'order_id': {'type': 'string'}}

问题在工具描述上。 「keyword 是要查的关键词」这句话什么信息都没给:

模型只能凭用户的原话瞎猜。它的行为完全合理,是我们没告诉它规则。

3.5 于是有了两个修复方案 #

方案 改哪里 优缺点
A:改工具实现 search_kb 支持模糊匹配 治本,但要处理同义词、分词,工作量大
B:改工具描述 在 docstring 里列出可选值 五分钟就能改完,立竿见影

方案 B 长这样:

@tool
def search_kb(keyword: str) -> str:
    """在知识库中检索政策条目。

    keyword 必须是以下之一(精确匹配,不支持模糊查询):
    退货政策 / 换货政策 / 发票 / 运费

    用户问「鞋子能退吗」这类问题时,应转换成「退货政策」再调用。
    """
    time.sleep(0.25)
    return KB.get(keyword, "")

两个方案都应该做,但 B 能立刻止血。这也是排障的一般规律:Trace 告诉你「模型缺什么信息」,而补信息通常比改逻辑快。

顺带一提:这个案例说明工具的 docstring 不是给人看的注释,是给模型看的指令。第 8 章讲过这一点,这里是它的代价第一次被量化出来——多花了 2.7 倍 token 和 2.6 倍时间。


4. 耗时:怎么找瓶颈 #

耗时分析有个反直觉的地方,先看实测数据:

根 Run 总耗时 5.510s,直接子节点:
  model       1.486s  占比 27.0%
  model       1.199s  占比 21.8%
  model       1.032s  占比 18.7%
  model       1.029s  占比 18.7%
  tools       0.254s  占比  4.6%
  tools       0.253s  占比  4.6%
  tools       0.253s  占比  4.6%
  tools       0.253s  占比  4.6%
  tools       0.252s  占比  4.6%
  ── 合计 6.011s,剩余 -0.500s 是图自身的调度开销

合计 6.011 秒,比父节点的 5.510 秒还多,「调度开销」成了负数。

第 29 章 §3.5 说父 Run 的耗时包含子 Run,这里怎么反过来了?

因为有两个 tools 是并行跑的。回看 §3.2 那棵树,中间有一处一个 model 后面跟着两个 tools——模型在同一轮里发起了两个工具调用,LangGraph 并发执行它们。两个各占 0.25 秒的节点在墙钟上只花了 0.25 秒,但相加是 0.5 秒。

差值 -0.500s 恰好等于被重复计算的那部分。

4.1 正确的读法 #

现象 含义
同层合计 小于 父节点 串行执行,差值是调度开销
同层合计 大于 父节点 有并行,超出的部分是并行节省下来的时间

所以看到「负的调度开销」不要慌,它是个有用的信号:它告诉你这里发生了并行,而且并行帮你省了 0.5 秒。

4.2 这条 Trace 的瓶颈在哪 #

分类加总:

四次模型调用:1.486 + 1.199 + 1.032 + 1.029 = 4.746s   ← 86%
五次工具调用:0.25 × 5 = 1.25s,但并行后实际约 0.75s   ← 14%

瓶颈是模型,不是工具。 即使把 search_kb 优化到零延迟,也只能省下 14%。真正的解法是减少模型调用轮数——而轮数多正是 §3.3 那个 bug 导致的。

这个结论很重要:优化前先看占比。 常见的错误是看到 search_kb 被调了 5 次就去优化检索性能,结果忙活半天只提升了一成。

4.3 一个实用的判断表 #

观察 瓶颈在 该做什么
模型步占比 > 70% 模型 减少轮数、换更快的模型、缩短提示词
工具步占比 > 50% IO 加缓存、并发化、优化查询
两者都不高,根 Run 却很慢 框架/网络 看 §4.1 的调度开销,检查是否有阻塞操作

5. Token:从数字里看出上下文膨胀 #

逐轮列出模型调用的 token:

轮次    prompt  completion    合计  说明
第1轮      444          85     529  上下文里有 2 条消息
第2轮      540          91     631  上下文里有 4 条消息
第3轮      666         143     809  上下文里有 7 条消息
第4轮      827         170     997  上下文里有 10 条消息
合计 prompt 2477,completion 489

三个可以直接读出来的结论:

① prompt 单调递增:444 → 540 → 666 → 827。 这是 Agent 的固有特性——每一轮都要把之前所有的消息(包括那 4 个空结果)重新发一遍。轮数每多一轮,成本不是线性增加而是累加式增长。

② prompt 是 completion 的 5 倍。 2477 vs 489。这意味着成本主要花在「重复发送上下文」上,不是花在「生成回答」上。

③ 消息数 2 → 4 → 7 → 10。 每一轮工具调用会往历史里加两条消息(AI 的调用请求 + 工具的返回)。

5.1 为什么「减少轮数」是最有效的优化 #

把 §3.5 的修复应用后,轮数从 4 降到 2。省下的不只是后两轮的 token,还有前两轮被重复发送的部分:

修复前:444 + 540 + 666 + 827 = 2477 prompt tokens
修复后(估算,2 轮):444 + 540 = 984 prompt tokens
省了约 60%

这就是为什么第 29 章说 token 属于「第三类故障」但根因往往在第二类。 上下文膨胀通常不是因为提示词太长,而是因为流程绕了远路。

5.2 和正常用例对比 #

把 q2 和 q1 放在一起:

指标            正常用例    问题用例     倍数
span 数              7         19    2.7x
模型调用             2          4    2.0x
工具调用             1          5    5.0x
总 token          1112       2966    2.7x
耗时(s)           2.15       5.51    2.6x

「工具调用 5 倍」这个数字最尖锐。 其他指标都是 2~3 倍,唯独它是 5 倍——放大最明显的那个指标,往往离病根最近。

这也是一个通用技巧:排障时永远要有一个「正常样本」做对照。 单看 q2 的 2966 token,你不知道这是多还是少;有了 q1 的 1112 做对比,问题一目了然。


6. 失败的 Trace 怎么读 #

前面讲的都是「不报错但有问题」。现在看会报错的。

6.1 制造一个故障 #

@tool
def query_db(sql: str) -> str:
    """执行 SQL 查询订单库。"""
    if "orders" not in sql.lower():
        raise ValueError(f"表不存在:只允许查 orders 表,收到的 SQL 是 {sql!r}")
    return "order_id=A1001, status=shipped"


agent = create_agent(
    model="deepseek:deepseek-v4-flash",
    tools=[query_db],
    system_prompt="你是数据助手。查数据必须调用 query_db,不要编造。",
)

agent.invoke(
    {"messages": [{"role": "user", "content": "users 表里有多少人?"}]},
    config={"run_name": "q4-工具抛异常", "tags": ["ch31", "case-err"]},
)

本地看到的是:

invoke 抛出 ValueError: 表不存在:只允许查 orders 表,收到的 SQL 是
'SELECT COUNT(*) AS cnt FROM users'

6.2 第一步:看树上哪几层标红 #

[chain ] q4-工具抛异常                   1.448s    463tok  [ERR]
    [chain ] model                       1.257s    463tok
        [llm   ] ChatDeepSeek                1.193s    463tok
    [chain ] tools                       0.050s           [ERR]
        [tool  ] query_db                    0.022s           [ERR]

三层标红,两层正常。逐层列 status:

[chain ] q4-工具抛异常   status=error    error=ValueError("表不存在:只允许查 orders 表…
[chain ] model          status=success  error=(无)
[llm   ] ChatDeepSeek   status=success  error=(无)
[chain ] tools          status=error    error=ValueError("表不存在:只允许查 orders 表…
[tool  ] query_db       status=error    error=ValueError("表不存在:只允许查 orders 表…

关键判断:错误源头是「树上最深的那个标红节点」。

这里是 query_db。上面的 tools 和根 Run 都标红,但它们只是冒泡——异常从工具里抛出,一路向上穿透。

这个判断规则很重要,因为三层的 error 字段内容是一样的。如果只看根 Run 的错误信息,你不知道它是从哪一层来的;看树的形状才知道。

同时注意 model 和 ChatDeepSeek 是 success——模型这一步没问题,它正常返回了。 排除掉两层,范围就小了。

6.3 第二步:看源头的入参和错误栈 #

源头: [tool] query_db
它收到的入参: {'sql': 'SELECT COUNT(*) AS cnt FROM users'}
error 字段共 683 字符 / 17 行:
  ValueError("表不存在:只允许查 orders 表,收到的 SQL 是 'SELECT COUNT(*) AS cnt FROM users'")
  Traceback (most recent call last):
    File "…\langchain_core\tools\base.py", line 1098, in run
      response = context.run(self._run, *tool_args, **tool_kwargs)
    File "…\langchain_core\tools\structured.py", line 152, in _run
      return self.func(*args, **kwargs)
    File "D:\forever\docs\langrag\_ls\c31c.py", line 25, in query_db
      raise ValueError(f"表不存在:只允许查 orders 表,收到的 SQL 是 {sql!r}")
  ValueError: 表不存在:只允许查 orders 表,收到的 SQL 是 'SELECT COUNT(*) AS cnt FROM users'

error 字段里有完整的 Python traceback,包括你自己的文件名和行号。 这比日志强的地方在于:它和「模型当时收到了什么」「工具当时收到了什么参数」绑在同一棵树上,不用去别处对时间戳。

6.4 第三步:往上一层,问「谁让它这么干的」 #

工具抛异常是结果,不是原因。原因在上一层的模型调用:

模型这一轮收到 2 条消息:
  [SystemMessage ] '你是数据助手。查数据必须调用 query_db,不要编造。'
  [HumanMessage  ] 'users 表里有多少人?'

模型输出的工具调用: [('query_db', {'sql': 'SELECT COUNT(*) AS cnt FROM users'})]

现在因果链完整了:

系统提示词说「查数据必须调用 query_db,不要编造」
   ↓
用户问 users 表
   ↓
模型生成 SELECT ... FROM users     ← 完全合理的行为
   ↓
query_db 只允许 orders 表,抛异常   ← 约束在代码里,模型不知道
   ↓
异常穿透整个 Agent,用户看到 500

根因不是模型「犯错」,是约束没有传达给模型。 和 §3.4 是同一类问题:工具的限制写在代码里,却没写进 docstring。

修复同样有两个层次:

@tool
def query_db(sql: str) -> str:
    """执行 SQL 查询订单库。

    只能查 orders 表(字段:order_id, status, amount)。
    其他表没有权限,不要尝试。
    """
    if "orders" not in sql.lower():
        # 别抛异常,返回一句模型能理解的话,让它自己纠正
        return "错误:只能查 orders 表。请改用 orders 表,或告知用户此数据不可查。"
    return "order_id=A1001, status=shipped"

两处改动缺一不可:

第二点尤其重要。第 51 章讲 ToolErrorMiddleware 时会系统地讲这件事,这里先记住结论:工具里的业务校验失败,应该返回错误信息,不应该抛异常。 抛异常等于把「模型能处理的问题」升级成了「用户能看到的 500」。


7. 服务端过滤:从几千条里捞出那几条 #

前面都是把 Run 全拉回来在本地筛。项目里有几千条 Run 时这样会很慢。LangSmith 支持在服务端过滤。

7.1 基本语法 #

过滤表达式是一个字符串,函数式写法:

from langsmith import Client

client = Client()

# 只要失败的
runs = client.list_runs(project_name="my-project",
                        filter='eq(status, "error")')

# 只要带某个 tag 的
runs = client.list_runs(project_name="my-project",
                        filter='has(tags, "case-err")')

# 组合条件:失败的 + 根 Run(排除冒泡上来的子节点)
runs = client.list_runs(
    project_name="my-project",
    filter='and(eq(status, "error"), eq(is_root, true))')

实测:

filter='and(eq(status,"error"), eq(is_root,true))' → 1 条
  q4-工具抛异常    2026-09-03T05:25:26 tags=['ch31', 'case-err']

eq(is_root, true) 这个条件很关键。 不加它,§6.2 那个故障会返回 3 条(根 Run、tools、query_db 都是 error),你得自己去重。加上它,一个故障就是一条。

7.2 常用过滤器 #

需求 表达式
失败的根 Run and(eq(status, "error"), eq(is_root, true))
慢请求(> 5 秒) gt(latency, 5)
贵请求(> 5000 token) gt(total_tokens, 5000)
某个标签 has(tags, "prod")
某个 metadata 字段 eq(metadata_key, "user_id") 配合 eq(metadata_value, "u_123")
只看某类节点 eq(run_type, "tool")
名字包含某串 search("search_kb")
时间范围 用 start_time 参数,不写在 filter 里

组合用 and(...) / or(...),取反用 not(...)。

7.3 排障常用的三条查询 #

① 今天所有失败的请求:

from datetime import datetime, timedelta

runs = client.list_runs(
    project_name="my-project",
    filter='and(eq(status, "error"), eq(is_root, true))',
    start_time=datetime.now() - timedelta(days=1),
)

② 最贵的 10 次调用:

runs = list(client.list_runs(
    project_name="my-project",
    filter='and(eq(is_root, true), gt(total_tokens, 3000))',
    limit=100,
))
top = sorted(runs, key=lambda r: r.total_tokens or 0, reverse=True)[:10]

③ 某个用户遇到的所有问题:

runs = client.list_runs(
    project_name="my-project",
    filter='and(eq(metadata_key, "user_id"), eq(metadata_value, "u_123"))',
)

第三条就是第 30 章 §8.2 说「记 metadata」的回报——用户来投诉时,你能直接调出他的全部历史。

7.4 一个 API 迁移提示 #

client.list_runs() 在 langsmith 0.12 里会打这个警告:

DeprecationWarning: list_runs() is deprecated and will be removed after
Jan 31, 2027. Use client.runs.query() instead.

新写法是 client.runs.query(...),参数结构略有不同。现在两者都能用,新项目建议直接用 runs.query(),老代码不急着改(还有一年多)。


8. 完整排障 walkthrough #

把前面的方法串成一个完整流程。场景:运营反馈「客服机器人回答政策问题特别慢,有时还答不上来」。

步骤 1:确认问题范围 #

先别急着复现,看数据规模:

from datetime import datetime, timedelta

# 最近一天的所有根 Run
runs = list(client.list_runs(
    project_name="customer-service-prod",
    filter='eq(is_root, true)',
    start_time=datetime.now() - timedelta(days=1),
    limit=500,
))

slow = [r for r in runs if (r.end_time - r.start_time).total_seconds() > 4]
print(f"共 {len(runs)} 次调用,其中超过 4 秒的 {len(slow)} 次 "
      f"({len(slow)/len(runs):.1%})")

先确认这是普遍问题还是个别案例。如果只有 0.5%,优先级就不一样了。

步骤 2:找一个典型样本 + 一个对照样本 #

# 最慢的那个
worst = max(slow, key=lambda r: (r.end_time - r.start_time).total_seconds())
# 同类问题里最快的那个,做对照
fast = min(runs, key=lambda r: (r.end_time - r.start_time).total_seconds())

对照样本不能省。 §5.2 说过,没有对照你不知道 2966 token 算多还是少。

步骤 3:看数字表 #

              慢样本    对照样本    倍数
耗时          5.51s     2.15s      2.6x
token          2966      1112      2.7x
模型调用          4         2      2.0x
工具调用          5         1      5.0x     ← 放大最明显

工具调用 5 倍,这是最尖锐的差异,从这里入手。

步骤 4:展开树,确认形状异常 #

model → tools → model → tools tools → model → tools tools → model

四轮模型、五次工具,而正常样本是 model → tools → model。模型在反复重试。

步骤 5:钻工具节点,看输入输出 #

5 次工具调用,4 次返回空(空返回率 80%)

找到直接原因。

步骤 6:往上追,找根本原因 #

看 invocation_params.tools 里的工具描述:

search_kb: 在知识库中检索。keyword 是要查的关键词。

描述里没说可选值、没说匹配方式。找到根本原因。

步骤 7:验证修复 #

改完 docstring 后,用同一个问题再跑一次,对比:

              修复前    修复后
工具调用          5        1
模型调用          4        2
token          2966     ~1100
耗时           5.51s    ~2.2s

这一步不能省。 而且你会发现一个问题:你只验证了这一个 case。 万一这个改动让别的 case 变差了呢?

手工再测二十个 case?这就是第 33 章存在的理由——把这二十个 case 固化成数据集,每次改动自动全跑一遍。

8.1 流程速查 #

① 定范围   有多少比例受影响?——决定优先级
② 取样本   一个典型 + 一个对照——没有对照就没有基准
③ 看数字   哪个指标放大最明显?——决定从哪入手
④ 看形状   树的结构反常在哪?——重复、过深、意外分支
⑤ 钻字段   只钻可疑节点的 inputs/outputs——别全点开
⑥ 往上追   谁让它这么干的?——通常是上一层模型调用的提示词/工具描述
⑦ 验修复   同一个 case 再跑一遍——然后意识到你需要数据集

9. 常见误读 #

几个新手容易搞错的地方,集中列一下。

9.1 status=success 不代表答案对 #

第 29 章强调过,这里再用数据说一次:§2 那三个用例全是 success,其中 q2 的检索 80% 落空。

status 只反映有没有抛异常。

9.2 根 Run 的 error 不是源头 #

§6.2 里三层的 error 内容完全一样。只看根 Run 的错误信息会让你以为问题出在最外层。 要看树上最深的那个标红节点。

9.3 同层耗时求和可能超过父节点 #

§4 讲过,那是并行。看到「负的调度开销」是并行的信号,不是数据错了。

9.4 token 记在父节点上是汇总值 #

chain 和 tool 类型的 Run 本身不消耗 token。树上 tools 节点显示的数字(如果有)是下游汇总的,不是它自己花的。只有 llm 类型的 token 是「真实发生」的。

9.5 latency 包含排队和重试 #

llm Run 的 1.2 秒不全是模型在思考。它包含 HTTP 往返、服务端排队、以及 SDK 内部的自动重试。模型调用异常慢时,先排除网络因素再怀疑模型。


10. 练习 #

  1. 给自己的项目做一次数字表。 拉最近 50 条根 Run,按 §3.1 那五个指标列表格。找出耗时或 token 的离群点,展开看看它们有什么共同点。

  2. 验证并行。 构造一个让模型同时调两个工具的问题(比如「订单 A1001 到哪了?顺便说下运费」),确认同层耗时之和大于父节点。算出并行到底省了多少秒。

  3. 对比修复前后。 按 §3.5 改掉 search_kb 的 docstring,用同一个问题重跑,把五个指标列成对比表。看看 docstring 这一处改动能带来多大差异。

  4. 写一个「每日故障摘要」脚本。 用 §7.3 的查询,每天输出:失败数、最慢的 3 次、最贵的 3 次。这个脚本第 37 章会扩展成监控清单。

  5. 反向练习:造一个只有 Trace 能发现的 bug。 要求:不抛异常、答案看起来合理、但过程明显不对。造完让同事只看 Trace 找出来。


11. 小结 #

读 Trace 的固定顺序:

看数字 → 展开树 → 钻字段 → 往上追

第一步千万别跳。数字用来选样本,树用来看形状,字段用来定病因,往上追用来找根因。

四个指标怎么读:

指标 异常时说明
工具调用次数远超实体数 工具没给模型想要的(本章主线 bug)
模型调用 > 3 轮 模型在试错
prompt 逐轮递增 上下文膨胀,减少轮数比压缩提示词有效
同层耗时和 > 父节点 有并行,不是数据错

失败 Trace 三步走:

  1. 看树上最深的那个标红节点——那才是源头,上面的都是冒泡
  2. 看源头的 inputs 和完整 error 栈
  3. 往上一层看模型的提示词和工具描述——问「谁让它这么干的」

两个反复出现的根因:

本章两个 bug(检索落空、SQL 越权)根因是同一个:工具的约束写在代码里,没写进 docstring。模型看不到你的 if 语句,只看得到你的文档字符串。

一条硬边界:

本章三个用例全是 status=success,其中一个的检索 80% 落空。观测能让你看见问题,但前提是你得先怀疑有问题。 想让系统主动告诉你「这次答得不如上次」,需要的是评测——第 33 章开始。

下一章先把观测这条线走完:中间件、HITL 这些复杂结构在 Trace 里长什么样,以及怎么用 Trace 替代 print 调试。