大模型缓存技术工程指南(下):面向缓存命中的 Prompt 与 Agent 工程实践

上篇重点介绍了大模型缓存技术的基本机制,包括 KV Cache、Prompt Cache、Prefix Cache、Context Cache、Semantic Cache 等。本篇继续从工程视角展开:当模型厂商已经把“缓存命中”体现在 API 定价和延迟优化中时,工程团队应该如何组织 Prompt、工具定义、上下文和 Agent 平台能力,才能真正提高缓存命中率,并把缓存收益转化为可观测、可治理、可持续优化的工程能力。

p-main


目录

  1. 为什么缓存问题最终会变成 Prompt 工程问题
  2. 先区分:哪些缓存是工程侧能明显影响的
  3. 总原则:稳定前缀 + 动态后缀
  4. Prompt 结构推荐模板
  5. 固定角色和系统约束应该前置
  6. 不要把时间戳、随机 ID、Trace ID 放进稳定前缀
  7. 固定内容要版本化,而不是每次动态拼接
  8. Tools / Function Calling Schema 要稳定排序
  9. 减少”等价但不同写法”的 Prompt 漂移
  10. 哪些内容适合缓存
  11. RAG 场景的缓存实践
  12. 多轮对话中的缓存实践
  13. 显式 Context Cache 的工程管理
  14. 建设 Prompt Block Registry
  15. 建立缓存命中率和成本观测
  16. Prompt Lint:把缓存实践变成工程门禁
  17. 常见反模式清单
  18. 面向 Agent 平台的落地路线
  19. 推荐实践总结

TL;DR

p-part2

一句话:缓存能不能命中,很大程度取决于工程侧如何组织 Prompt、工具定义、RAG 片段、多轮历史和 Agent 平台上下文。

工程实践要点:

  1. Prompt 要模块化和版本化
    把 system prompt、工具定义、代码规范、DSL schema 拆成 Prompt Block,并维护 id / version / cachePolicy。
  2. Tools schema 要稳定
    工具顺序、JSON key 顺序、description 内容都要稳定,否则语义一样也可能缓存不命中。
  3. RAG 结果要后置且控长
    动态检索结果不要放最前面,也不要无限塞入。要做 topK、rerank、截断、去重和 token 预算控制。
  4. 多轮对话要摘要化
    最近几轮保留原文,更早历史压缩成 summary。稳定 summary 可以靠前,易变状态要放后面。
  5. Context Cache 要做权限隔离
    不能只存 cacheId,还要有 tenantId / projectId / accessControl / contentSensitivity / expiresAt 等信息。
  6. 要建立缓存观测指标
    重点看:

    1
    2
    3
    4
    5
    6
    cached_input_tokens
    cache_hit_ratio
    TTFT
    prefill_latency
    cost_saving
    prompt_template_version
  7. Prompt Lint 可以做工程门禁
    检查动态变量是否出现在前缀、tools 是否稳定排序、schema 是否稳定序列化、是否记录 cached tokens。


1. 为什么缓存问题最终会变成 Prompt 工程问题

很多工程同学第一次看到大模型厂商价格表里的 cached inputcache readcontext cache 时,会自然地把它理解为“模型服务端帮我做了缓存”。这个理解没有错,但还不够。

从 API 使用者视角看,缓存是否命中,往往不只取决于模型厂商是否支持缓存,而取决于调用方如何组织请求内容。

尤其是 Prompt Cache / Prefix Cache / Context Cache 这类跨请求缓存,本质上都依赖一个前提:

1
多次请求之间存在可复用的稳定上下文。

如果每次请求的前缀都不同,即使其中包含大量重复信息,也可能无法很好地命中缓存。

举一个工程侧很常见的例子。

不利于缓存的组织方式:

1
2
3
4
5
6
7
8
当前时间:2026-05-18 10:23:12
请求 ID:req_8f3a9d
用户问题:帮我生成一个活动页

你是一个前端代码生成 Agent。
以下是公司前端规范……
以下是组件库说明……
以下是工具定义……

看起来这段 Prompt 里有大量固定内容,但因为最前面的时间、请求 ID、用户问题每次都变,后面的稳定内容就不容易形成可复用的相同前缀。

更适合缓存的组织方式:

1
2
3
4
5
6
7
8
9
10
11
你是一个前端代码生成 Agent。
以下是公司前端规范……
以下是组件库说明……
以下是工具定义……

---

本次请求上下文:
当前时间:2026-05-18 10:23:12
请求 ID:req_8f3a9d
用户问题:帮我生成一个活动页

这背后的核心思想非常像前端工程里的“静态资源缓存”:

1
2
3
稳定不变的资源要有稳定 URL / hash;
经常变化的数据要和静态资源分离;
不要因为一小段动态内容,让整份资源都失去缓存价值。

在大模型调用里也一样:

1
2
稳定 Prompt 前缀 ≈ 可缓存静态资源
动态用户上下文 ≈ 每次请求的数据载荷

因此,缓存优化不只是模型服务端能力,而是调用方上下文工程能力的一部分。


2. 先区分:哪些缓存是工程侧能明显影响的

从工程实践角度,可以把大模型缓存分为三类。

