上下文压缩#
压缩通过把最老的轮次替换成一份 LLM 写的摘要,让长对话保持在模型上下文窗口之内。 本文是压缩的唯一权威文档:结果如何存储、如何改变模型读到的内容、如何与分支和多轮 压缩组合、由哪些不变量保护。摘要在 DAG 上的视觉呈现(胶囊)由 dag/rendering.md 第九节规定;本文定义渲染所消费的 数据与语义。
一、模型:滚动摘要,永远只有一份现役#
一个会话同一时刻至多有一份现役摘要。再次压缩不会在旧摘要上叠加第二份:
summarizer 接收上一份摘要文本作为输入、吸收它、产出替代品。会话的
extra_meta._last_summary_id 指明现役摘要;extra_meta._last_summary_text
携带其文本供下一次接力。更老的摘要节点作为遗迹留在盘上,面向图打
superseded_summary 标记,此后永不再被查询。
因此下一次 LLM 请求的形状永远是:
[系统提示] [现役摘要] [保留尾巴,原文] [新用户消息]
——一份摘要,绝不叠加,后面跟着它没有吃掉的轮次。
二、数据模型:append-only 的替身#
压缩恰好写一个节点,不改任何东西:
| 字段 | 值 |
|---|---|
id |
summary_<hex> |
role |
llm,name = "context/summary" |
output |
[Previous conversation summary]\n<文本> |
predecessor |
第一个被覆盖节点的 predecessor(拼接点) |
metadata.covers_ids |
它所替代的链节点的有序 id 列表 |
metadata.compaction |
true |
由 append-only 推出的规则:
- 不克隆。 保留尾巴的 id 和 predecessor 原封不动。克隆尾巴会造出第二套 id 空间,逼所有消费方做翻译。
- 不改边。 被覆盖节点原样留在链上;第一个保留节点仍指向最后一个被覆盖节点。 忽略摘要即可随时重建压缩前的视图。
- 不动 head。 压缩是纯插入。HEAD 停在原来的分支尖上;摘要改变的是该分支 渲染出什么,不是哪条分支处于活动状态。
- 用 id,不用 seq 区间。
covers_ids是被总结内容的记录。DAG 里 seq 区间 表达不了这件事——姐妹分支的 seq 交错,区间扫描会把死分叉拖进覆盖范围,而且 HEAD 一动答案就变。区间形式(metadata.covers = [first_seq, last_seq])在 本设计中不存在,无人读写。 covers_ids是真实轮次组成的连续链段。 它永不包含另一个摘要节点。再压缩 吃掉"上一份摘要加 k 轮"时,新节点的covers_ids是旧链段延长这 k 轮的 id—— 覆盖永远以底层对话表达,旧摘要经_last_summary_id退役,而非嵌套。
三、渲染规则:链段替换#
render_context(context/nodes.py)是决定模型读什么的唯一场所,聊天与
runtime.exec 同路。压缩以一条规则进入它:
设 S 为会话的现役摘要,L =
covers_ids(S),是某条对话链的连续链段。从 head H 渲染时:若 L 的每个节点都在 H 的 predecessor 主链上,则从渲染中剔除 L,并在 L 的位置接纳 S(S 自己的拼接点——它的predecessor——正好把它放在链段开始 处)。否则按原文渲染主链。
这条规则买到的性质,每一条都是需求而非副作用:
- 摘要真的进入 prompt。 S 靠规则被接纳,而不是指望主链碰巧走到一个无人指向
的节点。压缩过的分支渲染出的 id 列表就是
[ROOT, S, 保留尾巴…]。 - 分支隔离自动成立。 主链不完整包含覆盖链段的分叉——从覆盖范围内部重试出来 的、同时代的死姐妹——通不过 ⊆ 检验,按原文渲染。它的上下文从未被压缩过,也 不该继承一份总结了它没有的轮次的摘要。
- 存储与 HEAD 无关。 任何时候 checkout 任何分支,渲染结果只由数据决定。 没有任何渲染结果取决于别的东西运行时 HEAD 恰好在哪。
- 被取代的摘要在此不可见。 只查询现役摘要;遗迹永不剔除任何东西。
同一条规则、同一份 covers_ids,驱动 DAG 的胶囊折叠——图在上下文携带摘要的
分支上显示折叠胶囊,在按原文渲染的分支上显示原始轮次。一件事实,两个投影。
四、压缩流水线#
trigger_compaction(手动 /compact)、自动压缩(轮前预算 ≥ 80%)和被动压缩
(provider 溢出报错)都跑同一条 engine.compact 流水线:
- 输入是渲染视图,不是原始链。 交给切点计算的 history 就是模型当前读到的 内容:先现役摘要(如有),再保留轮次。在这里喂原始 predecessor 走链,会把 上一份摘要已经吃掉的轮次再总结一遍,产出覆盖完全重复的第二份摘要。
- 切分。
find_cut_index挑选切点使保留尾巴装进keep_recent_tokens(默认由预算策略给出),向前对齐到用户轮边界;渲染视图的开头元素——如有 上一份摘要——必然落在被覆盖一侧。 - 总结。 summarizer 从被覆盖切片写出新摘要,接力
previous_summary, 已总结过的内容不丢失。 - 持久化。 一个节点,按第二节的形状。新
covers_ids= 旧链段(若覆盖了 一份摘要)延长新被覆盖轮次的 id。_last_summary_id/_last_summary_text移到新节点。 - 事件。
compaction_started/compaction_finished(或compaction_failed)经会话通道广播;finished 事件携带summary_id、数量 与 token 增量。历史不足 4 条时短路,给用户可见的local_command提示。
五、HEAD 完整性#
压缩曾是能以副作用挪动 HEAD 的若干写者之一。本设计只允许一个移动者:
- 单一写者。
SessionStore.set_head是 HEAD 改变的唯一方式,且只被显式的 面向用户的移动调用:发消息推进、重试/编辑分叉、checkout、rewind、删分支。 压缩、会话加载、worker 重启、切换模型、meta 保存永不调用它。 - append 只在链延长时推进 HEAD。
append_message仅当新节点的predecessor等于当前 HEAD 时才把 HEAD 移上去——自然的"对话长了"情形。 其余插入(摘要拼接、旁支写入、遗迹)不动 HEAD。这取代旧的无条件自动推进 加各调用方快照/恢复的补偿。 - spawn 出的轮次永不移动 HEAD。 同会话的子 agent 轮(task /
send_message)以
TurnRequest.advance_head=False运行:spawn 分支打开 时不把自己注册为 head,内层 dispatcher 的每次写入(分支根、占位行、回复、 finalize、错误路径)都不碰 head。转录窗口跟着 HEAD 走——head 被偷走时用户 的窗口会在运行中切到 agent 的对话、两边消息混在一起。跨会话投递仍推进目标 会话自己的 head——在那边这一轮就是对话本身在生长。 - 一轮的 head 策略收敛为一个对象。
dispatcher/turn_writer.py的TurnWriter执行一轮的全部链上写入,并独家应用advance_head。这个不变量 是结构性的:dispatcher 包内set_head/update_session(head_id=…)只出现 在该文件(外加forced_tool.py的手动函数运行路径——定义上就是用户主动的 移动)。 - 镜像只读,转录单源。 webui 的内存
conv镜像只承载侧栏元数据和load_session时的一次性messages快照;此后没有任何增量写。活的转录 只有 React session store 一处——流式增量、轮次结果(upsert 到<user_msg_id>_reply行)、执行树水合全部写它。镜像永不回写存储:save_meta不携带head_id,镜像的任何行都不可能变成存储的 head 或 新的存储行。存储永远在镜像上游,重启前后皆然。
六、图上显示什么#
由 dag/rendering.md 第九节定义;本侧的下发契约:
- 现役摘要行携带
covers_ids——照抄metadata.covers_ids,加上被覆盖轮次的 caller 子树(被覆盖的轮连同它的工具调用一起折叠),去掉已不存在的 id。 - 被取代的摘要行携带
superseded_summary: true,不携带covers_ids。 - graph builder 不做 seq 运算、不做依赖 head 的过滤;它关于覆盖说的每句话都是
covers_ids的复述。
七、扩展点#
滚动单摘要策略与参照工具一致(Claude Code、Codex CLI、Gemini CLI),并保持 prompt 缓存前缀稳定。它是策略,不是存储的属性:每一种替代压缩方案的差异只在 哪些摘要算现役(一个策略字段)和渲染器如何替换(第三节那条规则)。 append-only 替身节点对所有方案通用,换方案永不迁移数据:
- 分段摘要(保留多个压缩节点共存):N 个摘要节点覆盖互不相交的链段;
第三节逐摘要应用,主干串起 N 个胶囊。把
_last_summary_id换成现役集合。 - 嵌套摘要(摘要的摘要):放宽"
covers_ids只点名真实轮次",允许它含 摘要 id,替换递归进行。 - 外置记忆方案(摘要按需检索而非内联):节点照原样存储;只有渲染器不再 内联它。
这些方案之下还有一层契约,即便上下文完全任意化——由检索、跨分支选取或任何 未来策略拼装而非主链走链——它也成立:
- DAG 是账本,不是上下文。 节点记录发生过什么,append-only;上下文是
其上的一个确定性视图函数。改变上下文的构建方式改的是视图函数,永不改
数据。渲染器今天就已偏离纯链(
render_range、expose、attach/merge、 memory prefetch)——每处偏离都是数据,不是隐藏状态。 - provenance 是硬性要求。 无论视图函数产出什么,实际进入某次调用 prompt
的内容的节点 id 都盖在该调用上(
reads)。重放、审计和图的逐节点上下文 标记依赖这份记录——不依赖视图函数保持简单。
摘要节点是视图节点的第一个实例——在渲染中替其他内容出场的节点。检索出的 记忆片段、注入的文档、跨会话引用都是它的推广;胶囊的视觉语法(原位替身、 展开见原文)就是这一类节点的通用画法。在出现具体的第二种方案之前,不预先 搭建通用的视图组合框架。
八、不变量及其测试#
| 不变量 | 落实/测试位置 |
|---|---|
压缩后渲染活动分支得到 [ROOT, S, 保留尾巴…]——被覆盖 id 缺席、S 在场 |
render_context 测试;场景套件 |
| 压缩永不移动 HEAD | persister 测试;场景套件 |
| 不完整包含覆盖链段的分支按原文渲染 | render_context 分支隔离测试 |
再压缩的输入不含已覆盖的原文轮;新 covers_ids 延长旧链段 |
压缩流水线测试 |
下发时每会话至多一行携带 covers_ids;旧摘要带 superseded_summary |
test_graph_builder_covers.py |
covers_ids 永不点名被总结链之外的节点(死分叉在外) |
test_graph_builder_covers.py |
| HEAD 挺过:worker 重启、会话加载、切模型、meta 保存 | 场景套件(test_dag_mutation_scenarios.py) |
| 存储往返:任何镜像行或镜像 head 都不会写回存储 | webui persistence 测试 |
场景套件在真实 SessionStore 上端到端跑这些流程(聊天 → 分叉 → checkout →
压缩 → 聊天 → 压缩 → 聊天 → 重启加载),每一步校验 head 与渲染——这里涉及的
跨模块副作用 bug,在部件的单元测试里不会现身。
实现状态#
以上各节均已实现:
- 第二节节点形状与第四节流水线——
context/persistence.py(insert_summary_node、covered_chain_ids、rendered_history);coversseq 区间已不存在于任何地方。 - 第三节链段替换——
context/nodes.py的render_context(active_summary、summary_covers_ids)。 - 第五节 HEAD 完整性——
store/session/session_store.py的链延长 append 规则;webui/persistence.py的save_meta无条件剥离head_id,save_messages已删除;CLI 轮次路径经db.append_message写行。 - 第六节图契约——
webui/graph_builder.py。 - 第八节不变量——
tests/unit/test_compaction_covers.py、tests/unit/test_graph_builder_covers.py、tests/integration/test_dag_mutation_scenarios.py。