程云来 / 杭州
返回文章·Agent 与 Harness
·约 6 分钟

思想|Context Compact,历史变短以后,还能信什么?

先归档,再整理活跃历史;保留完整工具交接,区分原始证据与摘要,理解压缩失败为什么应该停下来。


规范只在需要时加载,历史怎么还是长了?

用户仍然要检查 main.py 与 expected.txt,运行前取得允许,最后核对实际输出。为了找线索,Agent 又翻了几份旧日志。技能正文、日志内容、工具请求和每轮反馈,一条条留在 messages 里。

第七课已经避免提前加载无关规范,但加载过的内容不会自行消失。子任务也只能隔开它自己的探索过程;父流程拿到的摘要、后续文件和程序结果,仍然会增长。模型能接收的上下文有上限,每次都发送全部历史,迟早会碰到容量和成本问题。

最省事的办法似乎是删掉前半段。但如果删掉的是用户的“运行前问我”,或者只留下一个没有请求的工具结果,模型接下来该依据什么工作?Context Compact(上下文整理)要解决的,正是改变模型当前看到的历史,同时保住继续工作所需的边界。

先分开两件事:发生过什么,现在看什么

发生过的完整内容可以保存成记录;下一次请求则可以使用更短的活跃视图。两者不必永远是同一份列表。我们先把压缩前输入写成快照,再在副本里准备新的视图,确认可用后才替换活跃历史。

这不是把历史变成了一份同样可靠的短答案。快照保留原文,摘要只保留部分线索;细节一旦没有进入本次请求,模型当前就看不到它。即使文件还在磁盘上,也需要宿主查记录或让模型补读相关材料,不能把“有归档”当成“模型已经知道”。

ACTIVE HISTORYUSER GOALREQUEST BLOCKSRESULT BLOCKSJSONL / FULLBEFORE CHANGERECEIPTTRIM OLD BODYSUMMARY IF NEEDEDRECHECK BUDGETCONTEXT PREPCANDIDATEUSER GOALPREPARED VIEWRECENT / PAIRS活跃历史原始快照移出或摘要下一次请求
问题没变,历史已经变长

父流程读了几份旧日志,也保存了工具请求与反馈。下一次模型请求之前,Harness先检查活跃历史的大小。

先查日志;获准后运行,核对实际输出。
  1. 问题没变,历史已经变长:父流程读了几份旧日志,也保存了工具请求与反馈。下一次模型请求之前,Harness先检查活跃历史的大小。
  2. 先保存这次压缩前的输入:把完整输入写成JSONL快照,成功拿到收据后,才允许移出旧内容。近期交接也在快照里。
  3. 先尝试移出旧工具正文:旧长正文换成带收据的位置引用,请求ID与错误标记保留;最近一整组请求与结果原样留下。本次仍然超过教学预算。
  4. 再摘要旧前缀:摘要只处理旧前缀,保留工作线索;近期原始交接不让摘要模型重写。摘要本身仍可能漏掉信息。
  5. 原问题和近期证据重新组成候选:宿主带回原始用户问题,附摘要与快照说明,再接上最近完整交接;检查格式、大小和是否确实变小。
  6. 下一次判断使用新的活跃视图:候选通过后,循环才替换活跃历史并请求模型。system、当前计划与执行许可仍由宿主提供;摘要不是新许可。

沿正常路径看,顺序是保存快照、尝试移出旧正文、必要时摘要、检查候选、再继续请求模型。切换“摘要失败”,原活跃历史不会被空文本覆盖,循环也不会为了显得还在工作而无限重试。

为什么不能按消息条数直接截断?

一次工具动作至少涉及两种记录:assistant 提出 tool_use,user 工具反馈里的 tool_result 通过同一ID关联它。模型一次还可能请求多个工具,一组结果要覆盖这一组请求。按“保留最后三条消息”裁剪,恰好可能只留下结果,或者留着请求却丢了其中一项反馈。

本课只在工具结果已收齐、准备再次请求模型时整理。先把每次请求与对应结果视作一整组,再决定哪些组属于旧前缀,哪些属于最近工作。下面的短代码只观察这个边界:

