one-api 额度扣得比预期多?倍率与配额计算拆解
网关面板扣的额度和你数出来的 token 对不上?按 one-api v0.6.10 与 new-api v1.0.0-rc.41 的实际代码,讲清模型倍率、分组倍率、补全倍率与缓存 token 的处理,并给出核账清单。
one-api 的额度是怎么算出来的
one-api 面板扣掉的额度,和你自己数出来的 token 数不是同一个东西。额度是一个内部记账单位:token 数先让输出部分乘上补全倍率,再整体乘模型倍率和分组倍率,最后取整,才是面板上掉下去的那个数。三个倍率里只要有一个不等于 1,扣除值就必然和 token 数对不上。
下面说的都指向两个具体版本:songquanpeng/one-api 的 v0.6.10(2025 年 2 月 2 日发布,是当前最新 release,main 分支这部分逻辑一致),以及它的分叉 QuantumNous/new-api 的 v1.0.0-rc.41(2026 年 9 月 30 日)。两个项目在计费这一块已经分开走了,所以每一段都会写明讲的是哪一个。
一次请求的额度公式长什么样
one-api 的文本请求在 relay/controller/helper.go 的 postConsumeQuota 里结算,核心只有一行:
quota = int64(math.Ceil((float64(promptTokens) + float64(completionTokens)*completionRatio) * ratio))式子里的 ratio 来自 relay/controller/text.go:
modelRatio := billingratio.GetModelRatio(textRequest.Model, meta.ChannelType)
groupRatio := billingratio.GetGroupRatio(meta.Group)
ratio := modelRatio * groupRatio两段合起来读就是:输入 token 按原样计入,输出 token 先乘补全倍率,两者相加之后再乘模型倍率、乘分组倍率,最后向上取整。new-api 的官方文档把同一件事写成一个公式:配额消耗 = (输入token数 + 输出token数 × 补全倍率) × 模型倍率 × 分组倍率。两边结构一样,差别在缓存和按次计费,后面单独讲。
额度和钱之间还有一个固定换算。one-api 的 common/config/config.go 里写着 var QuotaPerUnit = 500 * 1000.0,relay/billing/ratio/model.go 文件顶部的注释写着 // 1 === $0.002 / 1K tokens。所以在未改动的配置下,500000 额度折合 1 美元,倍率 1 表示每 1000 token 折合 0.002 美元。new-api 文档写的也是 1 美元 = 500,000 配额点数。你的部署如果改过 QuotaPerUnit,这个换算跟着变:面板上那个美元数字是换算出来的展示值,数据库里存的始终是整数额度。
分组倍率为什么最容易被忽略
分组倍率挂在用户所属的分组上,和模型无关,所以它在排查时最容易被跳过。one-api 的 relay/billing/ratio/group.go 里,取值逻辑是这样的:
func GetGroupRatio(name string) float64 {
groupRatioLock.RLock()
defer groupRatioLock.RUnlock()
ratio, ok := GroupRatio[name]
if !ok {
logger.SysError("group ratio not found: " + name)
return 1
}
return ratio
}分组名在倍率表里找不到时,函数写一条 group ratio not found: <分组名> 的系统日志并返回 1。这行日志值得去 one-api 的容器日志里 grep 一次,因为它说明你有用户挂在一个没有配倍率的分组上,账按 1 算,而你以为它被打了折。反过来,你给某个分组配了大于 1 的倍率(常见于给外部团队加价),那个分组下每一次请求都会被整体放大,和模型本身无关。
模型倍率没命中会发生什么
GetModelRatio 的查找顺序是有讲究的:先用 模型名(渠道类型编号) 这种带渠道号的键去查你自定义的 ModelRatio,再查内置的 DefaultModelRatio,然后去掉渠道号用纯模型名再查一遍这两张表。四次都没命中,它写一条 model ratio not found: <模型名> 的系统日志,然后返回 30。
logger.SysError("model ratio not found: " + name)
return 30这是「扣多了」最典型的一种成因。上游刚发布的新模型、你在渠道里做过模型重定向、或者请求里的模型名多了一个后缀,都会让查表落空,倍率直接掉到兜底值上。按上面那条换算,倍率 30 等于每 1000 token 折合 0.06 美元,对一个便宜模型来说是离谱的高价,而面板不会报错,只会安静地按这个数扣。new-api 在 setting/ratio_setting/model_ratio.go 里同一处的兜底值是 37.5,不是 30,所以两个项目在同一个未知模型上会扣出不同的额度。
补全倍率:输出 token 单独一套价
补全倍率表达的是「输出 token 比输入 token 贵几倍」。上游厂商普遍对输出 token 收更高的单价,one-api 没有分开存两个单价,而是用一个倍数把输出折算回输入 token 的口径,所以公式里输出部分写成 completionTokens * completionRatio。
GetCompletionRatio 的查找顺序和模型倍率一样,四张表依次查,全都没命中时返回 1。返回 1 意味着这个模型的输出 token 和输入 token 同价,这通常不是你想要的结果,而是漏配的信号。反过来,一个补全倍率配成 3 的模型,1000 个输出 token 在账上等于 3000 个输入 token。上游为什么这样定价,可以看 输入 token 和输出 token 的价格差是怎么来的,那边讲计价侧的原因,这里讲网关怎么把它记成一个数。
同样的 token 数,倍率组合差多少
下面四行不是任何版本的默认值,是四组假设的倍率组合。输入和输出都固定在 1000 token,额度由上面那条公式算出。把你自己面板上的三个数字代进去,就能知道这次请求该扣多少。
The data behind this chart
[
{
"label": "\u6a21\u578b 1 / \u5206\u7ec4 1 / \u8865\u5168 1",
"quota": 2000
},
{
"label": "\u6a21\u578b 1 / \u5206\u7ec4 1 / \u8865\u5168 3",
"quota": 4000
},
{
"label": "\u6a21\u578b 1 / \u5206\u7ec4 0.5 / \u8865\u5168 3",
"quota": 2000
},
{
"label": "\u6a21\u578b 2.5 / \u5206\u7ec4 1 / \u8865\u5168 3",
"quota": 10000
}
]三个倍率都是 1 的时候,2000 个 token 扣 2000 额度,数字和 token 数相等,这是唯一一种看起来对得上的情况。只把补全倍率改成 3,同一次请求扣 4000。再把分组倍率降到 0.5,结果又回到 2000,和第一行相同,但成因完全不同。模型倍率 2.5 配补全倍率 3 时扣 10000,是第一行的五倍。这 4 行用的是同一条公式,差异全部来自倍率,没有一处来自 token 计数。
缓存命中的 token 算不算钱
one-api v0.6.10 的文本结算式里只有 promptTokens 和 completionTokens 两项,没有任何和缓存有关的项。这意味着上游在 usage.prompt_tokens_details.cached_tokens 里报给你的缓存命中数,在 one-api 这里不产生任何折扣:缓存命中的输入 token 和普通输入 token 同价。上游给了你折扣,面板并不知道,你的用户照全价被扣。
new-api 在这一点上和上游分开了。service/text_quota.go 的 calculateTextQuotaSummary 先把缓存读取 token 和缓存写入 token 从基础 prompt token 里减掉(Claude 系列上报的 usage 结构是例外,它的 prompt token 本来就不含这两项),再分别乘上缓存倍率和缓存创建倍率:
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)这里的 ratio 同样是模型倍率乘分组倍率。缓存创建倍率在 new-api 里还能按 5 分钟和 1 小时两档分别配置,对应上游两种缓存写入价。所以同一份上游账单,one-api 和 new-api 会算出不同的额度。这不是 bug,是两个项目对缓存的处理不同。
new-api 还有一条 one-api 没有的计费路径:按次计费。它的文档写的是 配额消耗 = 模型固定价格 × 分组倍率 × 配额单位(500,000),token 数完全不参与。如果某个模型在你的面板里被配成按次计费,你再怎么数 token 也不可能对上,先去确认这一项。音频模型在 new-api 里也是单独一条式子,文本 token 和音频 token 各乘各的倍率。
预扣和结算:额度为什么先掉了一大块
请求发出之前 one-api 会先扣一笔押金。getPreConsumedQuota 把 config.PreConsumedQuota(代码里是 var PreConsumedQuota int64 = 500)加上 promptTokens,请求里如果带了 max_tokens 就再加上它,然后整体乘 ratio。请求结束后 postConsumeQuota 算出真实 quota,只补差额:
quotaDelta := quota - preConsumedQuota所以一次请求在余额上是两次写入。请求还在进行中的时候去看余额,看到的是预扣后的数字,它通常比最终结果大,带了 max_tokens 的请求尤其明显:一个 max_tokens 写成 8192 而实际只输出 30 个 token 的请求,预扣阶段按 8192 算,结算阶段再把多扣的退回去。用户看到「额度突然掉了一大截,过一会儿又回来一些」,原因就在这里。真正需要留意的是请求崩在这两步之间的情况,那时预扣不会自动退回。
面板日志里到底记了什么
结算完成后 one-api 写一条消费日志,字段来自 model.RecordConsumeLog:user_id、channel_id、prompt_tokens、completion_tokens、model_name、token_name、quota、content、is_stream、elapsed_time。核账真正要用的是 content,因为它是这么拼出来的:
logContent := fmt.Sprintf("倍率:%.2f × %.2f × %.2f", modelRatio, groupRatio, completionRatio)三个数字按顺序依次是模型倍率、分组倍率、补全倍率。这一行记的是这次请求实际用到的倍率,不是设置页此刻显示的值。日志是写入当时的快照,你之后改了倍率,旧日志的数字不会跟着变,用户也不会被追补或退回。
想绕开面板直接看原始行,可以查库:
SELECT id, model_name, token_name, prompt_tokens, completion_tokens, quota, content
FROM logs
ORDER BY id DESC
LIMIT 5;每行的 quota 应该等于把 content 里那三个倍率代入公式的结果。同一次结算还会更新两处累计:model.UpdateUserUsedQuotaAndRequestCount 累加到用户,model.UpdateChannelUsedQuota 累加到渠道。这两个统计对不上的时候,先确认你比较的是不是同一个时间区间和同一批渠道。
对不上账时按这个顺序查
第一步,拿到一次请求两端的数字。先从网关取一次真实 usage:
curl -sS https://your-gateway.example.com/v1/chat/completions \
-H "Authorization: Bearer sk-your-token" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' \
| jq '.usage'正常会返回类似 {"prompt_tokens": 9, "completion_tokens": 5, "total_tokens": 14} 的对象。流式请求默认不返回 usage,要在请求体里加上 "stream_options": {"include_usage": true},usage 会出现在最后一个数据块里。拿不到 usage 就没有可对的两端,后面几步都不用做。然后在面板日志里找到对应的那一行,记下它的 prompt_tokens、completion_tokens、quota 和 content。
第二步,查分组倍率。这个用户属于哪个分组,那个分组配的倍率是多少,和 content 里的第二个数字是否一致。不一致说明请求走的分组和你以为的不是同一个。容器日志里如果有 group ratio not found,那就是分组名压根没进倍率表。
第三步,查模型倍率。content 里的第一个数字如果是 30(new-api 上是 37.5),几乎可以确定是查表落空。此时去看容器日志里的 model ratio not found,它会原样打出没命中的模型名。把那个名字和你的模型倍率表、以及渠道上的模型重定向配置逐字比对,注意大小写和后缀。
第四步,查输出 token 和补全倍率。用 content 里的第三个数字,手算 (prompt_tokens + completion_tokens × 补全倍率) × 模型倍率 × 分组倍率,向上取整,再和日志里的 quota 比。对得上,说明配置就是这样,只是倍率不是你想要的值。对不上,再看是不是命中了 new-api 的缓存倍率或按次计费路径。
第五步,才轮到怀疑 token 计数本身。走到这一步的情况很少,因为 one-api 记录的 prompt_tokens 和 completion_tokens 直接来自上游返回的 usage,网关不重新分词。
网关的账单说明不了什么
网关记的是它按你配置的倍率向你的用户收了多少,不是上游实际向你收了多少。这是两套账。one-api 的 quota 完全由你面板上的倍率决定,上游返回的价格信息不参与计算。上游降价了、上游给缓存打了折、上游改了某个模型的计价口径,面板一概不知道,除非你手动去改倍率。
要知道真实成本,只能去上游控制台拉账单,按时间段和模型名与面板日志对。两边不会完全相等:向上取整、崩在预扣和结算之间的请求、失败重试、渠道层面的模型重定向,每一项都会造成偏差。把网关日志当作内部分摊的依据,把上游控制台当作唯一的付款事实,这个分工不要混。
这也正是给同事和 agent 各发一个独立令牌的意义:额度是你自己定的记账口径,够用来做预算控制和成本分摊,但 按量付费和买订阅哪种更划算 这种判断必须回到上游账单上做。如果你在横向比较网关本身,自托管 LiteLLM 网关 走的是模型价格表而不是倍率表,记账口径不一样;把这类面板放在 agent 工具前面的具体做法,可以参考 面向 agent harness 的 one-api 式路由方案。
FAQ
为什么日志里的额度远大于我数出来的 token 数?
因为额度不是 token 数。one-api 的结算式是 ceil((prompt_tokens + completion_tokens × 补全倍率) × 模型倍率 × 分组倍率),三个倍率相乘之后很容易把数字放大几倍到几十倍。打开那条日志的 content 字段,它按模型倍率、分组倍率、补全倍率的顺序原样列出了这次请求用到的三个数,代回公式手算一遍就能定位是哪一个偏大。如果第一个数是 30(new-api 上是 37.5),说明模型倍率查表没命中,走了兜底值。
我改了分组倍率,为什么旧日志的额度没有变?
消费日志是结算当时写下的快照,quota 是一个已经算好的整数,倍率也已经拼成文本存进了 content 字段。之后修改设置页的倍率只影响新请求,不会重算历史行,也不会退补用户余额。想按新倍率追溯,只能自己按日志里的 token 数重算,再手工调整额度。
one-api 会对上游的缓存命中打折吗?
v0.6.10 不会。它的文本结算式里只有 prompt token 和 completion token 两项,prompt_tokens_details.cached_tokens 不参与计算,缓存命中的输入 token 按全价扣。new-api v1.0.0-rc.41 会:它在 service/text_quota.go 里把缓存读取和缓存写入的 token 从基础 prompt token 中拆出来,分别乘缓存倍率和缓存创建倍率,缓存创建还能按 5 分钟和 1 小时两档分开配。
面板扣的额度等于我要付给上游的钱吗?
不等于。额度是网关按你自己配置的倍率算出来的内部记账数字,上游账单是另一套数据,只能从上游控制台获取。向上取整、未结清的预扣、失败重试和渠道重定向都会让两边产生偏差。合理的用法是:用网关日志做团队内部的预算控制和成本分摊,用上游控制台核对真实付款。