LLM 缓存的难点不在存取,而在判断两个请求是否真的等价。本文从模型、提示词、工具和租户上下文出发,实现一个可运行的 Node.js 缓存层,并处理 TTL、主动失效与 SSE 流式回放。

一次请求到底由什么决定

普通 HTTP 缓存常把 URL、方法和少量请求头作为键,但 LLM 输出通常由更多隐含输入共同决定:

输入忽略后的风险
模型及发布版本模型升级后继续返回旧答案
system prompt 内容提示词修改后命中旧逻辑
消息历史不同对话得到相同缓存
工具定义工具参数或能力变化未生效
租户、用户和权限版本跨用户泄漏或越权读取
温度等生成参数不同采样策略共用结果
应用输出协议版本新代码读到旧结构

例如,两个租户都向 /chat 提交“查询本月账单”。如果反向代理只按 URL 缓存,租户 B 可能直接收到租户 A 的回答。即使把请求体加入键,system prompt、服务端注入的权限和工具定义也未必存在于原始 HTTP body 中。

因此,缓存键必须在完成身份认证、提示词解析和工具装配之后生成。租户 ID、用户 ID 不能直接信任客户端 body,而应来自网关验证后的令牌声明。外层 HTTP 响应则应设置 Cache-Control: no-store,避免 CDN 或代理再次缓存 SSE;本文实现的是应用内部缓存。

设计版本化且不可猜测的缓存键

安全的键应覆盖所有影响输出的输入,但不应直接拼接原始消息、邮箱或访问令牌。一个实用做法是先稳定序列化,再计算 SHA-256:

llm:v1:sha256({
  schemaVersion,
  tenantId,
  subjectId,
  permissionVersion,
  model,
  promptVersion,
  promptDigest,
  toolDefinitions,
  messages,
  generationParameters
})

这里同时保存提示词版本和内容摘要。正常发布时递增版本便于主动失效;即使有人忘记改版本,内容摘要仍能防止错误命中。对象键必须排序,否则语义相同但属性顺序不同的工具定义会产生不同键;数组不能排序,因为消息和工具参数中的数组可能有顺序语义。

租户和用户参与摘要意味着不同身份无法共享条目。若业务确认某类公共知识回答与用户无关,可以单独设计经过审计的公共缓存空间,而不要通过删除 subjectId 临时扩大命中率。缓存键隔离是纵深防御,真正的权限检查仍必须发生在生成键之前。

模型名称最好使用固定发布标识,而不是会悄然升级的别名。温度为零也不保证供应商推理永远确定,因此模型升级、提示词发布和应用协议变化仍需版本化。

可运行的 Node.js 缓存与流式回放

下面示例只使用 Node.js 20 的内置 API。为了无需外部密钥即可运行,callModel 是明确标注的本地模型适配器模拟;接入真实 SDK 时替换这个异步生成器即可,其余缓存逻辑不依赖不存在的接口。

服务只在完整收到 done 事件后写缓存。客户端中断、上游报错或半截响应都不会留下不完整条目。缓存保存结构化事件,而不是拼接后的文本,因此命中后仍能按 SSE 逐事件回放。

// server.mjs
import http from 'node:http';
import { createHash } from 'node:crypto';

const PORT = Number(process.env.PORT || 3000);
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'dev-secret';
const cache = new Map();

const prompts = {
  'support-v3': '你是客服助手,只根据已授权的信息回答。'
};

const toolRegistry = {
  lookup_docs: {
    type: 'function',
    name: 'lookup_docs',
    description: '查询只读文档',
    cacheMode: 'read-only'
  },
  create_ticket: {
    type: 'function',
    name: 'create_ticket',
    description: '创建工单',
    cacheMode: 'side-effect'
  }
};

function stable(value) {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value);
  }
  if (Array.isArray(value)) {
    return `[${value.map(stable).join(',')}]`;
  }
  return `{${Object.keys(value).sort().map(
    key => `${JSON.stringify(key)}:${stable(value[key])}`
  ).join(',')}}`;
}

function digest(value) {
  return createHash('sha256').update(stable(value)).digest('hex');
}

