第十二章 可观测性:如何解释一个 agent 为什么这样行动

第十一章最后留下了一个问题:当 skill、MCP、hook、dynamic tool 和多 agent 都进入同一套 LOOP,系统怎样解释“为什么模型没用某项能力”? 传统服务的可观测性主要回答“请求是否成功、耗时多少、哪里报错”。Agent harness 还必须回答更难的问题:模型当时看到了什么、为什么决定调用这个工具、审批和策略怎样改变了行动、结果是否真正回到上下文、恢复后的执行与过去有什么关系。 因此,Codex 的可观测性不只是 logs、traces 和 metrics。前端事件、rollout 与诊断 replay 共同组成了一条从实时现象到历史证据的观察链。 本章关心的不是“埋了多少点”,而是如何把一次不可预先确定的 agent 行为,还原成一条可定位、可归因、可解释的因果链。


12.1 为什么 agent 比普通服务更难观察

普通 HTTP 服务通常有相对确定的控制流:

收到请求 → 校验参数 → 查询数据库 → 返回响应

请求慢了,可以沿着固定调用链检查数据库、缓存和下游服务;请求错了,可以从状态码和异常堆栈定位失败点。

Agent 的控制流不是提前写死的。一次用户输入可能经历:

构造上下文
→ 模型采样
→ 输出 reasoning
→ 调用两个工具
→ 等待用户批准
→ 并行派生三个子 agent
→ 再次采样
→ 触发压缩
→ 切换模型连接
→ 给出最终回答

下一步做什么,由模型根据当时可见的历史、instruction、工具 spec 和工具结果临时决定。相同输入在不同世界状态下,可能走出完全不同的路径。

这给可观测性增加了四种困难。

12.1.1 控制流是运行时生成的

工具调用不是业务代码里固定的一行函数调用,而是模型输出的 item。要解释一次行动,不能只看工具执行阶段,还要知道:

  • 本轮有哪些工具对模型可见;
  • 模型生成了什么调用;
  • harness 是否接受并正确解析;
  • policy、审批和沙箱是否允许;
  • 工具结果是否回灌;
  • 回灌后模型是否继续采样。

只观察执行器,会错过行动产生之前和结果回灌之后的半条链路。

12.1.2 “成功”有多个层次

一次 MCP 请求返回成功,不代表任务成功;命令退出码为零,不代表模型正确理解了输出;turn 正常结束,也不代表用户目标达成。

至少要区分:

层次 “成功”意味着什么
传输 请求和流没有断开
协议 item、delta、工具参数能被正确解析
执行 工具或外部服务完成了动作
LOOP 结果进入上下文,循环正常推进
任务 agent 给出了结果或明确说明阻塞
用户目标 最终产物真的满足需求

前四层可以由 harness 较可靠地观察;最后一层通常仍需要用户反馈、评测或业务系统验证。

12.1.3 等待与计算混在同一条时间线上

一个 turn 用了十分钟,可能是:

  • 模型首 token 很慢;
  • 工具在排队;
  • 命令执行很慢;
  • MCP server 响应很慢;
  • 等了用户九分钟才批准;
  • 上下文过长,触发了 compaction;
  • 模型流断开后发生重试;
  • 子 agent 尚未返回。

只记录一个 turn_duration = 10min 几乎没有诊断价值。Agent 可观测性必须把模型时间、工具时间、调度时间、人工等待时间和 harness 自身开销拆开。

12.1.4 现在看到的状态,不一定能解释过去

工具目录会刷新,配置会变化,plugin 会升级,工作区文件会被修改。故障发生半小时后再查看当前状态,可能已经无法回答:

  • 当时模型看到的是哪一版 tool spec?
  • 那个 skill 是否已经激活?
  • 策略为何拒绝了命令?
  • 子 agent 收到的究竟是哪条消息?
  • 模型请求中实际包含哪些历史 item?

因此,agent harness 不仅需要实时 telemetry,还需要能在事后重建语义关系的 replay 证据。

可观测性的目标不是证明“系统记录过什么”,而是让人能从证据回答:发生了什么、为何发生、时间花在哪里、现在能否安全继续。


12.2 五种证据:每一种都只回答一部分问题

Codex 中可以观察到五类互补的信息:

证据 最擅长回答 不擅长回答
前端 Event 用户当时看到了什么,turn/item 如何推进 跨服务内部耗时、未对前端公开的细节
Logs 某个时刻发生了什么错误,携带哪些局部字段 完整调用树、总体趋势
Traces 一次请求跨组件经过哪里,各阶段耗时和父子关系 长期统计、完整恢复语义
Metrics 错误率、延迟分布、吞吐、资源和容量趋势 单次异常的完整上下文
Replay evidence 过去的语义事实如何关联,模型实际看到了什么 低成本实时告警

它们不是重复记录同一件事,而是从不同角度投影同一段执行。

flowchart TB
    RUN["一次真实执行"]

    RUN --> EVT["前端 Event<br/>实时交互事实"]
    RUN --> LOG["Logs<br/>局部诊断细节"]
    RUN --> TR["Traces<br/>跨组件因果与耗时"]
    RUN --> MET["Metrics<br/>聚合趋势与告警"]
    RUN --> REP["Replay evidence<br/>离线语义重建"]

    EVT --> UI["重建用户所见"]
    LOG --> DEBUG["定位具体错误"]
    TR --> PATH["追踪请求路径"]
    MET --> SLO["发现系统性退化"]
    REP --> WHY["解释过去为何如此行动"]

这五类证据对应五种不同的问题:

Event:界面为什么停在这里?
Log:这一刻具体报了什么?
Trace:时间花在哪条链路?
Metric:这是个例,还是系统性问题?
Replay:模型当时依据哪些事实作出决定?

如果只保留其中一种,都会产生盲区。

  • 只有 logs:能搜索错误,却难以拼出并发链路。
  • 只有 traces:能看调用树,却未必知道模型上下文和恢复语义。
  • 只有 metrics:知道 P95 变差,却不知道哪一次请求发生了什么。
  • 只有 rollout:能恢复对话,却没有足够细的 timing 和内部决策证据。
  • 只有前端 Event:能复现界面,却不一定能解释底层策略和网络行为。

