Book 第二十六章:附件和上下文注入到底在组织什么
第五部分:会话续航与治理

第二十六章:附件和上下文注入到底在组织什么

拆附件系统、上下文注入和宿主控制信号,理解 Claude Code 怎么组织非纯文本上下文。

1. 为什么这一块必须单独拆

如果你只从 query.ts 那边看 Claude Code,很容易以为它的上下文注入就是:

  • 拼 system prompt
  • 拼用户消息
  • 然后把文件或提示词塞进去

但源码里真正存在的是另一套更像 runtime 基础设施的东西:

Claude Code 把“动态上下文”设计成了一条 typed attachment bus。

文件、图片、PDF、memory、skill discovery、MCP instructions、诊断信息、队友消息、排队中的用户输入,最后都不是直接散落进 prompt,而是先变成统一的 Attachment,再按不同目标投影到:

  • 模型输入
  • transcript
  • 宿主 UI
  • 运行时控制分支

这块对你以后做 C# 版非常重要,因为它会直接影响:

  • Message 模型怎么分层
  • ContextBuilderAttachmentCollector 怎么拆
  • transcript 里到底记录什么
  • UI / SDK / headless 宿主怎么消费同一批上下文事件

相关源码锚点:

  • src/context.ts
  • src/utils/attachments.ts
  • src/utils/messages.ts
  • src/QueryEngine.ts
  • src/constants/prompts.ts
  • src/utils/imageStore.ts
  • src/components/Messages.tsx

2. 先说结论

我对这套设计的判断是:

Claude Code 不是把所有上下文都塞进一段大 prompt,而是把上下文拆成了“四层”。

  1. context.ts 负责会话级、缓存型、相对稳定的静态上下文。
  2. attachments.ts 负责每一回合现算的动态上下文采集。
  3. messages.ts::normalizeAttachmentForAPI() 负责把附件投影成模型真正能看到的消息。
  4. QueryEngine.ts + UI 负责把附件当成 transcript 事件、宿主信号和特殊控制消息来处理。

换句话说:

context.ts 负责“每个 session 开头就该带着的前缀”,attachments.ts 负责“每一轮新冒出来的上下文事件”。

3. 总体结构图

flowchart TD
    A["静态上下文<br/>context.ts"] --> B["query.ts / QueryEngine.ts"]
    C["当前输入 / IDE 选择 / 队列命令 / 工具上下文"] --> D["getAttachments()"]
    D --> E["Attachment[]"]
    E --> F["createAttachmentMessage()"]
    F --> G["AttachmentMessage 写入 mutableMessages / transcript"]

    E --> H["normalizeAttachmentForAPI()"]
    H --> I["模型可见消息"]

    G --> J["宿主特殊处理"]
    J --> K["structured_output 提取"]
    J --> L["max_turns_reached -> 终止结果"]
    J --> M["queued_command -> 用户消息回放"]

    C --> N["图片粘贴内容"]
    N --> O["imageStore.ts"]
    O --> P["按 session 落盘的本地图片缓存"]

    Q["prompts.ts"] --> H
    Q --> A

这张图里最关键的一点是:

Attachment 不是 UI 附件,而是 Claude Code 的回合级上下文事件协议。

4. 会话级静态上下文,不等于附件系统

源码锚点:

  • src/context.ts

context.ts 里主要有两个入口:

4.1 getSystemContext()

这一层是会话级缓存上下文,主要放:

  • 对话开始时的 git status 快照
  • 当前 branch / main branch / recent commits / git user
  • 可选的 cache breaker 注入

这里有两个很值钱的设计点:

  1. git status 是快照,不是实时状态。 它明确写着“这是会话开始时的状态,不会在对话过程中自动更新”。
  2. 这层是按 conversation 缓存的。 不是每轮重新跑 git 命令。

4.2 getUserContext()

这一层也是会话级缓存上下文,主要放:

  • CLAUDE.md / memory files 的合并结果
  • 当前日期

它还会顺手把 CLAUDE.md 缓存到 bootstrap/state,给 auto-mode classifier 之类的旁路逻辑复用。

4.3 这一层的本质

这一层本质上是:

conversation prefix builder。

它适合放:

  • 相对稳定
  • 缓存价值高
  • 不需要按回合增量变化

的上下文。

而附件系统处理的是另一类东西:

  • 本轮刚刚出现的
  • 可能只对当前 turn 有意义
  • 需要按宿主差异化投影

的上下文事件。

5. getAttachments() 才是动态上下文总线

