Harness Engineering

第19章 可观测性与调试:把黑盒变成玻璃盒

作者 杨艺韬 · 12,107 字

第19章 可观测性与调试:把黑盒变成玻璃盒

“You can’t debug what you can’t see.” — 所有在生产环境调过 Agent 的人都懂

本章要点

  • 理解 Agent 可观测性和传统 Web 服务可观测性的根本差异:决策链长、分支多、成本方差大
  • 掌握 MELT 四类遥测数据:Metrics(指标)、Events(事件)、Logs(日志)、Traces(链路)
  • 读懂 Trace 的树状 Span 模型:顶层任务 → LLM call → tool execute,嵌套展开
  • 掌握 OpenTelemetry GenAI 标准属性(gen_ai.*):写一次、各平台通吃
  • 对比 LangSmith / LangFuse / Langtrace 三大主流 Agent 观测平台,选型有据
  • 学会三大调试技巧:Trace 重放(reproduce)、差分调试(diff two traces)、时光回溯(travel back)
  • 理解 Token 级成本追踪的三个粒度:per-task / per-user / per-tenant
  • 拿到 10 条生产告警规则模板,以及仪表盘四大必备视图
  • 避开四类观测反模式:采样过度、PII 泄漏、trace 爆炸、成本黑洞

19.1 为什么 Agent 需要专门的可观测性

传统 Web 服务的可观测性工具(Datadog、New Relic、Prometheus)设计时针对的是”请求-响应”式的同步服务:

  • 一次请求 = 一个 HTTP 调用 + 几次 DB 查询 + 几次下游服务调用
  • 总链路深度 2-5 层
  • 延迟以毫秒-秒计
  • 失败模式明确(HTTP 5xx、超时、DB 错误)

Agent 系统完全是另一种生物:

graph TB
    subgraph "传统服务"
        HTTP[HTTP Request] --> Handler[Handler]
        Handler --> DB1[DB Query]
        Handler --> Svc1[Service Call]
        DB1 --> Result[Response]
        Svc1 --> Result
    end

    subgraph "Agent 服务"
        UMsg[User Message] --> Loop[Agent Loop]
        Loop --> LLM1[LLM Call 1]
        LLM1 --> T1[Tool: Read]
        LLM1 --> T2[Tool: Grep]
        Loop --> LLM2[LLM Call 2]
        LLM2 --> T3[Tool: Edit]
        LLM2 --> T4[Tool: Bash]
        T4 --> LLM3[LLM Call 3 循环修复]
        LLM3 --> T5[Tool: Edit]
        Loop --> LLM4[LLM Call 4 总结]
        LLM4 --> Final[Response]
    end

    style Loop fill:#f59e0b,color:#fff,stroke:none
    style T4 fill:#ef4444,color:#fff,stroke:none

4 个关键区别

维度传统服务Agent 服务
链路深度2-5 层10-50 层嵌套(Loop × LLM × Tool)
执行时长100ms-1s10s-10min
分支确定路径非确定,同样输入可能走完全不同的路径
成本方差请求成本相近单次请求 0.010.01 到 10+ 的跨度
失败模式HTTP 错误码清晰”做错了但没报错”常见(silent failure)

这些差异意味着传统 APM 工具在 Agent 场景下根本用不明白——你在 Datadog 的 flame graph 里看到一个 45 秒的请求,点进去只有几个函数调用,中间几十次 LLM / Tool 调用全部消失在黑盒里。

Agent 需要专门的可观测性

19.2 MELT:四类遥测数据

现代可观测性的共识是把遥测数据分成四类,简称 MELT

graph TB
    M["📊 Metrics<br/>数值型时间序列<br/>用途:告警 + 仪表盘"]
    E["📝 Events<br/>离散业务事件<br/>用途:用户行为分析"]
    L["📄 Logs<br/>结构化 / 非结构化文本<br/>用途:故障根因 + 审计"]
    T["🔗 Traces<br/>树状嵌套 span<br/>用途:链路重建 + 调试"]

    M -.-> Alerting[Alertmanager]
    E -.-> Analytics[分析平台]
    L -.-> SIEM[日志存储]
    T -.-> APM[Trace 可视化]

    style M fill:#10b981,color:#fff,stroke:none
    style E fill:#3b82f6,color:#fff,stroke:none
    style L fill:#f59e0b,color:#fff,stroke:none
    style T fill:#8b5cf6,color:#fff,stroke:none

Agent 场景下四类数据的侧重:

数据类型传统服务Agent 服务
Metrics延迟 / 错误率 / QPS成功率 / token 消耗率 / tool error rate
Events用户注册、支付用户反馈 thumbs up/down、中断、重试
Logs错误堆栈LLM prompt/response 全文、tool 参数与结果
TracesRPC 调用链Agent loop → LLM → Tool 嵌套链

Trace 是 Agent 最重要的一类——没有 Trace 就等于闭眼开车。下面深入。

19.2.1 来自 Claude Code 的真实样本:src/services/analytics/ 四千行

打开 src/services/analytics/index.ts:133-163——生产级 Agent 的 telemetry 入口就 30 行:

export function logEvent(eventName: string, metadata: LogEventMetadata): void {
  if (sink === null) {
    eventQueue.push({ eventName, metadata, async: false })
    return
  }
  sink.logEvent(eventName, metadata)
}

三个生产化的决策值得抄走——

  • 启动前的事件 queue——sink 还没 attach 时不丢事件、先压队列、等 attachAnalyticsSink 调用时通过 queueMicrotask 异步 drain——zero startup latency
  • LogEventMetadata = { [key: string]: boolean | number | undefined }——故意不允许 string——避免 Agent 作者一不小心把代码片段/文件路径记成 metadata——类型系统当防线
  • Idempotent attach——if (sink !== null) return——allows 多入口(主命令 + 子命令 preAction hook)各自调用、不协调也不会重复初始化

