powered by TechFeed
表示モード
Deep Dive

LLMのプロンプト肥大化を「モデルなし・embedding不要」で30%削減するパイプラインを作った — 依存関係を壊さず安全に刈り込む仕組み

7月12日、Toward Data ScienceのEmmimalが「Long Context Isn't Free — I Built a Safe Prompt-Pruning Layer That Makes LLM Systems Work」と題した記事を公開した。この記事では、LLMへ送るプロンプトの肥大化を、モデルもembeddingも使わない決定論的なパイプラインで安全に削減する実装手法について詳しく紹介されている。

7月12日、Toward Data ScienceのEmmimalが「Long Context Isn't Free — I Built a Safe Prompt-Pruning Layer That Makes LLM Systems Work」と題した記事を公開した。この記事では、LLMへ送るプロンプトの肥大化を、モデルもembeddingも使わない決定論的なパイプラインで安全に削減する実装手法について詳しく紹介されている。


「追記専用ログ」になったプロンプトの何が問題か

長期稼働するエージェントを作ると、会話の50ターン目のプロンプトには次のようなものが混在している。

  • システムプロンプト
  • 全チャット履歴
  • 同じクエリで2回実行された古いツール出力(片方はすでに無効)
  • 同じトピックへの回帰で再取得された重複RAGチャンク
  • 20ターン前のSQLクエリ結果

技術的に「間違い」ではない。挿入された瞬間はすべて意味があった。問題は何も削除されないことだ。プロンプトは過去のすべてを記録するappend-onlyなログになり、毎ターンそのまま丸ごとモデルへ送られる。

これはコストだけの問題ではない。入力が長くなるほど推論性能は低下し、長いコンテキストの中央に埋まった情報はエッジ付近の情報より活用されにくいことが研究で示されている。

なぜ単純なトランケーションでは駄目なのか

「最後のN件だけ残す」は1行で書ける。しかし依存関係を無視するため、次のような問題が起きる。

Turn 3:   User: My preferred output format is CSV.
Turn 4:   Assistant: Got it.
...
Turn 47:  User: Export the results.

ウィンドウを最後の20ターンに絞ると、Turn 47の時点でTurn 3が消える。アシスタントはCSV指定を忘れる。これは「古い文脈」ではなく、後続ターンが依存する「まだ生きている文脈」だ。位置情報だけで古さを判断する限り、この区別は不可能である。

Emmimalはこの設計制約を解くために、元記事中でOSのページ管理になぞらえたアーキテクチャを構築している。OSがRAMのどのページを退避させるかを常に判断するように、プロンプトにも「何がまだ必要か」を管理するコンポーネントが必要だという発想だ。

3パスの決定論的パイプライン

構築されたPrompt Prunerは、モデル呼び出しもembeddingも使わない。標準ライブラリのdataclass・regex・dict lookupだけで動作する。同じ入力に対して常に同じ出力を保証する完全決定論的な設計だ。

各メッセージは以下のデータ構造で表現される。

@dataclass
class Message:
    id: str
    role: str
    content: str
    turn: int
    tool_call_key: Optional[str] = None
    expires_after_turn: Optional[int] = None
    defines_keys: list = field(default_factory=list)

Pass 1:期限切れコンテキストの除去

同じキー(同じ検索クエリ、同じSQLルックアップ)でツールが複数回呼ばれた場合、最新の結果だけが信頼できる。それ以前の出力はすべて除去する。

def _pass1_expired_context_elimination(self, messages):
    last_occurrence = {}
    for m in messages:
        if m.tool_call_key:
            last_occurrence[m.tool_call_key] = m.id
    kept, removed = [], []
    for m in messages:
        if m.tool_call_key and last_occurrence[m.tool_call_key] != m.id:
            removed.append(m)
        else:
            kept.append(m)
    return kept, removed

Pass 2:重複コンテキストの除去

