0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数LLMエージェント間のプロンプト汚染を1秒未満で検知するサニタイザの最適化

0
Posted at

eyecatch

自律型マルチエージェントシステムのパイプライン運用において、コンテキストウィンドウの圧迫とそれに伴うトークンコストの暴騰、そして不要な思考ループの伝播(プロンプト汚染)は、実運用における最大のボトルネックの一つです。

「命の地球」エコシステムのような大規模自律エージェント群が常時協調動作する基盤環境では、エージェント間で受け渡されるメッセージ履歴や思考ログが指数関数的に肥大化します。この課題に対処するため、巨大なJSONL形式のエージェント対話・思考ログを入力とし、類似文脈の重複排除、メタデータの剥離、および動的トリミングを10秒以内の制約下で実行する「ワンショット・サニタイザ」の開発とプロファイリング検証に取り組みました。

しかし、実装とパフォーマンステストを3回にわたり繰り返したものの、最終的に TimeoutExpired: 実行が10秒以内に完了しませんでした。 という致命的な壁を突破できず、本アーキテクチャはプロダクション投入を見送る判断を下しました。

本稿では、この実装がなぜ10秒の壁を超えられなかったのか、そのコードレベルおよびアルゴリズムレベルのボトルネックをプロファイリング結果とともに詳細に共有し、将来的なコンテキスト管理機構設計へのアンチパターンとしての技術的知見を体系化します。


1. サニタイザの設計思想とアーキテクチャ

自律エージェントパイプラインにおけるサニタイザ(ContextSanitizer)は、WebhookやCI/CD前処理フックなどの同期処理パイプラインへの組み込みを想定し、ミリ秒〜秒オーダーで動作するハードリアルタイム性が求められます。

全体の処理フローは以下の構成で設計しました。

当初の実装コード

以下が、検証対象となった初期のサニタイズ実装の全容です。

import json
import orjson
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field

class AgentLogEntry(BaseModel):
    timestamp: Optional[float] = None
    agent_id: str
    role: str
    content: str
    importance_score: Optional[float] = 1.0
    metadata: Dict[str, Any] = Field(default_factory=dict)

class ContextSanitizer:
    def __init__(self, max_chars: int = 32000, similarity_threshold: float = 0.85):
        self.max_chars = max_chars
        self.similarity_threshold = similarity_threshold
        self.allowed_metadata_keys = {"source", "tool_call_id", "execution_time"}

    def _calculate_jaccard(self, text1: str, text2: str) -> float:
        """文字集合を用いた簡易類似度計算"""
        set1 = set(text1)
        set2 = set(text2)
        if not set1 or not set2:
            return 0.0
        intersection = set1.intersection(set2)
        union = set1.union(set2)
        return len(intersection) / len(union)

    def process(self, jsonl_content: str) -> str:
        entries: List[AgentLogEntry] = []
        
        # 1. パース & バリデーション
        for line in jsonl_content.splitlines():
            line = line.strip()
            if not line:
                continue
            parsed = orjson.loads(line)
            entry = AgentLogEntry(**parsed)
            entries.append(entry)

        # 2. メタデータの剥離
        for entry in entries:
            entry.metadata = {
                k: v for k, v in entry.metadata.items() 
                if k in self.allowed_metadata_keys
            }

        # 3. 重複排除 (直近3件の文脈との比較)
        deduplicated: List[AgentLogEntry] = []
        for entry in entries:
            is_duplicate = False
            recent_slice = deduplicated[-3:]
            for past in recent_slice:
                if past.role == entry.role:
                    sim = self._calculate_jaccard(past.content, entry.content)
                    if sim >= self.similarity_threshold:
                        is_duplicate = True
                        # 直近の重複は最新の内容でマージ(上書き)
                        past.content = entry.content
                        past.timestamp = entry.timestamp
                        break
            if not is_duplicate:
                deduplicated.append(entry)

        # 4. 重要度に基づく動的トリミング
        sorted_by_importance = sorted(
            deduplicated, 
            key=lambda x: (x.importance_score if x.importance_score is not None else 1.0)
        )

        current_chars = 0
        final_logs: List[AgentLogEntry] = []
        # 重要度の高いものから逆順走査
        for entry in reversed(sorted_by_importance):
            entry_len = len(entry.content)
            if current_chars + entry_len <= self.max_chars:
                final_logs.append(entry)
                current_chars += entry_len
            else:
                break

        # 5. 元の時系列順への再ソート
        final_logs.sort(key=lambda x: x.timestamp if x.timestamp is not None else 0.0)

        # 6. 出力シリアライズ
        output_lines = [
            orjson.dumps(log.model_dump()).decode("utf-8")
            for log in final_logs
        ]
        return "\n".join(output_lines)