缓存类型 工程侧是否容易影响 主要影响方式
KV Cache 较弱 通常由推理引擎自动管理;工程侧主要通过控制上下文长度、输出长度、并发策略间接影响
Prompt Cache / Prefix Cache 很强 通过 Prompt 组织顺序、稳定前缀、工具定义排序、上下文拆分直接影响
Context Cache 很强 通过显式创建缓存、维护 cache id、版本和过期策略直接影响
Semantic Cache 很强 应用层自己实现,基于问题相似度或业务 key 复用答案

本篇重点讨论工程侧最能发力的两类:

1
2
Prompt Cache / Prefix Cache
Context Cache

它们共同指向一个问题:

1
如何让稳定内容稳定复用,让动态内容动态注入。

3. 总原则:稳定前缀 + 动态后缀

最重要的 Prompt 组织原则是:

1
2
稳定、复用率高、变化少的内容放前面;
动态、每次请求都不同的内容放后面。

推荐结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[稳定前缀]
1. 平台级 system prompt
2. 固定角色设定
3. 固定输出规范
4. 固定安全规则
5. 固定工具定义 / function schema
6. 固定 DSL / 组件库 / 代码规范
7. 固定项目背景 / 长文档 / 知识库摘要

[动态后缀]
8. 当前用户问题
9. 当前任务状态
10. 当前文件 diff
11. 当前错误日志
12. 当前检索片段
13. 当前轮临时约束

这不是单纯的“写 Prompt 技巧”,而是一种工程分层方式。

可以类比前端页面:

前端工程 大模型 Prompt 工程
静态 JS/CSS 资源 稳定 system prompt、工具 schema、项目规范
接口返回数据 当前用户问题、当前错误日志、当前 diff
CDN 缓存 Prompt Cache / Context Cache
文件 hash / version Prompt block version
cache miss Prompt 前缀变化导致无法复用

如果把所有内容随意拼成一个字符串,就像把 JS、CSS、接口数据、用户状态全部打进一个文件里。任何一点变化,都会导致整体缓存失效。


4. Prompt 结构推荐模板

对于 Agent 平台,可以建立一套统一 Prompt 模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# System Role

你是一个公司内部研发 Agent,负责辅助完成需求分析、代码生成、代码审查和测试验证。

# Global Rules

- 必须优先遵守公司研发规范。
- 不确定时必须先读取上下文,不允许编造。
- 涉及危险操作时必须停止并请求确认。
- 输出必须结构化、可执行。

# Tool Definitions

{{stable_tools_schema}}

# Project Rules

{{stable_project_rules}}

# Code Style Guide

{{stable_code_style_guide}}

# DSL / Component Specification

{{stable_dsl_or_component_spec}}

---

# Runtime Context

当前项目:{{project_name}}
当前分支:{{branch_name}}
当前任务类型:{{task_type}}

# User Task

{{user_task}}

# Dynamic Context

{{retrieved_context}}
{{current_diff}}
{{error_logs}}

# Output Requirements

{{runtime_output_requirements}}

这个模板有几个关键点:

  1. 角色、规则、工具、项目规范、代码规范都在前面。
  2. 当前任务、当前 diff、错误日志、检索片段都在后面。
  3. 固定上下文和动态上下文之间有明确分隔。
  4. 后续可以对前半部分做版本管理、缓存统计和命中率优化。

5. 固定角色和系统约束应该前置

在 Agent 类产品里,以下内容通常是高复用的:

1
2
3
4
5
6
- 你是什么角色
- 你要遵循哪些全局规则
- 你有哪些工具可以用
- 你应该如何输出
- 你不能做哪些危险操作
- 你应该遵守哪些团队规范

这些内容应该尽量放在 Prompt 的最前面。

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
你是一个公司内部 AIGC 研发 Agent。
你需要遵循以下规则:
- 优先读取项目规范
- 修改代码前先分析影响范围
- 不允许执行危险命令
- 输出必须符合指定格式

以下是可用工具定义:
...

以下是公司前端规范:
...

不推荐:

1
2
3
4
5
6
7
本次用户问题:帮我修复一个样式问题
当前时间:2026-05-18 10:23:12
当前文件:src/pages/Home.tsx

你是一个公司内部 AIGC 研发 Agent。
以下是可用工具定义:
...

后者的问题是,真正稳定的内容被动态信息“挡在后面”,不利于缓存命中。


6. 不要把时间戳、随机 ID、Trace ID 放进稳定前缀

以下内容天然会破坏前缀稳定性:

1
2
3
4
5
6
当前时间:{{now}}
请求 ID:{{requestId}}
用户 ID:{{userId}}
会话 ID:{{sessionId}}
随机 nonce:{{nonce}}
Trace ID:{{traceId}}

不是说这些信息永远不能给模型,而是不要放在 Prompt 的最前面,尤其不要夹在稳定系统说明之前。

推荐做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[固定系统说明]
你是一个前端代码生成 Agent。
你需要遵守以下代码规范……

[固定工具定义]
……

[固定项目规范]
……

[动态请求上下文]
当前时间:{{now}}
请求 ID:{{requestId}}
用户输入:{{userPrompt}}

