上周有个人问我:我们这个 AI 客服上线一个月,大概要花多少钱。
我说你先告诉我三个数:一次对话平均调几次模型、每次输入多少 token、重试率多少。
他一个都答不上来。
这在我这儿不是第一次。能报出模型单价的人十有八九,能说出自己那个功能单次成本的人几乎没有。到要报价的时候,只能拍一个数,拍低了亏自己,拍高了单子飞了。
这篇文章把成本这件事拆成可算的口径。看完你应该能自己填出一张表。
一、先把「一次业务请求」和「一次模型调用」分开
成本算不清,八成是因为这两件事被当成了一件事。
用户点一次「发送」,这是一次业务请求。但这个请求背后可能调了模型好几次:
- 意图识别调一次(通常是小模型);
- 检索到的片段要不要用,可能再调一次;
- 生成最终回复调一次(通常是大模型);
- 如果第一次超时重试,上面每一步都可能再走一遍。
也就是说,一次业务请求的实际花费,是这条链路上所有调用的花费之和,还要乘上一个重试放大系数。
我在项目里见过的最夸张的情况:一个看起来很简单的问答,因为工具调用老是抖动,重试放大到了 2.7 倍。预算是按 1 倍算的,上线第一周就超了。
二、token 计费不是一个数,是三段
模型厂商的报价单上,通常至少有三行:
| 费用项 | 计价单位 | 谁在放大它 | 常见漏算 | 怎么测 | 降本手段 |
|---|---|---|---|---|---|
| 输入 token | 每百万 | 检索片段长度、历史对话轮数 | 把 system 提示词忘了算 | 服务端在调用前打印 prompt 长度 | 缩短提示词、裁剪历史 |
| 输出 token | 每百万 | 模型话多、格式没约束 | 按平均值估,实际长尾很贵 | 服务端累加 completion 长度 | 限制最大输出、要求结构化 |
| 缓存命中输入 | 每百万(打折) | 提示词前缀是否稳定 | 前缀里塞了时间戳,缓存全废 | 命中率从返回体里读 | 固定前缀顺序、不放动态值 |
第三行是很多人没注意的省钱项:如果你的提示词前缀是稳定的,命中缓存的那部分输入会便宜很多。但只要你把当前时间或者随机数放进前缀,命中率立刻掉到接近零。
这是个很典型的后端能发挥的地方。它不需要你懂模型,需要你懂「什么东西会被当成 key」。
三、用一段 Go 把成本累加起来
讲完口径,落到一个能直接用的东西。下面这段是我自己在服务里用的做法:每次调用完打一条记录,按请求 ID 汇总。
packagecostimport("fmt""sort""sync""time")// Price 是每百万 token 的单价,单位:元。typePricestruct{InputPerMfloat64OutputPerMfloat64CachedInputPerMfloat64}// Record 是一次模型调用的记录。RequestID 用于把同一次业务请求// 的多次调用串起来,Attempt 记录它是第几次尝试。typeRecordstruct{RequestIDstringModelstringInputTokintOutputTokintCachedTokintAttemptintAt time.Time}typeTrackerstruct{mu sync.Mutex pricesmap[string]Price records[]Record}funcNewTracker(pricesmap[string]Price)*Tracker{return&Tracker{prices:prices}}// Add 拒绝两类坏数据:没有登记单价的模型、以及 Attempt 小于 1 的记录。// 这两类一旦放进去,后面算出来的成本会静默偏小,比报错难查得多。func(t*Tracker)Add(r Record)error{t.mu.Lock()defert.mu.Unlock()if_,ok:=t.prices[r.Model];!ok{returnfmt.Errorf("no price registered for model %q",r.Model)}ifr.Attempt<1{returnfmt.Errorf("invalid attempt %d for request %q",r.Attempt,r.RequestID)}t.records=append(t.records,r)returnnil}func(t*Tracker)cost(r Record)float64{p:=t.prices[r.Model]returnfloat64(r.InputTok)/1e6*p.InputPerM+float64(r.OutputTok)/1e6*p.OutputPerM+float64(r.CachedTok)/1e6*p.CachedInputPerM}// requestCostLocked 是不加锁的汇总实现,调用方必须已持有 t.mu。func(t*Tracker)requestCostLocked(reqIDstring)(totalfloat64,callsint,amplifyfloat64){byAttempt:=map[int]float64{}for_,r:=ranget.records{ifr.RequestID!=reqID{continue}total+=t.cost(r)calls++byAttempt[r.Attempt]+=t.cost(r)}iflen(byAttempt)==0{return0,0,0}// 第一次尝试的花费作为基准,放大倍数 = 总花费 / 基准ifbase:=byAttempt[1];base>0{amplify=total/base}returntotal,calls,amplify}// RequestCost 汇总一次业务请求的总花费、调用次数与重试放大倍数。func(t*Tracker)RequestCost(reqIDstring)(float64,int,float64){t.mu.Lock()defert.mu.Unlock()returnt.requestCostLocked(reqID)}// PerThousand 把所有请求的花费折算成每千次业务请求的平均成本。// 样本太少时返回 0,避免拿三五个请求去推整月预算。func(t*Tracker)PerThousand(minSamplesint)float64{t.mu.Lock()defert.mu.Unlock()ids:=map[string]bool{}for_,r:=ranget.records{ids[r.RequestID]=true}iflen(ids)<minSamples{return0}varsumfloat64forid:=rangeids{c,_,_:=t.requestCostLocked(id)sum+=c}returnsum/float64(len(ids))*1000}// TopExpensive 按单次花费倒序返回请求 ID,用来定位长尾。func(t*Tracker)TopExpensive(nint)[]string{ifn<=0{returnnil}t.mu.Lock()defert.mu.Unlock()typeitemstruct{idstringsumfloat64}m:=map[string]float64{}for_,r:=ranget.records{m[r.RequestID]+=t.cost(r)}items:=make([]item,0,len(m))fork,v:=rangem{items=append(items,item{k,v})}sort.Slice(items,func(i,jint)bool{returnitems[i].sum>items[j].sum})out:=make([]string,0,n)fori,it:=rangeitems{ifi>=n{break}out=append(out,it.id)}returnout}这段代码里有四个刻意的设计,值得单独说:
Add先挡坏数据:模型没登记单价、或者Attempt填了 0,直接返回错误。这两种情况放进去都会让成本静默偏小,比报错难查得多;Attempt单独记字段,这样重试放大倍数能算出来,而不是只能看到总额;PerThousand带最小样本数,样本不够就返回 0,防止拿三五个请求推整月预算;TopExpensive找长尾,成本超预算基本都是长尾拉上去的,平均值看不出来。
一个提醒:RequestCost里我按Attempt == 1的花费做基准算放大倍数。如果你的重试策略是换模型重试(比如大模型超时换小模型),那这个倍数就不再是纯粹的重试放大,解读时要小心。
实测一遍的行为(示例单价,输入 7 元 / 输出 21 元 / 缓存命中 0.7 元每百万 token):漏填Attempt的记录会被Add拒绝;正常重试场景下次请求成本 0.03514 元、调用 2 次、放大倍数 1.99;TopExpensive(-1)返回空切片而不是崩掉。
四、报价口径:不要报「每百万 token 多少钱」
对外报价的时候,最容易出事的是单位。
甲方听不懂「每百万 token」,但听得懂「每一次用户提问大概多少钱」。所以报价单上我一般写成这个形式:
| 口径 | 数值(示例) | 说明 |
|---|---|---|
| 单次业务请求平均成本 | 0.03 元 | 含重试,示例值 |
| 日均业务请求量 | 5000 次 | 示例值 |
| 日均模型成本 | 150 元 | 上面两行相乘 |
| 月度模型成本 | 4500 元 | 按 30 天,示例值 |
| 长尾预留系数 | 1.3 | 覆盖流量波动与重试尖峰 |
| 对外报价底价 | 5850 元 | 月度成本乘系数,示例值 |
上表所有数字都是示例值,用来说明口径之间的关系,不是任何项目的实测结果,填的时候请换成自己的数。
这里最容易被漏掉的是第五行。模型成本不是稳定的,遇到上游限流或者业务高峰,重试率会跳,成本跟着跳。留 1.3 的系数是我自己用的经验值,不是公式,你要按自己的波动幅度调。
五、一张可以自己填的成本自检表
最后给一张空的,你可以直接复制到自己的项目文档里。
| 项目 | 我的值 | 数据来源 |
|---|---|---|
| 一次业务请求平均调几次模型 | 服务端埋点 | |
| 平均输入 token | 调用前 prompt 长度 | |
| 平均输出 token | 返回体累加 | |
| 缓存命中率 | 返回体读命中标记 | |
| 重试率 | 按 Attempt 统计 | |
| 重试放大倍数 | 总花费 / 首次尝试花费 | |
| 单次业务请求成本 | 上面几项算出 | |
| 月均请求量 | 业务侧预估 | |
| 月度模型成本 | 相乘 | |
| 长尾预留系数 | 按波动幅度自定 |
十行填完,你就有了能拿去报价的数。
回到开头那个问题:AI 项目的成本不难算,难的是没人规定该从哪一层开始算。把「一次业务请求」当成最小计价单位,剩下的就都是能测的东西了。
你现在的项目里,一次用户提问背后实际调了几次模型?测过没有?评论区说说。