RAGパイプラインはユーザーが同じトピックに戻るたびに同じチャンクを再取得する。ホワイトスペースと大小文字を正規化し、最初の出現だけ残して後続の重複を削除する。

def _pass2_duplicate_context_removal(self, messages):
    seen_hashes = set()
    kept, removed = [], []
    for m in messages:
        normalized = re.sub(r'\s+', ' ', m.content.lower().strip())
        content_hash = hash(normalized)
        if content_hash in seen_hashes:
            removed.append(m)
        else:
            seen_hashes.add(content_hash)
            kept.append(m)
    return kept, removed

Pass 3:依存関係の復元(そして設計を変えたバグ)

最も重要なのがこのパスだ。Pass 1・2が削除したメッセージの中に、後続ターンがまだ依存しているものがあれば、それを復元する。

仕組みはシンプルな識別子マッチングだ。メッセージがDEFINE:タグで事実を定義し、後続メッセージがREF:で参照する。削除されたDEFINEが残っているREFから参照されていれば、復元する。

def _pass3_dependency_restoration(self, all_messages, kept_messages, removed_messages):
    kept_ids = {m.id for m in kept_messages}
    by_id = {m.id: m for m in all_messages}
    key_definer = {}
    for m in all_messages:
        for key in m.defines_keys:
            key_definer[key] = m.id
    referenced_keys = set()
    for m in kept_messages:
        referenced_keys.update(m.references())
    restored = []
    for key in referenced_keys:
        definer_id = key_definer.get(key)
        if definer_id and definer_id not in kept_ids:
            restored_msg = by_id[definer_id]
            kept_messages.append(restored_msg)
            kept_ids.add(definer_id)
            restored.append(restored_msg)
    kept_messages.sort(key=lambda m: (m.turn, m.id))
    return kept_messages, restored

ここで著者は重大なバグを発見する。初回のベンチマーク全15構成で「Restored (deps): 0」が並んだ。一見完璧に見えたが、実態はPass 3が一度も発火していないことを意味していた。

原因は合成データの設計ミスだった。DEFINEマーカーはユーザーメッセージにしか付けておらず、Pass 1・2が削除するのはツール出力とRAGチャンクだけ。2つのカテゴリが交差しないため、Pass 3が動く状況が一度も生まれなかった。

修正後、ツール出力にもDEFINEを付与できるように変更したところ、最小規模のtool-agent会話で2件、最大規模では127件の復元が発生し始めた。必須ファクトの保持率は依然100%だが、「テストが落ちる可能性のあるコードが実際に落ちなかった」という意味に変わった。

なお制限事項として、依存検出はあくまで識別子の完全一致であり、ユーザーが言い換えた場合には対応できない。それを解決するにはembeddingかLLM呼び出しが必要で、それはこのパイプラインの設計範囲外だと明示されている。

ベンチマーク結果

ワークロードは3種類、会話サイズは5段階、合計15構成。

ワークロード ターン数 削減率 処理時間
通常チャット 50〜2,000 約2〜4% 最大8.27ms
RAGアシスタント 50〜2,000 約27〜32%
ツールエージェント 50〜2,000 約33〜34%

2,000ターン・131,000トークンの規模でも前処理は50ms未満。全構成で必須ファクトの保持率は100%、冪等性(2回実行しても結果が変わらない)も確認済みだ。

通常チャットの削減率が2〜4%に留まるのは、このパイプラインが除去の対象とする重複ツール出力や再取得RAGチャンクが、純粋な会話ログにはほとんど含まれないためだ。裏を返せば、ツール呼び出しやRAG検索を多用するシステムほど恩恵が大きく、そうした構成では30%超のトークン削減が見込める。プロンプトの構造次第で効果は大きく異なる。

コード全体と35本のテスト、ターミナル出力はすべてGitHubリポジトリで公開されている。


詳細はLong Context Isn't Free — I Built a Safe Prompt-Pruning Layer That Makes LLM Systems Workを参照していただきたい。