15 · 一次 Agent 运行如何对账
用事件与 token 轨迹重放一次运行,并解释成本数字的来源和不确定性
本章任务
要回答的问题
出现失败、延迟或高账单时,怎样从事件中重建这次 Agent 运行到底发生了什么?
读完你能
- 设计 step、turn、session 三层事件模型
- 解释 token、缓存、工具和并发如何进入成本
- 用 transition reason、verifier 和 rollout 定位行为偏差
- 适合现在读
- 正在建设 Trace、成本归因、告警、回放或线上诊断的工程师
- 先修知识
- 理解 Loop、Tool Call 与 Session 生命周期
- 实践产物
- 一个可回放事件 Schema、核心查询清单与成本对账表
- 证据边界
- 日志能解释已记录的行为;采样、价格变更和缺失事件仍会造成归因不确定性
先回答这次运行发生了什么
Section titled “先回答这次运行发生了什么”场景:一次任务花了 47 美元、运行 38 分钟,最终没有答案。日志只记录“模型调用失败”,看不出是压缩重试、工具超时、Subagent 重复检索还是价格表计算错误。没有事件因果链,成本数字不能解释,也无法复现。
通过标准:每个 step 带 run/turn/span、开始结束时间、输入输出 token、cache、模型、工具、重试原因和父子关系;价格版本可追溯;缺失事件和不平衡 span 能被检测;从事件可以重建最终退出原因。
先定义一次运行的证据
Section titled “先定义一次运行的证据”四家在观测、成本、日志 5 件事上的覆盖:
| 维度 | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| token 计数 | `TokenUsage`(input、output、cache_creation、cache_read、reasoning_output),codex-protocol crate | BetaUsage(@anthropic-ai/sdk):input_tokens、output_tokens、cache_read_input_tokens、cache_creation_input_tokens、server_tool_use.web_search_requests | `DiagnosticUsageEvent.usage`:input、output、cacheRead、cacheWrite、promptTokens、total 加 lastCallUsage | `CanonicalUsage`:input_tokens 加 output_tokens 加 cache_read_tokens 加 cache_write_tokens 加 reasoning_tokens 加 request_count |
| 价格表 | 通过 model-provider-info crate 维护。analytics 上报 usd_cost 但不硬编码 | 硬编码 6 个命名 cost tier 加 MODEL_COSTS map | 通过 model 元信息加 `costUsd` 字段。diagnostic-events 只发事件不存价 | `_OFFICIAL_DOCS_PRICING` Dict 维护 anthropic、openai、google、cohere 等供应商 30+ 模型。带 `pricing_version` 跟 source_url |
| 成本来源 | 模型 provider info 加上游 API 返回(usd_cost) | 从 modelCost.ts 算(input、output、cache 各 4 项加权) | gateway 算好后随事件发出 | `CostSource` 有 6 种有值来源,另有 none:provider_cost_api、provider_generation_api、provider_models_api、official_docs_snapshot、user_override、custom_contract |
| 远程上报 | OTLP HTTP、gRPC 通用加 Statsig 内置 default exporter(debug build 默认关) | logEvent("tengu_*") 走 Anthropic analytics endpoint | 通过 listener 接入外部 OTEL、Datadog 等 | 没远程上报,全部本地 SQLite 加本地报表(`InsightsEngine`) |
| 历史回溯 | rollout-trace crate:trace bundle 加 reducer 加 replay。codex debug trace-reduce 工具 | `getSessionFilesWithMtime` 加 `loadAllLogsFromSessionFile` 从 ~/.claude/projects/ 读 session 文件 | diagnostic-events 只在内存加 listener 决定持久化 | `InsightsEngine` 直接查 SessionDB SQLite,跑 group by 加 cost 汇总。`/insights` 命令出终端报表 |
源码证据:事件、价格与回放
Section titled “源码证据:事件、价格与回放”Codex · 把观测拆成三件互不打架的事
Section titled “Codex · 把观测拆成三件互不打架的事”一次运行至少留下三类证据:发生了什么、用了多少 token、如何计算成本。Codex 将 OTEL exporter、业务 analytics 和可回放 rollout trace 分开,便于分别接入和复盘。
第一件事是接通一个已有的监控协议。很多生产团队会使用 Datadog、Honeycomb、Splunk、阿里云 ARMS 或自建 Prometheus + Grafana;Codex 的快照选择 OTEL 作为适配协议,具体后端兼容性仍需由部署方验证。
Codex 不去自己造监控系统,而是写一个适配层,把 agent 内部产生的指标、trace、日志按 OTEL 协议(支持 HTTP 和 gRPC 两种传输)发出去,让用户接入自家的监控就好。除此之外它还内置了一个针对 Statsig 这套系统的默认 exporter:之所以做这一项,是因为它内部团队在用 Statsig 收集自家产品的使用数据。
Codex codex/codex-rs/otel/src/config.rs:50-108 观测后端被抽象成可插拔的 exporter:可以选择完全关闭、用内部默认、走 HTTP 上报或走 gRPC 上报;metrics、traces、log 三种数据可以各自配置不同的目的地。
#[derive(Clone, Debug)]pub struct OtelSettings { pub environment: String, pub service_name: String, pub service_version: String, pub codex_home: PathBuf, pub exporter: OtelExporter, pub trace_exporter: OtelExporter, pub metrics_exporter: OtelExporter, pub runtime_metrics: bool, pub span_attributes: BTreeMap<String, String>, pub tracestate: BTreeMap<String, BTreeMap<String, String>>,}
#[derive(Clone, Debug)]pub enum OtelExporter { None, /// Statsig metrics ingestion exporter using Codex-internal defaults. Statsig, OtlpGrpc { endpoint: String, headers: HashMap<String, String>, tls: Option<OtelTlsConfig>, }, OtlpHttp { endpoint: String, // ... },}这套抽象里有几个细节值得专门说一下。第一,三类数据可以走不同的目的地。metrics(数值指标,比如每秒请求数)、traces(一次请求的较全调用链)、log(结构化日志)在监控领域是三件本质不同的事,承载它们的后端也常常不一样:Datadog 可能擅长 trace、Grafana 可能更擅长 metrics、ELK 可能负责日志。Codex 允许这三类各自选不同的目的地,不是逼着所有数据都进一个后端。第二,调试构建默认不上报到生产监控。
代码里有一行很关键的判断:如果当前是 debug 模式(也就是开发者本地编译跑测试时),默认把 exporter 关到 None。这是一种清醒的工程纪律:开发跑测试很容易制造各种异常的指标抖动,如果这些数据进了生产监控的看板,会让运维误以为线上出了问题。
第二件事是把业务级事件汇总上报。前面那一层 OTEL 走的是技术指标,但 agent 还有一类很特别的事件:用户用了什么 skill、跑了什么工具、修了哪几个文件、跟模型来回了多少轮、调了多少次 MCP 服务、用户最后接受了多少行模型生成的代码。这些都不是 CPU/内存这种通用指标,而是 agent 产品自己的业务指标。
Codex codex/codex-rs/analytics/src/events.rs:56-100 业务事件被表达成一个加了标签的联合类型:每种事件都有自己专属的字段定义,不是塞到一个通用结构里靠字符串 key 来区分。
#[derive(Serialize)]#[serde(untagged)]pub(crate) enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), ThreadInitialized(ThreadInitializedEvent), GuardianReview(Box<GuardianReviewEventRequest>), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), HookRun(CodexHookRunEventRequest), Compaction(Box<CodexCompactionEventRequest>), TurnEvent(Box<CodexTurnEventRequest>), TurnSteer(CodexTurnSteerEventRequest), CommandExecution(CodexCommandExecutionEventRequest), FileChange(CodexFileChangeEventRequest), McpToolCall(CodexMcpToolCallEventRequest), DynamicToolCall(CodexDynamicToolCallEventRequest), CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), WebSearch(CodexWebSearchEventRequest), ImageGeneration(CodexImageGenerationEventRequest), AcceptedLineFingerprints(Box<CodexAcceptedLineFingerprintsEventRequest>), ReviewEvent(CodexReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), // ...}这种”每种事件都是独立类型”的做法跟你常见的”一个通用 event 结构 + 一堆 string-keyed 属性”不同。它可以让编译器检查字段、让接收端减少拼写漂移;新增类型仍需要 schema 兼容和下游处理。快照里的二十多种事件是当前覆盖面,不代表涵盖所有值得关注的业务面。
一类是用户驱动的动作:skill 调用、guardian 审查、hook 执行、上下文压缩、单 turn 内容、命令执行、文件变更。另一类覆盖工具面,包括 MCP 和动态工具、web 搜索、图片生成、插件生命周期。甚至还有一种叫”已接受代码行指纹”的事件,它记录的是用户最终采纳了模型生成的哪几行代码(用行级 hash 而不是内容,避免泄露源码),目的是度量”模型生成的代码被采纳的比例”这种关键产品指标。
第三件事是把整个会话过程打包成可以离线检查的轨迹。当一个 agent 跑出了奇怪结果,复现会受到 LLM 非确定性、外部 API 和文件系统状态影响;trace bundle 能保留决策证据,但不等于可以精确重跑。
Codex 的做法是把每次会话里产生的所有原始事件按顺序写到一个 JSONL 文件里,再加一个清单说明这次会话用的什么模型、什么环境、什么版本,最后打包成一个 trace bundle 可以离线传给任何一个调试工具。配套有一个叫做「reducer」的组件,专门用来把这堆原始事件「折算」成简洁的最终状态:就像 Redux 的 reducer 一样,输入是事件流,输出是某个状态快照。
设计哲学有一句话写得很清楚:会话的热路径只负责写原始事件,重的「reducer 和查看器」不能污染主代码库。
Claude Code · 把价格硬编码到代码里,让用户能在终端就看到自己花了多少钱
Section titled “Claude Code · 把价格硬编码到代码里,让用户能在终端就看到自己花了多少钱”IDE 用户常只想知道这一轮花了多少钱。Claude Code 将模型价格放在源码表中,把 session 写成 JSONL,再由 /insights 读取;价格变动时要同步版本。
第一件事是把模型价格直接硬编码到代码里。它不去调用任何远程价格 API、不去维护一个外部价格清单,而是把目前所有支持的模型的价格写成一组常量塞在源码里。
Claude Code claude-code/src/utils/modelCost.ts:26-90 6 个命名 cost tier(COST_TIER_3_15 / COST_TIER_15_75 / COST_TIER_5_25 / COST_TIER_30_150 / COST_HAIKU_35 / COST_HAIKU_45);每种 tier 记录 input / output / cache_write / cache_read / web_search
export type ModelCosts = { inputTokens: number outputTokens: number promptCacheWriteTokens: number promptCacheReadTokens: number webSearchRequests: number}
// Standard pricing tier for Sonnet models: $3 input / $15 output per Mtokexport const COST_TIER_3_15 = { inputTokens: 3, outputTokens: 15, promptCacheWriteTokens: 3.75, promptCacheReadTokens: 0.3, webSearchRequests: 0.01,} as const satisfies ModelCosts
// Pricing tier for Opus 4/4.1: $15 input / $75 output per Mtokexport const COST_TIER_15_75 = { inputTokens: 15, outputTokens: 75, promptCacheWriteTokens: 18.75, promptCacheReadTokens: 1.5, webSearchRequests: 0.01,} as const satisfies ModelCosts
// Fast mode pricing for Opus 4.6: $30 input / $150 output per Mtokexport const COST_TIER_30_150 = { inputTokens: 30, outputTokens: 150, promptCacheWriteTokens: 37.5, promptCacheReadTokens: 3, webSearchRequests: 0.01,} as const satisfies ModelCosts这看上去很「不工程化」:价格变了不就要重新发版本吗?但仔细想,这正是 Claude Code 想要的取舍:它的用户是开发者,开发者运行的是他们自己机器上的 Claude Code 二进制。如果价格能被远程下发,意味着 Anthropic 服务器可以悄悄改一次价格让所有用户的本地成本统计跟着变,这反而会让用户失去信任。把价格写死在代码里、跟版本号绑定,意味着用户清楚地知道「我装的这一版认的就是这个价格表」,要涨价就升级版本,整件事是透明的。
价格被组织成 6 个档位(叫 cost tier),每个档位对应同一价格区间的一组模型:比如 Sonnet 级别走 $3 加 $15 一档(输入每百万 token $3、输出每百万 token $15),Opus 4、4.1 走 $15 加 $75 一档,Haiku 走更便宜的一档。每个档位记录五种价格:输入价、输出价、提示缓存写入价、提示缓存读取价、web 搜索按次价。
算成本就是把这五种用量分别乘以对应单价再加总,没有任何花哨之处,但要按 provider contract 分开:本文引用的 Claude 档位里缓存读取显著低于输入价,不能把这个比例外推到所有模型。
第二件事是未知模型不报错也不算成 0。Claude Code 的代码里有一段工程化的处理:当用户用了一个本地价格表里没有的新模型,做法是自动回落到默认模型的价格做估算,同时上报一个「未知模型价格」事件让团队感知。这个事件会进入 Anthropic 内部的分析系统,让他们知道「哎,已经有一批用户在用某某新模型但我们的客户端还没更新价格表」,于是触发下一次的版本更新。
这种「fail soft 加告警」的取舍背后有一个明确的产品判断:抛错会让 agent 直接崩,算 0 会让用户以为新模型免费:这两个都是糟糕的用户体验。回落到默认价加内部告警,既保证用户不崩,又不会丢失「需要更新价格表」这个信号。
第三件事是让用户在终端里用 /insights 命令看到自己的会话报表。它的实现方式很特别:先把所有会话的 JSONL 文件从 ~/.claude/projects/<dir>/sessions/ 读出来(这个目录每个项目有一个独立子目录,是一种干净的命名空间设计),然后跑两次 Opus 推理:第一次让 Opus 从原始会话里抽取出「这个 session 干了什么」这种结构化的事实清单,第二次让 Opus 基于这些事实写出一段自然语言总结。
最后把这份报表打印到终端。
源码能确认 /insights 连续使用两次 Opus:一次提取事实,一次生成总结。它没有给出 Haiku 对照评测、单次 token 或美元成本,因此本文不替这个选择计算性价比。要判断是否值得,应记录真实会话长度、模型价格、失败样本和人工复核结果。
OpenClaw · 用一根”诊断事件总线”把所有观测信号都汇到同一根管子上
Section titled “OpenClaw · 用一根”诊断事件总线”把所有观测信号都汇到同一根管子上”多部署形态需要稳定事件名,而不是固定后端。OpenClaw 用 typed diagnostic events 和 listener 分发 usage、webhook、queue、session 等信号,落点由调用方决定。
在这种场景下,观测有一个独特挑战:你不能预判用户会把日志汇到哪:可能是 Datadog、可能是 Sentry、可能是公司自建的 Elasticsearch、可能甚至只是想直接打到 stderr。
针对这个挑战 OpenClaw 选了一种经典的解耦模式:所有观测都通过同一根叫「诊断事件」的总线发出来,至于这根总线的另一端连到哪里完全由部署方决定。它做了三件事来让这根总线实际可用:
第一件事是把观测范围归纳成语义清楚的事件类型。每个事件都是一个带具体字段的强类型对象;快照覆盖模型用量、Webhook、消息队列、会话、心跳和工具循环等状态,但仍需按自己的业务补充事件。
OpenClaw openclaw/src/infra/diagnostic-events.ts:1-100 DiagnosticEventPayload 13 种:model.usage / webhook.received|processed|error / message.queued|processed / session.state|stuck / queue.lane.enqueue|dequeue / run.attempt / diagnostic.heartbeat / tool.loop
type DiagnosticBaseEvent = { ts: number; seq: number;};
export type DiagnosticUsageEvent = DiagnosticBaseEvent & { type: "model.usage"; sessionKey?: string; sessionId?: string; channel?: string; provider?: string; model?: string; usage: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; promptTokens?: number; total?: number; }; lastCallUsage?: { ... }; context?: { limit?: number; used?: number }; costUsd?: number; durationMs?: number;};
// 还有 12 种事件类型...每个事件都自带两个共享字段:一个时间戳和一个全局递增的序号。这两个字段看起来朴素,但解决的是一个具体的问题:当多个事件几乎同时发出,时间戳本身可能撞到同一毫秒,导致下游做时序分析时分不清谁先谁后。加一个单调递增的序号,就有了「哪怕时间戳一样我也知道谁更晚」的兜底。
第二件事是用监听器模式把事件分发出去。任何代码都可以注册一个监听函数,告诉总线「我对这些事件感兴趣,请把它们交给我处理」。注册函数会立刻收到一个反注册句柄:撤销监听只要调用它就行,不需要维护额外的 ID 簿。这种模式让接入新后端变得简单:要接 Datadog 就写一个监听函数把事件转 OTel;要接 Sentry 就写一个监听函数把错误类事件转 Sentry;要存本地 SQLite 就写一个监听函数往数据库里塞:所有这些都跟核心 agent 代码完全解耦。
事件分发函数本身做了几件值得注意的工程兜底:
OpenClaw openclaw/src/infra/diagnostic-events.ts:171-242 全局 listener + 递归保护 depth=100;emitDiagnosticEvent 自动注入 seq + ts;onDiagnosticEvent 返回 unsubscribe
export function emitDiagnosticEvent(event: DiagnosticEventInput) { const state = getDiagnosticEventsState(); if (state.dispatchDepth > 100) { console.error( `[diagnostic-events] recursion guard tripped at depth=${state.dispatchDepth}, dropping type=${event.type}`, ); return; } const enriched = { ...event, seq: (state.seq += 1), ts: Date.now(), } satisfies DiagnosticEventPayload; state.dispatchDepth += 1; for (const listener of state.listeners) { try { listener(enriched); } catch (err) { console.error(`[diagnostic-events] listener error type=${enriched.type} seq=${enriched.seq}: ${errorMessage}`); } } state.dispatchDepth -= 1;}
export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void { const state = getDiagnosticEventsState(); state.listeners.add(listener); return () => state.listeners.delete(listener);}前面这段代码里藏着三个聪明的设计。
第一个是「递归保护」。想象一下这样一个场景:某个监听器在收到事件之后又触发了新事件(比如「我看到 model.usage 事件,让我 emit 一个 cost_alert 事件」),而这个新事件又被其他监听器接收触发更多事件:如果不加保护,整个系统会陷入无限递归直至栈溢出。OpenClaw 用一个「分发深度」计数器解决:每进入一层 emit 就加 1,退出减 1。如果深度超过 100,就直接丢弃事件并在错误日志里留痕。这是一种「宁可丢观测数据也不能让 agent 主流程崩」的工程态度。
第二个是「监听器隔离」。每个监听器调用都包在 try-catch 里:某个监听器写得有 bug,抛了异常,错误会被吞掉并打到 stderr,但不影响其他监听器正常运行。这件事看上去简单,但在多个观测后端共存的场景里很关键:你不希望 Datadog 监听器的一个 bug 让 Sentry 监听器也跟着失灵。
第三个是「序号在 emit 时才注入」。在这个单进程状态对象里,state.seq += 1 为已经进入 emit 的事件提供局部递增编号。它不能单独保证没有跳号、没有重复,也不能证明多个进程或异步写入之间的全局顺序;跨进程原子性、持久化失败和并发调度需要额外的序列器与存储测试。
第三件事是用 tool.loop 这种特别的事件类型直接给「agent 失控」留一个观测口子。这是 OpenClaw 在所有诊断事件里最有特色的一个:
export type DiagnosticToolLoopEvent = DiagnosticBaseEvent & { type: "tool.loop"; sessionKey?: string; sessionId?: string; toolName: string; level: "warning" | "critical"; action: "warn" | "block"; detector: "generic_repeat" | "known_poll_no_progress" | "global_circuit_breaker" | "ping_pong"; count: number; message: string; pairedToolName?: string;};这个事件类型本身就讲了一个故事:它承认 agent 会「失控」,并且把「我检测到 agent 失控了」这件事变成一个 first-class 的观测信号,而不是把它埋在某个日志里等运维去翻。事件里的「检测器名」字段告诉你触发这条 loop 警报的是 4 种检测器中的哪一种:
- 一种叫「通用重复」:同一个工具用同样的参数被连续调用了 N 次(比如 agent 死循环跑
ls)。 - 一种叫「已知轮询无进展」:某些工具(比如
git status、docker ps)在产品语义上就是轮询型的,agent 反复调用但每次返回都一样,这是典型的「卡在等某件事但那件事永远不会发生」。 - 一种叫「全局熔断」:单个会话里工具调用总次数超过一个阈值,无论是不是同一个工具。
- 一种叫「乒乓」:两个 agent 互相调用对方在反复传递问题,每次的参数都不一样所以单看任何一个 agent 看不出来重复,但站在系统外面看是明显的 A 走 B 走 A 走 B 模式。
每种检测器都对应一种「agent 经常会陷入的失控模式」,而把这些模式产品化为「事件总线上的一种事件类型」,让监听器可以简洁地处理:比如订阅 tool.loop 事件,命中 critical 级别就直接发钉钉、Slack 告警;命中 warning 级别就只记数据库不告警。
Hermes · 把「我有多确定这个数字」当成一等公民来设计成本系统
Section titled “Hermes · 把「我有多确定这个数字」当成一等公民来设计成本系统”本地账单的难点是价格来源不一。Hermes 用 CanonicalUsage、PricingEntry 和 CostSource 保存 token、价格版本与来源,并把历史写入 SQLite。
如果只是在 SQLite 里存一个「成本:$0.0234」的字段,会出现一个糟糕的情况:用户看到月度账单 $200 但提供商实际扣了 $250,他没办法分辨「这 $50 的差距是某个 provider 没返回成本所以本地估算偏低」还是「我的代码里某个 bug 算错了」还是「提供商 API 自己出了异常」。Hermes 的设计是把这种不确定性显式化:
Hermes hermes-agent/agent/usage_pricing.py:27-77 CanonicalUsage(5 维度 token + request_count)+ BillingRoute(provider + model + base_url + billing_mode)+ PricingEntry(5 维度价格 + source + version + fetched_at)+ CostResult(amount_usd + status + source + label + pricing_version + notes)
CostStatus = Literal["actual", "estimated", "included", "unknown"]CostSource = Literal[ "provider_cost_api", "provider_generation_api", "provider_models_api", "official_docs_snapshot", "user_override", "custom_contract", "none",]
@dataclass(frozen=True)class CanonicalUsage: input_tokens: int = 0 output_tokens: int = 0 cache_read_tokens: int = 0 cache_write_tokens: int = 0 reasoning_tokens: int = 0 request_count: int = 1 raw_usage: Optional[dict[str, Any]] = None
@property def prompt_tokens(self) -> int: return self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
@property def total_tokens(self) -> int: return self.prompt_tokens + self.output_tokens
@dataclass(frozen=True)class BillingRoute: provider: str model: str base_url: str = "" billing_mode: str = "unknown"
@dataclass(frozen=True)class PricingEntry: input_cost_per_million: Optional[Decimal] = None output_cost_per_million: Optional[Decimal] = None cache_read_cost_per_million: Optional[Decimal] = None cache_write_cost_per_million: Optional[Decimal] = None request_cost: Optional[Decimal] = None source: CostSource = "none" source_url: Optional[str] = None pricing_version: Optional[str] = None fetched_at: Optional[datetime] = None
@dataclass(frozen=True)class CostResult: amount_usd: Optional[Decimal] status: CostStatus source: CostSource label: str fetched_at: Optional[datetime] = None pricing_version: Optional[str] = None notes: tuple[str, ...] = ()这段类型定义里藏着 Hermes 整套成本系统的精髓:它把「价格的可信度」分成了从高到低的六个级别,再让最终的成本结果同时携带「这个数是怎么算出来的」和「我有多确定它」这两个维度。
第一组分级是「价格来源」:也就是「这个单价是怎么来的」。优先级从高到低排列的含义是:
- provider_cost_api(优先):提供商的 API 在响应里直接给出本次调用金额。它最接近供应商账单,但仍可能受折扣、税费、月末调整或字段语义影响,财务用途需要跟发票对账。
- provider_generation_api:响应里给出 token 分解,但金额仍由本地单价计算。token 是上游计量,价格版本和计费规则仍可能造成差异。
- provider_models_api:模型列表 API 返回单价时,可减少离线快照的滞后。是否覆盖最新改价、区域价和合同价,要看该 API 的更新与字段约定。
- official_docs_snapshot:本地代码里记录下来的价格快照,跟 Claude Code 的做法一样。准但有滞后:提供商改价后到本地快照更新之间会有窗口期算错。
- user_override:用户自己在配置里写「这个模型按这个价算」,常见场景是自部署模型或者用了某种私有合约价。
- custom_contract:企业合同定价。它和 user_override 都可能过期或录入错误,provider 返回值也可能不含折扣、税费和账期调整。这里的优先级是源码策略,不是本文统计出的错误率;财务用途仍要跟发票对账。
第二组分级是「成本状态」:也就是「这个数到底有多确定」。它有四个值:
- actual:从提供商 API 实际拿到的金额,置信度最高。
- estimated:本地用定价表算出来的估算值,可能跟最终账单偏差几个百分点。
- included:这一次调用本身就包含在用户的订阅套餐里(比如用户用了 ChatGPT Plus 订阅走 ChatGPT 内置 auth,那这次调用对用户来说不额外计费)。这个状态值很重要:少了它你算出来的「本月成本」会比用户实际付的多很多。
- unknown:实在拿不到数据,金额留空。前端看到这个状态就显示「价格未知」让用户知道是数据缺失而不是真免费。
第三个设计是给价格记录带上来源 URL 和价格版本号。_OFFICIAL_DOCS_PRICING 的记录包含源 URL 与 pricing version,团队可以追查一条估算使用了哪次价格快照。它能否满足企业审计,还取决于保留周期和发票对账要求。
第四个设计是把成本结果落到本地数据库再用一个命令出报表。引用路径把 usage、状态、来源、价格版本和备注写入本地 SQLite,InsightsEngine 再按模型、时间和 session 聚合。本文查看的这条路径没有远程 exporter;这能支持本地报表,但不能据此断言项目所有遥测都不离开机器,部署方仍要审计其他网络路径。
观测流程共享的底线
Section titled “观测流程共享的底线”把四个源码快照放回一次账单与复盘流程,可以提炼出五个检查项。它们是本文的工程归纳,不是行业标准。
第一件事是按计价维度记录 token。在本文引用的 Claude 价格档位里,input、output、cache_read 和 cache_write 单价不同;支持 reasoning 或 web search 时还要增加对应字段。具体字段和比例应跟随 provider 的当前计费 contract,不能从一个模型档位外推到所有模型。
第二件事是成本计算按维度分别乘单价再加总。只要各维度单价不同,统一平均价就会引入偏差。Codex、Claude Code、Hermes 的当前快照都有分维度计算;OpenClaw 的事件只携带 cost 字段,实际算法由 gateway 或调用方决定。
第三件事是历史会话需要能够回溯。具体怎么持久化各家做法不同:Codex 用 rollout-trace bundle 加 reducer、Claude Code 把会话写到项目目录、Hermes 使用本地 SQLite、OpenClaw 把落盘交给监听器。事件回放可以重建已记录状态,但重新执行还会受模型采样、外部状态和 API 变化影响;要区分“从日志还原”与“再次运行得到同一结果”。
第四件事是给未知模型定义显式结果。引用路径展示了不同策略:Claude Code 可用默认模型价并发内部告警,Hermes 能标记 unknown,OpenClaw 可以留空 costUsd 交给下游。默认价、unknown 和空值语义不同;无论选择哪种,都不能把缺失价格静默显示成免费,也不应让成本显示错误终止 agent 主流程。
第五件事是开发模式下默认关上远程上报。Codex 用 Rust 的 cfg!(debug_assertions) 编译开关在 debug 构建里把 exporter 切到 None,OpenClaw 默认把诊断功能关掉要用户主动开启。这背后是同一个工程纪律:开发跑测试时会制造各种异常的指标抖动(无限循环、超大输入、故意失败),如果这些数据进了生产监控的看板,运维会被误导以为线上出问题,整个团队都会被噪声淹没。
按复盘需求组合观测层
Section titled “按复盘需求组合观测层”四种典型场景:
- 要做企业级 SaaS agent:参考 Codex 的 OTEL + analytics + rollout trace 三件套。能接 Datadog / Honeycomb / Splunk,能离线 replay。
- 要做 IDE / 工具型 agent:参考 Claude Code 的硬编码 modelCost +
/insights命令;终端报表可以在不部署远程 dashboard 时提供本地反馈。 - 要做多平台 ChatOps:参考 OpenClaw 的 DiagnosticEvent + listener 模式。事件流出,落哪儿由部署方决定。
- 要做隐私敏感场景:参考 Hermes 的 CostSource 来源标记 + 全本地 SQLite。没远程上报。
选择:先能复盘,再谈仪表盘
Section titled “选择:先能复盘,再谈仪表盘”这里不做星级评分。先保证一次运行能被重放,再谈仪表盘或成本优化。
| 你的约束 | 可先读 | 代价与边界 |
|---|---|---|
| 企业监控需要接收 metrics、logs、traces | Codex 的 OTEL、analytics 与 rollout trace 分层 | 组件多,需维护 exporter 和隐私边界 |
| 开发者只需在本地查看会话账单 | Claude Code 的 model cost 表、JSONL 和 /insights | 价格更新要跟版本或配置同步 |
| 部署方要自选事件落点 | OpenClaw 的 typed diagnostic events 与 listener | 事件 schema 之外的成本核算由调用方承担 |
| 本地账单需要带来源和置信度 | Hermes 的 CanonicalUsage、PricingEntry 与 SQLite | 多机汇总和价格更新需要额外系统 |
从最小事件记录开始
Section titled “从最小事件记录开始”先固定一次 turn 的用量、价格来源和成本结果契约,再按复盘需求增加 exporter、价格源或报表,并为每个扩展保留可重放的证据。
复刻方案
最小可行
- 按 provider contract 记录计价维度:常见起点是 input / output / cache_read / cache_write,支持推理或 web search 时再加字段;本文引用的 Claude 档位里 cache 比例不同,不能外推到所有模型
- cost 计算分维度乘单价后加总(参考 Claude Code 的 tokensToUSDCost):每个维度(input/output/cache_read/cache_write)独立查价独立乘,最后求和;引用的 Claude 档位里 cache_read 与 cache_write 的价格差异很大,不能把这个档位的比例外推到所有模型
- 未知模型 fallback 到默认 + 告警事件(参考 Claude Code 的 tengu_unknown_model_cost):模型 ID 查不到价格时 fallback 到默认估算价并发埋点告警(让你知道有未知模型出现),不能直接 crash(影响主流程)也不能算 0(误导用户以为免费)
- debug build 默认关闭上报(参考 Codex 的 cfg!(debug_assertions)):开发时跑测试 / 调试会触发大量调用,这些数据进生产指标会污染数据;用编译期开关默认 debug build OTEL 关闭,发布版打开
进阶
- 观测代码独立 crate(参考 Codex):把 codex-otel / codex-analytics / codex-rollout-trace 各拆成独立 crate,hot path 只调小写入 API(不直接依赖 OTLP / Statsig 这些重 SDK);这样观测系统可以独立升级 / 替换 / 删除(比如换 Datadog 不需要改业务代码)
- OTLP HTTP + gRPC + 自家 default 三档 exporter(参考 Codex):企业用户按 infra 偏好接 OTLP,自家不配置走 default exporter,dev 环境走 None;三档是起点,按部署合规与网络边界增删
- trace bundle + reducer 模式(参考 Codex):raw event 写入 JSONL,reducer 离线算 reduced state;写入是否影响 hot path 仍要测磁盘、缓冲与 flush 策略
- AcceptedLineFingerprints(参考 Codex):度量模型生成代码的后续留存;行级 fingerprint 存 hash 而非原文,但仍需做隐私威胁评估
- /insights 命令(参考 Claude Code + Hermes):让用户在终端就能看自己的使用报表(model 用量 / cost / token 分布),不一定要上 dashboard;这降低了"看报表"的门槛,用户随时可以查
- DiagnosticEvent 13 类 + global listener(参考 OpenClaw):事件 schema 在类型系统里强约束(哪些字段需要 / 可选),全局 listener 决定落到哪里(stdout / stderr / file / OTEL);新增事件时集中更新 schema,并检查所有 listener 的兼容性
- tool.loop 4 种检测器(参考 OpenClaw):generic_repeat(重复调同一工具) / known_poll_no_progress(已知的 poll 无进展模式) / global_circuit_breaker(全局熔断) / ping_pong(多个工具反复来回);把"agent 反控失措"作为 first-class 信号上报,这样 SRE 能看到趋势
- CostSource 记录 6 种有值来源加 none(参考 Hermes):provider_cost_api / generation_api / models_api / docs_snapshot / user_override / custom_contract;来源标签用于解释金额从哪里来,最终账单仍要对账
- CostStatus 4 状态(参考 Hermes):actual(实付,从 provider API 来)/ estimated(估算)/ included(套餐覆盖,无额外费用)/ unknown(无法确定);让下游消费者知道这个数有多准,避免把 estimated 当 actual 报给财务
- PricingEntry 带 source_url + pricing_version(参考 Hermes):每条价格记录都标明从哪个 URL 抓的(哪天的快照)+ pricing_version(价格版本号),方便审计"这个价格是哪天从哪个 URL 摘录的",模型涨价后能溯源
- 隐私模式(参考 Hermes):完全本地 SQLite + 终端报表,不远程上报;当工作负载或合规政策禁止远程遥测时使用
一开始别做
- 别把 token / cost 算成同一个数:input cost ≠ cache_read cost ≠ web_search cost;先按 provider contract 拆维度,再用真实用量和价格表验证误差,不要把某个档位的数量级差异当成通用结果
- 别在 hot path 同步发 telemetry:OTLP 发送是网络 IO(可能慢可能失败),同步发会阻塞 agent loop;用 sender 模式(写到内存 channel)/ async(异步任务)/ batch(批量发),发送失败要 swallow(不能让监控失败拖垮业务)
- 别让 listener 异常炸主流程:listener 是用户写的 / plugin 注册的代码,可能有 bug;用 try-catch 包住每个 listener,异常写 stderr 不抛出(监控不能影响业务)
- 别假设上游 API 一定返回 cost:provider API 可能没返回(旧版本 / 错误情况),需要 fallback 到本地估算(按 token 数 × 你存的单价)+ 标 CostStatus.estimated;不能 crash 不能算 0
- 别把价格写在产品代码里又不带 version:模型改价后会无法判断历史数据使用哪一版单价;价格需要有版本号 + 时间戳 + 来源
- 别给每个 hot path 加 OTEL span:span 也有开销(创建 / context 传递 / 序列化),profile 看哪里慢再有针对性加 span,否则 trace 噪音会淹没实际有问题的地方
把观测差异放回部署场景
Section titled “把观测差异放回部署场景”把 4 种放一起,观测投入差距一眼可见:Codex 是企业级 SRE 友好,Claude Code 是开发者自助,OpenClaw 是事件流给运维选,Hermes 是隐私本地 + 价格精度优先。
从事件到成本数字的证据链
Section titled “从事件到成本数字的证据链”本章带走什么与下一步实验
Section titled “本章带走什么与下一步实验”可观测性先服务于三个问题:发生了什么、为什么继续、钱花在哪里。仪表盘是结果,事件 Schema、因果关系和价格来源才是真相源。
下一步实验:设计一次包含缓存命中、工具超时、模型重试和一个 Subagent 的已知任务。用事件流重建时间线和成本,再故意删除一条 tool_result。验收系统能指出缺口,账单与事件估算在声明的误差内,且每次继续都有 reason,而不是只看到总 Token。
附录:练习与复盘
Section titled “附录:练习与复盘”按需展开练习和十道复盘题
- 🟢 算一次 turn 成本:给定
{input: 8000, output: 2000, cache_read: 12000, cache_write: 4000}+claude-sonnet-4(COST_TIER_3_15),手算 USD 成本。验证:约(8/1000)*3 + (2/1000)*15 + (12/1000)*0.3 + (4/1000)*3.75 = 0.024 + 0.030 + 0.0036 + 0.015 ≈ $0.0726。 - 🟠 实现 DiagnosticEvent listener:写一个 Python 函数
on_event(event: dict),把所有event['type'] == 'model.usage'的事件累加costUsd,每 100 个事件 print 一次累计金额。 - 🟠 CostSource 优先级:写一个函数
pick_pricing(entries: list[PricingEntry]) -> PricingEntry,按provider_cost_api > provider_generation_api > provider_models_api > official_docs_snapshot > user_override > custom_contract > none优先级选最高的一个。 - 🔴 trace bundle replay:用一个简单 JSONL 文件模拟
trace.jsonl,每行一条{type, ts, payload}事件。写 reducer 函数:聚合type == "turn.start"/"turn.end"算出 turn 数 + 总耗时 + 每 turn token 平均值。
Q1 · 概念:token 至少要分多少维度?为什么不能只算总数?
最低维度要跟 provider contract 走;本文 Claude 示例使用 input / output / cache_read / cache_write。只算总数会在价格不同的档位产生偏差,偏差大小需用真实价格和用量测量。
价格差异(Anthropic claude-sonnet-4 为例):
- input:$3 / 1M token
- output:$15 / 1M token
- cache_read:$0.3 / 1M token(input 的 10%)
- cache_write:$3.75 / 1M token(input 的 1.25 倍)
一个典型 turn 输入 30k token,里面 25k 命中 cache(cache_read),5k 不命中(input)。如果只算「总输入 30k」按 input 单价:
30 * 3 / 1000 = $0.09
实际成本:
5 * 3 / 1000 + 25 * 0.3 / 1000 = 0.015 + 0.0075 = $0.0225
在这组假设用量与价格里,两种算法相差 4 倍。月度差额仍取决于真实用量分布,不能由单次示例推出一个数量级。
第 5 维:
reasoning_tokens:OpenAI o1 / Anthropic extended thinking 单独计费web_search_count:每次 web 搜索 $0.01-0.05(按提供商)request_count:固定 per-request 费用(部分 provider)
Hermes CanonicalUsage 6 字段较全覆盖这 6 维:
@dataclassclass CanonicalUsage: input: int output: int cache_read: int cache_write: int reasoning: int request_count: int追问:「unknown 模型怎么算?」Claude Code 用 tengu_unknown_model_cost 事件 + 默认值;不要返回 0(会让用户以为「免费」),也不要 raise(会让 agent crash)。
源码:hermes-agent/agent/usage_pricing.py:CanonicalUsage + claude-code/src/utils/modelCost.ts。
Q2 · 概念:Codex 把观测拆 3 个独立 crate 的好处?
codex-otel / codex-analytics / codex-rollout-trace 三个 crate,职责各异:
codex-otel:实时指标 / trace。
- 实时 export 到 OTLP / Statsig backend
- 用于 SRE on-call 报警
- hot path 调用频率最高
codex-analytics:业务事件。
- 离线分析「用户用 agent 怎么样」
- TrackEventRequest 20+ 类,包括 SkillInvocation / HookRun / TurnEvent / AcceptedLineFingerprints
- 用于产品 / 增长团队
codex-rollout-trace:较全 session replay。
- trace.jsonl + manifest.json 落盘
- reducer 离线算 reduced state,让 replay 不阻塞 hot path
- 用于 debug / postmortem
为什么不合并?
每个 crate 的用户 / 部署方式不同:
- otel 给 SRE,热数据
- analytics 给 PM / 增长,温数据
- rollout-trace 给开发者,冷数据
如果合并,会出现「我只想要 OTLP 但被强制带 analytics 依赖」。本文没有可复核的构建体积基准;独立 crate 的可取之处是让发布、依赖和部署边界保持可选。
追问:「3 crate 之间有交叉吗?」有共享 schema(TraceMessageId 等),抽到 codex-protocol 单独 crate。三家观测 crate 共享 protocol,互不依赖。
源码:codex/codex-rs/otel/ + codex/codex-rs/analytics/ + codex/codex-rs/rollout-trace/。
Q3 · 架构:Hermes 的 CostSource 为什么要保留来源标签?
provider_cost_api > provider_generation_api > provider_models_api > official_docs_snapshot > user_override > custom_contract > none
核心原则:越接近 provider 系统、越实时,可信度越高。
逐级解释:
provider_cost_api:API 返回里直接带{"cost_usd": 0.0234}字段。它最接近 provider 的计费记录,但不能写成 100% 准确;折扣、税费、账期调整和字段口径仍要通过账单核验。provider_generation_api:generation 接口里有{"prompt_tokens": ..., "completion_tokens": ..., "cost": ...}。也是 provider 算的,但要再做一次乘价格。provider_models_api:models 列表 API 返回{"id": "gpt-4o", "pricing": {"input": 0.0025, "output": 0.010}}。比 docs snapshot 新,但要自己乘。official_docs_snapshot:从官方价格文档摘录后 hard-code 到代码里。snapshot 时间清楚,但不能跟随实时价格变化。user_override:用户手动设置pricing.json覆盖。可能跟实际偏差。custom_contract:企业自定义合同(实际花的钱跟标价不一样)。用户最懂,所以放最后。
为什么 user_override 不在最前?
矛盾点:「用户最懂」对个人开发者成立,但对企业不成立。企业用户可能错配(忘了改 / 复制错文件),provider 实时数据反而最准。所以默认按 provider 接近度排序,用户 override 是最后兜底。
none 是什么?
unknown 模型,pricing 完全找不到。CostStatus = unknown,前端显示「价格未知」让用户知道。比报 0 好得多。
PricingEntry 带 source_url + pricing_version:审计时知道「这个 0.0025 是 2024-10-01 从 openai.com/pricing 记录下来的」。半年后 OpenAI 改价,可以反查为何老数据用老价格。
追问:「provider 的 cost_api 也可能错怎么办?」记 source_url,让用户能反查 provider 的请求。Hermes CostResult.notes 字段就是为这种「我返回 X 但下游觉得不对」的 trace 留的口子。
源码:hermes-agent/agent/usage_pricing.py:CostSource + estimate_usage_cost。
Q4 · 概念:OpenClaw 13 类 DiagnosticEvent 为什么不直接用 OpenTelemetry?
OpenClaw 选 13 类自定义 event + listener 模式,而不是直接接 OTel。背后的取舍:
1. event schema 跟业务语义对齐
webhook.received / webhook.processed / webhook.error 是 agent 业务概念。OTel span 是通用 trace 概念。直接用 OTel:
- 需要写 attribute 表达「这是 webhook receive」
- 下游分析时要查 attribute 解析业务语义
- 出问题查难
13 类 DiagnosticEvent 把业务语义直接编码进 type 字段,listener 拿到 event 一眼知道含义。
2. listener 模式让落哪儿都行
onDiagnosticEvent((event) => { // 自己决定怎么处理 if (event.type === 'model.usage') { forwardToOtel(event); }})部署方可以在 listener 里转 OTel,也可以写入 PostgreSQL 或发送告警。适配器代码量取决于字段映射、重试和认证;OpenClaw 的关键点是没有把落点写死。
3. recursion guard / dispatchDepth
OTel SDK 内部已经有一套自我保护,但 OpenClaw 13 类事件更复杂(tool.loop 这种「检测 agent 自己被卡」的事件,emit 时可能触发更多 emit)。OpenClaw 自己实现 dispatchDepth < 100 保护,比包 OTel 干净。
4. 类型安全
13 类事件都有 TS 类型,编译期保证 emit 时字段较全。OTel attribute 是 Record<string, any>,类型推断弱。
坏处:
- listener 写多了重复代码(OTel forwarder / log forwarder / DB forwarder 都要写)
- 没有 OTel 那种丰富的生态工具(Jaeger UI / Grafana 等不能直接吃)
- cross-service trace 没现成支持(需要自己 propagate trace_id)
OpenClaw 的选择对吗?
对企业 SaaS 场景对。客户已有 OTel / Sentry / Datadog 等基础设施。OpenClaw 只 emit event,客户写 listener 适配自家系统。
追问:「能不能把 13 类事件设计成 OTel 兼容?」可以。把 event 字段 mapping 到 OTel span / metric / log,listener 转一次即可。tool.loop 这种 OpenClaw 独有的转成 OTel custom event。
Q5 · 概念:Claude Code 的 /insights 用两次 Opus,源码能证明到哪一步?
/insights 让用户终端看到本地 session 报表。实现:
- 读
~/.claude/projects/<dir>/sessions/*.json - 第一次 Opus call:facet extraction(从 session 文本提取 turn 数 / 总 tokens / 工具调用次数 / 失败率等结构化数据)
- 第二次 Opus call:summary(基于 facets 生成自然语言报表)
- 终端 print
为什么这么贵的设计是值得的?
1. 用户不需要 dashboard
dev 用 agent 关心「今天我跟 agent 聊了多少」「哪个 prompt 最贵」「最近 1 小时的趋势」。本地一条 /insights 出报表,比开浏览器看 Grafana / Datadog 顺手得多。
2. Opus 处理非结构化数据是强项
session.json 里有 message text / tool call / cost。直接 SQL group by 拿不到「这个 task 主要做了什么」这种语义信息。用 Opus 让它读完 session 给出语义总结。
3. 成本要从真实调用记录计算
源码没有提供单次报表 token、美元成本或开发者日均花费。上线前应记录两次调用的输入、输出、缓存命中和模型价格,再决定是否默认使用 Opus。
4. 隐私
报表全本地生成。session 数据不发到远程 dashboard。这对企业 dev 友好(敏感 prompt 不离开机器)。
两次 call 的拆分:
- facet extraction 让 Opus 做「结构化」(容易,便宜)
- summary 让 Opus 做「自然语言」(基于上一步的结构化结果,质量更高)
合并成一次 call 也行,但拆分让两步都更可控。
追问:「能不能用更便宜的模型替代 Opus?」源码不能回答质量差异。可以拿同一批 session 做盲评,记录事实错误、遗漏、延迟和成本,再决定模型或按上下文长度路由。
源码:claude-code/src/commands/insights.ts + claude-code/src/utils/queryWithModel.ts。
Q6 · 实战:你给自己的 agent 加观测,从 0 到生产怎么走?
按 结构化 token → 价格 + 成本 → DiagnosticEvent → /insights → OTLP 五个 scope 推进;每层先对账和回放,再决定是否增加下一层。
阶段 1 · 结构化 token
@dataclassclass Usage: input: int output: int cache_read: int = 0 cache_write: int = 0 reasoning: int = 0
def total_tokens(self) -> int: return sum([self.input, self.output, self.cache_read, self.cache_write, self.reasoning])每次 LLM call 返回都填充 Usage。不要只存 total_tokens。
验收门槛:固定 provider fixture 的 raw usage 能重算四维 token,缺失字段和未知模型都有显式状态。
阶段 2 · 价格 + 成本
前置:Usage 字段已与 provider contract 对齐。
PRICING = { "claude-sonnet-4": { "input": 3 / 1e6, "output": 15 / 1e6, "cache_read": 0.3 / 1e6, "cache_write": 3.75 / 1e6, }, # ...}
def cost(usage: Usage, model: str) -> float: p = PRICING.get(model) if not p: log.warning(f"Unknown model {model}, using fallback") p = FALLBACK_PRICING return sum(getattr(usage, k) * v for k, v in p.items())参考 Claude Code tokensToUSDCost 思路。
验收门槛:固定价格快照可重算金额,source、version 和 fallback 都写入结果;不把 unknown 当成 0。
阶段 3 · DiagnosticEvent emit
前置:成本结果已有稳定 schema,且 emit 不需要同步网络 IO。
def emit_event(event_type: str, **kwargs): event = { "type": event_type, "ts": time.time(), "seq": next_seq(), **kwargs, } for listener in listeners: try: listener(event) except Exception as e: log.error(f"Listener failed: {e}")
emit_event("model.usage", model=model, usage=usage, cost_usd=cost(usage, model))参考 OpenClaw listener 模式。
验收门槛:事件序号单调、listener 失败不阻塞主流程,同一 trace 可重放并与 turn 记录对上。
阶段 4 · /insights 命令
@cli.command()def insights(): """Show terminal cost report.""" sessions = load_sessions(SESSIONS_DIR) total_cost = sum(s.cost_usd for s in sessions) by_model = group_by_model(sessions) print(f"Total: ${total_cost:.2f}") for model, cost in by_model.items(): print(f" {model}: ${cost:.2f}")参考 Hermes InsightsEngine + Claude Code /insights。
验收门槛:报表中的 model、token、cost 能回到原始 session 记录,未知价格显示 unknown 而非免费。
阶段 5 · OTLP exporter(可选)
前置:部署方有明确 exporter 落点、隐私边界和失败策略。
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporterfrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider()provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))trace.set_tracer_provider(provider)
# emit_event 时也 emit OTel span参考 Codex codex-otel exporter 思路。
验收门槛:关闭 exporter 时核心事件仍可落盘;发送失败、重试和批量 flush 均有契约测试。
关键经验:
- 不要从 OTel 起步:OTel 复杂、依赖多。先 emit_event 自家模式,需要时再 export 到 OTel
- 价格表带 version:
PRICING_VERSION = "2026-05-15",方便审计 - /insights 早做:dev 自助报表是高价值低成本
- OTLP 按 SRE 需求再加:先让本地事件和复盘路径稳定
追问:「价格表怎么更新?」在 provider 发布新模型或价格变更时触发 review,并保留旧版本快照;也可以用 Hermes 思路接 provider models API 自动 fetch。完全自动化省心但增加运维。
源码组合:参考 Hermes usage_pricing.py + Claude Code modelCost.ts + OpenClaw diagnostic-events.ts + Codex analytics/events.rs。
Q7 · 概念:AcceptedLineFingerprints 度量「代码留存率」是怎么工作的?
Codex 想知道:模型生成的代码被人接受了多少。
朴素方案:模型每次生成代码记录「行数」,用户 accept 时再记录「行数」。问题:用户可能 accept 之后又改了 50%。「accept」不等于「留存」。
Codex 方案 - line fingerprint:
- 模型生成代码时,每行算一个 fingerprint(不存内容,存 hash)
- 用户 accept 之后,记录每行 fingerprint 到 session 数据库
- 之后用户编辑文件 / 重构 / 删除时,扫文件每行算 fingerprint,跟历史 fingerprint 对比
- 「7 天后还在文件里的 fingerprint 数 / 当初 accept 的 fingerprint 数」= 留存率
为什么用 fingerprint 不存内容?
- 隐私:不上传用户实际代码
- 数据量小:hash 32 字节 vs 一行代码可能 200 字节
- 白名单:line-level 信号,不污染整文件 hash
Fingerprint 计算(简化):
fn fingerprint(line: &str) -> u64 { let normalized = normalize_whitespace(line); let hash = xxhash64(normalized); hash}
fn normalize_whitespace(line: &str) -> String { line.trim().split_whitespace().collect::<Vec<_>>().join(" ")}normalize 让「加空格」「改缩进」不影响 fingerprint。重命名变量 / 改字符串则改变 fingerprint,被算作「编辑了」。
业界价值:
- 给 Codex 团队产品决策:哪类生成代码留存率高 / 低
- 给用户成本可视化:「你这个月生成 5000 行,留存 1500 行」
- 给模型 fine-tuning 数据:高留存的输入输出对,是好训练样本
反模式:直接传代码上云:
- 隐私违规
- 流量太大
- 法务风险
fingerprint 让这件事可做。
追问:「不存内容怎么知道是哪一行?」每次 fingerprint 时同时记录 {file_path, line_number, fingerprint}。如果用户改文件,line_number 可能漂移,需要 fuzzy match(看 fingerprint 还在不在文件其他位置)。
Q8 · 概念:tool.loop 4 种检测器分别识别什么?
OpenClaw 的 tool.loop 是「agent 自己失控」的事件类型。4 种检测器对应 4 种典型失控模式:
1. generic_repeat:相同 tool call argv 在窗口内重复 > N 次。
turn 1: bash("ls")turn 2: bash("ls")turn 3: bash("ls")→ generic_repeat detectedagent 死循环跑同一个命令,没换 argv。最 dumb 的 loop。
2. known_poll_no_progress:已知的轮询 tool(git status / docker ps)没进展。
turn 1: bash("git status") → "nothing to commit"turn 2: bash("git status") → "nothing to commit"turn 3: bash("git status") → "nothing to commit"→ known_poll_no_progress detectedagent 在 polling 但 git 状态没变化。手动 lookup table 标 is_polling_tool: bash("git status") == true。
3. global_circuit_breaker:全局熔断(单 session tool call 总数超阈值)。
session 1: 50 tool callssession 2: 80 tool callssession 3: 200 tool calls→ global_circuit_breaker triggered (limit=100)agent 失控做太多 tool call。global 阈值兜底,强制停止。
4. ping_pong:agent 跟另一个 agent / 工具反复来回。
turn 1: agent_a 调用 agent_b ask("X")turn 2: agent_b 调用 agent_a ask("Y about X")turn 3: agent_a 调用 agent_b ask("Z about Y")→ ping_pong detected最微妙的 loop:每次 tool call 内容都不一样,但模式是「两个 agent 互问」。需要看上下文不是看单次 call。
为什么不靠 max_turns?
max_turns = 50 是粗糙保护。问题:
- agent 跑 49 turn 全是 ping_pong,最后一 turn 才被 kill → 浪费 49 turn
- 不同 task 复杂度不同,max_turns 固定难配
- agent 可能合理用 50 turn(巨型 refactor)
4 种检测器是细粒度,能提前识别问题。generic_repeat 出现 3 次就警告,不用等到 turn 50。
实现思路:
- 维护一个 ring buffer 存最近 N turn 的 tool call argv
- 每次 emit
tool.use事件时,跑 4 个检测器 - 命中任何一个 → emit
tool.loop事件,listener 决定要不要打断 agent
追问:「检测器还能加什么?」可以测试 LLM judge 判断相邻 turn 是否重复,也可以加 cost_explosion。阈值如 10 倍只能作为示例,需从真实成本分布和误报率校准。
源码:openclaw/src/infra/diagnostic-events.ts:tool.loop + openclaw/src/agents/loop-detectors.ts。
Q9 · 工程:在 hot path emit telemetry 怎么不阻塞主流程?
四种典型实现:
1. async sender + queue
import queue
event_queue = queue.Queue(maxsize=10000)
def emit_event(event): try: event_queue.put_nowait(event) except queue.Full: log.warning("Event queue full, dropping")
def background_sender(): while True: event = event_queue.get() send_to_backend(event)
threading.Thread(target=background_sender, daemon=True).start()hot path 只 enqueue,后台 thread 发送。这里不写微秒级保证;队列竞争、序列化和运行时都会影响开销,应在目标负载下测量。
2. batch + flush
batch = []last_flush = time.time()
def emit_event(event): batch.append(event) if len(batch) > 100 or time.time() - last_flush > 5: flush(batch) batch.clear() last_flush = time.time()减少 backend 请求数。100 个事件 batch 成 1 请求。
3. fire-and-forget HTTP
async def emit_event(event): asyncio.create_task(_send(event))
async def _send(event): try: async with session.post(url, json=event) as resp: pass # 不等响应 except Exception: pass # swallowasyncio 把发送丢到 event loop,不等结果。
4. OTEL SDK 自带 BatchSpanProcessor
provider.add_span_processor(BatchSpanProcessor( OTLPSpanExporter(), max_queue_size=10000, schedule_delay_millis=5000,))OTel SDK 已经做了 batch + async。直接用。
所有方案的共同点:
- 失败 swallow:telemetry 出错不应该让 agent crash
- 背压策略:queue 满了丢事件,不阻塞 hot path
- debug 友好:dev 环境跑日志而不是真发,避免污染指标
Codex 的做法:
cfg!(debug_assertions) 编译开关让 debug build 走 NoOp exporter,release build 走真实 exporter。一行代码切环境。
追问:「fire-and-forget 丢事件怎么办?」accept 它。telemetry 是「最大努力」性质,不是事务。如果某个事件类型需要不丢(计费用),单独走可靠队列(Kafka / SQS)。
反模式:在 hot path 同步发
def emit_event(event): requests.post(url, json=event) # 假设这次网络调用阻塞 200ms若一次同步网络调用阻塞 200ms,调用路径至少会多出这段等待。200ms 是示例输入,不是本文实测。
源码:Codex codex-otel/src/exporter.rs BatchSpanProcessor + Hermes agent/usage_tracker.py async update。
Q10 · 开放:按复盘与对账需求设计观测框架,并说明证据缺口。
5 层架构:
Layer 1 · 结构化 token(需要)
@dataclassclass CanonicalUsage: input: int output: int cache_read: int cache_write: int reasoning: int = 0 request_count: int = 1参考 Hermes CanonicalUsage。
Layer 2 · 多源 pricing(需要)
class CostSource(Enum): PROVIDER_COST_API = 1 # 最准 PROVIDER_GENERATION_API = 2 PROVIDER_MODELS_API = 3 OFFICIAL_DOCS_SNAPSHOT = 4 USER_OVERRIDE = 5 CUSTOM_CONTRACT = 6 NONE = 99
def estimate_cost(usage, model, route) -> CostResult: for source in CostSource: entry = lookup_pricing(model, route, source) if entry: return CostResult( amount_usd=compute(usage, entry), status=CostStatus.ACTUAL if source <= 2 else CostStatus.ESTIMATED, source=source, pricing_version=entry.version, ) return CostResult(amount_usd=None, status=CostStatus.UNKNOWN)参考 Hermes 的 6 种有值 source 加 none。
Layer 3 · DiagnosticEvent emit(需要)
def emit_event(event_type, **fields): event = {"type": event_type, "ts": time.time(), "seq": next_seq(), **fields} for listener in listeners: try: listener(event) except Exception as e: log.error(f"Listener failed: {e}")参考 OpenClaw 13 类 + listener。
Layer 4 · OTLP / Statsig exporter(可选 · enterprise)
class Exporter(Enum): NoOp = "noop" # dev Statsig = "statsig" # 自家 OtlpGrpc = "otlp-grpc" # 企业 OtlpHttp = "otlp-http" # 企业 HTTP
def configure_exporter(exporter: Exporter, settings): ...参考 Codex 4 exporter。
Layer 5 · /insights 终端报表(推荐)
def insights_cli(): sessions = load_sessions(SESSIONS_DIR) facets = extract_facets(sessions) # 模型 / cost / tool calls summary = llm_summarize(facets, model="claude-sonnet-4") # 用便宜模型 print(format_terminal_report(facets, summary))参考 Claude Code /insights + Hermes InsightsEngine。
贡献矩阵:
- Codex 贡献:3 crate 拆分 + AcceptedLineFingerprints + rollout-trace replay + cfg debug 开关
- Claude Code 贡献:modelCost 5 tier + tengu_unknown_model_cost + /insights 用 Opus
- OpenClaw 贡献:13 类 DiagnosticEvent + listener 模式 + tool.loop 检测器
- Hermes 贡献:CanonicalUsage 6 字段 + 6 种有值 CostSource 加 none + CostStatus 4 状态 + 全本地 SQLite
按 scope 评估:
- Layer 1-2 · 用量与价格契约:先解释一笔 turn 的四维 token、价格版本和 CostStatus。验收:固定 provider fixture 能从 raw usage 重算金额,并保留 source、version 和 unknown 状态。
- Layer 3 · 事件与本地复盘:在需要诊断 loop、回放或本地报表时增加。前置:事件 schema 和序号稳定;验收:同一 trace 可重放,listener 失败不影响主流程,/insights 与原始记录对账。
- Layer 4 · exporter:只有当部署方有明确的 OTLP/Statsig 等落点和隐私边界时加入。前置:emit 与 send 解耦;验收:发送失败、重试和关闭 exporter 的行为有契约测试。
- Layer 5 · 报表与价格源扩展:按用户查询和 provider contract 增加。前置:价格来源可追溯;验收:一笔账单能回到 usage、price snapshot 和 invoice 对账记录。
关键决策:
- token 4 维起步,不要 total 一个数
- pricing 带 version + source_url
- emit 跟发送解耦:hot path 只 enqueue
- debug 默认 NoOp:测试不污染指标
- /insights 早做:本地报表是高价值低成本
追问:「跨语言怎么共享?」CanonicalUsage / DiagnosticEvent schema 用 JSON Schema 定义,codegen 类型。具体 emit / export 各语言独立实现。OTel 自己也是这个模式(spec + 各语言 SDK)。
源码组合:codex/codex-rs/otel/ + codex/codex-rs/analytics/ + claude-code/src/utils/modelCost.ts + openclaw/src/infra/diagnostic-events.ts + hermes-agent/agent/usage_pricing.py。