Context Compression

Agentica 提供两层上下文压缩策略,防止长对话或大量工具输出导致 token 超限。Layer 2 对齐 Codex TokenBudget compact:满窗时换一个空的活动窗,不再调用 LLM 或 /responses/compact 做摘要。旧对话留在 session JSONL,用 search_session 查。

两层设计

压缩本质上只有两种操作,按代价从低到高尝试:

做什么 代价 可逆性
Layer 1 淘汰 把更早回合的工具结果换成占位符;超长 tool_call 参数换成 JSON 对象 {"$evicted": N}(不是一段可当正文的字符串)。正在跑的那一轮(未返回的调用 + 末批 result)不动,含 SDK / CLI / Web 自定义工具 免费,无 LLM 参数还在时可重发;payload 已丢掉则不可恢复
Layer 2 换窗 丢掉活动窗里的旧轮次,装上 <context_window> + session notes 摘录 免费,无 LLM 原文在 JSONL,不在 prompt 里

在这两层之前还有一个 Layer 0,它不是压缩而是工具输出策略:单条结果超过阈值时在产生的那一刻就落盘,从不以全量进入上下文。

Tool 输出 (可能很大)
    |
    v
[Layer 0 Tool Result Storage] -- 超大输出产生时即落盘
    |
    v
Context Messages
    |
    v
[Layer 1 淘汰] -- 窗口紧时免费收缩 tool result / 超长参数
    |
    v
[Layer 2 换窗] -- 空摘要 compact_boundary;同一 session_id
    |
    +--> auto / /compact:保留 system + 正在问的尾巴
    +--> prompt-too-long: 强制换窗后再重试

Layer 1:淘汰(agentica.compression.evict

ToolConfig.enable_evict 控制(默认开)。关掉后这一层完全不跑,窗口会更快涨到 Layer 2。

只有两个参数,没有「保留最近 N 条」这类计数:

  • EVICT_THRESHOLD_RATIO = 0.7 — 占用低于窗口 70% 时一条都不动。清掉一条窗口本来放得下的结果是净亏:省下的上下文没人要,模型却要重跑工具才能拿回来。
  • EVICT_TARGET_RATIO = 0.5 — 超过阈值后按最旧优先淘汰,降回 50% 就停。目标低于阈值是为了迟滞,否则每轮刚跌破阈值又超,变成持续抖动。

最近的结果之所以幸存,是因为淘汰在够到它们之前就停了。消息尾部那一段连续的工具结果(模型还没看过的当前批次)整体排除在外:任何固定条数都会输给 count+1 大小的并行批次,这正是「读了又读」死循环的成因。CLI --tools、SDK tools=、Web extra、MCP 与内置工具走同一条边界,不按名字开白名单。

这条边界由 live_tool_round_start 定位,它先跳过被标记为中途注入的消息_injected)。_inject_steering / _inject_peer_messages 在尾部没有 role="tool" result 可折叠时会 append 一条 user 消息,而这恰好发生在两种「调用还在飞」的情况下:调用尚未返回,或 Anthropic 把整轮结果打包进 user 消息。若不跳过,注入的消息会被当成尾巴,cutoff 抬到整轮之上,于是这一层会把模型还没看过的调用参数换成占位符、把刚返回的 result 淘汰掉。真实回合(用户真的问下一句)不带标记,旧轮照常可淘汰。

占位符写明是哪个调用(read_file(file_path=..., offset=...)),模型据此可以原样重发。它先把内容复制到磁盘——取回同样是一次工具调用,而对文件读取来说原路径上的内容比快照更新鲜。

超长参数叶子必须是 JSON 对象 {"$evicted": N},不能是字符串。字符串占位符和 send_message / execute / write_file 的正文同型,模型会把它抄进下一轮(长会话里对端只收到 <evicted-tool-arg chars=1183)。get_function_call 对对象占位符 fail-closed,错误里不回显占位符。结果占位符在参数已被丢掉时不写「Re-run the call」。命令里只是提到旧字符串的不受影响。

淘汰的单位是「一条结果」,不是「一条消息」

两种 provider 对结果的打包方式不一样,这是这一层唯一容易出错的地方:

形态 结构
OpenAI 系 一条结果 = 一条 role="tool" 消息
Anthropic 一整轮结果打包进一条 role="user" 消息的 content 列表,每条是 {"type": "tool_result", "tool_use_id": ...} block

只扫 role="tool" 意味着 Anthropic 路径上这一层从来没生效过——不报错,只是静默失效。所以遍历以「结果」为单位展开,两种形态都覆盖。tool_result block 本身不带工具名,占位符通过发起调用的那条 assistant 消息的 tool_calls 反查 tool_use_id 得到。

同一个形态差异还影响另一处,已按同样口径处理:

  • Layer 2 保留「最后一条 user 消息之后的整段尾巴」。Anthropic 的工具轮本身就是 user 消息,从那里切会留下一批 tool_result,而它们对应的 tool_use 在刚被换窗丢掉的 assistant 消息里——这种孤儿 block 会被 API 直接拒绝。所以判断尾巴时跳过承载工具结果的 user 消息。

