Skip to content

B4|上下文装配与压缩:模型看见的必须能从 rollout 重建 ​

先别把 messages[] 当成历史 ​

读完 B1–B3,下一件错事是打开 Prompt,把发给模型的那一串 ResponseItem 当成「会话真相」。于是 compact 被理解成截断数组,resume 被理解成把 transcript 贴回去,AGENTS.md 被理解成 system prompt 里的一段字符串。

AGENTS.md 给这个仓库自己定的纪律更狠(仓库根 AGENTS.md:91-100):

  1. 禁止 rewrite 历史,必须增量追加
  2. 少改已经发出去的上下文,避免 cache miss
  3. 每条注入有界,有硬顶
  4. 单条不超过 10K tokens;新条目可能超过 1k 要当 P0 审
  5. 所有注入必须是 ContextualUserFragment 结构体,不许在循环里 += 一段匿名文本

模型看见的是投影。能重建投影的,是 fragment 的 markers、world state 快照、以及磁盘上的 rollout JSONL。这一章把这三层钉死。C1 再写 AGENTS.md 该写什么;这里只问它 怎么进 prompt、怎么被认出来、怎么在压缩后还活着。

四样东西怎么接起来,先看这张图,再进细节:

整张图只有一条规则:新东西永远是"追加一个带标记的 fragment",不是"改写已经存在的字符串"。 World state 和 AGENTS.md 的差量、compact 的换起点、rollout 的落盘方式,都是这条规则在不同层的具体做法。


1. 四十多个具名 fragment:注入必须能被认回 ​

ContextualUserFragment 的契约在 context-fragments/src/fragment.rs:55-99:自己的 role、稳定的 content_kind(<feature>.<name>)、markers、body。render() 把标记和正文拼在一起,不另加分隔符。有标记的 fragment,resume 和 compact 才能用 matches_text 把它从历史里认出来;无标记的默认不匹配任意文本,避免把用户话误判成注入。

core/src/context/mod.rs 下面四十多个模块不是「prompt 模板目录」,是注入类型目录:环境、权限、AGENTS.md、compaction 摘要、guardian 证据、plugin 说明书、turn aborted、token budget……每一种都有独立的 struct。UserInstructions 的 kind 是 agents_md.instructions,标记是 # AGENTS.md instructions … </INSTRUCTIONS>(context/user_instructions.rs:10-34)。换一种注入,先加类型,再让 world state 或 turn 路径调用它。

没有类型和标记会怎样?compact 会把环境和用户话搅在一起;resume 无法丢掉过期的环境块;prompt cache 因为「整段 system 被重写」而全 miss。玩具 agent 的 system_prompt += extra 死在这里。


2. AGENTS.md 进 prompt 的路径:发现 → fragment → 差量 ​

发现规则写在 agents_md.rs:1-16:从 cwd 走到 project root(默认碰到 .git),把沿途 AGENTS.md 拼起来,不越过 root。本地覆盖文件名是 AGENTS.override.md(:41-42)。用户级说明书和项目级之间用 --- project-doc --- 隔开(:44-46)。字节上限默认 32 KiB(config_toml.rs:74,AGENTS_MD_MAX_BYTES)。

未信任项目在加载函数开头就返回:只保留 host 给的 user instructions,不读项目树里的 AGENTS.md(agents_md.rs:61-63)。克隆一个带恶意说明书的仓库,不能靠这一层把政策写进模型。B5 / C1 会把信任边界写完;装配层的职责是服从这个开关。

装进模型时不是 raw 文件。AgentsMdState 把 LoadedAgentsMd 收成 UserInstructions fragment(world_state/agents_md.rs:13-30)。render_diff 才是关键:和上一份 snapshot 相同则 什么都不注入(:56-58);内容变了,才追加一份带 These AGENTS.md instructions replace all previously provided... 的新 fragment(:9-10, 66-70)。说明书从磁盘消失时,注入的是拆除通知,不是静默删掉历史里的旧块——那会变成 rewrite。

C1 讲发现链和该写什么。B4 只要记住:说明书是有标记的用户角色 fragment,按差量进历史,不是每轮把文件全文当 system。


3. World state:模型可见世界的差量基线 ​

Turn 开始时 run_turn 调用 record_context_updates_and_set_reference_context_item(A2 引用过 turn.rs:283)。它比较的不是「整份 prompt」,而是 WorldState 里一节一节的 snapshot。

WorldStateSection(world_state/mod.rs:226-235)要求:稳定 ID、可序列化的 Snapshot、render_diff(previous)。环境、权限、工具列表、collaboration mode、plugins 说明书……各是一节。render_diff 返回 None 表示这一节对模型来说没变,历史里已有的 fragment 继续有效。变了才追加。测试钉死:cwd 变、时区变、网络政策变,会发出带 <environment_context> 的更新;关掉 include_environment_context 则整段省略。

快照会作为 RolloutItem::WorldState 落盘(history/src/lib.rs:122-132)。resume 不是重读磁盘上的 AGENTS.md 再猜当时模型看见了什么,而是重放当时冻住的 section。文件系统后来改了,下一轮 render_diff 会发出替换通知——增量,不是改旧 item。