成熟的设计不是把所有数据塞进一套系统,而是让它们共享稳定的标识符,可以互相跳转和交叉验证。


12.3 先建立坐标:trace ID 不等于业务身份

分布式 tracing 常用 trace_id 串起一次请求。但对 agent 来说,仅靠 trace_id 不够。

一个 thread 可以活几天,包含很多 turns;一个 turn 内可能有多次模型采样和几十次工具调用;一个子 agent 的工作可能由父 agent 触发,却在另一条异步链路上执行;进程重启后,旧 span 已经结束,但同一个 thread 还会 resume。

所以 Codex 同时需要两类坐标。

12.3.1 运行时坐标:这件事属于谁

标识符 回答的问题
thread_id 发生在哪个 agent thread
turn_id 属于哪一次用户驱动的工作
root_turn_id 整棵多 agent 工作由哪个根 turn 发起
parent_turn_id 当前工作直接由哪个 turn 派生
item_id 对应哪条消息、reasoning、工具调用或结果

12.3.2 调用坐标:这次交互是哪一次

标识符 回答的问题
call_id 某次 tool call 与结果如何配对
inference_call_id 属于哪一次模型采样
mcp_call_id MCP 请求、响应和工具语义如何关联
communication_id 多 agent 消息从发送到接收如何配对
trace_id 这次在线执行跨越了哪些进程和传输

可以把二者理解为:

  • 业务坐标说明“这是故事中的哪一段”;
  • trace 坐标说明“这一次运行经过了哪些机器和函数”。
flowchart LR
    ROOT["root_turn_id"]
    PARENT["parent_turn_id"]
    TURN["turn_id"]
    INF["inference_call_id"]
    ITEM["item_id"]
    CALL["call_id"]
    TRACE["trace_id"]

    ROOT --> PARENT --> TURN
    TURN --> INF
    INF --> ITEM
    ITEM --> CALL
    TRACE -. "贯穿本次在线执行" .-> TURN
    TRACE -.-> INF
    TRACE -.-> CALL

这套双坐标解决了一个常见误区:span tree 不等于 agent 的业务树。

异步任务可能脱离原来的调用栈;消息经过队列后,发送者和接收者不再是直接父子 span;resume 可能为同一个业务 turn 创建新的 trace;多 agent 的父子关系也不是普通函数调用关系。

因此,tracing 系统应该同时使用:

  • parent-child span 表示真实的在线调用嵌套;
  • link 或业务 ID 表示跨队列、跨进程、跨恢复的因果关系;
  • rollout 中的 lineage 表示长期稳定的 agent 历史关系。

不要为了让 trace 图看起来像一棵整齐的树,就伪造并不存在的同步父子关系。


12.4 端到端 tracing:把一次 turn 串起来

一次来自网络前端的 turn,通常会跨过这些边界:

sequenceDiagram
    participant FE as 前端程序
    participant AS as App Server
    participant Q as Submission Queue
    participant T as Turn Task
    participant L as LOOP
    participant M as Model Service
    participant P as Policy / Approval
    participant X as Tool / MCP / Exec Server

    FE->>AS: thread/turn 请求 + trace context
    AS->>AS: app-server request span
    AS->>Q: Submission + trace context
    Q->>T: dispatch turn
    T->>T: turn span
    T->>L: run LOOP
    L->>M: inference request + W3C trace context
    M-->>L: streaming item / delta
    L->>P: 工具决策与权限判断
    P-->>L: allow / deny / ask user
    L->>X: 执行工具 + trace context
    X-->>L: tool result
    L->>M: 回灌结果,继续采样
    M-->>L: final response
    L-->>T: turn complete
    T-->>AS: Event stream
    AS-->>FE: 通知与最终 item

W3C traceparenttracestate 负责让上下游接续同一条 trace。它们可以穿过:

  • 前端到 app-server 的请求;
  • app-server 到内核的 submission;
  • 内核到模型服务的 HTTP 请求;
  • WebSocket 建连时的 metadata;
  • harness 到独立 exec-server 或其他支持 tracing 的组件。

但“把 header 传过去”只是第一步。真正有诊断价值的 trace,还需要合理的 span 层级。

12.4.1 Span 应围绕语义阶段,而不是每个函数

典型层级可以是:

app-server request
└── submission dispatch
    └── turn
        ├── prepare context
        ├── inference call #1
        │   ├── send request
        │   ├── receive stream
        │   └── handle response items
        ├── tool call A
        │   ├── wait for concurrency permit
        │   ├── policy / approval
        │   └── execute
        ├── tool call B
        ├── inference call #2
        └── finalize turn

好的 span 有三个特点:

  1. 对应人能理解的阶段。 “模型流接收”比某个内部函数名更稳定。
  2. 有明确的开始和结束。 结束时才能计算 duration 和 outcome。
  3. 不会因为实现重构就完全失效。 可观测语义应比代码调用栈更稳定。

如果给每个小函数都建 span,会得到一张巨大但无法阅读的火焰图;如果只给整个 turn 建一个 span,又无法解释时间去哪了。观察粒度应该围绕生命周期边界和外部交互。

12.4.2 Event 与 span 各司其职

持续一段时间的动作适合 span:

  • 一次模型采样;
  • 一次工具执行;
  • 一次 MCP 调用;
  • 一次 compaction;
  • 一次 hook;
  • 一段等待或重试。

瞬时事实适合 event:

  • 收到首个 token;
  • 模型改道;
  • policy 给出 deny;
  • sandbox 拒绝;
  • SSE 重连;
  • token usage 结算;
  • 某个 item 完成。

把所有事实都做成 span,会制造大量零时长节点;把长操作只记成开始、结束两条 log,又容易在并发环境中配错。span 表示区间,event 表示区间中的关键点。


12.5 Turn latency:总耗时只是答案的开头

用户最直接的感受是“这个 turn 很慢”。但一个总时长不能指导优化。Codex 会把 turn 的时间拆成若干阶段:

阶段 含义
before_first_sampling turn 开始后,到第一次模型采样前的准备时间
sampling 所有模型采样和流式接收累计时间
compaction 上下文压缩消耗的时间
between_sampling_overhead 两次采样之间未归入工具和压缩的 harness 开销
tool_blocking LOOP 等待工具 future 完成的时间
after_last_sampling 最后一次采样结束后,到 turn 完成前的收尾时间

同时还要记录:

  • sampling request 数量;
  • sampling retry 数量;
  • TTFT(time to first token);
  • TTFM(time to first meaningful output);
  • 输入、输出、缓存和 reasoning token;
  • 最终 outcome。
flowchart LR
    START["Turn 开始"] --> PRE["采样前准备"]
    PRE --> S1["Sampling 1"]
    S1 --> MID["采样间阶段"]
    MID --> S2["Sampling 2"]
    S2 --> POST["采样后收尾"]
    POST --> DONE["Turn 完成"]

    MID -. "主要等待" .-> TOOL["Tool blocking"]
    MID -. "harness 开销" .-> OVER["Between-sampling overhead"]
    MID -. "按需触发" .-> COMP["Compaction"]

这张图最重要的含义是:

E2E 慢,不等于模型慢。

例如:

  • TTFT 高:更可能是模型排队、网络或请求体过大;
  • TTFT 正常但 sampling 长:可能是输出很多、reasoning 很长或流速慢;
  • sampling 不长但 tool blocking 高:应继续检查工具、MCP、审批或并发闸门;
  • compaction 占比高:说明上下文压力已经进入用户可感知路径;
  • between-sampling overhead 高:可能是结果归一化、上下文重建、hook 或调度开销;
  • sampling retry 增加:最终请求虽成功,但连接质量或服务限流正在恶化。

12.5.1 TTFT 与 TTFM 不是一回事

第一个流式字节可能只是协议心跳、response created 或空 delta。用户真正感知到“agent 开始回应”,往往要等到首段可显示文本、reasoning summary、计划或工具动作出现。

因此:

  • TTFT 更适合观察传输和模型服务是否开始返回;
  • TTFM 更接近用户感知到有效进展的时间。

如果 TTFT 很低、TTFM 很高,问题可能不在网络,而在模型长时间生成不可见 reasoning、前端过滤策略或 item 聚合逻辑。

12.5.2 当前时间分类仍然可能有空白

时间分解不是天然准确的。每一毫秒只能在埋点边界足够清楚时被正确归类。

例如,tool_blocking 表示 LOOP 等待工具 future 的时间。这个 future 内部可能包含:

  • 等待并发 permit;
  • 路由和参数校验;
  • policy 判断;
  • 等待用户审批;
  • 选择 sandbox;
  • 真正执行;
  • 结果归一化。

所以 tool_blocking = 60s 不能直接推导出“命令执行了 60s”。还要进入单工具 timing 继续拆解。

同样,Human in the loop 的等待虽然是非常重要的用户体验指标,但如果系统没有统一的 approval_waithuman_wait 阶段,它就可能被包含在工具 handler 或其他等待中。指标缺少一个分类,本身也是可观测性结论,而不是应该被报表掩盖的问题。


12.6 工具链路:一次调用至少经过四段

第六章把工具调用拆成 spec、路由、policy、执行和结果回灌。可观测性也必须沿着同样的边界展开。

flowchart LR
    A["模型生成 tool call"] --> B["解析与路由"]
    B --> C["等待 runtime ready<br/>等待并发 permit"]
    C --> D["policy / guardian<br/>审批 / sandbox"]
    D --> E["真实执行<br/>local / MCP / dynamic"]
    E --> F["结果归一化"]
    F --> G["tool result 回灌"]
    G --> H["下一次 sampling"]

一条有用的工具记录至少应该包含:

  • 所属 thread_idturn_idtrace_id
  • tool_namecall_id 和执行类型;
  • 开始、结束、总耗时;
  • 排队或 dispatch 耗时;
  • handler 耗时;
  • outcome 和错误类别;
  • 是否经过审批、沙箱升级或 guardian;
  • 结果大小,而不是默认记录全部结果正文。

12.6.1 不要把 handler duration 当成纯执行时间

工具 timing 常见三个值:

指标 通常覆盖什么
dispatch_duration runtime readiness、并发 permit 等进入 handler 前的等待
handler_duration 路由后的完整处理,包括 policy、审批、sandbox 和真实执行
total_duration 从调用被接收到结果返回的总时间

handler_duration 并不等于 shell process 的 wall time。它是“handler 拿到调用以后直到返回”的黑盒区间。

如果要回答“为什么命令慢”,还需要执行域自己的子指标:

  • exec-server queue duration;
  • process spawn duration;
  • process running duration;
  • stdout/stderr drain 与退出收尾;
  • MCP request latency;
  • dynamic tool 等待前端响应的时间。

只有逐层拆开,才能区分:

慢在 harness 排队
≠ 慢在等用户
≠ 慢在沙箱准备
≠ 慢在外部服务
≠ 慢在进程本身

12.6.2 错误要按阶段分类

“工具失败”也不是一个充分的错误类别。

阶段 示例
模型输出 tool name 不存在、参数 JSON 不完整
路由 catalog revision 已过期、runtime 不可用
policy 命令被规则拒绝、guardian 否决
approval 用户拒绝、请求超时、前端断开
sandbox OS 拒绝访问、沙箱后端启动失败
transport MCP 断连、请求超时、协议错误
tool result MCP 请求成功,但工具返回业务错误
feedback 结果过大、编码失败、无法进入上下文

尤其要区分 MCP 的请求失败工具结果为 error

  • 请求失败表示没有正常完成协议交互;
  • error result 表示 server 正常回复,只是业务动作失败。

二者的重试、告警和责任归属完全不同。


12.7 决策也要可观察:不仅记录“做了什么”

Agent 的问题经常不是某个函数报错,而是“为什么没有走预期路径”。

例如用户问:

为什么 agent 明明安装了数据库 skill,却没有调用数据库工具?

要回答这个问题,至少要检查一条完整的能力因果链:

flowchart LR
    P["能力来源<br/>plugin / project / user"] --> D["Discovery<br/>是否被发现"]
    D --> A["Activation<br/>是否启用与受信任"]
    A --> V["Visibility<br/>本 step 是否对模型可见"]
    V --> M["Model decision<br/>是否生成调用"]
    M --> R["Routing<br/>是否绑定正确 runtime"]
    R --> S["Safety<br/>policy / approval / sandbox"]
    S --> X["Execution<br/>是否成功执行"]
    X --> F["Feedback<br/>结果是否进入上下文"]

如果只记录最后的 tool call,模型没有调用时就什么也看不到。可观测系统还需要记录“未发生之前”的关键状态:

  • catalog 是否发现能力;
  • activation 是否禁用,以及禁用原因;
  • step snapshot 中是否包含对应 tool spec;
  • 是否因为 deferred exposure 尚未展开;
  • 模型是否输出了相近但不存在的工具名;
  • policy 是否在执行前拒绝;
  • 结果是否因为过大或协议错误未能回灌。

这并不意味着要保存模型内部不可见的“真实思想”。系统只能观察协议上出现的 reasoning、item、调用和配置事实,不能从 telemetry 推断模型心里一定在想什么。

可解释性必须建立在可验证证据上。记录“模型看到了什么”和“模型输出了什么”,比事后编造一个看似合理的动机更可靠。

12.7.1 安全裁决需要结构化字段

第八章讨论过 policy、guardian 和网络管控。它们的可观测结果不能只是一行自然语言:

request denied

至少应结构化记录:

  • decision:allow、deny、ask;
  • rule 或 policy 来源;
  • risk 分类;
  • authorization 类型;
  • 是否发生 sandbox escalation;
  • 网络目标的域名、协议和端口;
  • 耗时和失败阶段。

这样既能聚合“最近 deny 是否暴增”,也能在单次 trace 中解释“谁做了最终决定”。

网络审计尤其应遵循最小披露:通常记录域名、协议、端口和裁决已经足够,不必保存完整 URL、query 或认证信息。

12.7.2 Hook 要区分控制结果和旁路结果

同步 hook 可能阻止流程,异步 hook 通常只做通知或上报。两者在 telemetry 中必须区分:

  • hook 名称、来源和版本;
  • 触发生命周期点;
  • sync 或 async;
  • duration;
  • allow、block、modify、error 或 timeout;
  • 是否改变了最终控制流。

否则一个异步上报失败可能被误读成任务失败,或者一个真正阻断执行的 hook 只留下一条不起眼的 warning。


12.8 多 agent:调用树之外还有一棵协作树

第七章中,多 agent 通过 spawn、消息和结果交付形成一棵 thread tree。可观测性必须同时面对两棵树:

  1. 执行树:span 的父子关系,表示这次在线调用如何展开;
  2. 协作树:root turn、parent turn、thread lineage 和 communication,表示任务如何委派。

它们经常重合,但并不等价。

flowchart TB
    RT["Root turn T0"]
    A["Agent A / thread A"]
    B["Agent B / thread B"]
    C["Agent C / thread C"]
    M1["message c1"]
    M2["message c2"]

    RT --> A
    RT --> B
    A --> C
    B --> M1 --> A
    C --> M2 --> A

    subgraph Traces["可能分散在多条在线 trace 中"]
        TR1["trace x:spawn A/B"]
        TR2["trace y:B 完成后投递"]
        TR3["trace z:resume A 并消费结果"]
    end

如果只看 trace_id,可能看到三段互不相连的执行;如果只看 thread_id,又无法知道一条消息属于哪个根任务。

多 agent 观测至少需要:

  • root、parent、child 的 thread 与 turn 坐标;
  • spawn 请求与 agent 启动结果;
  • communication ID;
  • 发送、排队、投递、消费四个时刻;
  • 子 agent 的最终状态和结果 item;
  • 并发 permit 的等待时间;
  • 父 turn 是否在等待、取消或已经结束。

12.8.1 “子 agent 很慢”仍然需要拆解

父 agent 等待子 agent 30 秒,可能是:

  • 子 agent 排队等并发名额;
  • 子 agent 自己在等模型;
  • 子 agent 在跑工具;
  • 结果已产生,但消息尚未投递;
  • 消息已投递,但父 agent 尚未进入下一个 step;
  • 父 turn 已中断,结果成为迟到消息。

因此,多 agent latency 不能只记录 spawn → result。它应至少区分:

spawn queue
→ child active
→ child result ready
→ message delivered
→ parent consumed

这和第二章的 Event 原则、第五章的 LOOP 边界以及第十章的恢复语义是同一个问题:事实产生、事实送达、事实被消费,是三个不同时间点。


12.9 Metrics:用聚合信号发现系统性问题

Trace 适合解释一次请求,metrics 适合回答:

  • 最近一小时 turn 错误率是否升高?
  • P95 TTFT 是否只在某个模型上恶化?
  • MCP timeout 是否集中于某个 server?
  • 工具队列是否接近容量?
  • compaction 是否越来越频繁?
  • guardian 的 deny 比例是否异常?
  • SQLite 初始化或 fallback 是否增加?

12.9.1 三种基础指标

类型 含义 Agent 场景
Counter 只增加的累计次数 turn、请求、错误、重试、tool call
Gauge 某一时刻的当前值 活跃 turn、队列长度、连接数、运行中进程
Histogram 一组数值的分布 TTFT、turn duration、tool duration、token

平均值常常会掩盖 agent 的长尾。比如九个 turn 用 5 秒,一个 turn 因审批等待用了 10 分钟,平均值看起来只有约 65 秒,却同时误导了两类用户。

延迟应重点看分位数,并按有意义的阶段拆分:

  • P50 反映常见体验;
  • P95/P99 揭示长尾;
  • max 帮助发现极端挂起,但不适合单独告警。

12.9.2 指标应围绕用户体验和容量

一套实用指标通常覆盖:

请求与模型

  • API 请求次数、状态和 duration;
  • SSE/WebSocket 连接、事件和重连;
  • TTFT、TTFM;
  • token usage;
  • sampling retries。

Turn 与 LOOP

  • turn E2E duration;
  • 各 timing phase;
  • 每个 turn 的 sampling 次数;
  • compaction 次数和耗时;
  • complete、interrupt、error 等 outcome。

