Harness Engineering
第8章 System Prompt 分层设计
"A prompt is not a string — it's an architecture."
本章要点
- System Prompt 是架构问题,不是文案问题——要像写代码一样严谨
- 五层模型:Base Personality / Role / Tools / Context / Dynamic Rules
- 三种关注点分离:静态指令 / 动态上下文 / 用户自定义——三者生命周期完全不同
- CLAUDE.md 模式——用户自定义的优雅解法,值得在所有 Agent 平台借鉴
- Prompt Caching 对齐——缓存友好的分层设计可节省 70%+ 成本
- 像代码一样治理:版本控制 + 回归测试 + A/B + 变更审计
- 六大反模式:巨石 Prompt / 指令矛盾 / 过度变更 / Token 失控 / 缺乏可观测 / 责任模糊
8.1 System Prompt 不是一段字符串
很多开发者第一次接触 Agent 开发时,system prompt 是这样写的:一个字符串常量,塞在代码里,想到什么加什么,越写越长,最后变成一坨没人敢动的文本。改一个词,三个功能崩了。加一条规则,和前面的指令冲突了。
这是 prompt 的泥球架构——和代码世界里的 Big Ball of Mud 如出一辙。
真实的生产级 Agent 系统不会这样做。如果你去分析 Claude Code 的实现,会发现它的 system prompt 是一个精心设计的多层架构,由十几个模块在运行时组装而成。每个模块有明确的职责边界,静态内容和动态内容严格分离,用户自定义和系统默认互不干扰。
System prompt 是一个架构问题,不是一个文案问题。
graph TD
subgraph Assembly["运行时 Prompt 组装"]
L5["Layer 5: 动态规则<br/>(system-reminder, hooks)"] -.->|"对话中途注入"| Final
L4["Layer 4: 上下文注入<br/>(git status, CLAUDE.md, 记忆)"] --> Final
L3["Layer 3: 工具定义<br/>(Read, Edit, Bash...)"] --> Final
L2["Layer 2: 角色指令<br/>(任务规范, 安全协议)"] --> Final
L1["Layer 1: 基础人格<br/>(身份, 能力边界)"] --> Final["最终 System Prompt"]
end
Final --> LLM["发送给 LLM"]
style L1 fill:#dbeafe,stroke:#3b82f6
style L2 fill:#e0e7ff,stroke:#6366f1
style L3 fill:#f3e8ff,stroke:#a855f7
style L4 fill:#fef3c7,stroke:#f59e0b
style L5 fill:#fee2e2,stroke:#ef4444
本章的目标,就是把这个架构拆清楚。
8.2 分层模型:从人格到动态规则
一个设计良好的 system prompt 可以抽象为五个层次,从底层到顶层依次是:
第一层:Base Personality(基础人格层)
这是 Agent 最核心的身份定义。它回答一个问题:你是谁? 包括名字、角色定位、基本行为准则、沟通风格。这一层极少变化,可能整个产品生命周期只改动几次。
你是 Claude,由 Anthropic 开发的 AI 助手。
你诚实、有帮助、无害。
当你不确定时,你会明确说明。
看起来平平无奇,但这一层承担的是"锚定"功能。后续所有层次的指令都建立在这个基础之上。如果基础人格层定义了"你要诚实",后面的角色指令就不应该让 Agent 编造信息。
变更频率:极低(一年 1-2 次) 所有者:通常由模型提供商或产品负责人决定 Prompt Caching 友好度:⭐⭐⭐⭐⭐(极稳定,天然可缓存)
第二层:Role Instructions(角色指令层)
在基础人格之上,根据具体应用场景定义 Agent 的专业角色。同一个基础人格可以适配不同的角色指令。
Claude Code 的角色指令包括:你是一个编程助手,你在命令行环境中运行,你的任务是帮助用户完成编码工作。这些指令界定了 Agent 的能力范围和行为预期。
你是 Claude Code,一个运行在用户终端中的交互式编程代理。
你的工作环境信息:操作系统、shell、当前工作目录。
你应该完整地完成任务——不要过度设计,也不要半途而废。
Tone and style:
- Short and concise responses
- No emojis unless requested
- Reference file paths as file_path:line_number
角色指令层的变更频率高于人格层,但仍然是相对稳定的。它通常随着产品版本迭代而调整。
变更频率:低(每个产品版本 1-2 次) 所有者:产品团队 + 资深工程师 Prompt Caching 友好度:⭐⭐⭐⭐(稳定,按版本缓存)
第三层:Tool Definitions(工具定义层)
这一层描述 Agent 可以使用哪些工具、每个工具的参数格式和使用约束。工具定义层本身是半动态的——工具集合可能随着用户配置或权限变化。
关键原则:工具定义不仅包括"能做什么",还必须包括"什么时候该用"和"什么时候不该用"。比如 Claude Code 的文件编辑工具明确指出"你必须先用 Read 工具读过文件才能编辑",文件搜索工具则强调"不要用 Bash 跑 grep 命令,用专用的 Grep 工具"。
## Edit Tool
Performs exact string replacements in files.
Usage:
- You must use the `Read` tool at least once in the conversation
before editing. This tool will error if you attempt an edit
without reading the file.
- The edit will FAIL if `old_string` is not unique in the file.
Either provide a larger string with more surrounding context
to make it unique or use `replace_all`.
- ALWAYS prefer editing existing files. NEVER write new files
unless explicitly required.
这些使用约束就是工具层的 prompt 架构设计,它们直接影响 Agent 的行为质量。
变更频率:中(每个功能迭代) 所有者:平台工程团队 Prompt Caching 友好度:⭐⭐⭐(基础工具稳定,可选工具动态)
第四层:Context Injection(上下文注入层)
这是真正动态的部分。每次对话开始时,系统根据当前状态注入一系列上下文信息:当前日期、git 状态、项目结构、用户偏好、之前的对话摘要等。
Claude Code 在每次交互中会注入:当前工作目录、git 分支和最近提交、操作系统和 shell 环境信息。这些都不是写死在 prompt 模板里的,而是运行时实时采集后拼装进去的。
# Environment
- Primary working directory: /Users/dev/my-project
- Is a git repository: true
- Platform: darwin
- Shell: zsh
- OS Version: Darwin 23.6.0
# gitStatus
Current branch: main
Status:
M src/app.ts
?? src/new-feature.ts
Recent commits:
a0b32bd book(harness): ch17 rewrite
ef3980f book(harness): ch20 rewrite
上下文注入层的设计难点在于取舍——不是所有可用信息都该注入。每多注入一段文本,都在消耗宝贵的 token 预算。
变更频率:每次会话 所有者:平台工程团队(定义字段),业务逻辑(填充数据) Prompt Caching 友好度:❌(几乎不可缓存——必须隔离)
第五层:Dynamic Rules(动态规则层)
最顶层是根据特定条件触发的规则。比如用户通过 CLAUDE.md 文件定义的项目级指令,或者根据当前对话内容动态加载的专项规则。
<system-reminder>
The user is working on a production database migration.
Be extra careful with any SQL modification suggestions.
Require explicit user confirmation before any DROP/TRUNCATE.
</system-reminder>
这一层最灵活,也最容易出问题。动态规则可能和底层指令冲突,可能彼此矛盾,可能因为注入时机不对而被模型忽略。因此动态规则层需要格外关注优先级和冲突消解机制——这也是下一章的重点内容。
变更频率:每次会话或每轮对话 所有者:用户(CLAUDE.md)+ 系统(hooks) Prompt Caching 友好度:❌(不可缓存)
五层的对比矩阵
| 维度 | 人格 | 角色 | 工具 | 上下文 | 动态规则 |
|---|---|---|---|---|---|
| 变更频率 | 极低 | 低 | 中 | 每会话 | 每轮 |
| 所有者 | 模型商 | 产品 | 平台工程 | 平台+业务 | 用户+系统 |
| Prompt Caching | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ❌ | ❌ |
| 测试覆盖要求 | 高 | 高 | 极高 | 中 | 中 |
| 版本控制必需 | ✅ | ✅ | ✅ | ❌ | ❌ |
8.3 Claude Code 的 Prompt 模块化实践
让我们以 Claude Code 为具体案例,看看分层模型如何落地。
Claude Code 的 system prompt 并非一个完整的文本文件,而是由多个功能模块在运行时组装而成。通过分析其设计与实现,可以识别出以下关键模块:
| 模块名称 | 职责 | 层次 | Token 范围 | 缓存 |
|---|---|---|---|---|
| Identity | 身份声明、模型信息 | 基础人格 | ~200 | ✅ |
| Environment | OS、shell、cwd 等运行环境 | 上下文注入 | ~100 | ❌ |
| Tool Descriptions | 每个工具的描述和约束 | 工具定义 | ~5000 | ✅ |
| Task Guidelines | 完成任务的通用方法论 | 角色指令 | ~800 | ✅ |
| Tone & Style | 沟通风格要求(简洁、不用 emoji) | 角色指令 | ~300 | ✅ |
| Output Efficiency | 减少不必要输出的规则 | 角色指令 | ~150 | ✅ |
| Task Tools | TaskCreate/TaskUpdate 指南 | 角色指令 | ~400 | ✅ |
| Git Protocols | Git 操作的详细行为规范 | 动态规则 | ~2000 | ⚠️条件加载 |
| CLAUDE.md | 用户/项目级自定义指令(实现上注入在 system prompt 之后的 user message 中) | 动态规则 | 0-5000 | ❌ |
| Memory Index | 跨会话长期记忆索引 | 上下文注入 | 0-1000 | ❌ |
| System-Reminder | 运行时动态提示 | 动态规则 | 0-500 | ❌ |
每个模块是一个独立的文本片段,有自己的维护者和变更节奏。Identity 模块可能一年改一次,Environment 模块每次对话都重新生成,Git Protocols 模块随着最佳实践积累不断完善。
这种模块化设计带来的好处是显而易见的:
- 独立演进:改动 Git 规范不会意外影响工具描述。
- 条件加载:如果用户没有 git 仓库,Git Protocols 模块可以不加载。
- 可测试性:可以针对单个模块做单元测试,验证它是否正确引导了模型行为。
- 可复用性:Tone & Style 模块可以在不同产品之间共享。
- 缓存友好:稳定模块可以单独作为缓存 block,动态模块隔离出来。
组装的顺序原则
模块的组装顺序不是随意的——遵循"稳定性从上到下递减":
1. Identity ← 最稳定(缓存友好)
2. Role Instructions
3. Tone & Style
4. Task Guidelines
5. Task Tools
6. Tool Descriptions ← 次稳定
7. Git Protocols ← 条件加载
8. Memory Index ← 会话级
9. CLAUDE.md
10. Environment ← 最动态(不缓存)
11. System-Reminder ← 运行时注入
这个顺序让前面的内容可以被长期缓存,后面动态变化的内容不影响前面的命中。
8.4 关注点分离:静态、动态与用户自定义
prompt 架构的核心设计原则是关注点分离。具体来说,需要把内容按三个维度拆分:
静态指令(Static Instructions)
不随对话变化的部分。身份定义、角色说明、通用行为准则、输出格式要求——这些写一次,所有用户所有对话都一样。静态指令应该存储在代码仓库中,随产品版本一起发布。
管理方式:
- 存储在 code repo
- 走 PR/review 流程
- 有回归测试
- 发布时统一部署
动态上下文(Dynamic Context)
每次对话开始时实时生成的部分。当前时间、环境信息、会话状态、相关文件内容摘要。动态上下文由 harness 层的代码在运行时采集和注入。
管理方式:
- 运行时生成
- 有 fallback(采集失败不阻塞)
- Token 预算硬限制
- 不进入 Prompt Cache
用户自定义(User Customization)
由最终用户或项目维护者提供的指令。这是三者中最不可预测的部分——你无法控制用户会写什么。
管理方式:
- 受信任度低——必须有容错
- 可能和系统指令冲突——需要优先级
- 可能包含敏感信息——需要过滤
- 大小要封顶——防止滥用
为什么分离如此重要?因为每一类内容的生命周期、变更频率和质量保障方式完全不同:
- 静态指令需要 code review,需要 prompt 回归测试。
- 动态上下文需要运行时校验,需要容错处理。
- 用户自定义需要优先级机制和安全过滤。
把它们混在一起,就像把配置文件、环境变量和用户输入全部硬编码到同一个函数里——调试噩梦。
三维分离的可视化
graph TD
Assembly[Prompt 组装器]
Assembly --> S[静态模块<br/>from code repo]
Assembly --> D[动态上下文<br/>from runtime collectors]
Assembly --> U[用户自定义<br/>from CLAUDE.md + settings]
S --> SA[Identity]
S --> SB[Role]
S --> SC[Tools]
S --> SD[Style]
D --> DA[Environment]
D --> DB[Git State]
D --> DC[Memory Index]
U --> UA[Project CLAUDE.md]
U --> UB[User CLAUDE.md]
U --> UC[settings.json hooks]
SA & SB & SC & SD --> Cache[Prompt Cache<br/>命中]
DA & DB & DC --> Fresh[每次重建<br/>不缓存]
UA & UB & UC --> PartialCache[稳定用户配置<br/>可缓存]
Cache --> Final[最终 Prompt]
Fresh --> Final
PartialCache --> Final
style S fill:#dbeafe,stroke:#3b82f6
style D fill:#fef3c7,stroke:#f59e0b
style U fill:#dcfce7,stroke:#22c55e
8.5 CLAUDE.md 模式:用户自定义的优雅解法
CLAUDE.md 是 Claude Code 引入的一个精巧设计,值得深入分析其工程思想。
核心思路很简单:在项目根目录放一个 CLAUDE.md 文件,其内容会被自动注入到模型上下文中——实现上不是拼进 system prompt 本体,而是作为 system prompt 之后的 user message 注入(官方 troubleshooting 文档对此有明确说明)。但简单背后有一系列精心的设计决策:
四个设计决策
1. 多层拼接机制
CLAUDE.md 可以存在于多个位置:
- 用户级:
~/.claude/CLAUDE.md——跨所有项目 - 项目级:项目根目录的
CLAUDE.md——当前项目 - 目录级:子目录的
CLAUDE.md——特定模块
需要注意,各层之间不是"内层覆盖外层"的替换关系——官方 memory 文档明确它们是逐层拼接注入的:所有命中的层都会被加载,作用域更小的层靠加载顺序获得注意力上的优势,"就近者更受关注"(与第 9 章的层叠分析一致)。
~/.claude/CLAUDE.md # 用户全局偏好
└─ /project/CLAUDE.md # 项目级(拼接其后,冲突时更受关注)
└─ /project/mobile/CLAUDE.md # 模块级(拼接其后,冲突时更受关注)
2. 声明式优先于命令式
CLAUDE.md 的内容是声明式的——"这个项目使用 TypeScript"、"提交消息用中文"、"不要修改 vendor 目录"。它描述约束和偏好,而不是编写执行流程。
# 项目约定
- 使用 TypeScript 严格模式
- 提交消息用中文,格式:type(scope): subject
- 不要修改 vendor/ 目录下的文件
- 测试使用 vitest,不要用 jest
3. 零侵入性
不需要修改任何核心代码,不需要了解 prompt 的内部结构。用户只需要会写 Markdown 就能定制 Agent 行为。这大幅降低了自定义的门槛。
4. 版本可控
CLAUDE.md 可以提交到 Git 仓库,团队成员共享同一套项目级约束。新成员 clone 仓库后自动获得一致的 Agent 行为——这是最容易被低估的价值。
平台工程的通用范式
从架构角度看,CLAUDE.md 模式解决了一个经典的平台工程问题:
如何在不暴露系统内部实现的前提下,给用户提供足够的定制能力?
答案是提供一个定义良好的注入点,配合明确的优先级规则。
这个模式具有高度的可迁移性。如果你在构建自己的 Agent 平台,完全可以借鉴这种设计:
def build_system_prompt(user_id, project_path, conversation):
layers = []
# === 静态层(可缓存)===
layers.append(load_static("identity.txt"))
layers.append(load_static("role_instructions.txt"))
layers.append(load_static("tool_definitions.txt"))
layers.append(load_static("tone_style.txt"))
# === 用户级 CLAUDE.md(次稳定)===
user_config = load_user_claude_md(user_id) # ~/.claude/CLAUDE.md
if user_config:
layers.append(user_config)
# === 项目级 CLAUDE.md(次稳定)===
project_config = find_project_claude_md(project_path) # 项目根 CLAUDE.md
if project_config:
layers.append(project_config)
# === 动态上下文层(不缓存)===
layers.append(collect_environment(project_path))
layers.append(collect_git_state(project_path))
layers.append(load_memory_index(user_id, project_path))
# === Hooks 层 ===
for hook in load_hooks(user_id):
layers.append(execute_hook(hook))
# 组装
return "\n\n".join(layers)
同类模式横评
| 产品 | 用户自定义机制 | 核心特性 |
|---|---|---|
| Claude Code | CLAUDE.md 多层拼接 |
Markdown, 层级拼接, Git 友好 |
| Cursor | .cursor/rules/*.mdc(旧 .cursorrules 已弃用) |
Markdown + frontmatter, 支持按 glob 条件生效 |
| Windsurf | .windsurf/rules/ 目录(旧 .windsurfrules 已弃用) |
Markdown, 项目级多文件 |
| Continue | .continue/config.yaml(旧 config.json 已迁移) |
YAML, 配置+指令混合 |
| Zed AI | 内置 settings | GUI 配置 |
Claude Code 的 Markdown + 层级拼接是被验证最平衡的方案——既保留了可读性,又支持细粒度控制。
8.6 模板组装:运行时拼装的工程细节
理解了分层模型之后,下一个问题是:这些层如何在运行时组装成最终的 system prompt?
最朴素的方式是字符串拼接。但生产级系统需要考虑更多:
条件化加载
不是所有模块在所有场景下都需要。如果当前任务不涉及 git 操作,加载 Git Protocols 模块就是在浪费 token。Claude Code 会根据当前工作目录是否是 git 仓库来决定是否注入 git 相关指令。
class PromptAssembler {
async assemble(context: Context): Promise<string[]> {
const blocks: PromptBlock[] = []
// 总是加载的基础模块
blocks.push(this.identity)
blocks.push(this.role)
// 条件加载
if (context.isGitRepo) {
blocks.push(this.gitProtocols)
}
if (context.hasDockerfile) {
blocks.push(this.dockerProtocols)
}
if (context.hasCI) {
blocks.push(this.ciProtocols)
}
// 用户偏好
if (context.userConfig) {
blocks.push(context.userConfig)
}
// 工具(按可用性)
for (const tool of context.availableTools) {
blocks.push(this.toolDescriptions[tool])
}
return blocks.map(b => b.content)
}
}
顺序敏感性
大模型对 prompt 中信息的位置是敏感的。一般来说:
- Primacy effect:越靠前的内容权重越高
- Recency effect:最末尾的内容也有较高关注度
- Lost in the middle:中间部分最容易被忽略
因此关键指令应该放在 prompt 的开头或结尾。次要约束放中间。
分隔符设计
模块之间需要清晰的视觉分隔,帮助模型理解结构边界。常见做法:
# Markdown 标题(最常用)
# Identity
...
# Tool Usage
...
# XML 标签(结构化更强)
<identity>
...
</identity>
# 自定义分隔线
═══ IDENTITY ═══
...
═══ TOOLS ═══
Claude Code 使用 XML 风格的标签(如 <system-reminder>、<env>)来标注动态注入的内容块,让模型能够区分核心指令和运行时上下文。
Token 预算管理
这是模板组装中最务实的考量。system prompt 和对话历史共享同一个上下文窗口。prompt 越长,留给对话的空间越小。一个 200K 窗口的模型,如果 system prompt 占了 30K,对话就只剩 170K(还要预留输出空间)。
实践中的常见策略:
// 伪代码示例:为突出预算管理逻辑,省略 import 与部分辅助实现
interface PromptBlock {
content: string
priority?: "low" | "optional" | "high"
compactVersion?: string
}
declare function countTokens(text: string): number
declare const logger: { warn: (msg: string) => void }
class TokenBudgetManager {
private maxPromptTokens = 30_000 // 上限
private compactThreshold = 25_000 // 超过就压缩
async fitInBudget(blocks: PromptBlock[]): Promise<PromptBlock[]> {
let total = blocks.reduce((s, b) => s + countTokens(b.content), 0)
if (total <= this.compactThreshold) return blocks
// 策略 1: 降级低优先级模块
blocks = blocks.map(b => {
if (b.priority === "low" && b.compactVersion) {
return { ...b, content: b.compactVersion }
}
return b
})
total = blocks.reduce((s, b) => s + countTokens(b.content), 0)
if (total <= this.maxPromptTokens) return blocks
// 策略 2: 丢弃可选模块
blocks = blocks.filter(b => b.priority !== "optional")
total = blocks.reduce((s, b) => s + countTokens(b.content), 0)
if (total <= this.maxPromptTokens) return blocks
// 策略 3: 告警并截断
logger.warn(`Prompt budget exceeded: ${total}, truncating dynamic context`)
return this.truncateDynamic(blocks)
}
private truncateDynamic(blocks: PromptBlock[]): PromptBlock[] {
// 实际实现:按策略截断动态上下文模块
return blocks
}
}
核心策略清单:
- 设定 system prompt 的 token 上限(比如不超过窗口的 15%)
- 对动态内容做截断和摘要——git log 只取最近 5 条,文件列表只展示前两层目录
- 对低优先级模块实施"压缩模式"——当 token 紧张时,用精简版替代完整版
- 监控各模块的 token 占比,识别"膨胀"的模块
8.7 Prompt Caching 对齐:为缓存而设计
2024 年 Anthropic 推出的 Prompt Caching 机制,对 prompt 分层架构提出了新要求。
缓存命中的铁律
- 前缀匹配——Cache 是按前缀匹配的。前面改了,后面全部失效。
- 字节级一致——完全一致才能命中。
- TTL 有限——默认 5 分钟(命中时免费刷新计时),可选 1 小时(缓存写入价更高,约为 5 分钟档的 2 倍)。
这意味着:稳定的内容必须在前,动态内容必须在后。
缓存友好的分层
┌─ System Prompt ──────────────────────┐
│ ┌─ Cache Block 1 ─────────────────┐ │
│ │ Identity (static, 200 tokens) │ │
│ │ Role (static, 800 tokens) │ │
│ │ Tone & Style (static, 300 tokens) │ │ ← cache_control 标记
│ │ Tools (static, 5000 tokens) │ │
│ │ Task Guidelines (static, 500) │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌─ Cache Block 2 ─────────────────┐ │
│ │ User CLAUDE.md (semi-static) │ │
│ │ Project CLAUDE.md (semi-static) │ │ ← cache_control 标记
│ └──────────────────────────────────┘ │
│ │
│ ┌─ Uncached ──────────────────────┐ │
│ │ Environment (dynamic) │ │
│ │ Git State (dynamic) │ │ ← 不标记
│ │ Memory Index (dynamic) │ │
│ │ System-Reminder (dynamic) │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
缓存命中率优化策略
策略 1:Cache Block 粒度
每个 cache_control 标记都有开销。实践中 2-4 个 block 就够:
- Block 1: 静态系统内容
- Block 2: 用户配置
- Block 3(可选): 稳定的对话历史
策略 2:避免"小改动大失效"
如果在静态部分加一个换行,整个缓存就失效了。要严格控制静态内容的"无意义变动":
// ❌ 容易失效
function buildStaticPrompt() {
return `${identity}\n${role}\n${new Date().getFullYear()} version` // 每年会变
}
// ✅ 稳定
function buildStaticPrompt() {
return `${identity}\n${role}` // 不要把动态信息混进静态
}
策略 3:合并稳定模块
把多个小静态模块合并成一个 cache block,减少 cache_control 标记的开销。
成本估算
假设一个 Agent:
- 系统 Prompt 静态部分 20K tokens
- 每轮新增(用户消息 + 工具结果 + 响应)约 5K tokens
- 对话 30 轮
不缓存时,第 n 轮的输入是"20K 静态 + 前 n−1 轮累计历史",逐轮累加:
总输入 = Σ (20K + (n-1) × 5K), n = 1..30
= 30 × 20K + 5K × (0+1+...+29)
= 600K + 2175K ≈ 2.8M tokens
成本 ≈ 2.8 × base_price # base_price 为每 1M tokens 的基准输入价
带缓存时,关键不是只缓存 20K 静态部分——那样 2.2M 的历史 tokens 仍按全价计费,节省不到 20%。正确做法是对话历史增量缓存:每轮在最新内容块上打 cache breakpoint,下一轮整个前缀(静态部分 + 全部历史)按缓存读取价命中,只有本轮新增的约 5K 按缓存写入价计费:
首次写入的 tokens: 20K 静态 + 30 × 5K 增量 = 170K
按 cache_write_price ≈ 1.25 × base_price 计: ≈ 0.21 × base_price
其余全部走缓存读取: 2.8M − 170K ≈ 2.6M
按 cache_read_price ≈ 0.1 × base_price 计: ≈ 0.26 × base_price
总成本 ≈ 0.47 × base_price,vs 不缓存的 2.8 × base_price
节省 ≈ 83%
(1.25× 写入、0.1× 读取是 Anthropic 5 分钟档缓存的典型价格比率,仅作演算用;具体数字随供应商价格政策变化,应以官方价格页为准。)可以看到:会话越长,缓存节省越接近 read/base 价格比的上限;而如果只缓存静态前缀、放任历史按全价重复计费,大部分收益就流失了。
Prompt Caching 改变了长对话 Agent 的商业模型——分层设计的稳定性回报,直接变成了成本回报。
8.8 把 Prompt 当代码管理
如果 system prompt 是架构,那它就应该享受和代码同等的工程待遇。
版本控制
所有 prompt 文本必须入版本管理。不是放在数据库里由产品经理在后台随便改,而是放在代码仓库里,走 PR 流程,有 review、有 changelog。
每一次 prompt 变更都应该能回答三个问题:
- 改了什么?(diff 可见)
- 为什么改?(commit message 说明)
- 效果如何?(关联的评估结果)
环境隔离
就像代码有 dev/staging/prod 环境,prompt 也应该有。新的 prompt 变更先在开发环境验证,通过评估后再部署到生产。
# prompt-versions.yaml
prompts:
identity:
dev: v1.2.3
staging: v1.2.2
prod: v1.2.1
role:
dev: v2.1.0
staging: v2.0.5
prod: v2.0.5
变更审计
生产环境的 prompt 变更必须有记录。当 Agent 行为出现异常时,第一件事就是查看最近的 prompt 变更。没有审计记录,排查就是大海捞针。
interface PromptChangelogEntry {
version: string
module: string
author: string
timestamp: Date
diff: string
reason: string
evalResults?: EvalMetrics
rolloutPlan: {
canary: number // 初始灰度比例
fullAt: Date // 全量时间
}
}
Prompt 目录结构
一种被验证有效的实践是 prompt 目录结构:
prompts/
├── base/
│ ├── identity.md # 基础人格
│ └── role.md # 角色指令
├── tools/
│ ├── file_read.md # 文件读取工具描述
│ ├── file_edit.md # 文件编辑工具描述
│ └── bash.md # 命令行工具描述
├── protocols/
│ ├── git.md # Git 操作规范
│ └── security.md # 安全相关规则
├── dynamic/
│ ├── environment_tpl.md # 环境注入模板
│ └── memory_tpl.md # 记忆注入模板
├── templates/
│ └── system_prompt.py # 组装逻辑
└── tests/
├── test_identity.py # 人格层测试
├── test_git_protocol.py # Git 规范测试
└── fixtures/ # 测试场景
每个 .md 文件是一个 prompt 模块,templates/ 放组装逻辑,tests/ 放评估用例。这种结构一目了然,新人上手成本极低。
跨团队协作模式
大型团队中,prompt 的不同层往往归属不同团队:
| 层 | 负责团队 | 审批流 |
|---|---|---|
| Base Personality | 产品 + Legal | 产品总监 + 法务 |
| Role Instructions | 产品团队 | PM |
| Tool Definitions | 平台工程 | 架构组 |
| Context Injection | 平台 + 数据 | SRE |
| Dynamic Rules | 用户 / 运营 | N/A (用户自主) |
这种分工有助于避免"所有人都能改系统 prompt"的混乱局面。
8.9 Prompt 的测试与评估
代码有单元测试,prompt 也应该有。但 prompt 测试和传统测试有一个本质区别:输出是非确定性的。同一个 prompt,同一个输入,模型可能给出不同的回答。
因此 prompt 测试更接近"评估"而非"断言"。核心方法包括:
行为测试(Behavioral Testing)
给定一个场景,验证 Agent 的行为是否符合预期。不是检查具体输出文本,而是检查行为特征。
例如,要测试 Git Protocols 模块是否生效:
- 输入:"帮我提交代码"
- 期望行为:Agent 应先运行
git status和git diff,而不是直接git commit - 验证方式:检查 Agent 的工具调用序列
class GitProtocolTest:
scenario = "help me commit the changes"
def verify(self, agent_trace: list[Action]) -> bool:
# Assert: first action is git status or git diff
assert agent_trace[0].tool in {"git status", "git diff"}
# Assert: commit is NOT first action
assert agent_trace[0].tool != "git commit"
# Assert: if commit happens, must come after status/diff
for action in agent_trace:
if action.tool == "git commit":
prior_tools = [a.tool for a in agent_trace[:agent_trace.index(action)]]
assert "git status" in prior_tools or "git diff" in prior_tools
return True
return False
回归测试(Regression Testing)
每次 prompt 变更后,跑一遍核心场景的测试集。如果新改动导致已有场景的通过率下降,就需要审慎评估。
维护一个"黄金测试集":
- 50-100 个精心挑选的场景
- 覆盖所有关键行为
- 每次 PR 都跑一遍
- 通过率阈值(如 95%)作为 merge gate
A/B 测试
在生产环境中对比不同 prompt 版本的效果。随机将一部分流量分配给新 prompt,对比关键指标:
- 任务完成率
- 用户满意度
- 工具调用次数
- 重试率
- 平均响应 tokens
对抗测试(Red-Teaming)
专门构造试图"破坏"prompt 约束的输入。如果 prompt 规定"不要执行危险的 git 操作",测试用例就应该包括各种引诱 Agent 执行 git push --force 的请求。
对抗测试覆盖:
- Prompt 注入攻击
- 角色扮演诱导
- 分步诱导(每步看起来无害,组合起来有害)
- 紧迫性压力("紧急"、"不要问")
评估框架骨架
# 伪代码示例:评估框架骨架,省略 run_agent 等外部依赖的具体实现
from collections import defaultdict
class PromptEvalSuite:
def __init__(self, prompt_builder):
self.prompt_builder = prompt_builder
def eval_case(self, scenario, expected_behavior):
prompt = self.prompt_builder.build()
trace = run_agent(prompt, scenario)
return expected_behavior.check(trace)
def run_suite(self, cases):
if not cases:
return {
"total": 0,
"passed": 0,
"failed": [],
"metrics": defaultdict(list),
"pass_rate": 0.0,
}
results = {
"total": len(cases),
"passed": 0,
"failed": [],
"metrics": defaultdict(list),
}
for case in cases:
passed = self.eval_case(case.scenario, case.expected)
if passed:
results["passed"] += 1
else:
results["failed"].append(case.name)
results["pass_rate"] = results["passed"] / results["total"]
return results
关键指标不是 100% 通过率——那在非确定性系统中不现实。而是设定一个可接受的阈值(比如 95%),并监控趋势。如果通过率从 97% 掉到 92%,就需要排查原因。
8.10 六大反模式
最后,列举实践中最常见的 prompt 架构反模式,帮你避坑:
反模式一:巨石 Prompt(Monolithic Prompt)
现象:所有指令塞在一个字符串里,没有结构,没有分层。改动困难,测试不可能,冲突频发。这是最常见也最致命的反模式。
对策:按五层模型重构,每层独立文件,组装器负责拼接。
反模式二:指令矛盾(Conflicting Instructions)
现象:前面说"保持输出简洁",后面又说"给出详细的解释和示例"。模型遇到矛盾指令时的行为是不可预测的——它可能遵循前者,可能遵循后者,可能试图折中,也可能完全忽略两者。
对策:
- 明确优先级。比如 Claude Code 用
IMPORTANT:前缀标注高优先级指令 - 用层级关系隐式建立优先级(动态规则层 > 角色指令层)
- 矛盾检测工具——扫描所有 prompt 模块,发现潜在冲突
反模式三:过度频繁变更(Over-churn)
现象:每周改一次 system prompt,每次都是大改。结果是没有任何稳定基线,无法做有效的评估对比,也无法积累关于"什么有效什么无效"的工程认知。
对策:不同层采用不同节奏。基础层级每季度审视一次,角色指令层按版本迭代,动态规则层可以按需调整但要有测试覆盖。
反模式四:忽视 Token 成本(Token Bloat)
现象:不断往 prompt 里加内容,从不做减法。某天突然发现 system prompt 占了上下文窗口的一半,对话能力严重退化。
对策:
- 定期审计各模块 token 消耗
- 设定每模块的 token 上限
- 每 PR 报告 token delta
反模式五:缺乏可观测性(No Observability)
现象:不知道当前生产环境跑的是哪个版本的 prompt,不知道某次 prompt 变更是什么时候部署的,出了问题查不到根因。
对策:
- Prompt 版本号嵌入到日志
- 每次请求记录使用的 prompt 版本
- 变更通知接入告警系统
反模式六:责任模糊(Ownership Crisis)
现象:所有人都能改系统 prompt——产品经理加一段、实习生加一段、老板随意加一段。最后没人知道为什么有这些指令,也不敢删。
对策:
- 按层分配所有者(参考 8.8 节跨团队协作模式)
- 每个模块有 CODEOWNERS
- 无 owner 的模块定期清理
8.11 实测:Claude Code 的模块化 Prompt 在源码中的映射
本节内容基于作者对 Claude Code 内部源码快照的观察,并非来自公开可得的官方仓库。普通读者可能无法复现具体路径与行号,请将其作为“架构推断”而非“可公开验证的事实”阅读。
§8.3 给出 11 个模块的表格。若观察 Claude Code 的实现结构,这些模块大致对应 src/constants/prompts.ts 中一组 get*Section() 函数,以及一个负责最终组装的 getSystemPrompt() 函数。核心组装逻辑类似于:
return [
// === Static content (cacheable) ===
getSimpleIntroSection(outputStyleConfig), // ← Identity
getSimpleSystemSection(), // ← System role
getSimpleDoingTasksSection(), // ← Task guidelines
getActionsSection(), // ← git 安全协议等动作规范
getUsingYourToolsSection(enabledTools), // ← Tool descriptions
getSimpleToneAndStyleSection(), // ← Tone & Style
getOutputEfficiencySection(), // ← Output Efficiency
// === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
// === Dynamic content (registry-managed) ===
...resolvedDynamicSections,
].filter(s => s !== null)
对照 §8.3 表格,模块与源码函数的大致对应关系如下:
| 章节表格模块 | 源码函数/位置 | 说明 |
|---|---|---|
| Identity | getSimpleIntroSection() |
基础人格 |
| System role | getSimpleSystemSection() |
系统角色 |
| Task Guidelines | getSimpleDoingTasksSection() |
任务指南 |
| Tool Descriptions | getUsingYourToolsSection(enabledTools) |
工具描述 |
| Tone & Style | getSimpleToneAndStyleSection() |
沟通风格 |
| Output Efficiency | getOutputEfficiencySection() |
输出效率 |
| Git Protocols | getActionsSection() |
git 规则分散在 BashTool/PowerShellTool 等工具的 prompt 中,由 getActionsSection 引入 |
| Dynamic(CLAUDE.md / system-reminder / hooks) | getSystemRemindersSection / getHooksSection 等 |
通过 resolvedDynamicSections 异步注入 |
SYSTEM_PROMPT_DYNAMIC_BOUNDARY 哨兵 与 §8.7 “Prompt Caching 对齐:为缓存而设计” 的实现思想对应:在字符串数组中插入一个特殊字面量,然后由 splitSysPromptPrefix() 按它切分前后两段,前段加 cache_control: ephemeral,后段不加。
两条源码快照中可见的事实——
prompts.ts包含近 20 个function get*Section()(如getSimpleIntroSection、getActionsSection、getUsingYourToolsSection、getSimpleToneAndStyleSection、getOutputEfficiencySection、getFunctionResultClearingSection、getBriefSection、getProactiveSection、getSystemRemindersSection、getHooksSection),加上getMcpInstructions、getKnowledgeCutoff、getShellInfoLine等辅助函数,共二十余个get*函数。§8.3 抽象的"模块化 prompt"在源码里就是这些函数 +getSystemPrompt的 array filter 拼装机制——印证了 §8.3 所描述的模块化不是示意图上的抽象,而是真实存在的代码结构。getSystemPrompt函数本身的装配指挥代码很短(百余行量级),其余都是各 Section 函数的实现。这是模块化设计在装配层的具体体现:装配代码极简,复杂度全在各模块内部。
后续章节还会继续展开动态注入语义、指令优先级、工具 prompt 分散等主题。这些章节合起来,可以勾勒出 Claude Code prompt 架构的完整源码地图:装配层(getSystemPrompt)+ 模块层(二十余个 Section 函数)+ 缓存机制(DYNAMIC_BOUNDARY 切割)+ 工具 prompt 分散(BashTool/PowerShellTool/GrepTool 各自 prompt.ts)。
8.12 本章小结:prompt 架构的七条原则
System prompt 的分层设计,本质上是把软件工程中久经验证的架构原则应用到 prompt 领域:
- 分层:每一层有明确的职责和稳定性等级
- 模块化:独立的模块可以独立演进、独立测试
- 关注点分离:静态指令、动态上下文、用户自定义,三者各有其管理方式
- 缓存对齐:Prompt Caching 奖励稳定设计——分层本身就是成本优化
- 版本控制:prompt 是代码,不是随手写的便签
- 可测试性:行为评估框架替代简单的字符串比对
- 责任明确:每一层、每一模块都有所有者
这些不是高深的理论,而是工程常识在新领域的应用。但正是因为 prompt 看起来"只是一段文本",太多人忽视了对它施加工程纪律的必要性。
核心口号:
Prompts are not strings. They are architecture.
像写代码一样写 prompt,像管代码一样管 prompt。
下一章,我们将深入探讨分层设计中最棘手的子问题:当多层指令发生冲突时,优先级如何裁决? 这就是第 9 章"指令优先级"要解决的核心问题。