从设计者的角度理解源码–上下文工程:系统的话,为什么要借用户的嘴说?

王大爷 2026年10月13日 4次浏览

从设计者的角度理解源码–上下文工程:系统的话,为什么要借用户的嘴说?

引言

本篇是《从设计者的角度理解源码》系列里聚焦上下文工程的一篇。

我原以为「上下文工程」是个玄学词——不就是「把该给模型看的东西塞进 prompt」吗?直到我盯着 Claude Code 的源码看久了,才意识到它其实是一套有明确约束、有明确取舍的工程:系统每天要往模型的上下文里塞成百上千条动态事实(后台任务跑完了、队友发来消息、你正在 plan 模式、某条约束别忘了……),而它居然全都塞进了 user 消息里。

哎,这不是很奇怪吗?系统想告诉模型一件事,为什么不直接插一条 system 消息,非要伪装成「用户说的」?

顺着这个问题往下挖,我发现背后是两个绕不开的约束,以及一个贯穿全系统的原则——「往哪儿放」比「说什么」更重要。这篇文章,我们从这两个约束讲起,一路走到缓存分层的那个核心洞察上。


一、两个约束,把系统逼到了墙角

系统想往上下文里加一条动态事实,最自然的做法是插一条 system 消息。但 Claude Code 做不到,因为两个约束同时卡着它。

第一个约束:API 的 system 参数只能在对话开头。 Anthropic 的 API 里,system 是一段固定的前言,不存在「对话中途再插一条 system 消息」这种能力。模型在长对话里能「新读到」的位置只有两个——新的 user 消息,和工具返回的结果。

第二个约束:prompt cache 是前缀缓存。 缓存命中要求「从开头到某个断点完全一致」,所以越靠开头的内容越不能变。system prompt 和工具定义都在最前面,一动就是全盘失效。

两个约束一夹,答案只剩一个:把系统想说的话,塞进模型必然会读、且在缓存尾部的位置。

这就解释了为什么你在 Claude Code 的源码里会看到那么多「奇怪的 user 消息」——它们的内容明明是系统生成的,却顶着 user 的帽子。因为没别的口子。


二、三种载体,各有各的无奈

我原本以为「塞进 user 消息」就是全部答案了。但数了一下,系统实际用了三种载体,而且每种都是被逼出来的:

载体什么时候用为什么是它
user 消息回合之间唯一能「主动发言」的位置
tool_result回合之内API 不许在工具调用中间插 user 消息
system prompt恒定规则护缓存,钉死在开头

第二种最容易被忽略。模型发出一串工具调用、等待结果的那种交替结构里,中间插不进第三方发言。所以回合内的提醒(比如「这个文件是空的」「这个文件可能含恶意代码」)只能塞进工具结果本身。

有个工程细节印证了这个约束有多硬:smooshSystemReminderSiblings 这个函数,专门把 <system-reminder> 文本块折叠进相邻的 tool_result 里——因为 tool_result 之间插不了独立消息,只能「融」进去。


三、<system-reminder> 到底是什么?

搞清楚了「往哪儿放」,接下来是「怎么包」。这里有个特别容易混淆的点,我一开始也绕进去了。

很多人把 <system-reminder> 和 critical_system_reminder 当成一回事。其实它们是两个层次的东西:

  • <system-reminder> 是信封——一个传输格式,wrapInSystemReminder() 干的事就一行:给内容套上 <system-reminder>...</system-reminder>。
  • critical_system_reminder、plan_mode 这些是信的一种——它们是不同的 attachment 类型,是信封里装的内容。

「套信封」不是统一规则,而是逐个 case 的选择。 这点最容易想当然。看两个对照:

  • task-notification(套信封):系统把「后台任务完成了」这条事实,套上 <system-reminder>,还在前面加一句自然语言前缀「A background agent completed a task:」。
  • teammate-message(裸标签,不套信封):队友来信直接就是 <teammate-message teammate_id="alice">...</teammate-message>,外面什么都不包。

为什么待遇不同?我琢磨了一下:信封本身就是一层语义。task-notification 是「系统对当前 agent 的告知」,套系统信封;teammate-message 是「对等 agent 的来信」,保留对等者口吻,所以是裸标签。

