一、概述:被忽略的一块
一个 AI Agent 的落脚点只有一个——Claude API 的 messages 数组。API 只认三种 role:system、user、assistant。承载 Agent 的那层壳子(Harness),把花里胡哨的所有"状态、事件、上下文、提醒、约束"全都得塞进这三种 role 里,没有第四种。
Claude Code 通常被这么描述:"Claude API + 工具调用 + MCP + skill + 上下文压缩"。这几项确实都在。而且从工程密度上看,Claude Code 上还有其它很多层设计:hook 机制、plan 模式、auto 模式、team 协同、上下文压缩策略、MCP 接入……每一层都值得单独讲。但如果只看表面那几项,你会漏掉它最精华、最能决定模型"好不好带"的一层东西:<system-reminder> 标签。
所谓 system-reminder,其实就是 Claude Code 给自己立下的一条约定:
- 把一段文本包进一对
<system-reminder>...</system-reminder>标签里; - 以
user角色、带 meta 标记,插进messages数组; - 在系统提示里告诉模型:"见到这种标签,就知道是系统自动加的旁路信息,它跟前后那条 user 消息、tool result 没有因果关系。"
就这么一个约定,Claude Code 拿到了一条旁路通道:在不改变 API 角色语义、不自创新 role 的前提下,持续往模型脑子里灌注引导和约束。
它决定了模型在第 50 轮时还记不记得自己在 plan mode、知不知道今天是哪一天、要不要继续维护 todo 列表、是否该避开已经失效的 MCP 工具。大多数 system-reminder 用户永远看不到,但模型每一轮都在读。这篇文章就专门梳理这一层:定义、所有生产者(含类型、含义、注入时机和位置)、流水线后处理、消费与过滤;末尾两则附录顺带留一扇缝,看看大厂怎么做 prompt 工程的 A/B 实验。
二、定义与语法
2.1 标签本身
形如:
包装函数固定在前后各加一个 \n,所以换行是标签的一部分、不是内容的一部分。批量包装时,字符串型 content 整个包一层;数组型 content 只给 type === 'text' 的 text 块包,image / tool_result 等其它块保持原样。
2.2 "鉴别前缀"
整个 Claude Code 系统共用一个判定条件:文本是否以 <system-reminder> 开头。后续的"合并回 tool_result"、"UI 剥离"、"transcript 搜索"、"telemetry 采样"都靠它分辨"这段是不是 reminder"。
为此 Claude Code 在所有附件类消息的尾段跑一次幂等包装——如果某路径忘了加 reminder,就在这步补上。保证没有"漏网之鱼",否则会影响后续合并逻辑的正确性。
2.3 标签在消息里的形态
永远以 user-role 文本块的身份出现。形态有两种:
形态 A:整条 user 消息就是一条 reminder(最常见)
或数组写法:
这种消息在 Claude Code 内部带有 isMeta: true 标记,用于本地 UI 过滤,不影响发出去的 API 请求体。
形态 B:被合并到某个 tool_result 的 content 里(下文 §五详讲)
2.4 识别与剥离
识别用一个简单正则,用于 telemetry 等场景:
UI 侧的剥离分两种粒度:
- "只剥开头":渲染聊天气泡、复制到剪贴板时用。因为 reminder 往往拼在用户消息最前面,剥掉开头就能露出用户真正打的字。
- "满文剥离":transcript 搜索时用。因为用
claude -c(continue 子命令)恢复旧会话时,memory reminder 可能插在消息中段,只剥开头的版本不够用。
三、模型侧的"宪法"——系统提示中的声明
Claude Code 的系统提示里有两段声明,明确告诉模型"见到 <system-reminder> 怎么理解"。两段都是系统提示组成部分,一条给一般 agent,一条给自主工作型 agent。
第一段:
第二段(出现在另一份系统提示 items 清单里):
两段声明里,有三个关键点:
- 既可能出现在 user 消息里,也可能出现在 tool result 里 —— 对应 §二.3 的 A / B 两种形态。
- "bear no direct relation to..." —— 明确告诉模型"这段话不是对前面那条 user 消息或 tool result 的回复,不要当因果关系来读"。
- "automatically added by the system" —— 模型不应当面 echo、不应模仿、不应把这句话复述给用户。
此外,在具体模板里还有大量加强措辞:"DO NOT mention this to the user explicitly because they are already aware"、"Make sure that you NEVER mention this reminder to the user"、"Don't tell the user this, since they are already aware"——反复提醒模型:"这段话不要念出来。"
四、生产者分类:谁在往 messages 里塞 <system-reminder>
下文按语义分类,每一类先给一个表格,列出类型 / 含义 / 注入三项。"注入"这一列会分别讲清楚时机(什么时候触发)与位置(进入 messages 数组的哪个位置),因为这两者对理解 Harness 的行为同样重要。
"类型"列用的是代码中对应的 attachment.type 字符串;没有对应 type 字段、而是由函数/常量直接拼的,用函数名或常量名。模板里出现的 ${...} 都是代码里的运行时变量(比如 ${filename}、${planFilePath}),实际渲染时会被替换成具体值。
4.1 用户上下文预置
| 类型 | 含义 | 注入 |
|---|---|---|
prependUserContext | 一组 key: value 字典,打包进一条 reminder。主要字段有两个:claudeMd(项目根 CLAUDE.md 的完整内容)、currentDate("Today's date is ..." 一句话)。 | 时机:每次 API 调用前都跑一次,重新构造并插入。位置:整条 messages 数组的最前面(固定作为第 0 条 user 消息)。内容本身在会话期间几乎不变(除了日期翻篇),这种稳定性是为了最大化 Anthropic 端的 prompt cache 命中。 |
模板:
说明:
context字典里的字段并非硬编码固定,调用方(主对话 / forked agent / companion 等)可以各自决定塞哪些键。主对话里实际塞的是claudeMd、currentDate。claudeMd字段承担的是项目根 CLAUDE.md 的注入;但在填入这个字段之前会先跑一次filterInjectedMemoryFiles,把本会话里已经作为 attachment 注入过的 memory 文件(比如下面 §4.4 的nested_memory)从这里剔除,避免双重注入。换句话说:项目根 CLAUDE.md 走prependUserContext.claudeMd,子目录 CLAUDE.md 走nested_memoryattachment——两条路径互斥分工。
4.2 @提及的文件与目录
| 类型 | 含义 | 注入 |
|---|---|---|
directory | 用户在输入里 @ 了一个目录。Claude Code 会构造一对文本形式的"工具调用 + 工具结果",把 ls 的描述和实际目录列表包进 reminder。 | 用户输入里出现 @目录 的那一轮;作为两条 user 消息附在该轮用户消息之后。 |
file | 用户 @ 了一个文件。同样构造一对"文件读取 + 读取结果"的文本消息。子类型包括 text / image / notebook / pdf。文本文件若被截断,额外追加一条"截断说明" reminder。 | 用户输入里出现 @文件 的那一轮;作为两条 user 消息附在该轮用户消息之后。 |
edited_text_file | 先前被 @ 过的文件在之后被用户手工改动或 linter 自动改动——用一条 reminder 告知模型"这改动是有意的,不要 revert"。 | 检测到文件 mtime 变化、且该文件在会话中曾被引用时;作为一条 user 消息附在当轮用户消息序列中。 |
compact_file_reference | 历史对话被压缩后,原文件内容太大没保留下来,只保留一条占位 reminder 说"曾经读过"。 | 上下文压缩阶段替换原始文件内容时;作为一条 user 消息留在被压缩后的 messages 里。 |
pdf_reference | 10 页以上的 PDF 不能一次性读入,留一条 reminder 强制模型用分页参数读取。 | 用户 @ 了大 PDF 的那一轮。 |
模板:
edited_text_file:
file 子类型为 text 且被截断时,额外追加的第二条:
compact_file_reference:
pdf_reference:
示例:@目录 / @file 的消息结构
这两种分支特别之处在于,它们不是单纯塞一段文字,而是构造两条 user-role 文本消息,分别对应"工具调用的描述"和"工具结果的描述",整体再包进 reminder。注意这里的"工具调用/工具结果" 不是 API schema 定义的 tool_use / tool_result 结构化块——它们就是普通的 user-role 文本,字面写着:
以 @src/ 为例,最终进入 messages 的大致是这样两条相邻消息(实际发出前还会经过第五节的合并流水线):
效果:模型"以为"这次是用户要它看看 src/ 里有什么,并看到 ls 的文本结果。但整个过程并没有真的走一趟 Bash 工具——它完全是在文本层面伪造出来的一次调用叙事。
4.3 IDE 联动
| 类型 | 含义 | 注入 |
|---|---|---|
selected_lines_in_ide | 用户在 IDE 里选中了一段代码。内容超过 2000 字符会截断并在末尾追加 \n... (truncated)。 | IDE 把选中区信息同步到 Claude Code 后的那一轮;作为一条 user 消息附在当轮用户消息序列中。 |
opened_file_in_ide | 用户在 IDE 里打开了某文件(没选中具体行)。 | IDE 同步打开事件后的那一轮。 |
模板:
selected_lines_in_ide:
opened_file_in_ide:
4.4 Memory(CLAUDE.md / 项目 memory / 个人 memory)
| 类型 | 含义 | 注入 |
|---|---|---|
nested_memory | 在项目子目录里找到的嵌套 CLAUDE.md。注意这里只负责子目录的 CLAUDE.md;项目根 CLAUDE.md 走的是上面 §4.1 的 prependUserContext.claudeMd 路径。两者互斥,由 Harness 显式去重。 | 时机:发现到"会话里引用了某个子目录、且该子目录下存在 CLAUDE.md"时注入;每发现一个新的 CLAUDE.md 注入一次,并用一个去重 Set 记录已注入路径,防止同一个 CLAUDE.md 在同一会话里被重复注入多次。位置:作为一条独立 user 消息插入到当轮用户消息序列中。 |
relevant_memories | 按相关性排序后注入的一组个人/项目 memory 文件,每个 memory 一条独立 user 消息。每条前置一段 header,包含 "N days ago" 的年龄说明。 | 时机:由后台相关性检索任务预取(不阻塞主轮次),命中后在相应轮次随附件一起注入。位置:相关 memory 的若干条 user 消息插入到当轮用户消息序列中。 |
memoryFreshnessNote | 专供文件读取工具结果使用的内嵌式提醒,针对 1 天以上的 memory 文件追加"可能已过期"的告警。 | 时机:文件读取工具返回一个 memory 文件且其 mtime 超过 1 天时追加。位置:不单独成消息,直接嵌入在该次 FileReadTool 的 tool_result 内容字符串里。 |
模板:
nested_memory:
relevant_memories 的每一条(header 在 attachment 创建时就算好并缓存住,不会每轮用当前时间重算——这是为了 prompt cache 稳定):
memoryFreshnessNote(来自 memoryFreshnessText):
4.5 Skills
| 类型 | 含义 | 注入 |
|---|---|---|
skill_listing | 本会话可用的所有 skill 的只读清单(name + description 一行一条)。 | 时机:单次触发机制——由进程内一张 sentSkillNames Map 追踪"哪些 skill 已经广播过",整条清单只在会话首次出现、或 skill 集合真的发生变化时(插件重载、磁盘上 skill 文件变动)才注入;claude -c 恢复旧会话时,若 transcript 里已有过,会被主动压制下一次注入。压缩(compact)后不会重新注入,因为约 4K tokens 全花在 prompt cache 创建上、收益极小。位置:作为一条 user 消息插入到当次注入轮的用户消息序列中(通常是 turn 0)。 |
invoked_skills | 本会话中已经通过 Skill 工具调用过的 skill,把完整 markdown 内容一并封装保存。它不是"每轮重复注入"的机制——那样会每轮多花大量 token。真正的作用是跨压缩事件的保活。 | 时机:只在压缩(compaction)发生时创建一条这样的 attachment,作为压缩后消息序列的一部分加入——目的是让"本会话里已调用过的 skill 的完整内容"在 summarizer 的合并中不被吞掉。会话后续各轮并不重复注入。claude -c 恢复会话时由恢复逻辑读取该 attachment,把进程内的 skill 状态还原,以便后续再次压缩时仍能保全。位置:压缩事件生成的那条 user 消息里,位于压缩产物之后。 |
skill_discovery | 通过 skill 搜索匹配出的相关 skill(只给 name + description,不展开内容),提示模型"考虑用它"。 | 时机:启用 EXPERIMENTAL_SKILL_SEARCH feature 时,后台检索命中相关 skill 的那一轮注入。位置:作为一条 user 消息插入到当轮用户消息序列中。 |
dynamic_skill | 仅 UI 用,在 API 层不产生任何 user 消息(代码里明确返回空数组)。 | —— |
模板:
skill_listing:
invoked_skills:
其中 skillsContent 是每个 skill 按下列格式拼接、以 \n\n---\n\n 连接:
skill_discovery:
说明:
- Skill 的初次加载不是走 reminder 的——走的是 Skill 工具调用,调用后由
tool_result把 skill 的完整内容返回给模型。之后该 skill 的内容就在tool_result里躺着,不需要任何重复注入。 invoked_skills这条 reminder 的真正作用是跨压缩事件的保活。正常不压缩的情况下,skill 的内容活在tool_result里;但一旦触发上下文压缩,summarizer 可能会把tool_result的细节汇总掉,使得后续的 turn 丢失 skill 指南。为此压缩流程会在生成压缩产物的同时,额外塞进一条invoked_skills把这些 skill 的完整 markdown 带过压缩边界;用claude -c恢复旧会话时,也由这条 attachment 把进程内的 skill 注册表恢复。skill_listing的节制也类似——整条清单大约 4K tokens,所以代码里反复强调"只注入一次、不要重复铺",宁可相信模型还记得 Skill 工具的 schema 和已用 skill 的tool_result。skill_discovery分支只给 name + description;完整 skill 内容要通过 Skill 工具自行拉取。
4.6 Todo / Task 软提醒
| 类型 | 含义 | 注入 |
|---|---|---|
todo_reminder | 催促使用 Todo 工具、并附上当前 todo 列表的温和提示。 | 检测到 Todo 工具一段时间未被使用、且当前任务看起来需要跟进时(具体阈值由内部判断逻辑决定);作为一条 user 消息附在当轮用户消息序列中。 |
task_reminder | 类同上,但对应新版 Task 系列工具(TaskCreate / TaskUpdate)。 | 与上同,但只在 isTodoV2Enabled() 启用时生效。 |
模板:
todo_reminder:
task_reminder:
说明:两段模板末尾的 "NEVER mention this reminder to the user" 是 reminder 类提示的固定收尾——对模型的压制是怕它把"系统让我用 TodoWrite"当真理念给用户听,变成一次无意义的无工具回合。
4.7 模式切换(Plan / Auto / Output Style / Brief)
这一类 reminder 在语义上最"重"——它们是运行时的模式开关,一注入就相当于给模型换上了一整套新的行为准则。
| 类型 | 含义 | 注入 |
|---|---|---|
plan_mode(reminderType=full) | 进入 plan 模式——模型只能读文件、写 plan 文件,不能改代码。完整 5-phase workflow。 | 用户进入 plan mode 的那一轮;或会话恢复后首次需要重新声明时。 |
plan_mode(reminderType=sparse) | plan 模式已激活但到了会话深处——一条极简版提醒,防止模型"忘了还在 plan mode"。 | 会话较深时由"稀疏提醒"策略决定。 |
plan_mode_reentry | 曾退出过 plan mode,这次又要进入时的特殊引导。 | 用户重入 plan mode 且已存在 plan 文件时。 |
plan_mode_exit | 告知模型退出 plan mode,允许写入等操作。 | 用户退出 plan mode 的那一轮。 |
auto_mode(reminderType=full) | 进入 auto 模式(持续自主执行,少打断)。 | 用户进入 auto mode 的那一轮。 |
auto_mode(reminderType=sparse) | 深层会话的简化版提醒。 | 同 Plan sparse。 |
auto_mode_exit | 告知模型退出 auto mode,恢复正常交互节奏。 | 用户退出 auto mode 的那一轮。 |
output_style | 当前输出样式声明(default / Explanatory / Learning 等)。 | 每一轮 API 请求前(除 default 外);作为一条 user 消息。 |
brief(/brief 命令) | brief 模式开启/关闭提示。 | 用户切换 brief 模式的那一轮;不走 attachment,直接字符串拼接入消息。 |
模板:
plan_mode(full,不含 interview phase 的版本):
补充说明:整个 plan workflow 是 5 个 Phase,其中 Phase 4(写最终 plan 那一步)在代码里另做了一组 A/B 实验,共 4 个变体(CONTROL / TRIM / CUT / CAP),由服务端下发的 feature flag 分桶决定。上面展开的就是 CONTROL 对照组原文;完整的实验细节、动机、监测指标见附录 A。
plan_mode(sparse):
其中 workflowDescription 在 interview phase 启用时为 Follow iterative workflow: explore codebase, interview user, write to plan incrementally.,否则为 Follow 5-phase workflow.。
plan_mode_reentry:
plan_mode_exit(planReference 在存在 plan 文件时为 The plan file is located at ${planFilePath} if you need to reference it.,否则为空):
auto_mode(full):
auto_mode(sparse):
auto_mode_exit:
output_style:
brief 模式开启:
brief 模式关闭:
4.8 子 Agent / 后台任务
| 类型 | 含义 | 注入 |
|---|---|---|
agent_mention | 用户 @AgentName 时的触发提示。 | 用户输入里含 agent 提及的那一轮。 |
task_status(killed) | 用户手动停止了某个后台 agent。 | 停止操作发生后的下一轮。 |
task_status(running) | 后台任务尚未结束——重点是阻止模型重复 spawn 一个同样的 agent,尤其在 compaction 之后,原始 spawn 消息已不在 messages 里了。 | 每一轮 API 请求前,只要检测到有对应后台任务还在运行且 spawn 消息已被压缩掉时。 |
task_status(completed / failed) | 后台任务出结果——告诉模型如何读结果。 | 任务完成事件到达后的下一轮。 |
team_context | 团队协同模式下,声明当前 agent 的身份、team 配置路径、task list 路径等。 | agent swarm 初始化时注入一次(后续是否继续注入没核实到一手结论,先按首轮注入理解)。 |
SHUTDOWN_TEAM_PROMPT | 非交互模式下,强制模型"在返回最终响应前先关闭子 team"。 | 时机:非交互模式下检测到 team 仍存在时。位置:不走 attachment 管线,由 CLI 打印路径直接拼成一条 user 消息发给模型。 |
模板:
agent_mention:
task_status(killed):
task_status(running,带 outputFilePath):
task_status(running,不带 outputFilePath):
task_status(completed / failed,带 outputFilePath):
(不带 outputFilePath 时,末句换成 You can check its output using the TaskOutput tool.。deltaSummary 为空时不附 Delta: 段。)
team_context(模板里内嵌一个 JSON 代码块,下面首行反引号做了转义以免破坏外层渲染):
SHUTDOWN_TEAM_PROMPT(reminder 段 + 同一条 user 消息里 reminder 外的命令句,两段一起发):
4.9 Hook 事件(用户自定义的 shell 钩子)
| 类型 | 含义 | 注入 |
|---|---|---|
hook_blocking_error | Hook 以非零退出码阻塞了本次工具调用。 | Hook 返回阻塞退出码后的下一轮;作为一条 user 消息附在当轮用户消息序列中。 |
hook_success | Hook 成功且有额外输出。只限 SessionStart / UserPromptSubmit 两类事件、且内容非空——否则每轮都出现 "hook success: Success" 会污染 messages。 | 满足上述两条件时。 |
hook_additional_context | Hook 通过 additionalContext 字段主动追加的上下文文本。 | Hook 返回 additionalContext 后的下一轮。 |
hook_stopped_continuation | Hook 主动阻止当前 turn 继续。 | Hook 指示中断后。 |
async_hook_response | 异步 Hook 完成后的回调,可能带 systemMessage 和 additionalContext。 | 异步 hook 完成事件到达后的下一轮。 |
| 异步 Stop Hook 阻塞 | 异步 Stop hook 阻塞时由专用路径直接包装并入队。 | 异步 Stop hook 以阻塞码退出时;通过 enqueuePendingNotification 注入下一轮消息。 |
模板:
hook_blocking_error:
hook_success:
hook_additional_context:
hook_stopped_continuation:
异步 Stop Hook 阻塞:
4.10 MCP / 动态工具与 Agent 注册
这几类 reminder 服务于一件事:运行时的动态工具/ agent 生态变化要让模型立刻得知。
| 类型 | 含义 | 注入 |
|---|---|---|
deferred_tools_delta | ToolSearch 生态下,新接入/断开的延迟工具。 | 检测到 MCP 服务器连接状态变化、或 ToolSearch 索引更新时;作为一条 user 消息附在当轮用户消息序列中。 |
agent_listing_delta | 子 agent 类型注册/注销的增量广播。首次注入时会附加"并发提示"。 | Agent 清单首次注入时;之后检测到增减时。 |
mcp_instructions_delta | MCP 服务器提供的使用说明。 | MCP 服务器连接建立/断开时。 |
mcp_resource | 用户 @ 了某个 MCP 资源——把资源内容转成文本/图像块,前后加提示。 | 用户 @ MCP 资源的那一轮。 |
模板:
deferred_tools_delta(增/删可以同时出现,多段之间用 \n\n 连接):
agent_listing_delta(首次 vs 增量的 header 不同):
mcp_instructions_delta:
mcp_resource(有 text 内容时,item.text 前后加两段提示文本):
mcp_resource(空/二进制/无可显示内容时,从如下三种里挑一种):
说明:deferred_tools_delta 的精妙之处在于和 ToolSearchTool 的配合——它只广播工具的名字,让模型知道"有这些工具";真正的工具 schema 要通过 ToolSearch 工具按需查询。这是按需加载工具 schema 的一种经典做法,避免新增 MCP 工具就把 system prompt 撑爆。
4.11 会话生命周期与状态切换
| 类型 | 含义 | 注入 |
|---|---|---|
compaction_reminder | 提醒模型"无限上下文",安抚它不用急着结束。 | 启用 COMPACTION_REMINDERS feature 时在每轮请求前注入。 |
context_efficiency | 启用 HISTORY_SNIP feature 时的一条提示,作用是让模型可以用 snip 工具裁剪过大的历史内容(比如把一大段 Read 结果折成摘要以省 token)——这条 reminder 的内容大概是指引模型在上下文快速膨胀时主动用 snip 收紧。具体模板文本代码中无可对照的源(文本位于一个已编译的 js 文件里)。 | 时机:启用 HISTORY_SNIP feature、上下文增长较快的轮次注入。位置:作为一条 user 消息插入到当轮用户消息序列中。 |
date_change | 日期翻篇时的告知——不要当着用户面提。 | 检测到今天的日期与会话开始时不同的那一轮。 |
critical_system_reminder | 某些 agent 定义里声明的"关键提醒"字段(实验字段,代码注释里称作 "Short message re-injected at every user turn")。内容由 agent 作者自定义。一个真实的内置用例是 verificationAgent,它用这条 reminder 让验证子 agent 记住"只能验证、不能改代码、必须以 PASS/FAIL/PARTIAL 结尾"。 | 时机:每一轮 user turn 都重新注入(只要当前 agent definition 里配置了此字段)。位置:作为一条 user 消息插入到当轮用户消息序列中。 |
ultrathink_effort | 用户请求把推理强度调高到指定 level。 | 用户请求相应推理等级的那一轮。 |
companion_intro | Buddy 功能里"伴生动物"的身份自介。 | 启用 Buddy 时的相应时机。 |
queued_command | turn 进行中,用户/子系统排队的消息插入时的包装(由前缀函数按来源分 4 种,然后外层包 reminder)。 | turn 进行中队列被 drain 时。 |
verify_plan_reminder | plan 执行完成后,提醒模型调用 VerifyPlanExecution 做验收。 | 检测到 plan 项执行完毕时。 |
plan_file_reference | 把当前 plan 文件内容作为提示带进对话。 | plan mode 的相应轮次。 |
模板:
compaction_reminder:
date_change:
ultrathink_effort:
companion_intro(来自 companionIntroText(name, species)):
queued_command 的 4 种前缀(由 wrapCommandText(raw, origin) 按 origin.kind 分支,外层再统一包 reminder):
来源 human / undefined(普通用户打字):
来源 task-notification:
来源 coordinator:
来源 channel:
verify_plan_reminder(toolName 在 CLAUDE_CODE_VERIFY_PLAN === 'true' 时为 VerifyPlanExecution,否则为空字符串):
plan_file_reference:
critical_system_reminder 的内容由 agent definition 自行定义,内置 verificationAgent 里的原文是:
4.12 Token / 预算统计
| 类型 | 含义 | 注入 |
|---|---|---|
token_usage | 当前累计 token 用量。 | 开启 token 统计注入的 feature 时每轮或按阈值注入。 |
budget_usd | 美元预算消耗情况。 | 开启预算注入的 feature 时。 |
output_token_usage | 输出 token 的每轮与每会话统计。 | 开启的相应 feature 下。 |
模板:
token_usage:
budget_usd:
output_token_usage(budget !== null 时):
output_token_usage(budget === null 时):
4.13 Diagnostics(IDE / LSP 诊断)
| 类型 | 含义 | 注入 |
|---|---|---|
diagnostics | IDE / LSP 新发现的 lint 警告或 error 集合。 | 检测到诊断数量发生变化的那一轮。 |
模板(reminder 内部再嵌一层 <new-diagnostics>,便于模型分辨):
4.14 Side Question(不经 attachment,独立路径)
| 类型 | 含义 | 注入 |
|---|---|---|
| Side Question | 用户在主任务进行中"问一个不打断主线的小问题"。Claude Code 会 spawn 一个轻量 forked agent,把整个用户问题用 reminder 包起来,强约束它直接回答、不用工具、不承诺后续动作。 | 用户触发"旁支问题"的那次独立 forked agent 调用中,reminder + 用户原始问题作为该 forked agent 的唯一 user 消息。 |
模板(reminder 闭合后紧跟一个换行 + 用户真实问题文本 ${question}):
说明:本分支是全系统里 reminder 强度最高的地方之一——连"不要说 Let me try..."这种措辞层面的限制都写进了约束。原因是 side question 的 forked agent 没有任何工具可用,如果它说"我去查一下"就彻底卡死。
4.15 内嵌在 tool_result 里的 reminder
这些不是独立的 user 消息,而是在某个工具结果字符串里手工拼出来的 reminder——模型看到的是"工具结果末尾附带的注解"。
| 类型 | 含义 | 注入 |
|---|---|---|
FileReadTool 空文件 | 读到 0 行的文件。 | 读空文件时,嵌入在该次 FileReadTool 的 tool_result 字符串里。 |
FileReadTool 偏移越界 | 指定的 offset 超过实际行数。 | 越界读时,嵌入在 tool_result 里。 |
CYBER_RISK_MITIGATION_REMINDER | 对疑似含恶意代码的文件内容,追加"可以分析但不能改造"的约束。 | 文件读取后、针对特定模型集合追加(除某些模型外都追加);嵌入在 tool_result 末尾。 |
memoryFreshnessNote(见 §4.4) | Memory 文件在 FileRead 输出里的 staleness 注解。 | 读 1 天以上 memory 文件时嵌入在 tool_result 里。 |
模板:
FileReadTool 空文件:
FileReadTool 偏移越界:
CYBER_RISK_MITIGATION_REMINDER(前后有 \n\n 和 \n,完整原文如下):
4.16 返回空的分支(声明了类型但在 API 层不产生 reminder)
为了穷尽列一下——这些 attachment.type 在代码里明确返回空数组,只在 UI 层有意义:
already_read_file、command_permissions、edited_image_filehook_cancelled、hook_error_during_execution、hook_non_blocking_error、hook_system_message、hook_permission_decisionstructured_outputdynamic_skill(仅 UI 用)- 已移除的遗留类型:
autocheckpointing、background_task_status、todo、task_progress、ultramemory等
五、消息流水线后处理:Smoosh —— 把 reminder 折回 tool_result
到 §四为止,所有生产者产出的都是独立的 user 消息。但在真正发到 API 之前,Claude Code 会做一次关键的合并:把 <system-reminder>-前缀的文本块折进紧邻它的最后一个 tool_result 里。
5.1 为什么要折?
代码里对这件事的解释(翻译自注释原文):
如果 toolresult 块后面直接接着一个 text 块(哪怕只是一条 reminder),在底层 prompt 序列化里会被渲染成 `</functionresults>\n\nHuman:
的形态。在对话中段反复出现这种 pattern,会让模型"学到"一个坏习惯:工具调用完之后自己吐一个空的Human:` 前缀再结束回合——浪费 3 个 token 的无效回合。内部 A/B 实验显示:不合并时这种行为发生率约 92%,合并后降为 0%。
简单说:不合并,就会污染模型的输出习惯。
5.2 合并规则
- 紧挨着的 user 消息先被合并成同一条;
- 其中若同时包含
tool_result块和<system-reminder>前缀的 text 块,就把这些 reminder text 折进最后一个 tool_result 的content字段里; tool_result.content本来是字符串、待合并块又全是 text → 拼接成字符串,用\n\n连接;tool_result.content含某种实验性的tool_reference块 → 不合并,跳过;- 错误态(
is_error: true)的 tool_result 受 API 约束只能含 text——先过滤掉非 text 块再拼; - 其它情况规整成数组形态、相邻 text 块再合并。
5.3 合并前后对比
合并前(两条 user 消息已经被相邻合并成一条,但 reminder 仍是独立 text 块):
合并后(reminder 折进 tool_result.content 字符串):
5.4 相关的两个护栏
- tool_result 前置:user 消息里的
tool_result块必须出现在最前面,否则 API 会报 "tool result must follow tool use"。合并过程中会做一次前置整理。 - 错误结果内容消毒:老会话里若把 image 等非 text 块塞进了
is_error: true的 tool_result,恢复时会 API 400 崩溃。读端有一次无条件的消毒,过滤成纯 text。
5.5 为什么需要"鉴别前缀"幂等包装
合并逻辑靠"文本是否以 <system-reminder> 开头"判定该不该折进 tool_result。所以每个 attachment 分支都必须把自己的 text 内容包上 reminder 标签,否则就会漏网,变成那个会教坏模型的 sibling。在流水线尾段跑的幂等包装,就是兜底补齐的最后一步。
六、消费者:谁在读这些 reminder
6.1 模型(唯一"认真"的读者)
- 系统提示里已经告诉它"这是旁白",别 echo、别把它当作用户原话。
- 绝大多数具体模板里,还额外写了 "DO NOT mention this to the user explicitly"、"NEVER mention this reminder to the user"、"Don't tell the user this" 反复强化。
- 模型读得不读得好,就是 Agent 工程的上限。
6.2 UI 渲染聊天气泡
Claude Code 的交互界面,每一条用户消息气泡、每一条 assistant 消息气泡都由前端组件根据 messages 数据源渲染出来。问题是:对"模型视角"的 messages 而言,一条用户消息的完整文本往往是形如:
直接把这种文本渲染到聊天气泡里,用户会看到一堆不知所云的英文旁白。所以 UI 组件在渲染前会把消息文本喂给 stripSystemReminders,把开头那些连续的 <system-reminder>...</system-reminder> 块依次剥掉,只保留尾部用户真实输入。仅剥开头即可——因为生产侧的约定就是"reminder 永远拼在用户消息最前面"。
6.3 复制到剪贴板
Claude Code 支持把某条消息复制到剪贴板(比如方便贴到别处分享)。复制走的是同一个剥离函数,规则和 UI 渲染一致:只剥消息开头的 reminder 块,把用户真正打的字复制出去。不是调试场景的话,用户不需要看到 reminder。
6.4 Transcript 搜索
Claude Code 把会话历史保留成 transcript(对话脚本),支持用户事后检索"我之前说过什么"、"Claude 之前说过什么"。搜索命中的是人类可读内容,因此在索引/匹配之前,同样要把 reminder 从文本里清掉。但 transcript 搜索用的版本比 UI 渲染更狠——它不是"只剥开头"而是"循环剥全文"。
原因是:当用户用 claude -c 恢复旧会话时,某些 reminder(比如 memory 更新)会被插在消息中段而不是开头;只剥开头会留下残骸。所以 transcript 搜索用的全文剥离版本在整段文本上迭代,找到一个 <system-reminder>...</system-reminder> 就切掉一个,直到没有为止。
6.5 Telemetry(遥测)
Telemetry 指 Claude Code 收集的使用行为与性能指标——比如"每一轮消息里 reminder 占了多少字节、哪类 reminder 最常注入、prompt 缓存命中率、各种 reminder 对输出 token 的影响"等等。这些指标通过匿名化的会话追踪上报给研发团队,用来支撑 A/B 实验和长期统计(文中多次出现的"A/B 实验代号"就是从这类遥测拉出来的证据)。
在这个场景下,Telemetry 需要把 reminder 的内容单独抽出来做分类统计——不抽出来就只能统计整条消息,无法区分"这轮有多少字节是用户原话、多少字节是系统注入"。因此 Telemetry 专用的辅助函数用一个简单正则:
把整段消息文本与之匹配:若整段恰好是一条 reminder,就提取内部文本、归类为"系统注入";否则当作普通用户/模型内容计入另一桶。
七、设计哲学:引导与约束
<system-reminder> 从头到尾体现的都是同一件事——对大模型的引导与约束。把链路读一遍,可以归纳出四条具体做法:
- 约定一条模型能辨认的旁路通道。API 只有三种 role,无法给"系统注入"开新 role,于是 Claude Code 用一对 XML 风格标签 + 系统提示里的两句声明,让模型学会"见到这种前缀就知道是旁白、不是因果"。
- 在关键节点反复注入以维持状态。Plan 模式、Auto 模式、Output Style、skill 调用的指南——这些只讲一次模型会忘;每一轮都重新注入一次,才能让"现在在 plan mode"这件事真的持续 50 轮。
- 让 reminder 的字节"可缓存"。模板里所有容易每轮抖动的字段都被主动冻结(例如 memory header 里的 "N days ago" 在 attachment 创建时就算好并缓存),避免
Date.now()把 prompt cache 打穿——这让每轮注入的 reminder 成为 prompt cache 的友军,而不是对手。 - 矫正副作用。reminder 放错位置(作 tool_result 的 sibling)会教坏模型、让它多吐一个空
Human:回合。smoosh 合并机制把这种漂移从根本上消除掉,让"不停注入 reminder"这个动作本身不会累积毒性。
这四条放在一起,才是 <system-reminder> 真正做的事——它不只是一个标签,而是一整套"如何持续地引导和约束大模型"的工程实践。
结语
如果你之前对 Claude Code 的印象是"Claude API + 工具调用 + MCP + skill + 上下文压缩"那几件事,希望这篇文章让你多看到了一层:在每轮 API 请求真正发出去之前,messages 数组里早已被系统静悄悄填满了各种 <system-reminder>。它们是模型行为长期保持稳定的那条几乎看不见的骨架。
附录
A. Phase 4 的 A/B 实验
正文 §4.7 提到 Plan 模式 Phase 4("写最终 plan"那一步)在代码里有 4 个 A/B 变体。这里把实验的具体细节整理一下,作为一扇看看"大厂怎么做 prompt 工程实验"的小窗。
实验代号:tengu_pewter_ledger,由 Anthropic 服务端的 feature flag 分桶下发。返回值为 null(走 CONTROL 对照组,也是默认/兜底)、'trim'、'cut'、'cap' 四种。所以一个用户具体看到哪一版 Phase 4 不取决于本地配置,而是服务端分配;null 是 fallback,意味着统计上多数用户看到 CONTROL。
4 个变体从宽到严:
CONTROL:对照组原版(正文已展开),要求 Context 背景段、详细文件列表、端到端验证说明。TRIM:Context 压成一行,验证部分改成"一条验证命令"。CUT:直接禁止写 Context/Background 段,并加入"好 plan 通常少于 40 行,废话即是凑字数"。CAP:最严版——禁止散文段、每个文件只能一行说明、硬性 40 行上限。
实验动机(来自 CONTROL 组的 baseline):代码注释里附了一段 14 天内约 2630 万个会话(截止 2026-03-02)的 baseline 统计:
- plan 文件长度:中位数 4,906 字符、p90 11,617 字符、均值 6,207 字符。
- 82% 会话走 Opus 4.6。
- 拒绝率随 plan 大小单调增长:plan 不到 2K 字符时用户拒绝率约 20%,plan 超过 20K 字符时拒绝率升到 50%。
这条单调关系就是 TRIM/CUT/CAP 实验的出发点——plan 越短,用户越容易接受。于是实验要回答的是"能不能把 Phase 4 的指令压缩到让模型主动写更短的 plan,同时又不损失质量"。
监测指标(来自代码注释):
- 主指标:会话级平均成本(Anthropic 内部指标代号
fact__201omjcij85f)。用成本而不是直接用"plan 长度"当主指标,原因是 Opus 输出价格是输入的 5 倍——花多少钱等价于吐多少字节;这比 plan 长度更能反映整体开销。 - 机制变量:plan 退出时记录的 plan 文件字符数(
planLengthChars)。注释里专门提醒:"CAP 变体可能把 plan 文件压短了,但总输出反而因为 write→count→edit 的反复循环变多——所以光看 plan 长度会误判。" - 护栏指标:用户点坏反馈(feedback-bad)的比率、每会话发起的请求数(plan 太薄 → 实现阶段补轮次变多)、工具错误率。三条护栏挡住"为压而压把质量压没了"。
到截稿为止实验还在跑——代码里 4 个变体的分支都还在线,服务端仍按配置做分桶,没有收敛为某个唯一胜者的迹象。
B. Interview Phase:Anthropic 员工走的另一条 Plan 链路
正文 §4.7 的 Plan 模式展开的是 5-phase workflow——面向外部用户的主流程。代码里还存在另一条完整的 Plan workflow,代号 interview phase。它不是 5-phase 的第几步,而是 5-phase 的替代:Plan 模式被激活后,Claude Code 要么走 5-phase、要么走 interview phase,二选一。
怎么决定走哪条:函数 isPlanModeInterviewPhaseEnabled() 按优先级判定:
- Anthropic 员工(代码用环境变量
USER_TYPE === 'ant'判定)——始终启用 interview phase。 - 否则看环境变量
CLAUDE_CODE_PLAN_MODE_INTERVIEW_PHASE(用户显式开关)。 - 否则查 feature flag
tengu_plan_mode_interview_phase。
正是因为员工强制走 interview phase,而 Phase 4 的 A/B 实验(见附录 A)只跑在 5-phase 工作流上,interview phase 就天然被实验隔离,成了观测设计上的参照组。
完整模板(来自 getPlanModeInterviewInstructions,替换后占位符如 ${planFilePath} 等为运行时值;最外层再包一层 <system-reminder>):
其中 ${planFileInfo} 与 5-phase 的同名占位符用的是同一套条件文案——plan 文件已存在时展开为"A plan file already exists at ${planFilePath}. You can read it and make incremental edits using the Edit tool.",不存在时展开为"No plan file exists yet. You should create your plan at ${planFilePath} using the Write tool."。