如果你们团队最近开始用 Claude Code,却没人能回答“这个月 AI 编程到底花了多少钱、花在谁身上”,那这篇文章值得读完。Hacker News 上出现了一个叫 Aimeterly 的项目,标题很直白:Per-user cost dashboards from Claude Code's built-in OTel。它的做法是把 Claude Code 内置的 OpenTelemetry 遥测数据接上成本计算,变成一张按用户拆分的成本看板。
这个方向比“又一个 AI Agent 工具介绍”重要得多。AI 编程助手不是传统软件那样买断或订阅后成本固定,而是每一次对话、每一次自动补全、每一次测试修复都在消耗 token。当这种消耗从一个人变成十个人、一个部门时,成本就从一个可忽略的数字,变成了必须向财务解释的严肃问题。真正值钱的不是某个看板的界面,而是 Claude Code 把全链路遥测数据以标准协议暴露出来这件事本身。
本文不打算只做项目速览,而是把 Aimeterly 这类工具的底层链路完整拆开:OTel 是什么、Claude Code 内置遥测为什么是分水岭、如何从一条 span 推导到按用户成本,以及如何用几十行 Python 自己跑通一个最小 Cost Dashboard。读完你得到的不是一个依赖特定项目的结论,而是一套可以立刻评估和复用的工程思路。
1. AI 编程 Agent 的成本为什么需要“按用户”统计
先想一个问题:传统开发工具的成本是相对透明的。IDE 许可证多少钱、服务器多少钱、数据库多少钱,采购单上写得很清楚。但 AI 编程 Agent 的成本模型完全不同,它没有“按年付费”,而是“按 token 消耗付费”。token 消耗又和每个人的使用习惯强相关:有人只在写复杂算法时打开 Agent,有人把日常所有小改动都喂给模型,还有人习惯让模型反复重写同一个文件。结果是,同岗位、同级别的人,对预算的消耗可能差出数倍。
没有按用户维度的数据时,团队只能面对几类典型困境。第一类是月底账单来了,财务问“这个月为什么多了几万美元”,技术负责人回答不上来。第二类是想要做预算管控,但不知道限制谁、限制什么维度,只能一刀切降低所有人使用频率,反而压制了合理的使用。第三类是想评估“AI 编程工具到底值不值”,但缺少“谁在用、用了多少、产出什么”的基础统计,ROI 论证只能靠感觉。
按用户成本看板解决的不是“记账”这个小问题,它解决的是 AI 编程工具从“个人尝鲜”走向“团队基础设施”过程中最尴尬的断档。只有先建立“成本可解释”这层地基,后续的预算、配额、告警、模型路由优化才有依据。Aimeterly 选择的切入点是准确的:Claude Code 本身已经通过内置 OTel 把结构化数据做出来了,缺的只是把数据变成业务可读的成本语言。
这也引出本文的核心判断:AI 编程工具的使用成本不是靠行政命令管出来的,而是靠数据量化出来、再反馈到决策流程里。按用户统计是第一步,也是最关键的一步。
2. 核心概念:Claude Code 内置 OTel 到底给了我们什么
OpenTelemetry 是目前可观测性领域事实上的标准,它统一了 Trace、Metric、Log 三类遥测数据的产生、传输和采集方式。你不需要看它的完整规范,只要理解它的几个核心概念:Service 代表一个服务,Trace 代表一次完整请求或会话,Span 代表其中的一个操作单元,Attribute 是挂在 Span 或 Resource 上的键值对属性。发给后端的协议叫 OTLP(OpenTelemetry Protocol),采集端一般用 OpenTelemetry Collector 接收再转发到存储与分析平台。
Claude Code 内置 OTel 的意义在于,它把“一次 AI 编程会话”这种过去很难观测的对象,变成了标准的 Trace 数据。一次会话里,模型调用、工具执行、文件读写、用户交互,都可能以 Span 的形式出现,并且附带了 token、model、耗时等关键属性。过去你想知道一次对话用了多少 token,只能靠肉眼读客户端日志,或者用正则表达式去匹配 stdout,这是脆弱且不可维护的。现在数据以标准协议流出来,你接一个 Collector 就能开始分析。
要特别提醒的是,Claude Code 不同版本的 OTel 导出开关、默认字段、启用方式可能存在差异。本文给出的环境变量基于 OTel 标准,具体是否能直接被 Claude Code 识别,请以你当前版本的官方文档为准。一个稳妥的做法是:先启动本地 Collector,再开启 Claude Code,观察后端是否收到数据,收到了就说明当前版本支持,收不到再查文档,这样比背某个历史配置命令可靠得多。
我整理了一个传统日志方式与 OTel 方式的对比,方便你理解为什么这个变化是架构级的。
| 维度 | 传统日志解析 | Claude Code 内置 OTel |
|---|---|---|
| 数据来源 | 抓取客户端 stdout、解析文本 | 应用内部产生的标准化 Trace 数据 |
| 可靠性 | 格式一改就失效,无法覆盖所有输出 | 结构化字段,语义稳定,不易被输出文本影响 |
| 会话关联 | 缺少唯一 Trace ID,难以还原完整会话 | 天然有 Trace ID / Span ID,可还原调用链 |
| 成本属性 | 往往拿不到 token 和模型信息 | Span Attribute 中可以携带模型、token 等属性 |
| 接入成本 | 需要维护解析脚本,随版本不断修 | 接入标准 OTLP Collector,一次配置即可 |
3. 从一条 Span 到一张按用户成本看板
现在我们把链路完整拉通。假设你已经开启 Claude Code 的 OTel 导出,数据的流向是这样的:Claude Code 进程产生 Trace,通过 OTLP 协议发送到 OpenTelemetry Collector,Collector 负责接收、校验、批量处理和转发,然后写入文件、本地数据库或云上可观测平台。之后你写一个聚合服务读取这些数据,把“用户”“模型”“token 用量”“单价”组合起来,最终渲染成一张看板。
这条链路里最需要理解的是 Span 的角色。一个 Span 可以是一次模型调用,也可以是一次工具执行。它记录了该操作在什么时候开始、什么时候结束、调用了什么模型、输入和输出各有多少 token、这个 span 归属于哪个会话。只要这些字段里能够识别出“用户”维度,成本就可以按用户汇总。
用户维度的识别是实现中最容易出问题的地方。Claude Code 的 OTel 导出并不保证一定有一个统一叫user_id的字段,它可能藏在 Session 属性里,也可能需要从组织名、工作区名、API Key 或客户端账号信息中推导。Aimeterly 这类项目本质上做了一件重要工作:把不同来源、不同命名的用户字段归一化到统一的用户模型上。自己实现时,建议也建一层“字段映射”,在数据入口就把原始属性转换成user_id、organization、model、input_tokens、output_tokens这几个标准字段。
成本计算本身不复杂,核心公式是:
估算成本 = 输入 token 数 / 1,000,000 × 模型输入单价 + 输出 token 数 / 1,000,000 × 模型输出单价不同模型的输入输出单价不同,这个配置必须外置,不能硬编码在代码里。真正的工程难点在于几个副问题:如果某个 Span 缺少用户字段怎么归档到 unknown;同一个用户可能在不同 Session 里使用不同模型怎么展示;价格表更新后历史数据要不要重算。这些问题虽然琐碎,但决定了看板能不能被业务团队真正信任。
4. 环境准备:先搭一个能接收 OTLP 数据的最小后端
要跑通完整链路,第一步是准备一个能接收 OTLP 数据的后端。不需要一开始就上云上平台,本地用 OpenTelemetry Collector 就可以。它既能接收 Claude Code 发来的数据,也能把数据写入本地文件,非常适合调试。
建议的目录结构如下:
claude-code-cost-demo/ ├── config/ │ └── otel-collector.yaml ├── mock_otel_traces.json ├── cost_dashboard.py └── output/ └── dashboard.htmlOpenTelemetry Collector 的配置文件重点是接收器和导出器。下面这个配置同时开启了 gRPC 和 HTTP 两种 OTLP 接收方式,并配置了 debug 导出器和 file 导出器,前者方便在控制台实时看到数据,后者把原始数据落盘供后续分析。
# 文件路径:config/otel-collector.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s exporters: debug: verbosity: basic file: path: ./traces.json rotation: max_megabytes: 100 max_days: 7 service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [debug, file]启动 Collector 有两种常见方式。如果是二进制方式,直接执行:
otelcol --config config/otel-collector.yaml如果使用 Docker,推荐使用 contrib 镜像,因为 file exporter 通常在 contrib 版本中自带:
docker run --rm \ -v "$PWD/config":/etc/otel/config.yaml \ -p 4317:4317 -p 4318:4318 \ otel/opentelemetry-collector-contrib \ --config /etc/otel/config.yaml启动后,控制台会进入等待状态。接下来你要做的,是在 Claude Code 运行环境中配置 OTLP 导出地址。下面是一组基于 OTel 标准的环境变量,适合大多数支持标准协议导出的客户端,具体是否能被 Claude Code 识别,请以当前版本官方文档为准。
export OTEL_SERVICE_NAME=claude-code export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_METRICS_EXPORTER=otlp claude这里使用http/protobuf协议时,Collector 的 HTTP 接收端口 4318 就是目标地址。如果使用 gRPC 协议,则通常指向 4317。配置完成后,随便让 Claude Code 执行一个简单任务,然后回到 Collector 的 debug exporter 输出,如果能看见span相关日志,说明链路已经通了一半。
不要忽视一个风险点:OTel Trace 数据很可能包含你输入给模型的完整代码片段、Prompt 甚至业务数据。在本地调试没问题,但一旦要接入云端可观测平台,务必确认脱敏、加密和访问控制策略。成本数据同样是敏感数据,不能随意共享。
5. 完整示例:Python 实现最小按用户成本看板
在没有真实 Claude Code 数据时,可以用一份模拟的 Span 数据先跑通聚合逻辑。我将给出三部分内容:模拟数据、Python 解析脚本、HTML 看板生成。先看模拟数据文件,它模拟了多个用户的会话记录,字段名和真实 OTel 导出可能存在差异,这里仅演示结构。
[ { "trace_id": "trace-1001", "span_id": "span-1001-1", "service": "claude-code", "timestamp": "2025-06-01T09:10:00Z", "attributes": { "user_id": "zhangsan", "organization": "platform", "session_id": "session-001", "model": "model-a", "input_tokens": 32000, "output_tokens": 4100 } }, { "trace_id": "trace-1001", "span_id": "span-1001-2", "service": "claude-code", "timestamp": "2025-06-01T09:15:00Z", "attributes": { "user_id": "zhangsan", "organization": "platform", "session_id": "session-002", "model": "model-a", "input_tokens": 15000, "output_tokens": 3000 } }, { "trace_id": "trace-1002", "span_id": "span-1002-1", "service": "claude-code", "timestamp": "2025-06-01T10:00:00Z", "attributes": { "user_id": "lisi", "organization": "search", "session_id": "session-003", "model": "model-b", "input_tokens": 28000, "output_tokens": 5200 } } ]接下来是核心脚本。它读入 Span 数组,按用户维度聚合会话数、Token 总量、调用次数和估算成本,最后渲染一个自包含的 HTML 页面。脚本中所有价格都是示例,你需要按实际模型单价维护一份配置。
#!/usr/bin/env python3 """ cost_dashboard.py - 将 OTel 导出的 Span 数据按用户维度聚合为成本看板。 用法:python cost_dashboard.py --input mock_otel_traces.json --output dashboard.html """ import argparse import json from collections import defaultdict from datetime import datetime # 模型单价配置:实际使用时请从 YAML、配置中心或环境变量注入 PRICE_PER_MILLION = { "model-a": {"input": 3.0, "output": 15.0}, "model-b": {"input": 2.5, "output": 12.0}, } DEFAULT_PRICE = {"input": 3.0, "output": 15.0} def load_spans(path: str) -> list: with open(path, "r", encoding="utf-8") as f: return json.load(f) def cost_for(model: str, input_tokens: int, output_tokens: int) -> float: price = PRICE_PER_MILLION.get(model, DEFAULT_PRICE) input_cost = input_tokens / 1_000_000 * price["input"] output_cost = output_tokens / 1_000_000 * price["output"] return input_cost + output_cost def aggregate(spans: list) -> dict: users = defaultdict(lambda: { "sessions": set(), "spans": 0, "input_tokens": 0, "output_tokens": 0, "cost": 0.0, "models": defaultdict(int), }) for span in spans: attrs = span.get("attributes", {}) user = attrs.get("user_id", "unknown") model = attrs.get("model", "unknown") input_tokens = int(attrs.get("input_tokens", 0)) output_tokens = int(attrs.get("output_tokens", 0)) session = attrs.get("session_id", "unknown") u = users[user] u["sessions"].add(session) u["spans"] += 1 u["input_tokens"] += input_tokens u["output_tokens"] += output_tokens u["cost"] += cost_for(model, input_tokens, output_tokens) u["models"][model] += 1 return users def render_html(users: dict) -> str: rows = [] total_cost = 0.0 for user, data in sorted(users.items(), key=lambda item: item[1]["cost"], reverse=True): total_cost += data["cost"] model_summary = ", ".join( f"{model}({count})" for model, count in data["models"].items() ) rows.append(f"""<tr> <td>{user}</td> <td>{len(data["sessions"])}</td> <td>{data["spans"]}</td> <td>{data["input_tokens"]:,}</td> <td>{data["output_tokens"]:,}</td> <td>{data["cost"]:.2f}</td> <td>{model_summary}</td> </tr>""") return f"""<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>Claude Code 按用户成本看板</title> <style> body {{ font-family: Arial, "PingFang SC", sans-serif; margin: 40px auto; max-width: 960px; }} table {{ border-collapse: collapse; width: 100%; }} th, td {{ border: 1px solid #ddd; padding: 8px; text-align: right; }} th {{ background: #f5f5f5; }} tr:hover {{ background: #fafafa; }} </style> </head> <body> <h1>Claude Code 按用户成本看板</h1> <p>生成时间:{datetime.now().isoformat(timespec="seconds")}</p> <table> <thead><tr><th>用户</th><th>会话数</th><th>Span 数</th><th>输入 Token</th><th>输出 Token</th><th>估算成本(USD)</th><th>模型分布</th></tr></thead> <tbody> {''.join(rows)} </tbody> </table> <h2>合计:${total_cost:.2f}</h2> </body> </html>""" def main() -> None: parser = argparse.ArgumentParser(description="Claude Code 按用户成本看板") parser.add_argument("--input", default="mock_otel_traces.json") parser.add_argument("--output", default="dashboard.html") args = parser.parse_args() spans = load_spans(args.input) users = aggregate(spans) html = render_html(users) with open(args.output, "w", encoding="utf-8") as f: f.write(html) total = sum(u["cost"] for u in users.values()) print(f"已生成 {args.output}") print(f"合计成本:${total:.2f}") if __name__ == "__main__": main()这段脚本里最值得注意的两个逻辑点是:成本计算函数cost_for做了输入和输出的分别计价,因为大模型 API 的输入输出价格往往差异很大;聚合函数aggregate使用defaultdict自动处理未出现过的用户,避免空值报错。unknown用户被独立列出,这是有意的设计,因为 unknown 比例过高通常说明字段映射有问题。
运行方式是:
python cost_dashboard.py --input mock_otel_traces.json --output dashboard.html运行成功后,脚本会在终端打印生成的文件名和合计成本,同时生成一个可直接在浏览器打开的 HTML 文件。这个示例离一个生产级看板还有距离,但核心链路已经完全跑通:接收结构化数据、归一化用户维度、按模型计算成本、输出结果。
6. 运行结果与效果验证
用上面提供的模拟数据运行后,预期的聚合结果应该是这样的。
| 用户 | 会话数 | Span 数 | 输入 Token | 输出 Token | 估算成本(USD) | 模型分布 |
|---|---|---|---|---|---|---|
| zhangsan | 2 | 2 | 47,000 | 7,100 | 0.25 | model-a(2) |
| lisi | 1 | 1 | 28,000 | 5,200 | 0.13 | model-b(1) |
合计成本约为 0.38 美元。你可以用这个结果验证脚本是否计算正确。第一,检查 Token 数字是否和输入 JSON 一致;第二,核对乘法计算是否精确;第三,观察用户归类是否正确。如果每个用户的会话数和 Expect 不匹配,优先检查 Span 中重复的 trace_id 和 session_id。
验证通过后,建议做两个额外测试来模拟真实环境。第一个是有缺失字段的数据,把某个 Span 的user_id删掉,确认它能进入 unknown 分组且不报错。第二个是模型单价缺失的情况,把model-a从价格配置里删掉,确认脚本会使用DEFAULT_PRICE兜底。这两个测试都通过,说明脚本对真实数据的鲁棒性足够。
在真实 Claude Code 场景中,效果验证的核心标准不是“有没有数据”,而是“数据是否可信”。可以比对看板统计的 Token 总量与模型网关后台的用量明细,误差应该在合理范围内。如果误差较大,优先排查是否有多个导出通道重复计费,或者 Collector 的 file exporter 和多级转发之间是否存在重复写盘。
7. 常见问题与排查思路
在做这套成本采集链路时,团队最容易踩的问题集中在数据接收、字段解析、费用计算三个方面。这里我整理了一张排查表,基本覆盖了从部署到使用的常见故障。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Collector 收不到任何 Trace | OTLP endpoint 地址或协议配置错误 | 查看 Collector debug exporter 日志,确认监听端口是否正常 | 核对 endpoint 和 protocol,http/protobuf 走 4318,gRPC 走 4317 |
| Claude Code 启动后没有导出行为 | 当前版本未开启 OTel 支持或开关不同 | 查阅当前版本官方文档,确认遥测开启方式 | 按官方配置开启;验证后再进入后续步骤 |
| 数据里没有 user_id 字段 | Claude Code 导出字段名与模拟结构不一致 | 导出一段原始 Trace JSON,检查属性命名 | 在脚本入口加字段映射,统一转换到标准字段 |
| 成本与模型网关后台账单不一致 | 价格配置过时或存在额外计费项 | 对比网关明细与看板 Token 总量 | 将价格表外置,设置定期更新,定期核对 Token 总量 |
| 同一用户被拆成多个条目 | 用户字段在不同会话中命名不统一 | 检查原始数据里的账号与组织字段 | 建立用户别名映射,以统一业务标识为准 |
| 数据量快速膨胀 | 全量 Trace 长期保留,缺少采样与清理 | 查看 Collector file exporter 轮转配置 | 开启按需采样、设置保留天数、定时清理历史文件 |
另一个值得注意的坑是模型接入方式。如果团队把 Claude Code 接入了第三方网关、代理或本地模型服务,那么“实际费用”发生在网关或模型服务端,而不是 Claude Code 的 OTel 输出中。此时 Claude Code 侧导出的 Token 数据适合做行为分析,但费用计算应以网关侧账单为准。两者要配合使用,不能互相代替。
8. 最佳实践与工程建议
基于上面的实现和踩坑经验,我把生产环境落地时需要重点关注的工程问题总结为五点。
第一,用户维度归一化要提前设计。不要假设 OTel 数据里天然有一个干净的 user 字段。团队通常有企业 SSO、个人账号、临时账号、共享账号等不同登录方式,建议在数据入口维护一份“登录身份 → 业务用户”的映射表,并且把映射规则集中管理。否则看板做得再漂亮,用户字段对不上,业务方也无法信任。
第二,价格配置必须外置且可回溯。模型价格会调整,不同模型、不同计费模式(按量、包周期、折扣)差异很大。建议把价格表放在 YAML 或配置中心里,每次更新都留版本记录。看板上最好展示“估算成本”而不是“绝对账单”,并明确说明这是基于 token 量的估算,最终应以服务商账单为准。
第三,Trace 数据中包含敏感信息。Span 的属性、事件详情,甚至某些嵌套资源可能包含代码片段、Prompt 原文、文件路径,这些都是企业内部敏感数据。接入云上可观测平台前,建议在 Collector 侧做脱敏处理,比如删除指定 attribute、对长文本字段做 hash 或截断。成本看板本身也要做权限控制,避免所有员工都能看到全公司每个人的 AI 消耗明细。
第四,合理的采样与保留策略比保留全量更重要。AI 编程 Agent 的并发高峰期,Trace 量可能非常大。建议在 Collector 层配置采样策略:普通会话按 10% 采样保留,对超长会话或高 token 会话全量保留,这样既能评估成本趋势,又不会让存储成本失控。历史数据保留周期建议按团队复盘节奏设置,常见的是 30 天明细加 12 个月聚合。
第五,从“看板”走到“动作”。成本看板的最终价值是帮助团队回答三个问题:谁在用、成本结构是否合理、怎样优化。建议在看板稳定之后,接入按用户预算告警,比如某用户单日成本超过阈值时通知管理员;再进一步,可以根据 Token 消耗和 Session 时长,识别出“高消耗低产出”的会话模式,推动团队优化 Prompt 或调整模型路由策略。
9. 总结与后续学习方向
这篇文章真正讲清楚的事情是:Claude Code 这类 AI 编程 Agent 的成本,本质上是一类新的可观测性问题。它不能靠“月底账单 + 人工估算”来管理,而应该靠 OpenTelemetry 带来的结构化数据,建立从 Trace 到 Span、从字段到用户、从量到成本的完整链路。Aimeterly 这个项目是否成熟、是否开源并不影响你理解这个方向,重要的是它的出现说明:AI 编程成本已经成为一个值得被工具化的工程领域。
你可以按本文给出的最小链路先跑一遍:本地启动 OpenTelemetry Collector,配置 Claude Code 的 OTLP 导出,用一份模拟数据验证 Python 脚本,再逐步替换为真实数据。跑通之后,值得继续深入的方向有几个:一是研究 OTel 语义约定,理解 attribute 命名如何影响聚合规则;二是把看板接入 Prometheus 和 Grafana,变成随时可查的监控面板;三是把同一套思路扩展到 Codex、Cursor 等其它 AI 编程工具,做一个统一的“多工具 AI 成本控制台”。
从成本可见到成本可控,中间差的不是预算额度,而是数据管道。Claude Code 已经把最难的一步做完了,剩下的事情,值得每一个把 AI 编程当作团队正式工具的工程负责人认真投入。