4040 行代码analytics/*.ts 总行数)撑起 Claude Code 全部 telemetry——学这套结构、比从零造轮子快一个数量级

19.3 Trace 的树状 Span 模型

Trace 的核心数据结构是嵌套 Span——一棵树:

graph TB
    Root["Task Span: 修复 login bug<br/>duration: 45s | tokens: 85K | cost: $1.28"]

    Root --> L1["llm.call #1<br/>duration: 3s<br/>input: 2K | output: 500<br/>model: claude-opus-4.6"]
    L1 --> T1a["tool.execute: Read<br/>duration: 0.1s<br/>file: auth.ts"]
    L1 --> T1b["tool.execute: Read<br/>duration: 0.1s<br/>file: auth.test.ts"]

    Root --> L2["llm.call #2<br/>duration: 5s<br/>input: 8K | output: 1.2K"]
    L2 --> T2a["tool.execute: Edit<br/>duration: 0.2s<br/>file: auth.ts"]
    L2 --> T2b["tool.execute: Bash<br/>duration: 12s<br/>command: npm test<br/>❌ exit_code: 1"]

    Root --> L3["llm.call #3<br/>duration: 4s<br/>input: 6K | output: 800<br/>(循环修复失败的测试)"]
    L3 --> T3a["tool.execute: Edit<br/>duration: 0.2s"]

    Root --> L4["llm.call #4<br/>duration: 2s<br/>input: 3K | output: 400"]
    L4 --> T4a["tool.execute: Bash<br/>duration: 15s<br/>command: npm test<br/>✅ exit_code: 0"]

    Root --> L5["llm.call #5 总结<br/>duration: 2s<br/>input: 1K | output: 300"]

    style Root fill:#3b82f6,color:#fff,stroke:none
    style T2b fill:#ef4444,color:#fff,stroke:none
    style T4a fill:#10b981,color:#fff,stroke:none

看这张图一眼就能诊断:

  • 第一次测试红色(12s npm test 失败)
  • 触发了 llm.call #3 循环修复
  • 第二次测试绿色(15s npm test 成功)
  • 总耗时 45s,85K tokens,$1.28

这种时序 + 层级的展示,是调试 Agent 的”瑞士军刀”。没有它你只能盯着日志文件 grep。

19.3.1 Span 的最小数据模型

interface Span {
  // 标识
  span_id: string             // 全局唯一
  parent_id: string | null    // 父 span(顶层 trace 为 null)
  trace_id: string            // 同一 trace 下所有 span 共享

  // 元信息
  name: string                // "agent.task" / "llm.call" / "tool.execute"
  kind: SpanKind              // INTERNAL / CLIENT / SERVER
  start_time: number          // 开始时间 (ms)
  end_time: number | null     // 结束时间,running 时为 null

  // 状态
  status: "ok" | "error" | "unset"
  error?: { type: string, message: string, stack?: string }

  // 结构化属性(key-value)
  attributes: {
    [key: string]: string | number | boolean
  }

  // 事件(时间点日志)
  events: Array<{
    timestamp: number
    name: string
    attributes?: Record<string, any>
  }>
}

attributes 字段是 Span 的”货物舱”。Agent 场景下常见属性:

// LLM call
gen_ai.system: "anthropic"
gen_ai.request.model: "claude-opus-4-6"
gen_ai.usage.input_tokens: 2500
gen_ai.usage.output_tokens: 580
gen_ai.response.finish_reason: "stop"

// Tool execute
tool.name: "Read"
tool.input: "{\"file_path\":\"src/auth.ts\"}"
tool.output_size_bytes: 1520
tool.success: true

// Agent task
agent.user_id: "user-1234"
agent.session_id: "sess-abcd"
agent.task_type: "bug_fix"

19.3.2 Span 的生命周期

# 概念性伪代码
class Span:
    @contextmanager
    def span(self, name: str, **attributes):
        child = Span(
            span_id=uuid(),
            parent_id=self.current_span_id,
            trace_id=self.trace_id,
            name=name,
            start_time=now(),
            attributes=attributes,
        )
        self.push(child)
        try:
            yield child
            child.status = "ok"
        except Exception as e:
            child.status = "error"
            child.error = {"type": type(e).__name__, "message": str(e)}
            raise
        finally:
            child.end_time = now()
            self.pop()
            self.emit(child)

关键是 try/finally 保证 span 一定会关闭——即使中间抛异常,span 也会正确 emit(带着 error 属性)。

19.3.3 PII 标记类型:类型系统当”红线巡查员”

Claude Code 在 src/services/analytics/index.ts:13-33 定义了两个看似奇怪的 never 类型:

export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never

名字长到离谱——但这是故意的

  • 当工程师想把 string 塞进 telemetry、TS 编译器报错、必须显式 cast as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  • 这个 cast 会在 code review 里字面跳出来、reviewer 一眼看到”I_VERIFIED”就被迫思考”这个字段真的不包含代码或文件路径吗?”
  • 如果包含、改用 _PROTO_* 前缀的字段、进入PII-tagged 特权列、只有 1P exporter 能读、Datadog 看不到

这叫”把合规变成人人必须走的流程”——不是靠 wiki 文档、不是靠 code review checklist、是靠类型定义强制

任何 Agent 产品都应该抄这一手——三行类型定义、减 90% PII 泄漏风险

19.3.4 _PROTO_* 前缀约定与 stripProtoFields 防护

analytics/index.ts:46-59stripProtoFields 函数在 Datadog fanout 前兜底剥离 _PROTO_* 键——单点过滤、不是按 sink 重复写一遍——减少遗漏

export function stripProtoFields<V>(metadata: Record<string, V>): Record<string, V> {
  let result: Record<string, V> | undefined
  for (const key in metadata) {
    if (key.startsWith('_PROTO_')) {
      if (result === undefined) result = { ...metadata }
      delete result[key]
    }
  }
  return result ?? metadata
}

性能细节:没有 _PROTO_* 键时直接返回原对象(return result ?? metadata)、不浪费一次 clone——99% 的事件走 hot path、零分配

这个实现值得抄到任何有”多 sink + 敏感字段分级”需求的项目里——命名+单点兜底+零开销 hot path 三合一

19.4 OpenTelemetry GenAI 标准

2024 年 OpenTelemetry 社区发布了 GenAI 语义规范(semantic conventions),标准化了 LLM / Agent 场景下的 attribute 命名:

19.4.1 标准属性列表

# 所有 LLM call 都该带
gen_ai.system                   # "openai" / "anthropic" / "google" / ...
gen_ai.request.model            # 请求的模型名
gen_ai.request.temperature      # 采样温度
gen_ai.request.max_tokens       # max_tokens 参数
gen_ai.request.top_p
gen_ai.usage.input_tokens       # prompt 长度
gen_ai.usage.output_tokens      # completion 长度
gen_ai.response.id              # API 返回的 ID
gen_ai.response.model           # 实际服务的模型(可能和 request 不同)
gen_ai.response.finish_reason   # "stop" / "length" / "tool_calls"

# Tool call 相关
gen_ai.tool.name                # 工具名
gen_ai.tool.call.id             # tool_call_id

# Token 可选(对于敏感场景可以省略)
gen_ai.prompt                   # 完整 prompt(JSON 序列化的 messages 数组)
gen_ai.completion               # 完整 completion

19.4.2 为什么要遵循标准

三个明显好处:

1. 跨平台搬家零成本 用 OTel 标准属性打的 trace,发给 LangSmith、LangFuse、Datadog、Grafana Tempo 都能正确识别。换后端不用改埋点代码。

2. 自动化分析 你可以直接写查询:

# 找出所有 token 使用率超过 80% 的 trace
gen_ai.usage.input_tokens > (gen_ai.request.max_tokens * 0.8)

# 找出最近 24h 单次 task 成本 TOP 10
SELECT task_id, SUM(gen_ai.usage.input_tokens * 0.003 + gen_ai.usage.output_tokens * 0.015) AS cost
FROM spans WHERE trace.name = 'agent.task' ...

3. 工具生态 使用标准属性的 trace 能被自动化工具自动做成本归因、偏好分析、异常检测——都是开源可插拔方案。

19.4.3 代码示例

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("agent-service")

async def execute_task(user_message):
    with tracer.start_as_current_span("agent.task") as task_span:
        task_span.set_attribute("agent.task_type", detect_intent(user_message))

        while not done:
            # LLM call
            with tracer.start_as_current_span("llm.call") as llm_span:
                llm_span.set_attribute("gen_ai.system", "anthropic")
                llm_span.set_attribute("gen_ai.request.model", "claude-opus-4-6")
                llm_span.set_attribute("gen_ai.request.temperature", 0.7)

                response = await client.messages.create(...)

                llm_span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
                llm_span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
                llm_span.set_attribute("gen_ai.response.id", response.id)
                llm_span.set_attribute("gen_ai.response.finish_reason", response.stop_reason)

            # Tool calls
            for tc in response.content:
                if tc.type == "tool_use":
                    with tracer.start_as_current_span(f"tool.{tc.name}") as tool_span:
                        tool_span.set_attribute("gen_ai.tool.name", tc.name)
                        tool_span.set_attribute("gen_ai.tool.call.id", tc.id)
                        try:
                            result = await tools[tc.name](tc.input)
                            tool_span.set_status(Status(StatusCode.OK))
                        except Exception as e:
                            tool_span.set_status(Status(StatusCode.ERROR, str(e)))
                            tool_span.record_exception(e)
                            raise

这段代码产出的 trace 在任何 OTel 兼容的观测平台都能正确显示。

19.4.4 OTel GenAI 规范的版本演进

OpenTelemetry GenAI semantic conventions(opentelemetry-specification/semantic_conventions/ai2024 年 7 月首发 draft、2025 年 Q1 进 stable——演进路径值得追踪:

  • v0.1(2024-07)——只定义了 gen_ai.system / gen_ai.request.model / gen_ai.usage.* 核心六项
  • v0.2(2024-10)——新增 gen_ai.tool.* 工具调用属性
  • v0.3(2025-01)——新增 gen_ai.operation.namechat/completion/embedding)、gen_ai.response.finish_reason 改为枚举
  • v1.0(2025-Q2)——stable、属性名冻结、向后兼容承诺

工程启示——

  • 现在就按 v1.0 埋点、不要用旧版属性名——否则 2 年后搬家
  • tool 调用务必带 gen_ai.tool.call.id——方便重放时关联 LLM 请求和工具结果
  • gen_ai.prompt / gen_ai.completion 是 opt-in——规范明确说”包含用户生成内容、默认不导出”——别踩

这条演进曲线本身是个信号——Agent 可观测性 2024-2026 是标准形成的窗口期早入场、少返工

19.4.5 Claude Code 的”事件抽样动态配置”

analytics/index.ts:132 的注释提到 tengu_event_sampling_config——通过 Statsig feature gate 动态下发的抽样率

为什么要动态——

  • 排查生产事故时临时把抽样率拉到 100%、复现后又调回 1%
  • 特定 build 版本上线后临时全采、观察 regression、稳定后调回
  • A/B 实验时只对实验组 100% 采、对照组 1%

固态写死的抽样率都是短视的——遥测抽样策略必须支持 runtime hot-reload

本书第 18 章讨论持续评估时提过 feature flag 的跨场景复用——抽样策略也适用——同一套 gate 基础设施给多个用途

19.5 三大主流 Agent 观测平台对比

2024-2026 年市场上专注 LLM/Agent 观测的平台主要三家:

19.5.1 LangSmith(LangChain 家)

  • 强项:和 LangChain/LangGraph 深度集成,自动捕获;评估工具链完善;可视化 UI 最精致
  • 弱项:绑定 LangChain 生态;自托管复杂
  • 典型用户:深度用 LangChain 的团队

19.5.2 LangFuse(开源优先)

  • 强项:Apache 2.0 开源、可自托管;OTel 标准;价格透明
  • 弱项:UI 相比 LangSmith 稍弱
  • 典型用户:自研 Agent 栈 / 数据合规敏感 / 预算紧张

19.5.3 Langtrace(OpenTelemetry native)

  • 强项:100% OTel 原生;支持几乎所有 LLM SDK;轻量
  • 弱项:功能比前两者少
  • 典型用户:已有 OTel 栈想加 LLM 观测

19.5.4 对比决策树

graph TB
    Q[选观测平台?]
    Q --> LC{用 LangChain/LangGraph?}
    LC -->|是| LS[LangSmith<br/>集成最深]
    LC -->|否| SH{需要自托管?}
    SH -->|是| LF[LangFuse<br/>开源 + 可自托管]
    SH -->|否| OT{已有 OTel?}
    OT -->|是| LT[Langtrace<br/>零侵入]
    OT -->|否| LF2[LangFuse Cloud<br/>稳妥选]

    style LS fill:#3b82f6,color:#fff,stroke:none
    style LF fill:#10b981,color:#fff,stroke:none
    style LT fill:#f59e0b,color:#fff,stroke:none
    style LF2 fill:#10b981,color:#fff,stroke:none

三家都支持完整的 Trace 可视化、cost tracking、evaluation。新项目选自己技术栈契合的那个就好——不存在”全面更优”的选项。

19.5.5 三家具体 SDK 调用差异对比

抛开营销、直接看 SDK 调用姿势

LangSmith(Python)——

from langsmith import traceable

@traceable  # 自动 trace,需要 LANGCHAIN_TRACING_V2=true 环境变量
async def my_agent(msg): ...
  • 装饰器式埋点、对 LangChain/LangGraph 代码零改动
  • 非 LangChain 代码也能用、但需要手动 with trace(...)

LangFuse(Python)——

from langfuse import Langfuse
langfuse = Langfuse()
trace = langfuse.trace(name="agent.task")
span = trace.span(name="llm.call", input=prompt, output=response)
span.end()
  • 显式 API、命名空间独立、不依赖任何 agent 框架
  • 需要手动调 .flush() 在进程退出前——否则最后一批 span 丢

Langtrace(Python)——

from langtrace_python_sdk import langtrace
langtrace.init(api_key="...")  # 全局 init
# 之后所有 OpenAI/Anthropic SDK 调用自动产生 OTel span
  • monkey-patch OpenAI/Anthropic SDK、零侵入
  • 缺点:SDK 升级后可能 patch 失效、需要等 Langtrace 跟进

选型决策——

  • 团队已经用 OpenTelemetry——Langtrace 最轻
  • 团队用 LangGraph——LangSmith 的 trace 自带 graph topology
  • 数据合规高——LangFuse 自托管 + 显式 API、易于审计
  • 全部想试——同时插入所有三个 sink(都是 OTel-compatible、互不干扰)——2 周内对比真实 trace 再决定

19.6 三大调试技巧

19.6.1 Trace 重放(Reproduce)

客户报 bug:“Agent 在修改我的代码时突然把函数删了!”

传统调试:让客户提供复现步骤 → 10 次尝试 9 次复现不出 → 放弃。

Agent 调试:直接打开那次 Trace

1. 根据 trace_id 或用户 session_id 查找 Trace
2. 查看 Task Span 的完整 input(用户原始消息)
3. 逐个 LLM Call 展开:
   - 看每次的 prompt(system + conversation history)
   - 看每次的 response(完整 completion)
4. 在某次 LLM Call 里发现:prompt 被上一个工具的输出"污染"
   (工具输出里有类似代码的文字,模型误以为是用户要求)
5. 定位根因:工具输出没做文本转义
6. 修复并加回归测试

Trace 让 10 分钟的调试变成 30 秒

关键基础设施:

  • 完整保存每次 LLM 请求的原始 prompt 和 response(注意脱敏)
  • trace_id 和用户 session_id 双向映射
  • 快速查询 UI(按用户 ID / 时间 / 错误状态过滤)

19.6.2 差分调试(Trace Diff)

同一任务跑两次,一次成功一次失败。差异在哪?

graph TB
    subgraph "Trace A:成功"
        A1[Read auth.ts]
        A2[Read types.ts]
        A3[Edit auth.ts]
        A4[Run npm test]
        A5[返回成功]
        A1 --> A2 --> A3 --> A4 --> A5
    end

    subgraph "Trace B:失败"
        B1[Read auth.ts]
        B2[Edit auth.ts ← 跳过了 types.ts]
        B3[Run npm test]
        B4[❌ type error]
        B5[放弃]
        B1 --> B2 --> B3 --> B4 --> B5
    end

    Diff[Diff 发现:<br/>B 少了 Read types.ts 步骤]

    A2 -.对比.-> Diff
    B2 -.对比.-> Diff

    style A5 fill:#10b981,color:#fff,stroke:none
    style B4 fill:#ef4444,color:#fff,stroke:none
    style Diff fill:#f59e0b,color:#fff,stroke:none

LangSmith / LangFuse 都有原生的 Trace Diff View。两条 trace 并排显示,鼠标悬停对应步骤高亮联动。一眼发现”B 少做了一步”。

差分调试适合:

  • A/B 测试两个 prompt 版本的行为差异
  • 回归分析:新版本在哪个步骤开始和旧版本分叉
  • 找”为什么成功”:成功案例的共同点是什么

19.6.3 时光回溯(Time Travel)

进阶技巧:从某个 Span 的上下文开始重新 fork 一次执行

# 伪代码
def time_travel(trace_id: str, fork_after_span_id: str, new_params: dict):
    # 1. 加载原 trace
    trace = load_trace(trace_id)

    # 2. 找到 fork 点
    fork_span = find_span(trace, fork_after_span_id)

    # 3. 重建 fork 点的完整上下文
    messages = reconstruct_messages_at(fork_span)

    # 4. 用新参数继续执行
    new_trace = run_agent(
        messages=messages,
        **new_params,  # 比如换一个 model、改一个 prompt
    )

    # 5. 保存 fork 关系
    new_trace.forked_from = trace_id
    new_trace.forked_at = fork_after_span_id
    return new_trace

应用场景:

  • “如果这一步换个 model 会怎样?” — fork + 切 model
  • “如果这里 prompt 改成 X 会怎样?” — fork + 改 system prompt
  • “如果跳过这个工具调用会怎样?” — fork + mock 工具

配合 LangSmith 的 Playground 或 LangGraph 的 checkpointer,你甚至可以在 UI 上点几下就”在历史的某个岔路口走另一条路”。这是 Agent 调试独有的超能力。

19.7 Token 级的成本追踪

Agent 单次任务成本 0.010.01-10+ 跨度 3 个数量级,不追踪 = 钱怎么烧的不知道

19.7.1 三个聚合粒度

graph TB
    Raw[每 span 的<br/>gen_ai.usage.*]

    Raw --> T1[per-task<br/>单次任务成本]
    Raw --> T2[per-user<br/>单用户成本]
    Raw --> T3[per-tenant<br/>单租户成本]

    T1 --> Use1[哪类任务最贵<br/>哪些 prompt 最费]
    T2 --> Use2[识别高消费用户<br/>发现滥用]
    T3 --> Use3[B2B 计费<br/>成本归因]

    style T1 fill:#3b82f6,color:#fff,stroke:none
    style T2 fill:#10b981,color:#fff,stroke:none
    style T3 fill:#f59e0b,color:#fff,stroke:none

19.7.2 成本计算的正确做法

# 从 trace 计算一次任务的成本
def compute_task_cost(task_span) -> float:
    total_cost = 0
    for llm_span in find_descendants(task_span, "llm.call"):
        model = llm_span.attributes["gen_ai.request.model"]
        input_tokens = llm_span.attributes["gen_ai.usage.input_tokens"]
        output_tokens = llm_span.attributes["gen_ai.usage.output_tokens"]

        pricing = MODEL_PRICING[model]
        total_cost += (
            input_tokens * pricing.input_per_1k / 1000
            + output_tokens * pricing.output_per_1k / 1000
        )

        # 缓存命中的 token 更便宜(通常 1/10)
        cached = llm_span.attributes.get("gen_ai.usage.cache_read_tokens", 0)
        total_cost -= cached * pricing.input_per_1k * 0.9 / 1000

    return total_cost

注意三个细节:

  1. 区分缓存命中 token 和常规 token——OpenAI/Anthropic 的 cached prompt token 价格通常是 1/10
  2. model 可能动态切换(rate limit fallback),每 span 要独立算
  3. tool 成本别忘了——某些工具(web search、code execution)有独立的 API 成本

19.7.3 成本告警

alerts:
  - name: cost_per_task_p99_too_high
    query: histogram_quantile(0.99, rate(agent_task_cost_usd[10m]))
    threshold: > 5.0
    severity: warning
    action: investigate 异常任务

  - name: user_daily_cost_abnormal
    query: sum by (user_id) (rate(agent_task_cost_usd[24h]))
    threshold: > 100
    severity: critical
    action: 停止服务 + 联系用户(可能是滥用 or bug)

  - name: tenant_monthly_budget_80_percent
    query: tenant_spend_month / tenant_budget_month
    threshold: > 0.8
    severity: warning
    action: 发送预算预警邮件

19.8 生产告警规则模板

除了成本,还需要质量、性能、可用性方面的告警:

# 质量告警
- name: success_rate_drop
  query: avg_over_time(agent_task_success_rate[10m])
  threshold: < 0.85
  action: 检查近期 prompt / 工具改动

- name: fallback_rate_high
  query: rate(llm_fallback_to_cheaper_model[10m])
  threshold: > 0.1
  action: 主模型可能 rate-limited 或故障

# 性能告警
- name: task_duration_p99
  query: histogram_quantile(0.99, rate(agent_task_duration_seconds[5m]))
  threshold: > 120
  action: 可能遇到死循环或无限重试

- name: ttft_p99
  query: histogram_quantile(0.99, rate(agent_ttft_seconds[5m]))
  threshold: > 5
  action: 用户首字延迟过高

- name: tool_error_rate
  query: rate(tool_errors_total[5m]) / rate(tool_calls_total[5m])
  threshold: > 0.05
  action: 工具故障,检查具体 tool.name

# 可用性告警
- name: llm_api_error_rate
  query: rate(llm_api_errors[5m]) / rate(llm_calls[5m])
  threshold: > 0.01
  action: 上游 LLM 服务异常,考虑切 provider

- name: infinite_loop
  query: agent_loop_iterations{iterations > 50}
  threshold: > 5 events / 10min
  action: 有 agent 陷入死循环,检查循环终止条件

- name: context_overflow_rate
  query: rate(context_overflow_events[15m])
  threshold: > 0.02
  action: 上下文压缩策略可能不够激进

# 安全告警
- name: dangerous_tool_call_spike
  query: rate(tool_calls{tool_name="Bash", danger_score > 0.8}[5m])
  threshold: > 10 per hour
  action: 可能有 prompt injection 或滥用

19.9 仪表盘四大必备视图

一个成熟的 Agent 可观测性仪表盘应该至少有这四个视图:

视图 1:实时健康总览

  • 当前 RPS、成功率、p50/p99 延迟
  • 最近 1 小时告警列表
  • 上游 LLM API 健康状态

视图 2:成本监控

  • 今日累计成本(per-user / per-tenant / 总)
  • 成本趋势(7 天、30 天)
  • Top 10 最贵任务(含 trace_id 链接)

视图 3:质量趋势

  • 成功率趋势(按任务类型分面)
  • 用户反馈 thumbs up/down 比率
  • Eval suite 最新跑分(来自 ch18 持续评估)

视图 4:工具健康

  • 每个工具的调用次数 / 错误率
  • 最慢工具排行(p99 延迟)
  • 工具错误率异常高的告警

19.10 四类观测反模式

反模式 1:采样过度

“trace 太多存不下,采 1% 吧”——结果某个 bug 只在 0.5% 请求中出现,根本观测不到。

修正

  • 错误请求 100% 采样sample_on_error=1.0
  • 正常请求按时间窗口分层采样(保证每个时段都有代表)
  • 使用头部采样 + 尾部重采样:先都收、看到关键特征后保留全部 span

反模式 2:PII 泄漏

把用户的原始消息、工具结果全部记录到 trace——结果信用卡号、邮箱、密钥都进了观测后台。

修正

  • 在埋点层做 structured redaction,识别 PII pattern(邮箱、手机号、信用卡、密钥)自动 mask
  • 敏感字段(prompt / completion)单独存到加密存储,常规 attributes 只记长度和 hash
  • 定期审计 observability 里的数据分类

反模式 3:Trace 爆炸

每次工具调用都 span,子 span 里还有子 span——最后一个 trace 含几千个 span,UI 卡死,存储成本飞涨。

修正

  • Span 聚合规则:相似的连续 span(比如 for 循环里多次 read)合并为一个 span + 计数
  • 深度限制:Agent loop 超过 50 次就警告(可能陷入死循环)
  • Attribute 大小限制:单个 attribute 截断到 4KB,大 payload 引用到外部存储

反模式 4:成本黑洞

只监控任务成功率,不监控 token 消耗。某天发现 $10k/天的账单,一查是某个 prompt bug 让 Agent 把每个请求都 context 塞到 200K tokens。

修正

  • Token 成本是一等公民指标,和延迟、错误率一起监控
  • 每 task 都带 cost attribute,支持多维聚合
  • 成本异常告警必须立即响应,不能进归档

19.11 本章小结

可观测性是 Agent 工程里性价比最高的投资:

  • Agent 特殊需求:链长、分支多、时长变、成本方差大——传统 APM 不够用
  • MELT 四类数据:Metrics / Events / Logs / Traces(核心)
  • Span 树状模型:顶层任务 → LLM call → tool execute 嵌套展开
  • OpenTelemetry GenAI 标准gen_ai.* 属性写一次、各平台通吃
  • 三大观测平台:LangSmith(LangChain 生态)/ LangFuse(开源自托管)/ Langtrace(OTel 原生)
  • 三大调试技巧:Trace 重放(复现问题)、差分调试(对比成败)、时光回溯(fork 历史执行)
  • Token 级成本追踪:per-task / per-user / per-tenant 三粒度
  • 生产告警:质量 + 性能 + 成本 + 安全 四大类共 10+ 条规则模板
  • 仪表盘四视图:健康总览 + 成本 + 质量趋势 + 工具健康
  • 避开四反模式:采样过度、PII 泄漏、trace 爆炸、成本黑洞

可观测性和评估的关系:评估(ch18)告诉你系统好不好,可观测性告诉你哪里不好。一个在开发期保底线,一个在生产期找病灶。两者缺一不可。

下一章讲成本控制与性能优化——知道了哪里不好,怎么系统地改进它。

19.12 Span 大小的实际约束——OTel 协议的硬上限

OpenTelemetry OTLP 协议规范里单条 Span 的 protobuf 编码建议上限是 1 MiBopentelemetry-proto/opentelemetry/proto/collector/trace/v1/trace_service.proto 注释)——超限collector 直接丢、不告诉你。

Agent 场景下一条 span 轻易破上限——

  • 一次 Opus 调用带 100k token prompt——JSON 序列化就 300 KB
  • 外加 output、tool_result、error context——很快突破 1 MiB
  • Datadog / Tempo 收到默认截断、你在 UI 上看到的是”残缺 trace”

对策(按优先级)——

  • gen_ai.prompt / gen_ai.completion 存到外部对象存储(S3 / GCS)、span 只存引用 URI——span 保持 < 1 KiB
  • 对长 tool_result 做 byte-size attributetool.output_size_bytes = 152000)+ head/tail 各 2 KiB——重放时可 fetch 全文
  • 设置 OTel SDK 的 max_attribute_value_length 全局截断(OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=4096

这一节是全章被忽视最多的知识点——80% 生产 Agent trace 有信息丢失、用户却不知道

19.13 OTel instrumentation 的性能开销

埋点是有代价的——量化一下:

官方基准数据(OpenTelemetry SIG Performance 2024 报告)——

  • Rust tracing + OTel exporter——单 span 创建 ~500 nsL1 cache hit 不可见
  • Python opentelemetry-sdk——单 span 创建 ~2-5 μs、高频循环可感知
  • Node.js opentelemetry——~10 μs、是三者最慢

Agent 场景下 OTel 开销占比——

  • 一次 LLM 调用 P50 5s(网络 + 推理)、OTel 开销 ~10 μs——0.0002% 忽略不计
  • 一次 tool.Read 调用 P50 200 μs(本地读文件)、OTel ~10 μs——5% 可感

结论——

  • LLM / tool 粒度的 span——无脑打满、开销 noise level
  • 循环内部(forEach/map 里的小操作)——可以合并span.addEvent 而非新建 span)、否则 1000 次循环 10 ms 纯开销

这是为什么 §19.10 反模式 3 强调”span 聚合”——不只是存储问题、也是 runtime 开销问题

19.14 Trace 和 Checkpoint 的关系:双写一致性

本书第 13 章(LangGraph 《Streaming》)和第 18 章(《Design Patterns》)都讲过 checkpoint——把 Agent 中间状态持久化、允许断点恢复。

checkpoint 和 trace 看起来是两个东西——实际上互为镜像

  • Trace——给看的历史记录(调试、分析)
  • Checkpoint——给系统看的历史记录(恢复、重放、fork)

双写一致性要点——

  • 每次写 checkpoint 前、对应的 span 必须先 close——否则恢复时看到的 trace 是”悬挂的”(span 没结束、无法显示时序)
  • checkpoint 里要带 trace_id——恢复时可以从 trace 继承、不是开新链
  • trace_id 是稳定的 resume 标识——本章 §19.6.3”时光回溯”本质上就是 trace + checkpoint 的组合技

这个”双写”模式——是所有复杂系统的底层模式——binlog + data 文件(MySQL)、WAL + segment(Postgres)、journal + snapshot(etcd)——都是”一份给恢复、一份给观察”

19.15 一个典型的生产事故复盘模板

下面是一个用于说明 trace 重放价值的复盘模板,不把它包装成没有一手来源的匿名真实事故:

  • 现象——用户报告”Agent 会把别人的代码贴到我的文件里”
  • 影响面——从 trace 和会话日志中统计受影响 session
  • 第一反应——prompt injection?contex 泄漏?

排查靠的就是 trace 重放——

  • 从用户 session_id 拿到 trace_id
  • 展开 trace、在第 3 次 LLM call 发现 system prompt 末尾多了另一个用户的代码片段
  • 继续追、发现是 prompt cache 的 hash key 冲突(某个 salt 被意外设成了固定值)
  • 两个并发用户在 5 分钟 TTL 内命中了同一个缓存、prompt 互相串台

根因修复 30 分钟——但如果没有完整的 trace(含真实 prompt 内容、脱敏存储)——这个 bug 可能要查一周

教训——

  • prompt/completion 存储不能图省事——脱敏 + 加密 + 可查 三件套必须齐
  • cache key 的生成函数要有单元测试、哪怕 “trivial”
  • 每次并发事件(缓存、session、account)都是潜在的”串台”风险——trace 是唯一能定位它的工具

本书第 20 章(《成本控制》§20.4.7)列过”缓存被打破的四种场景”——这个事故是第五种:key 碰撞”——比缓存失效严重得多、会直接导致数据泄漏

19.16 从 trace 到 LLM-assisted 根因分析

前瞻——2025-2026 趋势:用 LLM 本身做 trace 根因分析。

原理——

  • 把一条”失败 trace”的所有 span 序列化成 JSON
  • 加上”这个任务最终失败了、请分析在哪一步开始走错”的 prompt
  • 让 Sonnet / Opus 读完、输出 hypothesis + 修复建议

为什么 LLM 适合这个——

  • trace 本质是长上下文的因果链——正好是 LLM 擅长的
  • 人类工程师看一个 100-span 的 trace 需要 15 分钟——LLM 15 秒
  • LLM 能发现跨领域相似性(“这看起来像经典的 retry storm”)——人类需要经验积累才能看出

已落地的项目——

  • LangSmith 的 “AI-assisted trace analysis”(2025 Q1 beta)
  • OpenAI 自己的内部 debugging Agent “Copilot for Incidents”(2024 内部博客提到)
  • DataDog 的 “Watchdog Analyst”(通用观测场景、不限 Agent)

这是”可观测性 2.0”的开头——从”人查日志”到”人问 LLM、LLM 查日志”——你的 trace 数据不仅给人看、也在训练未来的调试 AI

19.17 三本姊妹书的交叉点

  • 本书第 18 章《持续评估》——评估用的 golden set、本质上是脱敏后的历史 trace——观测 → 评估数据集的管线是 Agent 团队最被低估的基础设施之一
  • 本书第 20 章《成本控制》——§20.17 讲了 OTel metric 导出 cost 到 Prometheus——本章的 trace 视角 + 第 20 章的 metric 视角合起来才完整
  • 《Claude Code 源码》第 19 章(analytics)——src/services/analytics/*.ts 4040 行代码就是本章讲的所有原理的工业级落地——读完本章再回去读代码、会有”啊、他也是这么想的”的顿悟
  • 《LangGraph 源码》第 13 章(streaming)——checkpoint 和 trace 的双写模式
  • 《hyper-tower》第 14 章(tracing)——tower 的 tracing 中间件演示了 Rust 侧 OTel 集成路径

交叉点的意义——Agent 可观测性不是新学科——是”传统 APM + LLM 语义 + Agent 编排”三者叠加——读这几本书的对应章节、你会发现知识在打通、不是重复

19.18 一张可以直接印的”Agent 可观测性体检表”

贴在工位、每月过一遍——10 分钟体检

维度检查项通过标准
Trace 完整性随机抽 5 条 trace、能否从顶层点到叶子 span100%
PII 合规prompt / completion 是否脱敏或存特权列100%
Cost attribution每个 llm.call span 是否带成本计算所需 5 字段100%
Sampling 策略错误请求是否 100% 采样必须是
告警覆盖§19.8 的 10 条告警是否都配了≥ 8 条
仪表盘§19.9 的 4 个视图是否都在线4/4
Trace 搜索按 user_id / task_type / error 能否秒级过滤
Attribute 大小单 span 是否 < 1 MiB99%
外部 prompt 存储长 prompt 是否不进 span
Replay 能力能否从 trace 重建 messages 重跑
LLM 辅助分析是否接入过 LLM trace analyst至少试过一次
事故演练上季度是否做过一次”靠 trace 定位假 bug”演练

12 项——打勾 ≥ 10 项是 A 级7-9 项 B 级、< 7 项 C 级

C 级的团队请把本章再读一遍——这不是苛责、是保护用户(PII)、保护团队(调试效率)、保护公司(成本可见性)。

19.19 结语:把 Agent 从神秘变透明

Agent 可观测性的目标很明确:把 Agent 从”神秘黑盒”变成”透明玻璃盒”

  • 神秘黑盒——报 bug 只能扔给工程师、排查靠运气、成本无法归因、PII 风险无人知
  • 透明玻璃盒——用户报一个现象、团队 30 秒定位、成本精确到 token、合规可审计

从黑盒到玻璃盒的路径——不是一个工具能给你的——是 trace 数据模型、OTel 标准、脱敏管线、告警体系、仪表盘设计、反模式防御六合一——本章给的就是这张六合一地图

下一章(§20)把地图上的”成本维度”放大、给你一本专门的”省钱手册”。

19.20 Claude Code 的 Datadog 集成——工业级实现的三个细节

src/services/analytics/datadog.ts 307 行——不直接用 OTel、而是自己包了 axios post 到 Datadog Logs API——为什么?三个生产级决策:

决策一:白名单机制datadog.ts:19-55)——

const DATADOG_ALLOWED_EVENTS = new Set([
  'chrome_bridge_connection_succeeded',
  'tengu_api_error',
  'tengu_api_success',
  // ... ~50 个 event name
])

不是所有 logEvent 都走 Datadog——只有白名单里的才送。为什么——Datadog 按 event 计费、所有事件进 DD 会烧钱——挑高价值事件入 DD、低频 debug 事件进 1P BigQuery。

决策二:批量 + 定时 flushDEFAULT_FLUSH_INTERVAL_MS = 15000MAX_BATCH_SIZE = 100)——

  • 每 15 秒或满 100 条批量发一次
  • 不是每条立即发——避免网络抖动连续超时
  • 进程退出前 flush 一次——否则最后一批丢

决策三:网络超时 5sNETWORK_TIMEOUT_MS = 5000)——

  • 超时不阻塞主流程、Agent 的 telemetry 绝不能拖慢用户交互
  • 失败的 event 进 retry queue、最多重试 N 次然后丢——“能送到 99% 就够、不追求 100%

这三条——白名单降成本、批量抗抖动、超时保主流程——是任何第三方 telemetry 集成的通用架构。

19.21 1P BigQuery Exporter 的 806 行究竟在做什么

firstPartyEventLoggingExporter.ts 806 行——是本项目最大的 analytics 文件。它的核心职责:

  • Proto field hoisting——把 _PROTO_* 键从 additional_metadata map 提到 proto 顶层字段——PII-tagged 列才有权限控制
  • Schema enforcement——BigQuery 表有严格的 schema、字段名错了插不进——所有 event 过一层 validator
  • Fallback chain——主 endpoint 失败切备用、备用失败进本地 disk queue、进程下次启动 resend
  • Rate limiting——防止某个 event 高频爆发打垮 BQ streaming quota

“为什么要 1P 自建 BigQuery 管线、不直接用 Datadog”——

  • BigQuery 便宜5/TBstorage5/TB storage、5/TB query)——Datadog Logs 按 GB 计费贵 10-50×
  • SQL 查询无限制——支持任意维度切片、pipeline 接下游 ETL
  • 数据驻留——某些合规场景(GDPR、中国《数据安全法》)要求数据在特定地域、Datadog 做不到
  • schema 自主演进——增加字段不需要跟供应商协商

读完这 806 行——你会理解”为什么成熟 Agent 公司都有自建 BigQuery 管线”——Datadog 是 convenience、BQ 是 sovereignty

19.22 W3C Trace Context:跨服务的 trace_id 传递

Agent 场景下 trace 常常跨多个服务——前端 SPA → API gateway → agent orchestrator → LLM provider → vector DB → …——如何让 trace_id 端到端传递?

答案——W3C Trace Context 标准w3.org/TR/trace-context/、2020 推荐、2024 普及)——定义两个 HTTP header:

traceparent: 00-{trace-id}-{parent-span-id}-{flags}
tracestate: vendor1=value1,vendor2=value2

格式细节——

  • traceparent必需、4 段 hyphen 分隔、每段定长——解析零歧义
  • trace-id16 字节(32 hex)、全局唯一
  • parent-span-id8 字节(16 hex)、当前 span 在链路中的位置
  • flags01(采样)或 00(不采样)——下游按父决定采不采

工程实践——

  • 所有 HTTP 客户端 + 服务端(axios、fetch、Express、Fastify)都要自动注入/解析这两个 header
  • message queue 消息体要附 traceparent——异步边界也能串起来
  • LLM API 请求——Anthropic/OpenAI 暂未支持接收 traceparent、但你可以放到自定义 header、provider 回 response 时会原样带回 X-Request-ID 等字段——用 X-Request-ID 反查 Anthropic 的 usage dashboard

跨服务 trace 是”全链路观测”的基础——没它、每个服务是一座孤岛

19.23 Head Sampling vs Tail Sampling

抽样分两种、差异巨大:

Head Sampling(头部采样)——

  • 在 trace 开始时立即决定采或不采
  • 简单、分布式无协调
  • 缺点——完全随机、罕见错误可能 miss

Tail Sampling(尾部采样)——

  • trace 先 “全采” 进内存、等结束后根据 span 特征决定保留与否
  • 好处——按 “error=true” / “duration>10s” 等条件留存、100% 保留异常 trace
  • 缺点——需要全链路 span 汇聚到一处再决定——collector 内存占用大

OpenTelemetry Collector 的 tail_sampling processor 支持规则:

tail_sampling:
  decision_wait: 10s
  policies:
    - name: errors
      type: status_code
      status_code: { status_codes: [ERROR] }
    - name: slow
      type: latency
      latency: { threshold_ms: 5000 }
    - name: sample_normal
      type: probabilistic
      probabilistic: { sampling_percentage: 1 }

策略组合——错误 100% + 慢请求 100% + 常规 1%——抓到所有坏的、抽样保存一部分好的——存储成本可控、调试质量不降

实战建议——新项目从 head sampling 开始(简单)、DAU > 10k 后上 tail sampling(存储成本痛)。

19.24 一个常被忽视的维度:trace 的 retention 策略

存下来的 trace 放多久?这是成本 × 合规 × 实用性的三角平衡:

数据类型建议保留理由
错误 trace(status=error)90 天事后复盘、合规审计
慢 trace(P99+)30 天性能优化迭代周期
常规 trace7 天短期调试够用
trace 聚合指标2 年长期趋势分析
prompt / completion 全文30 天(加密列)PII 最小化原则
用户反馈关联 trace180 天eval 数据集候选

技术实现——

  • 按 trace 打 labelretention_tier: short/long/forever
  • 存储引擎按 label 自动迁移冷热(BQ partition_expiration_days / Datadog Archive Tier)
  • 合规要求删除时、按 user_id 查所有 trace、批量调删除 API

GDPR “被遗忘权”的技术落地——如果你的 trace 按 user_id 分区、一行 SQL 就能满足——否则需要全表扫描、每次合规请求烧几千美元——架构阶段就要考虑这一点

19.25 Agent 可观测性与传统 APM 的本质差别

传统 APM 关注 CPU、内存、网络——它是”Ops 给 Ops 看”的观测,对 Agent 系统意义不大。

本章讲的是 “Agent 工程师给 Agent 工程师看”的观测——关注 decision chain、token economy、semantic failure——这才是 Agent 时代的生命线

本章十条知识点索引:

  1. 为什么需要 OTel GenAI 语义规范(§19.4)
  2. 怎么选 LangSmith/LangFuse/Langtrace(§19.5)
  3. 怎么做 Trace Replay / Diff / Time Travel(§19.6)
  4. 怎么设计 PII 红线(§19.3.3 / §19.3.4)
  5. 怎么算成本、怎么告警、怎么搭仪表盘(§19.7-19.9)
  6. 避开四反模式(§19.10)
  7. Span 1MiB 限制、OTel 性能开销、trace/checkpoint 双写一致性(§19.12-19.14)
  8. Datadog 白名单设计、1P BQ exporter 架构(§19.20-19.21)
  9. W3C Trace Context 跨服务传递(§19.22)
  10. Head/Tail Sampling、retention 策略(§19.23-19.24)

19.26 metadata.ts 973 行——“上下文富化”的工业级范例

src/services/analytics/metadata.ts 973 行——每一条 logEvent 发出前、都会被这个文件的 getEventMetadata 增加 20+ 维度的上下文

从 import 列表看它增加的维度——

  • env / getHostPlatformForAnalytics——操作系统 + 架构
  • getModelBetas / getMainLoopModel——当前用哪个模型 + 启用哪些 beta
  • getSessionId / getParentSessionId——当前会话树
  • getClientType——CLI / IDE extension / VSCode bridge 等入口类型
  • getWslVersion / getLinuxDistroInfo / detectVcs——细粒度环境信息
  • isOfficialMcpUrl——是否调用官方 MCP server(第 17 章《MCP》场景)
  • isClaudeAISubscriber / getSubscriptionType——用户订阅分层
  • getRepoRemoteHash——脱敏后的 git remote hash(识别同项目的多用户、但不泄漏仓库名)
  • getAgentId / getTeamName / isTeammate——多租户团队分组

总共 20+ 维度——每一条事件都带齐——这就是”事件的维度富化”:一次埋点、多维切片——Datadog / BQ 里按任何维度查询都秒级返回。

反面教训——只 log eventName + duration、出了问题查”是哪个版本的哪种用户在哪种 OS 上触发的”——只能一条 SQL 一条 SQL 拼 JOIN——分析效率差 100 倍

读者带走一条——埋点时 metadata 数量与 event 数量一样重要——每加一条 event 都问自己:切片维度齐不齐

19.27 growthbook.ts 1155 行——feature gate 基础设施的规模

analytics/growthbook.ts 1155 行——Claude Code 把 feature flag / gate 做成了完整子系统

  • 远程配置实时拉取——启动时 + 定时 refresh
  • 本地缓存——离线场景降级
  • user bucketing——基于 user_id hash 的一致性采样(同用户进同实验组)
  • 多维度条件匹配——按 OS / version / region / subscription 组合规则
  • 暴露 API checkStatsigFeatureGate_CACHED_MAY_BE_STALE(§19.20 的 Datadog gate 就用这个)

为什么 Agent 产品需要 feature gate——

  • 模型切换(Opus 4.6 → 4.7 beta 发布)需要 10% 流量试
  • prompt 改动需要 A/B——控制组 / 实验组数据 telemetry 侧区分
  • 生产事故时一键切到 backup——通过 gate 下发、不需要重启服务
  • 合规要求某地区用户禁用某功能——一个 gate 规则搞定

读者能抄走的架构——把 feature gate 和 telemetry 绑定——每个 event 都带当前 gate 状态——A/B 分析不需要 JOIN、直接 group by 实验组

19.28 实战:用 PromQL 写出四大仪表盘的 20 条查询

§19.9 只给了”四大视图”标题、这里落地到具体 PromQL

视图 1:实时健康总览

# 1. 当前 RPS
sum(rate(agent_task_start_total[1m]))

# 2. 成功率
sum(rate(agent_task_success_total[5m])) / sum(rate(agent_task_complete_total[5m]))

# 3. P99 延迟
histogram_quantile(0.99, rate(agent_task_duration_seconds_bucket[5m]))

# 4. 上游 LLM 健康
sum(rate(llm_api_errors_total[5m])) by (provider)

# 5. 当前告警数
ALERTS{alertstate="firing"}

视图 2:成本监控

# 6. 今日累计成本
sum(increase(agent_cost_usd_total[1d]))

# 7. per-tenant 成本
sum by (tenant) (rate(agent_cost_usd_total[1h]))

# 8. Top 10 用户
topk(10, sum by (user_id) (increase(agent_cost_usd_total[1d])))

# 9. cache 命中率
sum(rate(llm_cache_read_tokens_total[5m])) / sum(rate(llm_input_tokens_total[5m]))

# 10. 成本趋势(同比上周)
sum(increase(agent_cost_usd_total[7d])) / sum(increase(agent_cost_usd_total[7d] offset 7d))

视图 3:质量趋势

# 11. 任务类型分面成功率
sum by (task_type) (rate(agent_task_success_total[1h])) / sum by (task_type) (rate(agent_task_complete_total[1h]))

# 12. thumbs up/down 比率
sum(rate(user_feedback_total{rating="up"}[1d])) / sum(rate(user_feedback_total[1d]))

# 13. 平均迭代轮数
sum(rate(agent_loop_iterations_total[5m])) / sum(rate(agent_task_complete_total[5m]))

# 14. context compaction 触发率
sum(rate(context_compaction_triggered_total[5m])) / sum(rate(llm_calls_total[5m]))

# 15. eval 最新分数(来自 ch18 持续评估)
eval_score_latest{suite="coding_bench"}

视图 4:工具健康

# 16. 每个工具错误率
sum by (tool_name) (rate(tool_errors_total[5m])) / sum by (tool_name) (rate(tool_calls_total[5m]))

# 17. P99 工具延迟
histogram_quantile(0.99, sum by (tool_name, le) (rate(tool_duration_seconds_bucket[5m])))

# 18. 工具调用频率 TOP 5
topk(5, sum by (tool_name) (rate(tool_calls_total[5m])))

# 19. Bash 危险命令率
sum(rate(tool_calls_total{tool_name="Bash", danger_score="high"}[5m]))

# 20. MCP 工具占比
sum(rate(tool_calls_total{tool_type="mcp"}[5m])) / sum(rate(tool_calls_total[5m]))

20 条 PromQL 贴进 Grafana 即可得到一个完整的 Agent 可观测性仪表盘——剩下的只是美化

19.29 把 trace 当一等产品

很多团队把 trace / 可观测性当工具——“我用 Datadog 配了仪表盘”、“我接入了 OTel”就完事。

真正成熟的 Agent 团队把 trace 当产品——

  • 有 owner——专门的 observability engineer / SRE
  • 有 roadmap——季度规划哪些维度要补、哪些反模式要修
  • 有 SLO——不只是”能用”、是 “P99 trace 完整率 > 99.9%”、“PII 泄漏事件 = 0”
  • 有 cost budget——observability 自己的 Datadog / BQ 成本每月有预算
  • 有用户——下游开发 / SRE / 合规 / 产品 都是 trace 的”客户”、定期收集反馈迭代

这就是”可观测性 2.0”的工程文化——从”附属工具”到”内部产品”

19.30 附录 A:从零搭一个最小可用观测栈(MVP)

本章篇幅大、但很多读者手上还没有任何观测。给一个周一就能跑起来的 MVP:

Day 1——基础

  • opentelemetry-sdk + opentelemetry-exporter-otlp-proto-http
  • 启动一个本地 otel-collector(docker 一行)
  • 在每次 LLM call / tool call 外面包 tracer.start_as_current_span
  • 验证:能在 collector 的 log 里看到 span

Day 2——后端

  • 选 LangFuse Cloud 或 Grafana Tempo(都有免费额度)
  • 把 collector 的 exporter 配置指向后端
  • 验证:UI 上能看到第一条 trace

Day 3——属性完整性

  • 加齐 §19.4.1 的所有 gen_ai.* 属性
  • 加 PII 红线类型(§19.3.3)——防止敏感数据泄漏
  • 验证:任意抽 5 条 trace、属性齐全

Day 4——告警

  • 从 §19.8 挑 3 条最关键的(success_rate_drop、cost_per_task_p99、infinite_loop)
  • 在后端配置告警、路由到 Slack / Email
  • 验证:人为触发一次异常、告警到达

Day 5——仪表盘

  • 用 §19.28 的 5-10 条 PromQL 搭一个简易仪表盘
  • 按 §19.9 的四视图组织
  • 验证:一眼能看到生产健康

一周 MVP——比 80% Agent 项目的可观测性都强

19.31 附录 B:术语速查

本章用到的专业名词一页纸速查——

  • Span——一个操作的追踪单元、有 start/end 时间、可嵌套
  • Trace——同一个请求产生的所有 span 组成的树
  • Attribute——span 上的 key-value 元信息
  • Event——span 内部的时间点标记、不是 span
  • Status——span 的完成状态(ok / error / unset)
  • Sampling——决定哪些 trace 要留下的策略
  • Head/Tail Sampling——在开始时 vs 结束时做采样决策
  • OTel / OpenTelemetry——开源的遥测框架 + 语义规范
  • OTLP——OTel 的 wire protocol(protobuf/HTTP/gRPC)
  • GenAI Semconv——OTel 为 LLM/Agent 定义的标准属性集
  • MELT——Metrics/Events/Logs/Traces 四类遥测数据的合称
  • Span Kind——INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER
  • W3C Trace Context——跨服务 trace_id 传递的 HTTP header 标准
  • Flame Graph——按时间轴堆叠展示 span 的可视化
  • PII——Personally Identifiable Information、个人识别信息
  • Redaction——脱敏处理(邮箱→****、手机→hash)
  • Retention——数据保留策略(多久后删除)
  • Cardinality——label 可能取值的组合数、高基数会爆炸

把这张表贴在工位、半年内不用再 Google 这些词。

19.32 附录 C:把本章读进脑子里的 10 道自测题

测试第一原理掌握情况(答得上 8 道以上算及格)——

  1. OTel GenAI 规范里 token 使用量的 attribute 名是什么?(答:gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
  2. Span 的 protobuf 编码建议上限多少?(答:1 MiB)
  3. W3C Trace Context 的两个 HTTP header?(答:traceparent / tracestate
  4. traceparent 里 trace-id 长度?(答:16 字节 / 32 hex 字符)
  5. Claude Code 为什么用 never 类型做 PII 标记?(答:强制 cast 过 code review)
  6. Head Sampling vs Tail Sampling 的关键差异?(答:决策时机、tail 可基于完整 trace 特征采样)
  7. trace 和 checkpoint 的关系?(答:互为镜像——一份给人看、一份给系统恢复用)
  8. stripProtoFields 为什么在无 _PROTO_* 键时返回原对象?(答:零分配 hot path)
  9. per-task / per-user / per-tenant 成本聚合为何都要有?(答:分别对应成本分析、滥用检测、B2B 计费)
  10. 为什么错误请求要 100% 采样?(答:罕见错误在 1% 抽样下完全丢失、无法调试)

答不上的题——回去把对应小节再读一遍——这比读一百个框架文档都有用。

19.33 永别是不存在的——本章和下一本书的接口

本章的”可观测性”概念——会在后续多本书里再度出现

  • 《LangChain 源码》第 11 章(callbacks)——LangChain 的 callback system 是它自己的”可观测性原语”、和 OTel 共存
  • 《Claude Code 源码》第 14 章(analytics architecture)——深入 src/services/analytics/*.ts 4040 行的产品化实现
  • 《Vite 源码》第 15 章(Plugin Hook Observability)——Vite 插件钩子的性能追踪——前端工程工具也有 APM 需求
  • 《hyper-tower》第 14 章(tracing middleware)——Rust 侧的 tower::trace::Trace 实现
  • 《React 18 源码》第 16 章(Profiler API)——前端渲染性能观测

“可观测性”不是一个章节的话题、是贯穿 15 本书的暗线——任何复杂系统都需要”被看见”——你读透这条暗线、就打通了”从后端到前端、从协议到 UI”的工程哲学

一章终、余音长——下章成本控制见

19.34 一张 SVG 不贴、只贴一段话

如果读者把本章所有小节全部忘了——只记住这一段话

Agent 没有 trace 就像飞机没有黑匣子。平时不出事没事、一出事你什么都不知道。世上没有”不出事”的 Agent 产品——所以世上也没有”不需要 trace”的 Agent 团队

这句话不需要解释——读了本章你就会懂

19.36 补充一则:Anthropic SDK Usage 对象的真实字段清单

本章多处引用 gen_ai.usage.*——对应到 Anthropic Python SDK anthropic/types/usage.py 的真实字段(v0.40 版本):

  • input_tokens:prompt 长度
  • output_tokens:completion 长度
  • cache_creation_input_tokens:写入缓存的 token 数(付 1.25× 溢价)
  • cache_read_input_tokens:命中缓存的 token 数(付 0.1× 折扣)
  • server_tool_use.web_search_requests:web search 工具调用次数(Anthropic 侧按次计费 $0.01)
  • cache_creation:分 ephemeral_5m_input_tokens / ephemeral_1h_input_tokens 两档
  • service_tierstandard / priority / batch——决定单价档位

把这 7 个字段全部 attribute 化——你就拥有了和 Anthropic 账单对账的能力

llm_span.set_attribute("gen_ai.usage.input_tokens", usage.input_tokens)
llm_span.set_attribute("gen_ai.usage.output_tokens", usage.output_tokens)
llm_span.set_attribute("anthropic.usage.cache_creation_input_tokens", usage.cache_creation_input_tokens ?? 0)
llm_span.set_attribute("anthropic.usage.cache_read_input_tokens", usage.cache_read_input_tokens ?? 0)
llm_span.set_attribute("anthropic.usage.cache_creation_5m", usage.cache_creation?.ephemeral_5m_input_tokens ?? 0)
llm_span.set_attribute("anthropic.usage.cache_creation_1h", usage.cache_creation?.ephemeral_1h_input_tokens ?? 0)
llm_span.set_attribute("anthropic.usage.web_search_requests", usage.server_tool_use?.web_search_requests ?? 0)
llm_span.set_attribute("anthropic.service_tier", usage.service_tier)

8 个 attribute——你从此不再”不知道钱去了哪里”——这是本章给出的最具体的一条行动

19.37 为什么 Agent 时代的可观测性尤其重要

Agent 可观测性的跨时代对比——

  • 过去——服务 bug 靠日志 grep 能解决
  • 现在——Agent bug 需要重建”20 层嵌套的决策链 + 10 次工具调用 + 5 次 LLM 采样分支”才能理解
  • 未来——bug 本身可能不存在、只有”概率性能力退化”——只能靠时间序列和 trace 聚合发现趋势

底层原理(span 树、采样、PII 红线、成本归因)不随模型版本变化,这是本章在未来几年依然能作为参考的原因。

19.38 一句话浓缩

“If you can’t trace it, you can’t own it.”——你追溯不到的系统、就不是你的系统。


全章指标统计:7 小节原文 + 20 小节新增源码实证 / 跨书关联 / 工程实战 / 附录——覆盖从”什么是 trace”到”如何把 trace 当产品运营”的完整认知链条。下次翻开本章时、愿你已把这套方法落地到你的 Agent 产品里。


延伸阅读