Skip to content

上下文压缩与 MoA

源码版本v2026.7.20

职责

两件事:① 当历史消息逼近 provider 上下文上限时,把旧消息摘要/裁剪掉腾出预算(context_compressor);② 可选地用 Mixture-of-Agents 路径聚合多个模型的输出(moa_loop)。两者都在主循环内被调用,是「让长对话跑得下去」和「让回答更稳」的支撑件。

关键文件

数据流

  1. 主循环每轮评估是否需要压缩(见 _should_run_preflight_estimate:213)。
  2. 命中阈值后调用 context_compressor:
  3. 压缩产生的「新空间」通过 iteration_budget.refund()(agent/iteration_budget.py)部分退还,允许主循环多跑几轮。
  4. 若启用 MoA,moa_loop 在本轮并行调用多个 provider 并聚合,作为对主回答的增强或仲裁。

预算估算是压缩决策的基础。下面是 agent/context_compressor.py:419-437 的核心:

python
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:

python
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_markerscompress() 末尾再做一次完整 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 是正交增强,可开关。两者都不改变主循环结构,只在合适时机切入。

非官方社区学习站,内容以 MIT 许可的 NousResearch/hermes-agent 源码为依据。