云计算百科
云计算领域专业知识百科平台

Agent 读了 30 个文件之后,为什么会突然停住?

本文是「从零理解 Claude Code:20 个 Agent Harness 机制」系列的第 8 篇。 源码仓库:shareAI-lab/learn-claude-code 本文基于开源仓库学习整理,具体实现以仓库代码为准。

我第一次碰到上下文超限时,任务正在排查一组认证测试。

Agent 先读了失败日志,又读了认证服务、令牌处理、配置文件和几个测试用例。前面的过程没有任何异常,命令能执行,文件也能正常读取。等它准备发起下一次模型调用时,接口直接返回了 prompt_too_long。

回头看消息历史,问题就很清楚了。

前面读过的完整文件、几轮测试输出、搜索结果和修改记录都还留在 messages 里。Agent 已经不需要其中的大部分内容,但接口不会替它判断哪些结果已经过期。只要这些内容还在历史中,下一次调用就得继续携带。

上下文压缩解决的,是长任务进行到中途以后,如何让 Agent 丢掉已经用完的信息,同时保留继续完成任务所需的线索。

一次请求失败前,messages 里到底堆了什么

一开始很容易盯着消息数量。

例如,一段任务跑了几十轮,messages 长度看起来已经很大,第一反应通常是删掉早期对话。这个判断只解决了一部分问题。

实际占空间的,往往是工具结果。

read_file 可能返回完整源码,bash 可能返回数百行测试日志,load_skill 可能带回完整的技能说明。消息只有十几条时,只要其中有两三段大输出,上下文照样会接近限制。

以认证测试这个场景为例,真正值得长期保留的信息很少:

当前目标:修复认证测试失败
已确认:失败与 token 配置有关
已修改:tests/auth/test_token.py
待完成:重新运行相关测试,检查回归影响

而历史中可能还塞着:

第一次 pytest 的完整输出
多个已经排除的搜索结果
几份后续不再使用的文件全文
旧版本的配置内容

这些内容在当时都有用,后面却变成了负担。

因此,压缩不能只看消息出现得早不早,还要看它是任务目标、近期结果,还是已经失效的大段输出。

压缩顺序里,有一个位置不能换

这一章在每次调用模型前,都会先处理消息历史。执行顺序是:

大工具结果转存到磁盘
→ 裁掉过远的中间消息
→ 将较早工具结果替换为占位信息
→ 上下文仍然过大时,生成历史摘要

看上去只是几个预处理函数,顺序却不能随便调整。

先说最容易被忽略的一层:大工具结果转存。

tool_result_budget() 会检查最近一条工具结果消息的总大小。超过阈值后,程序从最大的结果开始处理,把完整内容写进 .task_outputs/tool-results/,上下文中只留下文件路径和前 2000 个字符预览。

<persisted-output>
Full output: .task_outputs/tool-results/toolu_123.txt
Preview:

</persisted-output>

这样,Agent 后续发现预览不够时,还可以通过文件读取工具重新查看完整结果。

接下来才是 micro_compact()。

它保留最近 3 条工具结果,将更早的结果替换成一行说明:

[Earlier tool result compacted. Re-run if needed.]

如果这两步顺序反过来,问题就出现了。

较早的大结果会先被替换成占位文本,后面的转存逻辑再也拿不到完整内容,也就无法写入磁盘。Agent 看到的只剩一行提示,原始输出没有保存入口。

这也是这章最值得注意的设计细节:压缩过程本身也会丢信息,必须先处理可恢复的大内容,再处理可以丢弃的旧内容。

在这里插入图片描述

前面的 tool_result_budget() 关注单条或单轮结果是否过大,snip_compact() 处理的是另一类问题:一段任务运行太久,消息数量本身已经很长。

教学代码超过 50 条消息后,会保留最开始的 3 条和最近的 47 条,中间部分用一条简短标记代替。开头通常保留了用户最初提出的任务和限制条件,最近的消息则对应 Agent 当前正在处理的文件、报错或测试结果。中间的搜索过程虽然曾经有用,距离当前任务已经比较远,优先缩短的风险相对较低。

裁剪时有一个边界需要额外处理:模型发起工具调用和程序返回工具结果,必须作为一组消息保留。

例如,Agent 想确认测试环境的数据库配置,于是先请求读取配置文件:

助手:调用 read_file,参数为 config.py

程序执行后,将结果交回模型:

工具结果:config.py 中 DATABASE_URL 为空

