我给一个接近8 万 Star的开源项目提交了 PR,最近已经通过审核并合并!
这个项目就是最近爆火的 learn-claude-code,
这次我主要对他的上下文压缩模块的snip_compact函数进行了修改,引入滞回阈值,给压缩操作留出缓冲,避免 Agent 在后续对话中频繁裁剪消息、写入内容高度重叠的历史归档。
核心问题用一串数字就能说明:
修改前:50 → 52 → 压回 50 → 52 → 再压回 50…… 修改后:50 → 压到约 30 → 32 → 34 → …… → 50 → 再压缩下面介绍这个项目,以及这次修改具体解决了什么。
learn-claude-code 是什么?
learn-claude-code 是 shareAI-lab 维护的教学项目,通过一系列递进式课程,演示如何从零搭建一个类似 Claude Code 的编程 Agent 运行框架。
如果你用过 AI 编程工具,应该见过这样的过程:它先读取文件,再搜索代码、执行命令,根据结果继续修改,直到完成任务。
这些操作背后需要一套工程机制:如何把工具提供给模型,如何执行模型发出的调用,如何保留历史,以及如何控制操作权限。项目把这套基础设施称为Harness。
最基本的运行过程可以理解为:
用户提出任务 ↓ 模型读取上下文,决定下一步 ↓ 调用工具,例如读取文件或执行命令 ↓ 把工具结果加入历史,再交给模型 ↓ 继续处理,直到给出最终回复项目从这样的基础循环开始,逐步加入工具调用、权限控制、子 Agent、上下文压缩和任务管理等机制。
我这次修改涉及其中两处实现:S08 上下文压缩课程,以及复用这一机制的S15 集成 Harness 课程。
为什么 Agent 需要压缩上下文?
假设你让 Agent 修复一个报错,它可能连续完成这些操作:
读取报错日志 读取相关源文件 搜索函数的调用位置 修改代码 执行测试 根据测试结果继续修改过程中,用户消息、模型回复、工具调用和工具结果都会不断加入历史。
模型的上下文容量有限。如果始终携带所有历史,长任务就会越来越难以继续。因此,项目提供了多种上下文管理方式,其中一种叫snip_compact。
它会先将完整历史保存到.transcripts/,再裁掉中间的一部分消息,保留开头和最近的内容,并插入一条归档标记,告诉模型较早的记录保存在哪里。
这一步主要通过裁剪消息控制历史长度,不需要额外调用模型生成摘要。
原来的问题:压缩完,马上又到门槛了
旧实现使用max_messages=50,同时控制触发条件和裁剪后的保留数量:
- 消息超过 50 条时触发裁剪。
- 裁剪后通常保留 50 条,包括归档标记。
问题出在第二点:裁剪后没有留下新增消息的空间。
举个不涉及工具调用边界的简单例子。历史第一次压缩后剩下 50 条,接下来用户问一句,模型答一句,又增加两条:
当前历史:50 条 新增一轮问答:+2 条 当前历史:52 条 再次触发裁剪:回到 50 条下一轮又会重复这个过程。
而每次裁剪之前,代码都会保存一份完整的当前历史。因此,一旦越过门槛,后续问答或工具调用轮次就可能频繁触发归档,产生内容高度重叠的文件,并反复调整模型看到的近期上下文。
这里真正需要调整的是:触发压缩的数量,与压缩后的目标数量。
我的修改:达到 50 条,裁到约 30 条
这次 PR 将原来的一个参数拆成两个:
trigger_messages=50# 达到多少条时触发target_messages=30# 触发后希望保留多少条新逻辑在消息数量达到 50 条时触发裁剪,通常将其缩减到 30 条,为后续新增消息留出约 20 条空间。
这就是这次修改中的“滞回”思路:触发点与处理后的目标之间保持距离,避免系统刚处理完就再次满足触发条件。
以 S08 为例,关键改动如下,省略了原有的归档和工具配对处理:
defsnip_compact(self,messages:list,trigger_messages:int=50,target_messages:int=30,)->list:ifnot5<=target_messages<trigger_messages:raiseValueError("Require 5 <= target_messages < trigger_messages")iflen(messages)<trigger_messages:returnmessages head_end=3tail_start=len(messages)-(target_messages-head_end-1)# 后续继续执行工具配对保护、归档和裁剪正常情况下,保留内容的组成是:
最初 3 条消息 + 1 条归档标记 + 最近 26 条消息 = 30 条参数校验中的下限5也来自这个结构:开头 3 条、归档标记 1 条,另外至少保留 1 条近期消息。目标数量还必须小于触发数量,才能留下缓冲空间。
用具体例子看看区别
假设每轮问答只新增两条普通文本消息,暂不考虑其他压缩机制和工具配对带来的数量变化。
| 阶段 | 修改前 | 修改后 |
|---|---|---|
| 首次触发裁剪后 | 保留 50 条 | 保留 30 条 |
| 新增第 1 轮问答 | 52 条,再裁到 50 条 | 32 条,不裁剪 |
| 新增第 2 轮问答 | 52 条,再裁到 50 条 | 34 条,不裁剪 |
| 新增第 9 轮问答 | 仍可能每轮归档 | 48 条,不裁剪 |
| 新增第 10 轮问答 | 再次归档 | 达到 50 条,再裁到 30 条 |
在这个例子里,新逻辑可以容纳 10 轮这样的问答后再触发下一次裁剪。
这里的“10 轮”来自每轮恰好新增两条消息的假设。实际 Agent 运行时,一次任务可能产生多轮工具调用,所以应以消息数量判断,不能简单理解成“每问 10 个问题压缩一次”。
裁剪时,还要保护工具调用和结果
Agent 的历史消息存在配对关系。例如:
assistant:调用工具,读取 main.py user:返回这次工具调用的文件内容如果裁剪恰好把前面的工具调用删掉,却留下对应结果,下一次 API 请求就可能因为缺少匹配的调用而无效。
因此,这次修改保留了原有的工具配对边界保护。必要时会移动裁剪位置,把完整的一组调用和结果留下来。
这也意味着,30 条是目标数量,最终保留数量可能略高于它。消息结构完整比机械地凑够条数更重要。
“先归档、后裁剪”的行为也继续保留,较早内容仍有对应的历史记录可供取回。
我如何验证这个修改?
PR 中补充了覆盖完整上下文准备流程的回归测试,重点检查:
- 49 条消息时,不生成归档。
- 达到 50 条时,裁剪至 30 条,并保存裁剪前的完整历史。
- 此后连续 9 次、每次新增两条消息,不产生新归档。
- 第 10 次新增两条消息时,再次触发归档。
另外还覆盖了一次新增越过阈值、非法参数,以及头尾工具调用配对等情况。两处实现都接受了这些检查。
根据 PR 中记录的提交时验证结果,Python 测试共有485 项测试、112 项子测试通过;TypeScript 类型检查和网站构建也通过了。
由于它是教学项目,我还同步更新了中、英、日三种语言的相关文档、示意图和网站生成资源,让课程里的解释与代码行为保持一致。
这次修改也有取舍
留出缓冲空间,需要减少直接保留在上下文中的近期消息。
默认情况下,近期消息从原来的 46 条减少到 26 条,较早内容需要通过归档取回。减少重复归档的同时,模型直接看到的近期历史也变少了,这是 PR 中明确说明的取舍。
这次修改针对的是消息数量阈值引发的重复裁剪,没有提供整体任务速度或 Token 成本的性能对比,也没有解决所有上下文保留问题。
PR 已经合并
这个修改通过了维护者审核,并于2026 年 10 月 27 日合并:
PR #582:fix: add hysteresis to message-count compaction
这次贡献让我更具体地理解了 Agent 工程里的上下文管理:除了关注“什么时候该压缩”,还需要考虑压缩结束后,系统能继续运行多久才会再次碰到门槛。