18.06-AI响应缓存
要点
- LLM 响应缓存是最直接的省钱手段——相同 prompt 不重复调模型,token 成本直接降下来
- 缓存键必须包含 model + messages + temperature + 所有影响输出的参数——少一个都可能返回错误结果
temperature > 0时模型输出随机,缓存会把多样性抹掉——看业务要不要- 流式响应有三种缓存策略:不缓存、缓存完整响应一次性返回、缓存完整响应假装再流一遍
- 大多数场景适合「缓存完整响应,命中时假装再流一遍」——用户体验不变,后端完全省 token
- 缓存失效策略:TTL(按场景)、主动删除(用户更新数据后)、版本号(schema 变更)
1. 为什么要缓存 LLM 响应
LLM 调用有两个硬成本:
- Token 费用:输入 + 输出 token 都要钱。Claude Opus 4.6 的输入 $15/M token,输出 $75/M token。一个 1000 token 的对话大约 $0.01——听起来不多,但月活 10 万用户每人每天 10 次对话就是 $30,000/月
- 延迟:LLM 响应几百毫秒到几秒。用户问「什么是 X」这种重复性问题,为什么要等 2 秒?
缓存能同时解决两个问题——命中率 30% 就能把成本和延迟砍掉三成。
2. 缓存键设计
缓存 LLM 响应的第一个问题:用什么做 key?
2.1 必须包含的参数
任何影响模型输出的参数都要进 key:
async function llmCacheKey(params: {
model: string
messages: Message[]
temperature?: number
topP?: number
maxTokens?: number
system?: string
}): Promise<string> {
// 排序保证相同参数不同顺序也能命中
const normalized = {
model: params.model,
messages: params.messages,
temperature: params.temperature ?? 1.0,
topP: params.topP ?? 1.0,
maxTokens: params.maxTokens,
system: params.system,
}
const json = JSON.stringify(normalized, Object.keys(normalized).sort())
const hash = await sha256(json)
return `llm:${params.model}:${hash}`
}2.2 容易漏掉的参数
- system prompt:同样的 user message,system prompt 不同输出完全不同
- tools / functions:工具定义变了,模型可能选择不同的工具
- seed:有些 API 支持
seed参数强制确定性输出——如果用了就更要进 key - stop sequences:停止词不同,输出长度不同
少一个参数就可能把错误的响应返回给用户。
2.3 messages 数组的序列化
messages 是数组,顺序很重要。[{role: 'user', content: 'hi'}] 和 [{role: 'user', content: 'hi'}] 要能识别为相同。
直接用 JSON.stringify 就行——数组顺序会保留。
3. 实现
// src/lib/llm-cache.ts
export async function callLLMCached(
env: Env,
params: LLMParams
): Promise<{ response: string; cached: boolean }> {
const key = await llmCacheKey(params)
// 1. 查缓存
const cached = await env.LLM_CACHE.get(key)
if (cached) {
return { response: cached, cached: true }
}
// 2. miss,调模型
const result = await env.AI.run(params.model, {
messages: params.messages,
temperature: params.temperature,
max_tokens: params.maxTokens,
})
const response = (result as { response: string }).response
// 3. 写缓存,TTL 按场景定
const ttl = pickTTL(params)
await env.LLM_CACHE.put(key, response, { expirationTtl: ttl })
return { response, cached: false }
}
function pickTTL(params: LLMParams): number {
// 事实类问题(不带 history)缓存久一点
if (params.messages.length === 1) {
return 86400 // 24 小时
}
// 多轮对话缓存短一点(上下文变化快)
return 3600 // 1 小时
}4. temperature 与缓存的矛盾
temperature 控制模型输出的随机性:
temperature = 0:确定性输出,相同 prompt 总是返回相同答案——缓存完美匹配temperature = 0.7:有一定随机性,相同 prompt 可能返回不同答案——缓存会抹掉多样性temperature = 1.0+:高随机性,缓存几乎没意义
4.1 业务怎么决策
- 客服问答、知识库:用户问的事实问题,答案应该一致——可以用缓存,temperature 设低
- 创意写作、头脑风暴:用户期待多样性——缓存不合适,temperature 设高
- 代码生成:答案通常唯一——可以缓存
一个折中方案:按 model 或 endpoint 决定是否缓存。
const CACHEABLE_MODELS = new Set([
'@cf/meta/llama-3.1-8b-instruct', // 通用问答,可缓存
])
if (CACHEABLE_MODELS.has(params.model) && (params.temperature ?? 1) < 0.3) {
return callLLMCached(env, params)
} else {
return callLLMDirect(env, params)
}4.2 强制缓存 + seed 参数
有些模型支持 seed 参数——相同 seed 强制相同输出。配合缓存使用可以确保一致性:
const result = await env.AI.run(model, {
messages,
temperature: 0.7,
seed: 42, // 固定 seed,保证缓存命中后输出一致
})不是所有模型都支持 seed。Cloudflare 的模型大多不支持——用 temperature 0 更稳妥。
5. 流式响应的缓存
流式(SSE)是 AI 接口的标配——用户看到「打字机效果」体验更好。但流式给缓存带来挑战。
5.1 三种策略
策略 A:不缓存
最简单。流式本来就是追求「低延迟首字」的场景,缓存价值被稀释。
app.post('/api/ai/stream', async (c) => {
const stream = await callLLMStream(c.env, await c.req.json())
return streamingResponse(stream)
})策略 B:缓存完整响应,命中时一次性返回
缓存存完整文本,命中时不再流式,一次性返回。用户体验变差(失去打字机效果)。
策略 C:缓存完整响应,命中时假装再流一遍(推荐)
缓存存完整文本,命中时分 chunk 写回 SSE。前端看到的体验几乎一样,后端完全不花 token。
// src/lib/stream-cache.ts
export async function callLLMStreamCached(
env: Env,
params: LLMParams
): Promise<ReadableStream> {
const key = await llmCacheKey(params)
const cached = await env.LLM_CACHE.get(key)
if (cached) {
// 命中缓存:假装流式返回
return fakeStreamResponse(cached)
}
// miss:真实流式调用,同时把完整响应存进缓存
const { stream, fullResponse } = await callLLMStreamWithCapture(env, params)
// 异步存缓存
env.waitUntil(
fullResponse.then((text) => env.LLM_CACHE.put(key, text, { expirationTtl: 3600 }))
)
return stream
}
function fakeStreamResponse(fullText: string): ReadableStream {
const encoder = new TextEncoder()
const chunks = splitIntoChunks(fullText, 20) // 每 20 字符一个 chunk
return new ReadableStream({
async pull(controller) {
for (const chunk of chunks) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ text: chunk })}\n\n`)
)
await new Promise((r) => setTimeout(r, 50)) // 模拟打字延迟
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
}
function splitIntoChunks(text: string, size: number): string[] {
const chunks: string[] = []
for (let i = 0; i < text.length; i += size) {
chunks.push(text.slice(i, i + size))
}
return chunks
}5.2 假装流式的细节
- chunk 大小:20-50 字符比较自然
- 延迟:30-100ms 之间,模拟真实打字速度
- 标点符号处可以加额外延迟,更像人打字
但这些都是锦上添花——大多数业务不需要这么细,直接一次性返回也能接受。
6. Token 成本估算
算一下缓存能省多少钱。
假设:
- 模型:Claude Opus 4.6(输入 $15/M,输出 $75/M)
- 平均每次调用:输入 500 token,输出 200 token
- 单次成本:500 × $15 / 1M + 200 × $75 / 1M = $0.0075 + $0.015 = $0.0225
- 月调用量:100 万次
月成本:$22,500
如果命中率 30%:
- 30 万次走缓存,省 $6,750
- 实际成本:$15,750
如果命中率 50%:省一半,$11,250。
缓存的收益跟调用量、prompt 重复度正相关。客服场景重复度极高,命中率可能 60-80%。代码助手场景重复度低,命中率 10-20%。
7. 失效策略
7.1 TTL
最简单。按场景设不同 TTL:
| 场景 | TTL |
|---|---|
| 事实问答(「什么是 X」) | 24 小时 |
| 多轮对话 | 1 小时 |
| 实时数据(「今天天气」) | 不缓存 |
| 代码生成 | 12 小时 |
7.2 主动删除
用户更新了知识库数据后,相关缓存应该失效:
app.put('/api/knowledge/:id', async (c) => {
const id = c.req.param('id')
const data = await c.req.valid('json')
await updateKnowledge(c.env.DB, id, data)
// 删除依赖这条知识的缓存
// 问题:怎么知道哪些 prompt 用到了这条知识?
// 解决:存 prompt → knowledge_id 的反向索引
const cacheKeys = await c.env.CACHE_INDEX.query(`knowledge:${id}`)
for (const key of cacheKeys) {
await c.env.LLM_CACHE.delete(key)
}
return c.json({ ok: true })
})反向索引维护成本高。一个简化方案:按知识 ID 前缀删缓存。
// 写缓存时记录依赖
await env.LLM_CACHE.put(key, response, {
expirationTtl: 86400,
metadata: { knowledgeIds: ['k1', 'k2'] }, // 用到了 k1, k2
})
// 删除时按知识 ID 过滤
// KV 不支持按 metadata 查询——需要自己维护反向索引(D1 表)7.3 版本号
缓存 key 里带版本号,schema 变更时改版本号:
const CACHE_VERSION = 'v3'
async function llmCacheKey(params: LLMParams) {
const hash = await sha256(JSON.stringify(params))
return `llm:${CACHE_VERSION}:${params.model}:${hash}`
}
// 升级 system prompt 时
const CACHE_VERSION = 'v4' // 改一处,所有旧缓存自然过期旧缓存还在 KV 里,但没人读到,等 TTL 过期自动清理。
8. 实战:完整流程
// src/routes/ai.ts
import { Hono } from 'hono'
import { callLLMStreamCached } from '../lib/stream-cache'
const app = new Hono<{ Bindings: Env }>()
app.post('/api/ai/query', async (c) => {
const body = await c.req.json()
// 校验参数
if (!body.messages || body.messages.length === 0) {
return c.json({ error: 'messages required' }, 400)
}
// 调 LLM(带缓存)
const stream = await callLLMStreamCached(c.env, {
model: body.model || '@cf/meta/llama-3.1-8b-instruct',
messages: body.messages,
temperature: body.temperature,
maxTokens: body.maxTokens,
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', // HTTP 层不缓存(POST 本来也不缓存)
},
})
})9. 小结
LLM 响应缓存是最直接的省钱手段。关键点:
- 缓存键:必须包含 model + messages + temperature + 所有影响输出的参数,少一个就可能返回错误结果
- temperature 与缓存:temperature 高时输出随机,缓存会抹掉多样性。按业务决策——事实问答可以缓存,创意写作不要缓存
- 流式缓存:推荐「缓存完整响应,命中时假装再流一遍」——用户体验不变,后端完全省 token
- 失效策略:TTL(按场景)、主动删除(用户更新数据后)、版本号(schema 变更)
Token 成本估算:命中率 30% 能省三成成本。客服场景重复度高,命中率可达 60-80%。
下一篇讲 embedding 缓存——同一段文本的 embedding 几乎永远不变,缓存 TTL 可以设得很长。这是 RAG 系统的必备优化。