工具与扩展

  • tool、MCP、hook、plugin、skill 的调用或激活结果;
  • 排队、handler、执行 duration;
  • timeout、deny、approval 和 sandbox outcome;
  • catalog 刷新和启动失败。

运行时资源

  • submission queue;
  • tool concurrency;
  • child agent permit;
  • exec-server queue、process、connection;
  • telemetry 自身队列和丢弃数;
  • 持久化初始化、写入和 fallback。

12.9.3 Tag 基数是一项架构约束

Metrics 系统最怕高基数标签。把 thread_idturn_idcall_id、完整错误信息、文件路径或用户 prompt 放进 tag,会让时间序列数量无限增长。

适合作为 metric tag 的字段通常是:

  • model family;
  • tool category;
  • outcome;
  • error class;
  • transport;
  • sandbox mode;
  • approval decision;
  • feature name。

不适合作为 tag 的字段通常是:

  • 任意 ID;
  • URL 和路径;
  • 用户名和项目名;
  • 原始异常文本;
  • 工具参数和输出。

单次 ID 应进入 trace 或 log,聚合维度才进入 metrics。所有 tag 还应经过白名单、长度限制和 sanitize。

Metrics 负责“分组”,traces 负责“定位”。试图让 metrics 同时完成定位,会把监控系统变成另一个高成本数据库。


12.10 三种 replay:恢复、重建界面与诊断不是一回事

“回放”在 agent 系统里经常指三种完全不同的机制。

Replay 目的 主要输入 结果
Rollout replay 恢复 Session 和模型历史 canonical rollout item 可继续工作的运行时状态
UI event replay 重建前端看到的 turn/item 持久化 item 和状态投影 对话、工具和状态界面
Rollout-trace replay 离线分析因果链 更细粒度的原始诊断事件与 payload 语义关系图和调查证据

12.10.1 Rollout replay:为了继续工作

第十章已经说明,rollout 保存的是恢复所需的 canonical facts:

  • 用户和 assistant item;
  • 工具调用与结果;
  • turn 边界;
  • compaction;
  • 配置和世界状态相关事实;
  • 多 agent lineage。

它的兼容性要求高,生命周期长,但不会保存所有内部细节。它不需要记录每个函数耗时,也不应该默认保存每个流式 delta。

Rollout replay 的问题是:

“恢复后,模型应该看到怎样的有效历史?”

12.10.2 UI event replay:为了重建用户所见

前端关心的是:

  • 哪个 turn 开始或结束;
  • 哪个 item 已完成;
  • 命令和补丁显示什么状态;
  • 哪些审批仍需处理;
  • token 与 warning 如何展示。

它可以从 canonical item 与运行时状态生成一份 UI projection。瞬时 delta 丢失后,完整 item 仍能恢复最终界面;但“当时每个字以什么速度出现”通常不值得长期保存。

UI event replay 的问题是:

“重新连接以后,前端应该显示什么?”

12.10.3 Rollout-trace replay:为了理解过去

诊断 replay 需要比 canonical rollout 更细的证据,例如:

  • 每次 inference 的请求与响应关系;
  • 模型实际接收的 conversation;
  • tool call、MCP call 与返回 item 的关联;
  • terminal session 和 operation;
  • compaction 前后的语义关系;
  • 多 agent 消息从来源 item 到目标 conversation 的投递;
  • 必要时引用独立保存的大 payload。

它遵循一个关键原则:

Observe first, interpret later:先忠实记录观察到的原始事实,再离线归约成语义图。

flowchart LR
    R["Raw events<br/>带 seq 的追加事实"] --> RED["Reducer<br/>严格按 seq 归约"]
    PAY["Payload files<br/>请求、响应、输出"] --> RED
    RED --> G["Semantic graph"]

    G --> TH["threads / turns"]
    G --> IN["inference calls"]
    G --> TO["tool / MCP calls"]
    G --> CO["conversation items"]
    G --> CP["compactions"]
    G --> ED["interaction edges"]

原始事件按单调递增的 seq 排序,而不是只相信 wall clock。原因很简单:并发任务的系统时间可能相同,跨线程写入也可能出现微小乱序;reducer 需要一个稳定顺序才能得到确定结果。

大 payload 通常与事件信封分开保存,并遵循:

先写 payload
→ 再追加引用它的 event
→ 每条 event 及时 flush

这样 event 一旦可见,它引用的 payload 就已经存在。反过来,即使进程在两步之间崩溃,最多留下一个未被引用的 payload,不会留下指向不存在内容的正式事件。

12.10.4 为什么不能让 rollout-trace 取代 rollout

诊断 trace 更详细,看起来似乎更适合作为“终极事实源”,但这样做会破坏几个重要边界:

  • 它可能包含敏感 prompt、response、路径、终端输出和工具参数;
  • 它是显式开启、本地保存的诊断机制,不保证始终存在;
  • 内部事件结构可以比公共恢复格式更快演进;
  • 数据量更大,不适合作为每个 Session 的长期负担;
  • 诊断证据描述“运行时观察到什么”,不一定等于“恢复时应向模型呈现什么”。

Canonical rollout 追求长期兼容和安全恢复;rollout-trace 追求故障现场的解释力。二者目标不同,不应合并。


12.11 Reducer:从原始事件还原语义,而不是还原调用栈

Raw trace 只是一串按序到达的证据。真正有价值的是 reducer 构造出的语义图。

为什么需要 reducer?因为并发和流式协议会让事件以“不方便理解”的顺序出现:

  • 某个工具结果先被观察到,来源 item 稍后才完成;
  • MCP transport ID 与模型 tool call ID 属于不同命名空间;
  • 子 agent 先完成,父 agent 过一会儿才消费消息;
  • runtime 为方便前端产生了一条 output,但模型并没有看到它;
  • compaction 改变了后续 conversation,却没有改写旧事实。

Reducer 的职责是:

  1. 严格按 seq 消费原始事件;
  2. 建立 thread、turn、inference、item、tool 和通信实体;
  3. 用稳定 ID 连接实体;
  4. 暂存尚未找到来源的 pending 关系;
  5. 在后续证据到达时补全关联;
  6. 区分模型真正看到的 conversation 与 runtime 的便利输出;
  7. 保留无法解释的原始证据,而不是静默丢弃。
