跳转至

第 14 章 可观测性与遥测:让 agent"看见"系统

日志、指标、追踪——这些词你大概觉得是"运维给人看的"。这一章要讲的,是一个反转:当你把这些信号回喂给 agent,让 agent 自己"看见"运行时发生了什么,它就能自己定位问题、自己修。 这是第 4 章那条魂——"对 agent 可见的才存在"——的最后一块拼图。

引子:agent 改完了,却不知道有没有变快

先看一个真实会卡住的场景。

你对 agent 说:"把这个服务的启动时间压到 800 毫秒以下。"

agent 翻代码、找到初始化逻辑、改了几行——把一个同步加载改成了懒加载,把一个串行的预热改成了并行。改得有模有样。然后呢?

然后它卡住了。它不知道改之前启动要多久,也不知道改之后变成了多少。它没有任何运行时的"地面真相"(ground truth)可以对照。它能做的只有两件事:要么编一句"应该会快一些"糊弄过去,要么回头问你:"你跑跑看,好点了吗?"

这就是可观测性缺位时 agent 的真实困境:它能改代码,但它看不见代码跑起来之后的样子。 代码是静态的、躺在磁盘上的,agent 读得到;可"这次实际启动花了 920ms""慢在数据库连接池那个 span 上"是动态的、藏在运行时里的,默认对 agent 完全不可见。一个看不见运行后果的 agent,本质上是在闭着眼睛改东西。

现在换一种情况。假设 agent 能查指标——能跑一句 PromQL 看启动耗时分布、能拉一条 trace 看哪个 span 占了大头。那句"把启动压到 800ms"就突然变得可执行了:

查(现在 920ms)→ 定位(init 阶段一个 span 占了 400ms)→ 改 → 重启 → 再查(现在 780ms,达标)→ 收工。

OpenAI 在《Harness engineering》里给的例子正是这种:"ensure service startup completes in under 800ms"(确保服务启动在 800ms 内)、"no span in these four critical user journeys exceeds two seconds"(这四条关键用户路径里没有 span 超过两秒)。能下这种命令的前提,只有一个——运行时对 agent 可见

这就是本章的主题:可观测性不只是给人看的仪表盘,更是喂给 agent 的一种上下文。一旦把它接进 agent 的视野,"看不见"就变成了"看得见",黑盒就变成了可查询的系统。

〔配图 1:看不见 vs 看得见运行时〕——左"看不见":一个 agent 对着一个黑盒服务一通瞎改,问号满头,旁注"不知道改前多久、改后多久,只能问人'好点了吗'"。右"看得见":agent 面前三块仪表盘(日志/指标/追踪),它用 PromQL 定位到一个红色"超时 span",精准下手,旁注"查→定位→改→重启→再查"。底部:"'把启动压到 800ms'能执行,靠运行时对 agent 可见。"

一、失败的另一面:Codex 自己也得"被看见"

上面那个故事讲的是 agent 看不见它开发的那个应用。但还有一层更内向的失败:Codex 这个 harness 自己跑得怎么样,谁来看?

一轮对话烧了多少 token?某个 MCP 工具调用失败了几次?Guardian(安全审批)拦了多少次危险命令?某个技能到底有没有被注入进上下文?这些都是 harness 自身的运行时行为。如果不记账,那么当用户抱怨"怎么这么费钱""怎么这么慢"时,连开发 Codex 的人都只能猜。

所以 Codex 给自己装了一套遥测系统。它分成两条并行的"账本",这两条最容易被混为一谈,得先把架构分清楚

通道 在哪 记什么 走向
① analytics 自有事件 analytics crate 把零散事件 reduce 成"事实"(一轮的 token 总账、工具调用、技能调用、安全审批……) Codex 自家的事件流
② OTel tracing span core + otel crate 给一次模型调用挂上 span,按 OTel gen_ai 语义约定 记 token 用量 可导出到任意 OTel 后端
③ OTel metrics(指标) otel crate metrics 计数器/直方图,如技能注入计数 codex.skill.injected 经导出后端落地

注意:①是 Codex 自有的一套事件聚合机制,并不走 OpenTelemetry;②③才是 OTel 体系。两者是平行的两套通道——analytics 是独立账本,OTel 是另一套通道。下面分头拆。