源码锚点:

  • src/utils/attachments.ts:743
  • src/utils/attachments.ts:2947
  • src/utils/attachments.ts:3180

getAttachments() 是这套系统真正的核心入口。

5.1 它先做的不是“尽量多塞信息”,而是控制时延和边界

这里有几个很容易被忽略、但非常值钱的细节:

5.1.1 simple / disable 模式也不直接返回空

如果 attachments 被整体关闭,Claude Code 依然会返回 queued_command 附件。

原因很工程化:

query.ts 后面会无条件把 queue 清掉,如果这里直接空返回,中途排队的人类输入或 task notification 就会被静默吞掉。

也就是说,Claude Code 在这里优先保证:

控制信号不能丢。

5.1.2 附件采集有 1 秒 abort budget

getAttachments() 内部会创建 AbortController,并在 1000ms 后 abort。

这说明它把附件采集视为:

  • 必须存在
  • 但不能无限拖慢 turn 提交

的辅助流水线,而不是“为了多拿一点上下文可以无限阻塞主流程”。

5.1.3 用户输入附件会先算

它会先跑:

  • @mentioned files
  • MCP resource 引用
  • agent mentions
  • turn 0 skill discovery

然后才继续后面的 nested_memory 等逻辑。

源码里的注释直接说明了原因:

前面的用户输入附件会先把 nested memory 的触发器填好,后面的 memory 注入才能算得对。

这说明 attachment pipeline 不是一堆独立 helper,而是有依赖顺序的。

5.2 它明确区分“所有线程都安全”和“只有主线程能做”

Claude Code 把附件采集分成两组:

5.2.1 allThreadAttachments

这类附件对子 agent 也安全,包括:

  • queued_commands
  • date_change
  • ultrathink_effort
  • deferred_tools_delta
  • agent_listing_delta
  • mcp_instructions_delta
  • changed_files
  • nested_memory
  • dynamic_skill
  • skill_listing
  • plan_mode
  • auto_mode
  • todo_reminders
  • team_context
  • agent_pending_messages
  • critical_system_reminder
  • compaction_reminder
  • context_efficiency

5.2.2 mainThreadAttachments

这类附件要么语义上只属于主会话,要么实现上并不 concurrency-safe,比如:

  • IDE selection / opened file
  • output style
  • diagnostics / LSP diagnostics
  • unified tasks
  • async hook responses
  • token / budget usage

这块非常值钱,因为它说明 Claude Code 在做的不是“收集一些提示词”,而是在做:

线程感知的上下文分发。

5.3 Attachment 的覆盖面非常广

Attachment 联合类型和各个生成函数看,Claude Code 这套协议至少覆盖了几大类:

类别例子作用
文件类filepdf_referencecompact_file_referencedirectoryselected_lines_in_ide给模型注入代码、文档和局部工作区视图
记忆 / 技能类nested_memoryrelevant_memoriesskill_discoveryinvoked_skills把长期知识和项目习惯动态拉进来
运行时治理类plan_modeauto_modetodo_remindertoken_usagebudget_usd改变模型当前回合的工作约束
扩展面变化类mcp_instructions_deltadeferred_tools_deltaagent_listing_delta把可用能力的变化增量同步给模型
异步 / 协作类queued_commandtask_statusteammate_mailboxteam_context让中途到来的输入和后台任务进入统一总线
诊断 / hook 类diagnosticsasync_hook_response、各类 hook attachment把工具外的反馈回路接进模型上下文

这已经不是“附件系统”,而是一条很完整的 runtime event bus。

5.4 它最后会统一包成 AttachmentMessage

attachments.ts 最后不是直接生成 API message,而是先生成:

  • Attachment
  • 再包成 AttachmentMessage

createAttachmentMessage() 只做三件事:

  • attachment
  • uuid
  • timestamp

这说明 Claude Code 把附件当成:

会进入 transcript 的一等事件类型。

不是临时 helper,不是拼 prompt 过程里的匿名中间值。

6. 真正关键的一刀:附件不会统一进模型,而是按目标投影

源码锚点:

  • src/utils/messages.ts:3453

normalizeAttachmentForAPI() 是这套设计里最值钱的一个函数。

因为它回答了一个核心问题:

“附件被收集出来之后,Claude Code 到底怎么决定哪些给模型看、用什么形态给?”

答案是:

不是所有附件都变成同一种消息。

6.1 第一类:伪装成“工具调用 + 工具结果”

典型例子:

  • directory
  • file

