<div id="上下文压缩"></div>

# 压缩

压缩通过把最老的轮次替换成一份 LLM 写的摘要，让长对话保持在模型上下文窗口之内。
本文是压缩的唯一权威文档：结果如何存储、如何改变模型读到的内容、如何与分支和多轮
压缩组合、由哪些不变量保护。摘要在 DAG 上的视觉呈现（胶囊）由
[dag/rendering.md 第九节](../runtime/dag/rendering.zh.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` 流水线：

1. **固定输入视图。** 使用 `render_context` 与 `render_dag_messages`，遵循工具可见性、
   spill 引用和 aging 规则。顶层消息定义覆盖范围，其可见子调用同时进入摘要输入与 token 预算。
2. **按完整轮次选范围。** 默认保留预算为模型窗口的 10%，限制在 8,000–40,000 tokens；
   显式传入的保留预算优先。默认优先在用户消息边界保留至少四条对话消息。总量低于预算时不调用摘要模型。
   如果最近两轮超过目标预算，切点可以前进到最新用户消息：已完成的前一轮进入摘要，最新用户轮次原样保留。
   如果最新轮次本身超过预算，仍原样保留。
3. **总结整个覆盖前缀。** 初始用户要求、当前分支适用的旧摘要与可见工具结果都进入输入。
   不注入其他分支的会话级摘要缓存。非文本媒体用原始记录提示表示，不要求摘要模型解释其内容。
4. **写入前验证。** 空摘要、失败、取消、超长摘要或没有缩小上下文时不写入。
   在内存图中渲染候选摘要，固定相同 aging 边界，要求本地 token 估算严格降低。
   异步生成后核对原图与 HEAD；内容变化则保留原记录，允许重新执行。
   提供方失败时保留原始上下文，不以结构性丢弃提示替代。
5. **保存与报告。** 只写一个摘要节点；`covers_ids` 将旧摘要展开为真实消息 ID。
   显示的覆盖数量使用同一 ID 集合。前后本地估算与上下文面板共用 provider 消息渲染。
   无操作不写节点、不增加压缩次数；原始记录和 HEAD 保持不变。

上下文显示在请求结束后采用提供方上报的输入用量。每次请求同时记录同一份实际发送消息的本地估算；
图变化后，用这对实测值和估算值的比率校准新的本地估算。下次请求上报用量前，校准后的数字仍标为估算。
旧记录如果没有同一请求的估算值，不用于校准后续图状态。

## 五、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 第九节](../runtime/dag/rendering.zh.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，替换递归进行。
- **外置记忆方案**（摘要按需检索而非内联）：节点照原样存储；只有渲染器不再
  内联它。

这些方案之下还有一层契约，即便上下文完全任意化——由检索、跨分支选取或任何
未来策略拼装而非主链走链——它也成立：

1. **DAG 是账本，不是上下文。** 节点记录发生过什么，append-only；上下文是
   其上的一个确定性*视图函数*。改变上下文的构建方式改的是视图函数，永不改
   数据。渲染器今天就已偏离纯链（`render_range`、`expose`、attach/merge、
   memory prefetch）——每处偏离都是数据，不是隐藏状态。
2. **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`）；
  `covers` seq 区间已不存在于任何地方。
- 第三节链段替换——`context/nodes.py` 的 `render_context`
  （`active_summary`、`summary_covers_ids`）。
- 第五节 HEAD 完整性——`store/session/session_store.py` 的链延长 append 规则；
  `apps/server/openprogram_server/_webui/persistence.py` 的 `save_meta` 无条件剥离 `head_id`，`save_messages`
  已删除；CLI 轮次路径经 `db.append_message` 写行。
- 第六节图契约——`apps/server/openprogram_server/_webui/graph_builder.py`。
- 第八节不变量——`tests/unit/context/test_compaction_covers.py`、
  `tests/unit/dag/test_graph_builder_covers.py`、
  `tests/integration/dag/test_dag_mutation_scenarios.py`。

## 同一条用户消息内压缩

未指定输出上限时，请求使用模型输出能力、默认输出预留量（16,384 token）及上下文窗口四分之一的最小值；输入计量和 provider 发送使用同一上限。显式上限保持不变，保护内容超预算时拒绝发送，不静默缩减请求的输出量。

每次 AgentLoop 请求前都会检查转换后的消息、实际工具、系统提示、输出上限与安全余量。聊天工具续调和函数内部的 `llm()` / `agent()` 都使用此入口。需要时摘要已完整返回的工具组中的纯文本结果，不必等待下一条用户消息；用户消息、调用参数、结果 ID、错误状态和多模态内容保持不变。

处理仅作用于当前请求，原始执行消息和 DAG 保留，不创建持久化的会话摘要节点。缓存限于一次调用，按原始结果内容和模型配置匹配。摘要请求单独计量输入与输出，每次构建最多 32 次摘要调用、总计 60 秒，不递归进入 AgentLoop。token 为本地估算，不保证与 provider 计量一致或语义完全等价；必要内容仍无法容纳时，不发送已知超预算请求。

## 请求内检查点

工具结果摘要仍不足以满足预算时，下一次请求可以用任务摘要替代较早的完整纯文本执行历史。
所有用户消息和最近的助手执行片段保持原样。未完成的工具调用组、多模态输入和带签名的推理
不参与这类请求检查点。检查点只在本次调用中复用，且模型和原始消息前缀必须一致；DAG 仍保存原始记录。

摘要请求按预算分段处理长文本，遇到上下文超限时缩小分段重试。请求次数和时间均有限制；
失败时保留原始历史。受保护内容本身超过窗口时，请求明确失败，不会静默删除用户指令。
