供应商侧的 Prompt Cache 和应用层 Redis 不是一类东西:前者奖励的是字节级稳定的长前缀,后者缓存的是你自己的键值。工具 schema 乱序、系统提示词里塞时间戳、动态 few-shot 任意一项进前缀,命中就会掉到接近于零。下面用可运行的 Node.js 把系统提示词与工具定义钉成稳定前缀,把用户输入和检索结果放到尾部,并在每次请求后读回命中与用量字段。

先分清两种「缓存」

应用层缓存(Redis、内存 Map、HTTP 层)缓存的是你计算好的结果:同一把业务键命中,就直接返回,不必再打模型。供应商侧的 Prompt Cache 则发生在模型请求内部:它识别「这一次请求的前缀,是否与近期某次请求的前缀在字节层面一致」,一致才可能复用已算过的 KV,从而少计一部分输入用量。

两者可以同时存在,但判定条件完全不同。Redis 命中靠你设计的 key;Prompt Cache 命中靠前缀本身。你把完整 prompt 哈希进 Redis,和供应商有没有复用前缀,没有因果关系。上线后如果只看「接口很快」或「Redis 命中率很高」,很容易误判 Prompt Cache 已经生效。

因此设计目标要拆开:业务结果能不能短路径返回,是 Redis 的事;发给模型的 messages / tools 是否从第一个字节开始保持稳定,才是 Prompt Cache 的事。后文只处理后者,并且不绑死某一家 SDK——差异放到适配层。

前缀一旦抖动,命中率接近于零

多数提供商按「从左到右的最长相同前缀」工作。前缀里任何一处变化,都会让缓存键失效,后面即使有几千 token 完全相同也帮不上忙。常见的抖动来源不是「用户问题变了」(那本来就该变),而是本该稳定的部分被写成了动态的:

  • 工具列表每次 Object.keys 或 Map 迭代顺序不同,schema JSON 字段顺序不同。
  • 系统提示词拼接了 new Date().toISOString()、请求 ID、灰度标记。
  • 检索到的 few-shot 或 RAG 片段插在系统提示词中间,而不是追加在对话尾部。
  • 多租户时把「当前用户名、余额、今天星期几」写进同一段 system。

正确的切分是:把跨请求不变的内容做成前缀——角色设定、安全策略、工具定义、固定输出协议;把每请求必变的内容放到尾部——用户原话、检索文档、会话内最新几轮。工具定义属于前缀,就必须做规范化:按名字排序、对 schema 做稳定序列化,再解析回对象,避免「逻辑等价、字节不等价」。

前缀还需要足够长,各家有最小可缓存长度,以官方文档为准,不要用短 system 做验证。验证方式是对比「同一前缀连打两次」的用量字段,而不是凭体感延迟。

稳定前缀、尾部分离:一份可运行的骨架

下面这个文件可以单独运行(Node.js 18+,npm i openai)。它做三件事:工具按名称排序并用稳定 JSON 规范化;system 不含时间与检索结果;用户消息才拼接检索片段。模型名和 Key 走环境变量,方便换账号,不写死某一代模型。

import crypto from "node:crypto";
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const MODEL = process.env.OPENAI_MODEL || "gpt-4o";

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

function stabilizeTools(tools) {
  const sorted = [...tools].sort((a, b) => {
    const na = a.function?.name ?? "";
    const nb = b.function?.name ?? "";
    return na.localeCompare(nb);
  });
  return JSON.parse(stableStringify(sorted));
}

const SYSTEM = [
  "你是内部知识助手。只根据用户消息末尾的「检索片段」回答。",
  "片段不足时明确说不知道,不要编造来源。",
  "回答使用简体中文,先给结论再给依据。",
].join("\n");

