第二十六章:附件和上下文注入到底在组织什么
拆附件系统、上下文注入和宿主控制信号,理解 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模型怎么分层ContextBuilder和AttachmentCollector怎么拆- transcript 里到底记录什么
- UI / SDK / headless 宿主怎么消费同一批上下文事件
相关源码锚点:
src/context.tssrc/utils/attachments.tssrc/utils/messages.tssrc/QueryEngine.tssrc/constants/prompts.tssrc/utils/imageStore.tssrc/components/Messages.tsx
2. 先说结论
我对这套设计的判断是:
Claude Code 不是把所有上下文都塞进一段大 prompt,而是把上下文拆成了“四层”。
context.ts负责会话级、缓存型、相对稳定的静态上下文。attachments.ts负责每一回合现算的动态上下文采集。messages.ts::normalizeAttachmentForAPI()负责把附件投影成模型真正能看到的消息。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 注入
这里有两个很值钱的设计点:
- git status 是快照,不是实时状态。 它明确写着“这是会话开始时的状态,不会在对话过程中自动更新”。
- 这层是按 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:743src/utils/attachments.ts:2947src/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_commandsdate_changeultrathink_effortdeferred_tools_deltaagent_listing_deltamcp_instructions_deltachanged_filesnested_memorydynamic_skillskill_listingplan_modeauto_modetodo_remindersteam_contextagent_pending_messagescritical_system_remindercompaction_remindercontext_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 这套协议至少覆盖了几大类:
| 类别 | 例子 | 作用 |
|---|---|---|
| 文件类 | file、pdf_reference、compact_file_reference、directory、selected_lines_in_ide | 给模型注入代码、文档和局部工作区视图 |
| 记忆 / 技能类 | nested_memory、relevant_memories、skill_discovery、invoked_skills | 把长期知识和项目习惯动态拉进来 |
| 运行时治理类 | plan_mode、auto_mode、todo_reminder、token_usage、budget_usd | 改变模型当前回合的工作约束 |
| 扩展面变化类 | mcp_instructions_delta、deferred_tools_delta、agent_listing_delta | 把可用能力的变化增量同步给模型 |
| 异步 / 协作类 | queued_command、task_status、teammate_mailbox、team_context | 让中途到来的输入和后台任务进入统一总线 |
| 诊断 / hook 类 | diagnostics、async_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 第一类:伪装成“工具调用 + 工具结果”
典型例子:
directoryfile
比如:
directory会被投影成一次假的BashTool(ls ...)调用和结果file会被投影成一次假的FileReadTool调用和结果
这个设计非常聪明,因为它让模型看到的上下文形态,和它平时真正调用工具后看到的形态保持一致。
也就是说,Claude Code 不只是把内容“给模型看”,它还在尽量维持:
模型内部工作记忆的形状一致性。
6.2 第二类:变成 system reminder / meta user message
典型例子:
pdf_referenceselected_lines_in_ideopened_file_in_ideplan_modeauto_modedate_changediagnosticsmcp_instructions_deltaagent_listing_delta
这类附件通常会被包进 wrapMessagesInSystemReminder(...),然后用 createUserMessage({ isMeta: true }) 注入。
这说明 Claude Code 很明确地在区分:
- 正常用户消息
- 给模型的隐藏治理提示
很多文案还会明确写:
- “不要告诉用户”
- “这只是 gentle reminder”
- “除非你觉得它变了,不要重复读”
也就是它不仅注入内容,还注入使用约束。
6.3 第三类:只给 UI / runtime,不给模型
典型例子:
dynamic_skillalready_read_filecommand_permissionsedited_image_filestructured_output- 若干 hook 相关附件
这些附件在 normalizeAttachmentForAPI() 里直接返回 []。
也就是说,它们会存在于 transcript 和宿主处理链里,但不会进模型。
这块特别值钱,因为它说明 Claude Code 已经显式承认:
不是所有上下文事件都应该被模型看到。
6.4 第四类:宿主控制信号
最典型的是:
structured_outputmax_turns_reachedqueued_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:823src/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 很明确地区分了两件事:
- 模型侧需要的是 image block / pasted content
- 宿主侧需要的是一个本地可显示、可点击的图片路径
也就是说,它没有把“模型输入格式”和“宿主资源管理”混成一件事。
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
建议把 Attachment 和 AttachmentMessage 分开:
Attachment是业务 payloadAttachmentEnvelope是 transcript event
这样 session store、UI、SDK 就都能复用。
10.4 IAttachmentProjector
负责把附件投影到不同目标:
ProjectToModelMessages(...)ProjectToUiEvent(...)ProjectToTranscriptEvent(...)ProjectToHostControlSignal(...)
不要把“收集附件”和“投影附件”揉在一起。
10.5 AttachmentVisibility / AttachmentDestination
我很建议 C# 版从一开始就显式建这个枚举,而不是靠 if/else 硬编码:
ModelTranscriptUiRuntimeOnlyHostControl
Claude Code 现在是把这层知识分散在 normalizeAttachmentForAPI()、QueryEngine.ts 和 UI 过滤逻辑里。
你在 C# 版里可以把它抽得更干净。
11. 最重要的一条判断
到这里我对这块的最终判断是:
Claude Code 的 attachment system,本质上不是“附件功能”,而是“回合级上下文事件总线 + 多目标投影层”。
这也是它比很多普通 agent CLI 更成熟的地方:
- 不把所有东西都塞进 system prompt
- 不把所有上下文都直接暴露给模型
- 不把宿主事件、模型事件、transcript 事件混在一起
如果以后你要做 C# 版,这一块非常值得原样继承它的设计精神,但实现上可以再进一步抽象成更明确的:
StaticContext + AttachmentBus + Projector + HostHandlers。