〔配图 2:Codex 的两条遥测账本〕——中间一个"Codex core"方块吐出两股线。上面一股标"OTel":先到一个"handle_responses span(gen_ai 语义约定)"和一个"metrics 计数器(codex.skill.injected)",再汇到右侧"导出后端"。下面一股标"analytics(自有)":进一个"事件流"漏斗,被"reducer"挤压成几颗"fact 晶体"(TurnTokenUsageFact 等),落进"Codex 自家事件流"。底部:"一套是 OTel 通道、一套是 analytics 自有账本,别混为一谈。"

二、analytics crate:把"事件流"reduce 成"事实"

analytics crate 由五个模块组成,各司其职:

  • events.rs——定义所有原始事件的类型(一个 MCP 工具调用长什么样、一次 Guardian 审批携带哪些字段)。
  • client.rs——对外的打点入口(track_skill_invocationstrack_guardian_reviewtrack_request…),其他 crate 调它来"上报一件事发生了"。
  • reducer.rs——整个 crate 的心脏,约 2700 行。它把流进来的零散事件按 turn / thread 聚合,攒成一颗颗"事实"。
  • facts.rs——定义聚合后的事实类型(TurnTokenUsageFactTurnResolvedConfigFactTurnCodexErrorFact…)。
  • accepted_lines.rs——专门给"用户接受了 agent 写的多少行 diff"算指纹、做统计。

这里的关键设计,是 "事件流 → reduce → 事实" 这条流水线。

2.1 为什么需要 reduce?

一轮对话(turn)里,token 用量不是一次性给你的。模型流式返回时,可能分多次报告增量用量;turn 的配置(用了哪个模型、什么推理强度、什么沙箱策略)又是在另一个事件里给的;turn 出没出错,是第三个事件。这些事件零散地、乱序地流进来,各自只是拼图的一角。

直接把每一片碎事件都上报,既冗余又难分析。真正有价值的是聚合后的一句话总结:"turn X 这一轮,一共烧了多少 input/output/缓存/推理 token。"这就是 TurnTokenUsageFact——一个"事实"。

reducer 的活,就是把碎事件成事实,攒齐了再发一颗出去。

2.2 一个具体例子:TurnTokenUsageFact 怎么被攒出来

先看这个"事实"本身有多干净——它就是把一轮的身份和总账打包在一起(facts.rs):

pub struct TurnTokenUsageFact {
    pub turn_id: String,
    pub thread_id: String,
    pub token_usage: TokenUsage,   // input / output / cache / reasoning 的总账
}

解读:一个 fact 不关心"事件来了几次、什么顺序",它只表达一个已经成立的结论——"turn_id 这一轮,token 账是这么多"。三个字段,自洽完整。

再看 reducer 怎么把它攒起来(reducer.rsingest_turn_token_usage,裁剪后):

async fn ingest_turn_token_usage(&mut self, input: TurnTokenUsageFact, out: &mut Vec<...>) {
    let turn_id = input.turn_id.clone();
    let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { .. });
    turn_state.thread_id = Some(input.thread_id);
    turn_state.token_usage = Some(input.token_usage);   // 把这一片信息塞进该 turn 的状态
    self.maybe_emit_turn_event(&turn_id, out).await;     // 攒齐了吗?齐了就发一颗事件
}

解读:reducer 内部维护一张 turns 表(turn_id → TurnState)。每来一片信息(token 用量、配置、错误……)就更新对应 turn 的那一格状态,然后调 maybe_emit_turn_event 问一句"这一轮的拼图凑齐了吗"。凑齐了才发一颗完整的事件出去——这就是 reduce 的精髓:用一张可变的状态表,把无序的事件流收敛成有序的、完整的事实。其余的事实(配置、错误、技能、压缩、Guardian 审批……)都走同一条流水线,由 reducer 里成对的 ingest_* 函数处理,最后汇到 CustomAnalyticsFact 这个枚举里统一分发。