const tools = stabilizeTools([
  {
    type: "function",
    function: {
      name: "lookup_ticket",
      description: "按工单 ID 查询状态",
      parameters: {
        type: "object",
        properties: {
          ticket_id: { type: "string" },
        },
        required: ["ticket_id"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "search_kb",
      description: "按关键词搜索内部知识库",
      parameters: {
        type: "object",
        properties: {
          query: { type: "string" },
        },
        required: ["query"],
      },
    },
  },
]);

function buildMessages({ userText, retrievedChunks }) {
  const tail = [
    userText,
    "",
    "检索片段:",
    retrievedChunks.map((c, i) => `[${i + 1}] ${c}`).join("\n"),
  ].join("\n");

  return [
    { role: "system", content: SYSTEM },
    { role: "user", content: tail },
  ];
}

function prefixFingerprint(messages, tools) {
  const prefix = stableStringify({
    system: messages[0],
    tools,
  });
  return crypto.createHash("sha256").update(prefix).digest("hex").slice(0, 16);
}

export async function askWithStablePrefix({ userText, retrievedChunks }) {
  const messages = buildMessages({ userText, retrievedChunks });
  const fp = prefixFingerprint(messages, tools);

  const res = await client.chat.completions.create({
    model: MODEL,
    messages,
    tools,
  });

  const usage = res.usage ?? {};
  const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;

  return {
    fingerprint: fp,
    text: res.choices[0]?.message?.content ?? "",
    usage: {
      prompt_tokens: usage.prompt_tokens ?? 0,
      completion_tokens: usage.completion_tokens ?? 0,
      cached_tokens: cached,
    },
  };
}

const demo = await askWithStablePrefix({
  userText: "退货几天能到账?",
  retrievedChunks: [
    "退款在仓库签收后 3 个工作日内原路返回。",
    "虚拟商品不支持七天无理由。",
  ],
});

console.log(JSON.stringify(demo, null, 2));

prefixFingerprint 只哈希 system 与 tools。用户问题和检索片段变化时,指纹必须不变;一旦指纹变了,就是前缀被污染,应在 CI 或预发直接失败,而不是等账单出来再查。

命中与费用:请求后读字段,而不是猜

不要用「第二次更快」当证据。延迟受排队、流式、工具调用次数影响,和前缀是否命中不是同一变量。以 Chat Completions 为例,读 usage.prompt_tokens_details.cached_tokens:大于 0 说明这次请求的前缀被供应商认作可复用。cached_tokens 为 0 时,先查前缀是否够长、是否在短时间内重复使用同一前缀,再查 messages 是否被中间件改写(例如自动插入日期、重排 tools)。

观测建议打成结构化日志,便于和 Redis 命中分开看:

function logCacheObservation(event, result) {
  const { fingerprint, usage } = result;
  const prompt = usage.prompt_tokens;
  const cached = usage.cached_tokens;
  const ratio = prompt > 0 ? cached / prompt : 0;

  console.log(
    JSON.stringify({
      event,
      fingerprint,
      prompt_tokens: prompt,
      cached_tokens: cached,
      cache_ratio: Number(ratio.toFixed(4)),
      completion_tokens: usage.completion_tokens,
    })
  );
}

const first = await askWithStablePrefix({
  userText: "退货几天能到账?",
  retrievedChunks: ["退款在仓库签收后 3 个工作日内原路返回。"],
});
logCacheObservation("first", first);

const second = await askWithStablePrefix({
  userText: "换货谁承担运费?",
  retrievedChunks: ["质量问题换货由商家承担运费。"],
});
logCacheObservation("second", second);

if (first.fingerprint !== second.fingerprint) {
  throw new Error("prefix drifted: cache cannot hit");
}

适配其它提供商时,保持同一观测形状,只换读取字段的函数。例如有的 API 把缓存读写拆成「本次写入多少 / 本次读到多少」两个计数;有的需要你在 system 或工具块上显式打缓存标记。应用层不要直接依赖某一字段名,包一层:

function readCacheUsage(provider, rawUsage) {
  if (provider === "openai_chat") {
    return {
      prompt_tokens: rawUsage.prompt_tokens ?? 0,
      cached_tokens: rawUsage.prompt_tokens_details?.cached_tokens ?? 0,
      cache_write_tokens: 0,
    };
  }
  throw new Error(`unknown provider: ${provider}`);
}

费用不要自己用「猜测单价 × token」在文章或仪表盘里写死。账单以控制台为准;日志里保留 prompt_tokens、cached_tokens、completion_tokens 足够做回归:同一前缀指纹下,第二次及以后的 cached_tokens 应稳定大于 0,否则就是前缀设计回退了。

上线前的检测清单与回退

把下面当作发布门禁,而不是上线后的排障备忘。

必须稳定(前缀)

  • system 文本来自版本化常量或构建产物,禁止运行时模板插入日期、trace id、用户 ID。
  • tools 先排序再稳定序列化;新增工具只追加定义,不在每次请求里按「本次用得到哪些」动态裁剪前缀(若必须裁剪,把「工具全集」放前缀,把「允许调用的子集」放尾部说明)。
  • 多租户的共性策略放前缀,租户个性放 user 消息尾部。
  • 对 {system, tools} 做指纹;测试里用两套不同的用户问题 + 检索结果,断言指纹相同。

必须放到尾部

  • 用户原话、RAG 命中、动态 few-shot、当前时间、购物车快照。
  • 会话历史若要截断,从最旧的 user/assistant 轮次截,不要改 system,也不要在历史中间插入新的系统说明。

可检测

  • 预发用固定前缀连续请求两次,断言第二次 cached_tokens > 0(前缀需达到该模型文档中的最小长度;过短则测试无意义,应把固定说明写全,而不是人为灌水乱码)。
  • 日志同时打 Redis 命中与 Prompt Cache 命中,避免运维只盯一个指标。
  • 中间件(网关、安全过滤、自动翻译 system)纳入同一指纹;谁改前缀谁负责。

可回退

  • 指纹突变时:先回退到上一版 system/tools 配置,而不是关模型。
  • 提供商字段读不到时:观测降级为「仅记录 prompt_tokens」,不要把 cached_tokens 当 0 去「优化」前缀,以免误伤。
  • 若某家需要显式缓存标记,把标记放在适配层,业务侧仍然只维护「稳定前缀 + 变化尾部」这一契约。

总结

Prompt Cache 吃的是字节级不变的长前缀,不是语义相近,也不是 Redis 里那把业务键。把系统提示词和工具 schema 规范化并钉死,把用户输入与检索结果留在尾部,再用前缀指纹和用量字段做门禁,缓存有没有生效在预发就能看出来。提供商差异用适配器消化,业务契约保持不变。先让前缀不再抖动,再谈命中率;否则上线后你看到的「缓存」,多半从未在模型侧发生过。