Harness Engineering

第8章 System Prompt 分层设计

作者 杨艺韬 · 8,433 字

"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 模块随着最佳实践积累不断完善。

这种模块化设计带来的好处是显而易见的:

  1. 独立演进:改动 Git 规范不会意外影响工具描述。
  2. 条件加载:如果用户没有 git 仓库,Git Protocols 模块可以不加载。
  3. 可测试性:可以针对单个模块做单元测试,验证它是否正确引导了模型行为。
  4. 可复用性:Tone & Style 模块可以在不同产品之间共享。
  5. 缓存友好:稳定模块可以单独作为缓存 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 分层架构提出了新要求。

缓存命中的铁律

  1. 前缀匹配——Cache 是按前缀匹配的。前面改了,后面全部失效。
  2. 字节级一致——完全一致才能命中。
  3. 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 变更都应该能回答三个问题:

  1. 改了什么?(diff 可见)
  2. 为什么改?(commit message 说明)
  3. 效果如何?(关联的评估结果)

环境隔离

就像代码有 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 statusgit 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,后段不加。

两条源码快照中可见的事实——

  1. prompts.ts 包含近 20 个 function get*Section()(如 getSimpleIntroSectiongetActionsSectiongetUsingYourToolsSectiongetSimpleToneAndStyleSectiongetOutputEfficiencySectiongetFunctionResultClearingSectiongetBriefSectiongetProactiveSectiongetSystemRemindersSectiongetHooksSection),加上 getMcpInstructionsgetKnowledgeCutoffgetShellInfoLine 等辅助函数,共二十余个 get* 函数。§8.3 抽象的"模块化 prompt"在源码里就是这些函数 + getSystemPrompt 的 array filter 拼装机制——印证了 §8.3 所描述的模块化不是示意图上的抽象,而是真实存在的代码结构。
  2. getSystemPrompt 函数本身的装配指挥代码很短(百余行量级),其余都是各 Section 函数的实现。这是模块化设计在装配层的具体体现:装配代码极简,复杂度全在各模块内部。

后续章节还会继续展开动态注入语义、指令优先级、工具 prompt 分散等主题。这些章节合起来,可以勾勒出 Claude Code prompt 架构的完整源码地图:装配层(getSystemPrompt)+ 模块层(二十余个 Section 函数)+ 缓存机制(DYNAMIC_BOUNDARY 切割)+ 工具 prompt 分散(BashTool/PowerShellTool/GrepTool 各自 prompt.ts)。

8.12 本章小结:prompt 架构的七条原则

System prompt 的分层设计,本质上是把软件工程中久经验证的架构原则应用到 prompt 领域:

  1. 分层:每一层有明确的职责和稳定性等级
  2. 模块化:独立的模块可以独立演进、独立测试
  3. 关注点分离:静态指令、动态上下文、用户自定义,三者各有其管理方式
  4. 缓存对齐:Prompt Caching 奖励稳定设计——分层本身就是成本优化
  5. 版本控制:prompt 是代码,不是随手写的便签
  6. 可测试性:行为评估框架替代简单的字符串比对
  7. 责任明确:每一层、每一模块都有所有者

这些不是高深的理论,而是工程常识在新领域的应用。但正是因为 prompt 看起来"只是一段文本",太多人忽视了对它施加工程纪律的必要性。

核心口号:

Prompts are not strings. They are architecture.

像写代码一样写 prompt,像管代码一样管 prompt。

下一章,我们将深入探讨分层设计中最棘手的子问题:当多层指令发生冲突时,优先级如何裁决? 这就是第 9 章"指令优先级"要解决的核心问题。