周毓杰

不是把聊天“总结一下”:Open Nua 如何压缩长任务上下文

Open Nua Engineering

一个桌面 Agent 工作得越久,越容易撞上一个看似简单的问题:模型的上下文窗口是有限的。

最直接的办法,是在消息太多时生成一段摘要,再用摘要替换旧对话。但对会写代码、读取文件、调用工具、等待确认并从 checkpoint 恢复的 Agent 来说,“总结一下”远远不够。

一次不可靠的压缩,可能把尚未完成的任务写成已经完成,切断 tool call 与 tool result 的配对,丢掉最近读取的文件和活动计划,甚至把只能在原 provider 请求链中使用的加密 reasoning state 带进下一次调用。

Open Nua Desktop 最终把上下文压缩做成了一条独立的运行时管线。它不删除会话事实,而是为下一次模型调用构造一个更小的有效上下文投影:旧内容被有结构地概括,近期消息保留原文,被压缩的原文片段另行保存,压缩边界和摘要写入 checkpoint,工具协议、恢复状态与可观测事件则继续保持完整。

先分清三种“历史”

上下文压缩最重要的设计决定,不是摘要提示词,而是先把三种历史拆开:

  1. 会话事实:用户消息、模型回答、tool call、tool result、HITL 与 checkpoint 中的原始状态;
  2. 模型投影:下一次请求真正发送给模型的消息列表;
  3. 用户视图:Desktop 会话详情里展示的对话、压缩提示和运行状态。

压缩只重写第二层。原始 messages 可以继续增长,运行时通过持久化的 compaction event 重建有效消息:

raw state
  = 旧消息前缀 + 最近原始消息

effective context
  = 压缩摘要 + 最近原始消息

实现中还有一层专门的压缩状态,用来把摘要与原始消息边界重新拼接起来。摘要因此不是新的事实源,而是一个可替换的读取投影。

同一线程中,会话事实、压缩状态、模型上下文与用户视图各自承担不同职责

被压缩的旧消息会追加到当前工作区的 conversation history 文件;checkpoint 则保存摘要、边界、策略以及压缩前后的 token 估计。重新打开同一线程时,运行时不必重新总结,也不会把已经压缩的前缀再次完整发送给模型。

压缩不是一个动作,而是一条从轻到重的级联

Open Nua 不会一接近上下文上限就立即调用模型做摘要。每次模型请求前,运行时会依次尝试四级处理:

  1. Collapse:合并连续的读取与搜索轨迹;
  2. Truncate:截短旧消息中过大的 tool call 参数;
  3. Microcompact:清理久置的旧 tool result;
  4. LLM compaction:前三步之后仍超过阈值,才执行部分或完整摘要。

前三层不需要额外模型调用。它们处理的是已经失去逐字价值、但仍在重复占用窗口的结构性负载。

长任务上下文从轻量整理、阈值判断到摘要、恢复与压缩后复测的完整决策路径

Collapse 识别连续的 read_filegrepglobweb_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:持续追加被压缩原文的历史文件位置;
  • strategytokens_beforetokens_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

因此自动与手动压缩都会在两个位置应用同一条规则:

  1. 摘要前剥离 provider-only reasoning / encrypted state;
  2. 压缩后的主模型请求仍经过共享 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_startedcompaction_completedcompaction_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 适配、运行生命周期和可观测性的基础设施。