跳到主要内容

15 · 一次 Agent 运行如何对账

用事件与 token 轨迹重放一次运行,并解释成本数字的来源和不确定性

本章任务

要回答的问题

出现失败、延迟或高账单时,怎样从事件中重建这次 Agent 运行到底发生了什么?

读完你能

  • 设计 step、turn、session 三层事件模型
  • 解释 token、缓存、工具和并发如何进入成本
  • 用 transition reason、verifier 和 rollout 定位行为偏差
适合现在读
正在建设 Trace、成本归因、告警、回放或线上诊断的工程师
先修知识
理解 Loop、Tool Call 与 Session 生命周期
实践产物
一个可回放事件 Schema、核心查询清单与成本对账表
证据边界
日志能解释已记录的行为;采样、价格变更和缺失事件仍会造成归因不确定性

场景:一次任务花了 47 美元、运行 38 分钟,最终没有答案。日志只记录“模型调用失败”,看不出是压缩重试、工具超时、Subagent 重复检索还是价格表计算错误。没有事件因果链,成本数字不能解释,也无法复现。

通过标准:每个 step 带 run/turn/span、开始结束时间、输入输出 token、cache、模型、工具、重试原因和父子关系;价格版本可追溯;缺失事件和不平衡 span 能被检测;从事件可以重建最终退出原因。

四系统的观测栈:codex OTEL 加 analytics 加 trace、claude code modelCost 加 insights 加 sessionStorage、openclaw diagnostic-events 13 类、hermes usage_pricing 加 SQLite
同一个「agent 跑得怎么样」,四家从 1 个 crate 到 3 个独立 crate,从硬编码定价到 6 种有值 source 加 none。

四家在观测、成本、日志 5 件事上的覆盖:

维度 CodexClaude CodeOpenClawHermes
token 计数 `TokenUsage`(input、output、cache_creation、cache_read、reasoning_output),codex-protocol crateBetaUsage(@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` 命令出终端报表
观测栈的工程化程度

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 Mtok
export 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 Mtok
export 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 Mtok
export 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 statusdocker 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;这能支持本地报表,但不能据此断言项目所有遥测都不离开机器,部署方仍要审计其他网络路径。

把四个源码快照放回一次账单与复盘流程,可以提炼出五个检查项。它们是本文的工程归纳,不是行业标准。

第一件事是按计价维度记录 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 默认把诊断功能关掉要用户主动开启。这背后是同一个工程纪律:开发跑测试时会制造各种异常的指标抖动(无限循环、超大输入、故意失败),如果这些数据进了生产监控的看板,运维会被误导以为线上出问题,整个团队都会被噪声淹没。

四家观测栈在观测投入 x 价格表精度上的相对位置
Codex 拆成 3 个观测 crate;Hermes 记录 6 种有值来源加 none;Claude Code 用 modelCost 加 /insights;OpenClaw 发事件并把 cost 留给调用方。

四种典型场景:

  • 要做企业级 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。没远程上报。

这里不做星级评分。先保证一次运行能被重放,再谈仪表盘或成本优化。

你的约束可先读代价与边界
企业监控需要接收 metrics、logs、tracesCodex 的 OTEL、analytics 与 rollout trace 分层组件多,需维护 exporter 和隐私边界
开发者只需在本地查看会话账单Claude Code 的 model cost 表、JSONL 和 /insights价格更新要跟版本或配置同步
部署方要自选事件落点OpenClaw 的 typed diagnostic events 与 listener事件 schema 之外的成本核算由调用方承担
本地账单需要带来源和置信度Hermes 的 CanonicalUsage、PricingEntry 与 SQLite多机汇总和价格更新需要额外系统

先固定一次 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 噪音会淹没实际有问题的地方
四种观测栈并列对照
Codex 3 个独立 crate;Claude Code modelCost + /insights;OpenClaw 13 类 DiagnosticEvent + listener;Hermes CanonicalUsage + BillingRoute + 6 种有值 CostSource 加 none。

把 4 种放一起,观测投入差距一眼可见:Codex 是企业级 SRE 友好,Claude Code 是开发者自助,OpenClaw 是事件流给运维选,Hermes 是隐私本地 + 价格精度优先。

可观测性先服务于三个问题:发生了什么、为什么继续、钱花在哪里。仪表盘是结果,事件 Schema、因果关系和价格来源才是真相源。

下一步实验:设计一次包含缓存命中、工具超时、模型重试和一个 Subagent 的已知任务。用事件流重建时间线和成本,再故意删除一条 tool_result。验收系统能指出缺口,账单与事件估算在声明的误差内,且每次继续都有 reason,而不是只看到总 Token。

按需展开练习和十道复盘题
  1. 🟢 算一次 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
  2. 🟠 实现 DiagnosticEvent listener:写一个 Python 函数 on_event(event: dict),把所有 event['type'] == 'model.usage' 的事件累加 costUsd,每 100 个事件 print 一次累计金额。
  3. 🟠 CostSource 优先级:写一个函数 pick_pricing(entries: list[PricingEntry]) -> PricingEntry,按 provider_cost_api > provider_generation_api > provider_models_api > official_docs_snapshot > user_override > custom_contract > none 优先级选最高的一个。
  4. 🔴 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 维:

@dataclass
class 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 系统、越实时,可信度越高。

逐级解释

  1. provider_cost_api:API 返回里直接带 {"cost_usd": 0.0234} 字段。它最接近 provider 的计费记录,但不能写成 100% 准确;折扣、税费、账期调整和字段口径仍要通过账单核验。
  2. provider_generation_api:generation 接口里有 {"prompt_tokens": ..., "completion_tokens": ..., "cost": ...}。也是 provider 算的,但要再做一次乘价格。
  3. provider_models_api:models 列表 API 返回 {"id": "gpt-4o", "pricing": {"input": 0.0025, "output": 0.010}}。比 docs snapshot 新,但要自己乘。
  4. official_docs_snapshot:从官方价格文档摘录后 hard-code 到代码里。snapshot 时间清楚,但不能跟随实时价格变化。
  5. user_override:用户手动设置 pricing.json 覆盖。可能跟实际偏差。
  6. custom_contract:企业自定义合同(实际花的钱跟标价不一样)。用户最懂,所以放最后。

为什么 user_override 不在最前?

矛盾点:「用户最懂」对个人开发者成立,但对企业不成立。企业用户可能错配(忘了改 / 复制错文件),provider 实时数据反而最准。所以默认按 provider 接近度排序,用户 override 是最后兜底。

none 是什么?

unknown 模型,pricing 完全找不到。CostStatus = unknown,前端显示「价格未知」让用户知道。比报 0 好得多。

PricingEntrysource_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>,类型推断弱。

坏处:

  1. listener 写多了重复代码(OTel forwarder / log forwarder / DB forwarder 都要写)
  2. 没有 OTel 那种丰富的生态工具(Jaeger UI / Grafana 等不能直接吃)
  3. 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。

源码openclaw/src/infra/diagnostic-events.ts

Q5 · 概念:Claude Code 的 /insights 用两次 Opus,源码能证明到哪一步?

/insights 让用户终端看到本地 session 报表。实现:

  1. ~/.claude/projects/<dir>/sessions/*.json
  2. 第一次 Opus call:facet extraction(从 session 文本提取 turn 数 / 总 tokens / 工具调用次数 / 失败率等结构化数据)
  3. 第二次 Opus call:summary(基于 facets 生成自然语言报表)
  4. 终端 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

@dataclass
class 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 OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from 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 均有契约测试。

关键经验:

  1. 不要从 OTel 起步:OTel 复杂、依赖多。先 emit_event 自家模式,需要时再 export 到 OTel
  2. 价格表带 versionPRICING_VERSION = "2026-05-15",方便审计
  3. /insights 早做:dev 自助报表是高价值低成本
  4. 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

  1. 模型生成代码时,每行算一个 fingerprint(不存内容,存 hash)
  2. 用户 accept 之后,记录每行 fingerprint 到 session 数据库
  3. 之后用户编辑文件 / 重构 / 删除时,扫文件每行算 fingerprint,跟历史 fingerprint 对比
  4. 「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 还在不在文件其他位置)。

源码codex/codex-rs/analytics/src/accepted_lines.rs

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 detected

agent 死循环跑同一个命令,没换 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 detected

agent 在 polling 但 git 状态没变化。手动 lookup table 标 is_polling_tool: bash("git status") == true

3. global_circuit_breaker:全局熔断(单 session tool call 总数超阈值)。

session 1: 50 tool calls
session 2: 80 tool calls
session 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 # swallow

asyncio 把发送丢到 event loop,不等结果。

4. OTEL SDK 自带 BatchSpanProcessor

provider.add_span_processor(BatchSpanProcessor(
OTLPSpanExporter(),
max_queue_size=10000,
schedule_delay_millis=5000,
))

OTel SDK 已经做了 batch + async。直接用。

所有方案的共同点:

  1. 失败 swallow:telemetry 出错不应该让 agent crash
  2. 背压策略:queue 满了丢事件,不阻塞 hot path
  3. 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(需要)

@dataclass
class 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 对账记录。

关键决策:

  1. token 4 维起步,不要 total 一个数
  2. pricing 带 version + source_url
  3. emit 跟发送解耦:hot path 只 enqueue
  4. debug 默认 NoOp:测试不污染指标
  5. /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