〔配图 3:事件流 → reduce → fact〕——左边一串大小不一的小方块(带标签"token 增量""配置""出错")乱序飘进来;中间一个"reducer 状态表(turns: id→TurnState)"漏斗,把它们按 turn_id 归拢、填格;右边吐出一颗规整的晶体"TurnTokenUsageFact"。漏斗口有个小判断"凑齐了?→ 发一颗"。底部:"reducer = 把零散事件收敛成一句完整的事实。"

2.3 analytics 都记些什么:事件分类一览

analytics 记的远不止 token。把散落在 events.rs / facts.rs 的事件类型归一下,能看到 Codex 对 harness 自身行为的关注面有多广:

事件 / 事实 记录什么 对应章节
TurnTokenUsageFact 一轮的 token 总账(input/output/cache/reasoning) 第 3 章
TurnResolvedConfigFact 一轮实际生效的配置(模型、推理强度、沙箱策略…) 第 3、7 章
TurnCodexErrorFact 一轮出了什么错(含 30 多种错误类型分类)
McpToolCall 一次 MCP 工具调用 第 9 章
DynamicToolCall 一次动态注册工具的调用 第 6 章
CollabAgentToolCall 协作 agent(子 agent)作为工具被调用 第 13 章
SkillInvocation 一次技能被调用(注意:不是 codex.skill.injected,见下节) 第 8 章
GuardianReview 一次安全审批(含决策、风险等级、最终结果) 第 10 章
NetworkAccess / SandboxPermissions 网络访问、沙箱权限相关 第 10 章
HookRun / Compaction / PluginUsed 钩子运行、上下文压缩、插件使用 第 5、6 章

你在前面几乎每一章都撞见过它的影子——它一路在给 harness 的每个动作记账。

三、OTel 第一条通道:token 用量挂在 handle_responses span 上

现在切到 OpenTelemetry 这一侧。第一条是 tracing span

一个容易搞错的点:token 用量并不是挂在名为 turn 的 span 上,而是挂在 core/src/session/turn.rs 里一个名为 handle_responses 的 span 上,这个 span 的父 span 叫 receiving_stream

看 span 是怎么开的(turn.rs,裁剪后):

let receiving_span = trace_span!("receiving_stream");
let handle_responses = trace_span!(
    parent: &receiving_span,
    "handle_responses",
    gen_ai.usage.input_tokens = field::Empty,
    gen_ai.usage.cache_read.input_tokens = field::Empty,
    gen_ai.usage.output_tokens = field::Empty,
    codex.usage.reasoning_output_tokens = field::Empty,
    codex.usage.total_tokens = field::Empty,
);

解读三点:

  1. span 名是 handle_responses,父 span 是 receiving_stream——这是处理一次模型流式响应的 span,不是泛泛的"一轮"。
  2. 那些字段先以 field::Empty 占位——开 span 时还不知道 token 数,先把"格子"留好,等数据流回来再填。
  3. 字段名分两类前缀:gen_ai.usage.*OpenTelemetry GenAI 语义约定里的标准名;codex.usage.* 是 Codex 自定义的扩展(如 reasoning token、total token)。

3.1 为什么用 gen_ai.usage.* 这套名字?

因为这是行业标准。OpenTelemetry 的 GenAI 语义约定(Semantic Conventions)专门为大模型可观测性定义了一套属性名。其 spans 规范里明确定义:

  • gen_ai.usage.input_tokens——"输入(prompt)用掉的 token 数",且约定应包含缓存命中的 token
  • gen_ai.usage.output_tokens——"响应(completion)用掉的 token 数"。
  • gen_ai.usage.cache_read.input_tokens——从 provider 缓存命中读出的 token。
  • gen_ai.usage.reasoning.output_tokens——推理过程用掉的 token。

Codex 逐字采用了前三个标准名(input / output / cache_read);reasoning 这一项虽然标准里也有对应名(gen_ai.usage.reasoning.output_tokens),但 Codex 用的是自家前缀 codex.usage.reasoning_output_tokens(注意是下划线连写、不是标准的点分),total 这种标准没单列的则干脆用 codex.usage.total_tokens 补充。这意味着:只要你的观测后端认 OTel GenAI 约定,input/output/cache 这几项 token 账就能直接读懂、不用做字段映射;reasoning 和 total 则要认 codex.usage.* 这层自家扩展。 这正是"用标准而非私有协议"的杠杆——和第 9 章 MCP 的思路一脉相承:模型是商品,标准是让你不被锁死的杠杆。