对于纯日志追踪字段,如果模型并不需要理解它们,最好不要放进 Prompt,而是放入业务日志、tracing 系统或 API metadata 中。


7. 固定内容要版本化,而不是每次动态拼接

工程系统里常见的 Prompt 拼接方式是:

1
2
3
4
5
6
7
const prompt = `
${getRolePrompt()}
${getRulesFromDB()}
${getToolsSchema()}
${getProjectDocs()}
${userInput}
`;

这很直观,但容易产生几个问题:

1
2
3
4
5
- getRulesFromDB 每次返回顺序可能不同
- tools schema 序列化字段顺序可能不同
- 不同业务方会插入不同版本的 system prompt
- 某些动态字段混入了固定规则块
- 很难知道缓存命中和哪个 Prompt 版本有关

更好的做法是把 Prompt 拆成版本化 block。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export type PromptBlock = {
id: string;
version: string;
content: string;
cacheable: boolean;
};

const stableBlocks: PromptBlock[] = [
{
id: "system-role",
version: "v1.3.0",
cacheable: true,
content: SYSTEM_ROLE_PROMPT,
},
{
id: "frontend-rules",
version: "v2.1.0",
cacheable: true,
content: FRONTEND_RULES,
},
{
id: "tool-schema",
version: "v1.8.2",
cacheable: true,
content: stableStringify(TOOLS_SCHEMA),
},
];

const dynamicBlock: PromptBlock = {
id: "user-task",
version: "runtime",
cacheable: false,
content: userInput,
};

这样做的好处是:

1
2
3
4
5
- 能区分哪些内容可缓存,哪些内容不可缓存
- 能追踪缓存命中率和 Prompt 版本的关系
- 能对 Prompt 变更做审计
- 能支持灰度实验
- 能减少多人维护导致的 Prompt 漂移

8. Tools / Function Calling Schema 要稳定排序

Agent 平台经常会动态注册工具,例如:

1
2
3
4
5
read_file
write_file
search_code
run_tests
git_diff

如果每次 tools 数组顺序不同,即使工具语义完全一样,最终序列化出来的 token 序列也可能不同。

不稳定示例:

1
2
3
4
5
[
{ "name": "run_tests" },
{ "name": "read_file" },
{ "name": "write_file" }
]

另一次请求:

1
2
3
4
5
[
{ "name": "read_file" },
{ "name": "write_file" },
{ "name": "run_tests" }
]

对于人来说语义相同,对于缓存来说却可能是不同前缀。

推荐对工具定义做稳定排序:

1
2
3
4
5
6
7
8
9
10
function normalizeTools(tools: ToolDefinition[]) {
return tools
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(tool => ({
name: tool.name,
description: tool.description.trim(),
parameters: sortJsonSchema(tool.parameters),
}));
}

同时,对 JSON schema 也要做稳定序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}

if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
return `{${Object.keys(obj)
.sort()
.map(key => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
.join(",")}}`;
}

return JSON.stringify(value);
}

生产环境注意:上面的 stableStringify 只是用于解释“稳定序列化”的简化示例,不建议直接复制到生产环境。真实 tools schema / OpenAPI / JSON Schema 可能包含 $ref 循环引用、多行 description、转义差异、数字精度差异、Unicode 规范化差异,以及不同 JSON 库的序列化行为差异。生产环境建议使用成熟的 deterministic JSON / canonical JSON 实现,例如 fast-json-stable-stringifycanonicalize,或结合 JSON Schema canonical form / schema bundling 工具处理 $ref、排序、格式化和版本化。

核心原则是:

1
同一组工具,在语义不变时,序列化后的文本也应该完全一致。

9. 减少“等价但不同写法”的 Prompt 漂移

下面这些表达对人类来说差异不大:

1
2
3
你是前端专家。
你是一个前端专家。
你是一名资深前端专家。
1
2
3
请返回 JSON。
请以 JSON 格式返回。
输出格式必须是 JSON。

但对于缓存来说,这些就是不同文本。

如果平台允许每个业务方、每个开发者随意写一版 system prompt,那么缓存命中率会变差,Prompt 质量也难以治理。

推荐建立统一模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Role
你是一个资深前端工程 Agent。

# Rules
- 修改代码前必须先分析影响范围。
- 不确定时必须先读取相关文件。
- 不允许编造不存在的 API。

# Output Format
返回 Markdown,包含:
1. 问题分析
2. 修改方案
3. 风险点
4. 验证方式

业务侧只填写动态变量:

1
2
3
4
5
# User Task
{{user_task}}

# Runtime Context
{{runtime_context}}

这样可以把 Prompt 从“个人经验文本”变成“平台可治理模板”。


10. 哪些内容适合缓存

以下内容通常非常适合进入 Prompt Cache / Context Cache:

1
2
3
4
5
6
7
8
9
10
11
12
- 公司前端代码规范
- 组件库使用说明
- 低代码 DSL Schema
- amis / KAmis 规则说明
- AGENTS.md
- 项目 README
- API 设计规范
- 安全门禁规则
- 测试用例生成规范
- UI 设计系统规范
- 固定业务背景说明
- 固定输出格式说明

这些内容有几个共同特点:

1
2
3
4
- 比较长
- 经常重复出现
- 不会每次请求都变
- 对模型输出质量有稳定影响

不适合放进稳定前缀的内容:

1
2
3
4
5
6
7
8
9
- 当前用户问题
- 当前 git diff
- 当前报错日志
- 当前接口返回值
- 当前用户权限
- 最新新闻
- 实时价格
- 当前库存
- 临时实验参数

这些内容应该作为动态上下文放在 Prompt 后半部分,并且每次请求重新注入。

一个简单判断标准:

1
2
如果这段内容在 10 分钟后仍然大概率正确,可以考虑缓存;
如果这段内容每次任务、每个用户、每个环境都可能不同,不要放进稳定前缀。

11. RAG 场景的缓存实践

RAG 场景里,最容易出现一个误区:把每次检索出来的结果都放在最前面。

不推荐:

1
2
3
4
5
6
7
[本次检索结果]
chunk 1...
chunk 2...
chunk 3...

[固定系统规则]
你是一个知识库问答 Agent...

因为检索结果通常每次都不同,会破坏后面固定规则的前缀稳定性。

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[固定系统规则]
你是一个知识库问答 Agent...

[固定回答规范]
你必须基于资料回答,不能编造...

[固定产品背景 / 可缓存]
以下是产品 A 的稳定背景资料...

[本次动态检索结果]
chunk 1...
chunk 2...
chunk 3...

[用户问题]
...

RAG 中适合缓存的内容:

1
2
3
4
5
6
- 固定产品手册
- 固定 API 文档
- 固定投研方法论
- 固定合规规则
- 固定项目知识库摘要
- 同一批用户反复查询的大型文档

不一定适合缓存的内容:

1
2
3
4
- 每次 query 检索出来都不同的 topK chunks
- 带实时排序分数的检索结果
- 带时间戳的搜索结果
- 带用户个性化过滤条件的片段

除了“前后顺序”,RAG 还要控制动态区长度。检索结果即使放在后面,如果每次都拼入 8K、16K 甚至更长的 chunks,也会让不可缓存输入快速膨胀,稀释缓存收益,并拉高 prefill 延迟。

建议给动态检索区设置明确 token 预算,例如:

1
2
3
4
5
6
总上下文预算:32K tokens
稳定前缀预算:8K tokens
最近对话预算:4K tokens
动态 RAG 预算:4K~8K tokens
输出预留预算:4K tokens
安全余量:剩余部分

动态 RAG 区可以按以下方式控制:

1
2
3
4
5
6
7
- topK 限制:不要盲目把 top20 / top50 全部塞入 prompt
- chunk 大小控制:避免单个 chunk 过大
- rerank 后截断:优先保留高相关片段
- 去重合并:合并高度相似或来自同一段落的片段
- 字段裁剪:只保留标题、正文关键段、来源,不带无关 metadata
- 摘要后注入:长片段先压缩成 query-focused summary
- 按优先级排序:强相关 > 权威来源 > 新鲜度 > 长度

可以用一个简单策略作为起点:

1
2
3
4
5
1. 检索 topK=20
2. rerank 到 top5
3. 每个 chunk 最多保留 600~1000 tokens
4. 总动态 RAG 区不超过当前上下文窗口的 20%~30%
5. 超出预算时优先截断低相关片段

这些数字不是固定标准,需要结合模型上下文窗口、业务准确性要求和成本目标调整。核心原则是:动态区要小而准,稳定前缀才有持续复用价值。

如果使用支持显式 Context Cache 的模型 API,可以把稳定大文档提前创建为 cache,然后后续请求只引用 cache id,再追加本次用户问题和动态检索结果。


12. 多轮对话中的缓存实践

多轮 Agent 对话常见结构是:

1
2
3
4
5
system prompt
历史消息 1
历史消息 2
历史消息 3
当前用户问题

这类结构的问题不在于历史消息本身,而在于很多系统会不断改写 system prompt 或在最前面插入新的上下文。

更推荐的结构是:

1
2
3
4
5
6
[固定 system prompt]
[固定工具定义]
[固定项目规则]
[历史消息摘要]
[最近 N 轮对话]
[当前用户问题]

对于长对话,不建议无限追加历史原文。可以采用:

1
2
3
4
- 最近 N 轮保留原文
- 更早历史压缩成 summary
- 长期知识沉淀到 memory / skill / 项目文档
- 当前任务状态单独结构化维护

这里的 N 不宜写死。可以按上下文窗口和任务类型动态决定:

1
2
3
4
5
6
7
8
9
短上下文模型 / 简单问答:
保留最近 3~5 轮原文即可。

中等上下文 / 常规 Agent 任务:
保留最近 5~10 轮原文,较早历史进入 running summary。

长上下文 / 复杂研发任务:
保留最近关键步骤原文,而不是机械保留最多轮数;
对工具调用结果、文件修改、用户确认结论做结构化状态记录。

summary 的生成方式通常有三类:

1
2
3
4
5
6
7
8
9
10
11
模型生成:
使用当前主模型或轻量模型定期生成 running summary。
适合语义复杂、需要保留决策过程的 Agent 任务。

