Skip to main content

06 - 成本归因

前置01 篇的 token 指标;02 篇做法 D 里的网关子路线。了解 Agent 网关 · 多租户与配额的虚拟密钥概念会更好。

本篇回答:把 token 数乘以单价为什么算不准、这份数据该由谁产生、以及数据模型怎么设计才不会返工。

本篇会用到的词

意思
虚拟密钥网关自己签发的一套 key,key 上绑着团队、配额和可用模型范围。真实的 provider key 只有网关持有 —— 它是成本归因能成立的前提
打标(tagging)在请求上附加团队、项目、环境这些维度,落库时一起存下来。事后加不了维度,因为历史数据里没有那个字段
usage 明细provider 在响应里返回的 token 用量。注意它会区分「缓存命中的输入 token」和「新算的输入 token」,两者单价不同
prompt cachingprovider 提供的前缀缓存,命中的部分按折扣价计费。它是「只按总 token 数乘标准单价会系统性高估成本」的直接原因
分档单价按 token 的种类(缓存命中输入 / 新输入 / 输出)分别定价,而不是一个统一价

一、成本归因的四个难点

1.1 调用者身份在链路中丢失

模型调用发生在最内层,"谁发起的"这一信息在最外层,中间隔着若干层编排:

钱花在最内层,而「谁花的」这个信息在最外层用户 A研究团队身份在这里Agent 编排层要手工往下传子 Agent要手工往下传�工具要手工往下传模型调用这里还知道是用户 A 吗钱在这里花掉中间任何一层漏传,这笔花费就成了一笔无主的账。而层数越多、编排越动态,漏传的概率越高。所以成本归因必须做在网关层 —— 只有网关同时看得见调用者身份(虚拟密钥)和 token 数,中间那几层怎么传都影响不到它。
这张图解释的是一个选址问题:同样一件事,做在应用层要在每一层编排里手工透传,做在网关层只需要做一次。

身份未能一路透传时,得到的只有"某次调用花了 0.03 美元",无法归属。

还有一层更硬的理由:应用侧传入的身份标识可以被伪造。 应用代码里 metadata={"team": "growth"} 想写什么写什么,拿它当计费依据等于让被计费方自己报账。而虚拟密钥是网关签发的,请求方改不了。

结论:成本归因做在网关层,不做在应用层。 这不只是省事,是可信度问题。

1.2 一次用户请求对应 N 次计费调用

01 篇 3.2 节inference_calls 在这里成为关键:同一个问题,模型可能两步解决也可能绕十步,成本相差数倍。

因此成本看板的核心指标不是"平均每次调用的花费",而是平均每次用户请求的花费。两者的比值即平均步数,该值上升说明 Agent 在绕远路。

成本失控通常不是单价上涨,而是步数增加。 只盯"每次调用多少钱"的看板永远发现不了这件事 —— 那个数字可能纹丝不动。

1.3 缓存改变了计费结构

Agent 网关 · 缓存描述的四层缓存对账单的影响各不相同:

对成本的影响trace 可见性
网关精确匹配缓存该次调用完全免费需主动记录,否则该请求在 trace 中不存在
网关语义缓存免费,但增加一次 embedding 调用成本同上,且 embedding 那笔容易漏记
Provider prompt caching前缀部分按折扣价计费需读取 provider 返回的 usage 明细
引擎 prefix caching自建集群省算力,不影响外部账单不影响

最易算错的是语义缓存:省下一次模型调用,但每次查询都增加一次 embedding 调用。embedding 成本未计入时,"缓存节省了多少"这个数字是虚高的。

第一行还有个隐蔽后果:缓存命中的请求如果不产生 span,trace 上会出现一次"凭空完成"的执行 —— 用户问了问题、Agent 给了答案,中间没有任何模型调用记录。排查时容易误判成埋点丢了。

1.4 缓存命中的 token 单价不同

Provider 的 prompt caching 会在 usage 中区分"缓存命中的输入 token"与"新计算的输入 token",两者单价不同。

# ❌ 只看总数,按标准单价计算
# 缓存命中率越高,高估越严重 —— 也就是说越优化,账算得越不准
cost = usage.input_tokens * PRICE_INPUT + usage.output_tokens * PRICE_OUTPUT