3.2 占位的格子由谁来填?

span 开好后,模型响应一片片流回来。每来一片,session telemetry 就把对应的格子填上(otel/src/events/session_telemetry.rsrecord_responses,裁剪后):

pub fn record_responses(&self, handle_responses_span: &Span, event: &ResponseEvent) {
    // ...
    handle_responses_span.record("gen_ai.usage.input_tokens", token_usage.input_tokens);
    handle_responses_span.record("gen_ai.usage.cache_read.input_tokens", /* ... */);
    handle_responses_span.record("gen_ai.usage.output_tokens", token_usage.output_tokens);
    handle_responses_span.record("codex.usage.reasoning_output_tokens",
                                 token_usage.reasoning_output_tokens);
    handle_responses_span.record("codex.usage.total_tokens", token_usage.total_tokens);
}

解读:.record(字段名, 值) 就是把开 span 时留的空格子填实。等这次响应处理完、span 结束,一条带完整 token 用量的 trace 就成型了,可以导出给后端。

注意:这条 OTel span 通道和第二节的 analytics 通道是平行的、各记各的——同一笔 token 账,OTel 这边挂在 span 上给追踪后端,analytics 那边攒成 TurnTokenUsageFact 进自家事件流。一鱼两吃,从内部架构就开始了。

四、OTel 第二条通道:metrics,以及一个被放错地方的指标

OTel 的第二条通道是 metrics(指标)——计数器、直方图这类可聚合的数值。

还有一个容易混淆的点:codex.skill.injected 并不在 analytics crate,而是在 core-skills crate 里,走 OTel metrics 路径——和 analytics 是两条通道。

看它怎么发(core-skills/src/injection.rsemit_skill_injected_metric):

fn emit_skill_injected_metric(otel: Option<&SessionTelemetry>, skill: &SkillMetadata, status: &str) {
    let Some(otel) = otel else { return; };
    otel.counter(
        "codex.skill.injected",
        /*inc*/ 1,
        &[("status", status), ("skill", skill.name.as_str())],
    );
}

解读:这是一个计数器——每次尝试把技能注入上下文就 +1,并打上 status(ok / error)和 skill(技能名)两个标签。也就是说,失败的注入尝试也会计数;你要区分成功与失败,得看 status 标签。otel.counter(...) 最终落到 otel crate 的 u64_counter,走 OTel metrics 后端。

这里有个容易混的命名对照,必须记牢

名字 在哪 通道 含义
codex.skill.injected core-skills crate OTel metrics 计数器 技能被注入上下文的次数
SkillInvocation analytics crate analytics 自有事件 一次技能调用的结构化事件

同一件事(技能被用了),在两条账本里有两个不同的名字、两条不同的路径。把它们混为一谈,是排查问题时埋的雷。

五、导出去哪:默认不是 OTLP,而是 Statsig

最后,关于"不锁后端"这个说法要打个折扣。

"经 OTel 标准导出、不锁死在某一家"这个说法只对了一半。OTel 标准本身确实开放,但 Codex 的默认导出目标并不是中立的 OTLP,而是 OpenAI 自家的 Statsig。

看导出种类的定义(config/src/types.rsOtelExporterKind):

pub enum OtelExporterKind {
    None,
    Statsig,                          // OpenAI 自家
    OtlpHttp { endpoint, headers, .. },   // 标准 OTLP(HTTP)
    OtlpGrpc { endpoint, headers, .. },   // 标准 OTLP(gRPC)
}

再看默认值(同文件,impl Default for OtelConfig):

OtelConfig {
    exporter:         OtelExporterKind::None,      // 日志:默认不导
    trace_exporter:   OtelExporterKind::None,      // 追踪:默认不导
    metrics_exporter: OtelExporterKind::Statsig,   // 指标:默认走 Statsig
    // ...
}

