0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントの暴走を止めたガードレールをどう作ったか

0
Last updated at Posted at 2026-06-11

運用3日目、私のエージェントは、ある顧客のメールアドレスを別の顧客との会話に漏らしました。これはカンファレンスの講演で出てくるような仮想シナリオではありません。本番環境で動いていた私のコードが、テストしたことのない振る舞いをした、という話です。

私は LangGraph と GPT-4o でサポートエージェントを組みました。ナレッジベースを検索し、アカウント情報を引き、返信を下書きできるものです。ステージングでは見事に動いていました。本番では、ちょうど72時間で、あるユーザーの PII を別のユーザーの会話に表出させました。原因は気まずいほど単純で、モデルがデータベースの生のコンテキストをそのまま返信に含めてしまい、私のパイプラインにはそれをチェックするものが何もなかったのです。

後から見れば、直し方は明白でした。AIエージェントのフレームワークが提供するのは、オーケストレーション、ツール呼び出し、メモリです。安全性は提供してくれません。そこは自分の担当です。

なぜあなたのエージェントフレームワークにガードレールが入っていないのか

LangChain、CrewAI、LangGraph、OpenAI の Agents SDK。どれを選んでも構いません。入力バリデーション、出力フィルタリング、コスト制御を最初から備えているものは一つもありません。それらは自分で足すもの、という前提です。

そして大半のチームは、結局足しません。

なぜこれが効いてくるのか、簡単な算数で説明できます。1ステップあたりの精度が90%だとすると、5ステップのエージェントワークフローが成功するのは59%です。10ステップだと35%まで落ちます。20ステップでは12%。ガードのない各ステップは、失敗率の掛け算なのです。

ガードレールは精度を直しません。精度が崩れたときに、被害範囲(blast radius)を封じ込めるものです。「エージェントが間違った答えを返した」と「エージェントが、誰かの社会保障番号を含んだ間違った答えを返した」の違いは、出力バリデータ一つ分です。

その後の2週間、私はガードレールのスタックを作り続けました。以下のコードが、最終的にたどり着いたものです。

どのエージェントにも必要な4つのガードレール

どのエージェントにも、4つのポイントでの保護が必要です。

  1. 入力ガードレール:LLM がプロンプトを見る前に、プロンプトインジェクション攻撃を捕捉し、機微データをスクラブする。
  2. 出力ガードレール:ユーザーが見る前にレスポンスを検証し、幻覚(hallucination)や漏れたコンテキストをブロックする。
  3. コストサーキットブレーカー:ループのスタックや想定外に長い会話で、API課金が暴走するのを防ぐ。
  4. ツール呼び出しバリデータ:エージェントが許可されたツールだけを、スキーマチェックを通るパラメータで呼び出すことを確認する。

4つすべてで Python 200行未満に収まります。レイテンシのコストは1層あたり10〜50ms。代替案は、障害を顧客から教えてもらうことです。

入力ガードレール:危険なプロンプトを実行前に止める

入力バリデータは、LLM がプロンプトを見る前に走ります。やることは2つ、インジェクションの試みをブロックし、PII を秘匿(redact)することです。

import re
from dataclasses import dataclass

@dataclass
class ValidationResult:
    is_valid: bool
    reason: str = ""
    sanitized_input: str = ""

class InputGuardrail:
    INJECTION_PATTERNS = [
        r"ignore\s+(all\s+)?previous\s+instructions",
        r"you\s+are\s+now\s+a",
        r"disregard\s+(your|all)\s+(rules|instructions)",
        r"system\s*prompt\s*:",
        r"<<\s*SYS\s*>>",
    ]

    PII_PATTERNS = {
        "ssn": r"\b\d{3}-\d{2}-\d{4}\b",
        "credit_card": r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b",
        "email": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
    }

    def validate(self, user_input: str) -> ValidationResult:
        # Check for prompt injection
        for pattern in self.INJECTION_PATTERNS:
            if re.search(pattern, user_input, re.IGNORECASE):
                return ValidationResult(
                    is_valid=False,
                    reason=f"Prompt injection detected: {pattern}"
                )

        # Scrub PII from input
        sanitized = user_input
        for pii_type, pattern in self.PII_PATTERNS.items():
            sanitized = re.sub(pattern, f"[REDACTED_{pii_type.upper()}]", sanitized)

        return ValidationResult(
            is_valid=True,
            sanitized_input=sanitized
        )

これは鉄壁ではありません。本気の攻撃者なら、正規表現ベースのインジェクション検出は回避できます。ですが、よくある試みは捕捉できます。私の経験では、それが現実のインジェクション攻撃の約80%を占めます。機微データを扱う本番システムでは、正規表現のパスの上に、分類器モデル(Lakera Guard やファインチューンした DistilBERT など)を一枚重ねてください。

PII のスクラブこそ、3日目に私を救ってくれたはずの部分です。もし入力ガードレールが、あのメールアドレスをデータベースのコンテキストから会話に入る前に除去していたら、あの漏洩は起きなかったでしょう。

出力ガードレール:幻覚をユーザーが見る前に止める