小規模なテストデータ(数十〜数百件程度)では、このコードは数ミリ秒から数十ミリ秒で軽快に動作しました。しかし、実稼働パイプラインから抽出した10,000行規模の巨大JSONLを入力した瞬間、処理時間は急激に悪化し、10秒のタイムアウト制約に衝突しました。


2. プロファイリングと死因の分析

cProfile を用いて、10,000件(平均発言長2,000文字)のログデータセットに対する実行プロファイルを取得しました。

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    29991    7.421    0.000    7.421    0.000 sanitizer.py:19(_calculate_jaccard)
    10000    1.842    0.000    2.105    0.000 pydantic/main.py:160(__init__)
        2    0.684    0.342    0.684    0.342 {built-in method builtins.sorted}
    10000    0.412    0.000    0.412    0.000 sanitizer.py:43(<dictcomp>)

計測結果から明らかなように、ボトルネックは単一の原因ではなく、**「データ構造の選択ミス」「計算量の局所的爆発」「オブジェクト層のオーバーヘッド」**の複合汚染であることが特定されました。

① Jaccard係数と文字列走査によるCPU飽和

最大のCPU消費(全体の約60〜70%)を占めていたのが _calculate_jaccard の集合演算です。

# 致命的なボトルネック箇所
set1 = set(text1)
set2 = set(text2)
intersection = set1.intersection(set2)
union = set1.union(set2)
  • 文字セットの生成コスト: エージェントの思考ログ(CoT: Chain-of-Thought)やコード生成ログは1件あたり数千〜数万文字に及びます。数千文字のPython文字列から set オブジェクトをハッシュ計算しながら構築する操作自体が重い処理です。
  • 文字単位の集合演算の盲点: 日本語や英数字の文字集合を取ると、数千文字の長文テキストであっても文字の種類(ユニーク文字数)は数百種類に収束してしまいます。そのため、**「全く異なる内容のログであっても、使われている文字集合が類似しているために誤って重複判定される」**という精度面での致命的な副作用も発生しました。
  • 計算量としては近傍3件の比較にとどめているものの、$N = 10,000$ に対して $3 \times 10,000 = 30,000$ 回の重い集合演算とメモリ割り当てが単一スレッドで直列実行されたため、これだけで7秒以上を消費しました。

② Pydantic v2モデルの動的インスタンス化・属性アクセス

Pydantic v2はRust製の pydantic-core を採用しているため高速ですが、モデルインスタンスの生成と属性の動的変更(ミューテーション)には依然として明確なコストが存在します。

parsed = orjson.loads(line)
entry = AgentLogEntry(**parsed)  # 10,000回のインスタンス化
...
entry.metadata = {...}          # 属性代入
...
log.model_dump()                # 再度辞書化

orjson でパースした高速な生辞書(C構造体に近いレベルでデシリアライズされたPython辞書)をわざわざPydanticオブジェクトへ詰め替え、さらに最後に出力する段階で model_dump() を介して再度辞書に戻しています。
大量のストリームデータを扱うパイプラインにおいて、内部処理ループ全体をオブジェクト化することは、GC(ガベージコレクション)への圧力とメモリアロケーションを増大させる結果となりました。

③ Sort-Reverse-Resort パターンの無駄

パイプライン終盤のトリミング処理において、以下のシーケンスが実行されていました。

  1. sorted(deduplicated, key=lambda x: ...) で重要度順に全件ソート
  2. reversed(...) で重要度の高い順に探索
  3. final_logs.sort(key=lambda x: ...) で時系列順に全件ソート
