AI Agent 可观测性:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-09-01 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档是干什么的
本目录里已有 8 份研究文档,都是给已经懂的人看的——密度极高、术语不解释、直接摆
file:line证据。它们是资产,但不适合入门。这一份反过来:假设你完全没有可观测性经验,从"为什么需要它"开始,一层层往上搭, 直到能回答"给你一个 coding agent,你怎么从零建一套可观测性体系"这种面试题。
读法建议
你是谁 怎么读 完全零基础 第 0 章 → 第 1 章 → 第 4 章,先建立直觉,其余章节按需回查 会后端可观测性、不熟 agent 跳过第 1 章,直接第 2 章(agent 特有的东西)+ 第 5 章(计量精度) 准备面试 通读一遍,重点第 5、6、9 章。第 6 章的"陷阱"是最能拉开差距的部分 想动手 第 4 章 + 附录 A(可复跑命令) 一条关于数字的免责声明(很重要,第 6 章会解释为什么)
文中所有具体数字("176 个 span 无 parent"、"框架开销占 1.1%")都是某个时间点在某台 机器上跑出来的快照,不是恒定事实。引用它们是为了让你看见"真实数据长什么样", 不要把它们当结论沿用——数据会漂移,分母会变。附录 A 给了复跑命令。
目录
| 章 | 主题 | 一句话 |
|---|---|---|
| 0 | 为什么需要可观测性 | 从一次真实崩溃讲起:你什么都不知道 |
| 1 | 基础概念 | Trace / Span / Metric / Log / OpenTelemetry,用类比讲透 |
| 2 | agent 特有的观测对象 | Token、成本、缓存、上下文、轮次、流式、子代理 |
| 3 | 把指标组织起来 | 四个方向(更快/更省/更准/更安全)+ 它们必然互斥 |
| 4 | 从零到一实操 | 六个级别,从一行日志搭到完整体系 |
| 5 | 数字凭什么可信 | 计量精度四道防线(本文最硬的一章) |
| 7 | 排查实战 | 数据在哪、异常检测器、"让 AI 读嚼碎的摘要" |
| 8 | 三家横向对比 | 同一个能力,三种目的 |
| 10 | 术语表与学习路径 | 速查 |
| 附录 A | 可复跑命令 + 三条计数铁律 | 动手 |
第 0 章 · 为什么需要可观测性
0.1 先看一个场景
你让一个 coding agent 修一个 bug。8 分 45 秒后,终端上只剩一行报错,进程退了。
你现在想知道:
- 它到底做了什么?(调了哪些工具、读了哪些文件)
- 卡在哪一步?崩在哪一步?
- 花了多少钱?
- 慢的那 8 分钟,是模型在想、还是工具在跑、还是我们自己的框架在空转?
- 它是"做不出来",还是"做了但做错了"?
- 下次会不会再犯?
如果你手上只有终端里那行报错,这六个问题一个都答不了。
这就是可观测性要解决的问题。它不是"加点日志",它是:
让系统的内部状态,能从外部观察到的输出被推断出来。
这是可观测性(Observability)的标准定义,源自控制论。翻译成人话: 不用改代码、不用重现,就能回答"刚才发生了什么、为什么"。
0.2 传统软件 vs AI Agent:为什么不能直接照搬后端那套
后端可观测性已经很成熟(Prometheus、Jaeger、Datadog 都是现成的)。但 agent 有五个 特性让那套东西不够用:
| 差异 | 传统后端服务 | AI Agent | 后果 |
|---|---|---|---|
| 确定性 | 同样输入 → 同样输出,能重放 | 同样输入 → 不同输出(温度、采样) | 「重现一遍看看」这条路基本断了,必须一次采够 |
| 成本 | CPU/内存,事后算账 | 每次调用直接烧钱,一次跑几美元 | 成本是一等公民指标,不是运维附属品 |
| 执行边界 | 一次请求 = 一次处理 | 一次任务 = N 轮对话 × M 次工具调用 | 「一次请求」这个观测单位不够用,要层次化 |
| 黑盒程度 | 代码是你写的,逻辑可读 | 模型的决策过程不可见,只能看输入输出 | 只能从行为反推意图(它为什么反复读同一个文件?) |
| 失败形态 | 报错、超时、5xx | 静默做错:不报错,答案是错的 / 原地打转 / 悄悄放弃了一半任务 | 「没有报错」不等于「成功」,需要过程指标 |
第 5 行是最反直觉、也最重要的一条。举个例子:
agent 说"我已经修复了这个 bug 并通过了测试",退出码 0,没有任何报错。 实际上它把测试文件删了,然后跑了个空测试集。
这条链路上每一个传统监控指标都是绿的。
传统监控回答"服务活着吗";agent 可观测性必须回答"它做对了吗"。这需要完全不同的一 套指标——第 3 章的"更准"方向就是专门为这个存在的。
0.3 可观测性的三个层次(先建立这个心智模型)
后面所有内容都可以挂在这三层上:
第三层:结论 「这次会话崩在工具链中途,最该看 messages.json 的 attribution」
▲ ← 这一层才是人真正需要的
│ 谁做的:排查工具 / 异常检测器 / 聚合脚本(第 7 章)
│
第二层:指标 「TTFT p95 = 8.9s,缓存命中率 46.6%,本轮花了 $0.83」
▲ ← 能对比、能画曲线、能发现退步
│ 谁做的:Metric 聚合 + 口径归一化(第 2、5 章)
│
第一层:原始数据 「12:04:33 调用 grep,参数 pattern=bug-sync,耗时 120ms」
← 全都在,但读不完
谁做的:Trace / Span / 日志 / 落盘(第 1、4 章)一个几乎所有团队都会犯的错:只建第一层,然后宣布"我们有可观测性了"。
第一层的数据量是巨大的(一个会话的原始请求响应能有几十 MB)。人不会去读,AI 读它 要烧掉几十轮 token。数据不缺,缺的是把数据翻译成结论的那一步。
这句话是本文的主线之一。第 7 章会讲一个具体的解法。
0.4 一个"数据全在但没人用"的真实例子
某个 coding agent 项目,可观测性代码写得很完整:
analytics/目录 1113 行——零依赖事件 API、五阶段过滤管线、三个导出器、 磁盘缓存、退避重试、隐私双通道、采样、远端 killswitch。- 单元测试全绿。
- 架构图画出来很漂亮。
生产环境里,这套管线的埋点调用数:1 个。
也就是说:这 1113 行代码,实际上什么都没在采。它在文档里被记成"资产", 实际上是"负债"——占着维护成本、给人一种"我们有了"的错觉。
这是可观测性领域的头号反模式,而且它极难被发现:因为代码在、测试绿、 架构讲得通。你必须去数生产调用点,才能看出来。
第 6 章陷阱 1 会展开。面试里如果你能主动提这一点,是很强的信号。
第 1 章 · 基础概念(零基础必读)
这一章把行业标准术语讲清楚。如果你已经熟悉 OpenTelemetry,可以跳到第 2 章。
1.1 四种数据类型:Log / Metric / Trace / Event
可观测性圈子里有个说法叫"三大支柱"(Log、Metric、Trace)。先用一个类比区分:
假设你要观察一家餐厅的运营。
- Log(日志):服务员的手写便签。「12:04 3 号桌点了两份牛排」。 信息最全,但是一堆散纸,多了就没法看。
- Metric(指标):墙上的计数器。「今日上菜 142 份,平均等待 18 分钟」。 高度压缩,能画趋势,但丢掉了细节——你不知道是哪一桌等了 50 分钟。
- Trace(追踪):跟着一份订单走完全程的记录。「点单 → 传菜口 → 后厨切肉 → 煎制 → 摆盘 → 上桌」,每一步的耗时和父子关系都在。 能回答"这份订单为什么慢",但只覆盖被追踪的那些订单。
- Event(事件):产品分析视角的"发生了一件值得记的事"。 「用户用了新菜单」。它形态上像 Log,但语义上是给分析而不是给排障用的。
四者的取舍:
| 数据量 | 能否画趋势 | 能否定位单次 | 典型存储 | |
|---|---|---|---|---|
| Log | 巨大 | 难(要先聚合) | ✅ 最强 | 文件 / ELK |
| Metric | 极小 | ✅ 最强 | ❌ 完全不能 | 时序库(Prometheus) |
| Trace | 大 | 中(要先聚合) | ✅ 强,且带因果 | Jaeger / Tempo |
| Event | 中 | ✅(按事件名聚合) | 中 | 数仓(BigQuery) |
最常见的入门误区:以为 Metric 是 Log 的"高级版本"。不是。它们是互补的: Metric 告诉你"有问题",Trace/Log 告诉你"问题在哪"。只有 Metric 你会知道 p99 涨了 但查不出为什么;只有 Log 你会淹死在数据里且发现不了缓慢的退化。
1.2 Trace 和 Span:本章最重要的概念
这两个词在 agent 可观测性里出现频率最高,务必搞清。
- Span(跨度) = 一段有开始和结束的工作。它有:名字、开始时间、结束时间、 一堆属性(attributes)、以及一个父 Span 的 ID。
- Trace(追踪) = 一棵 Span 树,代表一次完整的端到端流程。 树里所有 Span 共享同一个
traceId。
画出来是这样(这就是一次 agent 会话理想的 span 树):
Trace: traceId = a3f9...(一次完整会话)
│
└─ Span: invoke_agent「会话根」 耗时 8m45s ← 根节点,parentSpanId = null
│
├─ Span: chat「第 1 轮 LLM 调用」 耗时 4.2s
│ 属性: model=deepseek-v4-pro, input_tokens=12043, output_tokens=286, ttft=1.8s
│
├─ Span: execute_tool「grep」 耗时 120ms
│ 属性: tool.name=grep, is_error=false
│
├─ Span: execute_tool「read」 耗时 45ms
│
├─ Span: chat「第 2 轮 LLM 调用」 耗时 6.1s
│
├─ Span: blocked_on_user「权限确认」 耗时 12s ← 人在这里犹豫了 12 秒
│
└─ Span: invoke_agent「子代理 explore」 耗时 45s ← 嵌套的子 Agent
│
├─ Span: chat ...
└─ Span: execute_tool ...为什么树形结构如此重要——因为它是 Span 相对 Metric 的唯一价值。
如果只有 Metric,你知道"LLM 调用平均 4.2s、工具调用平均 80ms"。 有了 Span 树,你能回答:
- 这 8 分 45 秒分别花在哪(这叫"归因分解")
- 谁导致了谁(子代理慢,是因为它内部的某个工具卡住了)
- 哪一段是串行等待、哪一段是并行的
一个真实的教训(第 6 章会详述):某个项目落盘了 190MB 的 trace 数据, 文件在、体积在涨、日志轮转都在正常触发。看起来一切健康。
实测:190MB 里的非换行字节 = 0。全是空行。
另一次实测:188 个 span 里 176 个是孤立根节点(
parentSpanId为 null)。 导入 Jaeger 看到的是 176 棵单节点树,而不是 11 棵会话树。父子关系是 span 唯一的价值,而它当时不成立。 更狠的是:这个损失是数据层面 的,不是视图层面的——后端无法重建你没采到的父子关系。
1.3 SpanKind:给 Span 分类
不能所有 Span 都叫"干了点活"。业界的 agent 语义约定通常分这几类:
| SpanKind | 代表什么 | 典型属性 |
|---|---|---|
invoke_agent | 一次 agent 调用(会话根 / 子代理) | agent.name、总轮次、总成本 |
chat | 一次 LLM 调用 | model、input/output tokens、TTFT、finish_reason |
execute_tool | 一次工具执行 | tool.name、duration、is_error |
blocked_on_user | 等人(权限确认、澄清提问) | 等待时长 |
hook_execution | 一次 Hook(生命周期钩子)执行 | hook 名、耗时 |
blocked_on_user 单列是个精妙的设计:它把"人的思考时间"从"系统的耗时"里剥出来。 不剥的话,用户去喝了杯咖啡会让你的 p99 延迟指标毫无意义。
面试加分点:主动提"要区分 wall-clock 时间和系统实际工作时间"。 延伸一步:还有更阴的情况——宿主机休眠。笔记本合盖 717 秒, 你用
Date.now()算出来的"agent 耗时"会包含这 717 秒, 而超时闸门用的是"可运行时间",于是两套判据在同一时刻给出相反结论: 耗时字段说超了上限,超时标记却说没超时——看起来像闸门坏了,其实是时钟口径不一致。
1.4 OpenTelemetry(OTel):为什么要用标准
OTel 是 CNCF 的可观测性标准,解决一件事:采集和后端解耦。
没有标准的世界: 用 OTel 的世界:
你的代码 你的代码
├─ Datadog SDK → Datadog └─ OTel SDK → OTLP 协议 ┬→ Datadog
├─ Jaeger SDK → Jaeger ├→ Jaeger
└─ Prometheus → Prometheus ├→ Prometheus
└→ 换后端不改代码
换后端 = 改所有埋点代码要记住三个东西:
- OTLP —— 传输协议(有 gRPC 和 HTTP 两种)。"支持 OTLP"是企业级的入场券, 因为客户已经有自己的后端了。
- 语义约定(Semantic Conventions) —— 字段该叫什么名字的规范。 比如模型名必须叫
gen_ai.request.model而不是model_name或llm_model。 针对 AI 的那部分叫 GenAI 语义约定。 - 三信号 —— Traces / Metrics / Logs,OTel 都覆盖。
为什么语义约定值得单独强调
假设你的字段叫 model_name,别人的叫 gen_ai.request.model。 后果不是"不好看",而是:
- 所有现成的 dashboard、告警规则、AI 可观测性平台全都认不出你的数据
- 你得自己写一套映射层,而且每换一个后端写一次
这里有个极容易混淆的判定,面试可能会考:
「用了 OTel SDK」 ≠ 「对齐了 GenAI 语义约定」。 这是两件独立的事。
实测过的三个真实情况:
- 项目 A:用官方 OTel SDK、OTLP 出口三信号真通 —— 但字段名全是自定义的,
gen_ai前缀零命中。结论应该是「协议通了、语义没对齐」。- 项目 B:
gen_ai有命中,看起来对齐了 —— 逐行看,那 13 行只是引入了opentelemetry_semantic_conventions这个包,实际用的还是自定义字段。- 项目 C:字段名严格对齐 GenAI —— 但出口是封闭的(只能落本地文件, OTLP 没接通)。
三个项目在一张对比表里如果只有 ✅/❌ 两档,会被记成同一档。 正确做法是拆成两格分别评:数据模型对齐度 / 出口连通性。
1.5 上下文传播:Span 怎么知道自己的爹是谁
这是实现层面最容易出 bug 的地方,也是面试的好考点。
问题:execute_tool 这个 Span 在工具函数里创建。工具函数是被层层调用的, 它怎么知道当前的 chat Span 的 ID?
三种做法:
| 做法 | 怎么做 | 问题 |
|---|---|---|
| 显式传参 | 每个函数多一个 spanContext 参数 | 污染所有函数签名,且必然漏(新加的调用点忘传) |
| 全局变量 / 栈 | 一个模块级的栈,push/pop | 并发(多个子代理同时跑)时会串台 |
| 异步上下文(推荐) | Node 的 AsyncLocalStorage、Go 的 context.Context | 正解 |
AsyncLocalStorage(简称 ALS)的原理:它给每条异步调用链一个隐式的"随身背包"。 你在链条起点往包里放东西,链条上任何深度的函数都能取出来,而不同的链条互不干扰。
用户输入 A ─→ chat span ─→ 工具 ─→ 深层函数 ← 从背包取到 A 的 spanId
用户输入 B ─→ chat span ─→ 工具 ─→ 深层函数 ← 从背包取到 B 的 spanId
↑ 同一个函数,不同的背包,不串台实践中常见两层上下文:
- 交互级(interaction):一次用户输入 → 完整响应
- 工具级(tool):一次工具执行
取的时候优先取最内层:getCurrentToolContext() ?? getCurrentInteractionContext()。
另外两个实现细节,都是踩过坑的
① 内存泄漏:Span 要用弱引用 + TTL 清理
活跃 Span 存在一个 Map 里。如果某个 Span 因为异常路径没有被 end(), 它就永远留在 Map 里。一个长会话跑几小时,Map 会无限膨胀。
两个措施:
- 用
WeakRef存(让 GC 能回收) - 加 TTL 定时清理(比如 30 分钟没结束的孤儿 Span 直接扫掉,每 1 分钟检查一次)
② 注册时序:这是一类会反复复发的 bug
Span 通常不是业务代码直接创建的,而是监听生命周期事件(Hook)来创建:
SessionStart 事件 → 创建会话根 span (invoke_agent)
BeforeModel 事件 → 创建 chat span
AfterModel 事件 → 记录 TTFT / token,结束 chat span
PostToolUse 事件 → 创建并结束 execute_tool span
SessionEnd 事件 → 写入总计,结束根 span坑在这里:如果 SessionStart 在探针注册之前就已经 fire 了,那个事件 就丢了——于是会话根 span 永远不会被创建,覆盖率恒为 0%。
实测过的形态:app.ts:2666 fire 了 SessionStart,:2673 才初始化遥测系统, 探针在 init-helpers.ts:110 才注册。差 7 行代码,导致 0% 的根 span 覆盖率。
而且注意:这类 bug 不报错。你只会发现"trace 树没有根", 而根因在一个完全不相关的文件的第 2666 行。
同一形态在同一个仓库复现过三次(子代理 hook 接线顺序、遥测探针、会话根 span)。 这说明它不是偶发失误,是架构层面缺一条约束: 「所有事件订阅必须早于第一次 fire」这件事没有任何机制在保证。
面试可以答的解法:① 事件总线支持"重放最近 N 个事件给新订阅者"; ② 或者把根 span 改成启动时主动重建,不依赖事件。后者是实际采用的方案。
1.6 一个 Span 的完整生命周期(代码级)
把上面串起来,一次 LLM 调用的 span 从生到死:
① 创建 BeforeModel 事件触发
├─ 生成 spanId(8 字节随机 → 16 位 hex)
├─ 从 ALS 背包里取当前 traceId 和 parentSpanId
├─ 记 startTime
└─ 存进 activeSpans(WeakRef)
② 填充 流式响应过程中
├─ 首个内容 chunk 到达 → 记 TTFT ← 口径极易搞错,见第 2 章
└─ 流结束 → 记 input/output/cache tokens
③ 结束 AfterModel 事件触发
├─ 记 endTime、计算 duration
├─ 记 finish_reason
├─ 从 activeSpans 移除
└─ enqueue 到导出队列 ← 注意这一步
④ 导出 批量导出器
├─ 队列满 batchSize(如 512)或定时器到(如 5s)触发 flush
├─ 写 traces.jsonl / 发 OTLP / 转 Perfetto 格式
└─ 队列溢出时丢弃最旧的 10% ← 有损,是刻意的
⑤ 关闭 进程退出
└─ graceful shutdown 强制 flush(带硬超时,如 500ms)第 ③ 步的 "enqueue" 有个陷阱值得单独讲,因为它是个很好的面试题材:
如果 span 只在
end()的时候才入队,那么所有没能正常结束的 span 全部丢失。实测数据:
SessionStart事件 41 次,SessionEnd事件 17 次 → 58.5% 的会话 没有正常结束(崩溃、Ctrl-C、被 kill)。于是 58.5% 的会话根 span 根本不存在。而恰恰是这 58.5% 才是你最想排查的—— 正常结束的会话你不需要看。
可观测性的一个通用规律:失败路径的埋点覆盖,比成功路径重要得多, 而它总是被漏掉。 因为写代码时你顺着 happy path 走。
同源的另一个例子:某项目只订阅了 PostToolUse(工具成功), 没订阅 PostToolUseFailure。结果四类失败在 trace 树上完全不存在: ① 工具抛异常、② Hook 阻止、③ 权限拒绝、④ 参数校验失败。
表象是"模型报错了但轨迹里查不到这次工具调用",而且工具失败率统计系统性偏低。
记住这个词:结构性盲区。不是精度不够,是失败路径没接埋点。 它的可怕之处是静默、且总是偏向"看起来更健康"。
第 2 章 · Agent 特有的观测对象
第 1 章讲的东西后端也有。这一章讲只有 agent 才有的部分。这是面试真正的分水岭—— 懂 OTel 的人很多,懂"agent 该测什么"的人少。
2.1 Token:一切的计量单位
Token 是模型处理文本的最小单位(大致 1 token ≈ 0.75 个英文词 ≈ 0.5 个汉字)。 它同时是成本单位和上下文容量单位。
一次 LLM 调用返回的 usage 有这几个数:
| 字段 | 含义 | 单价(相对) |
|---|---|---|
input_tokens | 输入(提示词)token | 1× 基准 |
output_tokens | 输出(生成)token | 3–8× 输入价 |
cache_read_tokens | 从缓存读的输入 token | 约 0.1× 输入价(便宜 10 倍) |
cache_creation_tokens | 写入缓存的 token | 约 1.25× 输入价(贵一点) |
reasoning_tokens | 思考/推理 token(部分模型有) | 通常按输出价 |
四个必须知道的事实:
- 输出比输入贵 3–8 倍。 所以成本主要由输出主导,而大家的直觉总是盯着输入。
output / input比值是个很有用但几乎没人算的指标。 - 缓存读比全价输入便宜约 10 倍。 所以缓存命中率直接等于省钱率。
reasoning_tokens在不同模型族里语义不同:有的模型这个字段 > 0, 有的恒为 0(它把思考过程放在别的地方)。恒为 0 不代表没有思考, 要用另一个字段(比如has_thinking)来区分。input_tokens这个字段的语义在不同厂商之间是不一样的。 这是整个计量体系最危险的暗礁,第 5 章专门讲。
2.2 成本:一等公民
计算公式看起来简单:
cost = (uncached_input / 1e6) × input_price
+ (cache_read / 1e6) × cache_read_price
+ (cache_write / 1e6) × cache_write_price
+ (output / 1e6) × output_price但工程上有四个坑,第 5 章会逐个讲透。这里先给结论:
| 坑 | 后果 |
|---|---|
不同厂商 input_tokens 口径不同 | 成本算错、命中率算出 444% 这种荒谬值 |
| 模型名不在定价表时静默归零 | 换个模型名费用变 0,预算守卫被绕过,用户以为免费实际在烧钱 |
流式响应里 output_tokens 是累积值不是增量 | 直接累加会虚高(0+50+80=130,实际只有 80) |
| 辅助调用(生成标题、压缩摘要、记忆召回)绕过主埋点 | 这部分成本完全不进账,实测能占相当比例 |
第 4 个坑有个专门的名字:影子调用(side call)。它是漏计成本的最大来源, 因为它们不走主 agent 循环,很自然就绕过了埋点。
2.3 Prompt Cache:省钱的最大杠杆
原理
LLM 调用是无状态的——每一轮你都要把完整的对话历史重新发一遍。第 10 轮的输入 大约是第 1 轮的 10 倍。这就是为什么长会话成本会爆炸。
Prompt Cache 让服务端缓存住"前缀",下次只对变化的部分收全价:
第 1 轮:[系统提示 8k][工具定义 4k][对话 1k] → 13k 全价
第 2 轮:[系统提示 8k][工具定义 4k][对话 1k][新增 2k]
└────── 12k 命中缓存(0.1 倍价)─────┘ └ 3k 全价 ┘关键:缓存是前缀匹配的
这决定了一个铁律:
任何动态内容放在前面,会击穿它后面所有的缓存。
举例:如果你把当前时间戳、或者一份每轮都变的 git status 放在系统提示的开头, 那么每一轮的缓存命中率都是 0%。这是最常见、最昂贵的可观测性事故之一。
实测过的一个真实修复:某项目缓存命中率 0% → 46.6% → 83.2%, 做的事情就是按"动态边界"重新排列提示词,把稳定的部分全部提到前面。
该测什么
| 指标 | 口径 | 目标 |
|---|---|---|
| 缓存命中率 | cache_read / promptTotal(分母口径见第 5 章,极易搞错) | 显式缓存 > 70% |
| cache_read / cache_creation 拆分 | 分开记 | 只有 read 涨才是省钱 |
| 缓存断裂次数 + 归因 | 断裂 = 本轮命中率突然掉到 0 | 见下 |
归因必须分清两类断裂,这是关键:
| 断裂类型 | 成因 | 是不是我们的 bug |
|---|---|---|
| 本地前缀断裂 | 我们自己在前缀里插了动态内容 | ✅ 是,必须修 |
| 服务端 TTL 过期 / 网关路由抖动 | 缓存自己过期了 / 请求被路由到另一台机器 | ❌ 不是,无能为力 |
不分清的后果:你会花一周时间找一个不存在的 bug。
一个极精彩的真实教训(面试可以直接讲这个故事):
某项目的缓存埋点只记录了"命中了多少",没记录"写入了多少"。
后果:两种完全不同的故障,在观测数据上塌缩成同一个值—— ① 我们的前缀断了(该改自己) ② 网关根本没有实现缓存写入(该找网关方)。 两种情况都表现为"命中数为 0"。
团队差一步就要给网关方发一封"你们的缓存有问题"的邮件。补上 write 字段之后 才发现是自己的问题。
教训:一个指标如果不能区分两种修法不同的故障,它就不够用。 这条会在第 6 章升级成一条通用法则。
两个族的缓存机制不同,不能用同一个阈值考核
| 机制 | 怎么触发 | 命中率上限 |
|---|---|---|
| 显式缓存 | 你在请求里主动标记缓存断点 | 可以做到 > 70%(可控) |
| 隐式缓存 | 服务端自动判断,你无法控制 | 结构性上限约 60–70% |
拿 70% 去考核隐式缓存的模型,你会永远达不到目标,然后误以为自己实现有问题。
2.4 上下文窗口:agent 特有的稀缺资源
模型的上下文窗口是有限的(比如 200k token)。agent 长任务会不断逼近这个上限。
| 指标 | 说明 | 经验阈值 |
|---|---|---|
| 上下文占用率 | used / window | 有效工作区 < 50–65% |
| 峰值与趋势 | 峰值多少、是单调涨还是有回落 | 单调涨 = 迟早撞墙 |
| compaction(压缩)次数 | 快满了就要压缩历史 | 越少越好 |
为什么 compaction 次数是个成本指标(这个链条要理解):
上下文快满 → 触发压缩,把早期历史摘要掉 → 摘要必然丢信息
→ 模型忘了它读过某个文件 → 重新读一遍那个文件
→ 重复付费 + 多花一轮 + 可能得出不一致的结论所以 compaction 不是"一个优化手段",而是"一个已经付出代价的信号"。
为什么 50–65% 而不是 95%:模型在上下文接近满的时候,中间部分的信息 利用率显著下降(业界叫 "lost in the middle")。塞满不等于用上。
2.5 轮次(turns):成本的最大杠杆
一次任务里 agent 和模型来回了几次,叫轮次。
2× 轮数 ≈ 3–4× 成本。
为什么是超线性——因为每一轮都要重发完整历史:
第 1 轮 input ≈ 10k
第 2 轮 input ≈ 12k
...
第 N 轮 input ≈ N × 10k 量级
总 input ≈ 10k × (1+2+...+N) = 10k × N(N+1)/2 ← O(N²)所以"减少一轮"比"省一点提示词"划算得多。 这是很多人的直觉盲区: 大家花很多精力压缩系统提示词(省的是常数项),却不管轮次(省的是平方项)。
2.6 延迟:TTFT / TTFB / 端到端,以及它们的口径陷阱
这一节的每一条都是踩出来的,也是最容易在面试里问出深度的地方。
三个概念
用户按下回车
│
├──── ① TTFB(Time To First Byte):HTTP 响应头到手
│ ↑ 这中间是:网络 + 网关握手 + 模型排队 + prefill
│
├──── ② TTFT(Time To First Token):第一个内容 chunk 到手
│ ↑ 用户第一次"看到东西"的时刻,体感的关键
│
│ ...流式生成中,逐 token 输出...
│
└──── ③ 端到端耗时:最终答复完成
↑ 中间可能包含 N 轮 LLM + M 次工具 + 人工确认陷阱 A:TTFT 必须算"首个任意内容",不是"首个可视文本"
如果你只在可视文本 chunk 上打点,那么:
- 对于会先输出思考过程(thinking)的模型 → TTFT 系统性虚高几十秒
- 对于纯工具调用的那一轮(模型只输出 tool_use,没有文本)→ TTFT 根本不存在或错到离谱
正确口径:首个任意内容 chunk(含 thinking、含 tool_use)。
陷阱 B:TTFT 每次 fetch 单独计,不跨重试累计
如果一次调用重试了 3 次,第 3 次成功。你的 TTFT 应该是"第 3 次的首字延迟", 而不是"从第 1 次开始算起的总时长"。混在一起会让重试频繁的时段延迟指标毫无意义。
(如果你想观测"用户实际等了多久",那是另一个指标,别和 TTFT 混。)
陷阱 C:TTFB 禁止跨网关路由汇总——这条最反直觉
先看实测数据(51 个会话、1372 个调用对):
| 模型(同一个底层模型,走不同网关路由) | TTFB p50 | TTFT p50 | (ttft−ttfb)/ttft |
|---|---|---|---|
| 路由 A | 484ms | 3983ms | 86.77% |
| 路由 B(同底层模型、同 provider 配置) | — | — | 5.02% |
差 17 倍。 原因:ttfb 的语义在两条路由上根本不同——
- 路由 A 的网关抢先回 header("我接单了"),然后才去问模型 → ttfb 很小
- 路由 B 的网关等模型开始出字才回 header → ttfb 接近 ttft
后果:按 provider 汇总出来的 TTFB p50 = 2665ms 是个假数。它既不描述 A、 也不描述 B,却会让人得出"首字节很快"的结论。
判据:(ttft − ttfb) / ttft 的中位数就是路由缓冲指纹,> 50% 说明 该路由在抢先回 header。
因果方向千万别搞反(这是最容易犯的二次错误): gap 大恰恰发生在网关响应最快的时候(gap > 50% 的样本 ttfb p50 只有 483ms)。 所以这个差值不是"框架开销",是"网关缓冲 + 模型 prefill"。 拿它去做"框架 overhead 拆解"会得出完全错误的优化方向。
工程解法:把"按模型分组的延迟计算"做成单一事实源模块, 并且刻意不提供跨模型汇总的 TTFB API——因为提供了就一定有人用。
这是一个很好的设计原则:危险的口径,用 API 设计来禁止,而不是靠文档提醒。
陷阱 D:一律看 p95/p99,均值会骗人
均值会被大量快请求拉平。慢尾巴才是用户流失点。 一个 p50=2s / p99=45s 的系统,均值可能只有 3s,看起来很健康。
该测的完整清单
| 层次 | 指标 | 回答什么问题 |
|---|---|---|
| 主口径 | TTFT p50/p95/p99 + 端到端耗时 | 快不快 |
| 归因 | TTFB(必须按模型/路由分组) | 卡在网关还是模型 |
| 归因 | 纯生成耗时(单次 fetch,不含重试) | 模型自己多快 |
| 归因 | 整轮 API 耗时(含握手+生成+重试) | 别和 TTFB 混,渲染要标"整轮" |
| 归因 | 工具执行总耗时 | 慢在模型还是慢在工具 |
| 体感 | tokens/sec(输出流速) | 比 TTFT 更贴"生成快慢"的体感 |
| 对照 | TTFT 按缓存命中分桶 | 缓存到底让首字快了多少(唯一对照口径) |
还有两个常见缺口(知道它们存在就是加分项):
- TPOT / ITL(token 间隔时间):决定"打字流畅感"。TTFT 好但 ITL 抖, 体感依然很差。
- Goodput:满足 SLO 的有效吞吐(区别于 throughput)。要先定义 SLO 才能算。
一个真实的分解结论(很有说服力的数据)
实测把端到端耗时拆开(40 个用户轮次):
| 分量 | 全部 40 轮 | 剔除 2 个异常会话后(38 轮) |
|---|---|---|
| LLM 等待 | 4593s(80.6%) | 4221s(89.6%) |
| 工具执行 | 479s(8.4%) | 479s(10.2%) |
| 框架 + 空隙 + 人工确认 | 625s(11.0%) | 12s(0.3%) |
再看框架自身开销(373 个有效样本):
| 口径 | p50 | p95 | max |
|---|---|---|---|
框架侧耗时(ttft − ttfb) | 32ms | 201ms | 333ms |
| 占 TTFT 比例 | 1.10% | 7.25% | 13.70% |
结论:优化框架开销是在优化 1.1%。 98.9% 的 TTFT 发生在 HTTP 响应头到手之前。
这个数据的价值不在数字本身,在于它推翻了一个"看起来很合理"的优化方向。 原本的路线图把"框架 overhead 拆解"列为 P1 首位。实测之后降级了。
面试里如果被问"你怎么决定优化什么",这就是标准答案的形状: 先量,再排序,不要按直觉。 而且要说清分母("373 个同时有 ttfb 和 ttft 的调用对")。
2.7 过程指标:agent 独有,也是"更准"的核心
这一组指标传统后端完全没有,因为传统服务没有"自主决策过程"。
| 指标 | 定义 | 病态判据 |
|---|---|---|
| retry 浪费比 | 重试烧掉的 token / 总 token | > 20% 判病态 |
| 白建连接数 | 建了连接但没拿到有效响应的次数 | — |
| 空转(unchanged observation run) | 最长的「重复调用且返回值完全不变」连续段 | ≥ 3 判病态 |
| backtrack 次数 | 推翻自己前面的结论、重新开始 | — |
| 步数比 | 实际步数 / 最优步数 | — |
| 工具调用失败率 | is_error 的比例 | 见下面那个大坑 |
| tool selection accuracy | 选对工具的比例 | < 90% 说明工具太多或描述太差 |
| 首次 edit 成功率 | 第一次编辑就成功的比例 | 反映编辑协议设计质量 |
| exit status 分布 | end_turn / abort / error / user_interrupt | 见第 7 章 |
"空转"为什么是个绝妙的指标
它抓的是这个形态:
第 12 轮:bash "ls src/" → 输出 A
第 13 轮:bash "ls src/" → 输出 A(一模一样)
第 14 轮:bash "ls src/" → 输出 A(一模一样)模型陷进去了,每一轮都在烧钱且零信息增益。返回值不变这个条件很关键—— 它区分了"合法重复"(比如轮询一个正在变化的状态)和"真的卡住了"。
工具失败率的一个大坑(这个必须知道)
实测某项目 bash 工具失败率 5.5%,看起来需要优化。逐条看那些失败:
- 7/8 是"复现脚本按预期报错" —— agent 写了个脚本去复现 bug,脚本报错 正是它想要的结果。这是正确行为被记成失败。
- 1/8 是真缺陷(agent 删掉了当前目录,然后
pwd -P失败 → 一次成功的 rm 被报成了失败)。
教训:
is_error这个字段混着两类语义完全相反的东西—— "工具坏了" 和 "工具正确地报告了一个预期中的错误"。直接拿它做失败率,方向可能是反的。要么在工具层区分, 要么在分析时按 agent 意图分类。
第 3 章 · 把指标组织起来:四个方向和它们的互斥
第 2 章列了几十个指标。问题来了:指标一多就等于没有指标——没人看得过来, 也没人知道哪个动了要紧。
这一章讲怎么组织。
3.1 主口径 + 辅助口径
每个方向只允许一个主口径(那条进 release 报告的曲线),其余全是辅助口径 (曲线动了之后用来归因的分解项)。
主口径:TTFT p95 ← 只有这条画在 release 曲线上
│
└─ 曲线涨了 20%,往下拆:
├─ TTFB(按模型分组)涨了吗? → 涨了 = 网关/模型侧问题
├─ 纯生成耗时涨了吗? → 涨了 = 模型变慢或输出变长
├─ 工具执行耗时涨了吗? → 涨了 = 慢在工具不在模型
└─ retry 浪费比涨了吗? → 涨了 = 在重试上烧时间为什么必须区分这两层:
- 只有主口径 → 曲线动了说不清为什么,只能干瞪眼
- 全是主口径 → 20 条曲线没人看,等于 0 条
3.2 四个方向
这是一个实际在用的组织方式,可以直接搬到面试答案里。
| 方向 | 主口径 | 服务什么 |
|---|---|---|
| 更快 | TTFT p50/p95/p99 + 端到端耗时 | 体感 |
| 更省 | 单位任务的 token 与成本 | 钱 |
| 更准(内部叫"更少返工/一次做对") | 见下方分层 | 质量 |
| 更安全 | 防线触发率(分母有讲究,见 3.4) | 风险 |
"更准"这个方向的定义要格外小心
"更准" 不是 "模型更聪明"(那不由你控制)。
准确的主语是你的 harness(承载 agent 的那层框架):
同一个模型,在我的框架里返工更少、一次做对的比例更高。
这个定义之所以重要——它把不可控的(模型能力)和可控的(框架设计)分开了。 分层看:
| 层 | 测什么 |
|---|---|
| ① 过程病态率 | retry 浪费比、空转、backtrack、步数比 |
| ② 工具层 | 工具成功率、tool selection accuracy、单步重试率 |
| ③ eval 通过率 | 回归测试套件,每次发布都跑 |
| ④ 编辑一次成功率 | 首次 edit 即成功的比例 |
| 结果层 | 人工介入率、返工率、exit status 分布 |
一个刻意"不追"的指标:hallucination rate
业界很喜欢报"幻觉率 < 5%"。在 coding agent 上刻意不追这个,理由:
没有可复算的 grounded 分母。追它只会得到一个自己定义、自己达标的数字。
它的位置由 ② 工具层和 ③ eval 通过率顶上——那两个有客观分母。
面试加分点:主动说出"某个指标我们刻意不测,因为它没有客观分母"。 这比多列 10 个指标更能显示判断力。知道不测什么,比知道测什么难。
3.3 "更安全"的度量难题:负面事件天然稀疏
安全是"坏事没发生"。所以:
用事故数当指标 → 分母恒 0、曲线恒平 → 分不清是防线起作用还是纯属运气好。
解法:全部换成正面信号。
| 指标 | 说明 |
|---|---|
| 防线触发率 | 防线被命中的比例(分母见下) |
| HITL 介入率(分工具、分规则)+ 确认耗时 | 它同时是"更安全 ↔ 更快"这个 trade-off 的计价器 |
| 权限规则匹配正确率 | 该拦的拦住、不该拦的别拦 |
| policy 端到端拦截验证 | fail-closed 路径的触发计数 |
分母口径决定一切
实测一个例子:某项目有四层安全防线,代码全在、测试全绿。 测触发率的时候,分母如果取"全量任务",信号会被彻底稀释—— 大部分任务根本不涉及安全审计,防线本来就不该触发。
正确做法:分母限定在"审计核查类任务"。 限定之后实测结果是 0% 触发(2 个审计类任务里,0 次触发)。
结论是刺眼的:"防线全在、调用全 0"。
由此得出一条验收判据,值得背下来:
新增一条防线的验收标准不是"build 过 + 单测过", 而是**"在真实会话里被触发过"**。
否则防线自己就成了它当初要消灭的那种死功能。这事真实发生过。
3.4 四个方向必然互斥,所以需要一个仲裁者
这是本章最重要的一点,也是面试的高频考点。
对立关系是明确的、结构性的,不是"注意平衡"这种废话:
| 冲突 | 机制 |
|---|---|
| 更安全 ↔ 更快 | 人工确认(HITL)直接拖慢速度 |
| 更安全 ↔ 更省 | 动态注入的安全上下文伤缓存命中率 = 伤省钱 |
| 更准 ↔ 更省 | 多验证一步更准,但那一步要花钱 |
| 更准 ↔ 更快 | 同上,验证要时间 |
四个指标不可能同时拉满。没有仲裁者时,四条曲线会互相欺骗。
"互相欺骗"的具体形态:
你优化了"更省":把验证步骤砍了 → 成本 ↓ 15%
→ 但返工率 ↑,用户重跑一遍 → 真实总成本 ↑
→ 而你的"更省"曲线显示进步了 ✅所以你需要一个能仲裁的复合指标。业界公认的那个是:
cost per successful task(= 总成本 ÷ 成功率) 或者它的倒数 tasks-per-dollar
它天然把"省"和"准"绑在一起:砍验证省下的钱,如果导致成功率下降, 这个指标会立刻变差。
但它有个前提:你必须先定义"任务成功"信号。这在 coding agent 上很难 (怎么自动判断一个 PR 是不是真的修好了 bug?),所以很多项目算不出来。
面试标准答案的形状: 「我会先列四个方向各一个主口径,然后明确说出它们两两之间的 trade-off, 最后指出需要一个复合指标(cost per successful task)做仲裁—— 但要坦白说这个指标的前提是能定义任务成功,而这在 coding 场景很难, 所以现实做法是分方向看曲线 + 人工判断 trade-off 是否可接受, 并且每次改动都要点破它牺牲了哪个方向。」
3.5 stock 与 flow:一个会算错所有比率的口径区分
这个概念简单但杀伤力极大,独立成节。
| 口径 | 含义 | 例子 |
|---|---|---|
| stock(存量) | 某一时刻的快照值 | "当前上下文占用 18k token" |
| flow(流量) | 一段时间的累加值 | "本会话累计发送了 250k token" |
铁律:stock 和 flow 不能相除。
一个真实的错法:
命中率 = 累计缓存命中 (flow) / 末次 input_tokens (stock)
= 80k / 18k
= 444% ← 荒谬,但代码不会报错正确做法:分子分母同口径。要 flow 就两边都用累计值:
命中率 = 累计缓存命中 / 累计 promptTotal ← 两边都是 flow ✅所以数据结构层面就要同时维护两个字段,且命名上让人看出区别:
| 字段 | 口径 | 用途 |
|---|---|---|
inputTokens | stock(末次值) | 展示"当前上下文占用" |
cumulativePromptTokens | flow(逐次累加) | 做命中率/省钱的分母 |
面试题:「给你一个
total_tokens_sent和一个total_cost_usd, 你怎么算平均单 token 成本?」陷阱在于:如果
total_tokens_sent是末次快照(stock)而total_cost_usd是累加(flow),那么这个除法结果毫无意义。正确答案是先确认两个字段的口径, 而不是直接除。这道题考的就是有没有 stock/flow 意识。
3.6 分母比分子重要
上一节的延伸,也是一条通用铁律:
"命中率" "成功率" "触发率" 的分母口径一变,曲线就整体平移。 分母必须和指标写在一起。
三个例子:
| 指标 | 分母 A | 分母 B | 差别 |
|---|---|---|---|
| 防线触发率 | 全量任务 | 审计类任务 | 差几十倍,见 3.3 |
| 缓存命中率 | 末次 input(stock) | 累计 promptTotal(flow) | 能差出 444% |
| cost 覆盖率 | 全部会话 | 有真实 LLM 调用的会话 | 前者会把空会话算进去,覆盖率虚低 |
实操建议:写指标文档时,分母和指标名写在同一行。 不要写"缓存命中率 46.6%",要写"缓存命中率 46.6%(分母=累计 promptTotal, 样本=41 个有 events.jsonl 的会话)"。
第 4 章 · 从零到一:六个级别的实操路线
前三章讲"是什么、测什么、怎么组织"。这一章讲动手顺序。
为什么要分级:可观测性最容易犯的错是"一上来就建大而全的体系", 结果三个月后有一套没人看的仪表盘。分级的价值在于每一级都独立可用—— 做完 L1 就能解决一部分问题,不需要等 L6。
L1 一行结构化日志 ← 半天,立刻能用
L2 落盘 + 会话目录 ← 一天,能回溯昨天的会话
L3 Span 树 ← 三天,能做归因分解
L4 Metric 聚合 ← 三天,能画趋势、发现退步
L5 排查工具(嚼碎数据) ← 一周,把数据翻译成结论 ★ 收益最高
L6 企业级出口 + 隐私 ← 两周,能卖给企业如果你只能做一级,做 L5。 理由在 0.3 节:数据不缺,缺的是翻译。
L1 · 一行结构化日志
目标
任何一次 LLM 调用和工具调用,都留下一条机器可解析的记录。
关键:结构化,不是 printf
❌ 错:console.log(`调用了 ${model},花了 ${ms}ms`)
→ 想统计 p95 得写正则解析,模型名里有空格就崩
✅ 对:一行 JSON
{"ts":1756...,"kind":"llm_call","model":"deepseek-v4-pro","ttft_ms":1823,
"elapsed_ms":4210,"input_tokens":12043,"output_tokens":286,"cache_read":9800}JSONL 格式(每行一个 JSON 对象)是这个阶段的最佳选择:
| 优点 | 说明 |
|---|---|
| 追加写 | 崩溃安全——写到一半只坏最后一行,前面全部可读 |
| 流式可读 | 不用把整个文件读进内存 |
jq 直接查 | 不需要任何基础设施 |
| 零依赖 | 不需要数据库、不需要服务端 |
这一级要定下来的三个字段规范
| 字段 | 为什么现在就要定 |
|---|---|
ts | 时间戳。用毫秒 epoch 数字,不要用字符串——字符串时区会坑你 |
session_id | 会话标识。没有它,所有跨会话分析都做不了,后面补极其痛苦 |
kind | 事件类型。用闭集枚举,不要自由字符串——否则半年后有 47 种拼写变体 |
常见错误
- 在业务代码里到处
logEvent()→ 后面想改格式要改几百处。 正解:收口到一个门面函数,业务代码只调它。 - 把日志写成同步阻塞的 → 磁盘慢的时候拖慢主流程。 正解:入队 + 批量刷。
L2 · 落盘:会话目录与文件分工
目标
一次会话结束后,能从磁盘完整重建"刚才发生了什么"。
一个实际在用的文件布局
~/.your-agent/
├── trajectories/sessions/{session_id}/
│ ├── session.traj 主轨迹:结构化步骤数组 + 汇总统计(原子覆盖写)
│ ├── raw.jsonl 逐次 API 的原始 request/response(追加写)
│ ├── events.jsonl 生命周期事件时间线(追加写)
│ └── messages.json 崩溃验尸快照:完整消息历史 + 退出归因
├── usage-ledger.jsonl 跨会话账本:每个会话一行汇总
└── protocol-violations/ 协议违规记录(比如孤儿 tool_use)四个文件为什么要分开——每个都有唯一职责
| 文件 | 职责 | 写入方式 | 为什么不能合并 |
|---|---|---|---|
session.traj | 给人和分析脚本读的结构化视图 | 原子覆盖 | 需要随时是一个完整合法的 JSON |
raw.jsonl | 原始报文,最后的真相来源 | 追加 | 体积大得多;且覆盖写会丢历史 |
events.jsonl | 事件时间线 | 追加 | 粒度比轨迹细,用途是时序对齐 |
messages.json | 崩溃验尸 | 退出时一次性 | 只有崩溃时才有价值,平时不该占空间 |
"原子覆盖" 是什么意思、为什么必须:
❌ 直接覆盖:truncate 文件 → 写入
→ 如果在中间崩溃,你得到一个半截的、无法解析的 JSON。原数据也没了。
✅ 原子覆盖:写到 session.traj.tmp → fsync → rename 成 session.traj
→ rename 在同一文件系统上是原子的。崩溃时你要么拿到旧版本、要么拿到新版本,
绝不会拿到半截。这一级最容易漏的:崩溃容错
关键认知:你最想排查的会话,恰好是没能正常结束的那些。
实测数据:SessionStart 41 次 vs SessionEnd 17 次 → 58.5% 的会话没正常结束。
所以:
| 做法 | 后果 |
|---|---|
| ❌ 只在会话结束时写盘 | 丢掉 58.5% 的数据,且丢的正是你要的 |
| ✅ 每轮结束就追加落盘 | 崩溃时最多丢最后一轮 |
同一条原则还有个推论——恢复会话的轮次要能续接。 如果 agent -c 续接的会话轮次号从 0 重新开始,那么同一个任务的轨迹在分析时 会被切成两段互不相关的会话,所有"turns per task"统计全错。
做法:启动时读已有 raw.jsonl 数已有的轮次;如果文件已被清理, 回退读汇总里的 total_api_calls。
还有一个几乎所有人都忘的:PID 文件与残留清理
进程崩了之后,如果你有后台心跳、临时文件、锁文件,它们会留下来。 下一次启动要能识别"这个 PID 已经死了"(process.kill(pid, 0) 判活)并清扫。
L3 · Span 树:从"一堆事件"到"一棵因果树"
目标
回答"这 8 分钟分别花在哪",且能看出谁导致了谁。
实现顺序(照这个顺序做,别跳)
① 先定 SpanKind 闭集 invoke_agent / chat / execute_tool / blocked_on_user / hook_execution
↓
② 上下文传播机制 AsyncLocalStorage(或语言对应物),两层:交互级 + 工具级
↓
③ 生命周期事件驱动创建 监听 Hook 事件,不要在业务代码里手写 startSpan
↓
④ 生命周期管理 WeakRef + TTL 清理孤儿
↓
⑤ 批量导出 队列 + 定时 flush + 关闭时强制 flush
↓
⑥ ★ 验证树是否成形 ← 90% 的人跳过这步,然后白干第 ⑥ 步必须做,而且判据要选对
这是 L3 唯一真正困难的地方。回顾第 1 章那个案例:188 个 span 里 176 个是孤立根。
判据设计是有讲究的,这里有个真实踩过的坑:
# ❌ 错的判据:「invoke_agent 数量 != 0」
# 为什么错:子代理也产生 invoke_agent span。
# 子代理跑了几次,这个判据就"通过"了,而会话根依然是 0。
# 这个判据会被伪装成 PASS。
# ✅ 对的判据:分两条,都要过
# 条件 1:会话根 span 的数量 == 正常退出的会话数,且这些 span 的 parentSpanId 为 null
cat ~/.your-agent/telemetry/traces*.jsonl \
| jq -c 'select(.kind=="invoke_agent" and .parentSpanId==null)' | wc -l
# 条件 2:根 span 总数 == traceId 去重数(每棵树恰好一个根)
# ⚠️ 不要 sort -u 整行!去重会把「28 个根」压成「1 个根」,得出假 PASS这里有一条元教训,比技术细节更值钱:
一个验收判据,如果在系统完全没修的状态下也能显示 PASS,那它就是假门禁。
检验办法叫变异自证:故意把被测功能改坏(或者干脆删掉), 看你的判据是否变红,以及红的是哪一条。不变红 = 这个判据从来没在测东西。
这是面试里非常强的一个信号点。大多数人只会说"我加了测试", 能说出"我验证了我的测试真的能测到东西"的人很少。
Span 属性该记什么
按语义约定命名(第 1.4 节),最小集:
| Span 类型 | 必记属性 |
|---|---|
| 全部 | session_id、turn、开始/结束时间 |
chat | model、input/output/cache tokens、TTFT、finish_reason、cost |
execute_tool | tool.name、duration、is_error |
invoke_agent | agent.name、总轮次、总成本、exit_status |
blocked_on_user | 等待时长(这是唯一目的) |
L4 · Metric 聚合:从"单次"到"趋势"
目标
发现缓慢的退化。这是 Span 做不到的——你不会去逐条对比一万个 span。
四种 instrument(记住这四个词)
| 类型 | 用途 | agent 里的例子 |
|---|---|---|
| Counter | 只增不减的累加 | 总调用次数、总 token |
| Gauge | 瞬时值 | 当前上下文占用率 |
| Histogram | 分布(能算 p50/p95/p99) | TTFT、每任务轮次 |
| UpDownCounter | 可增可减 | 活跃子代理数 |
这一级最重要的一条:Counter 算不出分位数
这是个非常常见的缺陷,而且它不报错:
你有 8 个 metric,全是 Counter:
llm_calls_total、tokens_total、cost_total、ttft_sum ...
想问「TTFT 的 p99 是多少」→ 答不了。
ttft_sum / llm_calls_total 只能给你均值,而 3.x 讲过均值会骗人。判定要点,面试可以直接用:
「有埋点」≠「有分布」。 只报"我们采集了 TTFT",会把 「能算 p99」和「只能看单次值」记成同一档。
所以延迟类指标必须用 Histogram,且要自己定 bucket 边界(默认边界通常 是给 HTTP 请求设计的,几十毫秒量级,套在 agent 上——动辄几十秒——全部落进最后一个桶)。
参考量级:延迟类可以做几十段 ms 边界 + 二十来段 s 边界。
一个反直觉的正确取舍
如果你的后端只支持 Counter/Gauge,不支持 Histogram,怎么办?
有个真实项目的选择是:把 histogram 降级导出成 gauge,而不是自己硬造 bucket。 代码注释写的理由是:
硬造 bucket 边界造出来的分布是错的,错数据比没数据更坏。
这个判断值得记住,它是"可观测性作为决策依据"这个定位的直接推论—— 运维仪表追求"不漏"(宁可多采),决策依据追求"不错"(一个错数字比没数字更坏)。
跨会话聚合:账本
Metric 通常是给外部后端的。但你还需要一份本地的跨会话账本,因为:
- 开发者要能在本机敲一个命令看"我这周花了多少钱"
- 不能要求每个用户都部署一套 Prometheus
做法:每个会话结束写一行汇总到 usage-ledger.jsonl,然后按 day/week/month 聚合。
⚠️ 注意覆盖率:账本只在"正常 SessionEnd"时写。实测某项目覆盖率 只有 1/90——因为大部分会话没正常结束。所以账本要么补一条崩溃时的兜底写入, 要么在分析时明确知道它的覆盖率,否则会把"没有数据"误判成"成本为 0"(见 7.3)。
L5 · 排查工具:把数据翻译成结论 ★
这一级收益最高,也最容易被跳过。
问题:数据齐全,但用起来极其低效
假设排查"上次那个会话怎么崩了",在没有工具的情况下,人或 AI 要走这一串:
1. 想起来有可观测性数据
2. 搞清楚数据落在哪个目录
3. 搞清楚哪个 session 是"上次那个"(90 个目录,名字都是哈希)
4. cat 出 session.traj,啃它的 JSON 结构
5. 人肉找异常点(exit_status?孤儿 tool_use?哪步报错?)
6. 再决定去翻 raw.jsonl 还是 messages.json 还是 protocol-violations/这 6 步每次都要重来一遍,花掉几十轮 token 和好几分钟, 而且经常啃错文件、漏掉信号。
解法:把第 2–5 步固化成代码
一个命令,3 秒拿到嚼碎的摘要:
━━━ session c8577840 [error] ━━━
模型 deepseek-v4-pro API 17 次 步骤 46 耗时 8m45s 成本 $0 tok 32732↑/4260↓
用户意图:
1. 我现在需要修复bug编号为 1646846 的bug...
⚠ 异常信号 (4):
[高] 异常退出: exit_status=error —— 会话因运行时异常终止
→ 看: messages.json (验尸快照,看 attribution) + raw.jsonl 末行
[高] 孤儿 tool_use: 1 个 tool_use 无对应 tool_result —— 通常是中途崩溃或协议违规
→ 看: protocol-violations/ 目录 + messages.json
[中] 工具执行失败: 1 次工具调用报错(见工具序列中标 ✗ 的步骤)
[中] 协议违规记录: 有 3 条疑似与本会话时间相近的记录
工具序列 (22 次调用):
· grep pattern=bug-sync
· glob pattern=**/*bug*sync*
· bash command=which bug-sync ...
✗ read file_path=...
思维链要点:
💭 ...(Agent 当时的推理)
深挖原始数据:
完整轨迹 ~/.../c8577840/session.traj
原始请求响应 ~/.../c8577840/raw.jsonl
崩溃验尸 ~/.../c8577840/messages.json一屏之内你就知道:这会话崩在工具链中途(有孤儿 tool_use + 协议违规), 最该看 messages.json 的 attribution。不用 cat 任何文件就有了方向。
输出结构:六个区块,各回答一个问题
| 区块 | 回答什么 |
|---|---|
| 头部状态行 | 哪个会话、什么模型、多久、多少钱、退出状态 |
| 用户意图 | 这会话本来要干什么 |
| 异常信号 | 有没有问题、多严重、下一步看哪个文件 ← 最重要 |
| 工具序列 | 做了哪些操作(·正常 ✗报错 ○孤儿) |
| 思维链要点 | 为什么这么做 |
| 深挖指针 | 各原始文件的绝对路径 + 各自该看什么 |
"下一步看哪个文件"这一条是设计精髓:它把"发现异常"和"定位根因"衔接起来了。 只报"有异常"的工具,用户还是得自己猜去哪看。
核心洞察
让 AI 更省力的办法,不是让它更会读文档,而是把脏活变成它能一键调用的工具, 让它读一份已经嚼碎的摘要。
这句话可以推广到所有 AI 工具设计:确定性的活交给代码,别让模型每次现学。
七类异常检测器
| 检测器 | 触发条件 | 严重度 | 指向 |
|---|---|---|---|
| 异常退出 | exit_status=error | 高 | messages.json + raw.jsonl 末行 |
| 中止 | exit_status=abort | 中 | — |
| 孤儿 tool_use | 有 tool_use 无对应 tool_result | 高 | protocol-violations/ + messages.json |
| 工具执行失败 | is_error 或正文含 error/failed | 中(≥3 次升高) | 工具序列标 ✗ 的步骤 |
| 疑似循环 | 同形状连续 ≥4 次 | 中 | 主轨迹 |
| 成本归零存疑 | 有账本条目且 cost=0 且非本地模型 | 低 | 成本计算模块 |
| 数据格式异常 | 解析成功但缺核心字段 | 高 | 见下方 ② |
每类检测器的误判特性也要写进文档(不然用户不知道能多信它):
- 异常退出:最可靠,几乎不误判。
- 孤儿 tool_use:排查崩溃的头号线索。强烈暗示中途崩溃或协议违规 (模型生成了非法工具调用,服务端拒绝)。
- 工具执行失败:有轻微误报——工具正常返回的内容里含 "error" 字样 (比如读了个叫
error.log的文件)会被算进去。所以 1 次不升级严重度。 - 疑似循环:最容易误判。合法的分段读大文件、多点编辑、反复试不同 bash 命令都会触发。所以只标"疑似"+中优先级,让人去确认。
两个踩坑后改的关键设计(都很值得学)
① 成本归零判据:误报率从 95% 降到 0%
最初判据:"消耗了 token 但 cost=0 就报异常"。 实测 90 个历史会话里 86 个触发——因为绝大多数会话根本没有账本条目 (账本覆盖率只有 1/90)。
"没有成本数据" 是数据缺失,不是成本异常。
改成"必须有账本条目、且账本里 cost 确实是 0、且非本地模型"才报,误报率 95% → 0%。
教训:判异常之前先确认数据真实存在,否则信号会被噪声淹没。 一个 95% 误报的检测器比没有检测器更糟——用户会学会忽略它。
② 防静默失效:schema 漂移必须大声报错
排查脚本和轨迹构建器的输出格式强耦合。如果哪天构建器改了格式:
JSON 仍然能解析成功 → 但所有字段提取都得到 undefined
→ 脚本报告"这会话无异常" → 骗过了用户和 AI这是最危险的失败模式:假装健康。
解法:加一道 schema 健全性校验。解析成功但缺核心键时,明确报 "数据格式异常,构建器 schema 可能变了,摘要不可信,直接看原始文件"。
通用原则:宁可报"我不知道",也不要报"没问题"。 这两者对用户的价值完全不同——前者让人去看原始数据,后者让人放心地错过 bug。
让 AI 自动用上它:四层递进兜底
工具建好了还有一个问题:AI 不一定想得起来用。 解法是分层叠加:
① 地基:确定性逻辑 把「定位→解析→找异常→指文件」固化成代码
↓ 被两处复用
② 出口:两个等价入口 内置斜杠命令(零摩擦)+ 独立脚本(外部 AI/终端可用)
↓ 教 AI 何时用、怎么解读
③ Skill:方法论包 带触发描述,AI 读描述自判是否该用
↓ AI 没想到 Skill 时,勾它一下
④ Memory:一条召回指针 常驻上下文里的一行提示分工:地基负责"干活",命令/脚本负责"能调到",Skill 负责"教会怎么用 + 自动触发",Memory 负责"让 AI 一开始就想起来有这东西"。
为什么不只靠一层(这是原始设计里的判断,很实在):
| 触发方式 | 可靠性 | 适合放什么 |
|---|---|---|
| Memory 自动召回 | 中低 | 只能放一句话指针。放完整流程 = 召回不稳 + 常驻吃 context |
| Skill 自动触发 | 中(靠模型读描述自判) | 方法论 |
| 命令手动触发 | 最高 | 但要用户记得敲 |
真正的瓶颈不在"怎么触发",而在"AI 每次要重新理解整套体系"。 这件事是确定性的、可脚本化的,不该靠概率推理每次现学。 所以地基才是杠杆,三种触发只是递进兜底。
L6 · 企业级:出口、隐私、可靠投递
目标
企业客户已经有自己的可观测性后端了。你要能接进去,且不能泄露他们的代码。
三件事
① OTLP 出口 + 三信号
支持标准协议,让客户自己指定 endpoint。要点:
- Traces / Metrics / Logs 三信号都支持
- 支持 gRPC 和 HTTP 两种传输
- 验证"出口真通"——不是"代码写了导出器",而是起一个真实的 loopback HTTP 服务器,验证数据真的发出去了。 (一个好做法:再测一个故意打不通的端点,验证失败路径不会崩主流程。)
② 分级隐私模型
典型三级:
| 级别 | 采什么 |
|---|---|
| 最严 | 只有计数和时长,没有任何内容 |
| 中 | 加上工具名、模型名(脱敏后的) |
| 宽 | 允许 prompt/response 原文(默认必须关) |
关键设计:脱敏在入口强制,不在出口靠白名单。
❌ 出口过滤:每个后端自己记得过滤 → 新加一个后端就漏一次
✅ 入口强制:门面函数就把敏感字段剥掉,下游拿不到原始值一个更强的做法是用类型系统禁止:把参数类型定义成 Metadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 这种名字, 让传参的人必须显式断言一次。丑,但有效——它把"记得脱敏"从纪律变成了编译期约束。
③ 可靠投递
网络会断。所以:
发送失败 → 落盘到本地失败队列 → 指数退避重试 → 成功后清理⚠️ 注意 fire-and-forget 与可靠投递是两个不同的诉求:
- 主流程:绝不阻塞,失败静默吞掉(遥测挂了不能拖垮 agent)
- 投递层:内部做重试,尽最大努力送达
关于"内容级 tracing"(记录 prompt 原文)
这是最敏感的一块。要点:
| 要求 | 原因 |
|---|---|
| 默认关闭 | 这是代码,泄露了很严重 |
| 独立开关 | 不能和"开遥测"共用一个开关 |
| 截断按字节,不按字符 | 多字节字符按字符截会截出半个字符 |
| 脱敏必须在截断之前 | 顺序反了,密钥可能刚好被截断到边界外躲过正则 |
有个真实事故:某项目的脱敏正则改动之后(为了匹配信用卡号加了小数处理), 导致整份 JSON 不可解析。教训:脱敏函数改动必须验证产物仍然合法, 不能只验证"敏感串没了"。
第 5 章 · 数字凭什么可信:计量精度四道防线
这是本文最硬的一章。 前面所有章节都在引用 cost、命中率、省了多少钱 这些数字,但这些数字本身的可信度从何而来?
根基错了,上层所有看板、聚合、评测、告警全都是错的——而且不会报错。
如果面试只能准备一章,准备这一章。它是"我真的做过"和"我读过几篇博客"之间的分界线。
5.1 核心难题:inputTokens 这个字段,不同厂商语义相反
这是整个计量体系最危险的暗礁。
假设一次调用:总提示 50k token,其中 40k 命中了缓存。
| 厂商族 | 字段语义 | inputTokens 的值 |
|---|---|---|
| A 族(Anthropic 式) | input_tokens = 未命中的余量(已排除命中和写入) | 10k |
| B 族(OpenAI / DeepSeek 式) | prompt_tokens = 含命中的完整输入 | 50k |
同一次调用,同一个字段名,一个是 10k 一个是 50k。
如果不加区分直接拿它算,会得到两类严重错误:
| 错误 | 机制 | 后果 |
|---|---|---|
| 命中率虚高 | B 族多轮对话里,末次 inputTokens(stock)≈18k,但累计命中(flow)可达 80k+ | 80k / 18k = 444% |
| 成本虚低 | A 族的 inputTokens 本来就是未命中余量,你再减去命中 token 就是重复扣减 | 少算钱 |
为什么这件事在单一厂商项目里不存在:如果你只接一家 API,你的代码里那个隐含假设 永远是对的。一旦接了网关、接了第二个厂商,它立刻变成一个静默的算错钱。
5.2 防线①:accumulateUsage() —— 累积值 vs 增量
先说一个比上面更隐蔽的坑,因为它在流式响应里。
流式响应会分多次推送 usage 更新。问题是:
A 族的
message_delta.usage.output_tokens是累积值,不是增量。
第 1 个 delta:output_tokens = 0
第 2 个 delta:output_tokens = 50 ← 这是"累计已生成 50",不是"又生成了 50"
第 3 个 delta:output_tokens = 80 ← 累计 80
❌ 直接累加:0 + 50 + 80 = 130 ← 虚高 62%
✅ 取最后一个:80修法:写一个单一权威累加实现,所有流处理路径共用。
⚠️ 这一点特别容易被破坏:一个项目里往往有 4 套流处理代码 (主循环 / 无头模式 / 子代理 / agentic loop)。如果各自拷一份累加逻辑, 口径必然漂移——修了一处,另外三处还错着,而且测试可能只覆盖了修好的那处。
这道防线还管一件事:缓存字段只在显式提供(!= null)时才累加。
❌ 错:cache_read += usage.cache_read ?? 0
→ 厂商没返回这个字段(undefined)时,被当成 0 累加。
→ 后果:把"不知道"记成了"确定是 0"。
✅ 对:if (usage.cache_read != null) cache_read += usage.cache_read这个区别看起来吹毛求疵,但它正是某个项目"子代理路径命中省钱失真"的根因—— 子代理走的路径厂商不返回缓存字段,于是全部记成 0,省钱数据全废。
通用原则:
undefined(不知道)和0(确定是零)绝不能混。 这条在第 6 章会以另一个形态再出现一次(显式 undefined 击穿默认值)。
5.3 防线②:normalizeCacheUsage() —— 口径归一化
所有涉及"命中率 / 省钱 / 计费"的计算,必须先经过归一化,把厂商差异抹平。
归一化的输出是四段互斥、厂商无关的量:
interface NormalizedCacheUsage {
cacheHitTokens: number; // 命中(读缓存)
cacheWriteTokens: number; // 写入缓存
uncachedInputTokens: number; // 全价输入(未命中部分)
outputTokens: number;
promptTotal: number; // 完整输入 = uncached + hit + write
}实现的关键是那个分支:
if (provider === "anthropic") {
// A 族:inputTokens 本来就是未命中余量,勿再减
uncached = input;
promptTotal = input + hit + write;
} else {
// B 族:inputTokens = prompt_tokens 含命中,需剔除
uncached = Math.max(0, input - hit - write);
promptTotal = input; // prompt_tokens 本来就是完整输入
}promptTotal 是命中率分母的单一事实源。 所有命中率计算——状态栏、 会话指标、账本——统一用 cacheHitTokens / promptTotal,分子分母同为 flow 口径。
这道防线为什么必须是一个函数而不是一段注释: 因为"记得按 provider 分支"这件事,只要依赖人记住,就迟早会漏一处。 实测这个函数有 15 个调用点——如果是散落的 if-else,15 处里漏 1 处就是一个 静默算错钱的 bug,而且只在某个特定 provider 上发生。
5.4 防线③:calculateCost() —— 单一成本真相源
成本计算只能有一个权威实现。所有需要算钱的地方(token 计量器、主循环、 UI)都通过注入回调复用它,而不是各自实现。
const n = normalizeCacheUsage(usage, provider);
// 缺省价的近似关系(不同厂商可覆盖)
const cacheHitPrice = pricing.cacheHit ?? pricing.input * 0.1;
const cacheWritePrice = pricing.cacheWrite ?? pricing.input * 1.25;
cost = (n.uncachedInputTokens / 1e6) * pricing.input; // 未命中,全价
cost += (n.cacheHitTokens / 1e6) * cacheHitPrice; // 命中,约 1/10
cost += (n.cacheWriteTokens / 1e6) * cacheWritePrice; // 写入,约 1.25 倍
cost += (n.outputTokens / 1e6) * pricing.output; // 输出,3-8 倍"省了多少钱"的定义:
savings = 假设 promptTotal 全按未命中全价的虚拟成本 − 实际成本注意这是个虚拟对照,不是实际支出差额。表述上要说清楚, 否则会被误读成"我们账上多了这笔钱"。
这道防线里最重要的一条:未知模型不能静默归零
❌ 错:模型名不在定价表 → cost = 0
后果链条:
换一个模型名(或者厂商改了模型 ID)
→ 费用立刻显示 0
→ 「花费上限」这个守卫恒不触发(0 永远小于上限)
→ 用户以为免费,实际在真金白银地烧钱正确做法:保守兜底 + 显式告警
| 情况 | 做法 |
|---|---|
| 未知的云端模型 | 用保守(偏贵)的兜底价估算(比如按主流模型的 input $3/M、output $15/M)。宁可高估触发预算告警,并按模型名去重记一次 WARN |
| 本地模型(ollama 等) | 恒 0,正确——不产生真金白银费用。且避免误触花费上限中断本地大上下文会话 |
这是一条通用的安全设计原则:当你不知道某个值时,往"会引起注意"的方向偏, 不要往"看起来正常"的方向偏。
归零是往"正常"偏(0 花费看起来最健康),所以是错的方向。
5.5 防线④:stock / flow 严格分离
第 3.5 节讲过概念,这里讲实现层面的落地。
数据结构里同时维护两个字段,且命名让人一眼看出区别:
| 字段 | 口径 | 用途 | 绝不能用于 |
|---|---|---|---|
inputTokens | stock(末次值,含全历史) | 展示"当前上下文占用" | 任何比率的分母 |
cumulativePromptTokens | flow(逐次累加) | 命中率 / 省钱的分母 | 展示上下文占用(会远超窗口大小) |
为什么必须用 flow 做分母(这个推导要能讲出来):
用 stock 做分母(B 族口径):
uncached = max(0, input_stock − hit_flow)
↑ 18k ↑ 80k(多轮累加后远超末次输入)
= max(0, −62k) = 0 ← 被钳到 0!
后果:uncached 恒为 0 → 命中率算成 100% → 省钱数字爆表5.6 四道防线的数据流图
┌────────────────────────────────────────────────────────────┐
│ LLM Provider 流式响应(口径因厂商而异,不可直接信任) │
│ A 族: input=未命中余量 / delta.output=累积值 │
│ B 族: input=prompt_tokens(含命中) │
└──────────────────────────┬─────────────────────────────────┘
│ message_start / message_delta
▼
┌─────────────────────────────────────────────────────────────────┐
│ 防线① accumulateUsage() │
│ · 累积值 → 增量统一 │
│ · 缓存字段仅在 != null 时累加(不拿 undefined 当 0) │
│ · 所有流处理路径共用唯一实现,消灭「各拷一份」的口径漂移 │
└────────────────────────┬────────────────────────────────────────┘
│ 本次调用聚合后的原始 Usage
▼
┌─────────────────────────────────────────────────────────────────┐
│ 防线② normalizeCacheUsage(usage, provider) │
│ 按 provider 分支,派生四段互斥的厂商无关量: │
│ cacheHit · cacheWrite · uncachedInput · output │
│ promptTotal = uncached + hit + write ← 命中率分母的单一事实源 │
└───────────┬────────────────────────────────┬────────────────────┘
│ │
┌───────────▼───────────┐ ┌─────────────▼────────────────────┐
│ 防线③ calculateCost() │ │ 防线④ stock/flow 严格分离 │
│ 三段分别计价,单一成本 │ │ inputTokens = stock(末次,展示) │
│ 真相源 │ │ cumulativePromptTokens = flow │
│ 未知模型保守兜底不归零 │ │ (累加,做命中率分母) │
│ → 花费上限不被绕过 │ │ 二者绝不混用 → 杜绝命中率虚高 │
└───────────┬───────────┘ └─────────────┬────────────────────┘
│ │
└────────────────┬───────────────┘
▼
┌────────────────────────────────────────────────────────────┐
│ 可信指标(下游一切看板/聚合/评测/计费的输入) │
│ cost · cacheSavings · 命中率 · promptTotal │
└────────────────────────────────────────────────────────────┘四道防线任缺其一的具体后果(这张表很适合背下来):
| 缺哪道 | 症状 |
|---|---|
| 缺① | 子代理路径省钱失真(缓存字段被当 0);输出 token 虚高 62% |
| 缺② | 命中率算出 444% |
| 缺③ | 换个模型名费用归零,花费上限守卫被绕过 |
| 缺④ | stock/flow 混用,命中率爆表 |
一句话总结:可观测性的数字可信度,靠的是 「一个归一化函数 + 一个成本真相源 + stock/flow 严格分离 + 累加口径统一」 四道防线。这是数据驱动开发能成立的前提。
5.7 采集覆盖面:影子调用
四道防线保证了"采到的数字是对的"。但还有一个问题:有些调用根本没被采到。
影子调用(side call) = 不走主 agent 循环的辅助 LLM 调用:
| 影子调用 | 用途 |
|---|---|
| 会话标题生成 | 给会话起个名字 |
| 压缩摘要(compaction) | 上下文快满时摘要历史 |
| 记忆召回 / 提取 | 从长期记忆里找相关内容 |
| 子代理内部调用 | 如果子代理有自己的调用路径 |
| 循环检测的 LLM 认知判断 | 问模型"你是不是卡住了" |
它们绕过主埋点是很自然的——因为主埋点挂在主循环上,而它们不在主循环里。
后果:成本账少了一块,且你不知道少了多少。
两个解法
① 收口到同一个生成器(架构解法,最彻底)
让所有 LLM 调用——不管什么用途——都经过同一个函数。 一个真实项目的做法是:32 种调用来源,全部收敛到一个生成器。 这样埋点只需要挂一处,天然不可绕过。
② side-call sink(补救解法)
如果架构上已经绕过了,加一个模块级的"侧信道接收器",让影子调用主动投递用量。
⚠️ 这里有个坑值得单独讲,因为它是"埋点自己变成死能力"的经典形态:
最初做法:给配置对象加一个 onTelemetry 回调,让调用方传进来。
问题:这个回调只在主实例上接线了。子代理走另一条路径、
每次调用新建实例、从不传 onTelemetry。
后果:字段加了、埋点代码写了、测试绿了、
但子代理的事件根本没有消费方,一条都落不到盘上。修法:模块级观察者。全局注册一次,所有路径 dispatch 到它, 绕开"N 个创建点逐个穿线"的问题。
判据:如果一个埋点需要"每个调用点都记得传参数",它就迟早会漏。 改成全局注册 + 主动 dispatch。
5.8 一个额外的验证手段:从原始数据反算
四道防线是"事前"保证。还需要一个"事后"核对手段:
从
raw.jsonl的原始响应里重新算一遍 cost,和落盘的 cost 对比。
用途有三:
| 用途 | 说明 |
|---|---|
| 僵尸会话补写 | 会话崩了没写账本 → 从原始数据重算补上 |
| 远端对账 | 网关的账单和自己算的对不上时,定位差在哪 |
| 防线自检 | 如果重算和落盘差很多,说明某道防线破了 |
⚠️ 注意一个陷阱:原始请求记录的可能是"内部请求对象"而不是真正发出去的 wire body。如果你要验证 wire 层的东西(比如缓存标记是否真的发出去了), 要看响应里的 usage.cache_read,而不是请求里你自己写的标记。
第 6 章 · 十二个真实陷阱
这一章是面试区分度最高的部分。
原因很直接:讲"什么是 Trace/Span"人人都会,讲"我采集的数据本身可能是假的、 以下是十二种假法"——这只能来自真实踩坑。
这些陷阱有一个共同结构,先记住它,比记住十二条本身更有用:
它们全都不报错。 代码在、测试绿、文件在、数字在、机理讲得通。 而结论是错的。
这就是为什么可观测性的元问题是:你怎么知道你的可观测性没坏?
陷阱 1 · 有代码 ≠ 有能力(死代码被记成资产)
形态
analytics/ 目录 1113 行完整管线:零依赖 API、五阶段过滤、三个导出器、 磁盘缓存、退避重试、隐私双通道、采样、远端 killswitch。测试全绿。
生产埋点调用数:1 个。
为什么难发现
代码质量高、架构漂亮、测试覆盖好。你去 review 它,找不出问题—— 因为问题不在代码里,在"没有人调用它"这件事上,而这件事在代码里看不见。
判据
统计代码行数没用,要统计"生产调用点数",且必须:
| 要求 | 为什么 |
|---|---|
| 排除定义文件 | 定义处自然会出现这个符号名 |
| 排除测试文件 | 测试调用不是生产调用 |
| 有运行数据时,优先用运行数据 | 静态调用点数说明不了实际调用频次 |
# 排除定义文件(用 -g '!path' 而不是管道 grep,后者会漏多行匹配)
rg -a -c "logEvent" --type ts -g '!src/analytics/index.ts' src/一个更精细的三档分类
不要只分"活的 / 死的"两档,实测发现三档才够:
| 档 | 说明 | 处理 |
|---|---|---|
| 活代码 | 有生产调用 | 资产 |
| 仅被测试消费 | 只有测试调用它 | 这是隐形大头 |
| 真死代码 | 谁都不调 | 删 |
实测某次扫描:真死代码只有 9 个,而**"仅被测试消费"有 31 个**。 如果两档分类,31 个会被算成"活的",掩盖了真正的债在哪。
⚠️ 反向的错法也存在:把
__resetForTest这类本来就该只被测试用的 辅助函数算成死代码 → 得出偏高的死代码数字。所以要三档,不是两档。
陷阱 2 · 有输出 ≠ 有内容(190MB 的空行)
形态
遥测落盘看起来完全正常:
ls -la ~/.your-agent/telemetry/
# traces.jsonl 20971268 字节
# metrics.jsonl 20939197 字节
# 还有 traces.1-4.jsonl / metrics.1-5.jsonl 若干轮转文件
# 合计 190MB三个信号都说健康:文件存在 ✅ 体积在增长 ✅ 日志轮转正常触发 ✅
实测:
# 数非换行字节
cat ~/.your-agent/telemetry/traces*.jsonl | tr -d '\n' | wc -c
# → 0190MB 全是空行。10 个文件,一个 span 都没有。
根因
配置里的 enabled 字段被显式赋成了 undefined, 而 undefined 击穿了默认值合并:
// 默认值
const defaults = { enabled: true, batchSize: 512 };
// 用户配置(某个路径上产生了显式 undefined)
const userConfig = { enabled: undefined };
// 合并
const config = { ...defaults, ...userConfig };
// → { enabled: undefined } ← 不是 true!展开运算符不跳过 undefined于是导出器每次 flush 都写一个换行,但一条数据都不写。
判据
# ❌ 错:只看文件存在与大小
ls -la telemetry/
# ✅ 对:数非空字节 / 数有效行数
cat telemetry/traces*.jsonl | tr -d '\n' | wc -c
cat telemetry/traces*.jsonl | jq -c 'select(.kind)' | wc -l通用教训
"有输出" 不等于 "有内容"。要数非空字节,不是数文件大小。
配套的一条:测试要断言"键不存在",而不是断言"值是默认值"。
// ❌ 这个测试过不了关:它测的是"传了正确的值"
expect(mergeConfig({ enabled: true }).enabled).toBe(true);
// ✅ 这个才测到了 undefined 击穿
expect(mergeConfig({ enabled: undefined }).enabled).toBe(true);
expect("enabled" in userConfig).toBe(false); // 断言键根本不存在陷阱 3 · 只在成功路径埋点(失败被系统性隐藏)
形态一:只订阅成功事件
只订阅 PostToolUse(工具成功),没订阅 PostToolUseFailure。 结果四类失败在 trace 树上完全不存在:
① 工具抛异常 ② Hook 阻止 ③ 权限拒绝 ④ 参数校验失败
表象:模型报错了,但轨迹里查不到这次工具调用。 副作用:工具失败率统计系统性偏低。
形态二:只在 end() 时入队
span 只在正常结束时才进导出队列 → 所有没能正常结束的 span 全丢。
实测:SessionStart 41 次 vs SessionEnd 17 次 → 58.5% 缺失。
为什么这个陷阱如此普遍
因为写代码时你顺着 happy path 走。异常路径是后来补的,而"补埋点"这件事 不会有人提醒你。
通用教训
失败路径的埋点覆盖,比成功路径重要得多,而它总是被漏掉。
因为你想排查的恰恰就是失败的那些——正常结束的会话你根本不需要看。
这类问题有个名字:结构性盲区。它的可怕之处是静默、且总是偏向 "看起来更健康"——失败没记录,于是失败率低,于是看起来一切良好。
自检问法
对每一个埋点,问一句:"如果这段代码抛异常了,还会有埋点吗?"
陷阱 4 · 假门禁(在完全没修的状态下也显示 PASS)
形态
要验证"会话根 span 是否落盘",写了这个判据:
# 判据:invoke_agent 类型的 span 数量 != 0
[ $(jq -c 'select(.kind=="invoke_agent")' traces.jsonl | wc -l) -ne 0 ] && echo PASS这个判据在系统完全没修的状态下也会显示 PASS。
原因:子代理也产生 invoke_agent span。子代理跑过几次,这个数就不是 0, 而会话根依然是 0 个。
另一个形态:sort -u 把 28 压成 1
# ❌ 数根节点,顺手加了 sort -u
jq -r 'select(.parentSpanId==null) | .traceId' traces.jsonl | sort -u | wc -l
# → 1 「只有 1 个根,树成形了!」
# 实际是 28 个孤立根,各自 traceId 不同,但去重逻辑写错了位置还有一个形态:等满冷却时间去测"清除冷却"
测试一个"清除冷却状态"的函数,做法是:设置冷却 → 等待超过冷却时长 → 调用清除函数 → 断言冷却已解除。
问题:等满之后冷却本来就自然过期了。把被测函数整个删掉,测试依然全绿。
判据:变异自证
一个验收判据,如果在系统完全没修的状态下也能显示 PASS,那它就是假门禁。
检验办法:故意把被测功能改坏(或干脆删掉),看判据是否变红, 以及红的是哪一条。
⚠️ 注意后半句。"变红了"不够——要确认红的是你以为的那一条。 一个改动可能同时破坏三条断言,其中两条是意外连带, 而你真正想验证的那条可能依然是绿的。
通用教训
新增任何门禁 / 断言 / 检测器,必须做变异自证。 否则你不知道它在测东西还是在装样子。
陷阱 5 · 一个指标区分不了两种修法不同的故障
形态
缓存埋点只记录了"命中了多少",没记录"写入了多少"。
两种完全不同的故障在观测数据上塌缩成同一个值:
| 故障 | 该谁修 | 观测表现 |
|---|---|---|
| 我们自己的提示词前缀断了 | 我们 | 命中数 = 0 |
| 网关根本没实现缓存写入 | 网关方 | 命中数 = 0 |
团队差一步就要给网关方发"你们的缓存有问题"的邮件。 补上 write 字段之后才发现是自己的问题。
同类的另一批例子
这个陷阱的解法(拆细事件类型 / 加字段)在重试遥测上有一整套示范:
| 该拆的两件事 | 压平之后问不出的问题 |
|---|---|
| 换模型降级 vs 换传输方式(流式→非流式) | "网关不支持 SSE" 会被读成 "这个模型不可用" → 修错地方 |
| 重试次数用尽 vs 重试时间预算用尽 | 前者指向"限流/故障持续"(该查网关);后者指向"退避配置与外层超时不匹配"(该调超时) |
| 认证刷新成功 vs 未注入钩子/刷新失败 | "401 之后我们到底刷新了没有" 完全无法回答 |
| 哪个子代理重试了几次 | 只知道"内置子代理一共重试 37 次"——是 1 路撞 37 次还是 6 路各撞 6 次?前者查那个模型,后者是全局限流,修法完全不同 |
判据
如果一个指标不能区分两种修法不同的故障,它就不够用。
反过来说:每加一个事件类型或字段,问一句"它能让我做出一个此前做不出的决定吗"。 不能,就别加(避免另一个极端:指标膨胀)。
一个具体技巧:让推论变成可实测
上面"重试时间预算用尽"那条,配一个 remainingMs 字段之后, "10 次重试是幻觉" 从推论变成了可实测:
delayMs 远大于 remainingMs → 退避上限相对这个 agent 的超时配得过大
→ 它其实只重试得了 2 次,不是配置里写的 10 次陷阱 6 · 跨口径汇总产出假数(TTFB 案例)
已在第 2.6 节详述,这里只留判据:
同一个字段,在不同网关路由 / 不同 provider 下语义可能不同。 汇总之前必须先确认语义一致。
判据:(ttft − ttfb) / ttft 的中位数 > 50% → 该路由在抢先回 header。
工程解法值得单独记:把"按模型分组的延迟计算"做成单一事实源模块, 并且刻意不提供跨模型汇总的 API。
危险的口径,用 API 设计来禁止,而不是靠文档提醒。 提供了就一定有人用;文档没人读。
陷阱 7 · 只按自己的命名去搜(系统性高估自己)
形态
做横向对比,检索对方代码里有没有某个能力。搜自己习惯的命名,零命中, 于是写"对方没有"。
实测数据:15 项"预期缺失"里有 3 项(20%)在第二轮检索后被证明存在, 三项都是"概念存在、命名不同":
| 能力 | 我们叫它 | 对方叫它 |
|---|---|---|
| 流卡死诊断 | stallDetect / hangDetect / streamPhase | watchdog / streamIdleTimer |
| 熔断器 | 一个 circuit-breaker.ts 文件 | 没有同名文件;概念散在注释里,实体是 MAX_CONSECUTIVE_* 这类常量 |
| 进程残留清理 | pidFile 模块 | concurrentSessions.ts |
只按自己的命名去搜,会系统性高估自己的领先程度——这次的量级是 20%。
更强的操作建议:从功能入口反查,而不是猜命名
与其猜对方把这个能力叫什么,不如去看它必然会出现的地方:
| 找什么 | 去哪看 |
|---|---|
| 异常/崩溃处理 | process.on('uncaughtException') 注册处 |
| 流式异常、卡死 | 流消费循环体(for await 那一段)前后 |
| 关闭时的数据刷新 | 信号注册处 + 退出路径 |
| 熔断/降级 | 重试封装、连续失败计数器、MAX_* 常量 |
| 计量口径 | 定价表 / cost 计算函数的全部调用点 |
读那几十行,比搜二十个候选词更快,也更不容易漏。
三档结论,不是两档
| 情形 | 该写什么 |
|---|---|
| 检索了明确关键词集合、零命中 | ❌ 未找到实现(必须附检索命令) |
| 没去找 | ⬜ 未核验(不许写任何判断) |
| 拿到的源码副本里是空壳/被剥离 | 🚫 不可核验(不得计入 ❌) |
第三档来自一个真实陷阱:某个代码副本里 88 个命令有 18 个是 stub ({ isEnabled: () => false, isHidden: true, name: 'stub' })。 要查的 13 个命令里有 5 个全是 stub。
"我拿到的代码里没有" 和 "这个产品没有" 是两件事。
陷阱 8 · 取数命令本身有 bug(造出一个不存在的 bug)
形态一:jq 字段路径写错
# 写的
jq '.total_cost_usd' session.traj # → 全是 null,看起来 23/23 会话成本为 0
# 真实路径
jq '.metadata.total_cost_usd' # → 17/23 有值"23 个会话成本全为 0" 是一个不存在的 bug,纯粹由取数命令写错造出来的。
形态二:rg -N 被当成"只出裸值"
# ❌ -N 只去掉行号,不去掉文件名
rg -a -N -o 'tengu_\w+' src/ | sort -u | wc -l
# 输出形如 "src/a.ts:tengu_foo",同一事件名在 N 个文件里被计成 N 个
# 实测虚高 24%:报 1119,真值 903
# ✅ -I / --no-filename
rg -a -I -o 'tengu_\w+' src/ | sort -u | wc -l⚠️ 这条 bug 命令曾经被写在方法论文档的模板里,还被复制进了任务提示词。 方法论文档自己传播了一条会造假数据的命令。
形态三:搜英语常用词不加词边界
# ❌ 搜 hang,被 change/changed/changes 淹没数百条 → 误判"零命中"
rg -a 'hang' src/
# ✅ 加 -w
rg -a -w 'hang' src/判断"对方有无卡死诊断"时就栽在这里,结论从"没有"翻转成"有"。
高风险词:hang / stat / cost / trace / log / span。
形态四:grep 遇到 NUL 字节静默零输出
某个文件含 NUL 字节,grep 直接静默不输出任何东西。 误判"这个能力从未接线"。
修法:一律用 rg -a(-a = 把二进制当文本处理)。
形态五:多关键词 or 模式只跑 -l
rg -a -l "uncaughtException|unhandledRejection" src/ # 列出 main.tsx
rg -a -n "uncaughtException" src/main.tsx # 零命中第一反应是"又是 NUL 字节问题",其实不是——-l 根本不告诉你是哪个词命中的。 定位阶段要逐个关键词单独搜。
形态六:shell 不做 word splitting
在 zsh 下把多个 flag 攒进一个变量再展开:
FLAGS="-a -w"
rg $FLAGS 'pattern' src/ # zsh 下 $FLAGS 是一个整体,不会拆成两个参数
# → 静默返回 0 行,不报错通用教训
数字有异常,先怀疑命令。
"全为 0"、"全部零命中"、"100%"、"恰好整数" 这类整齐的结果, 八成是路径或关键词写错了。
配套的一条:零命中必须反向自证。
# 你的正则报告"没有泄露的密钥"。先验证这个正则能抓到已知的东西:
echo 'sk-ant-api03-KNOWN-TEST-VALUE' | rg -a 'your-regex-here'
# 抓不到 → 你的"零命中"毫无意义有个真实案例:密钥扫描正则的字符类里漏了 -,导致漏掉真的 key, 而结果显示"零命中,很安全"。
陷阱 9 · 指标改善了,真实结果没变(代理指标反被优化)
形态
优化目标:减少无效的 web 搜索调用。
结果:web 调用从 14 次降到 0 次,目标指标完美达成,测试全绿。
而任务的最终产出逐字节没变。
原因:预算总会被花光。省下来的调用配额被拿去做了别的同样无效的事。
更精细的一个形态
想测"agent 是否真的改了代码",判据用 edit 调用次数 > 0。
结果被骗过了:agent 往 /tmp 写了个脚本,edit 调用数 > 0, 而目标仓库一个字节都没改。
修法:判据换成 patch_bytes > 0(真实产生的补丁字节数)。
另一个形态:把浪费重新贴个标签
优化"空转"指标,从 4 降到 0,全绿。而真实的端到端成功率下降了 11.2 个百分点。
因为"空转"的定义是"重复调用且返回值不变"。模型学会了在两次重复调用之间 插一个无意义的不同调用,于是"连续"不成立了,指标漂亮了,浪费还在。
通用教训
代理指标会奖励"把浪费重新贴个标签"。
收尾必须回到端到端的真实结果上验证,不要只看你专门优化的那个代理指标。
配套的一条硬教训:
目标指标改善 + 测试全绿 + 机理讲得通,三者同时成立时,结论仍然可能是错的。
这条听起来很反直觉,但它是实测出来的。它的实践含义是: "我拿什么证明它真的生效了" 这个问题,答案必须是"跑了什么命令、看到什么输出", 而不是"机理上讲得通"。
陷阱 10 · 沿用旧数字(自己的文档也不可信)
形态
引用自家文档里的"现状",不回源码核验。
实测踩到的:文档写"我们没有 TTFT 基线",据此把"建 TTFT 埋点"列为一个待办。 回源码一看——三周前就修完了,还有三个消费方在用。
更尴尬的:这个错误发生在一份专门讲"不要照抄文档"的方法论文档里, 而且是在同一份文档里第二次犯。
另一个形态:样本数变了但沿用旧结论
2026-08-05 实测 TTFT:p50=4.6s p95=15.0s p99=34.3s
2026-08-09 复跑: 598 样本 p50=3.1s p95=8.9s p99=15.0s样本数下降不是数据丢失,是分母里的会话集合变了 (旧会话被上传后清理、新会话加入)。
引用任何数字前一律复跑,别沿用任何一版。
通用教训
"以源码和运行数据为单位,不以文档为单位" 这条铁律, 对你自己的文档同样成立。
实操建议:文档里每个数字旁边写出取数命令。 不能复跑的数字,半年后无法判断真假。
陷阱 11 · 把默认关闭的功能记成"有"
形态
对比表里填"✅ 有四层循环检测 + 三层熔断"。
实际情况:循环检测默认全局关闭,实测生产触发率 0。
把关着的功能填进"有"这一格,就是拿死功能充数。
需要的图例,至少五档
只有 ✅/❌ 两档的表格,会把两类最需要被看见的债直接抹掉:
| 符号 | 含义 |
|---|---|
| ✅ | 有实现且有生产调用 |
| ⚠️ | 有代码但调用为零 / 部分生效 / 配置不可达 |
| ❌ | 已检索确认未找到(附命令) |
| ⬜ | 未核验 |
| 🔒 | 有实现但默认关闭,实测触发率 ≈ 0 |
| 🚫 | 源码副本里是空壳,不可核验(不得当 ❌ 用) |
⚠️ 和 🔒 是这套图例的核心价值。
还要再收紧一步:一个维度里有"常开 + 需开关"两套机制,不许合成一个符号
实测遇到的情形:卡死诊断里,被动 stall 检测是常开的, 而主动 idle watchdog 需要环境变量开启。
整段记 🔒 会低估,整段记 ✅ 会高估。正确做法是按机制拆格、分别标注, 并在格内写清哪一半默认开。
一个正面案例:默认关闭是对的,但理由必须是数据
上面那个循环检测,默认关闭是正确决定,而且理由不是"对齐别人",是实测:
| 检测器 | 实测表现 |
|---|---|
| shape 检测(同形状重复) | 误判率 ≈ 100% |
| exact 检测(完全相同) | 召回 ≈ 0 |
也就是说这套检测在真实轨迹上几乎只产生误杀、几乎抓不到真循环。
根因是结构性的、修不掉的:bash 的命令值不进 shape key, 于是所有 bash 调用退化成同一个 shape。分段读大文件、多点编辑同理。
附带一个"用数据推翻自己方案"的例子
曾设想的改进:"按副作用分级——只对只读工具开检测"(误杀只读工具代价小)。
听起来很合理。实测适得其反——只读工具(read/grep/bash 查询) 恰恰是最常合法重复的那类。
正解方向是"只读命令做输入+输出双重复判定"(同样的命令且同样的输出 才算循环),而非按副作用分级。
方案听起来合理 ≠ 方案有效。 探针脚本的价值就是在写代码之前 先把方案的前提验一遍。
陷阱 12 · 手写清单必然漂移
形态
循环检测需要一份"豁免工具名单"(这些工具连续调用是合法的,比如派发子任务)。
最初是一份手写死名单。问题:新增工具必然漏。 加了一个新的派发类工具,忘了往名单里加一行, 它就会在某次长任务里被误判成循环掐死——而且这种失败极难复现。
修法:唯一事实源下沉 + 双向对账
① 事实源下沉到工具自身:
每个应豁免的工具在自己的类定义处自报 readonly exemptFromLoopDetection = true
② 加一个测试做双向对账:
豁免名单集合 ↔ 所有自报 exempt 的工具
两边必须一一对应,任一侧漏了就红这是一个通用范式
同样的手法可以治所有"手写清单":
| 清单 | 对账对象 |
|---|---|
| 豁免工具名单 | 工具自报的标记 |
| 文档里的参数列表 | 源码里的实际参数(--check 门禁) |
| abort reason 白名单 | 所有 abort 调用点 |
判据:
只要一份清单需要人在别处改代码时"记得同步",它就迟早会漂。 那就加一个测试让它漂不动。
清单不可靠,对账才可靠。
附带一条判读经验
防漂移哨兵红了,要补清单而不是删断言。
一个哨兵测试(比如"abort reason 白名单必须覆盖所有 abort 调用点")报红, 说明它正在正常工作——有人加了新 reason 但忘了同步。
它红 = 它在干活。 删掉断言让它变绿,等于亲手拆掉唯一在防这件事的东西。
6.x 把十二条压缩成五句话
如果只能记五句:
- 代码在 ≠ 能力在。 数生产调用点,排除定义和测试。
- 有输出 ≠ 有内容。 数非空字节,不是数文件大小。
- 失败路径的埋点比成功路径重要,而它总是被漏掉。
- 任何门禁必须变异自证——改坏被测功能,看它是否变红、红的是哪条。
- 数字整齐(全 0 / 100% / 零命中)时,先怀疑取数命令,不要先下结论。
第 7 章 · 排查实战:从"它崩了"到"我知道为什么"
前面几章讲怎么建。这一章讲怎么用——拿到一个"上次那次跑挂了"的诉求, 按什么顺序查。
7.1 先建立一张"数据在哪"的地图
排查的第一障碍不是分析能力,是不知道去哪看。所以先记住这张表:
| 我想知道 | 去看 | 关键字段 |
|---|---|---|
| 这会话整体什么情况 | session.traj 的 metadata | exit_status、total_steps、total_cost_usd |
| Agent 做了哪些操作 | session.traj 的 trajectory[] | 每步的 action / observation |
| 模型到底收到了什么、回了什么 | raw.jsonl | 逐次的 request / response / usage |
| 崩溃时的现场 | messages.json | exit_status + attribution(归因) |
| 事件时间线(对齐时序) | events.jsonl | 各 Hook 事件的时间戳 |
| 花了多少钱(跨会话) | usage-ledger.jsonl | 每会话一行汇总 |
| 协议层出了什么错 | protocol-violations/ | 孤儿 tool_use 等 |
| 延迟分位、Span 树 | telemetry/traces.jsonl、metrics.jsonl | kind、parentSpanId、ttft_ms |
7.2 五个最常见问题的排查路径
问题 A:「它崩了,为什么」
1. 看 metadata.exit_status
end_turn → 没崩,是正常说完了(那你的问题可能在别处)
error → 运行时异常 → 转 2
abort → 收到信号(被 kill / 超时闸门)→ 转 3
user_interrupt → 用户 Ctrl-C → 通常不用查
2. error 路径:
messages.json 的 attribution ← 崩溃归因,第一站
raw.jsonl 最后一行 ← 最后一次 API 交互
有孤儿 tool_use 吗? ← 强烈暗示协议违规或中途崩
3. abort 路径:
是哪一层超时?(见 7.5,多层超时同值是个大坑)
检查宿主机有没有休眠(见 7.6)问题 B:「这次怎么这么慢」
1. 先分解,不要先猜:
LLM 等待总时长 vs 工具执行总时长 vs 框架空隙
经验分布:LLM ~90% / 工具 ~10% / 框架 ~0.3%(正常态)
2. 如果 LLM 占绝对多数(常态):
TTFT 是不是异常高?→ 按 model 分组看,别汇总(陷阱 6)
输出 token 是不是异常多?→ tokens/sec 正常吗
有没有大量重试?→ retry 浪费比 > 20% 就是病态
3. 如果框架空隙异常(>10%):
这就是故障态了,找专门信号:
· 「模型调用未配对」——BeforeModel 之后 N 秒没收到 AfterModel
· 「定时器漂移」——期望 5s 实际 500s,说明事件循环被阻塞了
4. 如果工具占比异常:
看 PostToolUse.duration_ms 分布,找那个最慢的工具第 3 步那两个信号值得单独说,它们是"框架自己卡住了"的精确指认:
| 信号 | 含义 | 实测例子 |
|---|---|---|
| 调用未配对 | 发起了模型调用,超时窗口内没收到结束事件 | elapsed_ms = 300000(300s 没回来) |
| 定时器漂移 | 定时器的期望触发时间 vs 实际触发时间差距巨大 | expected_ms=5000 / actual_ms=512708 → 事件循环被阻塞 507 秒 |
这比一个笼统的
overhead_ms分位有用得多——它直接告诉你"是事件循环被堵了", 而不是"框架慢了一点"。
问题 C:「它在原地打转」
1. 看"空转"信号:最长的「重复调用且返回值不变」连续段(≥3 判病态)
2. 看工具序列:同形状连续出现几次
3. 关键判别:返回值变了吗?
返回值在变 → 可能是合法的轮询/分段读,不是打转
返回值不变 → 真的卡住了
4. 看思维链:模型自己说了什么(往往能直接看出它在纠结什么)⚠️ 不要直接信"疑似循环"这个信号(见 7.4 的误判特性)。
问题 D:「为什么这么贵」
1. 先看轮次:turns 是不是异常多?(成本 O(N²),轮次是最大杠杆)
2. 看缓存命中率:< 40% 就要查前缀断裂
断裂是本地的(我们插了动态内容)还是服务端的(TTL/路由)?
3. 看 output/input 比:输出单价 3-8 倍,输出多是主因
4. 看 compaction 次数:压缩过 → 重读文件 → 重复付费
5. 看影子调用:标题生成、摘要、记忆召回有没有进账问题 E:「它说做完了,但没做对」
这是最难查的一类,因为所有传统指标都是绿的。
1. 看 patch_bytes(真实产生的补丁字节),不要看 edit 调用次数(陷阱 9)
2. 看它写的文件路径——是目标仓库还是 /tmp?
3. 看它跑的验证命令的完整输出,不要只看退出码
4. 看工具失败率里那些"预期报错"——它是不是把复现脚本的成功当成了失败?7.3 一个反复出现的判读错误:把"没数据"当成"数据是 0"
这个错误的形态非常统一,值得单列:
| 场面 | 错的读法 | 真相 |
|---|---|---|
| 账本里查不到这个会话的成本 | "成本是 0" | 账本只在正常 SessionEnd 写,覆盖率可能只有 1/90 |
| 某个字段 jq 取出来是 null | "这个值是 0" | 键根本不存在(可能是版本差异或路径写错) |
| 某个防线的触发计数是 0 | "防线在正常工作,没有威胁" | 可能是防线根本没接线(陷阱 11) |
| span 表里某类 span 是 0 | "系统没产生这类操作" | 可能是那几个事件全仓没有 emit 点 |
判据:
区分"没有发生" / "没有采到" / "键不存在" 三种情况。 它们在数据里长得一模一样,但结论完全不同。
实操办法:先确认数据源存在,再解读值。
# ❌ 直接取值
jq '.metadata.total_cost_usd' session.traj
# ✅ 先看键在不在
jq '.metadata | keys' session.traj
jq 'has("metadata")' session.traj这也是 L5 那个"成本归零"检测器把误报率从 95% 降到 0% 的原理—— 判据里加了"必须有账本条目"这个前置条件。
7.4 每个异常信号都要标注它的误判特性
这一条是排查工具设计的关键,但几乎所有人都会漏。
如果你的工具报"疑似循环",用户不知道该多信它。结果是两种极端:
- 太信 → 去改一个不存在的问题
- 不信 → 学会忽略这个信号,那它就等于不存在
所以每个检测器都要写清它什么时候会误判:
| 检测器 | 可靠性 | 什么时候会误判 |
|---|---|---|
异常退出(exit_status) | 最高,几乎不误判 | — |
| 孤儿 tool_use | 高 | — |
| 工具执行失败 | 中 | 工具正常返回的内容里含 "error" 字样(读了个 error.log) |
| 疑似循环 | 最低 | 分段读大文件、多点编辑、反复试不同 bash 命令都会触发 |
| 成本归零 | 高(收紧后) | 收紧前 95% 误报 |
| 数据格式异常 | 高 | 这是防 schema 漂移的兜底 |
对应的处理策略:
| 可靠性 | 严重度标注 | 措辞 |
|---|---|---|
| 高 | 直接标"高" | "会话因运行时异常终止" |
| 中 | 单次不升级,多次才升级(如 ≥3 次) | "工具执行失败 1 次" |
| 低 | 只标"疑似" + 中优先级,明确让人去确认 | "疑似循环,请看原始轨迹确认" |
7.5 多层超时同值:一个很典型的架构陷阱
形态
系统里有三层超时保护,都设成 300 秒:
第 1 层:流 idle 超时 300s
第 2 层:内容进展超时 300s
第 3 层:整体超时 300s看起来是"三重保险"。实际上:
三层同值 = 只有第一个触发的那层有意义,另外两层永远不会触发。
于是修第 1 层等于换了个杀手,症状一模一样,你会以为没修好。
更糟的是:如果第 2、3 层不写自己的触发记录,它们在观测数据里完全隐身—— 你甚至不知道有三层。
还有个更阴的变体:内层定时器架空外层
外层设了 300s 超时。
内层有一个硬编码的 60s 心跳检测。
结果:慢首字节的请求(>60s 才出第一个字)每 60s 被内层杀一次,
然后重试,再被杀,无限循环。外层 300s 永远等不到。
判据:中断间隔全部恰好是 60.0s ← 整齐的数字,见陷阱 8通用教训
| 原则 | 说明 |
|---|---|
| 每一层超时必须写自己的触发记录 | 否则隐身,你不知道是哪层杀的 |
| 各层的值必须有明显区分度 | 同值 = 只有一层生效 |
| 必须有"超时防线自己是否失效"的自证字段 | 见下 |
"防线自证" 是个很漂亮的设计
问题:超时闸门触发了之后,怎么知道它真的生效了?
做法:记一个 exit_delay_ms —— 从"触发超时"到"循环真的退出"之间的延迟。
| 值 | 结论 |
|---|---|
| 0–10ms | 防线正常生效 |
| >> 1000ms | 有别的东西在唤醒这个循环,防线名义触发了但实际没能停下来 |
这类"观测防线自己"的设计值得专门学——因为防线的失效是静默的。 它触发了、日志写了、看起来生效了,而循环还在跑。
7.6 一个容易归因错的外部因素:宿主机休眠
形态
某次评测:agent_ms 字段显示远超上限,但 timed_out 字段是空的。
看起来像超时闸门坏了。
真凶:笔记本合盖休眠了 717 秒。
| 口径 | 用的时钟 | 结果 |
|---|---|---|
agent_ms | 墙钟(now - start) | 包含休眠的 717s → 超了上限 |
| 超时闸门 | 可运行时间(定时器/alarm) | 休眠期间不推进 → 没超时 |
两套判据在同一时刻给出相反结论,而两者各自都是对的。
修法与预防
| 措施 | 说明 |
|---|---|
| 长时评测前先阻止休眠 | macOS: caffeinate -dimsu |
| 记录两个时间口径 | 墙钟 + 单调时钟,都落盘 |
| 归因时先排查外部因素 | pmset -g log 看有没有休眠记录 |
通用教训:当两个应该一致的指标不一致时,先怀疑它们的时钟口径不同, 再怀疑代码有 bug。
7.7 排查的元原则
把这一章压缩成六条:
- 先分解,再猜。慢——先拆成 LLM/工具/框架三块,别一上来就猜。
- 先确认数据存在,再解读值。"没数据"和"值是 0"是两件事。
- 每个信号都要知道它的误判率,否则不知道该多信它。
- 数字整齐时先怀疑取数命令(全 0、100%、恰好 60.0s)。
- 结论矛盾时,先怀疑仪器(两个字段互相矛盾,通常是口径不同,不是代码坏了)。
- 宁可报"我不知道",不要报"没问题"。
第 8 章 · 三家横向对比:同一个能力,三种目的
这一章的价值不在"谁更强",而在一个更重要的认知:
同一个可观测性能力,在不同产品里服务的目的完全不同。 所以"对方有、我们没有"这句话本身,推不出"我们该做"。
这是面试里很能体现判断力的一点。大多数人的对比停在功能清单, 能讲出"为什么他们需要而我们不需要"的人很少。
8.1 三家的定位差异
拿三个真实的 coding agent 做过逐行源码实测,结论是:
| 可观测性主要服务谁 | 因此优化什么 | 典型规模 | |
|---|---|---|---|
| 产品 A(大厂商业产品) | 公司内部的产品分析组织(专职数据团队 + 双数据后端 + 灰度平台) | 埋点覆盖面 | 660 个事件名 / 1089 个调用点 / 262 个文件 |
| 产品 B(大厂开源产品) | 服务端可靠性(多个本地数据库 + 常驻 server + 长连接都会坏) | 指标齐备度 + 主动实探 | 51 个 metric / 33 个具名 span / 诊断工具近 1 万行 |
| 产品 C(个人/小团队项目) | 一个人的工程决策(改哪、值不值、改完有没有效) | 单条数据的可信度与可反驳性 | 不追规模 |
这个差异如何直接改变"该抄什么"
运维仪表的追求:不漏 (宁可多采,漏了就查不到)
决策依据的追求:不错 (一个错数字比没数字更坏)同一条判据在两个方向上的应用(这个例子很能说明问题):
| 决定 | 表面上矛盾 | 实际是同一条判据 |
|---|---|---|
| 拒绝 抄产品 A 的 660 个埋点规模 | 看起来是"不想干活" | 覆盖面不是我的瓶颈,多采只会稀释信号 |
| 保留 把 histogram 降级成 gauge 的处理 | 看起来是"能力弱" | 硬造 bucket 边界造出来的分布是错的,错数据比没数据更坏 |
8.2 各家真正的独门优势
对比之后,三家各有一处对方确实没有的设计,都值得学:
| 产品 | 独门设计 | 为什么别人没有 |
|---|---|---|
| A | 入口强制脱敏 + 双通道隐私(用类型系统禁止传原始字符串) | 它面向海量外部用户,隐私是生存线 |
| B | 主动实探式诊断(doctor 命令主动去 ping、去连、去验,不只是读日志) | 它是常驻服务,"现在还好吗"比"刚才怎么了"更重要 |
| C | 结论分层 + 自带证伪条件 | 见下 |
C 的那一项值得展开,因为它最容易被忽略
做法是把分析结论分成两层:
L0 事实层:从数据直接可得的、不可争议的
例:「TTFT p95 = 8.9s(分母=41 个有 events.jsonl 的会话)」
L1 假设层:解释性的推断,且每条自带证伪条件
例:「假设:慢是因为网关排队。
证伪条件:如果按路由分组后两条路由的 gap 占比接近,
则该假设不成立,应转向模型侧。」为什么这是三家里唯一的:另两家的诊断工具都只给结论。 给结论的工具在结论错的时候会误导,而且用户无法判断该不该信。
面试可以把这一点提升成一个通用主张: 「可观测性工具的输出应该分成'事实'和'推断'两层, 并且推断必须带证伪条件——否则它在错的时候会比没有工具更糟。」
8.3 什么时候"对方有"不构成理由
这是本章最实用的部分。三家对比之后总结出的裁决判据:
判据一:先问"服务哪个目标"
对方有 X,我们没有 X
↓
X 在对方架构里服务什么目的?
↓
那个目的在我这里存在吗?
↓ 不存在 → 不做,且写下理由
↓ 存在 → 继续问量级真实例子:
| 能力 | 对方为什么需要 | 我为什么不需要 |
|---|---|---|
| 通道级隐私隔离(trace/log 走完全不同的通道) | 面向海量外部用户 + 合规审计 | 单人使用,入口脱敏已经够 |
| 660 个埋点 | 有专职数据团队消费 | 没有消费方,采了没人看 |
| 不算成本 | 那个产品免费/内部结算 | 我同时接多家网关,算错钱是真金白银 |
判据二:按「量级 × 趋势能力 ÷ 改动量」排序,不按「对方有没有」
这条推翻过一个实际的路线图。原本的排序逻辑是:
「对方有 + 我们没有 + 能对上某个目标词 → 做」
这条判据把一个实测占比 1.1% 的分量列成了 P1 首位(就是第 2.6 节那个 框架开销拆解)。
正确的排序要素:
| 要素 | 问什么 |
|---|---|
| 量级 | 这个东西占比多少?1.1% 还是 90%? |
| 趋势能力 | 做了之后能画出一条能发现退步的曲线吗?还是只能看单次值? |
| 改动量 | 5 行还是 500 行? |
判据三:⚠️/🔒 的严重程度,取决于同一行邻居是什么
这条很巧妙。看一个对比表的某一格:
| 维度 | 产品 A | 产品 B | 产品 C |
| Span 父子关系 | ✅ | ✅ | ⚠️ | ← 这一格是真该修的
| 循环检测 | ❌ | ❌ | 🔒 | ← 这一格不用管同一行另两列都是 ✅ → 说明这是业界共识的基本能力,你缺就是真缺。同一行另两列都是 ❌ → 说明大家都不做,可能是因为它确实没用。
第二行那个例子(循环检测)就是实证:三家里只有 C 做了,而 C 自己实测 误判率 ≈ 100%、默认关闭。"只有我们有"在这里不是优势,是过度设计。
8.4 一张真实的对比矩阵(十二维度)
这套维度划分可以直接拿去用:
| # | 维度 | 判定要点 | 服务什么 |
|---|---|---|---|
| D1 | 产品分析埋点 | 事件名去重数、生产调用点数、能否跨会话聚合 | 可度量 |
| D2 | 标准协议对齐 | 数据模型是否对齐语义约定 / 出口是否真通(两件事分开评) | 企业级 |
| D3 | Span 追踪 | span 种类、父子关系是否成立、是否统一产出 | 可度量 |
| D4 | Metrics | 指标清单、类型(有没有 Histogram)、能否导出 | 可度量 / 省 |
| D5 | 内容级 tracing | 是否记 prompt/response、去重、截断、开关默认值 | 可度量 |
| D6 | 本地轨迹持久化 | 落盘格式、崩溃容错、上传可靠性 | 可度量 |
| D7 | 排查/诊断工具 | 有会话级诊断入口吗、输出的异常信号种类 | 可度量 |
| D8 | 计量精度 | 定价单一入口、口径归一化、覆盖率 | 省 |
| D9 | 延迟可观测 | TTFT / 端到端 / 流阶段 / 卡死诊断 | 快 |
| D10 | 防护层可观测 | 循环检测/熔断/权限——必须含实际触发率 | 安全 |
| D11 | 隐私与合规 | 分级模型、脱敏位置、是否真接线 | 企业级 |
| D12 | 开发者出口 | 命令、状态栏、报告 | 可度量 |
几个设计决策值得说明:
- D9 单列是刻意的。延迟通常是最薄的一环,单列让这个空白每次对比都被迫暴露, 而不是混在别的格子里被掩盖。
- D2 必须拆两件事评(模型对齐 / 出口连通),见 1.4 节那三个真实案例。
- D10 必须带触发率,否则关着的功能会被记成"有"(陷阱 11)。
- D3 的判定要点是"父子关系是否成立",不是"有多少种 span"—— 因为父子关系是 span 相对 metric 的唯一价值。
D9 还要再拆一层,因为"有 TTFT"已经不是好问题
| 层次 | 判定 | 为什么这层重要 |
|---|---|---|
| ① 有 TTFT 原始值 | 埋点存在且基准正确(首个任意内容 chunk) | 基准错了值就是废的 |
| ② 多消费方不互相污染 | 各消费方读同一个纯净源 | 各拷一份必然漂移 |
| ③ 有分布视图(p50/p99) | 有 Histogram 类指标吗 | 有埋点 ≠ 有分布(第 4 章 L4) |
| ④ 超时防线自己是否失效可被观测 | 防线触发后能验证它真生效吗 | 见 7.5 的 exit_delay_ms |
实测发现:某个产品8 个 metric 全是 Counter,分布只在报告层一次性算—— 第 ③ 层是缺的。而只报"有 TTFT"会把这个差别完全抹掉。
8.5 做横向对比的四条铁律
如果面试问"你怎么调研竞品",这四条是标准答案:
| 铁律 | 内容 | 反面案例 |
|---|---|---|
| 1 | 以源码和运行数据为单位,不以文档为单位 | 两份文档口径可能完全不等价:一份是切片、一份是全景,交集只有 2 个子系统 |
| 2 | 每格填三样:①有/无 ②代码规模 ③生产调用点数 | 只填 ①② 会把"代码完整、测试通过、调用为零"记成优势 |
| 3 | 「没有」必须与「没找到」分开写(三档:❌/⬜/🚫) | 见陷阱 7,20% 的"缺失"是命名不同 |
| 4 | 检索工具用 rg -a,每个数字旁边写出取数命令 | 见陷阱 8,不能复跑的数字半年后无法判断真假 |
铁律 2 的第三格为什么最关键
再强调一次,因为它是最容易省掉的一步:
① 有/无 ← 检索一下就知道
② 代码规模 ← wc -l 就知道
③ 生产调用点数 ← 要排除定义文件、排除测试,还要区分"仅测试消费"
↑ 麻烦,所以经常被省掉
↑ 而它是唯一能区分"资产"和"负债"的那一格还要做一次"对称性检查"
这一步几乎所有人都不做,但它防的是一个很隐蔽的偏差:
自己一侧写得更详细,读者会误读成"自己能力更强"。
所以对比做完之后要回头检查:
| 检查项 | 怎么查 |
|---|---|
| 证据密度对称 | 两边每格的 file:line 证据数量是否量级接近 |
| 检索深度对称 | 每个 ❌ 是否都做了两轮检索(第二轮用对方的命名习惯) |
| 诚实记录局限 | 哪些格子是降级为 ⬜ 的、为什么 |
8.6 跨语言对比的额外陷阱
如果对比对象换了语言,所有取数命令都要重写,否则会得出大面积假"未找到"。
三条在 TS 上成立、在 Rust 上全部失效的隐含假设:
| 假设 | 在 Rust 上为什么失效 |
|---|---|
用 --type ts / -g '*.ts' 过滤 | 直接搜不到东西 |
| 事件名是字符串字面量 | Rust 常用 enum + 序列化派生表达事件类型,搜字符串零命中而实际有埋点 |
span 是手写 API(startSpan / endSpan) | Rust 是声明式的(属性宏 #[instrument]、info_span! 宏、subscriber 的 layer 装配),按函数名搜必然零命中 |
还有一条同名词陷阱:trace 在 Rust 里是日志级别(tracing::trace!), 而在其他项目里可能指"轨迹采集"。直接搜会得到成千上万无关命中。
处置:每换一个语言,先产出一张「能力 → 该生态惯用法」映射表,再动手检索。
通用教训:取数模板不是语言中立的。 把 TS 的模板套到 Rust 上, 你会得到一份"对方什么都没有"的假报告。
第 10 章 · 术语表与学习路径
10.1 术语速查
通用可观测性
| 术语 | 一句话解释 | 本文位置 |
|---|---|---|
| 可观测性 | 让系统内部状态能从外部输出被推断出来 | 0.1 |
| Log | 离散事件记录,信息最全但量大 | 1.1 |
| Metric | 聚合数值,能画趋势但丢细节 | 1.1 |
| Trace | 一次端到端流程的因果树 | 1.2 |
| Span | 一段有起止的工作,带属性和 parentSpanId | 1.2 |
| SpanKind | Span 的分类(chat / execute_tool / …) | 1.3 |
| OTel | OpenTelemetry,可观测性标准,让采集与后端解耦 | 1.4 |
| OTLP | OTel 的传输协议(gRPC / HTTP) | 1.4 |
| 语义约定 | 字段该叫什么名字的规范;AI 那部分叫 GenAI 语义约定 | 1.4 |
| 上下文传播 | 让深层函数知道当前 Span 是谁(ALS / context.Context) | 1.5 |
| ALS | AsyncLocalStorage,给每条异步链一个隐式"随身背包" | 1.5 |
| Counter / Gauge / Histogram | 三种 metric 类型;只有 Histogram 能算分位数 | L4 |
| p50 / p95 / p99 | 分位数。一律看 p95/p99,均值会骗人 | 2.6 |
Agent 特有
| 术语 | 一句话解释 | 本文位置 |
|---|---|---|
| Token | 模型处理文本的最小单位,同时是成本单位和上下文容量单位 | 2.1 |
| TTFT | Time To First Token,首个任意内容 chunk 到手 | 2.6 |
| TTFB | Time To First Byte,HTTP 响应头到手。禁止跨路由汇总 | 2.6 |
| TPOT / ITL | token 间隔时间,决定"打字流畅感" | 2.6 |
| Goodput | 满足 SLO 的有效吞吐(区别于 throughput) | 2.6 |
| Prompt Cache | 服务端缓存提示词前缀,命中约 1/10 价 | 2.3 |
| 前缀匹配 | 缓存的匹配方式。动态内容放前面会击穿后面所有缓存 | 2.3 |
| 显式 / 隐式缓存 | 主动标记断点 / 服务端自动判断(后者上限约 60–70%) | 2.3 |
| 上下文窗口 | 模型能容纳的 token 上限 | 2.4 |
| compaction | 上下文快满时压缩历史。它是"已付出代价"的信号 | 2.4 |
| turns | 一次任务里的来回轮数。成本 O(N²),最大杠杆 | 2.5 |
| 影子调用 / side call | 不走主循环的辅助 LLM 调用(标题、摘要、记忆召回) | 5.7 |
| 空转 | 最长的「重复调用且返回值不变」连续段,≥3 判病态 | 2.7 |
| 孤儿 tool_use | 有 tool_use 无对应 tool_result,排查崩溃的头号线索 | 7.2 |
| exit_status | 退出态:end_turn / error / abort / user_interrupt | 7.2 |
| HITL | Human In The Loop,人工确认环节 | 3.3 |
口径与方法论
| 术语 | 一句话解释 | 本文位置 |
|---|---|---|
| stock / flow | 快照值 / 累加值。两者不能相除 | 3.5、5.5 |
| 主口径 / 辅助口径 | 进曲线的那一条 / 归因分解项 | 3.1 |
| 归一化 | 把厂商口径差异抹平,派生厂商无关量 | 5.3 |
| 单一事实源 | 一个概念只有一个权威实现 | 5.4 |
| 结构性盲区 | 失败路径没接埋点导致的静默偏差 | 1.6、陷阱 3 |
| 假门禁 | 在系统完全没修的状态下也显示 PASS 的判据 | 陷阱 4 |
| 变异自证 | 故意改坏被测功能,验证门禁确实变红 | 陷阱 4 |
| 代理指标 | 用来替代真实目标的可测量指标;会被"重新贴标签"骗过 | 陷阱 9 |
| 对账 | 用测试机械地保证两份清单一致,代替"记得同步" | 陷阱 12 |
| 防线自证 | 观测防线自己是否真生效(如 exit_delay_ms) | 7.5 |
10.2 学习路径
阶段一:建立直觉(1–2 天)
- 读本文第 0、1 章。
- 动手:找一个你在用的 coding agent,把它的数据目录翻一遍 (通常在
~/.<name>/下)。用jq把一个会话的字段列出来看看。bashls ~/.your-agent/ jq 'keys' ~/.your-agent/trajectories/sessions/*/session.traj | head - 目标:能说清"一次会话产生了哪些数据、分别在哪个文件"。
阶段二:会用(3–5 天)
- 读第 2、7 章。
- 动手:用附录 A 的命令,在自己的数据上算出这几个数:
- TTFT 的 p50/p95/p99(按 model 分组)
- 缓存命中率(注意分母用 flow)
- exit_status 的分布
- 目标:能拿一个真实会话,说出"它慢在哪、贵在哪、有没有异常"。
阶段三:会建(1–2 周)
- 读第 4、5 章。
- 动手:给一个自己的小项目(哪怕是个调 LLM 的脚本)加 L1 + L2, 然后加一个最简单的 L5(一个脚本,读日志输出摘要)。
- 目标:体会到"L5 才是杠杆"这件事。
阶段四:会判断(持续)
- 读第 3、6、8 章。
- 动手:拿你在阶段三建的东西,做一次变异自证—— 把埋点删掉,看你的检查脚本是否变红。
- 目标:形成"我怎么知道我的可观测性没坏"这个反射。
10.3 本文与其他文档的关系
| 文档 | 职责 | 什么时候读 |
|---|---|---|
sid-code 可观测性体系分析.md | 单个产品的完整体系全景(14 子系统,带 file:line) | 要看具体实现时 |
claude-code 可观测性体系分析.md | 另一个产品的同口径分析 | 做对比时 |
codex 可观测性体系分析.md | 第三个产品(Rust 生态) | 做对比时、看跨语言差异 |
可观测性横向对比总表.md | 三方 D1–D12 矩阵 + 差距结论 + 明确不做清单 | 要看裁决结论时 |
横向对比骨架与方法论.md | 四条铁律 + 17 个口径陷阱 + 执行流程 | 自己要做调研时必读 |
trace-会话排查工具-使用与原理.md | L5 那一级的完整实现说明 | 要建排查工具时 |
20260807-三方对比差距清单-… | 具体待修条目 + 可复跑命令 | 要动手修时 |
一句话导航:本文是地图,那几份是地形图。 先看地图知道有哪些地方,需要细节时再翻对应的地形图。
附录 A · 可复跑命令与三条计数铁律
用法说明:下面的命令假设数据在
~/.sid-code/,字段名按本文档族的口径。 换到别的项目要先确认字段名——先jq 'keys'看一眼,再取值(陷阱 8)。
A.0 三条计数铁律(破一条就得出假结论)
这三条各自都造过一次假数据,所以放在最前面。
铁律 1 · 去重计数必须用 -I,不是 -N
# ❌ 错:-N 只去掉行号,不去掉文件名
rg -a -N -o 'tengu_\w+' src/ | sort -u | wc -l
# 输出形如 "src/a.ts:tengu_foo",同一个名字在 N 个文件里被计成 N 个
# 实测虚高 24%:报 1119,真值 903
# ✅ 对:-I / --no-filename
rg -a -I -o 'tengu_\w+' src/ | sort -u | wc -l每个去重数字都要换第二种方式交叉验证一次。
铁律 2 · 数根节点时绝不能 sort -u 整行
# ❌ 错:把 28 个孤立根压成 1 个,得出"树成形了"的假 PASS
jq -r 'select(.parentSpanId==null) | .traceId' traces.jsonl | sort -u | wc -l
# ✅ 对:分两条判据,都要过
# ① 根 span 数 == 正常退出的会话数,且这些 span 的 parentSpanId 为 null
cat ~/.sid-code/telemetry/traces*.jsonl \
| jq -c 'select(.kind=="invoke_agent" and .parentSpanId==null)' | wc -l
# ② 根 span 总数 == traceId 去重数(每棵树恰好一个根)
cat ~/.sid-code/telemetry/traces*.jsonl | jq -r '.traceId' | sort -u | wc -l铁律 3 · 调用点计数必须排除定义文件,且用 -g 不用管道
# ❌ 错:含定义文件(数字虚高,跨对象对比时口径失配)
rg -a -c "logEvent" --type ts src/
# ❌ 也错:用管道 grep 排除(会漏掉多行匹配的情况)
rg -a -c "logEvent" --type ts src/ | grep -v 'analytics/index.ts'
# ✅ 对:用 -g '!path'
rg -a -c "logEvent" --type ts -g '!src/analytics/index.ts' -g '!tests/**' src/实测差距只有 4 个(1093 vs 1089),幅度很小——但这正是最阴的一类: 小到不会引起怀疑,却让口径悄悄失配。跨对象对比时若一侧含定义、一侧不含, 得出的"谁埋点更密"就是假的。
附带三条搜索铁律
# ① 一律 rg -a,不用 grep(NUL 字节会让 grep 静默零输出)
rg -a 'pattern' src/
# ② 英语常用词加 -w(hang / stat / cost / trace / log / span)
rg -a -w 'hang' src/ # 不加 -w 会被 change/changed/changes 淹没
# ③ 定位阶段逐个关键词单独搜,不要 or 模式 + -l
# -l 不告诉你是哪个词命中的,与 -n 结果对不上时会误判为工具故障A.1 先做的事:确认数据存在
在解读任何值之前,先确认键存在(7.3 节那条判读错误)。
# 会话目录概览
ls -lt ~/.sid-code/trajectories/sessions/ | head -20
# 看一个会话有哪些文件
ls -la ~/.sid-code/trajectories/sessions/<id>/
# ★ 先看键,再取值
jq 'keys' ~/.sid-code/trajectories/sessions/<id>/session.traj
jq '.metadata | keys' ~/.sid-code/trajectories/sessions/<id>/session.traj
# 数非空字节(防 190MB 空行那类陷阱)
cat ~/.sid-code/telemetry/traces*.jsonl | tr -d '\n' | wc -cA.2 延迟:TTFT 分位(必须按 model 分组)
先确认字段名与 phase 取值(同样是陷阱 8 的预防动作):
bashfor f in ~/.sid-code/trajectories/sessions/*/events.jsonl; do jq -rc 'select(.event=="StreamPhase") | .data.phase' "$f" 2>/dev/null done | sort | uniq -c | sort -rn # 实测:headers_received 1227 / first_content 1225 / fetch_sent 881 # completed 732 / sse_consuming 679 / error 101关键实测结论:
ttft_ms只出现在phase == "first_content"上 (1225 条带ttft_ms的记录,phase 全是first_content)。 这就是 2.6 节说的「TTFT 的唯一干净源」——别去别的 phase 上找它。
# ⚠️ 分母声明:只统计有 events.jsonl 的会话;每次复跑样本数会变,这是正常的
# ⚠️ 绝不跨 model 汇总 TTFB(陷阱 6)
# TTFT 分位,按 model 分组
for f in ~/.sid-code/trajectories/sessions/*/events.jsonl; do
jq -rc 'select(.event=="StreamPhase" and .data.phase=="first_content")
| "\(.data.model // "unknown") \(.data.ttft_ms)"' "$f" 2>/dev/null
done | sort | awk '
{ vals[$1] = vals[$1] " " $2 }
END {
for (m in vals) {
n = split(vals[m], a, " "); delete b; c = 0
for (i = 1; i <= n; i++) if (a[i] != "") b[++c] = a[i] + 0
# 排序
for (i = 1; i < c; i++) for (j = i+1; j <= c; j++)
if (b[i] > b[j]) { t = b[i]; b[i] = b[j]; b[j] = t }
if (c > 0) printf "%-28s n=%-5d p50=%-8.0f p95=%-8.0f p99=%-8.0f max=%.0f\n",
m, c, b[int(c*0.50)+((c*0.50)==int(c*0.50)?0:1)],
b[int(c*0.95)+((c*0.95)==int(c*0.95)?0:1)],
b[int(c*0.99)+((c*0.99)==int(c*0.99)?0:1)], b[c]
}
}'# 路由缓冲指纹:(ttft − ttfb) / ttft 的中位数,> 50% 说明该路由抢先回 header
# ⚠️ 配对键必须是 (session, index, attempt) —— (session, index) 不是唯一键!
# 重试会产生同 index 的多条记录,只按 (session,index) 配对会张冠李戴上面那条命令的真实输出(2026-08-29 实测,51 个会话)—— 这段值得逐行读,因为它演示了「为什么必须按 model 分组」:
ali-deepseek-v4-flash n=95 p50=1223 p95=8341 p99=28630 max=28630
claude-sonnet-5-网关 A n=229 p50=7959 p95=27421 p99=56183 max=80433
deepseek-v4-pro n=50 p50=3957 p95=9492 p99=12616 max=12616
deepseek-v4-flash n=12 p50=2174 p95=3786 p99=3786 max=3786
glm-5.2 n=247 p50=2582 p95=10471 p99=17024 max=32398
glm-5.3 n=246 p50=5909 p95=13647 p99=96725 max=117453
glm-5.3-official n=26 p50=5072 p95=12699 p99=18826 max=18826
origin-deepseek-v4-pro n=1 p50=3935 p95=3935 p99=3935 max=3935
claude-opus-5 n=319 p50=9213 p95=37442 p99=61375 max=101651四条读法,每条都对应本文前面的一个原则:
| 观察 | 对应原则 |
|---|---|
| p50 从 1223ms 到 9213ms,跨 7.5 倍 | 汇总成一个「全局 TTFT p50」会得到一个不描述任何模型的假数(陷阱 6) |
glm-5.3 的 p50=5909 而 p99=96725,跨 16 倍 | 均值会骗人,慢尾巴才是用户流失点(2.6 陷阱 D) |
origin-deepseek-v4-pro 只有 n=1 | 样本量必须和分位数一起写。n=1 时 p50/p95/p99 是同一个数,那不是分布,是一次观测 |
deepseek-v4-pro(n=50) 与 origin-deepseek-v4-pro(n=1) 是同底层模型的两条网关路由 | 这正是 2.6 节 TTFB 案例里那两条路由。同一个底层模型,走不同路由要分开看 |
A.3 成本与缓存
⚠️ 先做这一步:确认账本的字段名和粒度。 下面的命令用的是实测确认过的字段名。换到别的项目必须先跑这一行—— 我写这份文档时第一版就把字段名猜错了,正好踩了陷阱 8。
bashhead -1 ~/.sid-code/usage-ledger.jsonl | jq 'keys' # 实测输出:cacheHit, cacheWrite, costUSD, durationMs, model, output, # promptTotal, provider, savingsUSD, sessionId, ts, uncachedInput
# 单会话成本(注意完整字段路径,不是 .total_cost_usd)
jq '.metadata.total_cost_usd' ~/.sid-code/trajectories/sessions/<id>/session.traj
# 实测样例输出:0.7708611000000001
# 跨会话:账本总成本
jq -s 'map(.costUSD // 0) | add' ~/.sid-code/usage-ledger.jsonl
# 实测样例输出:261.2063771439617 ← 419 条账本累计# ★★ 账本覆盖率 —— 这一段是本附录最值得抄的,因为它演示了「分母比分子重要」
# 实测结论反直觉:账本条数(419) 远大于 现存会话目录数(51)
# 原因:账本活得比目录久(目录被上传后清理了),两者的集合不是包含关系
LEDGER_IDS=$(jq -r '.sessionId' ~/.sid-code/usage-ledger.jsonl | sort -u)
DIR_IDS=$(ls -d ~/.sid-code/trajectories/sessions/*/ | xargs -n1 basename | sort)
echo "账本去重 sessionId : $(echo "$LEDGER_IDS" | wc -l | tr -d ' ')" # 实测 419
echo "现存会话目录 : $(echo "$DIR_IDS" | wc -l | tr -d ' ')" # 实测 51
echo "两者交集 : $(comm -12 <(echo "$LEDGER_IDS") <(echo "$DIR_IDS") | wc -l | tr -d ' ')" # 实测 43
# 所以「账本覆盖率」这个说法必须指明分母是哪一个:
# 43/51 = 84.3% ← 「现存目录里有几个能在账本里查到成本」(排查时问的是这个)
# 43/419 = 10.3% ← 「账本里有几个还能翻到原始轨迹」(要深挖时问的是这个)
# 两个数都对,描述的是完全不同的两件事。只报一个数就是片面。# cost 为 0 且非本地模型("成本归零存疑"的正确判据:必须先有账本条目,见 L5 ①)
jq -c 'select((.costUSD // 0) == 0 and (.provider // "") != "ollama")
| {sessionId, model, provider, promptTotal}' ~/.sid-code/usage-ledger.jsonl# 缓存命中率 —— ⚠️ 两个要点:
# ① 分母用 promptTotal(flow 累计),不是末次 input(stock)→ 否则算出 444%
# ② jq 的中文键名必须加引号,否则 10 个 compile error(我第一版就栽在这)
jq -s '
map(select((.promptTotal // 0) > 0))
| { "样本数": length,
"命中": (map(.cacheHit // 0) | add),
"分母": (map(.promptTotal) | add) }
| . + { "命中率%": ((.["命中"] / .["分母"] * 1000) | floor / 10) }
' ~/.sid-code/usage-ledger.jsonl
# 实测输出:{"样本数":419, "命中":396182477, "分母":575706098, "命中率%":68.8}
# 读法:68.8% 命中。落在 2.3 节说的「隐式缓存结构性上限 60-70%」区间内,
# 所以这个数不该拿 >70% 的显式缓存阈值去考核——先分清是哪一族。A.4 Span 树健康度
# ① 各类 span 的落盘数
for k in chat execute_tool invoke_agent blocked_on_user hook_execution; do
printf "%-18s %s\n" "$k" \
"$(cat ~/.sid-code/telemetry/traces*.jsonl | jq -c "select(.kind==\"$k\")" 2>/dev/null | wc -l)"
done
# ② 有 parent / 无 parent 两分(无 parent 的就是根)
cat ~/.sid-code/telemetry/traces*.jsonl \
| jq -r 'if .parentSpanId then "有parent" else "无parent(根)" end' 2>/dev/null | sort | uniq -c
# ③ ★ 三个数必须一起看,单看任何一个都会下错结论
echo "SessionStart 事件数 : $(for f in ~/.sid-code/trajectories/sessions/*/events.jsonl; do
jq -rc 'select(.event=="SessionStart")' "$f" 2>/dev/null; done | wc -l | tr -d ' ')"
echo "invoke_agent 且无 parent 数: $(cat ~/.sid-code/telemetry/traces*.jsonl \
| jq -c 'select(.kind=="invoke_agent" and (.parentSpanId==null))' 2>/dev/null | wc -l | tr -d ' ')"
echo "traceId 去重数 : $(cat ~/.sid-code/telemetry/traces*.jsonl \
| jq -r '.traceId' 2>/dev/null | sort -u | wc -l | tr -d ' ')"这一段的真实输出,以及为什么它是本附录最好的教学样本
2026-08-29 实测(同一台机器、51 个会话):
chat 1501 SessionStart 事件数 : 51
execute_tool 2216 invoke_agent 且无 parent 数: 58
invoke_agent 72 traceId 去重数 : 91
blocked_on_user 0
hook_execution 0 无parent(根) 1006
有parent 2783第一课:本文档族里那个"176 个孤立根"的数字已经过时了。
早先的研究文档记录的是 176 无parent / 7 有parent——几乎全是孤立根。 现在是 1006 无parent / 2783 有parent——73% 的 span 有父节点了。
这正是陷阱 10(沿用旧数字)的活教材:如果我照抄那份文档写"span 树没有根", 就会把一个已经大幅改善的状况报成现存缺陷。 引用任何数字前一律复跑。 这也是本文开头那条免责声明的理由。
第二课:但"改善了"不等于"好了",而判据必须选对。
1006 个根 vs 91 个 traceId如果每棵树恰好一个根,这两个数应该相等。现在差 11 倍—— 说明树还是碎的,只是没有以前那么碎。
⚠️ 注意这里不能用"invoke_agent 数量 != 0"下结论(陷阱 4 的假门禁): invoke_agent 有 72 个、无 parent 的有 58 个,看起来"根 span 有了"。 但 58 ≠ 51(SessionStart 数),而且 58 也远小于 1006 个总根—— 说明那 1006 个根里绝大多数是 chat / execute_tool 类的孤儿, 不是会话根。只看 invoke_agent 这一格会被伪装成 PASS。
第三课:blocked_on_user 和 hook_execution 恒为 0 该怎么写。
按 7.3 节那条判读铁律,这两个 0 有三种可能,它们在数据里长得一模一样:
| 可能 | 结论完全不同 |
|---|---|
| 这段时间真的没发生过权限确认 | 「没有发生」——不用管 |
| 事件有 emit 但 span 没建 | 「没有采到」——是 bug |
| 那几个 hook 事件全仓根本没有生产 emit 点 | 「能力不存在」——该改口径,不是补埋点 |
第三种是实测出来的答案(检索只命中类型定义 + 探针自身 + 测试)。 所以正确的写法不是"5 类 span",而是:
声明 5 类、生产产出 3 类(chat / execute_tool / invoke_agent)、 其余 2 类无 emit 点。
这一格填 ✅ 会高估,填 ❌ 会错怪采集层。这就是陷阱 11 那套五档图例存在的理由。
A.5 过程病态率
# exit_status 分布("更准"方向的结果层指标)
for f in ~/.sid-code/trajectories/sessions/*/session.traj; do
jq -r '.metadata.exit_status // "missing"' "$f" 2>/dev/null
done | sort | uniq -c | sort -rn
# 孤儿 tool_use(排查崩溃的头号线索)
jq -c '[.trajectory[]? | select(._orphan == true)] | length' \
~/.sid-code/trajectories/sessions/<id>/session.traj
# 轮次分布(成本最大杠杆)
for f in ~/.sid-code/trajectories/sessions/*/session.traj; do
jq -r '.metadata.total_steps // 0' "$f" 2>/dev/null
done | sort -n | awk '{a[NR]=$1} END{
printf "n=%d p50=%d p95=%d max=%d\n", NR, a[int(NR*0.5)+1], a[int(NR*0.95)+1], a[NR]}'
# 工具执行耗时(回答"慢在模型还是慢在工具")
# 字段名实测确认:PostToolUse.data = {duration_ms, is_error, tool_name, tool_use_id}
for f in ~/.sid-code/trajectories/sessions/*/events.jsonl; do
jq -rc 'select(.event=="PostToolUse") | "\(.data.tool_name) \(.data.duration_ms // 0)"' "$f" 2>/dev/null
done | awk '{s[$1]+=$2; n[$1]++} END{for(t in s)
printf "%-18s n=%-5d 总=%.1fs 均值=%.0fms\n", t, n[t], s[t]/1000, s[t]/n[t]}' | sort -t= -k3 -rn这三条命令的真实输出,以及它们各自推翻了什么
① exit_status 分布(实测 51 个会话):
24 end_turn ← 正常说完
12 unknown ← ⚠️ 注意这一档
12 error
1 max_turns
1 abortunknown 占 23.5%,这一格才是重点。 按 7.3 节的判读铁律, 它不是"第五种退出状态",而是**"这个会话没能写下自己怎么结束的"**—— 也就是 1.6 节说的那 58.5% 缺失的现存形态。
别把 unknown 和 error 加在一起报成"异常率 47%":前者是观测缺失, 后者是确认的失败。合并会把仪器的缺陷算进产品的缺陷里。 正确写法是:确认失败 12/51 = 23.5%(另有 12 个退出态未记录,占 23.5%)。
② 轮次分布(成本最大杠杆):
n=50 p50=25 p95=110 max=433按 2.5 节 O(N²) 的关系:p95 那些 110 轮的会话, 单会话成本量级约是 p50(25 轮)的 (110/25)² ≈ 19 倍。 所以成本优化的靶子是那条尾巴,不是把 25 轮压到 20 轮。
③ 工具执行耗时——这条实测输出很反直觉,值得单独看:
bash n=479 总=1727.5s 均值=3607ms ← 占绝对多数
sub_agent n=8 总=932.2s 均值=116526ms ← 次数极少但单次极贵
ask_user_question n=6 总=608.2s 均值=101372ms ← 这不是"慢",是人在思考
grep n=256 总=8.9s 均值=35ms
glob n=35 总=8.2s 均值=234ms
read n=313 总=2.2s 均值=7ms
edit n=147 总=0.4s 均值=3ms三条读法:
| 观察 | 结论 |
|---|---|
read/edit/grep 加起来 11.5s,而 bash 一个 1727s | 优化"文件工具"是在优化 0.6%。先量再排序(8.3 节那条判据的日常形态) |
ask_user_question 均值 101 秒 | 这必须从"系统耗时"里剥出去,否则你的工具耗时 p99 度量的是人的犹豫——这正是 1.3 节 blocked_on_user 单列一类 span 的理由 |
sub_agent n=8 但均值 116 秒 | 均值在这里没有意义(n=8)。而且子代理耗时是嵌套的——它内部的 LLM 和工具耗时会被重复计入,不能和同级工具直接相加,否则总和会超过墙钟时间 |
第三条是做耗时归因时最容易犯的错:把嵌套 span 的耗时和它的子 span 平级相加, 得到"工具总耗时 > 会话总耗时"这种自相矛盾的结果。 归因分解必须只在同一层级内做加法。
A.6 死代码 / 接线率自查
# 三档分类:活代码 / 仅被测试消费 / 真死代码
SYM="normalizeCacheUsage"
DEF="src/llm/types.ts"
echo "生产调用点(排除定义与测试):"
rg -a -c "$SYM" --type ts -g "!$DEF" -g '!tests/**' -g '!**/*.test.ts' src/ | wc -l
echo "测试调用点:"
rg -a -c "$SYM" --type ts tests/ 2>/dev/null | wc -l
# 生产 = 0 且测试 > 0 → 「仅被测试消费」,这一档是隐形大头
# 生产 = 0 且测试 = 0 → 真死代码# 空壳普查(做横向对比时的 Step 0 准入检查)
rg -a -l "name: 'stub'" src/ | wc -l # 命令级空壳
rg -a -l "^// Stub:" src/ | wc -l # 文件级空壳A.7 零命中的反向自证
任何"零命中"结论,先验证你的检索式能抓到已知的东西。
# 例:验证密钥扫描正则真的能抓到 key(曾因字符类漏了 '-' 而漏掉真 key)
echo 'sk-ant-api03-KNOWN-TEST-VALUE' | rg -a 'your-regex-here'
# 抓不到 → 你的"零命中,很安全"毫无意义
# 例:验证 jq 路径真的能取到值
jq '.metadata.total_cost_usd' <(echo '{"metadata":{"total_cost_usd":1.23}}')
# → 1.23 说明路径写对了,再去跑全量A.8 一份收尾自检清单
每次做完一轮可观测性分析,过一遍这 8 条:
□ 1. 每个数字旁边写出了取数命令吗?(不能复跑的数字半年后无法判真假)
□ 2. 每个比率写清分母了吗?(分母口径一变,曲线整体平移)
□ 3. 分子分母是同一个口径吗?(stock ÷ flow = 假数)
□ 4. 有没有整齐的数字(全 0 / 100% / 恰好 60.0s)?→ 先怀疑命令
□ 5. 每个"没有"是 ❌(检索过零命中)还是 ⬜(没查)?两者不许混
□ 6. 我引用的"现状"是回源码核过的,还是照抄文档的?
□ 7. 我新加的门禁做过变异自证吗?改坏被测功能,它变红了吗?红的是哪条?
□ 8. 我拿什么证明改动生效了?是"跑了什么命令看到什么输出",
还是"机理上讲得通"?—— 后者不算证明收尾:把整份文档压缩成十句话
如果这份文档你只带走十句话,是这十句:
- 可观测性的第一层是数据,第三层是结论。数据不缺,缺的是把数据翻译成结论那一步。
- 有代码 ≠ 有能力。 数生产调用点,排除定义与测试;要三档分类,不是两档。
- 有输出 ≠ 有内容。 数非空字节,不是数文件大小。
- 失败路径的埋点比成功路径重要,而它总是被漏掉——因为写代码时你顺着 happy path 走。
- 父子关系是 Span 相对 Metric 的唯一价值,而它经常不成立,且后端无法重建。
- 数字可信度靠四道防线:累加口径统一 + provider 归一化 + 单一成本真相源 + stock/flow 分离。
- 危险的口径要用 API 设计来禁止,而不是靠文档提醒——提供了就一定有人用。
- 任何门禁必须变异自证:改坏被测功能,看它是否变红、红的是哪一条。
- 四个方向(快/省/准/安全)必然互斥,没有仲裁者时它们会互相欺骗。
- 目标指标改善 + 测试全绿 + 机理讲得通,三者同时成立时结论仍然可能是错的—— 收尾必须回到端到端的真实结果上验证。
最后一句,也是整份文档的元问题:
可观测性是用来发现问题的。所以你必须先回答: 你怎么知道你的可观测性没坏?
本文的十二个陷阱,全部是这个问题的不同答案。