同样的提示,换个顺序便宜一半
「回声」的私信助手把检索片段放在历史之后。提示拼出来是: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,705 | 427 微元 | 42,700 微元 |
| 历史在前 | 594 | 1,111 | 293 微元 | 29,300 微元 |
| 参考文档在前 | 1,354 | 351 | 122 微元 | 12,200 微元 |
计算口径:输入价 250,000 微元/百万 token,缓存读取按 0.1 折扣,命中与未命中分别向上取整。重排后比“历史在前”省 17,100 微元(58.4%),比完全不缓存省 30,500 微元(71.4%)。
注意这是“全部命中”的上限。真实节省要乘以命中率,还要扣除缓存写入成本与 TTL 过期后的重算。3.2 只回答形状问题;缓存该不该开、开多大,属于第 5 章的经济性判断。
失效:改一个字,整段前缀作废
前缀是逐块比较的。三种改动的影响完全不同:
| 改动 | 仍可复用的前缀 | 第一处分叉 |
|---|---|---|
| 只改历史 | 1,354 token | history |
| 改参考文档 | 594 token | reference |
| 改 system 的一个字 | 0 | system |
这解释了两个常见现象。第一,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、最小长度都要按实际合同重算。
- 不记录前缀指纹。缓存失效没有报错,只能从账单异常倒推。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。