《互联网产品的 AI 架构·推理性能与成本工程》3.2 前缀缓存与提示结构

同样的提示内容只调整块顺序,可缓存前缀从 594 token 变成 1,354 token,百次请求的输入成本从 29,300 微元降到 12,200 微元。本节说明前缀缓存的命中条件、失效边界与“可缓存形状”,并给出可运行的前缀分析模块与示例样本运行结果。

同样的提示,换个顺序便宜一半

「回声」的私信助手把检索片段放在历史之后。提示拼出来是:system、最近十轮历史、三篇参考文档、当前问题。团队听说提供方支持前缀缓存,开了开关,命中率却只有 8%。

把同一份提示按块拆开看,问题一眼就能看见:system 之后紧跟历史,历史每轮都变,缓存到第一个可变块就断了。后面的三篇参考文档虽然一字未改,却永远进不了前缀。

只调整顺序,不改任何内容:system、参考文档、历史、当前问题。可缓存前缀从 594 token 变成 1,354 token,百次请求的输入成本从 29,300 微元降到 12,200 微元。以下数字均为示例样本上的运行结果,价格与折扣是示例值,不是任何提供方的报价。

前缀缓存命中的是什么

前缀缓存复用的是输入开头那段已经计算过的中间状态。它要求从第一个 token 开始逐字节一致,直到某个块边界;一旦某处不同,后面的内容即使完全一样也无法复用。

因此“内容相同”不够,还要满足一组条件:

条件检查什么不满足的后果
模型一致同一个模型 id换模型后缓存不可用
提示版本一致模板版本号改模板后旧前缀作废
未过期缓存 TTL超时后重新计算
前缀指纹一致从首块到断点的累计哈希任何一个字变化都断
达到最小长度提供方的最小可缓存 token太短不建立缓存
缓存范围允许租户、区域、权限域跨范围复用可能越权

最后一条最容易被忽略。前缀缓存复用的是计算结果,不是业务数据,但如果把租户 A 的私有文档放进共享前缀,租户 B 的请求可能命中一段包含 A 内容的中间状态。安全做法是:共享前缀只放与租户无关的规则和公共知识,租户私有内容一律放在可变后缀。

什么是“可缓存形状”

把提示块按角色分成两类:

  • 稳定角色:system、policy、reference。跨请求内容不变,可以进入前缀。
  • 可变角色:history、tool、question。每个请求都变,必须放在后面。

“可缓存形状”就是所有稳定块连续排在最前面,可变块连续排在后面。中间夹一个可变块,后面的稳定块就全部作废。代码里的 buriedStable 统计的正是这种被埋掉的稳定块数量。

重排不是免费的。它改变了模型看到内容的顺序,可能影响注意力与指令遵循。因此重排只允许在“语义上顺序不敏感”的块之间进行:system 本来就应该在最前,参考文档放在历史之前通常也更合理;如果某个块的位置本身承载语义(例如“先看历史再看文档”的指令),就不能为了缓存随意移动。

一次重排的收益

同一份内容、同样的总 token,只换顺序:

结构可缓存前缀可变后缀单次输入成本100 次请求
不缓存—1,705427 微元42,700 微元
历史在前5941,111293 微元29,300 微元
参考文档在前1,354351122 微元12,200 微元

计算口径:输入价 250,000 微元/百万 token,缓存读取按 0.1 折扣,命中与未命中分别向上取整。重排后比“历史在前”省 17,100 微元(58.4%),比完全不缓存省 30,500 微元(71.4%)。

注意这是“全部命中”的上限。真实节省要乘以命中率,还要扣除缓存写入成本与 TTL 过期后的重算。3.2 只回答形状问题;缓存该不该开、开多大,属于第 5 章的经济性判断。

失效:改一个字,整段前缀作废

前缀是逐块比较的。三种改动的影响完全不同:

改动仍可复用的前缀第一处分叉
只改历史1,354 tokenhistory
改参考文档594 tokenreference
改 system 的一个字0system