规则抽取:
从结构化事件中提取已确认需求、修改文件、接口信息、错误日志、待办项。
成本低、稳定性好,适合研发平台。

混合方式:
规则先抽取事实,模型再压缩成自然语言摘要。
适合既要准确事实,又要方便模型理解的场景。

摘要触发策略可以按 token 阈值而不是只按轮数:

1
2
3
if history_tokens > max_history_budget:
summarize(older_messages)
keep_recent_messages_within(recent_window_budget)

例如:

1
2
3
4
5
6
7
8
上下文窗口:32K
稳定前缀:8K
动态 RAG:6K
输出预留:4K
历史消息预算:8K
最近原文窗口:4K
running summary:2K
安全余量:4K

summary 生成本身也有成本,应该纳入平台成本模型:

1
2
3
4
5
6
总成本 =
主请求成本
+ summary 生成成本
+ summary 更新频率带来的额外成本
- 历史压缩节省的重复输入成本
- 缓存命中提升带来的成本节省

工程实践中,summary 不一定每轮都更新。常见做法是“超过 token 阈值再更新”或“关键状态变化后更新”。如果 summary 内容在多轮内保持稳定,可以作为相对稳定的上下文块,放在最近消息之前;如果 summary 每轮都会变化,则不要放到最前面的稳定缓存前缀中,否则会破坏后续固定内容的缓存命中。

可以用以下标准判断 summary 是否“稳定”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
内容 hash:
对 summary 文本或结构化 summary 做 hash。
如果连续多轮 hash 不变,可以认为它在当前阶段相对稳定。

版本字段:
给 summary 维护 summary_version。
只有关键事实变化、用户确认新约束、任务状态变化时才递增版本。

事件触发:
仅在需求变更、文件修改完成、接口约定变化、错误根因确认等关键事件后更新 summary。

人工/规则标记:
对研发 Agent,可以把“已确认需求”“已修改文件”“待办事项”等结构化字段单独维护。
只有这些字段变化时才认为 summary 需要更新。

一个实用策略是:把 summary 拆成两层。

1
2
3
4
5
6
7
stable_summary:
较少变化的任务背景、已确认需求、项目约束。
可以放在相对靠前的位置。

volatile_state:
当前轮错误、最新工具调用结果、临时推理过程。
应放在动态区,不参与稳定前缀。

这样有三个好处:

1
2
3
1. 控制上下文长度
2. 提高稳定前缀复用概率
3. 避免历史噪声干扰当前任务

13. 显式 Context Cache 的工程管理

有些模型厂商支持显式 Context Cache,即先创建缓存,再在后续请求中引用。

这种模式更像传统后端里的缓存对象:

1
创建 cache -> 保存 cache id -> 后续请求引用 cache id -> 过期或版本变化后重建

适合场景:

1
2
3
4
5
- 针对一本长文档持续问答
- 针对一个代码仓库持续分析
- 针对一份产品手册持续生成客服回复
- 针对一个设计系统持续生成页面
- 针对一批合规规则持续做审核

不适合场景:

1
2
3
4
- 一次性请求
- 上下文每次都不同
- 文档经常变化
- 用户隔离要求强但缓存隔离设计不清晰

13.1 缓存元数据不要只存 cacheId

推荐维护更完整的缓存元数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export type AccessScope = "private" | "project" | "tenant" | "public";

export type ContextCacheRecord = {
cacheId: string;
provider: "openai" | "anthropic" | "gemini" | "vertex" | string;
model: string;

sourceType: "document" | "codebase" | "rules" | "design-system";
sourceId: string;
sourceVersion: string;

tenantId: string;
projectId?: string;

/**
* 创建人或创建服务。owner 不等于权限边界,
* 权限判断应优先看 tenantId / projectId / accessControl。
*/
owner: string;

accessScope: AccessScope;
accessControl: {
allowedTenantIds?: string[];
allowedProjectIds?: string[];
allowedUserIds?: string[];
allowCrossTenantReuse: boolean;
};

contentSensitivity: "public" | "internal" | "confidential" | "restricted";
cachePolicy: {
ttlSeconds: number;
allowReuse: boolean;
allowCrossProjectReuse: boolean;
};

createdAt: string;
expiresAt: string;
};

关键是不要只保存一个 cache id,而要保存:

1
2
3
4
5
6
7
8
- 这个 cache 对应哪份文档
- 文档版本是什么
- 使用哪个 provider / model 创建
- 什么时候过期
- 属于哪个 tenant / project
- 创建人是谁
- 敏感级别是什么
- 是否允许跨用户、跨项目、跨租户复用

其中 owner 只能表示“谁创建或维护了这个 cache”,不应该承担权限隔离语义。多租户场景下,更明确的权限字段应该是 tenantIdprojectIdaccessScopeaccessControl

contentSensitivity 的语义也要提前定义清楚,尤其是 internal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public:
可公开内容,原则上允许跨租户复用,但仍需检查 model、sourceVersion、TTL 和 allowCrossTenantReuse。

internal:
内部内容,但“内部”的边界必须明确。
在公司级平台中,它可能表示公司内部;
在多租户平台中,它更应该表示当前 tenant 内部;
在 BU / 项目隔离场景中,它可能只能在当前 project 内部复用。