解读,三条都很关键:

  1. trace 默认 None——你前面看的那些漂亮的 handle_responses span,默认根本不往外导。它们存在于代码里、能被采集,但要真正送到追踪后端,得你显式配置 trace_exporter
  2. metrics 默认 Statsig——指标默认送往 OpenAI 自家的 Statsig,不是中立后端。
  3. OTLP 只是可选项之一——想接 Jaeger、Grafana Tempo、自建 collector?可以,把 exporter 配成 OtlpHttp / OtlpGrpc 就行(otel crate 依赖了 opentelemetry / opentelemetry-otlp 来支持)。但这是你主动选的路,不是默认路。

所以准确的说法是:Codex 在协议层用了开放标准(OTel),所以理论上不锁后端;但开箱默认值偏向自家(metrics→Statsig,trace 干脆不导)。 "不锁后端"是能力,不是默认行为——想要中立、想接自己的栈,得动手配。这个区别,对一个想把 Codex 接进自己可观测体系的团队来说,是必须先知道的。

六、OpenAI 的杀手锏:给 agent 一整套本地可观测栈

前面五节讲的是 Codex"记自己"。OpenAI 在用 Codex 造产品时,更进一步:给 agent 配了一整套本地可观测栈,让 agent 能像 SRE 一样查询自己开发的那个应用——也就是回到引子里那个"把启动压到 800ms"的能力。

据 OpenAI《Harness engineering》一文:被开发的 app 把日志/指标/追踪发给一个收集器 Vector,Vector 再分发到一组存储(Victoria 系的 Logs / Metrics / Traces);agent 则通过 LogQL / PromQL / TraceQL 去查询这些信号,定位问题、动手修、重启、重跑、再查验证——形成闭环。

最妙的一个设计是:这套栈是"每个 git worktree 一套、用完即焚"的。agent 在一个完全隔离的应用副本(连同它的日志和指标)上干活,任务一完,整套环境就被拆掉。这样既不互相污染,又能让每个并行的 agent 都有自己完整的"可观测视野"——呼应第 12 章的并行隔离。

〔配图 4:agent 的本地可观测闭环(用完即焚)〕——一个"app(某 worktree)"吐出 logs/metrics/traces 三股流 → 汇入"Vector 收集器" → 分发到三个存储桶(Victoria Logs / Metrics / Traces);一个 agent 用三种查询语言(LogQL / PromQL / TraceQL)读取,箭头连成闭环:查询 → 定位 → 改代码 → 重启 → 重跑 → 再查。整张图用一个虚线框圈住,角上标"每个 git worktree 一套,用完即焚"。底部:"让 agent 像 SRE 一样查自己的应用。"

诚实声明:这套可观测栈的具体组件(Vector / Victoria 系、三种查询语言)是 OpenAI 的工程选择,并非 Codex 开源仓库的一部分。本节据其公开文章重述;Codex 仓库侧聚焦的是前五节讲的 analytics 与 OTel 双通道。两者是"harness 自己被观测"与"agent 观测它造的 app"两个不同层面,别混。

七、可观测性 = legibility 闭环的最后一块

把这一章接回第 4 章那条贯穿全书的魂——"对 agent 可见的,才存在"

我们一路看它在各层现身:指令要写进 AGENTS.md(第 4 章)、能力要进名录(第 8 章)、外部工具要按协议接入(第 9 章)……但有一类东西最难"可见"——运行时行为。代码静态躺在那儿好读,可"这次运行实际发生了什么、慢在哪、错在哪"是动态的,默认对 agent 不可见。

可观测性,就是把这动态的一面也变得可见:把"运行时实际发生了什么"做成 agent 能查询的信号。 于是 agent 的视野从"代码"扩展到了"代码 + 它跑起来的真实行为"。这一块补上,"对 agent 可见"才算闭环——agent 不仅能读到该读的,还能看见自己行为的后果,从而自我修正。这也正是引子里那个困境的解药:agent 不再"改完不知道有没有变快",因为它能查、能对照、能验证。

这同时印证了本书另一条主线——harness 随模型演化。早期 agent 弱,可观测性主要给人看;模型强到能消化运行时信号、能自己下"启动<800ms"这种判断后,harness 就顺势把可观测性改造成喂给 agent 的接口。是模型的能力变化,倒逼 harness 把"仪表盘"变成了"agent 的眼睛"。

