一个 Claude Code 本地代理,凭啥配了 3 个观测出口?
写 CCM 之前我做了多年 SRE,落下一个改不掉的职业病:一个工具如果不能告诉我它此刻在干什么,我就默认它在骗我。所以 CCM 动工时,观测不是"以后再加的功能",而是和路由内核一起进设计主线的东西。
先给结论。CCM 的观测有三个出口------/metrics、OTLP 推送、usage.jsonl。这份清单不是功能规划堆出来的,三个出口几乎每条设计决策背后,都站着一个 SRE 的老习惯。习惯怎么变成设计,就是这篇的主题。
口径先说清,两套数法差在判据上:
- 底稿口径:/_ccm/* 控制 API+/metrics+OTLP 三出口,另有离线
ccm history查询; - 本篇口径:只数"数据能被系统消费"的出口------/_ccm/* 降为给人看的交互窗口(第七节仍会用到),usage.jsonl 因支撑核算与 advise 升位。
CCM(Claude Code Model Manager)是我用 Rust 写的单二进制本地模型路由代理:把 Claude Code 指向 z.ai、minimax 这类第三方模型网关,中间加装 fallback、熔断与运行时切换。为什么做它,是 75 篇《别再手改环境变量了》的故事。
架构选型到路由内核,归 77---79 篇。本篇只深挖一件事:数据怎么离开这个进程。先划一条边界:观测只在代理模式存在,直连模式是单模型直通、无指标(USAGE §4.1)。
一、先立死规矩:观测不许弄坏主路径
第一个进代码的习惯不是任何指标,是一条不变量:观测绝不缓冲 SSE、绝不阻塞请求(项目事实底稿·架构骨架;DESIGN §19 不变量 6)。代理转发的是流式响应,任何"先攒完再统计"的实现都会把首 token 延迟吃掉------这是那条不变量存在的理由【从业者判断】。
文内我的个人判断均带【从业者判断】标记,其余断言出处随文标注。
这条规矩在三处各落地一次:
- usage 捕获是增量扫描器:字节流原样透传,边流边认出 Anthropic 的 usage 帧,不缓冲整个响应体(USAGE §6.4);
- 历史落盘走有界队列:队列满就丢弃并计数,磁盘 IO 失败也只是停写历史,路由不受影响(USAGE §6.3)------连"丢弃"本身都由
ccm_history_dropped_total这个 counter 专门计量(USAGE §6.5); - OTLP 推送失败不重试,收集器挂掉完全不影响代理路由(USAGE §6.7)。
监控系统的失灵必须可被监控------这是我在 SRE 岗位上学到的第一课【从业者判断】。
观测影响路由还有更隐蔽的第三种形态:被观测绑架------为了指标好看而改变路由行为。
CCM 把这条路也堵死了,不变量交互表里写得明白,OTLP 渲染发生在热路径之外、从既有快照取数(V0.5_PLAN §10:OTLP renders from an existing Snapshot off the hot path)。观测是路由的旁观者,不是参与者。
在这三条管道之前,还有一条零成本的底线:代理运行期间,每次路由尝试往 stderr 打一行日志(USAGE §4.3)------它不在本篇的三出口清单里(那是你自己看的窗口),零依赖、零查询:
text
ccm route=coding-route attempt=1 model=glm result=HTTP 429 action=fallback
ccm route=coding-route attempt=2 model=minimax result=HTTP 200
后面的三个出口,都是在这条底线之上"让数据能被系统消费"的升级。
二、出口一 /metrics:指标长成 Prometheus 认识的样子
/metrics 是 v0.4 M7 落的(v0.4.0,2026-10-03;项目事实底稿·版本线)。
GET http://127.0.0.1:13521/metrics 输出 Prometheus 文本格式(text/plain; version=0.0.4),直接喂给 Prometheus / Grafana / VictoriaMetrics 抓取(USAGE §6.5):
sh
curl -s http://127.0.0.1:13521/metrics | grep ccm_
全部 12 个指标族一律 ccm_ 前缀,挑几个看设计意图(USAGE §6.5):
| 指标 | 类型 / 标签 | 说什么 |
|---|---|---|
ccm_proxy_requests_total |
counter {target,outcome} |
每个请求的最终裁决,outcome 只有 success(被接受的 2xx)和 error 两种 |
ccm_attempts_total |
counter {model,outcome} |
每次上游尝试,五个互斥 outcome:success / http_error / rate_limited / timeout / request_error |
ccm_header_latency_seconds |
histogram {model} |
响应头延迟,固定桶 5ms...10s |
ccm_circuit_open |
gauge {model} |
熔断正在跳过该模型时为 1(OPEN 冷却中或 HALF_OPEN 探测占用中) |
ccm_tokens_total |
counter {model,kind} |
被接受响应上报的 token,kind 四类:input / output / cache_read / cache_write |
ccm_cost_micro_usd_total |
counter {model} |
累计成本,整数微美元;没配定价表的模型不出线------成本未知而非零 |
ccm_up / ccm_process_start_time_seconds |
gauge | 存活与重启告警:在服务即 1;开始监听时间用于算 uptime(USAGE §6.5) |
给一个进程上监控,SRE 的第一个问题永远是"它重启了吗"------这组指标把那个条件反射直接编进导出面【从业者判断】。
ccm_decision_duration_seconds 量的是一次请求从到达到终态裁决的全程耗时,含 fallback 链(USAGE §6.5):单次上游延迟好看没用,用户体感的是整条链。
两个值得咂摸的细节:
- 同端口、仅回环:/metrics 与
/_ccm/*、/v1/messages 共用同一个监听器,没有第二个端口,回环绑定保护对它同样生效(USAGE §6.5)------少开一个端口就少一个暴露面,这是本能不是巧合【从业者判断】; - 双轨延迟里的 EWMA gauge,留给不接 Prometheus 时的快速浏览(USAGE §6.5)。
还有一处容易漏看的收敛:ccm_attempts_total 渲染时从 /_ccm/metrics 同一份每模型计数推导,429 只记进 rate_limited、不重复计入 http_error,两个观测面不会打架(USAGE §6.5)。防打架是本篇反复出现的主旋律,先记住它。
这个 counter 还有一个容易踩的口径:它数的是已结算的尝试,进行中或中途夭折的等待只体现在 /_ccm/metrics 的 attempts 里(USAGE §6.5)。拿指标对上游日志时先问一句"这个数结算到哪一步",能省掉很多无谓的排查【从业者判断】。
重启清零在这里不是缺陷,出口的总开关也很干脆:
- 清零是 counter 语义,Prometheus 靠
increase()/rate()自然处理(USAGE §6.5); prometheus_enabled = false时 /metrics 路由整个不存在,回 404,不是空页面(USAGE §6.3)。
但跨天的账、按会话的账,counter 回答不了------这个伏笔留给第三出口。
三、出口二 OTLP:不是所有人愿意为一台代理跑 Prometheus
/metrics 是拉模型,前提是你得先有个抓取器。
时间线上,/metrics 落在 v0.4、OTLP 落在 v0.5------先服务本机零配置可看,再服务接入已有管道,顺序本身就是取向【从业者判断】。
v0.5 计划文档把痛点写得很直白:An owner running a standard collector pipeline cannot ingest ccm's metrics without standing up a server just for it(V0.5_PLAN §3.5 原文)。
于是 v0.5 M4(2026-10-09 落地;V0.5_PLAN M4 落地记录)加了第二条指标管道:OTLP/HTTP JSON 主动推送。配一个 endpoint,ccm 按周期把指标 POST 给 otelcol、Grafana Alloy、SigNoz 这类收集器,默认接收端口 4318(USAGE §6.7):
toml
[observability.otlp]
endpoint = "http://localhost:4318" # 收集器地址;/v1/metrics 由 ccm 拼接
interval_secs = 30 # 推送间隔,默认 30
配置节缺省即全关,且与 prometheus_enabled 相互独立:只开推送时 /metrics 404、只开抓取时零推送(USAGE §6.7)。实现上,导出器状态看的是"任一出口开着",路由注册只看 prometheus_enabled 一个开关(V0.5_PLAN §3.5)。
最重要的设计约束:同一份状态、两个出口------USAGE §6.7 的说法是"渲染完全相同的 12 个指标族快照",快照组装函数抽出来给两个出口共用,不存在第二份真相(V0.5_PLAN M4 记录)。
SRE 最恨两块仪表盘数字对不上------会打架的监控比没有监控更糟。
这是多年值班攒下的偏见【从业者判断】。
这条管道的参数也不是拍的。M0 外部验证 spike 的四个 gate 之一是摸清 owner 的 pipeline 事实(V0.5_PLAN M0 gate 3):
- 有没有 collector;
- 要不要认证;
- 习惯什么间隔。
结论很诚实:本机没有 collector,4317/4318 都没监听------按默认走 loopback 4318、无认证、30 秒,推送路径等 collector 出现的那天再用(V0.5_PLAN M0 记录)。
两条顺带的纪律:
- 依赖纪律在这里赢了一次:OTLP 是手写的。实测引入 opentelemetry crate 需要 +31 个传递依赖,换一个 12 指标族、纯累计语义的导出器,不值(项目事实底稿·依赖纪律实例;V0.5_PLAN §3.5);新模块 otlp.rs 含测试约 960 行、零新增依赖(V0.5_PLAN M4 记录)。
- 类型纪律:计数器一律 int64(JSON 里按字符串发)、gauge 才用 double(V0.5_PLAN §3.5),所以累计成本被表达成整数微美元------累计语义下浮点误差会滚雪球,用最小货币单位做整数计数是老出纳的手艺【从业者判断】。
失败隔离与凭据边界,逐条都是值班养出来的姿势(USAGE §6.7):
- 每次推送全量重发,失败的 POST 不重试,下个周期自然补齐;
- 推送客户端自带 3s 连接 / 5s 总超时,卡住的收集器不会堆积卡住的任务;
- 收集器认证头只认
OTEL_EXPORTER_OTLP_HEADERS环境变量------永不写入 config.toml、永不打印、永不进负载; service.instance.id是 CCM_HOME 路径的稳定 16 位哈希:多实例推同一收集器可区分,又不泄漏原始路径。
wire 层的直方图按 OTLP 规范输出 11 个秒制桶界 + 12 个桶增量,计数器为 CUMULATIVE 累计语义(USAGE §6.7)。
四、手写规范的代价:一个 200 OK 骗过了第一次验证
手写协议编码的报应来得很快。
M4 落地前跑的多视角对抗验证(12 个 agent)抓到一个 confirmed 的真 bug(V0.5_PLAN M4 记录)。
bug 本身相当精致。OTLP 的 NumberDataPoint 用 oneof value 表达数值,而 protojson 序列化时 oneof 成员是打平在数据点上的;手写实现却把 asInt / asDouble 嵌进了一个 "value" 键下面------10 个数值指标族全部 spec 无效:
text
{"value": {"asInt": "42"}} # 手写错形:oneof 成员被多包了一层 "value" 键
{"asInt": "42"} # 规范形:protojson 把 oneof 成员打平在数据点上
致命的是对端很宽容:otelcol 的 protojson 带 DiscardUnknown,POST 照样回 200。第一次 docker smoke 拿到 200 就以为通了,而且只 grep 了指标族名字,没核对值(V0.5_PLAN M4 记录)。
修复后 smoke 重跑,验收打到值级别:otelcol 0.162.0 的 debug 输出里看到这一行------数字本身到了,才算数(V0.5_PLAN M4 记录)。
text
ccm_proxy_requests_total{target,outcome=error} → Value: 1
验收标准必须落到值。一个宽容的对端加一层名字级检查,能让你带着 spec 违规上线,还一路绿灯。
同类的还有直方图桶界:初稿按 10 个有限界写,事实核查指出 BUCKETS_MS 实际有 11 个------按初稿发出去同样是 spec 无效的 HistogramDataPoint(V0.5_PLAN §7 修正 1)。
另外两条 smoke 实录,比任何评审签字都有说服力:推送周期调成 2 秒、抓取关死,otelcol 收满 12 个指标族的同时 /metrics 一直 404------解耦是活体证明的。
收集器弄死后约 9 次失败推送只换来 stderr 上一条警告,代理全程照常路由,一个 502 误差还老老实实流进了 error 计数器(V0.5_PLAN M4 记录)。
五、出口三 usage.jsonl:账本和指标是两种东西
第三个习惯我认为最值钱【从业者判断】:counter 是给告警的,账本是给核算的,混用必翻车 。ccm_cost_micro_usd_total 能告诉你"今天在烧钱",但它是聚合值、重启归零,回答不了"哪个会话、哪个模型、按改价前的哪个单价烧的"。
所以 v0.4 M6 落了第三条管道:代理在每个被接受的 2xx 响应体外包一层增量扫描器,流结束时往 usage.jsonl 落一条记录,decision_id 关联到 decisions.jsonl 里的当条路由决策(USAGE §6.4)。
账按模型别名键,client 会话 id 有就带上------按会话算成本是免费的(USAGE §6.4)。
记账的诚实度全在细节里(USAGE §6.4):
- token 四类从流式帧逐字段合并、后到者覆盖:message_start 带输入与缓存 token,message_delta 带输出 token;翻译流(openai-compatible)的 message_start 是诚实的零占位,真实数字随最后一个 delta 到达;
- 单价快照随记录落盘,改价不影响已记的账(USAGE §5.3);
- 没配定价表的模型 cost_usd 记 null,绝不猜数;聚合视图单列"无价请求数"------成本未知不等于零;
- 客户端断开、传输错误、上游在 200 流里发 error 帧,记 complete:false------token 可能是半截的,下游折算必须剔除;
- 扫描有界:单行超 64 KiB 整行丢弃重新同步,非流式 JSON 超 16 MiB 按 0 token 诚实记账,不无限耗内存。
文件层的规矩(项目事实底稿·数据文件;USAGE §6.3):
- history/ 下四类 JSONL:decisions / metrics / circuit / usage;
- 轮转:单文件满 5 万条或 8MiB;
- 保留:默认 14 天;
- 快照:metrics.jsonl 默认每 30 秒一条全模型快照,原始计数落盘、比率读取时再算(USAGE §6.3)。别把它数成第四管道:落盘的是既有指标状态的耐久化拷贝,消费方是
ccm history metrics这类离线查询------属于你自己看的窗口那一类,不是数据被系统消费的新出口。
账本的耐久性扛住了两个 SRE 式的悲观假设。其一,进程会被强杀:代理正常退出时排空队列、落最后一拨记录;被 Ctrl+C 干掉则依赖周期性写入,崩溃残留的半行读取时自动跳过(USAGE §6.3)。
其二,账不能有洞:decision id 跨重启连续,启动时从磁盘尾部恢复(USAGE §6.3)------counter 的世界里"重启清零"是正常语义,账本的世界里它是事故。
落盘内容是白名单 serde 结构,测试断言记录里不含任何凭据子串(USAGE §6.3)。凭据永不入文件这条不变量,连观测数据也要过一遍。
查询两侧都有,代理停着也能查账(USAGE §6.4):
sh
# 离线:2026-10-03 按模型聚合的花费
ccm history cost --day 2026-10-03
powershell
# 在线:只看 term1 这个会话今天的账
Invoke-RestMethod "http://127.0.0.1:13521/_ccm/cost?client=term1"
/_ccm/usage 是逐条记录视图,过滤参数与 /_ccm/decisions 完全一致------按会话、按时间窗、按模型切账,两套记录同一个切法(USAGE §6.4)。聚合视图里还有个我很喜欢的细节:合计之外单列"无价请求数",没配定价表的模型成本未知而非零,合计不会虚报(USAGE §6.4)。
这本账的下游是 v0.5 的 ccm advise:从已落盘的真实用量重算每模型 cost_weight 建议,只打印、绝不写配置(项目事实底稿·工具链)。先有账本、后有建议------顺序反过来就是拍脑袋。
六、诚实边界:先写清楚数字不知道什么
三个出口共享同一句边界声明,逐字是:这是代理侧计量,不是账单真相(USAGE §6.4)。token 数来自上游上报,单价是手填的,只能用于相对比较与异常发现,不能对账。拿它对账单的人会得出"网关多收了钱"的结论,而真相是两边口径根本不同。
另一条更冷门:count_tokens 转发流量对所有指标族不可见------SRE 面板会低估请求数,且低于上游账单(USAGE §6.7;V0.5_PLAN §3.3)。
这是 v0.5 定下的零副作用契约:count 不进决策、不进指标、不进账本(V0.5_PLAN §3.3)。口径上这道缝是设计选择,文档把它写死,而不是假装没有。
账本的口径还有两处容易误读:
- 终态 429/5xx 透传不产生使用量记录,只有被接受的 2xx 才有账(USAGE §6.4);
- usage 只记实际被路由到的流量,路由从没选过的模型在这里没有数据------这是选择偏差,advise 的每份报告都会原样打印这条(USAGE §6.6)。
我的立场:观测系统最重要的输出不是数字,是数字的适用范围。告警语义不明比没有告警更危险------这句话我在值班室付过学费【从业者判断】。
七、反方观点:三个出口是不是过度设计?
评论区一定有人问:一个跑在本机的代理,配三条数据管道,是不是工程师自嗨?分三层回应。
第一,三个出口对应三种真实消费姿势:
- 临时诊断:代理抽风时先敲
/_ccm/*或 curl /metrics 看谁在熔断------前者是交互窗口、后者是可抓取的存量; - 接入:家里已跑着 otelcol / Grafana Alloy 的人,两行配置并入大盘;
- 核算:月底复盘哪个会话烧的 token,查 usage.jsonl。
砍掉任何一个,对应的人群就只能盲跑【从业者判断】。
第二,边际成本被刻意压到很低:两个指标出口共享同一份快照,多的只是一次渲染;OTLP 节缺省即全关,关闭时路由不注册、任务不启动(USAGE §6.7;V0.5_PLAN §3.5)。你不用它,它就不存在。
第三,看没做什么比看做了什么更能说明克制。明确不做的(V0.5_PLAN §3.5 / §4 非目标):
- OTLP traces------需要新的持久化字段和带真正丢弃语义的队列,且与 decisions.jsonl 已有内容重复
- exemplars、delta 时间语义、protobuf 编码------JSON 在同一个 collector 端口上是一等公民
- 每请求一条决策记录已经落盘,再造一份 trace 导出属于数据搬运
还有一个更狠的版本:owner 自己机器上根本没有 collector------如第三节所述,M0 外部验证时 4317/4318 都没开,这出口算不算 YAGNI?
我的回答:算不算浪费,看它有没有被真实验证过、不用时付不付费。验收用 docker 里真实的 otelcol 0.162.0 打到值级别完成;默认关、零新增依赖、零后台任务。你可以质疑它的优先级排位,但"没人用所以没验证"这个指控不成立【从业者判断】。工具的诚实是设计出来的,不是文档最后补出来的。
八、现在就能做的事
如果你在写自己的本地工具,拿这三个问题过一遍,30 秒:
- 观测挂了,主路径还能跑吗?(CCM 的答案:队列满丢弃并计数,路由不受影响)
- 你的两块仪表盘数字对得上吗?(CCM 的答案:一份快照,两次渲染)
- 你的成本数字能回答"哪个会话烧的"吗?(CCM 的答案:usage.jsonl 按会话记账,无价记 null)
三问有一个过不掉的,先别急着写 exporter,把"挂了不影响主路径"做了再说。
代理正在跑的话,现在就能看:
sh
# 12 个指标族长什么样
curl -s http://127.0.0.1:13521/metrics | grep ccm_
CCM 在 github.com/Thneoly/ccm(MIT),观测一节对应 docs/USAGE.md §6。这个项目有个偏执的习惯:文档即契约,每个声明逐一与行为核对(项目事实底稿·工程纪律)。
下一篇 81《一个人一支 AI 队伍,4 条规矩管住 Claude Code 路由器》,讲这整套东西是怎么被里程碑和门禁管出来的。对抗验证的驳回记录为什么值钱,82 篇细说。