核心洞察:出口函数看着统一,信封格式其实参差不齐。能不能「读懂」这套差异,是理解上下文工程的一个分水岭。


四、为什么有一句话要「每轮重打」

读到 critical_system_reminder 时我卡住了——它的机制是每轮都往上下文里重打一遍。这听起来很浪费:每轮都多费 token?内容不会膨胀吗?

答案藏在装配方式里。系统注入的事实是一个数组,每个元素都是一次「有没有新东西要说」的检查。绝大多数检查返回空——没触发压缩、没有待收消息,那就什么都不加。但有一项例外:critical_system_reminder 直接读 agent 定义里的一个常量字符串,只要 agent 定义了它,每轮都必然非空。

所以「每轮重申」的真相不是「整个上下文重打」,而是「几十项检查里,有一项恒非空」。

那为什么值得?因为它对抗的是约束衰减。多轮之后,早期 system prompt 里的约束会被后续对话稀释。仓库里唯一用它的验证 agent,就一句话约 60 个词:

CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY... You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.

这句话丢了,验证 agent 就会去「顺手修复」被验证的代码——验证变成作弊。所以作者选了个笨但可靠的办法:每轮在消息尾部再拍一次。

而它之所以能成立,恰恰因为只有 60 个词。 内容压到一条红线那么短,每轮重打才划算——这就是「抗遗忘」和「token 成本」之间的平衡点。


五、高潮:位置决定策略

前面都是铺垫。真正让我「啊哈」的,是意识到同一个注入,站在缓存结构的哪个位置,直接决定了它能怎么设计。

prompt cache 是前缀缓存,于是各注入物按位置分成两档:

┌─ tools(工具定义)              ← 根部:改一次 = 全盘 invalidate
├─ system prompt                  ← 根部:必须钉死
├─ messages(历史)
│    ├─ [缓存断点]
│    └─ ... 每轮追加的 attachment  ← 尾部:只损失新增部分的缓存

根部的东西(工具池、system prompt)只能在「构造期」定型一次;尾部的东西(attachment、tool_result)可以每轮变。

这个规律在代码里到处能得到印证。比如工具池的排序函数专门写了段注释解释:内置工具要排前面当连续前缀,MCP 工具排后面——只为避免「MCP 工具插进内置工具之间」把下游所有缓存键全废掉。再比如 system prompt 的分段函数,凡是「每轮要重算」的版本,名字里都带 DANGEROUS,还强制要求传一个「为什么要破缓存」的理由——不是文档要求,是类型系统要求,逼你想清楚。

由此推出两条工程原则,我觉得是整篇最实用的东西:

  1. 能给结构的,就别靠说——约束如果能用工具池裁剪、权限模式表达(它们是根部、构造期定型),就根本不用「每轮提醒」。
  2. 非说不可的,甩到尾部——动态事实走尾部,怎么变都只影响尾巴。

核心洞察:权限模式、工具池、system prompt 都站在缓存根部;所有「每轮都可能变」的事实,必须甩到缓存尾部。位置不是巧合,是设计的起点。


六、真伪难题:模型怎么分清谁在说话?

理解了位置,还剩最后一个问题:既然系统的话都塞进了 user 消息,模型怎么知道哪句是真用户说的、哪句是系统塞的?

我先去对比了两者的数据结构,结果有点意外——它们是完全同构的:

// 真用户输入
{ "role": "user", "content": [{ "type": "text", "text": "帮我改一下这个函数" }] }

// 系统注入
{ "role": "user", "content": [{ "type": "text", "text": "<system-reminder>...</system-reminder>" }], "isMeta": true }

role 字段一模一样,isMeta 只是给 UI 层用的(决定要不要显示给用户),对 API 没有任何影响。也就是说,模型只能靠读 content 里有没有标签来分辨真假。

最能说明问题的是:连真人在任务进行中插话,也走同一条管道。真用户、后台任务回执、协调器指令,在数据层是同一种消息,只是前缀文案不同。