这解释了两个常见现象。第一,system 里插入当前时间、请求 id 或用户昵称,会让缓存永远不命中;这些内容应该放在问题附近,而不是开头。第二,参考文档按固定顺序拼接时,只要第一篇变了,后面所有文档都失效;把最稳定的文档放前面、易变的放后面,可以缩小失效范围。

缓存失效不报错,只会让账单悄悄变高。因此前缀指纹要进观测字段:同一能力的前缀指纹频繁变化,说明提示里有不该出现的可变内容。

与卷一缓存的分工

卷一 9.1 的结果缓存按输入内容查表,命中的是“上一次的完整答案”;本节的前缀缓存复用“输入开头的计算结果”,命中的是“少算一段预填充”。两者可以同时存在:

  • 结果缓存命中,整个请求不调用模型。
  • 结果缓存未命中、前缀缓存命中,仍要调用模型,但输入前段便宜。
  • 两者都未命中,按完整价格计算。

把两者混成一个“缓存命中率”会掩盖问题:结果缓存低说明答案复用差,前缀缓存低说明提示形状差,处理方式完全不同。第 5 章会把两者放进同一张成本账,本节只负责把前缀形状做对。

实现

src/perf/prefix.ts 提供四件事:规划前缀、重排块、比较两次请求的共同前缀、检查命中条件。它不调用任何提供方接口,只处理提示结构。

// file: src/perf/prefix.ts
// tier: L2
// verify: node --test test/perf/prefix.test.ts

import { createHash } from "node:crypto";

import { AiError } from "../core/errors.ts";
import { estimateTokens } from "../prompt/context-budget.ts";
import type { ModelProfile } from "../core/types.ts";

/** 只有跨请求稳定的角色才允许进入可缓存前缀。 */
export const CACHEABLE_ROLES = ["system", "policy", "reference"] as const;
export const VOLATILE_ROLES = ["history", "tool", "question"] as const;
export type PromptRole = (typeof CACHEABLE_ROLES)[number] | (typeof VOLATILE_ROLES)[number];

export interface PromptBlock {
  id: string;
  role: PromptRole;
  text: string;
}

export interface PrefixPlan {
  blocks: PromptBlock[];
  cacheableBlocks: string[];
  volatileBlocks: string[];
  prefixTokens: number;
  suffixTokens: number;
  totalTokens: number;
  /** 稳定角色的块出现在可变块之后的数量;大于零说明前缀被截断。 */
  buriedStable: number;
  /** 每个块结尾的累计指纹,用来比较两次请求在哪一块分叉。 */
  fingerprints: string[];
  prefixFingerprint: string | null;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `prefix: ${message}`);
}

function isCacheable(role: PromptRole): boolean {
  return (CACHEABLE_ROLES as readonly string[]).includes(role);
}

function validate(blocks: readonly PromptBlock[]): void {
  if (blocks.length === 0) fail("至少需要一个提示块");
  const seen = new Set<string>();
  for (const block of blocks) {
    if (block.id.trim() === "") fail("块 id 不能为空");
    if (seen.has(block.id)) fail(`块 id 重复:${block.id}`);
    seen.add(block.id);
    if (!isCacheable(block.role) && !(VOLATILE_ROLES as readonly string[]).includes(block.role)) {
      fail(`未知角色:${String(block.role)}`);
    }
    if (block.text.trim() === "") fail(`块 ${block.id} 的 text 不能为空`);
  }
}

/**
 * 前缀从第一个块开始,连续到第一个可变角色为止。
 * 后面的稳定块即使内容不变,也已经落在断点之后,无法命中缓存。
 */
