改写前缀还是管理缓存:Agent 上下文压缩的预填充成本

受论文 Cache Engineering for Agents 启发,用模拟的 60 轮工具调用演示:摘要式上下文压缩会改写前缀、触发重新预填充,而直接丢弃旧工具返回的 KV 缓存可以控制活跃缓存又不必重新预填充;并演示同一消息序列在不同缓存操作顺序下状态不同。

已验证 Python 3.12.15 耗时 不到 0.1s 运行于 2026-10-23 22:10 UTC
结论

模拟中滚动丢弃把峰值缓存从 177.5 降到 38.3 千token 且无需重新预填充,摘要触发 4 次、额外预填充 20.0 千token

检查 4/4 通过
  • 滚动丢弃的峰值活跃缓存小于保留全部历史
  • 滚动丢弃不需要任何重新预填充
  • 摘要策略在阈值触发后需要重新预填充
  • 同一消息序列经不同操作顺序得到不同的缓存状态(历史依赖)
实验指标(千token)
full 峰值活跃缓存177.5千token
summarize 峰值活跃缓存41.5千token
rolling_drop 峰值活跃缓存38.3千token
summarize 额外重新预填充20.0千token
rolling_drop 额外重新预填充0.0千token

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

实验指标(次)
summarize 触发次数4次

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
设定:60 轮工具调用,摘要阈值 40000 token,滚动保留最近 4 个工具返回
METRIC: full 峰值活跃缓存 = 177.5 千token
METRIC: summarize 峰值活跃缓存 = 41.5 千token
METRIC: rolling_drop 峰值活跃缓存 = 38.3 千token
METRIC: summarize 额外重新预填充 = 20.0 千token
METRIC: rolling_drop 额外重新预填充 = 0.0 千token
METRIC: summarize 触发次数 = 4 次
[PASS] 滚动丢弃的峰值活跃缓存小于保留全部历史
[PASS] 滚动丢弃不需要任何重新预填充
[PASS] 摘要策略在阈值触发后需要重新预填充
[PASS] 同一消息序列经不同操作顺序得到不同的缓存状态(历史依赖)
RESULT: 模拟中滚动丢弃把峰值缓存从 177.5 降到 38.3 千token 且无需重新预填充,摘要触发 4 次、额外预填充 20.0 千token

目的

论文 Cache Engineering for Agents 指出:长时间运行的 Agent 常用「上下文工程」(裁剪或摘要历史)来控制上下文长度,但改动前缀会迫使推理引擎重新预填充保留下来的内容;论文提出的「缓存工程」直接丢弃选定的 KV 缓存条目(Drop)、调整保留部分的位置编码(Repos),不改写轨迹、也不需要重新预填充。论文还指出,同样的 token 序列在不同操作顺序下会得到不同的缓存状态,所以只按前缀匹配复用缓存是不够的。

本实验用模拟数据演示这两点。注意:轨迹长度由脚本按固定随机种子生成,成本模型是简化的近似(只数预填充 token,不计摘要本身的生成成本,也不模拟位置重排),不复现论文中的任何数字。

步骤

  1. 生成一条 60 轮的工具调用轨迹:每轮一段 Agent 输出(150–600 token)和一个长尾分布的工具返回
  2. 比较三种策略: - full:保留全部历史,只增量预填充新内容 - summarize:活跃上下文超过 40,000 token 时压缩成 3,000 token 的摘要,之后重新预填充「系统提示 + 摘要」 - rolling_drop:只保留最近 4 个工具返回的 KV,更早的从缓存中丢弃,不改写前缀
  3. 统计峰值活跃缓存、额外重新预填充的 token 数、摘要触发次数
  4. 用一个 5 条消息的小例子演示「历史依赖」:先丢后加与先加后丢,最终消息相同,缓存内容不同

运行:

python main.py

程序打印 METRIC: 指标、[PASS]/[FAIL] 检查和最后一行 RESULT:,任一检查失败时以非零状态退出。

预期结果

  • rolling_drop 的峰值活跃缓存远小于 full,额外重新预填充为 0
  • summarize 也能控制峰值,但每次触发都带来重新预填充
  • 历史依赖检查通过:相同的消息序列可以对应不同的缓存状态

启示与可以继续尝试

  • 改 KEEP_TOOL 或 THRESHOLD,观察峰值缓存与重新预填充的取舍
  • 丢弃工具返回会让 Agent「忘掉」原始内容;论文在基准上比较了准确率,本模拟只看成本,不能说明对准确率的影响
  • 如果你的推理服务只按前缀做缓存复用,考虑在缓存键中记录操作历史,否则可能错误复用

内网复现

  • 无需任何第三方依赖,只用 Python 3.8+ 标准库,无需联网,可直接在内网运行
  • 把 main.py 拷贝到内网机器执行 python main.py 即可
  • 论文代码在 GitHub(thu-nics/AKV,Apache-2.0),基于 SGLang,需要 Rust 工具链、模型权重和 GPU;依赖可通过内网 PyPI 镜像安装,权重可通过内网 Hugging Face 镜像下载,本实验不依赖它

代码

