一、状态驱动是 Agent Loop 的基础
Agent Loop 本质上是一个持续观察环境并采取行动的过程。
每一次操作都可能改变页面状态:页面跳转、弹窗出现、列表刷新、按钮状态变化,甚至只是一个 Toast 提示。
因此 Agent 无法像传统函数调用一样预先规划完整路径,而必须在每次操作后重新观察当前页面,再决定下一步动作。
Observe → Act → Observe无论是基于截图的视觉方案,还是基于 ViewTree、Accessibility 的结构化方案,本质区别只是观察方式不同,控制闭环是一致的。
因此,操作后的页面状态必须返回给模型。它是 Agent 能够持续推进任务的前提。
二、问题开始出现在状态的累积
状态返回本身没有问题。
问题在于,多轮 Agent Loop 会不断产生新的页面状态,而这些状态会持续堆积在上下文中。
以一个简单的点餐任务为例:
首页→ 搜索页→ 商品列表→ 商品详情→ 购物车当 Agent 已经进入购物车页面时,决策真正依赖的通常只有当前页面。
但上下文中仍然保留着此前所有页面快照。
首页快照搜索页快照商品列表快照商品详情快照购物车快照这些历史页面记录了任务轨迹,却很少参与后续决策。
统计线上任务后发现,一个 8~10 轮的典型流程中,历史页面状态约占每轮 Input Token 的 25%。随着任务长度增长,这部分比例还会持续上升。
于是一个看起来非常合理的优化思路出现了:
保留最新状态,压缩历史状态。
从 Token 视角看,这几乎是一个没有副作用的优化。
而后面的故事,也正是从这里开始的。
三、第一次优化:压缩历史状态
既然 Agent 的决策只依赖当前页面,那么历史页面是否还有必要保留完整内容?
基于这个判断,我实现了一个 Context Compactor Middleware。
原则很简单:
最新页面状态保留全文; 历史页面状态压缩为摘要; 保留关键跳转信息,删除可从后续状态重新推导的页面细节。
例如,一条完整页面状态:
Clicked at (143, 559). New page state:Environment: content_feedScreen: SearchResult## Page Structure- Items: 12- Scrollable: 1 region## Visible Entities- 星巴克- 瑞幸...## Available Actions- click(星巴克)...压缩后变成:
[compacted]click: Clicked at (143,559)Screen: SearchResult在我的 App Agent 场景中,这种压缩几乎没有观察到行为退化。
因为页面结构、实体列表、候选动作等信息,已经包含在后续页面状态中;对于历史页面而言,它们更多只是执行轨迹,而非当前决策依据。
统计结果也印证了这一判断:
无论从上下文长度还是 Token 消耗看,这都是一次相当成功的优化。
直到我看到账单。
四、KV Cache 改变了优化目标
最初我使用的是一个非常朴素的成本模型:
Token 越少 成本越低
这个模型在 KV Cache 出现之前基本成立。
但 KV Cache 普及之后,成本模型已经发生变化:
成本 ≈ 未命中的 Token
而不是:
成本 ≈ 总 Token
问题恰恰出在这里。
Compactor 真正做的并不只是压缩历史,而是在每一轮调用前改写历史消息。
例如:
Tool₁ → Tool₁'Tool₂ → Tool₂'Tool₃ → Tool₃'而主流 Provider 的 KV Cache(OpenAI、Anthropic、DeepSeek等)都依赖相同原则:
Prompt 前缀必须逐 Token 保持一致。
一旦前缀中的任意位置发生变化,从该位置开始的缓存全部失效。
于是 Compactor 每运行一轮,都会制造新的 Cache Miss:
Round 3[Sys, Hum, AI₁, Tool₁', AI₂, Tool₂, AI₃, Tool₃] ↑Round 4[Sys, Hum, AI₁, Tool₁', AI₂, Tool₂', AI₃, Tool₃, AI₄, Tool₄] ↑历史消息不断被改写,前缀不断被击穿。
最终得到一个极其反直觉的结果:
| 17k |
Input Token 减少了 33%。
实际账单却上涨了 76%。
原因也很简单:
Compactor 省掉的那部分 Token,本来已经被 KV Cache 以极低成本服务;而历史改写导致的大量 Cache Miss,反而让原本接近免费的前缀重新参与计费。
从这个案例开始,我意识到一件事:
KV Cache 出现后,Agent 的优化目标已经发生变化。
很多看起来正确的 Context Engineering,在新的成本模型下可能恰好是反优化。
五、KV Cache 改变了 Agent 的优化目标
这次踩坑最大的收获,不是发现了一个错误优化,而是发现 Agent 的成本模型已经发生变化。
过去做 Context Engineering,目标很简单:
Context Engineering = 减少上下文
因为在没有 Cache 的世界里:
成本 ≈ Token 数量
Token 越少,成本越低。
但 KV Cache 普及之后,情况发生了变化。
对于多轮 Agent Loop 而言,成本不再主要取决于发送了多少 Token,而取决于有多少 Token 没有命中缓存。
因此优化目标从:
minimize(tokens)变成了:
minimize(cost)subject to:maximize(cache_hit)这里有一个容易混淆的点:
Cache 命中率并不等于前缀稳定性。
前缀稳定性是约束条件,Cache 命中率是可观测结果。
我们真正维护的是:
Prompt Prefix Stability
最终看到的是:
Cache Hit Rate
而这两个目标经常与「减少 Token」发生冲突。
例如:
压缩历史↓改写历史消息↓前缀变化↓Cache Miss虽然发送的 Token 更少,但计费反而更高。
反过来:
保留历史↓前缀稳定↓Cache Hit虽然发送的 Token 更多,但真正计费的部分更少。
因此在 KV Cache 存在的前提下:
保持前缀稳定,往往比减少 Token 更重要。
六、什么样的优化才是安全的
理解 KV Cache 之后,很多优化策略会重新排序。
首先是源头压缩,而不是事后压缩。
ViewTree ↓GUI Context ↓Tool Result页面信息在生成 Tool Result 时就完成语义提炼。
页面类型、结构指标、关键元素识别、可执行动作生成等确定性计算全部放在 Runtime 侧完成,LLM 只负责推理。
这样生成出来的状态天然更小,同时不会触碰任何历史消息。
第二是裁尾,而不是改写。
如果确实需要控制上下文长度,只能删除最旧的完整消息, 删除只会缩短上下文尾部。
改写则会破坏前缀稳定性,并导致后续 Cache 全部失效。
第三是监控 Cache,而不是只监控 Token。
命中率高时,保持前缀稳定就是最大的成本杠杆。
只有在 Cache 几乎不存在的场景下(例如部分本地模型、小模型或无 Cache Provider),历史压缩这类策略才重新具备价值。
最终我移除了 Compactor,采用:
源头压缩+保持前缀稳定+监控 CacheKV Cache 存在时,最好的优化不是改写上下文,而是让上下文从产生的第一刻起就足够小。
稳态运行后:
结果比那次“成功优化”更便宜,也更稳定。
七、小结
这次踩坑最大的收获,并不是发现了一次失败的优化,而是重新理解了 Agent Runtime 的成本模型。
过去做 Context Engineering,关注的是Token 越少越好。
因为成本几乎与输入长度线性相关。
而在 KV Cache 普及之后,情况发生了变化。
对于多轮 Agent Loop 而言,真正昂贵的往往已经不是上下文本身,而是失去缓存命中的上下文。
于是优化目标也随之改变:
让上下文更短 ↓让前缀更稳定之前优化本身没有问题,问题在于它优化的是 Token,而 Provider 实际计费的却是 Cache Miss。
历史状态是否保留、页面信息如何表达、业务知识如何加载,看似是不同问题,本质上都在回答同一个问题:
哪些信息必须存在于上下文中,以及应该以什么代价存在。
因此,Context Engineering 的重点已经不再是单纯压缩上下文,而是在保持前缀稳定的前提下,让每一份状态都尽可能小、尽可能准确、尽可能接近事实。
理解模型如何推理很重要。
理解模型如何计费,同样重要。
夜雨聆风