export function planPrefix(blocks: readonly PromptBlock[]): PrefixPlan {
  validate(blocks);
  const cacheableBlocks: string[] = [];
  const volatileBlocks: string[] = [];
  const fingerprints: string[] = [];
  let rolling = "root";
  let prefixTokens = 0;
  let suffixTokens = 0;
  let inPrefix = true;
  let buriedStable = 0;

  for (const block of blocks) {
    rolling = createHash("sha256")
      .update(`${rolling}\u0001${block.role}\u0000${block.text}`, "utf8")
      .digest("hex")
      .slice(0, 16);
    fingerprints.push(rolling);
    const tokens = estimateTokens(block.text);
    if (isCacheable(block.role) && inPrefix) {
      cacheableBlocks.push(block.id);
      prefixTokens += tokens;
      continue;
    }
    inPrefix = false;
    volatileBlocks.push(block.id);
    suffixTokens += tokens;
    if (isCacheable(block.role)) buriedStable += 1;
  }

  return {
    blocks: [...blocks],
    cacheableBlocks,
    volatileBlocks,
    prefixTokens,
    suffixTokens,
    totalTokens: prefixTokens + suffixTokens,
    buriedStable,
    fingerprints,
    prefixFingerprint: cacheableBlocks.length === 0 ? null : fingerprints[cacheableBlocks.length - 1],
  };
}

export interface ReorderResult {
  blocks: PromptBlock[];
  moved: string[];
}

/** 稳定角色在前、可变角色在后,两组内部各自保持原相对顺序。只调位置,不改内容。 */
export function reorderForCache(blocks: readonly PromptBlock[]): ReorderResult {
  validate(blocks);
  const ranked = blocks.map((block, index) => ({ block, index }));
  ranked.sort((left, right) => {
    const group = Number(!isCacheable(left.block.role)) - Number(!isCacheable(right.block.role));
    return group || left.index - right.index;
  });
  return {
    blocks: ranked.map((entry) => entry.block),
    moved: ranked.filter((entry, position) => entry.index !== position).map((entry) => entry.block.id),
  };
}

export interface PrefixMatch {
  sharedBlocks: string[];
  sharedTokens: number;
  /** 下一个请求里第一处分叉的块 id;完全匹配时为 null。 */
  firstDivergence: string | null;
  reason: "exact" | "diverged" | "empty";
}

/** 缓存命中只看前缀:某块内容或角色不同,后面的块即使一样也不能复用。 */
export function commonPrefix(
  previous: readonly PromptBlock[],
  next: readonly PromptBlock[],
): PrefixMatch {
  validate(previous);
  validate(next);
  const sharedBlocks: string[] = [];
  let sharedTokens = 0;
  let firstDivergence: string | null = null;

  for (let index = 0; index < Math.min(previous.length, next.length); index += 1) {
    const left = previous[index];
    const right = next[index];
    if (left.role !== right.role || left.text !== right.text) {
      firstDivergence = right.id;
      break;
    }
    sharedBlocks.push(right.id);
    sharedTokens += estimateTokens(right.text);
  }
  if (firstDivergence === null && next.length > previous.length) {
    firstDivergence = next[previous.length].id;
  }
  return {
    sharedBlocks,
    sharedTokens,
    firstDivergence,
    reason: sharedBlocks.length === 0 ? "empty" : firstDivergence === null ? "exact" : "diverged",
  };
}

export interface CacheRecord {
  modelId: string;
  promptVersion: string;
  /** 缓存作用域:租户、区域与权限域的组合,不同范围不能复用。 */
  scopeKey: string;
  prefixFingerprint: string;
  prefixTokens: number;
  createdAtMs: number;
  ttlMs: number;
}

export interface CacheContext {
  modelId: string;
  promptVersion: string;
  scopeKey: string;
  nowMs: number;
  /** 提供方允许缓存的最小 token 数,低于它不会建立缓存。 */
  minCacheableTokens?: number;
}

export interface CacheEligibility {
  eligible: boolean;
  hitTokens: number;
  violations: string[];
}