flowchart TB
    E1["事件:tool result 到达"]
    E2["事件:来源 item 完成"]
    P["Pending association"]
    C["建立 call → result → item 关系"]

    E1 -->|"暂时缺少来源"| P
    E2 --> P
    P --> C

这是一种比在线日志更稳健的设计。在线路径不必为了立即得到完美语义而阻塞主流程;离线 reducer 可以接受“证据迟到”,只要 ID 和顺序足够稳定。

但 reducer 也不能随意猜测。如果找不到唯一关联,应把关系标为 unresolved,并保留原始引用。错误的确定答案,比明确的不确定更危险。


12.12 隐私:观察得越深,越要限制谁能看到

Agent telemetry 天然接近敏感数据:

  • 用户 prompt 可能包含源代码、商业信息和个人数据;
  • 工具参数可能包含路径、查询、收件人和命令;
  • 工具输出可能包含密钥、日志、数据库记录;
  • reasoning 和 assistant response 可能复述上下文;
  • 网络 URL 可能包含 query token;
  • rollout-trace 可能重建整个工作过程。

所以可观测性不能只有“开或关”,而需要分层。

12.12.1 Trace-safe metadata

默认 trace 应优先记录结构和规模,而不是正文:

  • prompt 长度;
  • 输入 item 类型和数量;
  • 工具名称与参数长度;
  • 输出字节数和行数;
  • error class;
  • duration;
  • policy decision;
  • token usage。

例如,记录“用户输入 2,431 字符,包含文本和一张图片”,通常足以分析请求大小和模型延迟,不必上传 prompt 正文。

12.12.2 Logs 比 traces 更可能包含细节

为了本地故障排查,logs 可能记录更详细的工具参数、错误和输出。Log exporter 因此应被视为比 trace exporter 更高风险的出口:

  • 默认关闭远程导出;
  • 明确区分安全 event 与详细 log;
  • 用户 prompt 默认 redact;
  • 限制字段长度;
  • 对认证 header、WebSocket frame 和已知敏感字段做过滤;
  • 配置导出目标时清楚说明数据边界。

“日志只给内部人看”不是隐私策略。只要数据离开本机,就应该按外发数据处理。

12.12.3 Rollout-trace 是本地、显式开启的诊断证据包

为了回答“模型当时究竟看到了什么”,rollout-trace 可能不得不保存:

  • 完整 prompt 和 response;
  • 工具参数与结果;
  • 终端输出;
  • 工作区路径;
  • 多 agent 消息;
  • 原始协议 payload。

这种能力不能默认上传,也不应在用户不知情时长期启用。合理边界是:

  • 显式 opt-in;
  • 本地存储;
  • 明确生命周期和清理方式;
  • 分享前由用户检查;
  • 诊断失败不影响主任务;
  • 不把它误当成普通匿名 telemetry。

用户主动提交反馈时,可以附带诊断日志或其他材料。但这仍然需要:

  • 前端明确展示将上传什么;
  • 用户主动确认;
  • 过滤高风险、高流量且不必要的原始 frame;
  • 对附件大小设上限;
  • 区分产品反馈与自动 telemetry。

Consent 不是一个永久开关,而是对这次具体数据提交的授权。


12.13 可观测系统自身也会失败

Telemetry 是辅助系统,不能成为 agent 主流程的新单点故障。

合理的失败语义是:

  • exporter 初始化失败:记录本地 warning,agent 仍可启动;
  • 单次 event 发送失败:吸收错误,不改变 tool 或 turn outcome;
  • metrics backend 不可用:丢失指标,但不阻塞 LOOP;
  • analytics 队列满:有界丢弃并计数,不能无限占用内存;
  • rollout-trace 写入失败:停止或降级诊断记录,不让任务失败;
  • shutdown flush 超时:在固定时间预算后退出;
  • reducer 遇到未知事件:保留原始证据并继续处理可理解部分。
flowchart LR
    MAIN["Agent 主流程"] --> BUF["有界 telemetry 队列"]
    BUF --> EXP["Exporter"]
    EXP --> BACK["外部 backend"]

    BUF -. "队列满:丢弃 + 计数" .-> DROP["Dropped telemetry"]
    EXP -. "失败:warning + 重试预算" .-> DEG["降级"]
    BACK -. "不可用" .-> DEG
    DEG -. "不得反向阻塞" .-> MAIN

这里有一个看似矛盾的要求:

  1. telemetry 不能影响主任务;
  2. telemetry 丢失又不能悄无声息。

解决方式不是“永不丢失”,而是有界、可见的失败

  • 队列必须有容量上限;
  • 丢弃数量本身是 metric;
  • 本地保留简短 warning;
  • shutdown 有时间预算;
  • 高价值 canonical rollout 与普通 telemetry 使用不同可靠性承诺。

Canonical facts 需要更强的持久化语义;可重建的 metrics 和 traces 可以 best-effort。把所有信号都提升到事务级可靠,会让观察系统反过来绑架主流程。


12.14 一次完整调查:为什么这个 turn 又慢又没用上工具

假设用户反馈:

我让 agent 查询线上告警并修复配置。它停了两分钟,最后只给了文字建议,明明已经安装了运维 plugin。

一个有效调查不应该从搜索某句错误日志开始,而应按因果链逐层缩小范围。

第一步:确认业务坐标和用户所见

从前端 Event 找到:

  • thread_idturn_id
  • turn 开始、结束和最终 outcome;
  • 用户看到了哪些 item;
  • 是否出现工具开始、审批或 warning;
  • 最终 assistant item 是正常完成还是中断后的残留。

此时确认:前端没有漏渲染工具事件,turn 确实只产生了文字回复。

第二步:用 metrics 判断是否为系统性问题

检查同一时间窗口:

  • 模型 TTFT 是否普遍升高;
  • MCP server 的 timeout 是否增加;
  • tool call 数是否下降;
  • plugin activation failure 是否增加;
  • sampling retry 是否异常。

结果发现整体 TTFT 正常,只有这个 turn 很慢。问题更像单次上下文、等待或决策链,而不是模型服务整体退化。

