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 是数组,顺序很重要。[&#123;role: 'user', content: 'hi'&#125;][&#123;role: 'user', content: 'hi'&#125;] 要能识别为相同。

直接用 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 系统的必备优化。