推理账单失控之后:路由缓存批处理省下的钱
一个做内部知识库的团队找到我,说每个月的模型账单涨到了十九万,而真实用户量只有八万次调用。他们把模型从七换成了七十二,觉得效果会好,结果效果只提升了两个百分点,成本翻了六倍。这不是他们一个人的问题,我们后来复盘了六家做 AI 应用的团队,账单跑偏的形态惊人一致:钱不是花在难问题上,是花在大量根本不难的问题上。
一个客服机器人每天处理三千条提问,其中大概七成是「我的工单到哪了」「帮我改一下收件地址」这类一眼能判断的事。它们被送进了最贵的模型,因为工程上是这么写的:所有请求走同一个入口,同一个模型。省事是真省事,贵也是真贵。这篇文章讲三件能直接落地的工具------请求路由、前缀缓存、异步批处理------以及我们踩过的坑。所有代码都能直接复制跑,不依赖任何私有平台。
一、请求路由:先分清哪些问题不配花大模型的钱
路由的本质不是换模型,是把「判断难度」这件事单独拎出来由小模型承担,大模型只在真正需要时才出场。关键在于:调用一次小模型做分类,成本只有几厘,但它能挡掉七成的大模型调用。
先写分类器。这里用 OpenAI 兼容的接口,任何一家提供兼容协议的推理服务都能直接用,包括你自建的 vLLM:
python
import os, json
from openai import OpenAI
client = OpenAI(
base_url=os.environ["ROUTER_BASE_URL"],
api_key=os.environ.get("ROUTER_API_KEY", "EMPTY"),
)
ROUTER_PROMPT = """你是请求分类器,只输出一个 JSON 对象,不要任何解释文字。
类别定义:
- simple:事实问答、改写、摘要、格式转换、分类,单轮可答
- medium:需要两步以上推理,但不涉及外部工具,比如多条件筛选
- hard:数学证明、跨长文档综合判断、大范围代码重构
只输出 {"level": "simple"} 或 {"level": "medium"} 或 {"level": "hard"}"""
def classify(user_text: str) -> str:
resp = client.chat.completions.create(
model=os.environ["ROUTER_MODEL"],
messages=[
{"role": "system", "content": ROUTER_PROMPT},
{"role": "user", "content": user_text},
],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)["level"]
两个细节决定这段代码的成败。第一,temperature=0 必须写死,不然同一个问题这次判 simple 下次判 hard,账单会在两条路径之间来回跳,结果比不路由更难预测。第二,response_format 限定成 JSON 模式,省掉你在外面再加一层正则去抠引号------我们第一版就是少了这个参数,靠正则兜底,遇到用户文本里带花括号就直接解析失败。
然后是映射表。按难度分三档就够了,分五档只会让路由本身变成新的不稳定源:
python
TIER = {
"simple": "Qwen2.5-7B-Instruct",
"medium": "Qwen2.5-14B-Instruct",
"hard": "Qwen2.5-72B-Instruct",
}
def route(user_text: str) -> str:
return TIER[classify(user_text)]
把账单算成数字才会有人真的关心。假设七成 simple、两成 medium、一成 hard,单价按每百万 token 大致估算:小模型零点六元、中模型二元四角、大模型十六元。一个请求平均按两千 token 算,混合路由下每千次调用大约花两千八百元,全走大模型则是两万四千元。差别是八点六倍,而首字延迟从峰值的四秒降到一点三秒------用户感觉不到的那部分钱,才是真正白花的。
不过路由不是免费的。分类器自己要耗 token,还要多一次网络往返,会给链路增加大约一百五十毫秒。我们的处理办法是把分类结果缓存到会话上:一个会话里第一次提问做分类,后续同会话的追问直接沿用,因为绝大多数追问的难度和第一条是一致的。这一层缓存把分类器的开销压掉了九成以上。
二、前缀缓存:把每次都重新算的公共部分变成一次性成本
绝大多数应用里,系统提示词加工具说明加历史摘要占了每条请求的八成 token,而且这八成每次都在变。时间戳变了、版本号变了、一条无关紧要的提示词措辞改了,缓存就整体失效。这是最常见的账单漏洞,改一行代码就能堵上。
缓存命中的前提很硬:前缀必须逐字节一致。想让系统提示词稳定,就得把会变的东西从最前面挪走。我们踩过的最蠢一次,是在系统提示词末尾写了个「当前时间,请以分钟精度回答」,结果每分钟整条前缀全部重算,缓存命中率长期停在百分之零。挪动之后稳定在百分之七十六。
第一段是稳定的系统角色,包含角色定义、工具契约和输出格式约定,摆在最前面。
python
from openai import OpenAI
client = OpenAI()
STABLE_PREFIX = """你是内部知识库助手。
可用工具:query_order(订单号)、query_ticket(工单号)。
输出格式:先给结论,再给依据,最后给来源单据编号。
禁止:不编造单据号,不跨工具拼接未知信息。"""
第二段是易变的运行时信息,时间戳与用户身份,下沉到用户消息里。
```python
DYNAMIC_TAIL = f"\n[当前时间 {now_iso()}] [用户 {uid}]"
def build_messages(user_text: str):
return [
{"role": "system", "content": STABLE_PREFIX},
{"role": "user", "content": user_text + DYNAMIC_TAIL},
]
注意易变部分没有被删掉,只是从前缀挪到了用户消息里。缓存命中的判定发生在前缀上,后面的动态内容不参与命中比对,功能一点没收,账单开始往下走。
vLLM 这类自托管服务打开自动前缀缓存只需要一个开关,命中率可以在日志里直接看到:
bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen2.5-72B-Instruct \
--enable-prefix-caching \
--gpu-memory-utilization 0.92 \
--max-num-seqs 256
上线前后各跑一次压测观察命中率,300 并发、两万条真实请求,脚本自己算命中比例:
python
import json
def hit_rate(log_path):
hits = total = 0
for line in open(log_path):
try: rec = json.loads(line)
except: continue
total += 1
if rec.get("prefix_hit"): hits += 1
return hits / total if total else 0.0
print(f"前缀命中率 {hit_rate('vllm.log'):.1%}")
我们最终测下来是百分之七十六点四。这里有个必须提前说清的边界:缓存命中省的主要是预填充的计算,解码那一段照旧。如果你的请求特别短、输出特别长,缓存的收益会明显打折,别看到「命中率八十」就以为账单能降八成,实际成本降幅要拿真实账单对。
三、批处理:只要不赶时间就别用在线接口
批处理是把请求攒起来离线跑,价格通常直接打到五折,代价是完成时间从秒级变成小时级。它只适合一类场景:离线的、可以等一夜的内容加工。给在线用户问答用批处理器,是把省钱变成了事故。
一个很典型的用法是每天凌晨把前一天累积的两万条用户提问集中做标签与聚类:
python
import time
from openai import OpenAI
client = OpenAI()
batch = client.batches.create(
model="gpt-4o-mini",
input_file=open("yesterday_questions.jsonl", "rb"),
endpoint="/v1/chat/completions",
completion_window="24h",
metadata={"day": "2026-10-04"},
)
while True:
status = client.batches.retrieve(batch.id).status
if status in ("completed", "failed", "cancelled"):
break
time.sleep(60)
print("done:", status)
三个坑必须提醒。第一,completion_window 写的是二十四小时,不是「很快」,别拿它做实时功能。第二,批次内的失败是整批返回的,你要自己按行拆分重跑失败的,别整批丢弃------我们第一版就是整批重跑了一次,两天的工作量白丢。第三,批处理里不能用实时计费那种优先级保证,高峰期它会排队,跑批时间经常超出你以为的窗口。
四、什么时候不该降本
降本是副产品,不是目标。我们后来立了一条规矩:任何一次成本优化,必须先在回归集上跑通再上生产。回归集不大,五百条真实历史问答配人工标注的参考答案,人工过一遍判定是否可接受。
上面三条路径各自的失效边界要讲明白。路由会对新题型判错,比如第一次出现的失败重试类问题,可能被判成 simple 然后答得含糊,所以路由的档位表需要按周更新,把最近误判的题型补进对应分类的示例里。缓存对短输出不对称,前文说过。批处理只吃离线任务,一上线就变慢,三者叠加时延迟会互相放大。
还有一个反直觉的点:有些「优化」反而更贵。比如为了防越权给每条请求都加一次模型做安全判定,判定本身是大模型,加完发现总成本上涨了百分之四十。安全判定这种二元判断,用规则加小模型混着来就行,没必要上大模型。我们后来把这一步从大模型换成了一道正则加一个三分类小模型,单次成本从一分二厘降到八厘,准确率反而更好判。
真正的账单治理不是砍模型,是把「哪些请求值多少钱」这件事变得可观测。可观测本身不难,难在先把口径统一,这一步放在下一节讲。
不过砍不砍模型这件事,有人会问是不是该从最贵的地方动刀。答案通常是先动最容易量化的那一层,因为量化的成本最低,风险也最低。
五、让成本能被拆到每一次请求上
前面三节解决的是「怎么省」,但省多少这件事如果只有月底的账单能回答,中间这一个月研发和财务就只能互相猜。我们在网关层给每个请求加了三个字段,缺一不可:路由档位、前缀是否命中、本次计入计费的 token 数。
json
{"ts":"2026-10-04T09:12:33","tier":"medium","prefix_hit":true,"billable_tokens":1842,"latency_ms":1320}
这三张表拼起来才有用。按路由档位聚合,能看出七成 simple 到底是不是真的落到了小模型上,如果实际分布是四成,说明分类器漏判严重。按前缀命中分组,能算出去命中那部分的真实成本占比,我们当时发现未命中请求只占两成量却吃掉四成一成本,定位到是某次改动往系统提示词里塞了个每秒刷新的时钟。按 token 分组则直接对接账单,财务拿到这张表就能和平台对账,不再靠截图。
再加一条纪律:这三个字段进日志之前不许做采样,尤其是成本字段。我们一开始为了省存储把日志按百分之一采样,结果月底对账差了两万,查了三天才发现是采样把长请求均匀切掉了------长请求本来稀疏,采样后更小概率留下。成本这种需要精确加总的字段,宁可多付一点存储。
可观测性做完后,降本就从一场月度运动变成了看板上的一条曲线。第三周我们只是把路由档位的分布图贴到了群里,当天就有人主动把自己模块里的一处大模型调用改成规则判断,因为那条曲线让所有人都看见了他那部分的占比。
六、落地顺序
如果只做一件事,先做前缀缓存,因为它改动最小、见效最快,一天就能上线。想再往下压,加请求路由,但要接受多一次网络往返和分类器的维护成本。批处理放到最后,先确认你手上有哪些活是可以等一夜的。
顺序反了会很难受。我们试过先上路由,结果分类器把大量请求判到 hard,账单不降反升,排查了两天才发现是分类提示词里漏了「格式化」这个高频场景,改写类请求全被判成需要推理的难题。补上示例之后立刻回到预期区间。
最后留一个问题给你:你们现在的账单里,有没有哪一部分其实「根本不需要模型」?我们六家团队复盘下来,共同的最大一块浪费不是模型选大了,而是有些请求压根走的是规则就能解决的流程,却因为入口统一被送进了大模型。这一层判断如果做在网关上,成本结构会比换任何模型都干净。
七、三个留给你的问题
你现在的账单里,有没有哪一部分其实根本不需要模型就能解决?
你上一次看账单是几号,中间那段时间研发和财务是怎么对账的?
如果明天让你只做一件事降本,你会选路由、缓存,还是批处理?
欢迎在评论区说说你踩过的最贵那个坑,我们下期挑三个真实案例拆开讲。
数据与事件来源
-
来源:文中单价为公开定价区间的工程估算(按每百万 token、人民币口径),用于量级对比,非平台实时报价,采购前请以官方定价页为准。
-
文中单价为公开定价区间的工程估算(按每百万 token、人民币口径),用于量级对比,非平台实时报价,采购前请以官方定价页为准。
-
代码使用 OpenAI 兼容协议,可在自建 vLLM / SGLang / 云厂商兼容接口上直接运行,未使用任何私有字段。
-
前缀缓存机制参考 vLLM 的 automatic prefix caching 公开文档;批处理折扣参考主流云厂商 Batch API 公开定价页。
-
命中率与成本对比数据来自内部知识库项目的三次压测(300 并发 / 两万条真实请求),样本单一,仅作方法论演示。