# ✅ 分档计算
# cached 部分通常是标准价的 1/10 左右,具体比例按 provider 文档
cost = (
usage.input_tokens_cached * PRICE_INPUT_CACHED
+ usage.input_tokens_new * PRICE_INPUT
+ usage.output_tokens * PRICE_OUTPUT
)
同一次调用:输入 20,000 token,其中 16,000 命中 provider 缓存,输出 1,000。金额单位美元❌ 只看总数输入全按标准价输出0.06020,000 × 2.50/M + 1,000 输出 × 10/M缓存✅ 分档计算新算输出0.02416,000 × 0.25/M + 4,000 × 2.50/M + 1,000 输出 × 10/M名义算法高估 2.5 倍。单价按 2.50/M 输入、缓存命中价为其十分之一、10/M 输出估算,各家比例不同但方向一致。要命的地方在于方向:缓存命中率越高,高估越严重 —— 也就是说你越优化,账算得越不准,「省了多少」这个数字越假。
两根柱子等宽表示的是同一次调用。左边那根多出来的部分不是真花掉的钱,是数据模型里没有分档字段时凭空多算出来的。

二、网关侧打标方案

成本归因的五步,全部发生在网关内部① 网关入口虚拟密钥解析出团队 / 项目 / 用户② 打标身份写入请求上下文维度要一次设计到位③ 调用模型取回完整 usage含缓存命中的分档④ 成本计算分档单价× 分档 token 数⑤ 异步落库带上全部标签维度不占请求热路径第 ② 步的标签维度事后加不了 —— 历史数据里没有那个字段。�至少要有:团队、项目、环境、模型、调用来源。第 ④ 步的单价表必须带生效时间,否则 provider 一调价,所有历史报表全错。
把 ⑤ 放进异步链路是有意的:记账慢一点没人察觉,请求慢一点用户立刻能感觉到。LiteLLM 的 Rust 网关就是这么做的。

2.1 四个设计要点

标签维度必须一次设计到位。 至少包含团队、项目、环境(prod / staging)、模型、调用来源。事后无法补加维度 —— 历史数据里没有该字段。

分档存储 token 数。

-- ❌ 只存总数,1.4 节的问题无解
input_tokens INTEGER,
output_tokens INTEGER

-- ✅ 分档存储,且保留原始 usage 以备重算
input_tokens_new INTEGER, -- 新计算的输入 token
input_tokens_cached INTEGER, -- 命中 provider 缓存的输入 token
output_tokens INTEGER,
raw_usage JSONB -- provider 原始响应,用于口径变更后重算历史

最后那个 raw_usage 是这张表里最容易被砍掉、也最不该砍的字段。provider 会加新的 usage 字段(推理模型的 reasoning_tokens、多模态的音频 token 都是后来才有的),存了原始响应,口径变更时能重算历史;只存三个整数,历史就永远只有那三个整数。

单价表需带生效时间。

-- provider 调价后,历史账单必须按当时价格计算。
-- 单价写死在代码里,则调价一次全部历史报表失真。
CREATE TABLE model_pricing (
model TEXT,
price_type TEXT, -- input_new | input_cached | output
unit_price NUMERIC,
effective_from TIMESTAMPTZ,
effective_to TIMESTAMPTZ -- NULL 表示当前生效
);

成本计算放在异步链路。 Agent 网关 · 性能与形态代价中 LiteLLM 的 Rust 网关即采用此方式:会话结束后异步记账,不占用请求热路径。记账延迟用户无感,请求延迟用户有感。

三、三种网关侧采集方案

网关这一层现在有三种形态,它们对应用的侵入程度和给出的数据都不一样。

3.1 LiteLLM Proxy:功能最全,回调最多

BerriAI/litellm(★56,842)是这个位置上使用最广的一个。它的可观测能力是 callback 机制:

# config.yaml
litellm_settings:
success_callback: ["langfuse", "otel", "datadog"] # 可以同时发多个下游
failure_callback: ["sentry"]
turn_off_message_logging: True # 内容治理开关,见 04 篇
redact_user_api_key_info: true # 连密钥别名也不往下游发

官方支持的下游包括 Langfuse、OpenTelemetry、Datadog、MLflow、LangSmith、Arize、Langtrace、Galileo、OpenMeter,以及 S3 / GCS / Azure Blob / SQS / DynamoDB 这类存储与队列,还能写自定义 Python 回调。

它往下游带的归因字段是现成的,不用自己拼:

字段含义
user_api_key_user_id虚拟密钥对应的用户
user_api_key_team_alias团队别名
user_api_key_alias密钥别名
cache_hit / cache_key缓存命中标记 —— 正好补上 1.3 节那个"凭空完成"的洞
请求 body 里的 metadata.tags业务方自定义维度

注意最后一行是业务方传的,属于 1.1 节说的"可被伪造"那一类,只能当分析维度,不能当计费依据。前四行才是网关签发的、可信的。

3.2 Helicone:改一行 base_url