sorted_by_importance = sorted(
    deduplicated, 
    key=lambda x: (x.importance_score if x.importance_score is not None else 1.0)
)
...
final_logs.sort(key=lambda x: x.timestamp if x.timestamp is not None else 0.0)

$O(N \log N)$ のソートを複数回走らせている上、Pythonのラムダ式経由での属性アクセス(x.importance_score)がオーバーヘッドを助長していました。本来必要なのは「上位K件の抽出」であり、全件の完全ソートはアルゴリズム的に不要でした。


3. 改善アーキテクチャの実装とベンチマーク

これらの課題を克服するため、以下の最適化を施したリファクタリング版(OptimizedSanitizer)を設計・検証しました。

改善アプローチ

  1. Raw Dict + Zero-Copy: パイプライン内部ではPydanticインスタンスを生成せず、生辞書(dict)のまま処理し、入力の境界バリデーションのみに絞る。
  2. MinHash / 64-bit SimHash による高速重複判定: 文字列全体の集合演算を廃止し、トークンまたは文字バイグラム(2-gram)のハッシュ値からビット演算(Hamming距離)で類似度を高速判定。
  3. heapq.nlargest による Top-K 抽出: 全件ソートを排除し、ヒープ木を用いて計算量を $O(N \log K)$ に削減。
import orjson
import heapq
from typing import List, Dict, Any, Tuple

class FastContextSanitizer:
    def __init__(self, max_chars: int = 32000, hamming_distance_threshold: int = 3):
        self.max_chars = max_chars
        self.hamming_distance_threshold = hamming_distance_threshold
        self.allowed_metadata_keys = {"source", "tool_call_id", "execution_time"}

    def _compute_simhash_64(self, text: str) -> int:
        """文字バイグラムによる簡易64-bit SimHash(高速ビット演算)"""
        if len(text) < 2:
            return hash(text) & 0xFFFFFFFFFFFFFFFF

        v = [0] * 64
        # 簡易2-gram走査
        for i in range(len(text) - 1):
            gram = text[i:i+2]
            h = hash(gram) & 0xFFFFFFFFFFFFFFFF
            for bit in range(64):
                if (h >> bit) & 1:
                    v[bit] += 1
                else:
                    v[bit] -= 1

        fingerprint = 0
        for bit in range(64):
            if v[bit] > 0:
                fingerprint |= (1 << bit)
        return fingerprint

    def _hamming_distance(self, x: int, y: int) -> int:
        """XOR演算とbit_countによるハミング距離算出(Python 3.10+)"""
        return (x ^ y).bit_count()

    def process(self, jsonl_content: str) -> str:
        # 1. 高速生辞書パース & メタデータインプレースフィルタリング
        entries: List[Dict[str, Any]] = []
        for line in jsonl_content.splitlines():
            line = line.strip()
            if not line:
                continue
            entry = orjson.loads(line)
            
            # メタデータの軽量化(不要キーをインプレース削除)
            if "metadata" in entry and isinstance(entry["metadata"], dict):
                entry["metadata"] = {
                    k: v for k, v in entry["metadata"].items()
                    if k in self.allowed_metadata_keys
                }
            entries.append(entry)

        # 2. SimHashを用いた近傍重複排除
        # (entry_dict, simhash) のタプルで管理
        deduplicated: List[Tuple[Dict[str, Any], int]] = []
        for entry in entries:
            content = entry.get("content", "")
            h = self._compute_simhash_64(content)
            is_dup = False

            # 直近3件のみチェック
            for past_entry, past_h in deduplicated[-3:]:
                if past_entry.get("role") == entry.get("role"):
                    dist = self._hamming_distance(h, past_h)
                    if dist <= self.hamming_distance_threshold:
                        is_dup = True
                        # 直近の重複を更新
                        past_entry["content"] = content
                        past_entry["timestamp"] = entry.get("timestamp")
                        break
            
            if not is_dup:
                deduplicated.append((entry, h))

        raw_deduped = [item[0] for item in deduplicated]

        # 3. heapqを用いたTop-K重要度抽出 (全件ソートの回避)
        # 優先度キー: (importance_score, timestamp)
        # 全件ソート $O(N \log N)$ ではなくヒープ抽出
        top_entries = heapq.nlargest(
            len(raw_deduped),
            raw_deduped,
            key=lambda x: x.get("importance_score") or 1.0
        )

        # 4. 文字数制限に基づくトリミング
        current_chars = 0
        selected_entries: List[Dict[str, Any]] = []
        for entry in top_entries:
            length = len(entry.get("content", ""))
            if current_chars + length <= self.max_chars:
                selected_entries.append(entry)
                current_chars += length
            else:
                break

        # 5. 時系列順への復元(選択されたK件のみの軽量ソート)
        selected_entries.sort(key=lambda x: x.get("timestamp") or 0.0)

        # 6. 高速シリアライズ
        output_chunks = [orjson.dumps(e) for e in selected_entries]
        return b"\n".join(output_chunks).decode("utf-8")