第三步:在 trace 中拆解时间

Turn profile 显示:

E2E                 121s
before sampling       1s
sampling             14s
tool blocking       105s
finalize              1s

这已经排除了“模型推理用了两分钟”。继续展开 tool span,发现 105 秒主要在等待 dynamic tool 的前端响应,最后以 timeout 结束。

用户没有看到工具,是因为这次 dynamic tool 只产生了一个等待中的内部调用,前端连接切换后没有成功展示并应答。

第四步:检查能力因果链

运维 plugin 的记录显示:

discovered: yes
enabled: yes
skill visible: yes
MCP server required: yes
MCP activation: failed
reason: authentication unavailable

模型看到了运维 skill 的方法说明,却没有看到实际查询告警的 MCP tool。它先尝试让前端 dynamic tool 获取当前环境,超时后退化为文字建议。

因此,“安装了 plugin”与“本 step 能调用 MCP tool”不是同一件事。第十一章的 discovery、activation、visibility、execution 四道门,在这里通过观测证据完整显现。

第五步:用 rollout-trace 验证模型实际输入

离线 replay 显示:

  • 本轮 conversation 中包含 skill 摘要;
  • tool snapshot 中没有告警查询工具;
  • dynamic tool spec 存在;
  • 模型生成了 dynamic tool call;
  • timeout result 已回灌;
  • 第二次 sampling 基于这个失败结果生成了文字建议。

现在可以给出有证据的结论:

慢:主要慢在等待前端 dynamic tool,不是模型。
没用 MCP:server 因认证失败未激活,所以本 step 不可见。
只给建议:dynamic tool timeout 已回灌,模型在缺少可执行能力时完成了降级回答。

进一步的根因修复也很明确:

  1. 修复 MCP 认证和 required server 的启动反馈;
  2. 让前端连接切换时显式失败所有悬空 dynamic tool 请求;
  3. 单独记录 frontend_tool_wait,不要只归入笼统的 tool blocking;
  4. required capability 不可用时应尽早、响亮地告知用户,而不是让模型带着残缺能力继续。

这就是可观测性的价值:不是给现象贴标签,而是把性能、能力、安全和协议证据拼成同一条因果链。


12.15 常见失败模式

失败模式 表面现象 根因 更好的做法
只记录 turn 总耗时 知道慢,却不知道慢在哪里 模型、工具、人工和调度混在一起 按 sampling、tool、compaction、human wait 等阶段拆分
把 handler time 当执行时间 错怪 shell 或 MCP 性能 handler 还包含 policy、审批和 sandbox 继续分解 queue、approval、sandbox、process
只靠 trace ID 关联 resume 和多 agent 链路断裂 trace 是在线执行身份,不是长期业务身份 同时记录 thread、turn、item、call 和 lineage
每个函数都建 span trace 巨大、无法阅读、成本失控 把调用栈当成业务语义 围绕生命周期阶段和外部交互建 span
只记录发生的工具调用 无法解释为什么没调用某工具 缺少 discovery、activation 和 visibility 证据 记录能力从来源到回灌的完整链
把所有错误归为 tool error 无法决定由谁修复、是否重试 混淆模型输出、policy、transport 和业务结果 按失败阶段和责任域分类
把 prompt 放进 metric tag 指标基数爆炸并泄露数据 混淆聚合维度与单次证据 低基数 tag;详细内容只进受控 log/trace
默认上传详细 trace 源码、路径和工具输出外泄 诊断深度没有对应隐私边界 trace-safe 默认;详细 evidence 本地 opt-in
用 rollout-trace 做恢复源 无 trace 时无法恢复,格式难兼容 混淆诊断证据与 canonical fact rollout 负责恢复,rollout-trace 负责解释
用 wall clock 排 replay 并发事件顺序不稳定 时钟不能提供可靠全序 writer 分配单调 seq,reducer 按 seq 处理
Reducer 猜测缺失关联 报告看似完整,结论却错误 把不确定性隐藏了 pending、unresolved 和原始引用显式保留
Telemetry 队列无界 后端故障拖垮 agent 内存 观察系统没有资源预算 有界队列、丢弃计数、固定 flush 预算
Telemetry 失败中断主任务 监控故障变成用户故障 辅助系统侵入控制流 best-effort 隔离,canonical 持久化另行保证
只看机器时间,不看人等多久 审批型 turn 被误判为性能退化 Human in the loop 没有独立阶段 显式记录 ask、display、answer 和 timeout
把产品 analytics 当 tracing 能看使用量,不能还原因 产品事实流缺少单次因果关系 analytics、telemetry、feedback 分别建模

这些失败背后有一个共同问题:系统记录了结果,却没有记录结果成立所依赖的边界和关系。


12.16 更深一层:可观测性是运行时模型的可验证投影

回看前十一章,会发现可观测性并不是最后才加上的横切功能。它是所有设计边界是否真实存在的一次验收。

  • 第一章定义了 thread、turn、task、step;可观测系统必须能按这些层级归因。
  • 第二章区分 Op、Event、item 与 delta;可观测系统必须知道哪些是权威事实,哪些只是实时表现。
  • 第三章区分请求构造、传输、流处理与重试;模型 latency 必须沿这些阶段拆开。
  • 第四章强调历史增量、窗口和片段注入;诊断 replay 必须能回答模型实际看到了什么。
  • 第五章要求 LOOP 在边界上推进;span 和 timing 也应围绕这些边界建立。
  • 第六章拆开 spec、policy、执行与回灌;工具 trace 不能只包住最终进程。
  • 第七章定义多 agent lineage 与通信;trace tree 之外必须保留协作树。
  • 第八章把安全做成结构化裁决;审计必须记录决策来源和结果。
  • 第九章把等待人类视为显式状态;latency 也应把人的时间独立出来。
  • 第十章区分 canonical fact 与 runtime state;rollout 和诊断 replay 因而不能混用。
  • 第十一章区分 discovery、activation、visibility 与 execution;“为何能力未生效”必须沿这四道门调查。

如果某个模块无法被观察,往往意味着它的边界还没有被真正建模。

