Skip to content
Published on

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

分享
Authors

引言 — 账单是按 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 是我编的示例值。实际上要在路由对象的分布上分别测出两个模型的精度再代入。而且这个测量里有个陷阱:要测的不是整个评测集上的精度,而是“决定送给小模型的那批请求”上的精度。如果路由器发挥了作用,在那个子集上两个模型的差距应该小得多;差距原封不动,就是路由器根本没能区分难度的信号。

级联还要额外看升级判定的可靠度。如果判定出错、没能把糟糕的答案升级上去,那不是节省,只是质量下降。判定器本身的精确率和召回率必须单独测。

杠杆作用点典型节省幅度质量风险实现负担
输出格式结构化输出 token30~70%低(依赖任务)
下调 max_tokens输出 token以防事故为主有被截断的风险非常低
Prompt caching输入 token最多为输入的 90%低(重排顺序)
用 RAG 缩小上下文输入 token5~50 倍检索失败时很大
模型路由全体50~70%必须测量
批处理全体大致减半无(只增延迟)

批处理,以及估算与账单对不上的原因

可以牺牲延迟的作业,送到单独的异步处理通道,单价会大幅下降。很多供应商以给出时间余量为交换,按大约一半的单价计费。夜间批量分类、日志摘要、重新生成嵌入、评测集打分这类没人在等的作业,全都是候选。把交互通道和批处理通道在代码层面分开,以后就不必再去判断哪个负载属于哪一边。

最后是计算器吐出的数字与实际账单对不上的常见原因。

系统提示和工具定义同样是每次请求的输入 token。挂上 20 个工具,它们的全部 schema 每次都会计费。仅仅是清理掉不用的工具,就能让输入 token 明显减少。

重试和中断也照样计费。流式输出过程中用户关掉窗口,截至那一刻生成的 token 仍然要付钱。因 JSON 解析失败而重试,这一条请求就结算了两次。解析失败率是 5%,成本也就多 5%。

用字符数去估 token 数,会因语言不同而偏差很大。同样意思的句子,韩语常常比英语出更多 token。用真正的分词器去数,或者把供应商返回的 token 计数字段记进日志,是唯一可靠的办法。

多模态输入遵循另一套换算规则。一张图片按分辨率会变成几百到几千个 token,直接叠加到按文本估的数上,偏差会非常大。

另外,日志里必须逐请求记下输入 token、输出 token、缓存读取 token、缓存写入 token 和模型名称。缺了这五个字段,事后就没有办法证明哪项优化省下了多少钱。

结语 — 先把日志的五个字段做好

成本优化中最常见的失败不是拉错了杠杆,而是拉完之后不知道效果如何。如果没有按请求记录各类 token 的用量,任何改进方案都只会停在“好像是有用”。

有了记录,顺序基本就定下来了。先确认输出 token 是不是在主导账单;如果是,就从输出格式动手。接着把提示拆成固定部分和可变部分,把顺序摆正。到这里为止几乎没有质量风险。路由和 RAG 是拿质量做交易,所以请把精度损失用同样大的字写在节省额旁边,再做决定。