调用 LLM API 的成本纪律:四个组合拳
做 LLM 应用的早期,很容易陷入一个默认假设:把所有请求都打给最强的模型,反正「质量最重要」。这个假设在原型阶段无害,但一旦上了批量处理或者生产流量,账单的增速往往比你预期的快一个量级。
问题不是「用了贵的模型」,而是对每一次调用缺乏意识——不知道这次花了多少,为什么花,下次怎么少花一点。
我沉淀出来的做法是把四个独立的模式组合进同一条 pipeline,每个模式只做一件事,但组合起来之后,成本就从黑箱变成了可观测的对象。
模型路由:让复杂度决定价格
同一厂商的不同模型,价格差距可以达到 4 倍甚至更多。Haiku 级别的模型处理分类、摘要、格式转换类任务完全够用;Sonnet 级别才值得用在推理链较长、上下文量大的任务上。
路由逻辑不必复杂,两个维度往往就够了:输入文本长度和需要处理的条目数量。
def select_model(text_length: int, item_count: int) -> str:
if text_length >= 10_000 or item_count >= 30:
return MODEL_SONNET # 复杂任务
return MODEL_HAIKU # 简单任务,便宜 3-4 倍关键原则:从最便宜的开始,只在明确不够用时升档。「这个任务感觉挺难的」不是升档的理由;「低档模型给出了有推理缺口的输出」才是。阈值是否合理,要靠实际日志来校准,不要凭感觉定死。
预算熔断:失败要快,不要慢慢烧钱
批量任务有一个典型陷阱:任务跑了一半发现效果不对,但这时候已经花掉了一半的预算。如果能在超出预算时立即停止,至少损失可控。
用不可变数据结构跟踪累计花销是个好主意——每次调用返回一个新的 tracker 而不是修改共享状态,调试时可以完整回溯调用链:
@dataclass(frozen=True, slots=True)
class CostTracker:
budget_limit: float = 1.00
records: tuple[CostRecord, ...] = ()
def add(self, record: CostRecord) -> "CostTracker":
return CostTracker(budget_limit=self.budget_limit, records=(*self.records, record))
@property
def over_budget(self) -> bool:
return sum(r.cost_usd for r in self.records) > self.budget_limit每次调用前先检查 tracker.over_budget,超了就抛异常,不继续往下打。预算限额在任务开始前设定,不是在任务跑完之后对账。
在批量任务启动前设置明确的 budget_limit,让超预算的情况在运行时可见、可停止,而不是等账单邮件到了才发现问题。
窄口重试:只对临时错误重试
不加区分地对所有错误重试,是一种常见的成本陷阱。认证失败重试 3 次,只是多花了 3 倍的时间;prompt 格式有问题重试 10 次,是用 10 倍的成本证明了 prompt 依然有问题。
正确的做法是只对临时性错误重试:网络连接失败、限流(429)、服务端 5xx。对认证错误、请求格式错误这类永久性错误,立即失败,不浪费 token。
_RETRYABLE = (APIConnectionError, RateLimitError, InternalServerError)
def call_with_retry(func, max_retries=3):
for attempt in range(max_retries):
try:
return func()
except _RETRYABLE:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt) # 指数退避
# AuthenticationError / BadRequestError 直接往上抛重试间隔用指数退避,避免在限流期间持续轰炸同一个接口。
提示词缓存:重复的 system prompt 只付一次
如果你的 system prompt 超过 1024 tokens,每次请求都把它完整地发一遍,是一种不必要的浪费。Claude 支持对消息内容打上缓存标记,缓存命中时输入成本会大幅降低,同时首 token 延迟也会缩短。
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"}, # 缓存这部分
},
{
"type": "text",
"text": user_input, # 每次变化的部分不缓存
},
],
}
]只有静态的、每次请求都相同的内容值得缓存;用户输入这类每次都变化的内容不应该加缓存标记。对于批量处理场景,共享同一份 system prompt 加上不同的 user_input,缓存的收益会非常显著。
四个模式组合起来
这四个模式的关系不是非此即彼,而是正交可叠加的:路由决定用哪个模型,缓存降低单次输入成本,熔断控制总量,重试处理抖动。一个完整的 pipeline 函数把它们串起来,签名大概是:
def process(text: str, tracker: CostTracker) -> tuple[Result, CostTracker]:
model = select_model(len(text), ...)
if tracker.over_budget:
raise BudgetExceededError(...)
response = call_with_retry(lambda: client.messages.create(
model=model,
messages=build_cached_messages(system_prompt, text),
))
record = CostRecord(model=model, ...)
return parse_result(response), tracker.add(record)返回新的 tracker 而不是修改旧的,让调用方可以完整地观测整个批次的消费分布,而不只是一个最终总数。
一句话心法
成本控制不是省钱,是把每次 LLM 调用变成可观测、可追溯、可干预的动作——知道花在哪里,才能决定哪里值得多花。