← 全部文章

为什么 Agent! 在上下文窗口用到一半时压缩,以及把阈值缩到 16K 的三个 bug

Agent! 的上下文压缩如何决定何时对长任务进行摘要,以及 9 月 28 日针对备用模型、Ollama 和 272K Codex 窗口的修复。

长时间运行的智能体任务面临一个"物理问题"。每次工具调用都会把输出追加到对话记录中:一次文件读取、一份构建日志、一个 diff。迟早,对话记录会塞不进模型的上下文窗口,提供商就会拒绝请求。一个要通宵运行的智能体必须学会 压缩 :在保留关键信息的同时缩减对话。

压缩逻辑位于 Agent/AgentViewModel/Messages/Compression.swift。9 月 28 日晚上,它一口气迎来了三处修复,针对的都是同一个症状:在窗口大得多的模型上,任务却在 16K token 时就开始压缩。下面我们来看看这套系统是如何工作的,以及问题出在哪里。

何时压缩:窗口的一半,并设上限

触发条件由一个名为 CompactionState 的结构体负责。它的核心只是一个函数:

static func threshold(for contextWindow: Int, maxTokens: Int = 0) -> Int {
    let byFraction = Int(Double(min(contextWindow, compactionWindowCap)) * compactionFraction)
    let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192
    return max(2_000, min(byFraction, contextWindow - reservedOutput))
}

在 compactionFraction = 0.5 和 compactionWindowCap = 256_000 的设定下,它的含义是:

  • 在窗口用到 50% 时压缩。 这是一个百分比,而非固定的 token 数。另一半留作输出预算,从而保证输入加输出始终放得下。
  • 永远不超过 128K。 标称窗口超过 256K 的模型(Claude 1M、MiniMax 1M、Gemini 和 Grok 2M)都在 128K 时压缩。200K 窗口则在 100K 时压缩。
  • 始终为回复留出空间。 阈值不能晚到连预留的输出空间都放不下。
  • 永远不低于 2K ,作为小型本地模型的下限。

为什么要把 2M 的窗口限制在 128K?注释说得很直白:对于标称窗口巨大的模型,不设上限的百分比会让压缩一拖再拖,一旦提供商那边出现任何偏差,结果就不是压缩,而是直接的上下文溢出。例如,某个路由服务实际提供的窗口比它报告的小,或者系统提示词被算错了。提前压缩的代价是一次摘要,压缩太晚的代价则是整个任务。

计量:先信提供商,再做估算

要和阈值比较,就需要 token 数。按字符数猜测误差很大,所以 measuredTokens 优先采用真实数据:提供商为上一次请求报告的 input_tokens。这个数字包含了系统提示词和工具 schema,而这些是本地估算看不到的。只有在那次报告 之后 追加的消息才需要估算。

如果没有报告,它就退回到经典的"字符数 ÷ 4"估算,并上浮 25%,因为密集的代码更接近每个 token 3.3 个字符。在仅凭估算就花钱执行压缩之前,循环会先用设备端计数器确认一遍。

如何压缩:分层进行

一旦触发阈值,tieredCompact 会逐级升级处理:

  1. 第 0 层:结构化摘要。 由当前模型对 完整 对话记录生成摘要。这一步最先执行,以便摘要器仍能看到它要总结的工具输出。
  2. 微压缩: 旧的工具结果被替换成简短、可恢复的占位符。restore_tool_result 工具可以把其中任何一个恢复回来。
  3. 剥离图片: 截图体积巨大,而且很难摘要。
  4. 第 1 层:Apple Intelligence 摘要 ,快速且在设备端完成。
  5. 第 2 层:激进裁剪 ,把中间部分的消息折叠成一段摘要。

有一句注释道出了其中的理念:结构性压缩"是一种安全机制,而不是一项功能"。即使你关闭了 Token Compression,它依然会运行,只有 Apple Intelligence 这一层会遵从该开关。

压缩完成后,模型会拿回它最不能缺少的信息,包括尚未完成的目标标准、当前的计划清单,以及它在任务中编辑过的最多五个文件的当前内容(总计约 10K token)。读取去重缓存也会被重置,因此可以再次读取文件。

最后,还有一个 熔断器 。如果连续三次压缩都没能缩小对话记录,循环就会停止尝试。但它不会永久放弃:一旦对话记录比上次失败时又增长了 25%,它就会再试一次。

bug:通往 16K 的三条路

9 月 28 日的每一处修复,最终都指向同一个数字。32K 的回退窗口会得出 min(16K, 32K − 8K) = 16K。对于 200K 以上的模型,在 16K 时就压缩意味着几乎一直在做摘要,而且会忘掉模型刚刚读过的文件。

1. 备用模型借错了窗口

Agent! 支持备用链:如果某个提供商返回 429、超时或断网,任务会切换到下一个已配置的提供商继续执行。标签页也可以覆盖所用的模型。但 contextWindow(for:) 查询的是提供商 全局选定 的模型的窗口,而不是实际正在运行的模型。对于没有对应条目的备用模型,它会退回到 32K 的静态大小。

修复方法是在查询中加入模型参数:

func contextWindow(for provider: APIProvider, model: String? = nil) -> Int

现在,每个调用点都会传入实际使用的模型:主循环、标签页任务、备用路径和子智能体。

2. Ollama 忘了它学到的东西

本地服务器会异步报告每个模型的真实窗口:Ollama 通过 /api/show,LM Studio 通过 /api/v0/models,vLLM 通过 /v1/models。这就是循环在第一次迭代之后每次都调用 refreshThreshold 的原因:即使获取结果在任务开始后才到达,也依然能生效。

然而在 Ollama 上,获取到的窗口没有被持久化,所以压缩一直默认使用 16K。修复后,获取到的上下文窗口会被持久化,并且在窗口未知时于任务开始时主动获取。这次修复还附带了一套新的 OllamaContextWindowTests.swift 测试。

3. 慷慨的输出预算吃掉了输入预算

这个问题比较隐蔽。Codex 为其 GPT-6 Astra 模型报告的窗口是 272K。某位用户把 Max Output Tokens 设为 256K。旧代码会预留全部输出预算:

272K − 256K = 16K   →   threshold = min(128K, 16K) = 16K

新代码最多只为输出预留一半窗口:

let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192

同样的情况现在会得出 min(128K, 272K − 136K) = 128K。百分比上限仍然基于封顶后的窗口计算,但输出空间检查使用的是 真实 窗口。否则,在 1M 窗口上,Claude 默认的 500K 输出预算会让 window − maxTokens 变成负数,把阈值压到 2K 的下限。

这对你意味着什么

如果你在备用提供商或本地模型上运行长任务,尤其是通宵的编程任务,这些任务现在应该能保留多得多的工作上下文。这意味着更少的"我得重新读一下那个文件"循环、更少的细节丢失,以及更少花在摘要上的 token。这些修复于 9 月 28 日随 Version 1.1.77(build 277)第 6 个候选版本一同发布,同时发布的还有跨提供商的上下文溢出和 max_tokens 错误检测。

给智能体开发者的经验具有普遍意义: 根据实际运行的模型来确定记忆容量 ,相信提供商报告的 token 数而不是你自己的估算,并为每一项预算设上限,确保没有任何单一设置能把其他预算挤空。