上下文压缩与 MoA
职责
两件事:① 当历史消息逼近 provider 上下文上限时,把旧消息摘要/裁剪掉腾出预算(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工具调用与路径提及提取: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 只退还压缩本身省下的那一两次工具调用迭代,「上限还是那个上限」。
边界与失败
- 压缩不前进:
_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 是正交增强,可开关。两者都不改变主循环结构,只在合适时机切入。