系列文章:从零实现一个通用智能体
本篇主题:上下文窗口、结构化压缩与完整 transcript
代码版本:mx-claw 0.1.3
长任务最终都会撞上上下文窗口。简单删除旧消息会破坏 assistant tool call 与 tool result 的配对;把全部历史原样重发则会持续增加成本和延迟。
mx-claw 将两类数据分开:
text会话事实源:完整 transcript,永久保留 模型请求视图:系统消息 + 结构化摘要 + 最近安全尾部 或:原始对话 + 旧工具输出占位符
压缩只影响本轮发给 Provider 的消息数组,不修改 SQLite 中的原始消息。
一次请求可能包含以下消息:
前五类决定任务边界,后三类记录执行过程。压缩必须优先保留边界,再压缩过程。
上下文管理不是“超过上限后删几条消息”,而是一条有固定优先级的流水线。每次调用模型前, 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 就可能拒绝整段历史。
错误示例:
textassistant: 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() 会补齐中断结果或移除孤立结果,先恢复协议结构,再执行压缩。
mx-claw 不绑定特定模型的 tokenizer,而是使用保守估算:
tsconst 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
规则包括:
它不是精确计量器,目标是在缺少模型专用 tokenizer 时稳定地提前触发压缩。
估算只覆盖真正会发送给 Provider 的字段。Web 中展示的 reasoning 会在生成请求前移除, 因此不占用这里的上下文预算。
压缩阈值由模型能力或显式配置决定:
tsconst threshold = Math.floor(
options.contextWindowTokens * options.thresholdRatio,
)
默认压缩比例是 0.8,最低保留最近约 8,000 token。请求估算低于阈值时,原消息数组直接
返回,不产生额外摘要。
每次模型迭代前都会重新检查上下文,因为工具结果会持续增加请求体积。压缩可能在一个 turn 中发生多次。
压缩器从会话尾部向前累计 token,直到达到 keepRecentTokens。尾部至少保留一条消息,
避免出现空上下文。
随后检查尾部第一条消息:
tswhile (start > 0 && conversation[start]?.role === 'tool') {
const toolCallId = conversation[start]?.toolCallId
// 向前寻找包含该 toolCallId 的 assistant 消息
}
如果尾部从 tool result 开始,索引会向前扩展,直到包含相应 assistant tool call。这个处理 保证协议段完整。
预算计算有两个边界:
keepRecentTokens,也至少保留它,避免发送空上下文;返回条数不是判断依据。一段很短的 JSON tool call 可能比几段长文本更贵;一张图片也会按 固定预算估算。裁剪始终基于 token 估值,而不是“只保留最近 N 条消息”。
被压缩的旧消息不会只留下“之前发生了一些事情”。摘要包含四类可操作信息:
摘要示例:
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。
摘要不是模型自由生成,而是确定性提取。这样更容易测试,也不会在压缩阶段额外发起一次 模型请求。
各字段还有明确的长度边界:
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 等错误证据;替换后的消息仍是原角色、原 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 消息。
本地估算可能低估某个 Provider 的真实 tokenizer。当 Provider 返回上下文过长错误时,Query Loop 会执行一次强制回退压缩:
tsif (
!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 的验证策略;user 与 project 记忆;冻结减少同一 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。后续恢复、搜索和审计仍然 读取完整消息。
上下文管理的核心取舍是:对模型发送可压缩的工作视图,对系统保存不可丢失的事实。


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