不是把聊天“总结一下”:Open Nua 如何压缩长任务上下文
Open Nua Engineering
一个桌面 Agent 工作得越久,越容易撞上一个看似简单的问题:模型的上下文窗口是有限的。
最直接的办法,是在消息太多时生成一段摘要,再用摘要替换旧对话。但对会写代码、读取文件、调用工具、等待确认并从 checkpoint 恢复的 Agent 来说,“总结一下”远远不够。
一次不可靠的压缩,可能把尚未完成的任务写成已经完成,切断 tool call 与 tool result 的配对,丢掉最近读取的文件和活动计划,甚至把只能在原 provider 请求链中使用的加密 reasoning state 带进下一次调用。
Open Nua Desktop 最终把上下文压缩做成了一条独立的运行时管线。它不删除会话事实,而是为下一次模型调用构造一个更小的有效上下文投影:旧内容被有结构地概括,近期消息保留原文,被压缩的原文片段另行保存,压缩边界和摘要写入 checkpoint,工具协议、恢复状态与可观测事件则继续保持完整。
先分清三种“历史”
上下文压缩最重要的设计决定,不是摘要提示词,而是先把三种历史拆开:
- 会话事实:用户消息、模型回答、tool call、tool result、HITL 与 checkpoint 中的原始状态;
- 模型投影:下一次请求真正发送给模型的消息列表;
- 用户视图:Desktop 会话详情里展示的对话、压缩提示和运行状态。
压缩只重写第二层。原始 messages 可以继续增长,运行时通过持久化的 compaction event 重建有效消息:
raw state
= 旧消息前缀 + 最近原始消息
effective context
= 压缩摘要 + 最近原始消息
实现中还有一层专门的压缩状态,用来把摘要与原始消息边界重新拼接起来。摘要因此不是新的事实源,而是一个可替换的读取投影。
被压缩的旧消息会追加到当前工作区的 conversation history 文件;checkpoint 则保存摘要、边界、策略以及压缩前后的 token 估计。重新打开同一线程时,运行时不必重新总结,也不会把已经压缩的前缀再次完整发送给模型。
压缩不是一个动作,而是一条从轻到重的级联
Open Nua 不会一接近上下文上限就立即调用模型做摘要。每次模型请求前,运行时会依次尝试四级处理:
- Collapse:合并连续的读取与搜索轨迹;
- Truncate:截短旧消息中过大的 tool call 参数;
- Microcompact:清理久置的旧 tool result;
- LLM compaction:前三步之后仍超过阈值,才执行部分或完整摘要。
前三层不需要额外模型调用。它们处理的是已经失去逐字价值、但仍在重复占用窗口的结构性负载。
Collapse 识别连续的 read_file、grep、glob 与 web_search 调用,把冗长轨迹折叠成保留路径、行号和结果状态的紧凑记录。它只处理已经完成的旧探索,并保留组内最后一次调用与结果。
Truncate 处理另一种常见膨胀:工具参数本身可能携带大段待写入文本。截短已经执行过的旧参数,比重写整段会话便宜,也不会改变工具执行事实。
Microcompact 针对长时间暂停后恢复的线程。当前 Desktop 使用 6 小时时间阈值;触发后,旧的可压缩 tool result 可以被替换为明确的 cleared marker,同时保留最近 5 个结果。这里清除的是下一次模型请求中的重复负载,不是底层 transcript。
只有这些轻量步骤之后仍超过阈值,运行时才启动 LLM 摘要。模型 profile 提供 max_input_tokens 时,当前 Desktop 在窗口的 75% 触发,并保留约 10% 的近期上下文;没有可靠 profile 时退回 100,000 tokens 触发、保留最近 6 条消息。
为什么优先做部分压缩
完整压缩会把边界之前的全部内容变成一条摘要。它简单,但摘要承担的语义负担很重。
当前实现默认优先使用 partial compaction:概括较早的前缀,同时把最近消息原样留在后面。这样做有三个直接收益:
- 用户最新的要求和纠正不必经过第二次生成;
- 正在进行的 tool loop 保留更精确的参数与结果;
- 下一次模型调用既能看到长期脉络,也能看到最近的原始证据。
摘要提示词区分“概括较早前缀”与“概括近期片段”两种方向。相对时间会结合本地日期和时区转成明确日期,避免几天后恢复线程时,“昨天”“下周”已经失去原义。摘要模型只能输出文本,不能在压缩过程中调用工具。
摘要之后,运行时还会重新读取最近访问的少量文本文件,并恢复 active plan 与 TODO。文件恢复有独立的总预算、单文件预算和数量上限;二进制内容不会被当作文本塞回上下文。
这让“模型记得我们在改哪个文件”和“模型重新拿到文件的当前内容”成为两件不同的事。
真正困难的是边界,而不是摘要
在普通聊天里,把第 50 条之前的消息概括掉似乎没有问题。工具型 Agent 的消息却有协议结构:一条 assistant message 发出若干 tool calls,后面必须紧跟对应的 tool results。压缩边界如果恰好落在 tool result 上,下一次 OpenAI-compatible 请求就会成为非法历史。
Open Nua 在确定 cutoff 后会把边界向后移动,保证 tool call 与连续 tool results 落在同一侧。生成摘要前,运行时还会修复被取消或中断的历史:为缺失结果补入明确的 cancelled result,丢弃没有对应调用的孤儿结果。
它不是在伪造工具成功,而是在恢复 provider 要求的消息邻接不变量。
仅保存一个数字下标也不够。线程恢复、history repair 或多 Agent 重建前缀后,原始消息位置可能发生漂移。当前 compaction event 因此同时保存:
cutoff_index:兼容旧 checkpoint 的位置 fallback;cutoff_message_id:第一条保留消息的稳定锚点;summary_message:下一次调用使用的摘要;file_path:持续追加被压缩原文的历史文件位置;strategy、tokens_before、tokens_after:策略与诊断事实。
恢复时优先按 message ID 找边界,找不到才回退到 index,并再次检查边界是否落在 tool result 上。这个看似细小的锚点,修复的是一种很危险的错误:摘要本身正确,但拼接出来的后半段来自错误位置。
Token 不能只靠字符数猜
压缩过早会增加延迟和摘要成本,压缩过晚则可能让正常请求直接失败。阈值判断必须尽量接近模型实际看到的上下文。
当前实现采用“真实 usage 锚点 + 新增尾部估算”的混合方式:从后向前寻找最近一条带 provider usage 的 AI message,以真实 input、output 与 cache usage 作为锚点,只对之后新增的消息使用启发式估算。没有 usage 时,才按文本、JSON、图片和工具块分别估算,并加入保守余量。
这里还有一个容易被忽略的修正:provider usage 中的 hidden reasoning output,并不会全部作为可见内容进入下一轮上下文。因此压缩压力计算会从 output tokens 中扣除已知 reasoning tokens;provider 没有给出明细时,再从可识别的 reasoning 字段估算。
真实 usage 也不能被盲信。它描述的是某一次已经发生的请求,不一定覆盖后来恢复或改写过的完整持久化历史。实现会同时计算当前消息的保守估计,取不低于完整历史估计的结果,防止旧 usage 让长线程被低估。
Provider 私有状态不能进入可重放历史
部分 provider 会在 reasoning item 中返回加密状态。它可以在原请求链路中帮助 provider 延续推理,但不等于可移植的会话内容。
压缩后,摘要和保留消息会以新的 item identity 重新组成一次请求;如果旧 encrypted content 跟着进入摘要请求、history 文件或下一次主模型请求,provider 可能返回 invalid_encrypted_content。
因此自动与手动压缩都会在两个位置应用同一条规则:
- 摘要前剥离 provider-only reasoning / encrypted state;
- 压缩后的主模型请求仍经过共享 sanitizer。
用户可见文本、普通 assistant message、tool call / result 与 checkpoint 语义继续保留。上下文压缩在这里不只是信息压缩,也是一次跨请求边界的状态净化。
手动 /compact 与自动压缩不是同一条产品语义
自动压缩发生在一次模型调用前:运行时先压缩,再用新上下文继续完成当前任务。
用户显式执行 /compact 时,目标只是更新线程状态,不应该在压缩完成后让 Agent 顺手继续工作。Desktop 把 manual compact 作为独立 turn operation:读取 checkpoint、生成摘要、更新 compaction event、保存一条用户可见的 compact command,然后结束这次 execution。
用户下一次发消息时,Agent 才基于压缩后的上下文继续。
这一点避免了两个问题:一是手动整理上下文意外触发工具或副作用;二是“压缩已完成”和“原任务已完成”在生命周期里被混成同一个结果。
失败也必须是可见状态
LLM 摘要本身会失败,history offload 会失败,压缩后的摘要加近期消息也可能仍然超过上下文窗口。可靠系统不能在这些情况下打印一条 warning,然后把原始超长请求再次交给模型。
当前自动压缩包含三层失败处理:
- 连续摘要失败会打开实例级 circuit breaker,并按指数 cooldown 暂停昂贵重试;
- 如果 provider 明确返回 context overflow,运行时可以绕过 circuit breaker,再尝试一次必要压缩;
- 摘要完成后重新从消息内容估算 token,仍超过阈值时明确要求新建线程,并记录失败阶段。
压缩过程还会发送 compaction_started、compaction_completed 或 compaction_failed 事件。会话界面用它们更新压缩状态、插入去重后的 notice,并留下可观察的活动记录。用户看到的不再是“对话突然少了一段”,运营侧也可以区分正常回答、摘要模型调用和压缩故障。
当前实现仍然有哪些边界
上下文压缩提高的是长任务可继续性,不是无损存储或形式化正确性证明。
- 摘要仍是模型生成的派生内容,可能遗漏细节;历史原文和近期消息是主要补偿机制。
- 文件恢复读取的是压缩时的当前文件内容,而不是历史快照。
- token 估算跨 provider 仍不可能完全一致,尤其在 provider 不返回完整 usage 时。
- history offload 失败不会阻止摘要生成,但被摘要掉的旧消息将无法通过历史文件路径恢复。
- 如果一个单独的近期消息、文件恢复片段或摘要本身已经大到无法容纳,继续压缩没有意义,系统会要求新建线程。
这些边界比“支持无限上下文”更诚实。上下文压缩真正提供的是一条受预算、可恢复、可诊断的退化路径,而不是把有限窗口包装成无限窗口。
我们学到的六件事
第一,压缩应当生成投影,而不是改写事实。 原始历史、模型上下文和 UI 展示必须有各自的 owner。
第二,先做确定性减负,再花一次模型调用。 Collapse、truncate 与 microcompact 往往已经能消掉大量低价值 token。
第三,近期原文比更长的摘要更值钱。 部分压缩降低了最新用户要求和进行中工具状态被二次解释的风险。
第四,工具协议边界是硬约束。 摘要质量再高,也救不了 orphan tool result、漂移的 cutoff 或不可重放的 provider state。
第五,压缩完成不等于任务完成。 手动压缩、自动压缩、原任务 execution 与 recovery 必须分别记账。
第六,压缩后要再次测量。 生成了摘要,不代表上下文已经安全;只有 post-compaction check 通过,才应该继续调用主模型。
长任务的本质不是把更多文字塞进模型,而是在有限窗口内持续维护正确的工作状态。Open Nua Desktop 的上下文压缩也因此不再是一个“总结对话”的小功能,而是一条连接消息协议、checkpoint、文件系统、provider 适配、运行生命周期和可观测性的基础设施。