例如:

  • 无法区分工具排队和执行,说明调度边界不清;
  • 无法解释谁拒绝了命令,说明 policy 决策没有统一结构;
  • 无法关联子 agent 结果,说明通信缺少稳定身份;
  • 无法重建模型输入,说明上下文构造没有留下证据;
  • 无法分开恢复和诊断,说明 canonical state 的定义还不够清楚。

因此,可观测性并不是“给代码加日志”,而是把运行时模型投影成一套可验证证据。

12.16.1 从故障定位走向行为解释

传统 observability 通常停在:

哪个组件失败?

Agent harness 还要继续追问:

当时有哪些能力?
模型看到了哪些事实?
哪个主体作了哪项决定?
决定经过哪些安全边界?
外部世界发生了什么?
结果如何影响下一次采样?

这不是要求系统解释模型的全部内部机制,而是要求 harness 对自己掌握的部分负责。模型为什么选择某个词,可能无法完全解释;但某个工具是否可见、某条 policy 是否命中、某个结果是否回灌,必须是可证明的工程事实。

12.16.2 可观测性也是一份成本预算

观察越细,成本越高:

  • 更多 span 和 event 增加 CPU 与序列化开销;
  • 更长保留期增加存储成本;
  • 更详细 payload 增加隐私风险;
  • 更高基数增加 metrics 成本;
  • 同步写入增加关键路径 latency;
  • 完整 replay 增加格式兼容负担。

所以每一类信号都应该回答四个问题:

  1. 它用于实时告警、单次定位、历史恢复,还是深度诊断?
  2. 它是否需要完整内容,还是元数据已经足够?
  3. 它需要保留多久,谁可以读取?
  4. 丢失它会影响用户任务,还是只降低诊断能力?

这四个问题决定了信号应该进入 metric、trace、log、rollout 还是本地 rollout-trace。

12.16.3 最好的诊断路径是从聚合到证据

一个高效的调查顺序通常是:

Metrics 发现异常范围
→ Event 确认用户所见
→ Trace 定位阶段与组件
→ Logs 查看局部错误
→ Replay 验证历史语义和模型输入

不必每次都走到最后。系统性延迟可能在 metrics 和 traces 阶段就能定位;只有涉及“为什么做出这个决定”“恢复前后发生了什么”时,才需要更昂贵的 replay 证据。

这是一种 progressive disclosure:不仅上下文和工具按需展开,诊断证据也应按需展开。


12.17 小结:可观测性的七条设计原则

  1. 观察完整因果链,不只观察最终动作。 从能力来源、step visibility、模型输出、policy、审批、执行到结果回灌,每个边界都应留下可关联的事实。没有发生的调用,也要能从上游状态解释原因。

  2. 业务坐标与 trace 坐标同时存在。 trace_id 描述一次在线执行,thread、turn、item、call 和 lineage 描述长期业务关系。异步队列、多 agent 与 resume 需要 link 和稳定 ID,不能硬塞进一棵 span tree。

  3. 总耗时必须按责任域拆开。 E2E latency 要区分 sampling、tool、compaction、调度、人工等待和收尾;工具还要继续区分 queue、policy、approval、sandbox 与真实执行。无法分类的时间应该显式暴露,而不是藏进“其他”。

  4. Logs、traces、metrics、Event 与 replay 各司其职。 Metrics 找趋势,traces 找路径,logs 看局部细节,Event 重建用户所见,replay 解释历史语义。它们通过稳定 ID 互相连接,但不强行合并成一种万能数据。

  5. 恢复 replay 与诊断 replay 分离。 Rollout 保存长期兼容的 canonical facts;UI replay 重建展示;rollout-trace 以本地、显式开启的方式保存更详细证据。Replay 历史永远不等于 re-execute 副作用。

  6. 隐私、基数和保留期都是架构边界。 默认 trace 记录长度、类型、耗时和结果类别;正文、路径、参数和输出进入更受控的通道。Metrics 只使用低基数 tag,详细诊断数据必须 opt-in、有上限、可清理。

  7. 可观测系统必须 best-effort,但失败要可见。 Exporter、analytics 和诊断 writer 不能阻塞或改变 agent outcome;队列、重试和 shutdown 都有预算。Canonical 持久化获得更强保证,辅助 telemetry 可以降级,但丢弃和缺口必须被记录。

留给读者思考的几个问题

  • 如果一个 turn 同时等待两个并行工具和一次人工审批,tool_blocking 应按 wall time、各任务耗时之和,还是关键路径计算?哪一种最能解释用户感知,哪一种最适合容量规划?
  • 模型没有调用某个工具时,应该保存整份 step tool snapshot,还是只保存 catalog revision 与内容摘要?二者在可解释性、存储和隐私上如何权衡?
  • Rollout-trace 能重建模型所见,但也可能包含最敏感的数据。怎样设计自动脱敏,才不会在删除秘密的同时破坏 tool call 与结果之间的语义关联?
  • 多 agent 的 communication 跨越多个 trace 和进程。应该用 span link、独立 message trace,还是只依赖业务 ID?不同 backend 对这些关系的查询能力是否会反过来限制协议设计?
  • Telemetry 必须 best-effort,canonical rollout 又必须可靠。如果二者对同一事件给出不同结果,调查工具应该以谁为准,怎样向用户表达“不完整证据”?
  • TTFM 比 TTFT 更接近用户感知,但“meaningful”会随前端而变化:文本、reasoning、plan、工具开始,哪一个才算首个有效反馈?这个定义应该由 harness 统一,还是由前端分别计算?
  • 当可观测系统已经能完整还原模型输入、工具行为和结果回灌时,我们能解释的是 harness 的因果链,而不是模型全部内部动机。产品界面应该怎样清楚表达这条能力边界,避免把证据推断包装成确定解释?

至此,我们已经从运行时层级、协议、模型、上下文、LOOP、工具、多 agent、安全、Human in the loop、持久化、扩展一直走到可观测性。十二个模块最终汇合成同一个判断标准:一个生产级 agent harness 不仅要能行动,还要能约束行动、恢复行动,并对自己的行动给出可验证的解释。

分类:Agent Harness标签:#agent #harness #codex