function makeCacheKey(input) {
  return `llm:v1:${digest({
    schemaVersion: 'chat-events-v1',
    tenantId: input.tenantId,
    subjectId: input.subjectId,
    permissionVersion: input.permissionVersion,
    model: input.model,
    promptVersion: input.promptVersion,
    promptDigest: digest(input.systemPrompt),
    tools: input.tools,
    messages: input.messages,
    parameters: { temperature: input.temperature }
  })}`;
}

async function readJson(req) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > 1024 * 1024) throw new Error('request too large');
    chunks.push(chunk);
  }
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 可替换为真实模型 SDK 的流式适配器。
async function* callModel(input) {
  const question = input.messages.at(-1)?.content || '';
  const text = `模型 ${input.model} 的演示回答:已收到“${question}”。`;
  for (const part of text.match(/.{1,6}/gu) || []) {
    await sleep(40);
    yield { type: 'delta', text: part };
  }
  yield { type: 'done' };
}

function beginSse(res, cacheStatus) {
  res.writeHead(200, {
    'content-type': 'text/event-stream; charset=utf-8',
    'cache-control': 'no-store',
    connection: 'keep-alive',
    'x-llm-cache': cacheStatus
  });
}

function sendEvent(res, event) {
  res.write(`data: ${JSON.stringify(event)}\n\n`);
}

function getEntry(key) {
  const entry = cache.get(key);
  if (!entry) return null;
  if (entry.expiresAt <= Date.now()) {
    cache.delete(key);
    return null;
  }
  return entry;
}

function invalidateTag(tag) {
  let deleted = 0;
  for (const [key, entry] of cache) {
    if (entry.tags.has(tag)) {
      cache.delete(key);
      deleted++;
    }
  }
  return deleted;
}

setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of cache) {
    if (entry.expiresAt <= now) cache.delete(key);
  }
}, 60_000).unref();

async function handleChat(req, res) {
  const tenantId = req.headers['x-tenant-id'];
  const subjectId = req.headers['x-subject-id'];
  const permissionVersion = req.headers['x-permission-version'];
  const valid = value => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,80}$/.test(value);

  // 示例假设这些头由已认证网关注入,而不是直接暴露给客户端。
  if (![tenantId, subjectId, permissionVersion].every(valid)) {
    res.writeHead(401).end('missing verified identity');
    return;
  }

  const body = await readJson(req);
  if (!Array.isArray(body.messages)) throw new Error('messages must be an array');

  const promptVersion = body.promptVersion || 'support-v3';
  const systemPrompt = prompts[promptVersion];
  if (!systemPrompt) throw new Error('unknown prompt version');

  const model = body.model || 'demo-model-2025-01';
  const temperature = Number(body.temperature ?? 0);
  const toolNames = Array.isArray(body.toolNames) ? body.toolNames : [];
  const tools = toolNames.map(name => {
    if (!toolRegistry[name]) throw new Error(`unknown tool: ${name}`);
    return toolRegistry[name];
  });

  const input = {
    tenantId,
    subjectId,
    permissionVersion,
    model,
    promptVersion,
    systemPrompt,
    tools,
    messages: body.messages,
    temperature
  };

  const cacheable = body.cache !== false
    && body.sensitive !== true
    && temperature === 0
    && tools.every(tool => tool.cacheMode === 'read-only');
  const key = cacheable ? makeCacheKey(input) : null;
  const hit = key ? getEntry(key) : null;

  if (hit) {
    beginSse(res, 'hit');
    for (const event of hit.events) {
      sendEvent(res, event);
      await sleep(10);
    }
    res.end();
    return;
  }

  beginSse(res, cacheable ? 'miss' : 'bypass');
  const events = [];
  let completed = false;
  let aborted = false;
  res.on('close', () => {
    if (!res.writableEnded) aborted = true;
  });

  for await (const event of callModel(input)) {
    if (res.destroyed) break;
    events.push(event);
    sendEvent(res, event);
    if (event.type === 'done') completed = true;
  }

  if (key && completed && !aborted) {
    const ttlSeconds = Math.min(Math.max(Number(body.ttlSeconds || 300), 1), 3600);
    cache.set(key, {
      events,
      expiresAt: Date.now() + ttlSeconds * 1000,
      tags: new Set([
        `tenant:${tenantId}`,
        `prompt:${promptVersion}`,
        `model:${model}`
      ])
    });
  }
  if (!res.destroyed) res.end();
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.method === 'POST' && req.url === '/chat') {
      await handleChat(req, res);
      return;
    }
    if (req.method === 'POST' && req.url === '/invalidate') {
      if (req.headers['x-admin-secret'] !== ADMIN_SECRET) {
        res.writeHead(403).end('forbidden');
        return;
      }
      const { tag } = await readJson(req);
      const deleted = invalidateTag(String(tag));
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ deleted }));
      return;
    }
    res.writeHead(404).end('not found');
  } catch (error) {
    if (!res.headersSent) {
      res.writeHead(400, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ error: error.message }));
    } else if (!res.destroyed) {
      sendEvent(res, { type: 'error', message: 'stream failed' });
      res.end();
    }
  }
});