Helicone/helicone(★6,087,Apache-2.0)走的是最轻的一条路 —— 不部署网关进程,直接把请求指过去:

# 应用侧的全部改动就是这两处:换 base_url、加两个头
openai.api_base = "https://gateway.helicone.ai"
openai.ChatCompletion.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hi!"}],
headers={
"Helicone-Auth": "Bearer <HELICONE_API_KEY>",
"Helicone-Target-Url": "https://api.openai.com", # 真正的目标
},
)

代价很直白:所有请求和响应都经过第三方。 用托管版意味着 prompt 出公司网络,这与 04 篇的内容治理直接冲突 —— 它是开源可自建的,但自建之后就和 3.1 是同一类东西了。

3.3 Envoy AI Gateway:指标口径最规范

envoyproxy/ai-gateway(★1,943,Apache-2.0)的定位不同 —— 它是 Envoy 生态的一部分,走的是标准可观测栈。它发的是 OTel GenAI 约定下的服务端指标:

gen_ai.client.token.usage            # token 用量,按 gen_ai.token.type 区分 input / output / total
gen_ai.server.request.duration # 从收到请求头到响应体结束
gen_ai.server.time_to_first_token # 服务端视角的 TTFT
gen_ai.server.time_per_output_token # 服务端视角的 TPOT

默认带的属性:gen_ai.operation.namegen_ai.original.modelgen_ai.request.modelgen_ai.response.modelgen_ai.provider.namegen_ai.token.type

original.modelrequest.model 分开这件事很实用:网关做模型改写或降级路由时,两个值会不一样 —— 用户请求的是 gpt-4o,网关因为限流降级到了 gpt-4o-mini。只记一个的话,成本对不上或者用户投诉"效果变差了"时都查不出原因。

注意这里是 gen_ai.server.* 而不是 gen_ai.client.*01 篇讲的那几个客户端指标是应用侧埋点发的,网关发的是服务端侧的同名概念。同一次调用的 TTFT 会有两个值,差额就是网关到应用的网络与排队开销 —— 这本身是个有用的量。

3.4 三者对比

LiteLLM ProxyHeliconeEnvoy AI Gateway
56,8426,0871,943
应用侧改动base_urlbase_url + 加头base_url
下游十几种 callback自有平台标准 OTLP / Prometheus
语义约定由 callback 决定自有模型追踪 OpenInference + 指标 OTel GenAI
归因字段密钥 → 用户 / 团队,开箱即用自定义属性头需自己配 header 映射
适合要账单看板、多租户配额想最快看到数据已有 K8s + Envoy + OTel 栈

共同点是最重要的那一点:三者都是"应用改一个地址"就能接入,且业务方绕不过去。 这正是 02 篇第六节那四项独占能力里的两项,且都只有旁路这一条路线拿得到。

四、报表走指标,不走 trace

一个常见的返工:成本看板直接查 trace 库,按 gen_ai.usage.input_tokens 求和。

这在开了采样之后立刻失真 —— 05 篇那套策略只保留了出错的、慢的、贵的和 5% 基线,求和出来的数字既不是全量也不是样本的无偏估计。

正确的分工:

三条链路各回答一类问题,不要互相替代指标链路spanmetrics 在采样前现算全量、无偏、体积小回答:这周成本涨了多少哪个团队涨得最快trace 链路经过采样,有偏但保留了完整执行细节回答:这一次为什么花了 2 美元绝不要拿它做求和网关记账链路独立落库,不经采样带 raw_usage 可重算回答:这个月该跟财务报多少这是唯一的计费依据第三条不能省。指标链路的维度受基数限制(05 篇 4.1 节),做不了「按用户逐条对账」;trace 链路又不全。一个实用的对账动作:定期比对指标链路的月度汇总与网关记账链路的月度汇总,偏差超过 1% 说明某条采集路径漏了。
三条链路的数据源其实是同一批请求,区别只在于走了哪条管道、被什么规则筛过。搞混它们的代价通常是在季度成本复盘会上才被发现。

五、本篇结论

  1. 口径放网关。 只有网关同时掌握不可伪造的身份与真实用量,应用层实现必然产生无法归属、且可被伪造的开销。
  2. 数据模型一次到位。 标签维度、分档 token、raw_usage 原始响应、带生效时间的单价表 —— 这四项事后都补不上。
  3. 报表走指标,排查走 trace,对账走网关记账。 三条链路不要互相替代。
  4. 看每次用户请求的成本,不看每次调用的成本。 成本失控通常是步数增加,不是单价上涨。

下一篇07 - 平台选型与落地

← 回到 专题索引  ·  Agent Infra 板块总览