PyPython · 浏览器本地运行
未运行
exchange = [
    {"role": "assistant", "content": [
        {"type": "tool_use", "id": "read_1", "name": "read_file"}
    ]},
    {"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": "read_1", "content": "42"}
    ]},
]
request_id = exchange[0]["content"][0]["id"]
result_id = exchange[1]["content"][0]["tool_use_id"]
print("整组关联:", request_id == result_id)
print("只留最后一条的角色:", exchange[-1]["role"])
输出

点击行号设置断点,再点“调试”;也可以直接运行。

代码只读;执行发生在浏览器中,不会发送到博客服务器。

运行得到“整组关联:True”和“只留最后一条的角色:user”。第二个输出没有告诉我们是谁请求了这条结果。正式实现会逐组检查角色与ID,拒绝缺少结果或关联不符的历史;近期至少保留一整组,而不是为了缩短长度把保留数改成0。

先移出旧正文,别急着再问一次模型

假设旧日志有两千字,而它对应的请求、调用ID和错误标记都很短。我们可以保留这些结构,只把旧正文替换为一条归档引用。模型还能知道发生过一次读取、结果是否被标记为错误,但不能从占位符猜出原始文件写了什么。

这里必须先有快照。否则“旧正文已移出”只是把内容扔掉,后面再想核查也没有依据。本课的引用写出快照收据和消息位置,方便宿主人手查看;它没有给模型增加自动检索归档的工具。

最近的交接则原样保留。当前程序刚返回的退出码和输出,应该直接参与下一次判断,不能先摘要成“运行成功”再让模型据此回答。旧错误标记也不能顺手改成 False,压缩只改变呈现,不改变动作曾经失败这件事。

这种处理只需本地复制和文本替换,不增加模型调用。它若已让历史满足教学预算,就可以停在这里,不必再生成一份没有必要的摘要。

不够短时,摘要哪些内容?

仍超预算时,只把旧前缀交给摘要接口,近期完整组留在外面。要求摘要保留目标、约束、未完成工作与记录线索;遇到正文占位符,要承认原文已经不在输入里,不能补写一个看似合理的执行结果。

摘要回来后,宿主重新带回原始用户问题,附上摘要与快照说明,再接近期组。原始问题由宿主单独持有,避免几次摘要之后,用户要求也跟着一遍遍改写。当前计划从宿主状态重新附带,权限规则也继续在执行器里生效。

假设摘要写成“用户之前已经允许过”。这仍不是本次 run_main 的许可证。父模型如果提出运行,权限入口照样判断;宿主拒绝时程序就不执行。摘要也不能把旧日志里的成功,变成眼前这份程序的运行证据。

已加载的技能正文可能位于被摘要的旧前缀里。本课会明确提醒需要重新加载相关规范,不能默认一段摘要保住了整份操作指南。system 中的技能目录仍由原组装器提供,加载与压缩各管自己的入口。

压不下去时,停在哪里?

我们需要两类预算:父模型调用次数,以及摘要尝试次数。摘要也是一次真实请求,有成本,也会失败。本课最多尝试两次摘要;先占用一次额度,再请求接口,失败后不会假装这次没有发生。

空摘要、被截断的回答、错误格式或候选仍超限,都不能提交。写快照失败,同样不能继续移出原文。若最近一整组结果本身就太大,保留它以后没有足够空间,应缩小工具输出或任务范围,不能把这一组拆开凑数。

实践里的阈值是 messages 序列化后的 UTF-8 字节数,用很小的值让短例子也能触发整理。它不是 token 数,而且还没算 system、工具定义和计划附件。真实模型的窗口预算必须留出这些内容和输出空间,不能把本课的3200字节直接当成产品配置。

实践篇把边界、正文移出和候选提交分别展开。可以在赋值行前停住,看到旧输入仍然存在;也可以主动让摘要失败,确认循环返回 context_error,而不是把“压缩完成”误当成“用户任务完成”。

资料核对于2026-10-19:课程 Context Compact提供先做本地整理、必要时摘要的路线;Claude Code 官方机制说明解释上下文增长与细节可能丢失;Claude Platform 压缩文档用于区分产品/API能力。本册使用独立 Python 客户端整理与普通 Messages 摘要请求,不声称复现 Claude Code 内部实现。