Layer 2:换窗(CompressionManager

淘汰兜不住时才走这层。不再调用摘要模型,也不再走 provider-native /responses/compactToolConfig.compression_manager 留空时自动创建(给 /compact 和跨 provider fallback 用)。自动触发由 ToolConfig.enable_auto_compact 控制(默认开);关掉后 runner / prompt_too_long 后的 reactive 都不跑,超窗就把 provider 错误抛出。/compact 不受此开关影响。

每个 Agent 会挂上 BuiltinContextTool。换了几个窗也只扫同一份 JSONL,不按窗建第二套档案。

~/.agentica/projects/<user>/<sanitized-cwd>/<session-id>.jsonl
~/.agentica/projects/<user>/<sanitized-cwd>/<session-id>.notes.md
  • search_session — 查整份 JSONL,包括每一条 compact_boundary 之前。关键词打在 strip_window_preamble 之后的正文上:油表 / <dropped_span> 不是命中,折在 preamble 后面的那句 user 问题还在。query 先当字面子串,中文问法再叠字(工单号 能命中 工单 ZX-41827),按相关度排序。每次结果都附带最近用户问题(倒序最多 20 条、截断、带 JSONL 时间戳)。空 query 只返回这份索引。不要扫 JSONL。不提供 read_session_item
  • <session-id>.notes.mdstanding state(goals / constraints / IDs / decisions),模型用已有文件工具写,不是第二份 transcript。第一次触及 Layer 2 阈值时若文件仍空,先注入 fallback 催写并推迟约 4% 窗口 —— 那段催写只要求「把状态写下来」,不要求继续干活(reserve 就是留给这件事的;自动换窗会保留被折入的那一轮,所以任何"别再继续"之类的话都会跨窗残留)。真正切窗时:文件已有内容则注入 <session_notes>;仍空则把丢掉的那一段按时间交织成 skim(user/assistant,其次 tool args/result,不写时间戳)注入 <dropped_span>不写进 notes.md。写进去会让 notes_are_ready 变真、之后不再催写。skim 会随 preserved tail 进 JSONL,靠 search 剥 preamble,不靠再抄一份。search_session 搜 JSONL,并搜模型手写的 notes

油表是 <context_window> user 片段:新窗写满窗身份,window_id > 1有 session log 时另加一行「本窗跟在一次 context reset 之后、更早的轮次不在这里」(对齐 Codex #29256Previous context window id;第 1 个窗不写,无 log 时也不写——那时 search_session 只会回「No session log on this agent」,与「只提做得到的事」同一条规则);剩余 token 降到工作窗口的 25% 时每窗提醒一次。不写进冻结的 system 前缀。

无 session log 时的换窗交接

session_id 决定有没有 JSONL 与 notes 文件。SDK 不传时两者都没有(notes_path_for 返回 None),这时注入 prompt 的那份 skim 就是那些轮次的唯一副本

  • skim 可累积_collect 会把上一轮 <dropped_span> / <session_notes> 里的 - 行继承进本轮(只取正文行,丢掉旧 header 与分区标题,否则每轮叠一层变成 header 汤)。连续换窗因此不会逐轮丢事实,长度线性有界,最老的先被 _fit 淘汰。strip_window_preamble 仍照常剥这些标签——搜索索引不该把我们的 chrome 当第二次命中;两处需求相反,所以在 _collect 里分开处理。
  • 提醒只说做得到的事:没有 notes 路径时不注入催写、也不推迟换窗(can_author_notes 现在同时要求工具与路径);<context_window> 里不提 search_session 与 notes 文件(没有 log 时前者只会回「No session log on this agent」)。有 session_id 时以上提示与能力完全不变。

CompressionManager 配置

from agentica import Agent, OpenAIChat, CompressionManager
from agentica.agent.config import ToolConfig

agent = Agent(
    model=OpenAIChat(id="gpt-4o"),
    tool_config=ToolConfig(
        compression_manager=CompressionManager(
            compact_token_limit=300000,  # 可选工作阈值;不配 = 窗口×0.95 才换窗
        ),
    ),
)

compact_token_limit 可省略:不配则约 95% 窗口才换窗。换窗会保留 system prompt(否则本轮剩下的调用没有任何指令)和 从最后一条 user 消息开始的整个尾部(否则对话以 assistant 结尾,provider 会直接拒绝)。

开关(默认都开)

两层都可以关。比例旋钮仍然只有 AGENTICA_EVICT_THRESHOLD_RATIO;这两个布尔管的是「要不要自动做」,不是「做多狠」。

开关 默认 关掉之后
ToolConfig.enable_evict True Layer 1 不淘汰。窗口更容易涨到 Layer 2
ToolConfig.enable_auto_compact True runner 自动换窗、prompt_too_long 后的 reactive 都不跑。/compact 仍可用
from agentica import Agent, OpenAIChat
from agentica.agent.config import ToolConfig

# SDK:评测 / 成本敏感服务可以关自动换窗
agent = Agent(
    model=OpenAIChat(id="gpt-4o"),
    tool_config=ToolConfig(enable_evict=False, enable_auto_compact=False),
)

CLI:--no-evict / --no-auto-compact(也认 --evict / --auto-compact 强制打开)。未传 flag 时读 ~/.agentica/config.yaml

settings:
  enable_evict: true
  enable_auto_compact: true
  # compact_token_limit: 300000   # optional working cap; see below

Gateway 读同一对 settings。SDK 的 Agent() 读 config.yaml——只认构造时传入的 ToolConfig

工作阈值 compact_token_limit

model.context_window 是服务商硬上限,不要把它填小来“早点压缩”。另设绝对 token 帽:

Layer 2 触发 = min(compact_token_limit 或 ∞, int(window × 0.95))
Layer 1 的 0.8 / 0.5 相对 min(compact_token_limit 或 ∞, window)

不配则和现在完全一样(约 95% 窗口才换窗)。1M 窗口配 300000 就在 30 万处换窗;32k 窗口配 128000 仍被窗口挡住。写在 profile 上(每个模型可以不同),或 settings.compact_token_limit 做全局默认。CLI:/config set compact_token_limit 300000--compact-token-limit。SDK:ToolConfig(compact_token_limit=300000)

Layer 0:工具输出预算

不是压缩,是输出策略——在结果产生的那一刻Model.run_function_calls)就把它限住, 所以超大输出一次都不会完整进入上下文。两条规则:

规则 阈值 说明
单条结果 Function.max_result_size_charsexecutemax_output_length,默认 20,000 字符) 单个 tool result 超过此值就收缩。read_fileNone(不收缩),否则它会去读自己的落盘文件,形成循环。execute 在读管道时就会封顶(硬顶 64MiB 后杀进程),所以 cat 一个 60 万行的文件不会整份进入 live round
单轮批次 0.25 × model.context_window 本轮全部新结果加起来超过窗口的这个份额时,从最大的开始收缩。Layer 1 从不动尾部批次(模型还没看过),所以一轮并行 6 个大调用只有这里能兜