比如:

  • directory 会被投影成一次假的 BashTool(ls ...) 调用和结果
  • file 会被投影成一次假的 FileReadTool 调用和结果

这个设计非常聪明,因为它让模型看到的上下文形态,和它平时真正调用工具后看到的形态保持一致。

也就是说,Claude Code 不只是把内容“给模型看”,它还在尽量维持:

模型内部工作记忆的形状一致性。

6.2 第二类:变成 system reminder / meta user message

典型例子:

  • pdf_reference
  • selected_lines_in_ide
  • opened_file_in_ide
  • plan_mode
  • auto_mode
  • date_change
  • diagnostics
  • mcp_instructions_delta
  • agent_listing_delta

这类附件通常会被包进 wrapMessagesInSystemReminder(...),然后用 createUserMessage({ isMeta: true }) 注入。

这说明 Claude Code 很明确地在区分:

  • 正常用户消息
  • 给模型的隐藏治理提示

很多文案还会明确写:

  • “不要告诉用户”
  • “这只是 gentle reminder”
  • “除非你觉得它变了,不要重复读”

也就是它不仅注入内容,还注入使用约束。

6.3 第三类:只给 UI / runtime,不给模型

典型例子:

  • dynamic_skill
  • already_read_file
  • command_permissions
  • edited_image_file
  • structured_output
  • 若干 hook 相关附件

这些附件在 normalizeAttachmentForAPI() 里直接返回 []

也就是说,它们会存在于 transcript 和宿主处理链里,但不会进模型。

这块特别值钱,因为它说明 Claude Code 已经显式承认:

不是所有上下文事件都应该被模型看到。

6.4 第四类:宿主控制信号

最典型的是:

  • structured_output
  • max_turns_reached
  • queued_command

这些附件并不只是“给模型的提示”,而是会改变宿主自己的控制流。

后面 QueryEngine.ts 会单独消费它们。

7. Claude Code 在这里做了哪些“额外处理”

如果你关心“它不是简单拼消息,而是额外做了什么”,这一块非常值得记住。

7.1 它会把大型 PDF 降级成轻量引用

tryGetPDFReference() 会根据 PDF 页数或体积估算,决定是不是不要直接内联。

一旦太大,就只给模型一个 reference,并明确要求:

  • 必须带 pages 参数读
  • 每次最多 20 页
  • 先读前几页了解结构

这说明 Claude Code 对文件附件不是“读了再说”,而是会先做格式感知和预算治理。

7.2 它会对大文件做截断,但给模型留补读路径

文件太大时,Claude Code 会:

  • 先读截断内容
  • 再额外告诉模型:这是截断后的结果
  • 如果需要更多,继续用 FileReadTool

这比简单截断更好,因为模型知道:

  • 它现在拿到的是局部视图
  • 还有继续获取完整视图的正式路径

7.3 它会把“中途来的用户输入”保留下来

queued_command 这类附件特别有意思。

它不是普通附件,而是:

  • 中途 drain 进来的真实用户输入
  • task notification
  • 其他队列化控制消息

Claude Code 不但不丢它,还会:

  • 在 transcript 里按 origin / isMeta 判断可见性
  • 在 SDK / headless 模式下把它回放成真正的 user message
  • 在 UI 里只保留真正的人类输入,不把系统通知误渲染成人类说的话

这个设计解决的是长回合里最容易出事故的一类问题:

用户在 agent 干活过程中插进来的消息,不能既丢失,又不能跟系统通知混淆。

7.4 它会为了 prompt cache 稳定性专门处理 memory 文本

relevant_memories 在附件创建时就把 header 预先算好,后面投影时优先复用这个 header,而不是临时重算。

理由很直接:

要让跨 turn 渲染出来的字节尽可能稳定,别把 prompt cache 打碎。

这点很像 Claude Code 一贯的风格:

不是只考虑“功能能不能跑”,而是考虑“同样的功能怎么更适合长期会话”。

7.5 它会把 MCP 指令从 system prompt 里拆出来做增量同步

prompts.ts 里写得很明确:

mcp_instructions_delta 启用时,MCP server 提供的使用说明不再每轮重算进 system prompt,而是通过持久化 attachment 增量注入。

这样做的价值很大:

  • MCP server 中途连上,不必重算整段 system prompt
  • 减少 prompt cache 失效
  • 指令变化能被 transcript 记录下来

这说明 attachment 不只是“内容载体”,还是:

动态能力变化的同步协议。

7.6 它会给 subagent 单独补 skill discovery 的 framing

