2026-10-24
AI
0

目录

开篇
上下文由什么组成
历史消息的管理顺序
为什么不能直接截断
保守 Token 估算
触发条件
安全尾部
结构化摘要
摘要何时重算
先微压缩,再摘要
大工具结果先外置
结构化摘要后的消息顺序
Prompt Too Long 重试
确定性回退
一次完整的历史管理示例
冻结上下文
观测与诊断
小结

系列文章:从零实现一个通用智能体
本篇主题:上下文窗口、结构化压缩与完整 transcript
代码版本:mx-claw 0.1.3

开篇

长任务最终都会撞上上下文窗口。简单删除旧消息会破坏 assistant tool call 与 tool result 的配对;把全部历史原样重发则会持续增加成本和延迟。

mx-claw 将两类数据分开:

text
会话事实源:完整 transcript,永久保留 模型请求视图:系统消息 + 结构化摘要 + 最近安全尾部 或:原始对话 + 旧工具输出占位符

压缩只影响本轮发给 Provider 的消息数组,不修改 SQLite 中的原始消息。

上下文由什么组成

一次请求可能包含以下消息:

  • 固定 system prompt;
  • 冻结的项目指令与验证策略;
  • 当前会话冻结的 Skill 元数据;
  • 当前会话冻结的长期记忆快照;
  • 用户输入与图片附件;
  • assistant 文本和 tool calls;
  • tool results;
  • 历史压缩摘要。

前五类决定任务边界,后三类记录执行过程。压缩必须优先保留边界,再压缩过程。

历史消息的管理顺序

上下文管理不是“超过上限后删几条消息”,而是一条有固定优先级的流水线。每次调用模型前, Query Loop 都会重新生成 Provider 请求视图:

text
读取完整 transcript -> 修复破损的 tool call / tool result 配对 -> 估算当前请求 token -> 未超阈值:原样发送 -> 超阈值:选择最近安全尾部 -> 对齐尾部中的工具协议 -> 尝试微压缩旧工具输出 -> 仍超阈值:摘要更早的历史 -> 压缩器异常:使用确定性回退窗口

不同消息的处理优先级如下:

消息层级是否保留处理方式
system prompt、项目规则、Skill 索引、记忆快照结构化摘要中全部保留不作为普通历史消息裁剪;紧急回退只保留前两条 system
最近对话尾部原文保留按 keepRecentTokens 从尾部向前选择
更早的用户目标、工具失败、assistant 状态结构化保留写入确定性摘要
更早的大段成功工具输出可替换用字符数占位符微压缩
单次超过 50 KB 的工具结果有界进入上下文全文进入 artifact,只回传头尾预览和引用
reasoning 展示字段不进入 Provider 请求持久化用于界面,发送前移除

所以“裁剪”只发生在模型请求视图里。SQLite 中的历史消息、工具结果、审批事件和完整 transcript 不会被压缩器删除。

为什么不能直接截断

OpenAI-compatible 工具协议要求 tool result 紧跟对应 assistant tool call。只要截断边界落在 两者之间,Provider 就可能拒绝整段历史。

错误示例:

text
assistant: tool_calls [read_file-1] tool: result(read_file-1) assistant: tool_calls [edit_file-1] tool: result(edit_file-1)

如果尾部从第二个 assistant 开始,却丢掉后续 tool result,历史结构就失效。压缩器因此需要 识别协议配对,而不是简单按消息数量截断。

另一个问题来自破损历史。进程中断可能留下没有结果的 tool call。发送请求前, repairToolCallTranscript() 会补齐中断结果或移除孤立结果,先恢复协议结构,再执行压缩。

保守 Token 估算

mx-claw 不绑定特定模型的 tokenizer,而是使用保守估算:

ts
const cjkCount = text.match(CJK_PATTERN)?.length ?? 0 const latinChars = text.replace(CJK_PATTERN, '').length const toolTokens = Math.ceil(JSON.stringify(toolCalls).length / 4) return cjkCount + Math.ceil(latinChars / 4) + imageCount * 2_000 + toolTokens

规则包括:

  • 每个 CJK 字符按一个 token 估算;
  • 拉丁字符约四个按一个 token 估算;
  • 每张图片预留 2,000 token;
  • 工具调用 JSON 单独计算。

它不是精确计量器,目标是在缺少模型专用 tokenizer 时稳定地提前触发压缩。

估算只覆盖真正会发送给 Provider 的字段。Web 中展示的 reasoning 会在生成请求前移除, 因此不占用这里的上下文预算。

触发条件

压缩阈值由模型能力或显式配置决定:

ts
const threshold = Math.floor( options.contextWindowTokens * options.thresholdRatio, )

默认压缩比例是 0.8,最低保留最近约 8,000 token。请求估算低于阈值时,原消息数组直接 返回,不产生额外摘要。

