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-v3、model:demo-model-2025-01 或 tenant: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 才只是缓存层的一种实现,而不是安全性的来源。
评论