# Prompt Cache 友好型 Agent 上下文设计规范
> 提炼自 OpenAI Codex CLI(Rust 实现)的实战经验,供另一个 Agent 在设计/重构其上下文组织时参照。
---
## 0. 核心目标
在面向支持 prompt caching 的大模型 API(OpenAI Responses / Chat、Anthropic Messages 等)调用时,**最大化稳定前缀的字节级一致性**,使得跨轮请求能命中缓存条目,从而显著降低 token 消耗与延迟。
衡量标准:
- 跨轮 hash 命中率(cache hit ratio)
- 单轮新增 token 数(incremental tokens)
- 上下文重写频率(prefix invalidation events / 会话)
---
## 1. 第一性原则(铁律)
> **稳定前缀,追加尾部。**
所有上下文内容必须被划分为两类,并严格执行:
| 类别 | 特征 | 行为 |
|------|------|------|
| **稳定前缀(Stable Prefix)** | 跨轮不变,构成缓存哈希基础 | 一旦设定,**绝不改写**,仅在不得已时整体失效 |
| **易变尾部(Volatile Tail)** | 每轮新增的内容 | 只允许 `append` 到 input 末尾 |
任何让前缀字节发生变化的操作(即使语义等价的重排、字段改名、空白调整)都视为缓存灾难,需要明确决策。
---
## 2. 请求 Payload 的字段顺序(强约定)
按"由稳到变"自上而下排列,序列化器必须按声明顺序输出(如 serde 默认行为),不允许 HashMap 等无序结构出现在顶层:
```
1. model ← 会话内固定
2. instructions / system ← 会话开始固定
3. input / messages ← 历史只 append
4. tools ← 由确定性函数从 MCP/插件状态生成
5. tool_choice ← 静态(如 "auto")
6. parallel_tool_calls ← 模型能力决定
7. reasoning ← 推理配置(effort/summary)
8. store ← 端点决定
9. stream ← 静态
10. include ← 推理模型固定附加 ["reasoning.encrypted_content"]
11. service_tier
12. prompt_cache_key ← = conversation_id,会话恒定
13. text / output_schema
14. client_metadata ← 请求级(不进缓存哈希范围)
```
> 易变字段必须置于稳定字段之后;可选字段使用 `skip_serializing_if` 跳过空值,避免空字段引入版本漂移。
---
## 3. 缓存命中机制(双层锁定)
### 3.1 显式 Cache Key
```
prompt_cache_key = conversation_id
```
整个会话生命周期内 cache key 不变,让服务端命中同一缓存条目。**新会话/分叉会话才生成新 key**。
### 3.2 服务端响应链 `previous_response_id`(WebSocket / 长连接)
在传输层进一步降本:
- 检测条件:上一响应存在 + 本轮请求是历史的"严格扩展"(除 `input` 外所有字段完全相等);
- 切换发送格式:`previous_response_id = last.response_id`,`input = 仅新增 items`;
- 一旦不满足任何一项 → 回退全量请求。
这意味着传输层**连前缀字节都不发**,是 cache 命中之上的二次优化。
### 3.3 验证不变量的代码契约
实现时必须有等价于以下逻辑的守护:
```
prev_request_without_input == new_request_without_input // 非 input 字段必须 byte-equal
new_input.starts_with(prev_baseline_input) // input 必须是前缀扩展
```
不满足则降级到全量。
---
## 4. Tools / MCP 的处理范式
### 4.1 每轮重建,但保证字节一致
工具列表允许每轮重建,**前提是构建过程必须确定性**:
- 同一 MCP 拓扑 → 同一组工具规格、同一顺序、同一 JSON 字段排列;
- 名字冲突走稳定哈希后缀(不是随机数);
- 集合容器使用有序结构(`Vec`、`BTreeMap`);不要在序列化路径上出现 `HashMap` 迭代。
> 用单元测试钉死:"连续两轮请求中 `tools` 与 `instructions` 必须 byte-equal。"
### 4.2 元工具折叠(Meta-Tool Pattern)
面对潜在数十~数百个 MCP/插件工具时,**绝不全量塞进 `tools` 数组**。改用:
```
{
"name": "search_tools",
"description": "Search and load tools matching the task...",
"parameters": { "query": "string" }
}
```
机制:
- 每个工具有 `defer_loading: bool` 标记;
- `defer_loading=true` 的工具从 `model_visible_specs` 中过滤掉;
- 模型按需调用 `search_tools(query)` → 返回匹配工具的 schema → 进入当轮 `input` 末尾;
- 后续轮次,被发现的工具自然处于历史尾部,仍在缓存范围内。
### 4.3 工具上下线就是缓存破坏事件
要承认这一点并设计退出路径:
- MCP server 连接/断开 → tools 变化 → 前缀失效(应破坏);
- 不要试图通过"占位空规格"伪装稳定,那只会让模型困惑且仍然破坏哈希;
- 由用户/系统层显式触发,告知缓存失效是预期行为。
---
## 5. Skills(或类似的"知识包")的处理范式
Codex 的核心选择:**Skill 不是 tool**。
### 5.1 元数据 vs 正文
- 元数据(`name / description / path`):在会话开始时渲染为一段 `...` 的 **developer/system 角色消息**,进入稳定前缀;
- 正文(SKILL.md):仅在用户**显式提及**时(`$skill-name` 或 `[$x](skill:///path)` 链接解析)才读入,作为 developer-role `ResponseItem` 追加到当轮 input 末尾。
### 5.2 为什么不做成 tool
- 工具调用走 schema 校验、JSON args 解析、handler 分发,重;
- 工具列表是稳定前缀的一部分,加 skill 会污染前缀;
- Skill 正文是大段 markdown,不适合塞进 tool description。
把 skill 当 "可被消息引用的 markdown 片段" 来管理,是把"易变内容"彻底隔离到尾部的关键。
### 5.3 提及解析
- 词法解析(state machine),不依赖 LLM;
- 过滤常见误报(`$PATH`、`$HOME` 等环境变量);
- 同名歧义时要求显式路径,否则放弃匹配。
---
## 6. Reasoning(推理模型)的跨轮保留
### 6.1 不要依赖服务端 `store=true`
对默认 `store=false` 的端点(OpenAI 直连)必须用:
```
include = ["reasoning.encrypted_content"]
```
服务端把推理 token 加密回传 → 客户端在下一轮把这些 reasoning item **原样塞回 input** → 服务端验证连续性,并与前一轮 cached prefix 衔接。
### 6.2 reasoning 配置字段属于稳定前缀
`reasoning.effort` / `reasoning.summary` 一旦设定不要轻易变;用户改 effort 是合法的"应当破坏缓存"事件。
### 6.3 适用面
任何返回"思维 token / 思考摘要"的模型都应套用同一模式:把模型私有的中间状态存进 input 尾部,让前缀保持稳定。
---
## 7. 配置变更的"只追加"纪律
会话中常见的运行时变化(cwd 切换、sandbox 策略调整、权限升级、个性化设定切换)**绝不改写已发送的前缀**。统一处理范式:
1. 把变更渲染成一段新的 developer/system 消息(带明确开闭 marker,如 `...`);
2. **追加到当轮 input 末尾**;
3. 历史中前面已经存在的旧版同类消息保留不动(让模型从消息时间序自行理解"以最新为准")。
> 这是 Codex 用 `prompt_caching.rs` 单元测试守护的最重要不变量之一。
> 测试模式:模拟"用户中途修改 sandbox/cwd"→ 断言 `body2["input"][0..N]` 与 `body1["input"][0..N]` byte-equal,新内容只能出现在 `body2["input"][N..]`。
---
## 8. 历史压缩(Compact):可控的缓存失效
历史压缩本质上重写了 input 前缀,**必然破坏缓存**。设计要点:
- **由用户/阈值显式触发**,不要悄悄发生;
- 压缩后视作"新的稳定前缀的开始",cache_key 可保持不变(让服务端按新前缀重新建缓存条目);
- 压缩点应稀疏——例如直到 input 占用接近上下文上限的 70% 再触发,避免高频失效;
- 把"压缩了哪一段"作为可追溯的 marker 写进新前缀,便于调试。
---
## 9. 必须落地的不变量测试(CI 守护)
最低限度三组:
```
test_1: instructions_and_tools_are_byte_equal_across_turns
- 起会话 → 跑两轮(不改任何配置)
- 断言 body0["instructions"] == body1["instructions"]
- 断言 body0["tools"] == body1["tools"](含顺序、字段命名)
test_2: prompt_cache_key_stays_constant_across_runtime_overrides
- 起会话 → 跑一轮 → 改 sandbox/effort → 跑第二轮
- 断言 body0["prompt_cache_key"] == body1["prompt_cache_key"]
- 断言 body1["input"][0..len(body0.input)] 与 body0["input"] byte-equal
- 断言新增内容仅出现在 body1["input"][len(body0.input)..]
test_3: deterministic_tool_serialization
- 给定同一 MCP 拓扑,重复构造 N 次 tools
- 断言两两 byte-equal(杜绝 HashMap 顺序漂移)
```
---
## 10. 反模式清单(必须避免)
| 反模式 | 后果 |
|--------|------|
| 在请求顶层使用 HashMap 序列化 | 字段顺序漂移,前缀爆掉 |
| 工具列表用集合迭代序生成 | 同状态不同字节 |
| 配置变更时改写历史中的旧消息 | 前缀全部失效 |
| 把 skill 全文塞进 system prompt | 前缀膨胀且每次提及都失效 |
| 默认全量加载所有 MCP 工具 | 前缀肥大,工具上下线频繁失效 |
| 在 instructions 里嵌时间戳/请求 ID | 永久不命中 |
| 推理模型不回传 encrypted_content | 模型行为漂移 + 缓存断裂 |
| 用 LLM 解析 skill mention | 慢、不确定、引入额外调用 |
| 在 cache key 里塞高基数变量 | 缓存条目爆炸,无命中 |
| 中途切换字段命名(`messages` ↔ `input`) | 整段历史重序列化 |
---
## 11. 决策检查表(设计 review 时逐项过)
- [ ] 顶层 payload 字段顺序是否声明序固定?
- [ ] `prompt_cache_key` 是否绑定 `conversation_id`(或同等粒度)?
- [ ] 是否区分了"稳定前缀"与"易变尾部"两类内容?
- [ ] tools 是否由确定性函数生成?是否有 byte-equal 测试?
- [ ] 大量工具是否走 meta-tool / deferred loading?
- [ ] Skills/知识包是否独立于 tools,按 mention 注入尾部?
- [ ] 推理模型是否走 `include=[reasoning.encrypted_content]` 链路?
- [ ] 运行时配置变更是否走"追加 developer 消息"而非改写?
- [ ] 是否实现了增量 input + `previous_response_id` 优化(长连接场景)?
- [ ] 历史压缩是否仅在显式阈值触发,并视作可控失效?
- [ ] CI 是否有缓存稳定性回归测试?
---
## 12. 一句话总结
> **把一切能在会话内固定的内容固定下来;把一切必须变化的内容追加到尾部;让传输层与服务端缓存层各自做它们最擅长的事。**
---
## 附录 A:Codex 实现参考点
| 主题 | 文件 |
|------|------|
| 请求 struct 定义 | `codex-rs/codex-api/src/common.rs:165` |
| 请求装配 | `codex-rs/core/src/client.rs:831`(`build_responses_request`) |
| cache key 设定 | `codex-rs/core/src/client.rs:880` |
| reasoning 加密回放 | `codex-rs/core/src/client.rs:856`(`include`) |
| WS 增量传输 | `codex-rs/core/src/client.rs:985`(`prepare_websocket_request`) |
| 增量校验 | `codex-rs/core/src/client.rs:936`(`get_incremental_items`) |
| 工具构建入口 | `codex-rs/core/src/session/turn.rs:1108`(`built_tools`) |
| ToolRouter 过滤 | `codex-rs/core/src/tools/router.rs:55` |
| 延迟工具过滤 | `codex-rs/core/src/tools/router.rs:300` |
| MCP 工具去重 | `codex-rs/codex-mcp/src/tools.rs:138` |
| Skill 元数据 | `codex-rs/core-skills/src/model.rs:1` |
| Skill 提及解析 | `codex-rs/core-skills/src/injection.rs:114` |
| Skill 注入 | `codex-rs/core/src/session/turn.rs:249` |
| 可用 skill 列表渲染 | `codex-rs/core/src/session/mod.rs:2617` |
| 历史压缩 | `codex-rs/core/src/compact.rs` |
| 缓存稳定性测试 | `codex-rs/core/tests/suite/prompt_caching.rs:100`、`:394` |
---
## 附录 B:OpenAI Responses API 调用参数全字段清单
> 以 Codex 出站 `ResponsesApiRequest`([`codex-rs/codex-api/src/common.rs:165`](codex-rs/codex-api/src/common.rs:165))为基准,列出 Codex 实际写入或显式跳过的全部顶层字段。第三方 Agent 在自行实现时,**必须**对每一字段做出"稳定 / 易变 / 跳过"的明确决策。
### B.1 顶层字段表(按声明序,即序列化输出顺序)
| 序 | 字段 | 类型 | Codex 取值 / 来源 | 跳过条件 | 稳定性 | 缓存意义 |
|----|------|------|-------------------|---------|--------|----------|
| 1 | `model` | `String` | `model_info.slug.clone()` | 永不跳过 | 会话内固定 | 模型变 → 缓存条目变 |
| 2 | `instructions` | `String` | `prompt.base_instructions.text` | `String::is_empty()` | 会话开始固定 | 整体作为 system prompt 进入哈希前缀 |
| 3 | `input` | `Vec` | `prompt.get_formatted_input()` | 永不跳过(可空数组) | 只 append 扩展 | 前 N 项是历史前缀,第 N+ 项是当轮新增 |
| 4 | `tools` | `Vec` | `create_tools_json_for_responses_api(&prompt.tools)` | 永不跳过(可空数组) | 给定 MCP 状态字节级一致 | 顺序+字段命名都进哈希 |
| 5 | `tool_choice` | `String` | 恒 `"auto"` | 永不跳过 | 静态 | 缓存中性 |
| 6 | `parallel_tool_calls` | `bool` | `prompt.parallel_tool_calls`(= `model_info.supports_parallel_tool_calls`) | 永不跳过 | 模型决定 | 切模型才会变 |
| 7 | `reasoning` | `Option` | 见 B.2 | `None`(非推理模型) | 用户改 effort 才变 | 改动即破坏前缀 |
| 8 | `store` | `bool` | `provider.is_azure_responses_endpoint()` | 永不跳过 | 端点决定 | 控制服务端是否保留响应 |
| 9 | `stream` | `bool` | 恒 `true` | 永不跳过 | 静态 | 缓存中性 |
| 10 | `include` | `Vec` | 推理模型 `["reasoning.encrypted_content"]`,否则 `[]` | 永不跳过(可空数组) | 与 reasoning 联动 | 推理模型必填 |
| 11 | `service_tier` | `Option` | `Fast→"priority"` / 其他→`service_tier.to_string()` / 不设→`None` | `Option::is_none()` | 用户切档才变 | 一般稳定 |
| 12 | `prompt_cache_key` | `Option` | `Some(state.conversation_id.to_string())` | `Option::is_none()` | **会话恒定** | 显式锁定缓存条目 |
| 13 | `text` | `Option` | 见 B.3 | `Option::is_none()` | 输出 schema/verbosity 改才变 | 改动即破坏前缀 |
| 14 | `client_metadata` | `Option>` | `{X_CODEX_INSTALLATION_ID_HEADER: installation_id}` | `Option::is_none()` | 安装级常量 | 不进入缓存哈希范围(请求级元数据) |
> **重要**:`tools` 字段虽是 `Vec`,但其 JSON 内容来自 `create_tools_json_for_responses_api`,必须保证字段顺序一致——见 B.4。
### B.2 `reasoning` 子字段(推理模型必填)
```rust
struct Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
effort: Option,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option,
}
```
| 字段 | 取值 | Codex 来源 | 稳定性 |
|------|------|-----------|--------|
| `effort` | `"minimal" / "low" / "medium" / "high"` | `effort.or(model_info.default_reasoning_level)` | 用户/配置切档才变 |
| `summary` | `"auto" / "concise" / "detailed"` | `summary` 配置;为 `None` 时整字段省略 | 设定后稳定 |
构造逻辑([`client.rs:844`](codex-rs/core/src/client.rs:844)):
```rust
let reasoning = if model_info.supports_reasoning_summaries {
Some(Reasoning {
effort: effort.or(default_reasoning_effort),
summary: if summary == ReasoningSummaryConfig::None { None } else { Some(summary) },
})
} else { None };
```
> **联动 `include`**:当 `reasoning.is_some()` 时必须设 `include = ["reasoning.encrypted_content"]`,否则空数组。这是把推理 token 安全跨轮回放的唯一开关。
### B.3 `text` 子字段(输出格式控制)
```rust
struct TextControls {
#[serde(skip_serializing_if = "Option::is_none")]
verbosity: Option, // "low" / "medium" / "high"
#[serde(skip_serializing_if = "Option::is_none")]
format: Option, // 结构化输出 schema
}
enum TextFormat {
JsonSchema {
name: String,
schema: serde_json::Value,
strict: bool,
},
}
```
来自 `create_text_param_for_request(verbosity, output_schema, strict)`([`client.rs:875`](codex-rs/core/src/client.rs:875)):
- 不需要任何控制时整个 `text` 字段输出为 `None` 并跳过;
- `verbosity` 仅在 `model_info.support_verbosity` 时生效,否则警告并丢弃;
- `output_schema` 由 turn-level `final_output_json_schema` 提供;`strict` 由是否为 guardian reviewer 决定。
> **缓存影响**:对话型场景中 `text` 通常为 `None`;一旦设了 schema 且后续轮次 schema 变化,整段缓存失效。
### B.4 `tools` 数组中每个工具规格的字段
由 [`ToolSpec`](codex-rs/core/src/tools/) 经 `create_tools_json_for_responses_api` 序列化为以下两种 variant 之一:
**Function tool**(最常见):
```json
{
"type": "function",
"name": "string",
"description": "string",
"strict": false,
"parameters": { /* JSON Schema */ }
}
```
**Namespace tool**(用于把多个 MCP 工具归入同一命名空间):
```json
{
"type": "namespace",
"name": "string",
"description": "string",
"tools": [
{ "type": "function", "name": "...", "description": "...", "parameters": {...} }
]
}
```
字段稳定性要点:
- `name` 必须经 `qualify_tools` 确定性命名(碰撞走 SHA1 后缀,不用随机数);
- `parameters` JSON Schema 内的字段顺序由原始 schema 决定——上游 MCP 服务返回顺序漂移会传染到这里,必要时在客户端用 `BTreeMap` 重排;
- `description` 不要拼接时间戳、请求 ID、随机 nonce。
### B.5 `input` 数组中 ResponseItem 的形态
以下形态都允许出现在 `input` 中,按 chronological 顺序排列:
| `type` | 角色 | 用途 | 稳定性 |
|--------|------|------|--------|
| `message` (`role: "system"`) | system | 极少使用,优先用 `instructions` | 不应出现在 input |
| `message` (`role: "developer"`) | developer | 环境上下文、可用 skill 列表、运行时配置变更 | 历史项稳定,新增项追加尾部 |
| `message` (`role: "user"`) | user | 用户输入 | 每轮追加 |
| `message` (`role: "assistant"`) | assistant | 模型上一轮回复 | 历史稳定 |
| `function_call` | — | 模型发起的工具调用 | 历史稳定 |
| `function_call_output` | — | 工具调用返回 | 历史稳定 |
| `reasoning` | — | 推理模型私有思维(含 `encrypted_content`) | 必须原样回传 |
| `custom_tool_call` / `custom_tool_call_output` | — | 自定义工具协议 | 历史稳定 |
> **关键纪律**:永远不要修改/删除/重排已经发送过的 `input` 项。配置变更走"在尾部追加新 developer 消息"。
### B.6 WebSocket 增量请求形态
```rust
struct ResponseCreateWsRequest {
// 与 ResponsesApiRequest 完全同构,但额外允许:
previous_response_id: Option,
}
```
切换为增量发送时([`client.rs:1006`](codex-rs/core/src/client.rs:1006)):
- `previous_response_id = Some(last_response.response_id)`;
- `input` 仅包含相对 `previous_request.input + last_response.items_added` 的**新增 item**;
- 其他所有字段必须与上一轮完全 byte-equal([`client.rs:948`](codex-rs/core/src/client.rs:948) 校验)。
### B.7 HTTP 路径专属:请求头
虽不属于 body,但影响调用形态:
| Header | 来源 | 说明 |
|--------|------|------|
| `Authorization` | API key / OAuth | 不进缓存哈希 |
| `OpenAI-Beta` | 如需启用未 GA 特性 | 影响服务端解释,谨慎 |
| `OpenAI-Organization` / `OpenAI-Project` | 多租户路由 | 与 cache key 联合作为命名空间 |
| `User-Agent` / 自定义诊断头 | Codex 安装 ID 等 | 不进缓存哈希 |
### B.8 默认值与省略策略速查
| 字段 | 默认 | 何时显式写出 |
|------|------|--------------|
| `instructions` | `""` | 非空才出 |
| `tool_choice` | — | Codex 总是写 `"auto"` |
| `parallel_tool_calls` | — | 总是写 |
| `reasoning` | `None` | 推理模型才写 |
| `store` | — | 总是写(端点决定) |
| `stream` | — | 总是写 `true` |
| `include` | `[]` | 总是写(数组可空) |
| `service_tier` | `None` | 用户切档才写 |
| `prompt_cache_key` | `None` | Codex 总是写(= conversation_id) |
| `text` | `None` | 需 verbosity / schema 才写 |
| `client_metadata` | `None` | Codex 总是写(installation_id) |
### B.9 字段对缓存命中的影响等级
| 等级 | 字段 | 任意改动后果 |
|------|------|-------------|
| 🔴 致命(前缀失效) | `model` / `instructions` / `tools` / `reasoning` / `store` / `stream` / `parallel_tool_calls` / `include` / `text` / `tool_choice` | 缓存条目作废,全量计费 |
| 🟡 显著(条目漂移) | `prompt_cache_key` | 改动即指向新缓存条目,原条目失效 |
| 🟢 中性 | `service_tier`(视实现)/ `client_metadata` / 请求头 | 通常不进哈希范围 |
| ⚪️ 仅尾部计费 | `input` 末尾新增项 | 命中前缀缓存,仅按新增 token 计费 |
### B.10 实现自查清单
- [ ] 是否有一个**唯一**的请求构造函数(如 Codex 的 `build_responses_request`)?
- [ ] 该函数对每一字段都有显式赋值或显式跳过,无遗漏?
- [ ] 序列化器是否保证字段按声明序输出(serde 默认 OK;若用其他库需验证)?
- [ ] `Option` 字段是否正确使用 `skip_serializing_if`,避免空字段进入 payload?
- [ ] `tools` JSON 内字段顺序是否在跨轮间 byte-equal?
- [ ] `reasoning.is_some()` 与 `include` 中 `"reasoning.encrypted_content"` 的存在严格联动?
- [ ] `prompt_cache_key` 是否绑定 `conversation_id` 这一稳定值?
- [ ] WebSocket 增量发送时是否实现了"非 input 字段全等 + input 严格扩展"双重校验?
- [ ] 是否有针对每条字段的"何时变化"决策记录在团队文档?