〔配图 5:agent 视野的扩展〕——沿用第 4 章的"agent 视野气泡":气泡内原本只有"代码 / AGENTS.md / docs",现在气泡外飘着的"运行时行为(这次实际跑成了啥)"被一根标着"可观测性"的管子抽进气泡,气泡里多出"日志/指标/追踪"。底部:"最难可见的是运行时行为——这是 legibility 闭环的最后一块。"

八、它对人也没白费

强调"喂给 agent"不等于"人不用了"。同一套遥测,对人同样关键:

  • 成本可见:那本 token 账(TurnTokenUsageFact / handle_responses span)让你知道每个 turn、每个任务烧了多少钱。
  • 性能可见:首字延迟(TTFT)、各阶段耗时,帮你优化体验。
  • 轨迹可审计:agent 这一长串操作到底干了啥、哪步出了岔,可以回放审计——GuardianReviewTurnCodexError 这些事实就是审计的原料。社区里 AgentOps、agenttrace 这类工具也专做会话追踪与成本/失败审计。

好的可观测性是一鱼两吃:同一份信号,agent 拿去自我修复,人拿去监控、控成本、做审计。这也呼应第 10 章——你要敢让 agent"放手干",前提之一就是它干的每件事你都看得见、追得到。这一点,业界共识也在逼近:Anthropic 在《Demystifying evals for AI agents》里给出的最佳实践,正是把自动化 eval(快迭代)+ 生产监控(地面真相)+ 周期性人工复核(校准) 三者结合——可观测性是这三条腿共同的地基。

本章小结

  • 可观测性不只给人看,更是喂给 agent 的上下文:能查日志/指标/追踪,agent 才能把"把启动压到 800ms"从一句空话变成"查→定位→改→再查"的可执行闭环。
  • Codex 的遥测是两条平行账本,别混:① analytics crate 走"事件流 → reduce → fact"自有流水线,把零散事件攒成 TurnTokenUsageFact 这类事实;② OTel 通道里,token 用量挂在 handle_responses span 上(按 gen_ai 语义约定命名),metrics 走计数器(如 codex.skill.injected)。
  • 三个必须记牢的纠错点:token 挂在 handle_responses span 而非名为 "turn" 的 span;codex.skill.injectedcore-skills(OTel metrics)而非 analytics;默认 metrics 走 Statsig、trace 干脆不导,OTLP 只是可选——"不锁后端"是能力不是默认。
  • OpenAI 的本地可观测栈:app→Vector→Victoria(Logs/Metrics/Traces),agent 用 LogQL/PromQL/TraceQL 自助查询、定位、修复、重跑;每个 worktree 一套、用完即焚(此栈非 Codex 仓库内容)。
  • 它是 legibility 闭环的最后一块:把最难可见的"运行时行为"变成 agent 能查询的信号——"对 agent 可见的才存在"至此闭环;也是"harness 随模型演化"的又一例。

下一章,我们走到 harness 的最外层——运行时与协议:这一整套 core,到底怎么被你的 IDE、命令行、甚至另一个 agent 调用起来。


参考来源

解剖标本(codex-rs 源码,HEAD=ad2012d6

  • analytics/src/events.rs — 原始事件类型
  • analytics/src/client.rs — 打点入口
  • analytics/src/reducer.rs — 事件→事实 reduce 核心、ingest_turn_token_usage
  • analytics/src/facts.rsTurnTokenUsageFactCustomAnalyticsFact 分发枚举
  • core/src/session/turn.rshandle_responses span 占位 token 字段
  • otel/src/events/session_telemetry.rsrecord_responses 把 token 用量填进 span
  • core-skills/src/injection.rsemit_skill_injected_metriccodex.skill.injected
  • config/src/types.rsOtelConfig::default 默认 trace=None、metrics=Statsig、OTLP 可选

注:可观测栈的具体组件(Vector / Victoria 系、LogQL/PromQL/TraceQL)是 OpenAI 的工程选择,非 Codex 开源仓库的一部分;本章据其公开文章重述,Codex 侧聚焦 analytics 与 OTel 双通道导出。

方法论 / 规范

留言