Skip to content

Compression de contexte et MoA

源码版本v2026.7.20

Responsabilité

Deux choses : (1) quand les messages d'historique approchent de la limite de contexte (context) du provider, résumer ou rogner les anciens messages pour récupérer du budget (budget) (context_compressor) ; (2) en option, utiliser un chemin Mixture-of-Agents pour agréger les sorties de plusieurs modèles (moa_loop). Les deux sont appelés depuis la boucle principale et sont les composants de support qui « font tenir les longues conversations » et « stabilisent les réponses ».

Fichiers clés

Flux de données

  1. La boucle principale évalue à chaque tour (turn) s'il faut compresser (voir _should_run_preflight_estimate:213).
  2. Au seuil déclenché, appel au context_compressor :
  3. L'« espace neuf » issu de la compression est partiellement remboursé via iteration_budget.refund() (agent/iteration_budget.py), ce qui permet à la boucle principale de faire quelques tours de plus.
  4. Si MoA est activé, moa_loop appelle en parallèle plusieurs providers et agrège leurs sorties, comme amplification ou arbitrage de la réponse principale.

L'estimation du budget est la base de la décision de compression. Voici le cœur de 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

Deux pièges rencontrés sont dans les commentaires : (1) compter seulement la chaîne function.arguments sous-estime de 2 à 15x (cas des appels d'outils (tools) parallèles) ; (2) ignorer les champs replay comme codex_reasoning_items laisse des messages assistant « petits en apparence mais gros en réalité » mal protégés, et la compression termine encore proche de la limite, puis recommence à compresser en boucle (#55572).

Le nettoyage et le retrait des marqueurs de persistance sont dans 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)

« Deux lignes de défense » : _fresh_compaction_message_copy retire au plus près à chaque site de copie ; _strip_persistence_markers refait un sweep complet à la fin de compress(). La première exprime l'intention, la seconde est un filet structurel. Le rognage d'images (voir _strip_image_parts_from_parts dans agent/context_compressor.py:497-515) suit la même logique — remplacer les parts image / image_url / input_image par un placeholder texte, et renvoyer None quand il n'y a pas d'image pour que l'appelant saute le remplacement, afin d'éviter de créer à chaque message une nouvelle list vide.

Mot de conception

Pourquoi « estimer » plutôt que compter exactement les tokens ? Le tokenizer du provider n'est pas public (surtout les champs cachés des modèles reasoning), tiktoken est lent et imprécis. Le module utilise une formule fixe d'estimation char-based +10 caractères de overhead — suffisante pour décider « faut-il compresser » et « comment protéger la queue » en restant stable et conservatrice. Pas besoin d'être exact, juste d'utiliser la même règle que l'estimation preflight (voir _should_run_preflight_estimate dans turn_context), sinon on tombe dans un deadlock : « preflight dit pas besoin de compresser, le compresseur dit qu'il ne peut pas ».

Pourquoi conserver les noms d'appels d'outils et les mentions de chemins ? Quand le LLM résume les anciens messages, il a tendance à avaler des opérations clés comme « a appelé write_file(/tmp/foo.py) ». Au tour suivant, le modèle lit « on a parlé d'un fichier » sans savoir où il a été écrit, et recrée ou lit au mauvais endroit. _extract_tool_call_name_and_args / _collect_path_mentions collent l'information dure à côté du summary ; le coût est quelques tokens supplémentaires, le gain est que les appels d'outils suivants ne deviennent pas amnésiques.

Le remboursement va à iteration_budget plutôt qu'ajouté directement au budget, pour que max_iterations ne devienne pas lettre morte — compresser un tour puis ajouter plusieurs tours, et les longues conversations tournent à l'infini. Le refund ne rend que la ou les deux itérations (iterations) d'appels d'outils économisées par la compression elle-même : « la limite reste la limite ».

Limites et échecs

  • Compression qui ne progresse pas : _compression_made_progress exige une baisse de tokens > 5 % pour compter comme progrès. Un tour qui n'en économise que 2 % est jugé « sans progrès », pour éviter une rotation à vide sur plusieurs tours ; la boucle principale bascule alors sur auto-reset pour ouvrir une nouvelle session (session).
  • Marqueur de persistance résiduel : si le produit de compression porte _db_persisted, le state.db du nouveau child session le saute, et le transcript compacté disparaît complètement de la DB (#57491). Le sweep terminal à la fin de compress() force un passage final en filet de sécurité.
  • Summary pris pour une nouvelle instruction : un modèle faible peut prendre un « ## Active Task » du summary pour une nouvelle requête utilisateur (#11475, #14521). _SUMMARY_END_MARKER ajoute une ligne de séparation explicite ; aux frontières d'alternation, un double wrapping via _MERGED_PRIOR_CONTEXT_HEADER / _MERGED_SUMMARY_DELIMITER lève l'ambiguïté.
  • Résurrection de prefix historique : _HISTORICAL_SUMMARY_PREFIXES conserve les anciens prefixes de summary. En entrant dans un nouveau lineage, l'ancien prefix est retiré pour empêcher une directive stale nichée dans le body de continuer à détourner la réponse.

Résumé

Le compresseur est la bouée de sauvetage des longues conversations ; le compromis central est « quoi jeter, quoi garder » — il conserve en priorité les appels d'outils et les mentions de chemins, car les perdre rendrait les tours suivants « amnésiques ». MoA est une amplification orthogonale, activable. Aucun des deux ne change la structure de la boucle principale : ils s'insèrent seulement au bon moment.

Site d'apprentissage communautaire non officiel. Basé sur le code source de NousResearch/hermes-agent (licence MIT).