confidential:
机密内容,默认不允许跨租户、跨项目复用。

restricted:
高敏内容,默认只允许创建者或明确授权对象使用。

因此,internal 不应天然等同于“全平台可复用”。如果平台存在租户隔离,建议默认把 internal 限制在同一 tenantId 内;只有明确标记为 public 且通过跨租户复用审核的内容,才允许跨租户共享。

13.2 cacheKey 设计建议

如果平台自己管理 cache registry,可以用类似下面的 key 结构:

1
2
3
4
5
6
7
8
9
10
cacheKey =
provider
+ model
+ sourceType
+ sourceId
+ sourceVersion
+ tenantId
+ projectId?
+ accessScope
+ cachePolicyHash

这样可以避免几个常见问题:

1
2
3
4
5
- 同一文档不同版本误用同一个 cache
- 不同模型误用同一个 cache
- 不同租户误用同一个 cache
- TTL / cache policy 变化后仍复用旧 cache
- 项目私有文档被其他项目引用

13.3 跨租户共享前的检查逻辑

跨租户共享 Context Cache 要非常谨慎。一个保守判断流程可以是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function canReuseContextCache(
record: ContextCacheRecord,
request: {
tenantId: string;
projectId?: string;
userId: string;
model: string;
now: string;
},
): boolean {
const notExpired =
new Date(record.expiresAt).getTime() > new Date(request.now).getTime();

const sameModel = record.model === request.model;

if (!sameModel || !notExpired) {
return false;
}

// 保守策略:
// confidential / restricted 默认不参与跨范围复用。
// internal 默认只允许在同一 tenant 内复用,除非平台另有更严格的 BU / project 规则。
if (record.contentSensitivity === "confidential") return false;
if (record.contentSensitivity === "restricted") return false;

if (record.accessScope === "private") {
return record.accessControl.allowedUserIds?.includes(request.userId) ?? false;
}

if (record.accessScope === "project") {
return Boolean(
request.projectId &&
record.accessControl.allowedProjectIds?.includes(request.projectId),
);
}

if (record.accessScope === "tenant") {
return record.tenantId === request.tenantId;
}

if (record.accessScope === "public") {
const explicitlyShareable =
record.contentSensitivity === "public" &&
record.accessControl.allowCrossTenantReuse === true;

return sameModel && notExpired && explicitlyShareable;
}

return false;
}

跨租户共享缓存前,至少要确认:

1
2
3
4
5
6
- 缓存内容不包含用户私有信息、未公开代码、敏感业务数据
- provider / 内部 serving 是否支持明确的租户隔离语义
- cache id 是否会在日志、usage、错误信息中泄露
- sourceVersion 变化后会重建或失效旧缓存
- TTL 合理,过期后不会继续引用
- 权限变更后能主动撤销或标记不可复用

需要强调的是:不同模型厂商对 cache 对象、cache id、组织隔离、项目隔离的抽象并不完全相同。平台侧不能假设“有 cache id 就天然安全隔离”,而应该在自己的缓存元数据和调用链路中补上权限判断。

如果团队对 internal 的边界没有统一定义,建议先按最保守策略处理:internal 仅在同一 tenant 内复用;跨 tenant 复用必须显式升级为 public,并通过敏感信息检查和审批。

否则后续很容易出现缓存污染、版本错乱和权限问题。

14. 建设 Prompt Block Registry

对于公司级 AIGC 平台,不建议让每个业务方自己手写完整 Prompt。更好的方式是建设 Prompt Block Registry。

示例结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export type CachePolicy = "stable-prefix" | "explicit-cache" | "dynamic";

export type PromptBlockDefinition = {
id: string;
name: string;
version: string;
content: string;
cachePolicy: CachePolicy;
owner: string;
updatedAt: string;
};

const promptBlocks: PromptBlockDefinition[] = [
{
id: "platform-agent-role",
name: "平台 Agent 角色定义",
version: "1.0.0",
content: "...",
cachePolicy: "stable-prefix",
owner: "aigc-platform",
updatedAt: "2026-05-18",
},
{
id: "frontend-code-rules",
name: "前端代码规范",
version: "2.3.1",
content: "...",
cachePolicy: "stable-prefix",
owner: "frontend-platform",
updatedAt: "2026-05-18",
},
{
id: "current-user-task",
name: "当前用户任务",
version: "runtime",
content: "{{user_task}}",
cachePolicy: "dynamic",
owner: "runtime",
updatedAt: "runtime",
},
];

Prompt Block Registry 可以承担以下能力:

1
2
3
4
5
6
7
8
- prompt 拼接顺序控制
- 缓存策略标记
- 版本管理
- 命中率统计
- 成本分析
- 灰度实验
- prompt 变更审计
- 多业务复用

这会把 Prompt 从“散落在代码里的字符串”升级为“平台级上下文资产”。


15. 建立缓存命中率和成本观测

如果只是调整 Prompt,但没有观测,很难证明收益。

建议每次模型调用记录以下字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
request_id
provider
model
prompt_template_id
prompt_template_version
input_tokens
cached_input_tokens
cache_hit_ratio
output_tokens
time_to_first_token
end_to_end_latency
estimated_input_cost
estimated_cached_input_cost
estimated_saving
cacheable_block_ids
dynamic_block_tokens