这两条消息合在一起,才构成一次完整的信息交换。模型知道自己读了哪个文件,也知道从文件里得到了什么结论。

如果压缩时只留下前一条调用记录,模型会知道自己曾经读过 config.py,却不知道读取结果。它后续可能重复读取文件,也可能在缺少依据的情况下继续推断。

如果只留下后一条工具结果,模型虽然看到了 DATABASE_URL 为空,却无法确认这段内容来自哪个文件、对应什么操作,更难判断它是否仍然与当前任务有关。

因此,snip_compact() 在确定裁剪边界时,会检查边界附近是否存在这类调用与结果的配对关系。边界刚好落在两者之间时,程序会向前或向后调整,保证这组消息一起留下,或者一起进入被压缩的历史。

摘要能让任务继续,丢掉的细节不会自动回来

前三层压缩都不需要再调用模型。

它们只是移动、删除或替换文本,成本低,也不会引入新的推理过程。经过这些处理后,如果上下文仍然超过阈值,程序才会调用 compact_history()。

这一步先把完整消息写入 .transcripts/,再请求模型生成摘要。摘要需要保留当前目标、重要发现、已修改文件、剩余工作和用户限制。随后,原来的消息历史会被替换成一条摘要消息。

def compact_history(messages):
write_transcript(messages)
summary = summarize_history(messages)

return [{
"role": "user",
"content": f"[Compacted]\\n\\n{summary}",
}]

压缩后,认证测试任务可能只剩下这样的上下文:

当前目标:修复认证测试失败。

已确认:token 配置缺少测试环境变量。
已修改:tests/auth/test_token.py。
待完成:运行 pytest tests/auth,并检查相关配置是否影响其他测试。
用户限制:不要修改生产环境配置。

这足以支撑后续任务继续执行。

不过,完整历史已经不在模型可见的上下文里了。教学代码虽然保存了转录文件,但没有提供让模型重新读取 .transcripts/ 的工具。摘要漏掉的某个细节,Agent 不会自动找回来。

这也是摘要压缩的边界。

摘要适合保留任务主线和关键结论,无法替代完整证据。涉及文件内容、测试日志、接口返回值等细节时,Agent 后续仍然可能需要重新读取文件、重新运行命令,或者由系统额外恢复近期的关键内容。

接口已经拒绝请求后,系统还剩一次补救机会

上下文阈值通常是估算值。

教学代码通过字符数量估算消息体积,它无法完全等同于模型实际计算的 token 数。再加上一轮工具调用可能突然返回大量内容,程序有机会在压缩前就收到 prompt_too_long。

这时会进入 reactive_compact()。

它会保留最近几条消息,把更早的历史交给模型总结,然后重试请求。近期消息通常包含正在执行的工具调用和最新结果,直接丢掉会让当前任务失去上下文。

紧急压缩只允许有限次数重试。

如果压缩后仍然超限,继续重试没有意义,只会不断消耗调用次数。仓库为这条路径设置了重试上限,达到限制后抛出异常,留给后续的错误恢复机制处理。

这一点看起来很普通,却是长任务系统必须具备的失败出口。压缩逻辑如果永远相信下一次会成功,最终只会把一次接口错误变成循环调用。

这套方案适合解决什么问题

上下文压缩不会提升模型对代码的理解能力,也不会让 Agent 永远记住所有细节。

它解决的是长任务运行过程中的容量管理问题。

对于持续读文件、反复运行测试、逐步修改代码的任务,早期工具结果会不断失去价值。压缩机制负责回收这些内容占用的空间,并尽可能留下任务目标、当前进度和可恢复入口。

运行这一章的代码时,可以重点观察三个现象:

python s08_context_compact/code.py

先让 Agent 连续读取多个文件,观察较早的工具结果是否被替换为占位信息。接着读取较多内容,检查 .task_outputs/tool-results/ 是否出现转存文件。最后进行一段较长对话,观察终端是否出现 [auto compact] 或 [reactive compact]。

前者表示程序在调用模型前完成了主动压缩;后者说明接口已经拒绝当前请求,程序开始执行紧急处理。

下一篇会讨论 Memory。

上下文压缩会主动丢弃过程内容,用户偏好、项目约束和重要决策也可能随着历史一起被缩短。Memory 要解决的是另一类问题:哪些信息应该在压缩后继续保留,哪些信息值得跨会话保存。

赞(0)
未经允许不得转载:网硕互联帮助中心 » Agent 读了 30 个文件之后,为什么会突然停住?
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!