- Published on
LLM API 成本优化 — 输出 token、prompt caching 与路由的盈亏平衡计算
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 账单是按 token 开出来的
关于 LLM 成本优化的讨论,通常从“换个更便宜的模型吧”开始,到“质量恐怕会下降”结束。两边都没有依据,所以得不出结论。
可这个问题本来属于容易造出依据的那一类。账单是 token 数乘以单价,而这两个值都已经躺在日志里了。需要做的只是确认哪一项在主导账单,再算出每个杠杆能把这一项削掉百分之几。
本文按顺序做这套计算。先拆解成本结构,再按节省幅度从大到小逐个讲杠杆,并给出每个杠杆的盈亏平衡公式。单价因供应商而异且经常变动,所以全部作为变量处理,示例里代入常见的比例:输入每百万 token 3 美元,输出每百万 token 15 美元。您把自己的单价代进去重跑一遍即可。
成本的结构 — 输入和输出的单价并不相同
首先要确认的事实是,输入 token 和输出 token 的价格并不一样。输出通常比输入贵三到五倍。原因是结构性的:输入在预填充阶段被一次性并行处理,而输出是每个 token 都要重新走一遍整个模型的串行工作。
正因为这种不对称,直觉经常出错。
def daily_cost(reqs, in_tok, out_tok, price_in, price_out):
"""price_* 是每百万 token 的美元单价"""
c_in = reqs * in_tok * price_in / 1e6
c_out = reqs * out_tok * price_out / 1e6
return c_in, c_out, c_in + c_out
c_in, c_out, total = daily_cost(
reqs=100_000, in_tok=2_000, out_tok=500,
price_in=3.0, price_out=15.0,
)
print(f"입력 {c_in:8.0f} 출력 {c_out:8.0f} 합계 {total:8.0f} (일)")
print(f"출력 비중 {c_out / total:.1%}, 월 {total * 30:,.0f}")
# 입력 600 출력 750 합계 1350 (일)
# 출력 비중 55.6%, 월 40,500
输出 token 只有输入的四分之一,成本却更高。把提示打磨一番省下 500 个 token,和把回复缩短 125 个 token,金钱上的效果是一样的。大多数团队把时间花在给提示减肥上,却对输出长度放任不管。
所以第一个问题永远是这个:我们的输出为什么这么长。
最大的杠杆是输出长度
输出变长的原因通常有三个。模型加上开场白和结语,补上没人要求的解释,再把结构化的结果包进散文里。
按效果从大到小排列如下。
第一,把输出格式从散文改成结构。同样的信息用 JSON schema 或枚举来接收,token 数会掉到几分之一。“请把分类结果连同解释一起告诉我”和“只输出一个标签”的差别是 200 个 token 与 3 个 token。如何稳定地拿到它,整理在结构化输出的稳定性那一篇里。
第二,把 max_tokens 降到实际需要的量。与其说这是省钱,不如说是事故防护装置。模型陷入重复循环、一路吐到上限的事故并不罕见,而那一次就抵得上几百次正常请求。
第三,收窄使用思维链的区间。分步推理会在难题上提高精度,但会让输出 token 翻好几倍。与其对所有请求一律套用,不如先判别难度,只给需要的请求加上。
这里有一点要老实说明。确实存在缩短输出就会掉质量的任务。摘要和分类做短几乎没有损失,但在需要推理的任务上砍掉思考过程,精度就会下降。所以缩短输出不是“无条件要做的事”,而是“按任务测过再做的事”。测量方法后面还会再讲。
Prompt caching — 只命中前缀
如果您的服务每次请求都在前面拼上同样的系统提示和 few-shot 示例,那部分的输入成本原理上就是在重复付费。prompt caching 消除的正是这种重复。
核心约束只有一条。缓存只按前缀命中。从提示开头起、token 完全一致的区段才会被复用,只要有一个 token 不同,其后的全部都要重新计算。夹在中间的相同段落是抓不住的。
这条约束事实上强制决定了提示的排列顺序。
# 糟糕的顺序: 缓存命中为 0
prompt = f"""오늘 날짜는 {today}입니다.
{SYSTEM_RULES} # 8,000 token 的固定指令
{FEW_SHOT_EXAMPLES} # 4,000 token 的固定示例
사용자 질문: {question}"""
# 好的顺序: 前面 12,000 个 token 整段命中缓存
prompt = f"""{SYSTEM_RULES}
{FEW_SHOT_EXAMPLES}
오늘 날짜는 {today}입니다.
사용자 질문: {question}"""
把带日期的一行放在最前面,后面那一万二千个 token 每次都要重算。只调一下位置就能全部救回来。原则很简单:不变的放前面,常变的放后面。
单价结构大体是:缓存读取约为基础输入价的十分之一,缓存写入比基础价略贵。确切的倍数和缓存保持时间要到供应商文档里确认。在这个结构下算盈亏平衡,结果如下。
def caching_breakeven(write_mult=1.25, read_mult=0.1):
"""在缓存保持时间内要复用几次才划算"""
# 使用 n 次时: write_mult + (n-1) * read_mult vs n * 1.0
n = 1
while write_mult + (n - 1) * read_mult >= n:
n += 1
return n
print(caching_breakeven()) # 2
def cached_input_cost(reqs, static_tok, dynamic_tok, price_in, hit_rate,
write_mult=1.25, read_mult=0.1):
dyn = reqs * dynamic_tok * price_in / 1e6
stat_tok = reqs * static_tok
stat = (stat_tok * hit_rate * read_mult + stat_tok * (1 - hit_rate) * write_mult) * price_in / 1e6
return dyn + stat
base = 100_000 * 2_000 * 3.0 / 1e6
cached = cached_input_cost(100_000, static_tok=1_600, dynamic_tok=400,
price_in=3.0, hit_rate=0.9)
print(f"캐시 없음 {base:.0f} → 적중률 90%에서 {cached:.0f} (일 {base - cached:.0f} 절감)")
# 캐시 없음 600 → 적중률 90%에서 223 (일 377 절감)
只要复用两次就已经划算。所以缓存不是一个“要不要上”的纠结对象,而是一个“怎么把命中率提上去”的纠结对象。杀死命中率的常见凶手是提示前部的时间戳、请求 ID、用户名,以及以不保证顺序的方式序列化的工具定义。
RAG 与全文上下文 — 盈亏平衡在哪里
随着上下文窗口变大,“干脆全都塞进去”成了一个选项。单看成本,账很好算。
def per_request_cost(tokens, price_in, cached=False, read_mult=0.1):
mult = read_mult if cached else 1.0
return tokens * price_in * mult / 1e6
DOC = 100_000 # 全文档 token
K, CHUNK = 5, 400 # RAG: 前 5 个分块
full_raw = per_request_cost(DOC, 3.0)
full_cached = per_request_cost(DOC, 3.0, cached=True)
rag = per_request_cost(K * CHUNK, 3.0)
print(f"전체 {full_raw:.3f} / 전체+캐싱 {full_cached:.3f} / RAG {rag:.3f} (요청당 달러)")
# 전체 0.300 / 전체+캐싱 0.030 / RAG 0.006
# 盈亏平衡的文档大小: 即使把 RAG 的检索开销算进去
print(f"RAG가 유리해지는 문서 크기: {K * CHUNK}토큰 초과")
这些数字说了两件事。第一,RAG 比全文塞入便宜 50 倍,但只要文档是静态的、缓存能生效,差距就缩到 5 倍。第二,5 倍仍然是个大差距,但这是要拿去和“搭建并维护一条 RAG 流水线的成本”相比的量级。在日请求一千次的服务里,每次请求 0.024 美元的差距,一个月也就七百美元出头。工程师的时间更贵。
所以我认为,这个决定更应该放在成本之外的轴上来做。
文档塞不进上下文窗口时,除了 RAG 没有别的选择。文档因用户而异时,缓存命中率低,全文塞入就变贵。延迟重要时,十万 token 预填充带来的首 token 延迟会成为问题。反过来,如果文档不大(几万 token)、静态,而且检索失败的代价很高,那么全部塞进去更简单也更安全。而且随着上下文变长,模型漏掉夹在中间的信息的倾向一直有报告,所以“全塞进去精度就一定更高”这个前提本身也是待验证的。
模型路由 — 把节省额和精度损失放进同一张表
把简单请求送给小模型能省多少,由两个数字决定:委派比例和单价比。
def routing_saving(p_small, price_ratio):
"""p_small: 送往小模型的比例,price_ratio: 小模型单价 / 大模型单价"""
return p_small * (1 - price_ratio)
def cascade_saving(price_ratio, escalate_rate, judge_ratio=0.0):
"""先让小模型作答,不达标再升级到大模型。升级的部分要付两次。"""
cost = price_ratio + judge_ratio + escalate_rate * 1.0
return 1 - cost
print(f"라우팅 {routing_saving(0.7, 1/15):.1%}") # 65.3%
print(f"캐스케이드 {cascade_saving(1/15, 0.25, 0.02):.1%}") # 66.3%
# 级联成立的条件: 升级率低于 (1 - 单价比 - 评判成本)
print(f"승급률 상한 {1 - 1/15 - 0.02:.1%}") # 91.3%
只看成本,两种方式都剩得很多。级联只要升级率不超过 91%就是赚的,也就是说事实上永远是赚的。换言之,这个决定的瓶颈不是成本,而是质量。
所以必须把下面这个值放在和节省率同一块屏幕上。
def routed_accuracy(p_small, acc_small, acc_large):
return p_small * acc_small + (1 - p_small) * acc_large
acc = routed_accuracy(0.7, acc_small=0.88, acc_large=0.94)
print(f"라우팅 후 정확도 {acc:.3f} (단일 대형 0.940, 손실 {0.94 - acc:.3f})")
# 라우팅 후 정확도 0.898 (단일 대형 0.940, 손실 0.042)
这里代入的 0.88 和 0.94 是我编的示例值。实际上要在路由对象的分布上分别测出两个模型的精度再代入。而且这个测量里有个陷阱:要测的不是整个评测集上的精度,而是“决定送给小模型的那批请求”上的精度。如果路由器发挥了作用,在那个子集上两个模型的差距应该小得多;差距原封不动,就是路由器根本没能区分难度的信号。
级联还要额外看升级判定的可靠度。如果判定出错、没能把糟糕的答案升级上去,那不是节省,只是质量下降。判定器本身的精确率和召回率必须单独测。
| 杠杆 | 作用点 | 典型节省幅度 | 质量风险 | 实现负担 |
|---|---|---|---|---|
| 输出格式结构化 | 输出 token | 30~70% | 低(依赖任务) | 低 |
| 下调 max_tokens | 输出 token | 以防事故为主 | 有被截断的风险 | 非常低 |
| Prompt caching | 输入 token | 最多为输入的 90% | 无 | 低(重排顺序) |
| 用 RAG 缩小上下文 | 输入 token | 5~50 倍 | 检索失败时很大 | 高 |
| 模型路由 | 全体 | 50~70% | 必须测量 | 中 |
| 批处理 | 全体 | 大致减半 | 无(只增延迟) | 低 |
批处理,以及估算与账单对不上的原因
可以牺牲延迟的作业,送到单独的异步处理通道,单价会大幅下降。很多供应商以给出时间余量为交换,按大约一半的单价计费。夜间批量分类、日志摘要、重新生成嵌入、评测集打分这类没人在等的作业,全都是候选。把交互通道和批处理通道在代码层面分开,以后就不必再去判断哪个负载属于哪一边。
最后是计算器吐出的数字与实际账单对不上的常见原因。
系统提示和工具定义同样是每次请求的输入 token。挂上 20 个工具,它们的全部 schema 每次都会计费。仅仅是清理掉不用的工具,就能让输入 token 明显减少。
重试和中断也照样计费。流式输出过程中用户关掉窗口,截至那一刻生成的 token 仍然要付钱。因 JSON 解析失败而重试,这一条请求就结算了两次。解析失败率是 5%,成本也就多 5%。
用字符数去估 token 数,会因语言不同而偏差很大。同样意思的句子,韩语常常比英语出更多 token。用真正的分词器去数,或者把供应商返回的 token 计数字段记进日志,是唯一可靠的办法。
多模态输入遵循另一套换算规则。一张图片按分辨率会变成几百到几千个 token,直接叠加到按文本估的数上,偏差会非常大。
另外,日志里必须逐请求记下输入 token、输出 token、缓存读取 token、缓存写入 token 和模型名称。缺了这五个字段,事后就没有办法证明哪项优化省下了多少钱。
结语 — 先把日志的五个字段做好
成本优化中最常见的失败不是拉错了杠杆,而是拉完之后不知道效果如何。如果没有按请求记录各类 token 的用量,任何改进方案都只会停在“好像是有用”。
有了记录,顺序基本就定下来了。先确认输出 token 是不是在主导账单;如果是,就从输出格式动手。接着把提示拆成固定部分和可变部分,把顺序摆正。到这里为止几乎没有质量风险。路由和 RAG 是拿质量做交易,所以请把精度损失用同样大的字写在节省额旁边,再做决定。