Repository navigation
模型账单月底出来,没人说得清哪个部门花的:我们两次做错的过程 #22
01men
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
模型账单月底出来,没人说得清哪个部门花的
月底,财务把模型 API 的账单发到群里。
一共四个部门在用。谁花了多少?没人答得上来。
账单上只有三样东西:API key 的尾号、模型名、金额。而 key 是三个月前某个人为了方便,一次性申请的一个"全公司共用"的 key。于是这张账单在技术上是完整的,在管理上是废纸。
我们做过一个内部统计:在没有归口设计的团队里,能一句话答出"上月哪个部门 AI 花得最多"的比例,比想象中低得多。多数人的答案是"大概研发多一点吧"。
这篇文章讲我们把这个数字变得能答得上来的过程,包括中间做错的两次。
一、事后归因注定失败,因为账本里没有组织
第一反应通常是加一层报表:把账单拉下来,按 key 分组,人工映射到部门。
这个方案在真实环境里活不过一个月,原因有三个,每一个都是结构性的:
key 的粒度不等于组织的粒度。 一个 key 可能被三个团队共用,也可能一个团队申请了七个 key。按 key 分组,你得到的是 key 的分布,不是部门的分布。
组织会变,key 不会。 三月成立的"AI 创新小组"六月并入了产品部。账单还在按老 key 分,报表跟着错。你没法回溯改历史,因为 key 和组织的映射关系没有生效时间。
最贵的那部分常常没有 key。 通过网关、MCP 服务、批处理任务产生的消耗,走的是服务间凭证而不是人手上的 key。
所以正确的顺序是反过来的:归口字段必须在消耗发生的那一刻就写下来,而不是月底再去猜。
我们的做法是让计量事件必须带一个
org字段,而且它是硬校验:报错不静默降级,直接抛。
这条看起来很粗暴,代价也真实:任何接入计量的代码(模型网关、MCP 网关、连接器、NAS、A2A 协作链)都必须先知道自己在为谁干活。有些调用点在早期根本拿不到组织信息——那时我们的选择是让调用失败,而不是写一条
org=''的事件进去。因为我们算过:一条 org 为空的事件,事后要修复,需要的人力和争议,远大于让代码在开发期就报错。
二、三个维度不是三个"分组字段",是三种归口语义
最容易做错的地方在这里。你以为"按部门、按 Agent、按 Skill 统计"是三次
GROUP BY,其实是三种完全不同的语义。我们的计量事件 schema 里有这么几个字段:
orgorg_xxxsubjectagent:agt_xxx/user:u_xxxprincipalorg:xxx/plugin:xxx/app:xxxresourcemodel:qwen-plus/mcp:xxx/skill:xxx关键点:
subject是"谁在用",org是"记在谁头上",两者可以不一致。一个销售部的 Agent,被市场部的人调用,成本记销售部还是市场部?答案取决于你的管理口径,而不是数据结构。数据结构的责任是把两个事实都存下来,让口径可以随时切换;而不是提前替业务做决定。
所以报表里我们先冻结了三个维度,并且写进契约文档(
docs/contract-j4-usage-report.md,冻结日期 2026-09-09):orgsubject且前缀为agent:resource且前缀为skill:三个维度都统计 tokens,但 tokens 的取法是"计量键名含
tokens的米值求和"——覆盖tokens、input_tokens、output_tokens,同一事件多键累加,calls这类非 tokens 米不参与。为什么用"键名含 tokens"而不是写死三个键名?因为计量字典是可扩展的。以后接进来一个按"缓存命中 token"计费的服务商,键名是
cached_tokens,报表不用改代码就能算对。这个决定的代价是:如果有人把计量键叫
tokens_estimate(估算值),它也会被算进真实用量。我们接受这个风险,因为键名规范在写入侧是有约束的(^[a-z][a-z0-9_.]*$),估算值不该进入计量管道。三、重复计费比漏计更糟,所以先落库再分发
计量管道有一条容易被忽略的要求:at-least-once 投递 + 消费端幂等。
我们不追求"恰好一次"。分布式系统里追求恰好一次,通常意味着要么引入两阶段提交(慢且脆),要么在某个环节静默丢数据(更糟)。
我们选的是:先写 SQLite 落库,再从总线分发。 宕机时最多丢"分发"这一步,事件本身在库里;重放一遍即可,不会重复计数。
防重复靠幂等键,由调用方自带:
幂等键在引擎层是唯一索引,重复投递不会重复计数。消费端失败重试 3 次进死信集合,可以按时间窗重放——重放同样不会双计。
这里有个反直觉的取舍:幂等键由产生方生成,而不是由计量管道统一生成。 统一生成看起来更整洁,但它会让"同一笔业务被两个组件各报一次"变成两条独立事件,永远合并不了。让产生方负责,才能表达"这就是同一件事"。
代价是:产生方如果拿错 id(比如用毫秒时间戳拼了一个 id),幂等就失效。我们接受这个代价,因为在我们的场景里,产生方手里一定有业务单号,而计量管道永远不会有。
四、为什么我们的"应收金额"恒等于 0
这一条很多人不理解,包括我们自己内部也吵过。
报表里有一列叫
charge_cents(应收金额),它恒为 0。另一列cost_cents(内部成本参考)才有真实数字。原因是商业模式:平台不转售 token、不分账。价格簿的 list 侧统一零价快照,
charge_cents恒 0;cost 侧按采购成本折算,只用于内部成本穿透,不对任何人呈现为"账单"。为什么保留一个恒为 0 的列,而不是干脆删掉?
因为它定义了接口契约的形状。删掉列容易,将来想加回来,所有消费方都要改。留着它,并且在文档里明确写"零价快照恒 0",语义是清晰的:这一列存在,但它不承载结算含义。
配套的还有一条硬校验,防止口径漂移:
意思是你不能"标了非计费,却按计费规则入账"。零费率规则产出的事件自动带上
rate.nonbillable=true,报表里单独统计nonbillable_events列——反馈、知识包使用这类观测事件在这里,不污染成本口径。在 demo 实例上,本月 364 条计量事件里有 262 条是非计费事件。如果这两类混在一起,成本数字会完全失真。
五、第一次做错:交叉矩阵的总计对不上
FinOps 视图里有个部门 × 模型的交叉矩阵,用来回答"哪个部门在哪个模型上花最多"。
第一版做完,矩阵所有格子的和,比全口径成本小了一个数量级。
原因是我们把"其他"兜底行算错了。当时的代码逻辑是:
问题在于,"其他"行的值我们最初用该部门的全资源成本减去 Top 模型直角和。而矩阵其他部分只统计
model:域的成本。于是一个部门如果还有大量 MCP 调用成本(mcp:前缀),这部分成本会被算进"其他"行,但矩阵的直角区域又没有对应的格子——矩阵总计就混入了非模型成本,和 byModel 的合计对不上。修正后的注释写得很直白:
这条不是一个聪明的问题,是一个必须靠断言盯住的问题。我们在 selftest 里加了不变量断言:矩阵所有格子之和必须等于 byModel 合计,而不是全口径合计。
这类"看起来能对上但其实口径混了"的错误,测试功能是发现不了的,只能靠定义不变量。
六、第二次做错:脏费率把成本台账写成了 NaN
价格簿是可运营配置的。运营在后台改费率,如果误填了一个非数值,或者
units_per_step填成 0,计价就会算出NaN或Infinity。第一版我们只在写入侧校验。结果出现了历史脏数据:已经入账的事件里
cost_cents是null/非有限值,读侧一求和,整个月的成本就变成NaN。NaN最坏的地方是它会传播:任何一个SUM里有NaN,结果是NaN;图表渲染成空白;断言比较NaN === NaN为 false,测试直接红在莫名其妙的地方。修法是读侧加固(我们内部叫 REL-11):
两个细节值得说:
另外,价格簿的 list/cost/step 三个字段各自独立校验,非法值当次按 0 计价,快照里记录的是实际采用的修正值——不能把原来的非法值写进快照,否则 JSON 序列化成
null会继续污染对账。七、空转检测:我们明确说它是近似口径
成本报告里最容易变成"假精确"的是空转检测——"花了钱但没产生业务结果的调用"。
我们的 v1 实现口径是:模型调用发生后 30 分钟内,同一主体没有任何非模型资源动作(
skill:/mcp:/nas:/app:/connector:/kb:),且它的trace_id也没关联到任何动作事件 → 计为疑似空转。窗口可调(1 分钟到 24 小时),默认 30 分钟。
这个口径有明确的误报:纯对话不算空转。 用户问一句、模型答一句、没有工具调用——按上面的规则,它会被计入空转。这是我们已知的、写在代码注释里的误报,页面上也必须标注"近似口径、需人工复核"。
精确口径要等证据引擎的
task_success/task_fail结果回传落地。在那之前,我们宁可呈现一个"标着不精确"的数字,也不呈现一个"看起来精确但其实是猜的"数字。顺带说,在 demo 实例上这个月的空转事件是 0 条,占比 0%。这个数字好看,但不说明我们的实现优秀——它说明 demo 的种子数据里每个模型调用后面都跟着动作事件。真实环境不会这么干净。
八、诚实边界:能归口,不等于人能看懂
这是我今天最想写清楚的一点,因为它决定了这套东西在真实企业里能不能用。
技术上我们已经能按部门聚合成本了。但在 demo 实例的报表接口里,部门列返回的是这样的值:
这是内部 id,不是"市场部"。
这意味着:归口字段有值,不等于人能读懂。
从
org_id到"市场部"还差一层组织目录(org directory),而这层目录会变——部门改名、合并、拆分、历史数据要不要跟着改、改的时候按什么时点算。这是 HR 系统的问题,不是计量系统的问题。我们目前的做法是不猜:报表返回 id,控制台前端做名字映射,映射关系来自资源台账里登记的组织记录。如果组织没登记,前端就显示 id。这不优雅,但它是诚实的——它告诉使用者"这条成本归属到了这个组织实体,但这个实体还没有人类可读的名字"。
其他我们做不到的,一并写清楚:
/chat/completions。 没配 endpoint 时直接报错,绝不生成假 completion 充数。写这些不是为了显坦诚,是因为成本类系统最怕的就是"看起来准"。一个报表说"市场部花了 4,200",如果底层口径没交代清楚,这个数字会被拿去开会、被写进预算、被用来评价某个团队。错了没人知道。
九、现在这套东西能回答什么
一句话版本:
null而不是+∞)技术上实现的规模:30 个插件包、499 条 REST 路由、98 个 MCP 工具、94 个权限点。成本这条链路只是其中
usage+audit+finops三个面。代码是 MIT 的,可以自己去看实现怎么写的:https://github.com/01men/ybkkasi
免登只读的 demo 实例上,
/api/finops/overview这个接口不用登录就能拉到上面那些数字——如果你想确认我说的和代码干的是不是一回事,那个接口是最短的验证路径。最后
「模型账单月底出来,没人说得清哪个部门花的」这个问题,看起来是个报表问题,实际上是个数据在产生时就没带归口的问题。
报表能做的只有聚合,做不了追认。
如果这篇文章只能留一句话:归口字段要在消耗发生那一刻强制写上,而且允许它让调用失败。 月底再来对账,你面对的是一堆没有组织的钱。
All reactions