1. 本章目标 #

前面 28 章一直在造东西:造工具、造 Agent、造图、造审批流。这一章开始换个角色——你不再是造它的人,而是它出问题时被叫醒的那个人。

这两个角色看问题的方式完全不同。造的时候你关心「这样写对不对」,排障的时候你关心「刚才那一次到底发生了什么」。后一个问题,print 回答不了。

学完本章你应能:

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

2. 一个真实的排障现场 #

先看一段代码。这是个「数据助手」,两个工具,标准写法,没有任何刻意为之的怪东西:

# 一个再普通不过的数据助手 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 的另一种叫法,强调「它占据一段时间」 甘特图里的一根横条

最容易踩的三个点:

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}

对着这棵树把三个词落实一遍:

再看这棵树的形状,它精确对应了 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 也差不了多少。

因为它们是两个抽象层级:

两者的差值 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 章   合成一个闭环

三个阶段的关系可以这么记:

6.3 一个务实的提醒 #

看到这你可能觉得:要建数据集、要写评测器、要接 CI,工程量不小。

确实不小,所以别一次全上。合理的推进顺序是:

  1. 先接 tracing(第 30 章,三行环境变量,五分钟)。它成本最低、收益最快,接上当天就能省下你排障的时间。
  2. 等到你第二次遇到同一类「答错了」的问题,再开始建数据集。第一次遇到时手工修就行;第二次说明它会反复出现,值得沉淀。
  3. 等到数据集攒到二三十条,再考虑接 CI。太早接,跑一次评测比手工看还慢。

观测和评测是为了省时间而做的,如果它本身占用的时间超过了它省下的,那就是做早了。


7. 练习 #

  1. 手动画一棵树。 拿第 28 章那个审批流图,不看 LangSmith,在纸上画出你预期的 Run 树:有几层?根 Run 叫什么?interrupt 会产生 Run 吗?第 32 章会给出实际答案,先猜一遍,对比之后印象最深。

  2. 给三类故障各找一个自己项目里的例子。 按第 4.1 的表格分类。如果第二类(答错了)一个例子都想不出来,那不是你的系统完美,是你还没有发现它们的手段——这正是第 33 章要解决的。

  3. 算一笔账。 用第 4.4 的公式,按你的实际调用量算一遍月成本。再算一遍:如果把 prompt token 降低 30%(比如精简系统提示词),能省多少。

  4. 验证嵌套关系。 第 3.5 节说父 Run 的耗时包含子 Run。构造一个反例试试:有没有可能子 Run 的 end_time 晚于父 Run?(提示:想想异步和后台任务。这个问题第 32 章讲流式时会正面回答。)


8. 小结 #

三个词:

三个必须记住的细节:

三类故障:

类型 有信号吗 靠什么
炸了 有(status=error) 看 Trace
答错了 没有 必须做评测
贵/慢 有(token / latency) 看聚合统计

一句话边界:

观测告诉你发生了什么,不告诉你这样对不对。前者靠第 30~32 章,后者靠第 33~35 章。

下一章开始动手:三行环境变量,让第一条 Trace 出现在界面上。顺便会踩到几个只有在真实网络环境下才会遇到的坑——包括一个把「配额限流」报成「密钥无效」的误导性错误。