Swift · AI · Shellby

让 AI Agent 的上下文不随步数膨胀——缓存断点、历史折叠与流式首字

Shellby 复盘:一个自主执行命令的 Agent,每多跑一步就把累积历史重发一遍,token 和首字延迟随步数线性爬升。三招压下去——Anthropic prompt caching 断点、单调固化的历史折叠、SSE 流式,且三者正交可叠加。

Shellby 的 AI Agent 会自己在服务器上一步步执行命令:发一个 tool call、看输出、决定下一步,直到做完。这个循环是最朴素的 manual loop——每一步都把「到目前为止的完整对话历史」重新发给模型。

朴素的代价随步数暴露:一个 20 步的运维任务,第 20 步要重发前 19 步的全部命令和输出。token 成本和 prompt 处理时间随步数线性膨胀,首字延迟越到后面越长,钱也越烧越快。这篇讲把它压下去的三招,以及它们为什么能叠加。

先认清膨胀长在哪

manual loop 的请求体每步都是 system + tools + 累积消息历史。其中:

  • systemtools 每步完全一样——却每步都重新让模型处理一遍;
  • 消息历史里,早期的工具输出(一大段 journalctl、一屏 ps aux)在后续每一步都原样重发,但模型早就不需要它们的全文了,它只需要知道「那步干了什么、成没成」。

三招各打一个点:缓存治「稳定前缀被反复处理」,折叠治「老输出反复重发」,流式治「首字要干等整个响应」。

第一招:prompt caching 断点

Anthropic 的 prompt caching 允许你在请求里打 cache_control: ephemeral 断点,断点之前的稳定前缀会被服务端缓存;下一次请求只要前缀逐字相同,就命中缓存,跳过重复处理,首字延迟和成本一起降。

断点打在三处:system 末尾、tools 末尾、以及最新一条历史消息上。前两个是天然稳定的前缀;第三个是关键设计——断点随最后一条消息单调向前滚动。第 N 步时断点在第 N 条消息,第 N+1 步滚到 N+1,于是「第 1 到 N 条消息」这段前缀在下一步命中缓存,只有新增的那条要重新处理。

[ system      ]  ← cache 断点(稳定)
[ tools       ]  ← cache 断点(稳定)
[ msg 1..N-1  ]
[ msg N       ]  ← cache 断点(随最新消息滚动)
[ msg N+1     ]  ← 下一步新增,只有它要重新处理

一个务实的边界:自定义 endpoint(非官方 Anthropic)忽略这个字段,所以 cacheControl 是可配开关,默认开、走官方时生效,指到别处时自动无害降级。这段请求体拼装抽成了 static 的 encodeRequestBody,可以脱离网络单测。

第二招:历史折叠,且必须单调固化

缓存解决了「稳定前缀重复处理」,但没解决「历史本身越滚越长」——老的工具输出全文一直躺在消息里。第二招是折叠:保留最近 N 轮(默认 3)工具结果的全文,更早的大输出折叠成摘要——只留 exit code 和首行,加一个「已折叠」标记。模型看摘要就够判断「那步成没成」,不需要那一屏原始输出。

但折叠有个和缓存打架的陷阱,必须小心处理:折叠动作本身会改历史,改历史就会破坏缓存前缀。如果每一步都重新决定「哪些该折叠」,那前缀每步都在变,缓存永远命不中,两招互相抵消。

解法是让折叠单调固化:一段输出一旦被折叠,就永远保持折叠、内容不再变动;短输出从不折叠。这样折叠只在「某段输出第一次跨过折叠阈值」时触发一次性的缓存 miss,之后这段前缀就稳定下来,不再反复破坏缓存。折叠只发生在发给模型的请求体里;实时 UI 上的步骤流仍然显示全文,用户看得到完整过程。

最近 3 轮:  [完整 stdout/stderr 全文]
更早的:     [exit 0 · "Reading package lists..." · ⋯已折叠 4.2KB]

第三招:流式,把首字延迟降到第一个 token

前两招压的是「处理多少」,第三招压的是「你要等多久才看到东西」。原来 loop 是阻塞式单次往返:模型要说一大段,你得干等整个响应回来才显示。改成 SSE 流式后,文本 delta 边到边渲染,首字延迟从「等完整响应」降到「等第一个 token」。

实现上把 provider 抽象成 stream() -> AsyncThrowingStream<AIStreamEvent>:过程中产出 .textDelta 喂 UI 预览,收尾产出 .completed(含完整结果,工具循环逻辑一个字没改)。协议给了默认实现——不支持流式的后端和 mock 自动回退到非流式,零改动。两个真 SSE provider 各自累积:Anthropic 消费 content_blocktext_delta / input_json_delta,OpenAI 按 index 累积 delta 的 content 和 tool_calls。工具参数在流里增量拼、收尾一次性给全。

一个诚实的取舍:流式路径不复用那套「失败自动重试」——首字节之后再重试语义太复杂(已经吐了一半的内容怎么算?),所以首字节前失败直接抛错,首字节后不重试。可靠性让位给了「已经在流了」这个既成事实。

三招为什么能叠加

关键在于它们正交:缓存作用于「稳定前缀的重复处理」,折叠作用于「历史体积」,流式作用于「首字节等待」——三个不同的轴,互不干扰。折叠特意设计成单调固化,就是为了不破坏缓存前缀;流式只改「响应怎么送达」,不碰请求体怎么拼。于是可以三个一起开:一个长任务既命中缓存、又不背着老输出的全文、还即时吐字。

复盘

  • manual loop 的默认行为是线性膨胀,不治理就是步数越多越慢越贵。先认清膨胀的两个来源——稳定前缀被反复处理、老输出被反复重发——再分别下药;
  • 缓存断点要随最新消息单调滚动,让「历史前缀」成为可复用的稳定段,只让新增的那条承担处理成本;
  • 任何会改历史的优化都要让位于缓存纪律。历史折叠必须单调固化——折了就不再变,否则每步都破坏前缀,省下的处理量还不够缓存失效亏的;
  • 区分「处理多少」和「等多久」。缓存和折叠降的是前者,流式降的是后者;把优化按轴拆开,才能确认它们正交、可叠加,而不是互相打架。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。