第 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_invocations、track_guardian_review、track_request…),其他 crate 调它来"上报一件事发生了"。reducer.rs——整个 crate 的心脏,约 2700 行。它把流进来的零散事件按 turn / thread 聚合,攒成一颗颗"事实"。facts.rs——定义聚合后的事实类型(TurnTokenUsageFact、TurnResolvedConfigFact、TurnCodexErrorFact…)。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.rs,ingest_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,
);
解读三点:
- span 名是
handle_responses,父 span 是receiving_stream——这是处理一次模型流式响应的 span,不是泛泛的"一轮"。 - 那些字段先以
field::Empty占位——开 span 时还不知道 token 数,先把"格子"留好,等数据流回来再填。 - 字段名分两类前缀:
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.rs,record_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.rs,emit_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.rs,OtelExporterKind):
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
// ...
}
解读,三条都很关键:
- trace 默认
None——你前面看的那些漂亮的handle_responsesspan,默认根本不往外导。它们存在于代码里、能被采集,但要真正送到追踪后端,得你显式配置trace_exporter。 - metrics 默认
Statsig——指标默认送往 OpenAI 自家的 Statsig,不是中立后端。 - OTLP 只是可选项之一——想接 Jaeger、Grafana Tempo、自建 collector?可以,把 exporter 配成
OtlpHttp/OtlpGrpc就行(otelcrate 依赖了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_responsesspan)让你知道每个 turn、每个任务烧了多少钱。 - 性能可见:首字延迟(TTFT)、各阶段耗时,帮你优化体验。
- 轨迹可审计:agent 这一长串操作到底干了啥、哪步出了岔,可以回放审计——
GuardianReview、TurnCodexError这些事实就是审计的原料。社区里 AgentOps、agenttrace 这类工具也专做会话追踪与成本/失败审计。
好的可观测性是一鱼两吃:同一份信号,agent 拿去自我修复,人拿去监控、控成本、做审计。这也呼应第 10 章——你要敢让 agent"放手干",前提之一就是它干的每件事你都看得见、追得到。这一点,业界共识也在逼近:Anthropic 在《Demystifying evals for AI agents》里给出的最佳实践,正是把自动化 eval(快迭代)+ 生产监控(地面真相)+ 周期性人工复核(校准) 三者结合——可观测性是这三条腿共同的地基。
本章小结¶
- 可观测性不只给人看,更是喂给 agent 的上下文:能查日志/指标/追踪,agent 才能把"把启动压到 800ms"从一句空话变成"查→定位→改→再查"的可执行闭环。
- Codex 的遥测是两条平行账本,别混:①
analyticscrate 走"事件流 → reduce → fact"自有流水线,把零散事件攒成TurnTokenUsageFact这类事实;② OTel 通道里,token 用量挂在handle_responsesspan 上(按 gen_ai 语义约定命名),metrics 走计数器(如codex.skill.injected)。 - 三个必须记牢的纠错点:token 挂在
handle_responsesspan 而非名为 "turn" 的 span;codex.skill.injected在core-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_usageanalytics/src/facts.rs—TurnTokenUsageFact、CustomAnalyticsFact分发枚举core/src/session/turn.rs—handle_responsesspan 占位 token 字段otel/src/events/session_telemetry.rs—record_responses把 token 用量填进 spancore-skills/src/injection.rs—emit_skill_injected_metric发codex.skill.injectedconfig/src/types.rs—OtelConfig::default默认 trace=None、metrics=Statsig、OTLP 可选
注:可观测栈的具体组件(Vector / Victoria 系、LogQL/PromQL/TraceQL)是 OpenAI 的工程选择,非 Codex 开源仓库的一部分;本章据其公开文章重述,Codex 侧聚焦
analytics与 OTel 双通道导出。
方法论 / 规范
- OpenAI — Harness engineering: leveraging Codex in an agent-first world
- OpenTelemetry — Semantic conventions for generative AI spans
- Anthropic — Demystifying evals for AI agents