1. 本章目标 #
数据流起来了,界面上一堆 Trace。现在的问题是:该看哪一条,看到哪一层,看什么字段。
新手打开界面最常见的状态是「什么都看得见,但不知道看什么」。一棵树展开二十几个节点,每个都能点进去看一大堆 JSON,看了半小时还是不知道问题在哪。这一章给出一套有固定顺序的读法,最后走一遍完整的排障。
学完你应能:
- 按「先看数字、再展开树、最后钻字段」的顺序读 Trace,而不是一上来就点开第一个节点
- 从耗时分布里定位瓶颈,并看懂同层求和超过父节点是怎么回事
- 从 token 逐轮的变化里看出上下文膨胀
- 在失败的 Trace 里三步找到故障源头,区分「真正抛异常的那一层」和「冒泡上来的层」
- 用服务端过滤语法(
eq/has/and)在几千条 Run 里捞出你要的那几条 - 完成一次完整排障:从「用户说答得不对」到「定位到具体那行代码」
前置依赖: 第 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 立刻跳出来了:
- 耗时是别人的 2.6 倍
- token 是别人的 2.7 倍
- 工具调用 5 次,另外两条只有 1~2 次
用户问的是「鞋子不合脚能退吗」,一个再普通不过的问题,凭什么要 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看形状,不要急着看内容。 这棵树的形状告诉了我们三件事:
model → tools交替了四轮。 模型每次拿到工具结果都不满意,又发起了新的调用。- 中间有两处「一个 model 后面跟两个 tools」。 那是模型在同一轮里并发调了两个工具。
- 每次
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"两处改动缺一不可:
- 改 docstring:让模型一开始就别生成错误的 SQL
- 把
raise改成return:万一还是错了,让模型有机会自我修复,而不是整个请求崩掉
第二点尤其重要。第 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. 练习 #
给自己的项目做一次数字表。 拉最近 50 条根 Run,按 §3.1 那五个指标列表格。找出耗时或 token 的离群点,展开看看它们有什么共同点。
验证并行。 构造一个让模型同时调两个工具的问题(比如「订单 A1001 到哪了?顺便说下运费」),确认同层耗时之和大于父节点。算出并行到底省了多少秒。
对比修复前后。 按 §3.5 改掉
search_kb的 docstring,用同一个问题重跑,把五个指标列成对比表。看看 docstring 这一处改动能带来多大差异。写一个「每日故障摘要」脚本。 用 §7.3 的查询,每天输出:失败数、最慢的 3 次、最贵的 3 次。这个脚本第 37 章会扩展成监控清单。
反向练习:造一个只有 Trace 能发现的 bug。 要求:不抛异常、答案看起来合理、但过程明显不对。造完让同事只看 Trace 找出来。
11. 小结 #
读 Trace 的固定顺序:
看数字 → 展开树 → 钻字段 → 往上追第一步千万别跳。数字用来选样本,树用来看形状,字段用来定病因,往上追用来找根因。
四个指标怎么读:
| 指标 | 异常时说明 |
|---|---|
| 工具调用次数远超实体数 | 工具没给模型想要的(本章主线 bug) |
| 模型调用 > 3 轮 | 模型在试错 |
prompt 逐轮递增 |
上下文膨胀,减少轮数比压缩提示词有效 |
| 同层耗时和 > 父节点 | 有并行,不是数据错 |
失败 Trace 三步走:
- 看树上最深的那个标红节点——那才是源头,上面的都是冒泡
- 看源头的
inputs和完整error栈 - 往上一层看模型的提示词和工具描述——问「谁让它这么干的」
两个反复出现的根因:
本章两个 bug(检索落空、SQL 越权)根因是同一个:工具的约束写在代码里,没写进 docstring。模型看不到你的
if语句,只看得到你的文档字符串。
一条硬边界:
本章三个用例全是 status=success,其中一个的检索 80% 落空。观测能让你看见问题,但前提是你得先怀疑有问题。 想让系统主动告诉你「这次答得不如上次」,需要的是评测——第 33 章开始。
下一章先把观测这条线走完:中间件、HITL 这些复杂结构在 Trace 里长什么样,以及怎么用 Trace 替代 print 调试。