出力バリデーションは、大半のチームがガードレールを丸ごとスキップする箇所です。モデルが答えを出した、もっともらしく見える、出荷だ。でも「もっともらしく見える」は、頼れる基準ではありません。

from pydantic import BaseModel, field_validator
from typing import Optional
import json

class AgentResponse(BaseModel):
    answer: str
    confidence: float
    sources: list[str]

    @field_validator("confidence")
    @classmethod
    def check_confidence(cls, v):
        if not 0.0 <= v <= 1.0:
            raise ValueError("Confidence must be between 0 and 1")
        return v

    @field_validator("answer")
    @classmethod
    def check_no_pii_leak(cls, v):
        pii_patterns = [
            r"\b\d{3}-\d{2}-\d{4}\b",  # SSN
            r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b",  # Credit card
        ]
        for pattern in pii_patterns:
            if re.search(pattern, v):
                raise ValueError("Response contains potential PII")
        return v

class OutputGuardrail:
    def __init__(self, confidence_threshold: float = 0.7):
        self.confidence_threshold = confidence_threshold

    def validate(self, raw_response: dict) -> ValidationResult:
        try:
            response = AgentResponse(**raw_response)
        except Exception as e:
            return ValidationResult(
                is_valid=False,
                reason=f"Response failed schema validation: {e}"
            )

        if response.confidence < self.confidence_threshold:
            return ValidationResult(
                is_valid=False,
                reason=f"Confidence {response.confidence} below threshold"
            )

        if not response.sources:
            return ValidationResult(
                is_valid=False,
                reason="No sources provided for claim"
            )

        return ValidationResult(is_valid=True, sanitized_input=response.answer)

Pydantic モデルは二役をこなします。構造を強制し(LLM は answer、confidence、sources を持つ JSON を返さなければならない)、同時にコンテンツチェックを走らせます(出力に PII を含めない)。バリデーションが失敗すると、私のエージェントは追加のコンテキスト付きでリトライします。「あなたの前回のレスポンスは [理由] により却下されました。もう一度試してください」と。

だんだん具体的になる指示を付けた2回のリトライで、大半のバリデーション失敗は直ります。3回失敗したら、定型のフォールバック応答を返し、インシデントとしてログに記録します。

キルスイッチ:コストとトークン予算のサーキットブレーカー

これは誰も書かないガードレールで、そして私に $400 を払わせたガードレールです。

エージェントがリトライループに入るバグがありました。ツール呼び出しが失敗し、エージェントがリトライし、リトライがわずかに違う形で失敗し、またリトライする。各リトライが推論ステップでトークンを焼き、さらにツール呼び出しも。私が気づくまで、それは一晩で6時間動き続けました。

import time
from threading import Lock

class CostCircuitBreaker:
    def __init__(
        self,
        max_tokens_per_request: int = 50_000,
        max_tokens_per_session: int = 200_000,
        max_api_calls_per_minute: int = 30,
        max_daily_spend_usd: float = 50.0,
    ):
        self.max_tokens_per_request = max_tokens_per_request
        self.max_tokens_per_session = max_tokens_per_session
        self.max_api_calls_per_minute = max_api_calls_per_minute
        self.max_daily_spend = max_daily_spend_usd

        self._session_tokens = 0
        self._minute_calls = []
        self._daily_spend = 0.0
        self._lock = Lock()

    def check_budget(self, estimated_tokens: int) -> ValidationResult:
        with self._lock:
            # Per-request limit
            if estimated_tokens > self.max_tokens_per_request:
                return ValidationResult(
                    is_valid=False,
                    reason=f"Request needs ~{estimated_tokens} tokens, "
                           f"limit is {self.max_tokens_per_request}"
                )

            # Session limit
            if self._session_tokens + estimated_tokens > self.max_tokens_per_session:
                return ValidationResult(
                    is_valid=False,
                    reason="Session token budget exhausted"
                )

            # Rate limit
            now = time.time()
            self._minute_calls = [t for t in self._minute_calls if now - t < 60]
            if len(self._minute_calls) >= self.max_api_calls_per_minute:
                return ValidationResult(
                    is_valid=False,
                    reason="API call rate limit exceeded"
                )

            # Daily spend
            estimated_cost = (estimated_tokens / 1_000_000) * 3.00  # ~GPT-4o rate
            if self._daily_spend + estimated_cost > self.max_daily_spend:
                return ValidationResult(
                    is_valid=False,
                    reason="Daily spend limit reached"
                )

            # All checks passed, record usage
            self._session_tokens += estimated_tokens
            self._minute_calls.append(now)
            self._daily_spend += estimated_cost

            return ValidationResult(is_valid=True)

per-request の上限は、最もわかりやすいケースを捕捉します。予算を一気に焼き尽くす、単一の巨大なコンテキストウィンドウです。session の上限は、1つの会話の合計支出にキャップをかけます。rate の上限は、リトライの嵐を防ぎます。そして日次支出の上限が、あなたの絶対的な天井です。

私は日次上限を $50 に設定しました。それに達したら、システムは API 呼び出しを止め、「サービス一時停止中」のレスポンスを返します。サプライズ請求書より、ダウンタイムのほうがマシです。

