OpenProgram Docs

压缩#

压缩通过把最老的轮次替换成一份 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 流水线:

  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 第九节定义;本侧的下发契约:

  • 现役摘要行携带 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 仍保存原始记录。

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

Last updated · 2026-10-10