main.py124 行
"""Agent 上下文管理的两种做法:改写前缀(摘要)vs 直接管理 KV 缓存(滚动丢弃)。

受论文 Cache Engineering for Agents (arXiv:2610.28577) 启发。论文指出:上下文工程
(裁剪或摘要历史)会改动前缀,迫使推理引擎重新预填充(re-prefill)保留下来的内容;
缓存工程则直接丢弃指定的 KV 条目、调整位置编码,不需要重新预填充。

本脚本用固定随机种子模拟一个 60 轮的工具调用 Agent(全部为模拟,不复现论文数字),
比较三种策略的「累计预填充 token 数」(近似预填充计算成本)和「峰值活跃缓存」:
- full:保留全部历史,只增量预填充新内容
- summarize:活跃上下文超过阈值时,把历史压缩成摘要,之后要重新预填充「系统提示 + 摘要」
- rolling_drop:只保留最近 K 个工具返回的 KV,较早的工具返回从缓存里丢弃;不重写前缀,
  因而不需要重新预填充
最后演示论文提到的「历史依赖」:同样的最终消息序列,经过不同的缓存操作顺序,
活跃缓存内容不同,所以只按前缀匹配复用缓存是不够的。
"""
from __future__ import annotations

import random
import sys

SEED = 24
TURNS = 60
SYSTEM = 2_000
THRESHOLD = 40_000  # summarize 的触发阈值
SUMMARY = 3_000
KEEP_TOOL = 4  # rolling_drop 保留最近几个工具返回


def make_trajectory(rng: random.Random):
    turns = []
    for _ in range(TURNS):
        assistant = rng.randint(150, 600)
        tool = int(rng.lognormvariate(7.6, 0.8))  # 工具返回长度偏长尾
        turns.append((assistant, tool))
    return turns


def full(turns):
    prefill, active, peak = SYSTEM, SYSTEM, SYSTEM
    for a, t in turns:
        prefill += a + t
        active += a + t
        peak = max(peak, active)
    return prefill, peak


def summarize(turns):
    prefill, active, peak, resets = SYSTEM, SYSTEM, SYSTEM, 0
    for a, t in turns:
        prefill += a + t
        active += a + t
        peak = max(peak, active)
        if active > THRESHOLD:
            # 前缀被改写:系统提示 + 摘要都要重新预填充
            active = SYSTEM + SUMMARY
            prefill += SYSTEM + SUMMARY
            resets += 1
    return prefill, peak, resets


def rolling_drop(turns):
    prefill, peak = SYSTEM, SYSTEM
    kept_assistant = 0
    tools = []
    for a, t in turns:
        prefill += a + t  # 只增量预填充新内容
        kept_assistant += a
        tools.append(t)
        if len(tools) > KEEP_TOOL:
            tools.pop(0)  # 直接丢弃最早工具返回的 KV,不重写前缀
        active = SYSTEM + kept_assistant + sum(tools)
        peak = max(peak, active)
    return prefill, peak


def history_dependence():
    """同一最终消息序列 [S, A1, T1, A2, T2],两种操作顺序得到不同的活跃缓存。"""
    # 顺序 1:先丢 T1,再追加 A2、T2 -> A2 生成时看不到 T1
    cache1 = ["S", "A1", "T1"]
    cache1.remove("T1")
    cache1 += ["A2(未见T1)", "T2"]
    # 顺序 2:先追加 A2、T2,再丢 T1 -> A2 的 KV 是在看过 T1 的情况下算出来的
    cache2 = ["S", "A1", "T1", "A2(见过T1)", "T2"]
    cache2.remove("T1")
    same_tokens = [c.split("(")[0] for c in cache1] == [c.split("(")[0] for c in cache2]
    return same_tokens, cache1 != cache2


def main() -> int:
    turns = make_trajectory(random.Random(SEED))
    f_prefill, f_peak = full(turns)
    s_prefill, s_peak, resets = summarize(turns)
    r_prefill, r_peak = rolling_drop(turns)
    re_prefill = s_prefill - f_prefill
    same_tokens, diff_cache = history_dependence()

    print(f"设定:{TURNS} 轮工具调用,摘要阈值 {THRESHOLD} token,滚动保留最近 {KEEP_TOOL} 个工具返回")
    print(f"METRIC: full 峰值活跃缓存 = {f_peak / 1000:.1f} 千token")
    print(f"METRIC: summarize 峰值活跃缓存 = {s_peak / 1000:.1f} 千token")
    print(f"METRIC: rolling_drop 峰值活跃缓存 = {r_peak / 1000:.1f} 千token")
    print(f"METRIC: summarize 额外重新预填充 = {re_prefill / 1000:.1f} 千token")
    print(f"METRIC: rolling_drop 额外重新预填充 = {(r_prefill - f_prefill) / 1000:.1f} 千token")
    print(f"METRIC: summarize 触发次数 = {resets} 次")

    checks = [
        (r_peak < f_peak, "滚动丢弃的峰值活跃缓存小于保留全部历史"),
        (r_prefill == f_prefill, "滚动丢弃不需要任何重新预填充"),
        (resets > 0 and re_prefill > 0, "摘要策略在阈值触发后需要重新预填充"),
        (same_tokens and diff_cache, "同一消息序列经不同操作顺序得到不同的缓存状态(历史依赖)"),
    ]
    ok = True
    for passed, desc in checks:
        print(f"[{'PASS' if passed else 'FAIL'}] {desc}")
        ok &= passed

    print(
        f"RESULT: 模拟中滚动丢弃把峰值缓存从 {f_peak / 1000:.1f} 降到 {r_peak / 1000:.1f} 千token 且无需重新预填充,"
        f"摘要触发 {resets} 次、额外预填充 {re_prefill / 1000:.1f} 千token"
    )
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助