prompts.ts 里还有一个非常值得学的细节:

主线程会走完整的 getSystemPrompt(),subagent 不一定会。

但 subagent 也会收到 skill_discovery 附件,所以 Claude Code 专门在 enhanceSystemPromptWithEnvDetails() 里给 subagent 补了一层解释,告诉它“这些是与当前任务相关的 skills,必要时可以再调 DiscoverSkills”。

这说明作者很清楚:

同一个附件,在不同宿主 / 不同执行主体里需要不同的 framing。

8. 宿主侧不是被动显示,而是主动消费附件

源码锚点:

  • src/QueryEngine.ts:823
  • src/components/Messages.tsx:147

QueryEngine.ts 在收到 attachment message 时,会先把它写进 mutableMessages,必要时还会立刻 recordTranscript()

这里的注释很关键:

它强调附件和 progress 一样,要 inline 记录,避免下一次 dedup / parent 链推导时把会话链搞叉。

也就是说,attachment 对 resume 来说不是边角料,而是正式历史的一部分。

除此之外,宿主还会对几类附件做特殊处理:

8.1 structured_output

不是给模型看,而是从 attachment 里抽出来,变成本次 headless 结果的结构化输出。

8.2 max_turns_reached

不是一段提示,而是直接转成终止结果 error_max_turns

8.3 queued_command

在需要 replay 用户消息的模式里,它会被重新投影成 user message。

8.4 UI 过滤逻辑

Messages.tsx 会特判:

  • 只把 queued_command 里真正的 prompt 型、人类输入型内容当作“用户说的话”显示出来
  • 把系统生成的 task notification 排除掉

所以 Claude Code 的 attachment 不只是模型侧协议,也是:

宿主 UI 的消息投影层。

9. 图片并不是直接挂在消息里,而是按 session 落到本地

源码锚点:

  • src/utils/imageStore.ts

图片处理这块也很能体现它的设计风格。

imageStore.ts 会:

  • 按当前 sessionId 建目录
  • 存到 image-cache/<sessionId>/<imageId>.<ext>
  • 只保留有限个路径的内存缓存
  • 在新 session 启动时清理旧 session 的图片缓存

这说明 Claude Code 很明确地区分了两件事:

  1. 模型侧需要的是 image block / pasted content
  2. 宿主侧需要的是一个本地可显示、可点击的图片路径

也就是说,它没有把“模型输入格式”和“宿主资源管理”混成一件事。

10. 这一层对 C# 版最值钱的抽象

如果要把这块迁到 C#,我建议不要做一个大而全的 ContextBuilder,而是拆成下面几层:

10.1 IStaticSessionContextProvider

负责:

  • git snapshot
  • CLAUDE.md / memory
  • current date

特点是按 session 缓存。

10.2 ITurnAttachmentCollector

负责:

  • 采集本轮动态附件
  • 区分 main thread / subagent
  • 控制超时和 abort

10.3 AttachmentEnvelope

建议把 AttachmentAttachmentMessage 分开:

  • Attachment 是业务 payload
  • AttachmentEnvelope 是 transcript event

这样 session store、UI、SDK 就都能复用。

10.4 IAttachmentProjector

负责把附件投影到不同目标:

  • ProjectToModelMessages(...)
  • ProjectToUiEvent(...)
  • ProjectToTranscriptEvent(...)
  • ProjectToHostControlSignal(...)

不要把“收集附件”和“投影附件”揉在一起。

10.5 AttachmentVisibility / AttachmentDestination

我很建议 C# 版从一开始就显式建这个枚举,而不是靠 if/else 硬编码:

  • Model
  • Transcript
  • Ui
  • RuntimeOnly
  • HostControl

Claude Code 现在是把这层知识分散在 normalizeAttachmentForAPI()QueryEngine.ts 和 UI 过滤逻辑里。 你在 C# 版里可以把它抽得更干净。

11. 最重要的一条判断

到这里我对这块的最终判断是:

Claude Code 的 attachment system,本质上不是“附件功能”,而是“回合级上下文事件总线 + 多目标投影层”。

这也是它比很多普通 agent CLI 更成熟的地方:

  • 不把所有东西都塞进 system prompt
  • 不把所有上下文都直接暴露给模型
  • 不把宿主事件、模型事件、transcript 事件混在一起

如果以后你要做 C# 版,这一块非常值得原样继承它的设计精神,但实现上可以再进一步抽象成更明确的:

StaticContext + AttachmentBus + Projector + HostHandlers