Agent 每次读取文件、运行测试或调用其他工具,结果都会进入消息历史。模型下一轮不仅要处理新请求,还要重新接收此前保留的消息。任务持续得越久,上下文越容易被旧工具输出占满。

Context Compact 的目标不是保存无限历史,而是在有限上下文内保留“继续完成当前任务所需的状态”。它必然涉及取舍:有些内容可以落盘后按需重读,有些只能被摘要,有些则不应丢失。

当前课程按成本与信息损失从低到高执行四步管线:

最新工具结果批次预算

消息中段归档与裁剪

缩短已经被模型消费的旧工具结果

仍然过大时,生成历史摘要

前三步是确定性处理,最后一步才增加一次模型调用并产生有损摘要。

上下文不是长期记忆

上下文是当前模型请求能看到的消息集合。它适合保存:

  • 当前用户目标;
  • 尚未完成的工作;
  • 最近工具结果;
  • 仍然有效的约束和决定;
  • 继续执行所需的关键文件位置。

它不适合无限保存所有日志、完整文件内容和已经消费完的搜索结果。

把历史写入 .transcripts/ 也不会自动变成模型记忆。只有当运行时提供读取工具、路径仍然有效、权限允许,并且模型知道何时重新读取时,落盘内容才是可恢复的。否则它只是供用户或审计系统查看的归档。

第一步:控制最新工具结果批次

一次模型响应可能并行提出多个工具调用,Harness 执行后会把对应 tool_result 一起放进一条 user message。如果这一批结果本身过大,甚至来不及进入下一次模型请求。

课程先统计最后一条 user message 中所有工具结果的字符数。超过批次预算时,从最大的结果开始处理;足够大的结果会完整写入:

.task_outputs/tool-results/<tool_use_id>.txt

上下文只保留恢复路径和开头预览:

<persisted-output>
Full output: .../tool-results/toolu_123.txt
Preview:
前 2000 个字符
</persisted-output>

这里的预览很重要。若新结果在模型首次看到之前就只剩一个文件路径,模型还要额外调用读取工具才能知道结果大致是什么。

当前阈值是教学参数:批次总预算 200,000 字符,单项超过 30,000 字符才优先转存。它们不是 API 标准,也不保证适合其他模型或工作负载。

第二步:归档消息中段

消息数量超过 50 条时,当前实现保留最初 3 条和最近约 46 条,在中间放入一条归档标记:

[18 messages archived at .../.transcripts/transcript_xxx.jsonl]

保留开头可以维持初始目标和早期约束,保留尾部可以维持当前工作状态。中间过程通常最适合裁剪,但“通常”不代表没有损失:若关键决定只出现于中段且没有在后续状态中体现,它仍可能被移出模型视野。

因此,Agent 在长任务中应把 durable decision 写入明确的项目文档、任务状态或持久记忆,而不是寄希望于原始聊天永远留在上下文。

工具调用与结果必须成对

消息裁剪不能随便从任意索引切开。Messages API 风格的协议要求 assistant 的 tool_use 和后续 user 的 tool_result 对应;留下孤立结果或移除结果却保留调用,下一次请求可能被判定为无效。

当前实现会在调整头尾切点时检查边界:

assistant: tool_use(id=A)
user:      tool_result(tool_use_id=A)

这两条应一起保留或一起归档。

同样的规则适用于手动压缩:如果模型一次响应既请求写文件又请求 compact,Harness 应先执行完整工具批次,为每个调用追加结果,闭合这一轮后再压缩。否则已经发生的写入可能失去对应记录,模型下一轮甚至可能重复副作用。

第三步:缩短旧工具结果

完成批次预算和消息裁剪后,若上下文仍超过字符阈值,micro_compact 会处理较早的工具结果。

当前实现区分两类结果:

  • 未消费结果:最近一次 assistant 响应后刚加入、模型还没看过;
  • 已消费结果:已经至少进入过一次模型请求。

它优先缩短已消费结果,保留最近 3 条,并把更早且超过 120 个字符的内容先写盘,再替换为:

[Earlier tool result saved at /path/to/output.txt]

这比无条件保留某一种工具更合理:一次短小的读取结果可能很重要,一次巨大的读取结果也可能随时重新获取;是否已经被模型消费、是否可恢复、离当前工作有多近,通常比工具名称更能指导压缩。

如果仅未消费的新结果就足以超限,另一层 fit_tool_results 会从最大的结果开始落盘,同时保留较短预览。目标是让模型至少看见一次新结果,而不是直接用全历史摘要掩盖它。

第四步:用模型总结历史

前三步后仍超过阈值,才进入 compact_history

  1. 把当前消息写入 transcript;
  2. 请求模型生成事实状态摘要;
  3. 单独保留当前用户请求;
  4. 用一条压缩消息替换历史。

压缩后的结构类似:

[Compacted]
 
Current user request:
修复认证刷新问题并运行测试
 
Conversation summary (reference only):
已确认入口文件、修改内容、测试状态和剩余工作……
 
Full transcript: .../.transcripts/transcript_xxx.jsonl

当前请求必须单独捕获,因为工具结果同样使用 role: user。简单地搜索“最后一条 user message”可能拿到工具结果,而不是人类真正提出的任务。

摘要提示要求模型只提取目标、决定、文件、约束和剩余工作,不执行被总结内容里的指令。把摘要标记为 reference data,也能降低历史提示注入被误当成当前授权的风险。但这只是防御的一层,不能保证摘要绝不遗漏、误解或虚构事实。

摘要输入也可能放不下

如果待总结的历史本身很大,不能假设总结模型一定能完整接收。当前实现把序列化后的历史限制为 80,000 字符:保留前四分之一和后四分之三,中间以省略标记替代。