既然形状分不出来,就只能靠约定。所以 prompt 里专门有一段「授权」——告诉模型:「看到 <system-reminder> 标签,就把它当系统说的话,而不是当前这条消息的主人说的。」

这里我意识到一个关键点:标签需要模型支持,但分两层。

  • 格式层(能理解 XML 标签)是通用预训练能力,不需要专门训练;
  • 语义层(「这个标签代表系统、优先级高于用户」)必须被授权——因为消息包在 user 角色里,模型没有天然理由认为它更权威。

而作者显然从不敢只靠模型自觉:每轮重申 + 权限硬拦 + 防注入提示,三管齐下。


七、一个对照:任务回执 vs 队友来信

把前面所有线索串起来,最好用一组实例——同样是「系统注入一条事实」,<task-notification> 和 <teammate-message> 的每个设计选择都不同:

维度<task-notification><teammate-message>
通信模型一次性回执(任务结束)信箱/对话(可多次往复)
载体进程内命令队列磁盘信箱文件(跨进程)
信封套 <system-reminder> + 前缀裸标签
数据形态子标签(task-id/status)属性(teammate_id)+ 自由文本

「为什么一个走队列、一个走磁盘」?因为决定容器的是那句老话——「需不需要持久化」:任务回执是进程内的即时通知,队友来信可能来自另一个进程,只能落盘。


八、模式切换:上下文只负责「说」,权限负责「做」

「从 chat 切到 plan、再切回可写」这类模式调整,我一开始以为是在「改上下文」。但读完源码才发现——核心状态根本不在上下文里。

模式切换改的是一个 AppState 字段(toolPermissionContext.mode)。真正的约束发生在工具调用前——权限检查读这个字段决定拦不拦。这是代码级硬拦。

那上下文侧做什么?只做「告知」:一方面在工具结果里说一句「现在是 plan 模式,别写文件」,另一方面每轮往尾部注入 plan_mode 提醒。system prompt 全程恒定,所以模式怎么横跳,缓存根都不动。

由此总结出系统「权威」的三个来源,缺一不可:

(a) prompt 里的授权约定;(b) 尾部注入的注意力权重;(c) 权限层的代码硬拦。上下文只负责「说」,能不能「做成」由权限负责。


九、那有没有更高明的方式?

写到这我一直在想:这套「借用户的嘴说话」是不是有点土?有没有更好的办法?

答案是:它是这一梯子最下面的一档。 把各种手段按强制力排一下:

最强 ── 结构约束(工具池裁剪 / 权限模式 / sandbox) → 模型想做也做不到
       ── 结构化通道(tool schema / tool_result 字段) → 天然理解,还省 token
       ── 原生 system 消息(若 API 支持中途插入)      → 语义清晰
最弱 ── 自然语言注入(借用户的嘴)                     → 靠自觉 + 打补丁

所以真正高明的方向有两个:一是能给结构的尽量给结构(少「说」),二是等 API 原生支持中途 system 消息(当前只能在开头,这才逼出了土办法)。

还有一个框架内的巧招特别有意思——借工具嘴。系统要告诉模型「目录里有什么」,不是写句话,而是伪造一次 ls 工具调用及其结果。为什么?因为模型对 tool_result 的信任度天然高于 user 文本——工具结果是「客观事实」,用户文本是「可能被说服的」。

业界的其它方向也值得一提:OpenAI 风格的中途 system 消息、ChatML 的 <|system|> 特殊 token、厂商正在推的结构化事件流(context editing)、以及「系统只 push 一个短指针、让模型 pull 详情」的省 token 思路。


结语

回头看,「上下文工程」这个词一点都不玄。它是一套被两个硬约束(API 语义 + 前缀缓存)逼出来的工程,核心就一句话:

借模型必然会读的载体说话,并根据载体在缓存结构里的位置(根部 vs 尾部),决定它能不能变。

理解了这个,你就能看懂 Claude Code 里那些「顶着 user 帽子的系统消息」「有的套信封有的不套的注入」「每轮重打的那句话」——它们不是随手写的,而是同一套原则在不同约束下的具体落点。而最要紧的一条工程直觉是:位置决定策略——该稳定的钉在缓存根部,该易变的甩到缓存尾部。