其中,cache_hit_ratio 建议区分三个统计口径:

1
2
3
4
5
6
7
8
单请求命中率:
cached_input_tokens / input_tokens

平台级加权命中率:
sum(cached_input_tokens) / sum(input_tokens)

模板级命中率:
按 prompt_template_id / prompt_template_version 分组后分别统计

不要简单对请求命中率做算术平均。不同模板的输入长度和调用频率差异很大,算术平均可能掩盖真实成本结构;平台级成本分析应优先看加权命中率和分模板命中率。

核心看板可以包括:

指标 说明
Cache Hit Ratio 单请求:cached_input_tokens / input_tokens;平台级:sum(cached_input_tokens) / sum(input_tokens)
Cached Input Tokens 命中的缓存输入 token 数
TTFT Time To First Token,首 token 延迟
Prefill Latency 长 Prompt 处理耗时
Cost Saving 缓存命中带来的成本节省
Template Version 哪个 Prompt 模板版本命中率更高
Dynamic Token Ratio 动态内容占比,过高会影响缓存收益

对于 Agent 平台,可以按场景拆分观察:

1
2
3
4
5
6
7
- 代码生成 Agent
- Code Review Agent
- 测试用例生成 Agent
- 需求分析 Agent
- 竞品分析 Agent
- 低代码 DSL 生成 Agent
- 运营页面生成 Agent

不同场景的缓存收益差异会很大。比如:

1
2
3
代码生成 Agent:工具定义、代码规范、项目规则复用率高,缓存收益通常较好。
竞品分析 Agent:每次检索网页内容不同,缓存收益可能较弱。
低代码 DSL 生成 Agent:DSL schema 和组件规范稳定,缓存收益通常较好。

16. Prompt Lint:把缓存实践变成工程门禁

为了避免缓存优化只停留在口头规范,可以在平台里加入 Prompt Lint。

示例检查规则:

1
2
3
4
5
6
7
8
9
10
1. 禁止在稳定前缀 token 窗口内出现 current_time / request_id 等动态变量
2. 检测稳定前缀中的疑似硬编码时间戳、trace id、uuid、随机 nonce
3. tools schema 必须按 name 排序
4. JSON schema 必须稳定序列化
5. system prompt 必须引用版本化 block
6. RAG dynamic chunks 不允许出现在 stable prefix 前
7. 固定规范类 block 必须标记 cacheable
8. dynamic block 不允许标记 cacheable
9. prompt_template_version 必须存在
10. 每次请求必须记录 cached_input_tokens

需要注意,Prompt Lint 里说的“前 1024 tokens”必须基于 tokenizer 计算,不能用字符数近似。中文、英文、代码、JSON schema 的 token 密度差异很大,slice(0, 8000) 这类字符截断只能作为调试预览,不能作为 token 边界判断。

renderPreview() 也需要有最低实现要求,否则 Lint 很容易漏检。建议它至少满足:

1
2
3
4
5
6
7
1. 使用与线上一致的 block 拼接顺序。
2. 展开所有静态 block 和工具 schema。
3. 对动态变量使用带语义的占位符,而不是空字符串。
例如用 {{current_time}}、{{request_id}}、{{user_task}}。
4. 对高风险动态变量提供测试样例值。
例如 current_time = 2026-05-18 10:23:00,request_id = req_xxx。
5. 输出应尽量接近真实请求形态,包括 role、tools、system、user 等结构。

不推荐:

1
renderPreview 时把所有动态字段替换为空字符串。

这样会让 Lint 无法发现动态字段出现在稳定前缀的问题。更好的做法是给每类变量一个可识别的占位符或样例值,让 Lint 能同时检测模板变量和疑似硬编码动态值。

示例伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
function lintPromptTemplate(
template: PromptTemplate,
tokenizer: { encode(input: string): number[]; decode(tokens: number[]): string },
): string[] {
const errors: string[] = [];

const renderedPrompt = template.renderPreview();

// 这里是示意:真实实现应使用目标模型或兼容 tokenizer。
const tokens = tokenizer.encode(renderedPrompt);
const stablePrefixTokens = tokens.slice(0, 1024);
const stablePrefixText = tokenizer.decode(stablePrefixTokens);

const dynamicPlaceholders = [
"{{current_time}}",
"{{request_id}}",
"{{trace_id}}",
"{{session_id}}",
"{{nonce}}",
];

for (const placeholder of dynamicPlaceholders) {
if (stablePrefixText.includes(placeholder)) {
errors.push(`${placeholder} 不应出现在稳定前缀 token 窗口中`);
}
}

// 这类正则只能做启发式检测,不能保证覆盖所有硬编码动态值。
const suspiciousRuntimePatterns = [
/\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/, // 时间戳
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i, // UUID
/\b(trace|request|session)[_-]?(id)?[:=][\w-]{8,}\b/i, // trace/request/session id
];

for (const pattern of suspiciousRuntimePatterns) {
if (pattern.test(stablePrefixText)) {
errors.push("稳定前缀中疑似包含硬编码时间戳、trace id 或随机标识");
break;
}
}

if (!template.version) {
errors.push("prompt template 必须声明 version");
}

if (!isToolsSorted(template.tools)) {
errors.push("tools 必须按 name 稳定排序");
}

if (template.blocks.some(block => block.cacheable && block.type === "dynamic")) {
errors.push("dynamic block 不应标记为 cacheable");
}

return errors;
}