没有 world state 会怎样?要么每轮把环境全文再灌一次(cache 全 miss),要么环境变了模型还活在上一轮的 cwd 里。差量是为了同时满足「增量追加」和「世界可以变」。


4. Compact 是 Task,也是采样前必须跑完的钩子 ​

压缩不是「数组太长就 drain」。它是一等公民的 SessionTask:TaskKind::Compact,不能被 steer(B1)。手动 /compact 走 CompactTask::run(tasks/compact.rs:19-66):

  • 开了 TokenBudget feature → compact_token_budget::run_manual_compact_task
  • provider 声明 RemoteCompactionSupport::V2 → compact_remote_v2::run_remote_compact_task
  • 否则本地总结:把 SUMMARIZATION_PROMPT(或配置的 compact_prompt)当用户输入,跑 compact::run_compact_task

本地还是远端,是 provider 能力,不是两种产品。Core 不把 compact 焊死在某一种 HTTP。远程 v2 的重试和图片预算见 附录 C;正文只要知道分叉点在 ctx.provider.capabilities().remote_compaction。

自动压缩插在 采样前。run_pre_sampling_compact(session/turn.rs:1231-1258)看 context_window_token_status:auto-compact 预算或可用窗口耗尽,就在 普通采样 step 创建之前 compact。run_turn 开头的待办注释 和失败路径把设计说死了(:179-191):压缩时新用户话还没入历史;失败必须 run_hooks_and_record_inputs,把那句话保住。吞掉用户最后一句,比窗口溢出更糟。

替换策略也不是改旧 item。InitialContextInjection(compact.rs:63-77):

  • 轮前 / 手动:DoNotInject。历史换成摘要,清掉 reference_context_item,下一轮 Regular turn 会全量再注入 initial context。
  • 轮中:BeforeLastUserMessage。模型被训练成「摘要是压缩后历史的最后一项」,所以 initial context 要插在最后一条真正的用户消息上面。

摘要本身是 CompactionSummary fragment(compaction_summary.rs:17-36)。它故意无标记——正文是总结文本——但作为独立 item 写入,并有 CompactedItem 元数据(window id、response id)。replace_compacted_history 保证 live history 和落盘的 CompactedItem 是同一份(compact.rs:80-83)。

没有「压缩是追加替换声明」会怎样?截断数组等于 rewrite,markers 全乱,审批证据和 AGENTS.md 一起消失。compact 必须留下可识别的边界,让下一轮 world state 知道该全量重注还是接着做差量。


5. Token budget 是资格,不是另一套拼字符串的技巧 ​

context_window.rs 把「当前窗口用了多少」和「auto-compact 作用域用了多少」分开(:7-18)。触发压缩的是这些计数碰到配置上限,不是某段 prompt 里写了「请注意 token」。

实验性的 context management 还要过资格门:ChatGPT 账号 + Plus/Pro/ProLite,API Key 即使声称 Pro 也不过(session/token_budget.rs:13-18,A1 已引用测试)。过门之后才 enable(Feature::TokenBudget)。这是产品后端能力,装配层只消费结果。

单条 fragment 的 10K / 1K 纪律(仓库 AGENTS.md)和窗口级 compact 是两层硬顶:一条注入不能无限长,整段历史也不能靠模型自觉缩短。B2 的 tool_search 渐进披露,就是为了少往这两层硬顶上堆。


6. Rollout 是 JSONL;resume 是重放,不是重贴 UI ​

rollout/src/recorder.rs:1:把 session 写成 .jsonl,以便 replay or inspect。一行一个 RolloutItem(history/src/lib.rs:122-137):SessionMeta、ResponseItem、Compacted、TurnContext、WorldState、TokenUsage、EventMsg、InterAgent……内存里的 Vec<Message> 是投影;磁盘上的是事件和快照。

decode_rollout_line 的注释(rollout/src/lib.rs:40-48)提醒:持久化边界有 JSON 精度坑,解码必须在这一层做对,resume 和投影才能用同一套 item。格式问题不许漏进 Session。

InitialHistory(history/src/lib.rs:278-282)是 Session 出生时的三种命运:New、Resumed、Forked。A2 说过 resume_thread_from_rollout 用 Resumed 重建同一 thread id。重建的是 当时模型可见的 item + world state 快照,不是 TUI widget 树。UI 丢了颜色、折叠状态,只要 JSONL 还在,下一枪 prompt 对得上。

Fork 拷一份 item 快照、换新 id(A2)。Compact 在这条 JSONL 里追加 Compacted,不回写覆盖旧行——符合「禁止 rewrite」。反向扫描、sqlite 索引、压缩文件是 recorder 的实现细节;读者只要相信:关掉终端再打开,走的是重放这条日志。

没有 rollout 会怎样?崩溃即失忆;IDE 和 CLI 无法打开同一会话;compact 的边界无法在磁盘上留下,下一次 resume 会把摘要当用户原话。


7. 结语:带走一句话 ​

模型看见的世界必须能从具名 fragment 和 rollout 重建——AGENTS.md、环境、压缩摘要都是增量追加的类型,不是 messages[] 上的字符串手术;compact 替换的是下一段历史的起点,不是改已经发出去的过去。一次 POST 里这三块怎么进 Prompt、失败怎么分流,见 附录 F。