某 LLM ops 平台 SDK 的渲染缓存 key 未包含实际变量,导致不同用户同一模板的请求返回相同缓存结果。团队通过完善 key 构成解决了数据串取问题。
这是一篇提交给 DEV 的夏季 Bug 排查活动的文章。
我在 AcruxCore 工作,这是一个 LLM 运维平台,有一套用 TypeScript 和 Python 发布的 SDK。两个 SDK 都在客户端渲染提示词,并使用一个小型内存缓存,这样热路径就不会在每次调用时都发起网络往返:
const rendered = await hub.renderPrompt('rag-chat', 'production', { question });
renderPrompt 一次性获取模板化的消息,然后在 cacheTtl 毫秒(默认 60 秒)内从缓存中提供相同的结果,之后才再次调用 API 检查。这就是整个功能。两个 bug 藏在"提供相同结果"的实际逻辑中——它如何判断什么是"相同的"。
正确性 Bug — 缓存键没有包含每次真实调用都会变化的那一样东西:变量。
缓存键由三个字段构成:
const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}`;
API 密钥、提示词名称、别名。没有包含你实际在问什么。同一个提示词的两次渲染,即使变量不同,只要在同一个 TTL 窗口内,就会哈希到相同的键——于是第二次调用拿到了第一次调用的缓存答案,而不是它自己的。
这并非假设场景。就在我们的文档里实况发生。RAG 无网关教程每次都是按问题渲染同一个提示词:
rendered = await hub.prompts.render("rag-chat", "production",
{"context": context, "question": question})
先问"我的订单在哪?",然后在 60 秒内问"如何退款?",第二次调用的 rendered.messages 仍然显示"我的订单在哪?"——而追踪查看器(它记录你传入的内容)正确显示"如何退款?"作为输入。追踪与实际发给模型的提示词不一致,但没有任何报错。这也正是另一个教程——工具调用智能体指南——从不把问题放进模板变量的原因,它改为在代码里把问题追加到消息数组中。这是一个被 bug 逼出来的设计决策,而非风格选择。
第二个 bug 在同一个文件里,往下数一个函数:
if (age < this.cacheTtl) {
return cached.value; // fresh — 直接提供
}
// stale — 仍然提供旧值,后台刷新
设置 cacheTtl: 0 来关闭缓存,效果恰恰相反。age < 0 永远不为真,所以每一次调用都走了 stale 分支:立即返回缓存值,然后启动一个没人等待的后台重新获取。TTL 为 0 并不代表"不缓存"——它意味着"永远提供第一次缓存的内容"。没有任何设置能够真正禁用缓存。
两个 bug 来自同一个缺口:缓存没有办法表示"这个输入是不同的"或者"这次调用选择不缓存"。一个键太粗粒度;一个标志位没有对应的分支。修复它们意味着两个都要加。
修复已公开在我们的镜像仓库中——两个 SDK 逻辑相同,这里展示 TypeScript 版本:
packages/sdk/src/client.ts
Bug 1 — 将变量哈希进键:
/**
* 以排序后的对象键序列化值,深度遍历每一个层级,这样两个变量映射
* 即使插入顺序不同也产生同一个字符串。
*/
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value) ?? 'null';
}
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
}
function hashVariables(variables: Record<string, unknown>): string {
return createHash('sha256').update(stableStringify(variables)).digest('hex').slice(0, 16);
}
- const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}`;
+ const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}:${hashVariables(variables)}`;
Bug 2 — 让非正值 TTL 在读取和写入时都真正跳过缓存:
async renderPrompt(name, alias, variables = {}) {
+ // 非正值 TTL 意味着"永不提供缓存的渲染结果"——跳过读取,并传入 null 键
+ // 让写入侧也跳过,而不是用永远不会被读取路径查询的条目填满缓存。
+ if (this.cacheTtl <= 0) {
+ return this._fetchAndCache(name, alias, variables, null);
+ }
const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
const cacheKey = `...`;
...
}
async _fetchAndCache(name, alias, variables, cacheKey /* 现在:string | null */) {
...
- const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
- cache.set(cacheKey, { value, fetchedAt: Date.now() });
+ if (cacheKey !== null) {
+ const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
+ cache.set(cacheKey, { value, fetchedAt: Date.now() });
+ }
return value;
}
JSON.stringify 保留插入顺序。{ name: 'Alice', city: 'Lahore' } 和 { city: 'Lahore', name: 'Alice' } 对任何调用方来说都是同一个请求,但它们会 stringify 成两个不同的字符串——导致同一个请求被分裂成两个缓存条目。一个调用方如果通过先展开默认值({ ...defaults, question })与最后展开默认值这两种方式来构建变量对象,会悄悄地把自身的缓存命中率减半。在每一个层级都排序键,包括嵌套对象内部,使键的顺序对缓存透明。一个专门的单元测试精确覆盖了这一点:
it('treats the same variables in a different key order as one cache entry', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(makeOkResponse());
await hub.renderPrompt('my-prompt', 'production', { name: 'Alice', city: 'Lahore' });
await hub.renderPrompt('my-prompt', 'production', { city: 'Lahore', name: 'Alice' });
expect(global.fetch).toHaveBeenCalledTimes(1);
});
这是一个真正的可靠性功能,不是意外的复杂性:如果 API 短暂不可达,有热缓存的调用方继续得到答案而不是错误。删掉它来修 cacheTtl: 0 的 bug 是以一个 bug 换一次回归。修复是增加第三个状态——"完全不缓存"——完全绕过 stale-serving 分支,而不是替换它。这样 cacheTtl: 30_000 在故障期间仍然 fallback 到 stale 值,而只有 cacheTtl: 0 才主动放弃这一保障。两个行为在 README 中并列记录,这样权衡是明确的,而不是在故障中才发现。
单元测试 mock 了 fetch。上述两个 bug 涉及真实渲染管道对真实提示词版本的处理,因此集成套件访问一个真实的 Express 应用和真实的 Postgres 数据库:
it('renderPrompt 在缓存窗口内渲染新变量,而不是回放第一次渲染', async () => {
// ...创建一个真实提示词、一个包含 "Question: {{ question }}" 的真实版本...
const hub = new acruxcore({ apiKey, baseUrl, cacheTtl: 600_000, maxRetries: 0 });
const first = await hub.renderPrompt(name, 'production', { question: 'Where is my order?' });
const second = await hub.renderPrompt(name, 'production', { question: 'How do I refund?' });
const repeat = await hub.renderPrompt(name, 'production', { question: 'Where is my order?' });
expect(first.messages[0].content).toBe('Question: Where is my order?');
expect(second.messages[0].content).toBe('Question: How do I refund?');
expect(repeat.messages[0].content).toBe('Question: Where is my order?'); // 仍然命中缓存,未被驱逐
});
我在写修复之前还原了 src/client.ts 并重新运行了这个测试。它在预期的地方失败了——second.messages[0].content 返回的是 'Question: Where is my order?',即第一个问题,而不是第二个。这正是这篇文章描述的故障,被一个测试而不是工单捕获到。
第二个集成测试对 Bug 2 做了同样的事情——cacheTtl: 0,提升一个新提示词版本,确认紧接着的调用看到了它,而不是提供升级前缓存的版本。
旧的缓存单元测试本身就是 bug 从未针对变量变化测试过的证据:
- it('caches the result — second call does not hit fetch', async () => {
+ it('caches the result — repeating the same variables does not hit fetch', async () => {
await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
- await hub.renderPrompt('my-prompt', 'production', { name: 'Bob' });
+ await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
expect(global.fetch).toHaveBeenCalledTimes(1);
});
它渲染了 Alice,然后 Bob,并断言只有一次 fetch 调用——而这恰恰就是那个 bug,一个被写成通过测试的 bug。在不动这个测试的情况下修实现,会让它保持绿色,同时对"缓存"的含义撒谎。它必须被重写来断言正确行为——不同输入,不同 fetch——修复才能被信任。
修复后的测试套件:111 个 TS 单元测试,14 个 TS 集成测试(两个新的都经过还原验证),112 个 Python 测试,干净的 tsc --noEmit,干净的 docs 构建。
缓存键必须包含调用方变化的所有东西,而不仅仅是调用方用来标识的所有东西。API 密钥和提示词名称回答了"谁和什么"——它们没有回答"用什么输入",而这才是每次调用中真正变化的部分。
"关闭"需要自己的代码路径,而不是现有路径上的一个边界值。0 看起来像是意味着"永远不新鲜,所以永不提供缓存"——而实际检查(age < ttl)让它变成了相反的意思。一个专用的 bypass,而不是一个巧妙值,才是真正的关闭开关所需要的。
一个只发送一种输入的缓存测试,测试的是管道,而不是缓存键。这个 bug 能在生产中存活下来,就是因为关于"缓存是否工作"的唯一既有测试恰好使用了两种不同输入,并断言了 bug 产生的错误结果。
如果你的 SDK 缓存了按小于完整输入的键的渲染或计算结果,值得今天花五分钟检查一下:在你的 TTL 窗口内用不同参数调用它两次,读回你实际得到的内容。你的返回了什么?