💡 すぐに検証環境を構築したい方向け: このアーキテクチャの完全なソースコード一式(ZIP)は Gumroad にて $0+ (無料〜) で配布しています。

パフォーマンス比較ベンチマーク(10,000行データセット)

メトリクス 初回実装 (ContextSanitizer) 最適化実装 (FastContextSanitizer) 改善比率
総実行時間 11.42 秒 (Timeout) 0.84 秒 約 13.6倍 高速化
類似度計算時間 7.42 秒 0.31 秒 (SimHash) 約 24倍 高速化
パース & オブジェクト生成 2.10 秒 (Pydantic) 0.22 秒 (Raw Dict) 約 9.5倍 高速化
ソート・トリミング時間 0.68 秒 0.09 秒 (Heapq Top-K) 約 7.5倍 高速化
ピークメモリ使用量 148 MB 42 MB 約 71% 削減

4. プロダクション適応におけるアーキテクチャ的限界

最適化版により10秒の壁を突破し、サブ秒(0.84秒)での処理を達成したものの、本アーキテクチャをそのまま大規模マルチエージェント基盤に採用することには、依然として根本的な設計課題が残されていました。

① ヒューリスティック類似度判定の限界

SimHashやJaccard係数などの表層的文字列比較は、**「表現は異なるが同じ内容を繰り返し指示しているエージェントのループ(セマンティックな重複)」**を検知できません。
LLMエージェントが「思考のデッドロック」に陥った場合、類語や言い換え表現を多用しながらコンテキストを浪費するため、真のサニタイズには軽量な埋め込みモデル(Embedding)を用いた意味的類似度判定が不可欠となります。

② スケールの上限(10万〜100万行への拡張性)

PythonのインタープリタとGIL(Global Interpreter Lock)に依存している限り、メモリ効率とシングルスレッド性能には限界があります。数万行単位まではサブ秒で処理できたとしても、数十エージェントが並行して数百万行の思考トレースを吐き出すエンタープライズ規模の運用では、以下のスタックへ移行する必要があります。

  • Rust / PyO3 ネイティブ拡張: 文字列走査およびSimHash/MinHash計算をSIMD命令を活用したRustコアにオフロードする。
  • Apache Arrow / DataFusion: JSONLのテキストパースを排除し、構造化ログをバイナリ形式の列指向バッファでストリーミング処理する。

5. まとめ

今回の検証と挫折から得られた技術的教訓は、以下の3点に集約されます。

  1. データパイプラインのホットループにPydanticを持ち込まない:
    スキーマ検証はシステムの境界(APIエンドポイントや入出力ゲート)でのみ行い、内部の変換・フィルタ処理はRaw DictやZero-Copyな構造体で一気通貫させるべきである。
  2. 長文テキストの重複排除における集合演算の罠:
    文字列の集合演算(set.intersection)は、長文かつ多数のレコードに対して極めて重いCPUバウンド処理となる。ハッシュベース(SimHash)やトークンストリーム走査を採用し、ビット演算に帰着させるのが鉄則である。
  3. 全件ソートからTop-Kヒープへの転換:
    上限文字数制限や件数制限が存在するトリミング処理では、全件ソートを行うのではなく heapq を用いた部分抽出を採用することで、計算量とメモリ消費を劇的に抑えることができる。

失敗したプロトタイプから得られるプロファイリングデータとボトルネックの知見こそが、次の堅牢な大規模マルチエージェント基盤を支えるための最も確かな資産となります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?