大模型 API 按 token 计费,单次调用不贵,但当一个 agent 任务要跑几十轮、一个产品日调用上百万次,成本就变成必须死盯的指标。阿里 Qwen3.8-Max 给了一个直观的数字:显式缓存命中输入 ¥1/百万 tokens,未命中 ¥12,差 12 倍(以官网为准)。同样 100 万 tokens 的输入,缓存命中花 1 块,没命中花 12 块。长程任务要跑起来,单次调用必须便宜到"敢让它反复试"。这篇 SOP 把成本优化拆成六步:量化基线 -> 前缀缓存 -> 模型路由 -> 批量 API -> 上下文优化 -> 监控迭代,每步给可复制的 Python 代码,最后附踩坑和 FAQ。核心思路四把刀:让贵的模型少干活、让便宜的模型多干活、让重复的输入走缓存、让不急的任务进批量队列。和本站《LLM 微调实战 SOP》讲怎么"训"不同,这篇讲怎么"省"——但两者有个共同前提:先量化,再动手。
一、量化当前成本基线
优化前先量。不知道每天花多少、花在哪、哪个模型吃掉大头,优化就是盲改。第一步不是砍成本,是把账算清。
每个 API 响应的 usage 字段都带 token 统计(prompt_tokens、completion_tokens,部分支持 cached_tokens)。把这些数字记下来,按模型、按接口、按时段聚合,才能定位"钱花在哪"。
import time
from collections import defaultdict
class CostTracker:
"""轻量调用成本追踪器:记录每次调用的 token 用量与费用"""
def __init__(self):
self.logs = []
self.cost_by_model = defaultdict(float)
def log(self, model, input_tokens, output_tokens,
cached_tokens=0, cost=0.0):
self.logs.append({
"ts": time.time(),
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cached_tokens": cached_tokens,
"cost": cost,
})
self.cost_by_model[model] += cost
def summary(self):
total_cost = sum(l["cost"] for l in self.logs)
total_input = sum(l["input_tokens"] for l in self.logs)
total_cached = sum(l["cached_tokens"] for l in self.logs)
hit_rate = total_cached / total_input if total_input else 0
return {
"total_cost": round(total_cost, 4),
"total_input_tokens": total_input,
"cache_hit_rate": f"{hit_rate:.1%}",
"cost_by_model": dict(self.cost_by_model),
}跑一周,看 summary() 输出:总花费、各模型占比、缓存命中率。如果发现 80% 的调用其实只是翻译/分类这种简单任务,但全打在贵模型上,那路由优化空间就很大。如果缓存命中率不到 5%,那前缀缓存是第一优先级。没有这层数据,后面所有优化都是拍脑袋。
二、前缀缓存(prompt caching)
前缀缓存的核心原理:如果多次请求的前缀(system prompt + 长文档 + few-shot 示例)完全一致,服务商把这段前缀的 KV-cache 存下来,后续命中时按"缓存读取"价计费,远低于常规输入价。Qwen3.8-Max 缓存命中 ¥1/M 对未命中 ¥12/M 差 12 倍就是典型例子。
各家的实现方式不同:
| 服务商 | 缓存机制 | 接入方式 |
|---|---|---|
| Anthropic | 显式缓存,需标记 cache_control | 在 system/content 块里加 cache_control |
| OpenAI | 自动缓存(prompt >1024 tokens 自动生效) | 无需改代码,把静态内容放前面即可 |
| DeepSeek | 前缀缓存,自动命中 | 把稳定内容放 prompt 开头 |
| Qwen(阿里) | 显式/自动缓存 | 以官方文档为准 |
Anthropic 的写法最清晰,以它为例:
import anthropic
client = anthropic.Anthropic() # 环境变量 ANTHROPIC_API_KEY
# 稳定的长文档/规则/示例放进 system,标记 cache_control
LONG_DOC = "(这里放几千 token 的文档内容、规则、few-shot 示例)"
response = client.messages.create(
model="claude-sonnet-4-5-20250929", # 以官网为准
max_tokens=1024,
system=[
{
"type": "text",
"text": f"你是文档助手。参考文档:\n{LONG_DOC}",
"cache_control": {"type": "ephemeral"},
}
],
messages=[
{"role": "user", "content": "总结文档要点。"}
],
)
# 响应 usage 里会返回缓存命中情况
print(f"缓存读取 tokens: {response.usage.cache_read_input_tokens}")
print(f"缓存写入 tokens: {response.usage.cache_creation_input_tokens}")
print(f"常规输入 tokens: {response.usage.input_tokens}")关键约束:前缀必须逐 token 完全一致。system prompt 改一个字、工具列表增删一条、few-shot 顺序换一下,缓存全部失效。所以接入前缀缓存意味着把 prompt 结构固化:稳定段放最前面(system + 长文档 + 规则),变化段放最后面(用户当次输入)。
踩坑提醒:
- 缓存有 TTL(Anthropic 默认 5 分钟,可用 beta header 延长到 1 小时;OpenAI/DeepSeek 各有窗口期,以官网为准),空闲太久自动过期。
- 第一次请求必然是 cache write(部分服务商缓存写入价略高于常规输入价),第二次起才命中。高频调用场景收益最大。
- 动态注入时间戳、随机 ID 到 prompt 开头会直接击穿缓存,这类变化内容必须放到 prompt 最末尾。
三、模型路由(分级调度)
不是所有任务都需要最强模型。翻译、分类、摘要、格式转换这类任务,便宜模型和贵模型差距很小,但价格差几倍到十几倍。模型路由的思路:按任务复杂度分流,贵模型只做难题,便宜模型做易题。
def route_model(query: str) -> str:
"""按任务复杂度路由到不同模型(模型名以官网为准)"""
q = query.lower()
# 复杂推理:代码、数学、长文分析、多步推理 -> 强模型
hard_keywords = [
"代码", "编程", "debug", "数学", "推理", "分析",
"code", "math", "reason", "analyze", "essay",
]
if any(kw in q for kw in hard_keywords):
return "qwen-max" # 强模型
# 简单任务:翻译、分类、摘要、格式转换 -> 轻量模型
return "qwen-turbo" # 便宜模型
def call_with_fallback(query: str) -> str:
"""带 fallback 的路由调用"""
from openai import OpenAI
client = OpenAI() # OpenAI 兼容接口(Qwen/DeepSeek 均支持)
primary = route_model(query)
fallback = "qwen-plus" # 主模型不可用时降级
for model in [primary, fallback]:
try:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": query}],
max_tokens=512,
)
return resp.choices[0].message.content
except Exception as e:
print(f"模型 {model} 调用失败: {e},尝试 fallback")
raise RuntimeError("所有模型均不可用")路由策略可以从简单到复杂逐步升级:
- 关键词匹配(上面代码):最简单,覆盖高频场景,几十行代码就能上线。
- 分类器路由:训一个轻量分类器(甚至用 embedding 相似度)判断难度,比关键词更准。
- 级联路由(cascade):先让便宜模型试,置信度低或结果不达标再升级到贵模型,最省但延迟翻倍。
不管用哪种,都要留 fallback:主模型限流或超时时自动降到备选模型,不能让用户看到 500 错误。
四、批量 API(batch API)
大量不急的任务(数据标注、批量翻译、内容生成、评估打分)不需要实时返回,可以走 batch API。OpenAI、Anthropic 都提供 batch 接口,把请求攒成 JSONL 文件提交,几小时内批量返回,价格通常有显著折扣(常见约五折,以官网为准)。
以 OpenAI Batch API 为例:
import json
from openai import OpenAI
client = OpenAI() # 环境变量 OPENAI_API_KEY
# 1. 把请求写成 JSONL 文件
texts = ["你好", "谢谢", "再见", "请问", "没问题"]
requests = [
{
"custom_id": f"req-{i}",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": "gpt-4o-mini",
"messages": [{"role": "user",
"content": f"翻译成英文:{text}"}],
"max_tokens": 200,
},
}
for i, text in enumerate(texts)
]
with open("batch_input.jsonl", "w", encoding="utf-8") as f:
for r in requests:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# 2. 上传文件 + 创建 batch 任务
batch_file = client.files.create(
file=open("batch_input.jsonl", "rb"),
purpose="batch",
)
batch = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
print(f"Batch ID: {batch.id}, 状态: {batch.status}")
# 3. 轮询状态,完成后下载结果
# batch = client.batches.retrieve(batch.id)
# if batch.status == "completed":
# result = client.files.content(batch.output_file_id)关键限制:
- 延迟换价格:batch 任务在 24 小时窗口内完成,不适合实时场景。
- 单文件上限:请求数和文件大小有上限(以官网为准),超量拆多个文件。
- 不支持流式:batch 只返回完整结果,没有 streaming。
五、上下文与重复请求优化
长对话和多轮 agent 任务里,上下文长度是成本杀手。一个 20 轮的对话,如果不裁剪,每轮都把前面全部历史发一遍,token 数线性增长,成本也线性增长。上下文优化做两件事:裁剪历史、去重重复请求。
def trim_context(messages: list, max_messages: int = 20) -> list:
"""保留 system + 最近 N 条,裁掉中间历史"""
if len(messages) <= max_messages:
return messages
system_msgs = [m for m in messages if m["role"] == "system"]
dialog = [m for m in messages if m["role"] != "system"]
keep = max_messages - len(system_msgs)
recent = dialog[-keep:] if keep > 0 else []
return system_msgs + recent
def compress_with_summary(messages: list, llm_call) -> list:
"""超长对话:早期消息压成摘要,保留近期原文"""
if len(messages) <= 20:
return messages
system_msgs = [m for m in messages if m["role"] == "system"]
dialog = [m for m in messages if m["role"] != "system"]
to_summarize = dialog[:-10]
recent = dialog[-10:]
# 早期对话让便宜模型压成摘要
summary_input = "\n".join(
f"{m['role']}: {m['content']}" for m in to_summarize)
summary = llm_call(f"把以下对话压成 200 字摘要:\n{summary_input}")
summary_msg = {
"role": "system",
"content": f"早期对话摘要:{summary}",
}
return system_msgs + [summary_msg] + recent除了裁剪,还要注意重复请求去重。同一个 prompt 在短时间内被多次调用(用户刷新、重试),可以加一层本地缓存:
import hashlib
import time
class ResponseCache:
"""简单的响应缓存(只适合幂等请求)"""
def __init__(self, ttl_seconds=300):
self.cache = {}
self.ttl = ttl_seconds
def _key(self, model, messages):
raw = f"{model}:{str(messages)}"
return hashlib.md5(raw.encode()).hexdigest()
def get(self, model, messages):
key = self._key(model, messages)
if key in self.cache:
ts, result = self.cache[key]
if time.time() - ts < self.ttl:
return result
return None
def set(self, model, messages, result):
key = self._key(model, messages)
self.cache[key] = (time.time(), result)注意:只对幂等请求缓存(如"翻译这段文本"),对需要实时性的请求(如"今天天气")不要缓存,否则返回过期结果。
六、监控与迭代
成本优化不是一次性工程,上线后要持续监控这几个指标:
| 指标 | 含义 | 告警阈值(参考) |
|---|---|---|
| 日总花费 | 每日 API 成本 | 日环比涨 50% |
| 缓存命中率 | cached_tokens / input_tokens | < 30%(有优化空间) |
| 模型分布 | 各模型调用占比 | 贵模型占比 > 60%(查路由) |
| 单请求均成本 | 总花费 / 请求数 | 周环比涨 20% |
| batch 占比 | batch 请求数 / 总请求数 | < 20%(有空间) |
把第一步的 CostTracker 接到生产环境,每天跑一次 summary(),趋势看一周。如果缓存命中率从 60% 掉到 20%,大概率是 prompt 结构被改了导致前缀漂移;如果贵模型占比突然升高,检查是不是路由规则有漏洞让简单任务漏到了贵模型上。
七、踩坑记录
坑一:缓存命中需前缀完全一致。 system prompt 改一个标点、few-shot 顺序换一条、工具列表增删一个,缓存全失效。把稳定段和变化段严格分开,稳定段放最前。
坑二:batch API 有延迟不适合实时。 batch 是"几小时内出结果"的模式,用户等不了的实时对话、在线推理场景不能用,只适合离线批量任务。
坑三:路由分级不留 fallback 会炸。 主模型限流或超时,没有 fallback 直接 500。必须配备选模型,降级也比报错强。
坑四:动态内容注入 prompt 头部击穿缓存。 时间戳、随机 ID、用户昵称放在 prompt 开头,每次都不同,缓存永远不命中。这类内容放到 prompt 最末尾(用户消息部分)。
坑五:上下文裁太狠丢失关键信息。 max_messages 设太小,agent 丢失任务上下文,导致重复提问或答非所问。经验值:对话场景留 10-20 条,agent 场景用摘要压缩而非硬截断。
坑六:只看单价不看总账。 单次调用便宜了 50%,但因为便宜了所以调用频次翻倍,总花费反而涨了。优化要看总账,不是单次成本。
FAQ
Q1:前缀缓存能省多少?
取决于 prompt 结构和调用频率。如果 system prompt + 长文档占输入的 80%,且高频调用,缓存命中后那 80% 按缓存价计费。以 Qwen3.8-Max 为例,缓存命中价是未命中价的 1/12(以官网为准),命中率高时输入成本可降到原来的十几分之一。低频调用(一天几次)缓存容易过期,收益有限。
Q2:batch API 多久出结果?
OpenAI Batch API 的 completion_window 设为 24h,实际通常几小时内完成,但不保证实时。Anthropic 的 Message Batches 类似。batch 适合数据标注、批量翻译、离线评估这类不急的任务,实时场景不能用。
Q3:小团队先做哪一步?
先量基线(Step 1),再接前缀缓存(Step 2)。这两步投入最小、收益最直接:CostTracker 是几十行代码,前缀缓存对 Anthropic 来说就是加一行 cache_control。路由和 batch 是第二步,需要改调用逻辑。上下文裁剪和监控是第三步,持续迭代。
Q4:路由会不会掉质量?
简单任务不会。翻译、分类、摘要这类任务,便宜模型和贵模型差距很小,但价格差几倍。关键是路由规则要准确——如果把难题误判为易题发给便宜模型,质量会掉。建议从保守路由开始(只把明确简单的任务分流),逐步扩大。级联模式(先便宜模型试,不达标再升级)更稳但延迟翻倍。
Q5:缓存命中率怎么提?
三件事:(1)prompt 结构固化,稳定段(system + 文档 + 规则)放最前,变化段(用户输入)放最后;(2)避免在前缀里注入动态内容(时间戳、随机数、用户 ID),必须注入的放到用户消息部分;(3)调用频率要够高,保证 TTL 内有后续请求命中。如果调用频率低,考虑用更长 TTL 的缓存选项(如 Anthropic 的 1 小时缓存,以官网为准)。
看法
成本优化的本质不是"省钱",而是"让钱花在刀刃上"。当一个 agent 任务需要跑 50 轮才能收敛,如果每轮调用成本太高,你根本不敢让它跑——要么中途放弃,要么人为缩减迭代次数,牺牲质量。前缀缓存把单次调用成本压到原来的十几分之一,意味着同样的预算可以跑十几倍的迭代,或者同样的迭代次数只花十几分之一的钱。这才是成本优化的真正价值:它改变的是"敢不敢让模型充分试"的决策门槛。Qwen3.8-Max 把缓存命中和未命中的价差拉到 12 倍,本质上是在告诉开发者:长程任务可以跑了,只要你把前缀缓存用好。
参考来源