批次预算按窗口比例而不是固定字符数:固定 200K 字符在 512K token 的窗口上会误伤, 在 8K token 的窗口上又完全不触发。

收缩成什么形态,取决于这个 session 能不能取回

can_recover_spill(model.functions) 检查是否注册了 read_fileexecute

  • 能取回(CLI、带文件工具的 agent):写入磁盘,上下文里换成预览 + 路径(<persisted-output>), 模型一次 read_file 就能拿回全量。execute 撞上 64MiB 硬顶被杀掉时,落盘文件只有前 64MiB, 头文案写 INCOMPLETE,不要把它当全文读。
~/.agentica/projects/<user>/<project-hash>/<session-id>/tool-results/<tool_use_id>.txt
  • 不能取回(只挂业务工具的服务型 agent):不写盘,直接截断成 <truncated-output>, 并说明"本 session 没有能读取副本的工具"。给一个没人能打开的路径既丢了数据, 又会诱导模型去调一个它根本没有的工具。

目录按 session_id 分(缺省 "default"),按 workspace.user_id 隔离租户—— 不按 run_id:run_id 每轮一个新 uuid,会把同一次会话打散成几十个目录。

预览统一 2,000 字符(40% 头 + 60% 尾),写盘和预览都先过一遍敏感信息脱敏。

Hooks 集成

压缩前后可以通过 Hooks 插入自定义逻辑:

from agentica.hooks import RunHooks

class CompactionTracker(RunHooks):
    async def on_pre_compact(self, agent, messages, **kwargs):
        print(f"Before: {len(messages)} messages")

    async def on_post_compact(self, agent, messages, **kwargs):
        print(f"After: {len(messages)} messages")

自动压缩触发

CompressionManager.auto_compact 在以下条件触发:

  1. 当前 token 数达到 min(compact_token_limit or ∞, int(window × 0.95))(notes 仍空时先 fallback 催写,推迟到约 99%)
  2. 用户执行 /compact(始终强制换窗;多余参数不再当摘要指令)
  3. provider 返回 prompt_too_long 之后的 reactive 换窗

本地换窗几乎不会失败;失败则对话不变。

观测压缩是否发生

换窗会从 prompt 里拿掉早期轮次(JSONL 仍在)。SDK 调用方没有 CLI 的事件回调,所以次数直接挂在响应上:

response = await agent.run("...")
if response.context_compactions:
    logger.info(f"本轮换了 {response.context_compactions} 次活动窗")

Layer 2 与 prompt_too_long 之后的 reactive 都会计数;Layer 1 淘汰是免费且可通过重跑工具恢复的,不计数。

下一步