server.listen(PORT, () => {
  console.log(`listening on http://localhost:${PORT}`);
});

保存后执行 node server.mjs,再连续调用两次:

curl -N http://localhost:3000/chat \
  -H 'content-type: application/json' \
  -H 'x-tenant-id: acme' \
  -H 'x-subject-id: user-7' \
  -H 'x-permission-version: 12' \
  -d '{"messages":[{"role":"user","content":"如何重置密码"}],"toolNames":["lookup_docs"]}'

第二次响应头中的 x-llm-cache 会变为 hit。换掉租户、用户、权限版本、消息或提示词版本,键都会变化。

TTL、主动失效与多实例部署

TTL 是错误影响范围的上限,而不是完整的失效方案。公共文档问答可以使用相对长的 TTL;权限、价格和库存相关回答应更短,甚至不缓存。TTL 应设置服务端上限,不能让客户端写入无限有效期。

提示词发布、模型下线或租户数据删除时,不应等待 TTL。示例为每个条目维护标签,可按 prompt:support-v3model:demo-model-2025-01tenant:acme 主动删除:

curl http://localhost:3000/invalidate \
  -H 'content-type: application/json' \
  -H 'x-admin-secret: dev-secret' \
  -d '{"tag":"prompt:support-v3"}'

生产环境应把管理接口放在内部网络,并使用正式认证,不要保留默认密钥。单进程 Map 只适合演示;换成 Redis 时,键和值结构可以保持不变,标签可用集合维护。写入缓存和标签索引最好通过事务或 Lua 脚本保持一致,同时为索引设置清理机制。

多实例还要考虑缓存击穿:同一个键并发未命中时,可使用短期分布式锁或请求合并,只让一个实例访问模型。锁必须有过期时间,等待者也要有超时和降级路径,不能为了减少模型调用而无限阻塞。

哪些请求不应该缓存

并非所有请求都值得缓存。以下情况默认绕过通常更安全:

  • 包含创建订单、发送邮件、扣款等有副作用的工具调用;
  • 回答依赖实时余额、库存、权限或外部系统瞬时状态,却没有可靠状态版本;
  • 用户明确要求随机创作,或生成参数强调多样性;
  • 包含密钥、医疗信息等敏感数据,且存储策略未经安全审查;
  • 身份上下文无法可信获得,或无法证明不同请求等价;
  • 上游流被取消、超时、内容审核中断,或没有收到完整结束事件。

只读工具也不天然可缓存。如果工具查询的数据会变化,应把知识库快照、索引版本或数据更新时间纳入键;做不到时就缩短 TTL 或直接绕过。缓存命中还可能跳过最新的审核逻辑,因此审核规则版本也应进入键,或者在回放时重新审核。

流式缓存保存的是事件序列,不应承诺复现原始 token 到达时间。若上游事件包含请求 ID、计费数据或时间戳,应先拆分:只缓存可复用的内容事件,命中时重新生成本次请求的元数据。

总结

LLM 缓存首先是等价性和安全边界设计,其次才是 Redis 选型。要点可以归纳为:

  • 在认证、提示词解析和工具装配之后生成缓存键;
  • 将租户、用户、权限、模型、提示词、工具和生成参数纳入稳定摘要;
  • 同时使用 TTL、版本号和标签主动失效;
  • 仅在流完整结束后保存结构化事件,并以 SSE 回放;
  • 对副作用、实时状态、敏感数据和不完整响应默认绕过;
  • 多实例部署时补充分布式存储、标签索引与防击穿机制。

把这些约束明确下来之后,Redis 才只是缓存层的一种实现,而不是安全性的来源。