这段代码仍然只是示例,不是完整的静态分析器。它有几个天然局限:

1
2
3
4
5
- 只能检测已知模板变量名,无法覆盖所有自定义变量
- 硬编码时间戳、trace id、uuid 只能用启发式正则发现,可能误报或漏报
- token 边界必须依赖目标模型 tokenizer,不同模型 tokenizer 可能不同
- renderPreview 如果没有按线上拼接顺序展开静态 block、工具 schema 和动态变量占位符,也可能漏掉运行时注入内容
- 多语言、转义、JSON schema、工具描述文本会增加检测难度

因此,Prompt Lint 更适合作为“工程门禁 + 风险提示”,而不是绝对正确的安全证明。生产环境建议结合:

1
2
3
4
5
- 模板变量白名单 / 黑名单
- Prompt Block 类型系统
- 运行时采样审计
- usage 指标异常检测
- 代码评审或配置审核

这些规则可以集成到:

1
2
3
4
5
- Agent 平台发布流程
- Prompt 模板变更审核
- CI 检查
- 管理后台配置校验
- 运行时告警

17. 常见反模式清单

反模式 问题 推荐修正
每次把当前时间放在 Prompt 开头 破坏前缀一致性 放到 Runtime Context 后面
tools 数组顺序随机 token 序列不稳定 按工具名稳定排序
JSON schema 字段顺序不稳定 内容语义相同但文本不同 使用 stable stringify
system prompt 每个业务方随意改 模板不可控,缓存命中低 建立版本化模板
RAG 片段放在最前面 检索结果每次变,破坏稳定前缀 固定规则前置,检索结果后置
把请求 ID、trace ID 放进 Prompt 对模型无帮助,还破坏缓存 放入日志 metadata
长文档每次重复传但不做缓存管理 成本高、延迟高 显式 context cache 或稳定前缀
历史对话无限追加 成本越来越高,缓存收益下降 摘要压缩 + 最近 N 轮原文
为了缓存复用过期事实 可能导致错误答案 易变事实动态注入
Prompt 模板没有版本 无法做命中率对比和问题回滚 引入 prompt_template_version

18. 面向 Agent 平台的落地路线

对于 Agent 平台,缓存优化可以分三步推进。

18.1 第一阶段:规范 Prompt 结构

目标是先减少明显破坏缓存的写法。

重点动作:

1
2
3
4
5
- 固定 system prompt 前置
- 动态任务信息后置
- 移除前缀中的时间戳、request id
- tools schema 稳定排序
- 建立统一输出格式模板

这个阶段投入较小,适合作为快速治理动作。

18.2 第二阶段:建设 Prompt Block Registry

目标是把 Prompt 变成平台资产。

重点动作:

1
2
3
4
5
- 将角色定义、工具定义、代码规范、DSL schema 拆成 block
- 每个 block 有 id、version、owner、cachePolicy
- 统一拼接顺序
- 支持模板版本发布和回滚
- 支持不同业务场景复用

这个阶段可以显著提升 Prompt 管理能力。

18.3 第三阶段:建立观测和优化闭环

目标是让缓存收益可量化。

重点动作:

1
2
3
4
5
- 记录 cached_input_tokens
- 记录 cache_hit_ratio
- 记录 TTFT 和端到端延迟
- 按模型、模板、业务场景统计成本节省
- 对命中率低的模板做 Prompt Lint 和优化建议

这个阶段可以把缓存优化和平台成本治理结合起来。


19. 推荐实践总结

可以把本文实践压缩成一句话:

1
让稳定知识稳定复用,让动态事实动态注入。

进一步展开:

1
2
3
4
5
6
7
Prompt 模板稳定化
→ Prompt Block 版本化
→ Tools Schema 稳定化
→ 长文档 Context Cache 化
→ 动态上下文后置化
→ 缓存命中率可观测化
→ 成本和延迟收益可量化

最终目标不是“所有内容都缓存”,而是构建一套面向大模型调用的上下文工程体系:

层级 建设内容
规范层 Prompt 编写规范、稳定前缀规则、动态上下文规则
平台层 Prompt Block Registry、模板版本管理、Context Cache 管理
观测层 cached tokens、命中率、TTFT、成本节省、模板对比
治理层 Prompt Lint、变更审计、灰度实验、回滚机制

缓存优化并不是一个孤立的性能技巧,而是大模型工程化的重要组成部分。对于 Agent 平台、AIGC 研发平台、低代码生成平台来说,它会直接影响三件事:

1
2
3
1. 成本:相同上下文不必反复付全价。
2. 延迟:长 Prompt 的 prefill 成本可以被部分复用。
3. 稳定性:Prompt 模板版本化后,更容易治理和复盘。

当模型厂商已经把缓存命中写进价格表,工程团队也应该把缓存命中写进 Prompt 规范、平台能力和观测体系里。