ツール呼び出しバリデーション:エージェントは承認していないツールを呼ぶべきではない

エージェントがデータベース、ファイルシステム、外部 API にアクセスできるとき、ツール呼び出しバリデーションは任意ではありません。これがなければ、ジェイルブレイクされた、あるいは混乱したエージェントが、破壊的な操作を実行しうるのです。

class ToolCallGuardrail:
    def __init__(self, allowed_tools: dict[str, dict]):
        """
        allowed_tools format:
        {
            "search_knowledge_base": {
                "allowed_params": ["query", "top_k"],
                "max_calls_per_session": 20,
            },
            "get_account_info": {
                "allowed_params": ["account_id"],
                "max_calls_per_session": 5,
            },
        }
        """
        self.allowed_tools = allowed_tools
        self._call_counts: dict[str, int] = {}

    def validate_tool_call(
        self, tool_name: str, params: dict
    ) -> ValidationResult:
        # Tool must be in allowlist
        if tool_name not in self.allowed_tools:
            return ValidationResult(
                is_valid=False,
                reason=f"Tool '{tool_name}' is not in the allowlist"
            )

        tool_config = self.allowed_tools[tool_name]

        # Check parameters
        for param in params:
            if param not in tool_config["allowed_params"]:
                return ValidationResult(
                    is_valid=False,
                    reason=f"Parameter '{param}' not allowed for {tool_name}"
                )

        # Check call frequency
        count = self._call_counts.get(tool_name, 0)
        if count >= tool_config["max_calls_per_session"]:
            return ValidationResult(
                is_valid=False,
                reason=f"Tool '{tool_name}' call limit reached"
            )

        self._call_counts[tool_name] = count + 1
        return ValidationResult(is_valid=True)

私はデフォルト拒否(default deny)にしました。ツールが allowlist になければ、エージェントはそれを呼べません。パラメータがそのツールの許可リストになければ、呼び出しは却下されます。これは、ジェイルブレイクの試み(モデルが execute_sqldelete_record を呼ぼうとする)と、幻覚で出てきたツール名(思っているより頻繁に起きます)の両方を捕捉します。

呼び出し頻度の上限はツールごとに分けて持っています。ツールによってリスクのプロファイルが違うからです。検索操作は安く安全で、20回呼んでも問題ありません。アカウント更新は、1会話で多くて1〜2回にすべきです。

まとめて組む:本番のガードレールスタック

この4つのピースが、実際のエージェントのパイプラインでどうつながるか:

User Input
    |
[Input Guardrail] --> reject / sanitize
    |
[Cost Circuit Breaker] --> check budget
    |
[LLM Reasoning]
    |
[Tool Call Guardrail] --> validate tool + params
    |
[Tool Execution]
    |
[LLM Response Generation]
    |
[Output Guardrail] --> validate / retry / fallback
    |
User Response

各ガードレールは自分の判断をログに記録します。却下のたびに、理由・それを引き起こした入力・タイムスタンプを持つ構造化ログのエントリが残ります。私は週に一度、これらのログにクエリを投げてパターンを探します。同じインジェクションの試みが50回現れたら、それはおそらく自動化された攻撃です。出力ガードレールが低い確信度を理由にレスポンスの15%を却下しているなら、それは上流の retrieval 品質の問題で、そちらを直す必要があります。

4つのガードレール全部の合計レイテンシオーバーヘッドは40ms未満です(ML ベースの分類器を除く)。ユーザーには見えません。

最初からやり直すなら、何を変えるか

もし一からやり直すなら、エージェントのロジックを1行書く前に、ガードレールを追加します。ここで示した骨組みは、Python でおよそ200行です。2週間かかったのは、既存システムに後付けしながら、同時に PII インシデントの調査もしていたからにすぎません。

私の予測:12か月以内に、主要なエージェントフレームワークはガードレールを一級の機能として提供するようになります。LangGraph は interrupt の仕組みで、すでにその方向に動いています。それまでは、自分でやるしかありません。

入力ガードレールとコストサーキットブレーカーから始めてください。この2つだけで、私の2つのインシデント(PII 漏洩と $400 の一晩請求)の両方を防げたはずです。出力バリデーションは、確信度の閾値をチューニングするための本番トラフィックが手に入ってから足してください。ツール呼び出しバリデーションは、エージェントが何かに書き込み権限を持っているなら足してください。

ガードレールはエージェントを賢くはしません。ですが、愚かな瞬間がインシデントに化けるのを防いでくれます。


余談:コスト管理について

この記事のサーキットブレーカーの節($400 の一晩請求)でも触れたとおり、エージェントを本番で回すと地味に効いてくるのが LLM API のコストとプロバイダ管理です。私自身は複数モデルの切り替えや課金の一本化に EvoLink のようなゲートウェイを使っています。Claude / GPT / その他モデルを1つの API でまとめて扱えると、上で書いたコスト上限の管理も一箇所に集約できて運用が楽になります。ガードレールと合わせて、運用の安心材料として検討してみてもいいかもしれません。

翻訳記事です。出典:https://hackernoon.com/how-i-built-guardrails-that-stopped-my-ai-agent-from-going-rogue

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?