Skip to content
AI 应用成本优化:Token 控制、缓存策略与请求优化
概述
LLM API 的成本由多次请求累计而成。单次请求的费用主要取决于输入 token 数量、输出 token 数量,以及是否命中供应商侧的缓存。与本地程序不同,LLM API 的每次调用都带有用量计量,因此成本优化需要围绕 token 控制和缓存展开。
下文先从计费模型开始,依次讨论 Token 控制、缓存策略、请求优化和成本观测。
基本概念:计费模型与成本构成
Input/Output Token 计费与缓存费用
主流 LLM API 的计费单位是 token。以 OpenAI 的 gpt-4o-2024-08-06 为例,其价格表如下:
| 计费项 | 每 1M token 价格 |
|---|---|
| 未缓存输入 token | $2.50 |
| 缓存输入 token | $1.25 |
| 输出 token | $10.00 |
从这个表中可以读出的信息是:
- 输出 token 价格远高于输入 token;
- 缓存输入价格约为未缓存输入的一半;
- 输入 token 在 prompt 较长时会成为不可忽略的固定成本。
因此,成本优化的两个基本方向是:减少输入 prompt 与输出 token,并尽可能让输入命中缓存。
usage 字段
几乎所有 API 都会在响应中返回 token 用量。以 OpenAI Chat Completions 接口为例,在 Node.js 中可以直接读取响应中的 usage 字段:
js
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '你是一个简洁的助手。' },
{ role: 'user', content: '用一句话解释什么是 token。' },
],
max_tokens: 100,
}),
});
const data = await response.json();
console.log(data.usage);上面的 max_tokens 参数用于限制输出 token 数量。不同供应商对这个字段的命名略有差异,但作用是相同的。
以 OpenAI 的返回结构为例,usage 通常包含三个数值:
prompt_tokens:输入 token 数;completion_tokens:输出 token 数;total_tokens:两者之和。
基于 usage 可以写一个简单的成本估算函数:
js
function estimateCost(usage, pricing) {
const inputCost = usage.prompt_tokens / 1_000_000 * pricing.inputPerMillion;
const outputCost = usage.completion_tokens / 1_000_000 * pricing.outputPerMillion;
return inputCost + outputCost;
}使用方式:
js
const pricing = {
inputPerMillion: 2.50, // 未缓存输入
outputPerMillion: 10.00, // 输出
};
const cost = estimateCost(data.usage, pricing);
console.log(`本次请求估算成本: $${cost.toFixed(6)}`);如果后续引入了 Prompt Caching,需要在估算时将 inputPerMillion 替换为缓存输入价格,或者同时记录缓存命中 token 数。若直接替换,成本估算会产生偏差,因此请求级别的日志最好同时保留这两个数据。
Token 控制:输入压缩与输出约束
max_tokens 与输出截断
限制输出 token 数量是单次请求中最直接的成本控制手段。调用接口时,如果只希望模型返回一个简短答案,却把输出上限设置得很大,模型可能会生成大量冗余内容,并按照输出价格计费。
js
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
max_tokens: 128,
});当输出达到上限时,响应会被截断。此时 usage.completion_tokens 等于上限值,用户得到的内容不完整,但该次用量仍按实际生成的 token 计费。因此,在设计输出长度时,既要避免浪费,也要为模型留出足够的空间完成回答。
结构化输出
结构化输出也是一种降低输出浪费的方式。部分供应商提供强制 JSON 输出等格式限制,让响应只包含结构化数据,省略模型默认添加的解释性文本。例如,让模型只返回 {"label": "spam"},而不是“根据您的要求,我将这封邮件分类为垃圾邮件……”,可以明显减少输出 token。
Prompt 压缩与历史裁剪
输入 token 的浪费通常来自 prompt 本身。
一段 prompt 由系统指令、上下文材料、历史消息和用户问题组成。常见做法是:
- 精简系统指令,去掉重复表述;
- 少量示例只保留对任务有区分度的部分;
- 多轮对话只保留最近若干轮;
- RAG 检索结果只截取与当前问题最相关的片段。
对于多轮对话,可以裁剪历史消息:
js
function trimMessages(messages, maxCount) {
if (messages.length <= maxCount) return messages;
const systemMessages = messages.filter((msg) => msg.role === 'system');
const history = messages.filter((msg) => msg.role !== 'system');
const trimmed = history.slice(-maxCount);
return [...systemMessages, ...trimmed];
}这里 maxCount 表示保留的非系统消息数量。保留系统消息是因为它通常包含任务定义,删掉后模型可能失去行为约束。
注意,历史裁剪会改变输入前缀,也可能影响供应商侧 Prompt Caching 的命中。这个问题在下一节会进一步展开。
缓存策略:从供应商 Cache 到语义缓存
供应商侧 Prompt Caching 的命中条件
OpenAI 的 Prompt Caching 是一种自动生效的前缀缓存。当 prompt 超过 1,024 token 时,API 会缓存已经计算过的公共前缀,缓存长度以 128 token 递增。后续请求如果包含相同的公共前缀,则可以按缓存输入价格计费,并降低 prompt 处理延迟。
这个机制不需要修改应用代码,只需要满足一个条件:请求的 prompt 前缀保持一致。
下面的例子中,系统提示和背景文档保持不变,用户问题依次变化:
js
const systemPrompt = readLongDocument();
const questions = [
'总结这篇文章的要点。',
'这篇文章的作者想表达什么?',
'文章中有哪些数据值得注意?',
];
for (const question of questions) {
const data = await callChatCompletion([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
]);
console.log(data.usage);
}由于三次请求的 systemPrompt 完全相同,API 会复用这一长串前缀的计算结果,后续请求只需要处理新的用户问题部分。
反过来,如果每次请求都向 system prompt 中追加动态内容,例如当前时间戳,公共前缀就会被破坏,缓存命中率随之下降。因此,在应用层进行 Prompt 压缩时,需要与 Prompt Caching 的要求一起考虑:可以压缩的部分应尽量放在公共前缀之后,而不是之前。
应用层精确缓存
供应商侧 Prompt Caching 按缓存后的输入价格计费,但仍然按 token 计费。如果用户大量提出完全相同的问题,在应用层直接缓存完整响应,可以完全跳过下一次 API 调用。
精确缓存可以用消息内容生成 key:
js
import { createHash } from 'node:crypto';
function exactCacheKey(model, messages) {
const payload = JSON.stringify({ model, messages });
return `exact:${createHash('sha256').update(payload).digest('hex')}`;
}这个 key 可以存入 Redis 或进程内缓存。实际业务中,还需要考虑以下维度:
- 模型版本变化后,旧缓存是否仍然有效;
- 知识库或 RAG 文档更新后,缓存响应是否过期;
- 不同租户之间是否需要隔离。
因此,缓存 key 中经常包含模型标识和知识库版本等元数据。版本变化后,key 自然失效,不需要手动清理全部旧数据。
语义缓存
精确缓存只能处理字节完全相同的请求。对于“Redis 是什么”和“介绍下 Redis”这类表达不同、语义相同的问题,需要用到语义缓存。
Redis 官方文档给出的语义缓存实现模式是:把用户查询转换为向量,在 Redis 中执行向量搜索,找到语义距离最近的缓存条目。如果相似度超过阈值,就直接返回缓存响应,不再调用 LLM。
一个简化流程如下:
js
import { createClient } from 'redis';
const client = createClient();
await client.connect();
async function semanticLookup(prompt, threshold = 0.9) {
const vector = await embed(prompt);
const results = await client.ft.search('idx:llm_cache', '*', {
PARAMS: { vector },
SORTBY: 'VECTOR_SCORE',
LIMIT: { from: 0, size: 1 },
});
if (results.total === 0) return null;
const best = results.documents[0];
if (best.score >= threshold) {
return JSON.parse(best.value.response);
}
return null;
}语义缓存的准确率取决于阈值。通常相似度阈值的参考范围在 0.85 到 0.95 之间。阈值设置过低会把不同问题判断为同一问题,导致返回错误答案;阈值设置过高则会降低命中率。
语义缓存适合自然语言变化较多的场景,例如客服问答、知识库检索。它不适合 prompt 由固定模板拼接的场景,因为那些场景下精确缓存已经足够,不需要引入向量搜索的复杂度和误判风险。
多级缓存组合
多级缓存组合时,一般顺序是:
- 精确缓存:直接返回完全相同的请求;
- 语义缓存:处理近似重复的自然语言问题;
- 供应商侧 Prompt Cache:未在应用层命中,但公共前缀仍然可以享受输入折扣。
各级缓存都有自己的失效条件。精确缓存和语义缓存通常设置 TTL,并在模型版本、知识库版本变化时主动失效。供应商侧缓存由服务商管理,应用无法主动清除,只能通过改变 prompt 前缀来绕过。
请求优化:模型路由、批处理与流式
模型路由与 fallback
不同模型的定价差异很大。一个应用里,摘要、分类、信息抽取等任务并不都需要调用最强模型。模型路由的核心思想是根据任务需求选择合适价格区间的模型。
js
function selectModel(task) {
switch (task.type) {
case 'classify':
return 'gpt-4o-mini';
case 'extract':
return 'gpt-4o-mini';
case 'reasoning':
return 'gpt-4o';
default:
return 'gpt-4o-mini';
}
}路由决策需要可观测。每次请求应当记录实际使用的模型、token 用量和估算费用,否则无法判断不同路由策略的成本差异。
fallback 用于处理某个模型暂时不可用的情况。当主模型请求失败时,可以降级到另一个模型重试。由于不同模型的价格和响应格式可能不同,fallback 后需要把模型信息写入日志,而不能假设成本和原模型一致。
超时重试的成本影响
超时和重试会直接影响成本。如果请求因为网络中断而没有到达服务端,重试不产生额外费用。但如果请求已经被服务端接收并开始生成,由于客户端超时断开,模型生成的 token 可能已经被计费。无限重试会让这类成本反复出现。一般做法是设置有限重试次数,并加入指数退避:
js
async function callWithRetry(messages, maxRetries = 2) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await callChatCompletion(messages);
} catch (err) {
lastError = err;
await sleep(2 ** attempt * 200);
}
}
throw lastError;
}对于不适合重试的请求,可以在错误类型中判断:客户端参数错误不需要重试;网络超时或 5xx 错误可以重试。
Batch API 与并发
实时 API 面向交互式请求。如果任务不要求立即返回结果,可以使用批处理接口。OpenAI 和 Anthropic 都提供 Batch API,这类接口一般会有价格折扣,但需要等待较长的处理窗口。
Batch API 适合离线数据标注、日志分类、批量摘要等场景。对于在线对话和 Agent 任务,批处理无法满足延迟要求。
并发可以提升吞吐量,但不会降低单价。同样的 token 量,无论并发还是串行,费用相同。并发的主要意义是缩短大量请求的总耗时。
流式输出与 usage 获取
流式输出让客户端边接收边生成,减少首字延迟,但不会改变 token 计费。流式响应中通常不直接给出完整 usage,需要在请求中显式要求使用量统计。以 OpenAI 为例,可以在请求体中开启 usage 统计:
js
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
stream: true,
stream_options: { include_usage: true },
});流式响应中的 usage 会在最后一个 chunk 返回。解析时需要注意:
js
const reader = stream.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const chunk = line.slice(6);
if (chunk === '[DONE]') continue;
const json = JSON.parse(chunk);
if (json.usage) {
console.log('total tokens:', json.usage.total_tokens);
}
}
}如果不开启这个选项,流式响应只包含增量内容,不包含完整 usage,成本统计就会出现缺口。
成本观测与中间层设计
请求协调器
成本优化不能只停留在代码层面,还需要让优化效果可观测。一个常见的做法是在应用与 LLM API 之间加入一个中间层,由它统一处理缓存、模型路由、用量统计和告警。
这个中间层不一定要引入额外服务。使用一个类就可以完成基本职责:
js
class LlmRequestCoordinator {
constructor({ cache, router, metrics }) {
this.cache = cache;
this.router = router;
this.metrics = metrics;
}
async chat(messages, task) {
const model = this.router.select(task);
const key = exactCacheKey(model, messages);
const cached = await this.cache.get(key);
if (cached) {
this.metrics.recordCacheHit(model);
return cached.response;
}
const data = await callChatCompletion(messages, model);
await this.cache.set(key, { response: data }, { ttl: 3600 });
this.metrics.recordUsage(model, data.usage);
return data;
}
}缓存命中时,请求不再消耗 token。因此,观测指标需要区分两个层面:
- 进入中间层的全部请求数;
- 真正调用 LLM API 的请求数。
缓存命中率的定义如下:
js
const hitRate = cacheHits / (cacheHits + cacheMisses);这里的 cacheHits 指精确缓存或语义缓存命中并直接返回响应的次数。cacheMisses 指实际触发 LLM 调用的次数。
预算告警
请求级费用估算同样由中间层完成。每次真实调用后,将 usage 和当前模型价格代入估算函数,把结果写入日志。累计到一定金额后触发预算告警:
js
async function assertBudget(metrics, dailyLimit) {
const cost = await metrics.getTodayCost();
if (cost > dailyLimit) {
throw new Error(`每日预算已用尽: $${cost.toFixed(2)}`);
}
}中间层的设计目标是让业务代码不感知缓存和路由细节。业务代码只需要传入消息和任务类型,其余逻辑由中间层处理。
适用场景与限制
不同的优化手段有各自的适用条件。
多轮对话场景中,系统提示通常保持不变,适合利用供应商侧 Prompt Caching。对话历史会不断变化,不适合放入公共前缀,应采用历史裁剪控制输入长度。
RAG 知识库问答场景中,用户问题的表达方式很多,语义缓存可以直接复用已验证的答案。需要关注的是知识库更新。知识库版本变化后,旧答案可能已经不正确,缓存必须失效。
Agent 场景中,一个任务会触发多次 LLM 调用。每次调用都有固定的系统提示和工具描述。这部分公共内容可以享受供应商侧缓存折扣,同时应避免在每一步都重复注入冗余内容。
同时,成本优化有自己的边界。语义缓存引入的相似度判断可能返回错误答案,在事实性和安全敏感场景中需要谨慎;更便宜的模型可能降低回答质量,导致应用需要多次重试或人工修正,反而增加总成本。因此,每一项优化都应该通过用量日志和数据来验证,而非默认“所有请求都应该走缓存”或“所有任务都应该换更便宜的模型”。