/** 命中条件逐条检查,任何一条不满足都不命中;返回值同时给出原因。 */
export function checkPrefixCache(
  plan: PrefixPlan,
  record: CacheRecord | null,
  context: CacheContext,
): CacheEligibility {
  if (record === null) return { eligible: false, hitTokens: 0, violations: ["no-record"] };
  const violations: string[] = [];
  if (record.modelId !== context.modelId) violations.push("model-changed");
  if (record.promptVersion !== context.promptVersion) violations.push("prompt-version-changed");
  if (record.scopeKey !== context.scopeKey) violations.push("scope-changed");
  if (context.nowMs - record.createdAtMs >= record.ttlMs) violations.push("expired");
  if (record.prefixFingerprint !== plan.prefixFingerprint) violations.push("prefix-changed");
  if (plan.prefixTokens < (context.minCacheableTokens ?? 0)) violations.push("below-minimum");
  const eligible = violations.length === 0;
  return { eligible, hitTokens: eligible ? plan.prefixTokens : 0, violations };
}

/**
 * 缓存读取价通常是输入价的一个折扣。命中与未命中分别取整,
 * 避免一次请求因为合并取整而出现零成本。
 */
export function cachedInputCostMicros(
  model: ModelProfile,
  totalTokens: number,
  hitTokens: number,
  readFactor = 0.1,
): number {
  if (!Number.isInteger(totalTokens) || totalTokens < 0) fail("totalTokens 必须是非负整数");
  if (!Number.isInteger(hitTokens) || hitTokens < 0 || hitTokens > totalTokens) {
    fail("hitTokens 必须落在 [0, totalTokens]");
  }
  if (!(readFactor > 0 && readFactor <= 1)) fail("readFactor 必须落在 (0, 1]");
  return (
    Math.ceil((hitTokens * model.inputMicrosPerMTok * readFactor) / 1_000_000) +
    Math.ceil(((totalTokens - hitTokens) * model.inputMicrosPerMTok) / 1_000_000)
  );
}

planPrefix 用累计 SHA-256 指纹标记每个块边界,指纹相同才可能命中。commonPrefix 逐块比较,返回共同 token 与第一处分叉,用来解释“为什么这次没命中”。checkPrefixCache 把命中条件拆成可枚举的违规项,而不是只返回一个布尔值。

测试与运行结果

node --test test/perf/prefix.test.ts

本机一次运行的输出摘要:

ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 208.15825

测试覆盖:历史夹在中间时稳定块被埋掉、重排后的前缀长度、两种结构的百次成本、历史变化不打断前缀、system 变化清零前缀、参考文档变化的断点,以及模型、版本、作用域、TTL、指纹、最小长度六类失效条件。

小结

前缀缓存不是“打开开关就有”,它要求提示具备可缓存形状:稳定块连续在前,可变块在后,且从第一个 token 起逐字节一致。重排能显著降低成本,但必须以语义允许为前提;失效条件要进观测,否则缓存坏了只会表现为账单变高。

下一节处理另一种情况:提示已经排好,但内容本身太多。压缩与检索裁剪要在不丢关键事实的前提下减少 token。

阅读导航:上一节:3.1 上下文窗口的成本结构 · 下一节:3.3 上下文压缩与检索裁剪 。

常见错误与边界

  • 把可变内容放在开头。时间戳、请求 id、用户昵称会让前缀永远失效。
  • 稳定块中间夹一个可变块。后面的稳定块再也不会命中。
  • 把租户私有内容放进共享前缀。命中是省钱了,但可能造成越权复用。
  • 只看命中率,不看前缀长度。命中一次 50 token 的前缀省不了多少。
  • 把前缀缓存与结果缓存混成一个指标。两者的失效原因和处理方式不同。
  • 为了缓存随意移动块。位置承载语义时,省下的钱会以质量下降为代价。
  • 忽略最小可缓存长度。短提示开缓存可能连写入成本都收不回来。
  • 缓存 TTL 设得过长。内容更新后仍在读旧前缀,质量风险高于成本收益。
  • 把示例折扣当成提供方报价。折扣、TTL、最小长度都要按实际合同重算。
  • 不记录前缀指纹。缓存失效没有报错,只能从账单异常倒推。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查