每次模型迭代前都会重新检查上下文,因为工具结果会持续增加请求体积。压缩可能在一个 turn 中发生多次。

安全尾部

压缩器从会话尾部向前累计 token,直到达到 keepRecentTokens。尾部至少保留一条消息, 避免出现空上下文。

随后检查尾部第一条消息:

ts
while (start > 0 && conversation[start]?.role === 'tool') { const toolCallId = conversation[start]?.toolCallId // 向前寻找包含该 toolCallId 的 assistant 消息 }

如果尾部从 tool result 开始,索引会向前扩展,直到包含相应 assistant tool call。这个处理 保证协议段完整。

预算计算有两个边界:

  • 即使最后一条消息本身超过 keepRecentTokens,也至少保留它,避免发送空上下文;
  • 扩展工具配对时允许尾部暂时超过预算,因为拆开的 tool call 比多占一些 token 更危险。

返回条数不是判断依据。一段很短的 JSON tool call 可能比几段长文本更贵;一张图片也会按 固定预算估算。裁剪始终基于 token 估值,而不是“只保留最近 N 条消息”。

结构化摘要

被压缩的旧消息不会只留下“之前发生了一些事情”。摘要包含四类可操作信息:

  1. 最近四个用户目标;
  2. 编辑过的文件路径;
  3. 最近的工具失败、权限拒绝和禁止命令;
  4. 最后一条有效 assistant 状态。

摘要示例:

text
[compressed conversation context] Older transcript messages were summarized. Goals: - 修复 CI 中的 SQLite FTS 测试 Files and tools touched: - packages/storage/src/sqlite.ts Failures to avoid: - Tool failed: no such module: fts5 Last useful assistant state: - FTS 兼容索引已经实现,正在验证 Node 22。

摘要不是模型自由生成,而是确定性提取。这样更容易测试,也不会在压缩阶段额外发起一次 模型请求。

各字段还有明确的长度边界:

  • 最近用户目标最多 4 条,每条最多 240 字符;
  • 失败证据最多 4 条,每条最多 240 字符;
  • 最后一条有效 assistant 状态最多 500 字符;
  • 编辑路径按出现顺序去重;
  • 没有内容时写入显式的 None recorded 或等价占位,避免摘要结构缺字段。

当前路径提取聚焦 edit_file 调用中的 path 参数;其他工具的完整参数和输出仍保留在 transcript 中。后续若增加更多写工具,需要同步扩展摘要提取规则,并加入协议回归测试。

这也意味着摘要不是完整历史的替代品。它只服务于模型继续执行当前任务;审计和检索仍然应 读取原始 transcript。

摘要何时重算

turn 开始时从 SQLite 读取并修复完整 transcript。turn 内每轮模型调用前,压缩器接收的是 “当前 Provider 视图”而不是重新读取数据库。生成结构化摘要后,该摘要作为 system 消息留在 请求视图中;如果同一 turn 后续工具结果再次超过阈值,已有摘要会被固定,新的尾部可以再 生成一份摘要。

摘要本身不单独写入 SQLite。下一次用户提交时,Core 会重新读取完整 transcript,因此不会 依赖上一 turn 的临时摘要链。这样做避免了摘要与原始消息双写,也不会因为恢复、分支或协议 修复而落后。

当前实现仍有两个后续优化点:超长单 turn 可能保留多份阶段性摘要;每次重新进入长会话都要 重新扫描历史并生成确定性摘要。未来若引入模型摘要、分段缓存或向量检索,需要记录摘要 来源、覆盖范围、失效规则和合并策略。

先微压缩,再摘要

上下文超阈值时,压缩器先尝试 microcompaction:只替换安全尾部之前的旧成功工具输出,把 大段构建日志或搜索结果改为字符数占位符。

微压缩不删除消息,也不拆开 assistant tool call 与 tool result。失败、权限拒绝和禁止命令 输出不会被微压缩,避免丢失关键纠错证据。

如果微压缩后已低于阈值,本轮不生成整段摘要;否则继续执行结构化摘要。

微压缩只替换“旧的成功工具输出”。以下内容会保留:

  • Tool failed、Invalid、Permission denied 和 Forbidden 等错误证据;
  • 安全尾部内的工具结果;
  • 当前 assistant tool call 与对应 tool result 的配对;
  • system 消息和摘要结构。

替换后的消息仍是原角色、原 toolCallId,只是正文变成类似 [old tool output compacted: 18432 characters] 的占位符。因此 Provider 看到的协议结构 没有变化。

大工具结果先外置

微压缩处理的是“已经进入历史的消息”。更前一步发生在 tool result 写入会话时:当单次工具 输出超过 50 KB,并且 artifact 存储可用,完整正文会写入会话隔离的 artifact,只把有界 预览返回给模型:

text
前 6,000 字符 ... [N bytes omitted from context] ... 后 3,000 字符 [artifact artifact-xxx] ... Use read_artifact for byte, line, or search access.

模型需要细节时,可以通过 read_artifact 按字节范围、行范围或搜索命中继续读取,而不是 让整段构建日志永久占据每一轮上下文。

artifact 写入失败不会改变工具的业务成功状态;消息退回为最多 50,000 字符的截断结果。 这保留了一个重要边界:存储优化失败不能伪装成命令执行失败。

结构化摘要后的消息顺序

最终请求视图是:

text
原始 system messages + 压缩摘要 system message + 最近安全尾部

在正常结构化摘要路径中,固定 system prompt、Skill 索引和记忆快照不会被压缩器删除。 它们是会话边界,不属于可丢弃的过程历史。只有结构化压缩本身失败、进入六条消息的紧急 回退时,才只保留前两条 system 消息。

Prompt Too Long 重试

本地估算可能低估某个 Provider 的真实 tokenizer。当 Provider 返回上下文过长错误时,Query Loop 会执行一次强制回退压缩:

ts
if ( !promptTooLongRetried && /prompt too long|context length|too many tokens/i.test(message) ) { promptTooLongRetried = true messages = fallback.messages continue }

重试只发生一次。第二次仍失败时,错误将正常进入 turn 失败路径,避免无限重试。

确定性回退

如果结构化压缩器本身抛错,Query Loop 不会停止任务,而是保留最多两个 system 消息、一条 固定回退摘要和最近六条对话消息。

回退路径仍会向前调整起始位置,避免窗口从 tool result 开始。它的信息量低于正常摘要, 但保持协议有效。

一次完整的历史管理示例

假设模型窗口为 64,000 token,阈值比例 0.8,最近尾部预算为 8,000:

text
完整 transcript 72,000 tokens system 消息 10,000 tokens 最近安全尾部 7,000 tokens 旧工具输出中可替换部分 20,000 tokens 更早的对话过程 35,000 tokens

第一次请求超过 51,200 token 阈值。压缩器先固定 system 消息,从尾部向前保留约 8,000 token,并扩展工具配对。旧工具输出被替换后如果降到阈值以下,本轮只发送:

text
完整 system 消息 + 全部原始对话消息 + 旧工具输出的字符数占位符

微压缩不删除旧用户/assistant 消息,也不需要生成摘要。

如果占位符仍不足以降到阈值,则把更早的对话提取为一份结构化摘要:

text
完整 system 消息 + 最近目标、编辑路径、失败证据、上次 assistant 状态 + 原文形式的最近安全尾部

如果确定性提取本身失败,则进入回退窗口。每一步都只改变当前请求数组,完整 transcript 仍然是后续恢复、搜索和重新压缩的唯一事实源。

冻结上下文

项目指令、Skill 和长期记忆采用按会话冻结策略:

  • 新会话首回合读取 AGENTS.md 与 .mx-claw/AGENTS.md;
  • 新会话首回合读取 .mx-claw/project.json 的验证策略;
  • 新会话首回合读取 Skill catalog;
  • 新会话首回合读取 user 与 project 记忆;
  • 注入后的内容作为 system 消息持久化;
  • 会话内新增记忆或 Skill 只影响后续会话。

冻结减少同一 session 内的指令漂移。任务进行到一半时,行为规则突然变化比使用稍旧的 索引更难排查。

观测与诊断

压缩发生时会产生 context.compacted 事件,包含:

ts
{ type: 'context.compacted', beforeTokens: 92_400, afterTokens: 24_100, summary: '[compressed conversation context] ...' }

微压缩使用 [microcompaction] 前缀,结构化摘要使用 [compressed conversation context] 前缀;两者都会通过同一个 context.compacted 事件发出。事件中的 beforeTokens 和 afterTokens 是保守估算值,用于观察压缩趋势,不应作为 Provider 账单中的精确 token 计数。

CLI 和 Web 可以显示压缩状态,但不会用摘要覆盖原始 transcript。后续恢复、搜索和审计仍然 读取完整消息。

小结

上下文管理的核心取舍是:对模型发送可压缩的工作视图,对系统保存不可丢失的事实。

  • 压缩前先修复工具协议;
  • 最近历史保留原文,更早历史按目标、路径和失败证据摘要;
  • 尾部不能从孤立 tool result 开始,也不能按固定消息条数硬切;
  • 成功输出先微压缩;更大的工具结果在产生时进入 artifact;
  • 固定 system 边界不能被摘要删除;
  • Provider 拒绝长上下文时只做一次确定性重试;
  • 压缩事件可观察,但原始历史不可改写。
如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:繁星

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!