上下文壓縮 (context compression) 與 MoA
職責
兩件事:① 當歷史訊息逼近 provider 上下文上限時,把舊訊息摘要/裁剪掉騰出預算 (budget)(context_compressor);② 可選地用 Mixture-of-Agents 路徑聚合多個模型的輸出(moa_loop)。兩者都在主迴圈內被呼叫,是「讓長對話跑得下去」和「讓回答更穩」的支撐件。
關鍵檔案
context_compressor 模組— 壓縮總入口(約 3700 行)預算估算:190-242—_estimate_msg_budget_tokens/_serialized_length_for_budget訊息清洗:136-155—_fresh_compaction_message_copy/_strip_persistence_markers工具呼叫 (tool dispatch) 與路徑提及提取:337-360—_extract_tool_call_name_and_args/_collect_path_mentions內容裁剪:472-515—_append_text_to_content/_strip_image_parts_from_partsmoa_loop— Mixture-of-Agents 聚合IterationBudget— 壓縮 refund 與預算聯動
資料流
- 主迴圈每輪評估是否需要壓縮(見
_should_run_preflight_estimate:213)。 - 命中閾值後呼叫 context_compressor:
- 估算每條訊息占用預算(
agent/context_compressor.py:419) - 對舊訊息做清洗拷貝(
agent/context_compressor.py:136)、剝除持久化標記(agent/context_compressor.py:155) - 保留工具呼叫名/參數與路徑提及,避免摘要丟失關鍵操作(
agent/context_compressor.py:337) - 裁剪圖片段與超長內容(
agent/context_compressor.py:472)
- 估算每條訊息占用預算(
- 壓縮產生的「新空間」透過
iteration_budget.refund()(agent/iteration_budget.py)部分退還,允許主迴圈多跑幾輪。 - 若啟用 MoA,
moa_loop在本輪並行呼叫多個 provider 並聚合,作為對主回答的增強或仲裁。
預算估算是壓縮決策的基礎。下面是 agent/context_compressor.py:419-437 的核心:
def _estimate_msg_budget_tokens(msg: dict) -> int:
"""Counts the full ``tool_call`` envelope, not just ``function.arguments``
— counting only the arguments undercounted parallel-tool turns by 2-15x
(#28053). Also counts provider replay fields (``codex_reasoning_items``)."""
content_len = _content_length_for_budget(msg.get("content") or "")
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
tokens += len(str(tc)) // _CHARS_PER_TOKEN
for key in _REPLAY_BUDGET_KEYS:
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
return tokens兩個踩過的坑都在註解裡:① 只算 function.arguments 字串會少算 2-15 倍(並行工具呼叫場景);② 不算 codex_reasoning_items 這類 replay 欄位,會讓「看著小但其實很大」的 assistant 訊息被錯保護,壓縮完仍貼近上限,然後不斷重壓(#55572)。
清洗和持久化標記剝除見 agent/context_compressor.py:136-160:
def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
"""Shallow .copy() propagates ``_db_persisted``; new child session then
skips every row (#57491). Strip at every copy site."""
fresh = msg.copy()
fresh.pop(_DB_PERSISTED_MARKER, None)
return fresh
def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
"""Terminal sweep — invariant holds regardless of intermediate copy sites."""
for msg in messages:
if isinstance(msg, dict):
msg.pop(_DB_PERSISTED_MARKER, None)「兩道防線」:_fresh_compaction_message_copy 在每個 copy 點就近剝除,_strip_persistence_markers 在 compress() 末尾再做一次完整 sweep。前者意圖清晰,後者結構性兜底。圖片裁剪(見 agent/context_compressor.py:497-515 的 _strip_image_parts_from_parts)邏輯類似——把 image/image_url/input_image part 替換成文字佔位符,沒圖時回傳 None 讓呼叫方跳過替換,避免每條訊息都產生無意義的新 list。
設計動機
為什麼用「估算」而不是精確 token 計數?provider 的 tokenizer 不公開(尤其 reasoning 模型的隱藏欄位),tiktoken 既慢又不準。模組走 char-based 估算 +10 字元開銷的固定公式,夠在「要不要壓」和「tail 怎麼保護」的決策上穩定偏保守——它不需要精確,只需要和 preflight 的估算用同一把尺子(見turn_context 的 _should_run_preflight_estimate),否則會出現「preflight 說不用壓、壓縮內部說壓不動」的死結。
為什麼保留工具呼叫名和路徑提及?LLM 摘要舊訊息時容易把「呼叫過 write_file(/tmp/foo.py)」這種關鍵操作吞掉。下一輪模型讀到「之前討論過檔案」,但不知道寫到了哪個路徑,就會重複建立或讀錯位置。_extract_tool_call_name_and_args / _collect_path_mentions 把硬資訊貼到 summary 旁邊,代價是幾行額外 token,收益是後續工具呼叫不會失憶。
refund 退給 iteration_budget 而不是直接加預算,是為了不讓 max_iterations 形同虛設——壓縮一輪就加幾輪,長對話能無限跑。refund 只退還壓縮本身省下的那一兩次工具呼叫迭代 (iteration),「上限還是那個上限」。
邊界與失敗
- 壓縮不前進:
_compression_made_progress要求 token 減少 >5% 才算進展。某輪只省 2% 會被判為「沒進展」,避免多輪空轉;此時主迴圈改走 auto-reset 開新會話。 - 持久化標記殘留:壓縮產物若帶
_db_persisted,新 child session 的state.db會跳過它,compacted transcript 在 DB 裡完全遺失(#57491)。terminal sweep 在compress()末尾強制掃一遍兜底。 - summary 被當成新指令:弱模型會把 summary 裡的「## Active Task」當成新使用者請求回答(
#11475、#14521)。_SUMMARY_END_MARKER加顯式分隔線;alternation 邊界場景下用_MERGED_PRIOR_CONTEXT_HEADER/_MERGED_SUMMARY_DELIMITER雙重包裹避免歧義。 - 歷史 prefix 復活:
_HISTORICAL_SUMMARY_PREFIXES保存舊版本 summary 前綴。resume 進新 lineage 時舊前綴被一併剝除,防止 stale directive 嵌在 body 裡繼續劫持回覆。
小結
壓縮器是長對話的生命線,核心權衡是「丟什麼、留什麼」——它優先保留工具呼叫與路徑提及,因為丟了這些會讓後續輪次「失憶」。MoA 是正交增強,可開關。兩者都不改變主迴圈結構,只在合適時機切入。