1. 本章目标 #
前面 28 章一直在造东西:造工具、造 Agent、造图、造审批流。这一章开始换个角色——你不再是造它的人,而是它出问题时被叫醒的那个人。
这两个角色看问题的方式完全不同。造的时候你关心「这样写对不对」,排障的时候你关心「刚才那一次到底发生了什么」。后一个问题,print 回答不了。
学完本章你应能:
- 说清楚一次 Agent 调用为什么是黑盒,以及黑在哪几个具体位置
- 分辨三类故障:炸了的、没炸但答错的、答对了但很贵的——它们需要的观测手段完全不一样
- 准确使用 Trace / Run / Span 三个词,不再混着说
- 看懂一棵真实的 run 树:谁套着谁、耗时为什么加起来对不上、token 记在哪一层
- 知道观测回答不了哪些问题(这决定了第 33 章为什么必须做评测)
前置依赖: 第 2 章(create_agent)、第 8 章(工具)。第 22 章的流式和第 26 章的 checkpoint 会拿来做对比,没读过也不影响。
参考文档:
这一章不写接入代码。 接入是第 30 章的事,一共三行环境变量。先把「为什么」和「看什么」想明白,再去看那三行,收获完全不一样——否则你只会得到一个能打开但不知道该看哪里的网页。
1.1 本章统一环境 #
本章所有输出都是在这套环境下实测得到的,不是示意:
langchain 1.3.18
langchain-core 1.6.1
langgraph 1.2.11
langsmith 0.12.1
langchain-deepseek 1.1.0
模型 deepseek-v4-flash2. 一个真实的排障现场 #
先看一段代码。这是个「数据助手」,两个工具,标准写法,没有任何刻意为之的怪东西:
# 一个再普通不过的数据助手 Agent
import time
from langchain.agents import create_agent
from langchain_core.tools import tool
# 工具一:执行 SQL。为了安全,只允许查 orders 表
@tool
def query_db(sql: str) -> str:
"""执行 SQL 查询订单库。"""
# 简单的白名单校验:SQL 里必须出现 orders
if "orders" not in sql.lower():
# 不符合就抛异常——这是很多人的第一反应写法
raise ValueError(f"表不存在:只允许查 orders 表,收到的 SQL 是 {sql!r}")
return "order_id=A1001, status=shipped"
# 工具二:查用户资料
@tool
def get_user(user_id: str) -> str:
"""按用户 ID 查用户资料。"""
time.sleep(0.3) # 模拟一点数据库延迟
return "" # 查不到时返回空字符串——也是很常见的写法
agent = create_agent(
model="deepseek:deepseek-v4-flash",
tools=[query_db, get_user],
system_prompt="你是数据助手。查数据必须调用工具,不要编造。",
)现在拿三个问题去问它。三个问题都很正常,一个恶意输入都没有。
2.1 三次调用,三种烂法 #
第一次——用户问:「帮我查一下 users 表里有多少人」
!! 整个 invoke 抛出 ValueError: 表不存在:只允许查 orders 表,
收到的 SQL 是 'SELECT COUNT(*) AS user_count FROM users;'整个 invoke() 崩了。注意崩的位置:不是模型崩了,是工具抛的异常一路冒泡穿透了 Agent。第 8 章讲工具时这个行为一笔带过,这里你会实际感受到它——用户看到的是 500,不是一句「抱歉我查不了 users 表」。
第二次——用户问:「用户 U777 的资料是什么?」
HumanMessage '用户 U777 的资料是什么?'
AIMessage 调用 [('get_user', {'user_id': 'U777'})]
ToolMessage ''
AIMessage '没有查询到用户 U777 的资料,可能是该用户不存在。需要我进一步核对或换个方式查询吗?'没有任何报错。 状态码 200,回答通顺,语气礼貌。但这个回答可能是完全错的——get_user 返回空字符串有两种可能:用户真的不存在,或者数据库连接超时被吞了。模型无从分辨,于是把「我查不到」说成了「该用户不存在」。
这类故障是最危险的:监控面板上它是一次成功请求。
第三次——用户问:「你觉得我们公司下个季度营收会是多少?」
!! 整个 invoke 抛出 ValueError: 表不存在:只允许查 orders 表,
收到的 SQL 是 "SELECT name FROM sqlite_master WHERE type='table';"这个最有意思。用户问的是一个预测性问题,压根没提数据库。但系统提示词里那句「查数据必须调用工具,不要编造」把模型逼进了死角:它既不能编,又得回答,于是自作主张去查表名,想先摸清楚有哪些数据可用。
这不是模型「犯傻」,是提示词和工具集不匹配导致的合理行为。而这个因果链,你从异常栈里一个字都看不出来。
2.2 现在,请你排查 #
假设这三条是线上日志里的三行,你手上只有:
2026-09-03 12:34:33 ERROR ValueError: 表不存在:只允许查 orders 表…
2026-09-03 12:34:36 INFO 200 OK latency=1.2s
2026-09-03 12:34:41 ERROR ValueError: 表不存在:只允许查 orders 表…试着回答这几个问题:
| 你想知道 | 从日志能看出来吗 |
|---|---|
| 用户当时问的是什么? | ✗ 日志里没有 |
| 模型为什么生成了那条 SQL? | ✗ 看不到模型收到的完整提示词 |
| 第二条为什么是 200?答案对不对? | ✗ 200 就是 200,日志不判断质量 |
| 第一条和第三条是同一个 bug 吗? | ✗ 错误信息一样,但成因完全不同 |
| 这次调用花了多少 token? | ✗ 没记 |
| 1.2 秒里,模型占多久、工具占多久? | ✗ 只有总数 |
六个问题,六个看不出来。这就是「黑盒」的具体含义——不是完全没信息,是信息的粒度不对。日志记的是「进程视角」(哪一行代码抛了什么异常),你需要的是「调用视角」(这一次对话经历了哪几步,每步的输入输出是什么)。
2.3 那加 print 呢 #
自然的下一步是加 print。很多人是这么干的,也确实能解决一部分问题。但它会在四个地方失效:
① 你没法给已经发生的事加 print。 用户昨晚 11 点遇到的问题,你今天加的 print 打不出来。你只能试着复现——而 Agent 的行为带随机性,temperature=0 也不保证每次的工具调用序列相同。
② 提示词太长,print 出来没法看。 模型实际收到的是完整消息列表:系统提示词 + 历史消息 + 工具 schema。一次几千字符。print(messages) 会刷屏,而且是一坨没有换行的 JSON。
③ 加 print 得改代码。 改代码就得发版。为了看一眼变量而走一遍发布流程,没人愿意做,于是问题就一直挂着。
④ 最要命的:print 是线性的,而 Agent 的执行是有层级的。 上面第三个案例里,「模型决定查 sqlite_master」这个决策发生在第一次模型调用,而异常抛在第二步的工具里。print 会把它们打成两条平行的行,你得自己在脑子里把因果关系接起来。步骤一多就接不上了。
这四点里,第 ④ 点是关键。它说明我们缺的不是「更多信息」,而是一种能表达层级和因果的信息组织方式。
3. Trace / Run / Span:先把词统一 #
这三个词在文档、界面、SDK 里混着出现,很多人读完还是分不清。它们其实只有一句话的区别。
3.1 一句话定义 #
| 词 | 是什么 | 类比 |
|---|---|---|
| Run | 一个「被记录下来的步骤」。一次模型调用是一个 Run,一次工具执行也是一个 Run | 函数调用栈里的一帧 |
| Trace | 一次完整请求产生的所有 Run 的集合,它们组成一棵树 | 完整的调用栈 |
| Span | Run 的另一种叫法,强调「它占据一段时间」 | 甘特图里的一根横条 |
最容易踩的三个点:
- Run 和 Span 是同一个东西。 LangSmith 的 SDK 和 API 里统一叫 Run,界面上和通用可观测性文献里常叫 Span(这个词来自 OpenTelemetry)。本课程统一用 Run,只有在强调时间跨度时才说 Span。
- Trace 不是一个独立对象。 它没有自己的数据库表,就是「
trace_id相同的那批 Run」。 - 根 Run 的
id就等于trace_id。 这条待会用实测数据验证。
3.2 用真实数据对齐 #
下面这棵树是真的从 LangSmith 拉回来的,对应一次两轮的 Agent 调用(模型 → 工具 → 模型):
[chain ] LangGraph 1.760s 904tok ← 根 Run
[chain ] model 0.955s 424tok
[llm ] ChatDeepSeek 0.892s 424tok
[chain ] tools 0.002s
[tool ] ping 0.001s
[chain ] model 0.672s 480tok
[llm ] ChatDeepSeek 0.670s 480tok
共 7 个 span,run_type 分布: {'chain': 4, 'llm': 2, 'tool': 1}对着这棵树把三个词落实一遍:
- 这里一共 7 个 Run(每一行一个)
- 这 7 个 Run 属于同一个 Trace(它们的
trace_id相同) - 最外层那个
LangGraph是根 Run,它的时间跨度 1.760 秒覆盖了所有其他 Run
再看这棵树的形状,它精确对应了 Agent 的执行逻辑:
LangGraph ← 整个 agent
├── model ← 第一轮:让模型决定做什么
│ └── ChatDeepSeek ← 真正的 HTTP 请求发生在这里
├── tools ← 模型说要调工具,进入工具节点
│ └── ping ← 具体执行哪个工具
└── model ← 第二轮:把工具结果交回模型,生成最终答案
└── ChatDeepSeek第 2.3 节说 print「表达不了层级」,对比这棵树就很清楚了:缩进本身就是信息。你一眼能看出 ping 是在 tools 节点里跑的,tools 又是 LangGraph 的一步。这个结构不用你在脑子里拼,它是记录时就带着的。
3.3 为什么中间要多一层 #
有人会问:model 和 ChatDeepSeek 看着是一回事,为什么要分成两层?0.955s 和 0.892s 也差不了多少。
因为它们是两个抽象层级:
model是图里的节点(第 20 章的概念)。它做的事包括:整理消息、调模型、把结果写回 state。ChatDeepSeek是真正的 HTTP 请求。它只负责发出去、收回来。
两者的差值 0.955 - 0.892 = 0.063s 就是节点里除网络请求之外的开销。平时这个差值可以忽略,但当你发现某个节点慢得离谱、模型调用本身却很快时,这一层就是唯一能告诉你「慢在自己代码里」的地方。
同理,tools(节点,0.002s)和 ping(具体工具,0.001s)也是这个关系。
3.4 run_type:一个 Run 是什么类型的 #
上面树里每行开头的 [chain]、[llm]、[tool] 是 run_type 字段。它决定了界面怎么渲染这个 Run,也是筛选时最常用的条件。常见的有五种:
run_type |
代表什么 | 界面上重点显示 |
|---|---|---|
llm |
一次模型调用 | 完整提示词、生成结果、token 数、模型参数 |
tool |
一次工具执行 | 入参、返回值 |
chain |
一个组合步骤(图节点、Runnable 链) | 输入输出的 state |
retriever |
一次检索 | 查询词、召回的文档列表 |
prompt |
一次模板渲染 | 模板变量、渲染结果 |
retriever 这个类型在第 15 章的 RAG 链里会大量出现。实际拉一个 RAG 项目的 Run 列表看,是这样的:
[retriever] ContextualCompressionRetriever
[retriever] EnsembleRetriever
[retriever] BM25Retriever
[retriever] VectorStoreRetriever四层检索器嵌套,每层召回了什么都单独记着。第 31 章排查「RAG 答非所问」时,第一件事就是展开这几层,看是哪一层开始召回结果就不对了。
3.5 耗时为什么加起来对不上 #
回到那棵树,把子 Run 的耗时加起来:
0.955 + 0.892 + 0.002 + 0.001 + 0.672 + 0.670 = 3.192s
根 Run 却只有 1.760s看起来矛盾,其实是父 Run 的时间已经包含了子 Run 的时间。model(0.955s)里面套着 ChatDeepSeek(0.892s),这 0.892 秒被数了两遍。
正确的算法是只看同一层:
model(0.955) + tools(0.002) + model(0.672) = 1.629s
根 Run 1.760s,差值 0.131s 是 LangGraph 自己的调度开销这个坑很常见——看到一堆 span 就想求和,然后得出「总耗时超过了请求耗时」的荒谬结论。记住:Run 树是嵌套的,不是串联的。 想知道某一步真实占比,要沿着树往下走,别横着加。
顺带说一句:同层求和小于父节点,这个结论只在串行执行时成立。如果几个节点是并行跑的,同层加起来反而会超过父节点的耗时。第 31 章会给出一个实测例子——同层合计 6.011 秒,父节点只有 5.510 秒。看到这种「负的调度开销」不要慌,那是并行的信号。
同样的道理适用于 token。根 Run 的 904 token 不是 424 + 480 再加别的,就是这两次模型调用之和;chain 和 tool 类型的 Run 本身不消耗 token,它们显示的数字是下游汇总上来的。
4. 三类故障,三种观测手段 #
第 2 节那三个案例不是随便挑的,它们代表了三类本质不同的故障。分清楚它们,比学会任何一个具体工具都重要——因为它们需要的手段完全不同,用错了就是白忙。
4.1 分类表 #
| 第一类:炸了 | 第二类:答错了 | 第三类:贵/慢 | |
|---|---|---|---|
| 表现 | 抛异常、超时、5xx | 200 OK,但内容不对 | 200 OK,内容也对 |
| 例子 | 案例 A(工具抛 ValueError) | 案例 B(把「查不到」说成「不存在」) | 一次问答烧了 8000 token |
| 谁先发现 | 报警系统 | 用户投诉 | 月底账单 |
| 有没有信号 | 有(status=error) | 没有 | 有(token / latency 字段) |
| 靠什么解决 | 看 Trace(第 31 章) | 做评测(第 33~35 章) | 看 Trace 的聚合统计(第 37 章) |
请特别注意中间这一列。第二类故障是本阶段存在的全部理由。
4.2 第一类:炸了 #
这类最简单,因为它自己会喊。观测在这里的价值是缩短定位时间,从「知道炸了」到「知道为什么炸」。
案例 A 在 Trace 里长这样(每层的状态):
[chain ] LangGraph status=error ← 根 Run 也标红了
[chain ] model status=success ← 模型这步是好的
[llm ] ChatDeepSeek status=success ← 模型也正常返回了
[chain ] tools status=error ← 工具节点炸了
[tool ] query_db status=error ← 真正的源头在这一眼就能看出故障源头在最深的那个 tool Run,上面几层的 error 都是冒泡上来的。日志给你的只有最外层那个异常栈,你得自己判断它是从哪来的;Trace 直接把路径标出来了。
更关键的是,点开 query_db 这个 Run 能看到它的入参:
{'sql': 'SELECT COUNT(*) AS user_count FROM users;'}于是「工具为什么抛异常」就变成了「模型为什么生成这条 SQL」。再往上看一层 ChatDeepSeek 的完整提示词,答案就浮出来了。这个顺着树往上追因的动作,是第 31 章的主要内容。
4.3 第二类:答错了 #
案例 B 的 Trace 全是绿的:
根 Run status='success' error=None
[chain ] LangGraph success
[chain ] model success
[llm ] ChatDeepSeek success
[chain ] tools success
[tool ] get_user success ← 它也是 success展开 get_user 这个 Run:
inputs = {'user_id': 'U777'}
outputs = {'output': {'content': '', 'status': 'success', ...}}content 是空字符串,status 却是 'success'。 从系统角度看这完全正确——工具没抛异常,就是成功。是不是「应该返回点什么」,系统不知道,也不该知道。
这就引出了本阶段最重要的一句话:
观测告诉你「发生了什么」,它永远不会告诉你「这样对不对」。
判断对错需要一个参考答案,而参考答案只能来自人。这就是数据集(第 33 章)和评测器(第 34 章)存在的意义:把「对不对」这个判断从人脑里搬出来,变成能自动跑的代码。
所以第 29~32 章(观测)和第 33~38 章(评测)不是两个并列的话题,而是递进关系:先能看见,再能判断。跳过前半段直接做评测,你会发现评测跑出低分却不知道为什么低——因为你看不见中间过程。
4.4 第三类:贵和慢 #
这类不影响正确性,但直接影响能不能上线。它的特点是单看一次调用发现不了,必须看聚合。
Trace 在每个 Run 上都记了这几个字段:
latency = 1.760s
total_tokens = 904 (prompt=802, completion=102)
total_cost = 8.81552e-05单看这一条毫无问题:1.76 秒、904 token、不到万分之一美元。但如果这是个日调用量 10 万的接口:
904 token × 100000 次 = 9040 万 token / 天
8.81552e-05 × 100000 = 8.8 美元 / 天 ≈ 264 美元 / 月而且 prompt=802, completion=102 这个比例值得警惕——输入是输出的 8 倍。这说明大部分成本花在了重复发送系统提示词和工具 schema 上。这类优化点只有在有数据时才看得见,第 37 章会讲怎么按维度聚合出这些结论。
5. 一个 Run 上到底记了什么 #
概念说完了,看看实际存下来的东西。下面是从真实 Run 上拉的字段,分四组。
5.1 身份与结构 #
name = 'LangGraph'
run_type = 'chain'
id = '01a0658c-10f7-7e23-b859-e35e4ad89ae9'
trace_id = '01a0658c-10f7-7e23-b859-e35e4ad89ae9'
id == trace_id ? True # ← 印证 §3.1:根 Run 的 id 就是 trace_id
parent_run_id = None # ← 没有父亲,所以它是根parent_run_id 是拼出整棵树的唯一依据。查询 API 返回的是扁平的 Run 列表,树是客户端按这个字段现拼的(第 31 章会给出拼树的代码)。
5.2 时间 #
start_time = 2026-09-03T04:34:33.335
end_time = 2026-09-03T04:34:35.095
latency = 1.760s # end - start,不是单独存的字段注意 latency 是算出来的。查询时要自己减,SDK 不会给你现成的。
5.3 状态与错误 #
status = 'success' # 常见值:'success' / 'error' / 'pending'
error = None # 出错时这里是完整的异常栈字符串status 只反映「有没有抛异常」。第 4.3 节那个空字符串就是 success。这个字段的含义要理解准确,否则你会误以为「没有 error 就是没问题」。
还有一个第四种取值 interrupted,出现在人工审批(HITL)暂停的节点上。它在语义上既不是成功也不是失败,而是「等人」。第 32 章会专门讲它,那里有个很容易误判的坑。
5.4 输入输出 #
不同 run_type 的 inputs/outputs 结构差别很大,这是实测的三种:
tool 类型——最干净,就是函数的参数和返回值:
inputs = {'x': 'hello'}
outputs = {'output': {'content': 'pong:hello',
'name': 'ping',
'status': 'success',
'tool_call_id': 'call_00_MsaSPf96eeZG3HSNNpZW3872',
'type': 'tool'}}llm 类型——最有价值,也最庞大:
inputs keys: ['messages']
outputs keys: ['generations', 'llm_output', 'run', 'type']inputs['messages'] 里是模型实际收到的完整消息列表,包括系统提示词、全部历史、以及序列化后的消息对象。第 2.3 节说的「print 出来没法看」,在这里被结构化地存了下来,界面会按消息逐条渲染。
chain 类型——节点级的 state 快照:
inputs keys: ['messages']
outputs keys: ['output']5.5 metadata:最容易被忽略的一块 #
llm 类型的 Run 上还挂着一个 metadata 字典。实测拉到的键有 21 个:
LANGSMITH_ENDPOINT, LANGSMITH_PROJECT, LANGSMITH_TRACING, _type,
checkpoint_ns, langgraph_checkpoint_ns, langgraph_node, langgraph_path,
langgraph_step, langgraph_triggers, lc_versions, ls_integration,
ls_model_name, ls_model_type, ls_provider, ls_run_depth, ls_temperature,
model, model_name, revision_id, stop, stream, usage_metadata挑几个真正有用的:
| 键 | 值(实测) | 什么时候用得上 |
|---|---|---|
ls_provider |
'deepseek' |
A/B 对比不同厂商时按它分组(第 35 章) |
ls_model_name |
'deepseek-v4-flash' |
换模型后确认真的换了 |
ls_temperature |
None |
排查「输出不稳定」时第一个要确认的 |
langgraph_node |
'model' |
定位这次模型调用发生在图的哪个节点 |
langgraph_step |
整数 | 图执行到第几步了(第 24 章的循环排查) |
还有一个字段叫 invocation_params,里面是发给模型的完整参数,包括工具 schema:
{'_type': 'chat-deepseek',
'model': 'deepseek-v4-flash',
'stop': None,
'stream': False,
'tools': [{'type': 'function',
'function': {'name': 'ping',
'description': '测试工具。',
'parameters': {'type': 'object',
'properties': {'x': {'type': 'string'}},
'required': ['x']}}}]}这个字段在排查一类高频问题时是决定性的:「模型为什么不调用我的工具」。九成情况下答案是工具压根没传进去,或者 description 写得模型看不懂。展开 invocation_params.tools 一看便知——比在代码里反复检查快得多。
6. 观测能回答什么,不能回答什么 #
这一节是全章的收口。把第 2.2 节那六个「看不出来」的问题重新过一遍:
| 问题 | 日志 | Trace |
|---|---|---|
| 用户当时问的是什么 | ✗ | ✓ 根 Run 的 inputs |
| 模型为什么生成那条 SQL | ✗ | ✓ llm Run 的 inputs.messages |
| 200 那次的答案对不对 | ✗ | ✗ 仍然不行 |
| 两次报错是同一个 bug 吗 | ✗ | ✓ 对比两棵树的形状和 inputs |
| 花了多少 token | ✗ | ✓ total_tokens |
| 时间花在哪 | ✗ | ✓ 逐层 latency |
六个问题解决了五个。剩下那一个,是观测的能力边界。
6.1 边界在哪 #
Trace 是一份忠实的记录。它记录发生了什么,不评判发生的事对不对。这不是产品缺陷,是定义使然——判断对错需要参考答案,而参考答案不在系统里。
具体地说,这些问题 Trace 回答不了:
- 这个回答的事实是否准确?
- 这个回答引用的文档对不对口?
- 换成另一个提示词,会更好还是更差?
- 这次改动有没有弄坏之前能答对的问题?
最后这条尤其致命,它有个名字叫回归。你改提示词修好了 case A,同时悄悄弄坏了 case B——线上没报错,用户过两周才反馈。这种事只靠看 Trace 是发现不了的,因为你不可能每改一次就把历史上所有 case 手工重跑一遍。
6.2 于是有了后面九章 #
这就是整个阶段五的结构:
第 29 章 建立心智模型 ← 你在这
↓
第 30~32 章 看得见
30 接入 tracing,让数据先流起来
31 读懂一棵 Trace 树,完整走一遍排障
32 中间件、HITL 这些复杂结构在 Trace 里长什么样
↓
第 33~35 章 判断得了 ← 补上 §6.1 那个空缺
33 攒数据集:把「对的答案」沉淀下来
34 写评测器:把「怎么算对」变成代码
35 批量跑:一次改动,全量验证
↓
第 36~37 章 自动化
36 发版前自动跑,不达标就拦住
37 上线后持续采样
↓
第 38 章 合成一个闭环三个阶段的关系可以这么记:
- 看得见(30~32)解决「炸了」和「贵/慢」——第 4.1 表格的第一、三列
- 判断得了(33~35)解决「答错了」——第二列,也是唯一需要新建基础设施的一列
- 自动化(36~37)把前两者变成不需要人盯着的流程
6.3 一个务实的提醒 #
看到这你可能觉得:要建数据集、要写评测器、要接 CI,工程量不小。
确实不小,所以别一次全上。合理的推进顺序是:
- 先接 tracing(第 30 章,三行环境变量,五分钟)。它成本最低、收益最快,接上当天就能省下你排障的时间。
- 等到你第二次遇到同一类「答错了」的问题,再开始建数据集。第一次遇到时手工修就行;第二次说明它会反复出现,值得沉淀。
- 等到数据集攒到二三十条,再考虑接 CI。太早接,跑一次评测比手工看还慢。
观测和评测是为了省时间而做的,如果它本身占用的时间超过了它省下的,那就是做早了。
7. 练习 #
手动画一棵树。 拿第 28 章那个审批流图,不看 LangSmith,在纸上画出你预期的 Run 树:有几层?根 Run 叫什么?
interrupt会产生 Run 吗?第 32 章会给出实际答案,先猜一遍,对比之后印象最深。给三类故障各找一个自己项目里的例子。 按第 4.1 的表格分类。如果第二类(答错了)一个例子都想不出来,那不是你的系统完美,是你还没有发现它们的手段——这正是第 33 章要解决的。
算一笔账。 用第 4.4 的公式,按你的实际调用量算一遍月成本。再算一遍:如果把
prompttoken 降低 30%(比如精简系统提示词),能省多少。验证嵌套关系。 第 3.5 节说父 Run 的耗时包含子 Run。构造一个反例试试:有没有可能子 Run 的
end_time晚于父 Run?(提示:想想异步和后台任务。这个问题第 32 章讲流式时会正面回答。)
8. 小结 #
三个词:
- Run = 一个被记录的步骤,也叫 Span;Trace =
trace_id相同的一批 Run 组成的树 - 根 Run 的
id等于trace_id,parent_run_id为None - 整棵树是靠
parent_run_id在客户端拼出来的,API 返回的是扁平列表
三个必须记住的细节:
- 父 Run 的耗时包含子 Run,横着求和会得出荒谬结论;要比较占比就沿树往下走
status只反映有没有抛异常,空结果、错答案、幻觉全都是successllmRun 的invocation_params.tools是排查「模型不调工具」的第一现场
三类故障:
| 类型 | 有信号吗 | 靠什么 |
|---|---|---|
| 炸了 | 有(status=error) |
看 Trace |
| 答错了 | 没有 | 必须做评测 |
| 贵/慢 | 有(token / latency) | 看聚合统计 |
一句话边界:
观测告诉你发生了什么,不告诉你这样对不对。前者靠第 30~32 章,后者靠第 33~35 章。
下一章开始动手:三行环境变量,让第一条 Trace 出现在界面上。顺便会踩到几个只有在真实网络环境下才会遇到的坑——包括一个把「配额限流」报成「密钥无效」的误导性错误。