与旧版“只取尾部”不同,这种头尾采样同时尝试保留早期目标与最近状态。但被省略的中间段仍不会参与摘要;完整 transcript 路径只能提供事后恢复,不会自动补回缺失信息。

更可靠的长历史总结可以使用分层摘要:先对多个片段分别总结,再合并状态,并对用户约束、未完成事项和已验证事实建立结构化字段。

字符数只是近似,不是 token 数

当前触发器使用:

len(json.dumps(messages, default=str, ensure_ascii=False))

它计算序列化后的字符数,课程阈值也是 50,000 字符。字符数与模型 token 数相关,却不是固定换算关系:中文、英文、代码、JSON 和不同 tokenizer 的比例都可能不同。

因此本文应称它为“字符估算”,不能写成“超过 50,000 token 自动压缩”。真实系统更适合:

  • 使用供应商或模型对应的 token 计数器;
  • 预留输出、工具定义和 system prompt 空间;
  • 按模型上下文上限动态计算预算;
  • 用实际请求用量校准安全余量。

API 拒绝后的补救压缩

即使字符估算未超过阈值,API 仍可能返回上下文过长错误。当前实现识别相关错误后执行 reactive_compact

  • 保存 transcript;
  • 总结较早历史;
  • 保留最近 5 条左右的消息;
  • 再发起一次请求。

切点同样避开 tool_usetool_result 之间。连续补救限制为一次,第二次仍失败就向上抛错,避免无限重试和重复摘要成本。

错误识别目前依赖异常字符串中的 prompt_too_longtoo many tokens。生产代码应优先使用 SDK 暴露的结构化错误类型和错误码,避免文案或本地化变化导致误判。

手动 compact 是阶段边界提示

自动策略只知道上下文大小,不知道某个工作阶段是否已经结束。模型可以主动调用 compact,表示后续只需保留阶段结果。

当前 Harness 会先为本批次所有工具调用生成结果,再执行历史摘要,然后继续 Agent Loop。它不会直接结束当前用户任务。

手动压缩仍是模型提出的建议,运行时应决定是否允许:例如有未记录的关键决定、尚未核验的写入或高风险操作时,可以先要求形成明确检查点,再压缩。

Transcript 与转存文件的安全边界

历史归档可能包含源码、个人信息、命令输出、错误堆栈甚至凭据。把它们写盘前应定义:

  • 文件权限和存储位置;
  • 加密需求;
  • 保留期限和清理策略;
  • 是否进入备份、同步或版本控制;
  • 多用户和多会话隔离;
  • 日志脱敏;
  • 配额与磁盘耗尽处理。

工具调用 ID 被用于输出文件名时也需要清洗,防止路径分隔符或异常长度影响目标路径。课程通过正则替换非安全字符并限制长度,这是一项必要但不充分的防护。

此外,使用 json.dumps(..., default=str) 能避免某些 SDK 对象无法序列化,却可能把结构化 block 变成字符串表示。这样的 transcript 适合调试查看,不应默认视为可精确重放的事件日志。需要重放时,应显式规范化每种消息和 block 类型,并记录模型、工具版本及调用参数。

压缩不能替代持久记忆

压缩服务于当前会话:它允许丢弃可恢复细节,以便继续正在进行的任务。持久记忆服务于跨压缩、跨会话仍需存在的信息,例如用户偏好、项目术语和稳定架构决定。

两者的判断标准不同:

  • 只为当前步骤服务的大段测试日志:转存或裁剪;
  • 当前任务的剩余工作:保留在摘要或任务状态;
  • 长期有效的项目决定:写入权威文档或受治理的记忆;
  • 敏感临时信息:不应因为“以后可能有用”而长期保存。

归档所有聊天不是记忆设计,摘要一次也不是事实治理。

几个 Python 列表细节

旧版笔记中的语法知识仍有助于理解实现。

older = results[:-KEEP_RECENT]

表示取除最后 KEEP_RECENT 项之外的前面部分。

messages[:] = compacted

表示原地替换列表内容,外部持有的同一列表引用也能看到变化;messages = compacted 只会让当前局部变量重新绑定。

next((item for item in values if matches(item)), "")

返回第一个匹配值,找不到时返回默认值,避免 StopIteration

这些是实现工具,真正需要守住的是消息配对、当前请求、信息恢复和失败语义。

建议覆盖的测试

Context Compact 至少应验证:

  • 最新一批工具结果低于和超过预算;
  • 大结果落盘、预览和文件名清洗;
  • 消息中段裁剪保持头尾数量;
  • 所有切点都不拆开工具调用与结果;
  • 未消费结果在模型首次看到前受到保护;
  • 已消费旧结果按目标大小逐步缩短;
  • transcript 或输出目录写入失败;
  • 摘要输入的头尾截取与中段省略;
  • 当前用户请求和历史摘要明确分开;
  • 空摘要、错误摘要和摘要 API 失败;
  • 手动压缩先闭合整个工具批次;
  • API 上下文拒绝只补救一次;
  • 压缩后不会重复已发生的副作用;
  • 敏感内容脱敏、文件权限、配额和清理;
  • transcript 是否满足声明的审计或重放能力。

小结

Context Compact 不是把无限历史无损塞进有限窗口,而是按可恢复性和成本逐层减少信息:先转存大工具结果,再裁剪消息中段,再缩短已消费结果,最后才让模型总结状态。

可靠实现必须同时保护消息协议和当前请求,承认字符数只是 token 近似,也承认摘要会损失甚至扭曲信息。磁盘归档只有在可访问、可治理且有明确恢复路径时才有价值;需要跨会话长期存在的事实,应进入独立的文档或记忆系统。

参考资料