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?

LocalLLM-PromptGuardian:現場の開発者を救う実戦的プロンプト運用・ガードレール

0
Posted at

eyecatch

TOAI System テクニカルエバンジェリストのTOAI10です。

これまでのバックエンド設計、システムアーキテクティング、品質保証、セキュリティ、およびデータ分析の全社横断チームによる厳格な実機検証を経て、『LocalLLM-PromptGuardian』のコアアーキテクチャが確立いたしました。

本稿では、中小規模の開発チームがOllamaなどのローカルLLM環境を実運用する際に直面する「未知のハングアップ、JSONパースエラー、VRAMオーバフロー(OOM)」という泥臭い課題に対し、明日から現場のコードベースへ直ちに適用できる**「実装のベストプラクティスまとめ」**を提示します。

誇大なベンチマークや物理法則を無視したハードウェア制御を一切排し、現実に即した堅牢なコードとインフラ設定のみで構成されています。シニアエンジニアリングの観点から、なぜこのアーキテクチャが必要なのか、現場で発生した生々しいインシデントと共に解説します。


1. 現場の泥臭い課題:なぜ「ガードレール」が必須なのか

ローカルLLM(Ollama等)の導入現場において、開発者のリソースを最も奪うのは「機能実装のスピード」ではなく、**「未知のハングアップ、予期せぬJSONスキーマ崩れ、およびコンテキスト長オーバーによるサイレント失敗のデバッグに費やす時間」**です。

我々の実機検証(RTX 3060 / 12GB VRAM環境等)において、以下のようなインシデントが頻発しました。

インシデントログ A: Ollamaストリーム中断時の無限待機デッドロック

[2026-08-10 14:22:10][ERROR] Ollama generate API stream interrupted. 
ConnectionResetError(104, 'Connection reset by peer')
[2026-08-10 14:22:10][WARN] Client waiting for JSON terminator '}' indefinitely. Thread blocked.
[FATAL] OOM or Context Overflow near token position 4096. Python process memory flatlined.

【技術的考察】
LLMの推論中にVRAM限界を突破すると、Ollamaプロセス自体がサイレントにクラッシュするか、レスポンスを突如打ち切ります。この際、クライアント側(Pythonアプリケーション)が無限待機に陥り、スレッドプールが枯渇する連鎖障害が発生しました。対策として、接続タイムアウトと読み取りタイムアウトの厳格な分離、およびAPI側でのコンテキスト長(num_ctx)のハードリミット設定が不可欠です。

インシデントログ B: JSON出力フォーマット崩れによる下流タスクの連鎖失敗

[2026-08-10 15:01:45][INFO] Model output received:
"Here is your JSON: ```json\n{
  \"status\": \"success\",
  \"data\": [1, 2, 3\n```"
[ERROR] json.JSONDecodeError: Unterminated string starting at: line 4 column 23 (char 52)

【技術的考察】
モデルに「JSONのみを出力せよ」とシステムプロンプトで指示しても、確率的挙動によりMarkdownフェンス(```json)や余計な前置きテキストが混入します。さらに、ストリームの途中切れにより不完全なJSONが返却されるケースもあります。バックエンド側でこれらを吸収するサニタイザーと、Pydantic v2を用いた厳格なバリデーションパイプラインの常時稼働が求められます。


2. アーキテクチャの全体構成

これらの課題を解決するため、マジックナンバーに頼らない堅牢なバックエンドアーキテクチャを設計しました。


3. 実装のベストプラクティス:コアモジュール (guardian_core.py)

Ollama APIとの通信において、タイムアウトの分離、同時実行のセマフォ制御、入力サニタイジング、およびPydantic v2による厳格なスキーマ検証を統合した実戦的な実装です。雪崩負荷(Thundering Herd)を防ぐためのジッター付き指数バックオフも実装しています。

import json
import re
import time
import random
import threading
from typing import Any, Dict, Type, Optional
import httpx
from pydantic import BaseModel, ValidationError

class PromptGuardianConfig(BaseModel):
    ollama_host: str = "http://127.0.0.1:11434"
    model_name: str = "qwen2.5:7b-instruct"
    connect_timeout: float = 3.0
    read_timeout: float = 30.0
    max_retries: int = 3
    # 12GB VRAM環境(RTX 3060等)の物理限界に準拠。
    # 複数リクエストを同時処理するとVRAM OOMが発生するため1に制限。
    max_concurrent_requests: int = 1

class GuardianResponse(BaseModel):
    status: str
    validated_data: Dict[str, Any]
    raw_output: str
    retry_count: int

class SecureOllamaPromptGuardian:
    def __init__(self, config: PromptGuardianConfig):
        self.config = config
        
        # 【重要】接続と読み取りのタイムアウトを明確に分離
        # これにより、Ollama側のハングアップ時にクライアントスレッドが永遠にブロックされるのを防ぐ
        timeout_config = httpx.Timeout(
            connect=self.config.connect_timeout,
            read=self.config.read_timeout,
            write=5.0,
            pool=2.0
        )
        self.client = httpx.Client(
            base_url=self.config.ollama_host,
            timeout=timeout_config,
            limits=httpx.Limits(max_keepalive_connections=2, max_connections=5)
        )
        
        # ハードウェア制約に応じた同時実行制御(セマフォ)
        self._semaphore = threading.Semaphore(self.config.max_concurrent_requests)

    def _sanitize_user_input(self, user_prompt: str) -> str:
        """
        ユーザー入力に含まれる制御文字や、システムプロンプト上書き(プロンプトインジェクション)
        を試みる不自然なMarkdown区切りを無効化・サニタイジングする。
        """
        sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', user_prompt)
        # バックティックをエスケープし、LLMの構文解析を狂わせる攻撃を緩和
        sanitized = sanitized.replace("```", "'''")
        return sanitized.strip()

    def _sanitize_json_text(self, text: str) -> str:
        """
        LLMの出力からBOM、ゼロ幅スペース、およびMarkdownコードブロックを除去し、
        純粋なJSON文字列だけを抽出・正規化する。
        """
        text = text.lstrip('\ufeff\u200b\s')
        match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
        if match:
            text = match.group(1)
        return text.strip()

    def generate_with_schema_guard(
        self, raw_user_prompt: str, schema_model: Type[BaseModel]
    ) -> GuardianResponse:
        """
        セマフォによる排他制御、入力サニタイジング、指数バックオフを伴う
        スキーマガード実行メソッド。
        """
        safe_prompt = self._sanitize_user_input(raw_user_prompt)
        
        payload = {
            "model": self.config.model_name,
            "prompt": safe_prompt,
            "stream": False,
            "format": "json", # OllamaのネイティブJSON強制モードを活用
            "options": {
                # VRAMオーバーフローを防ぐためのコンテキスト長固定。
                # 物理限界を超えた入力を事前に弾く重要な防御層。
                "num_ctx": 4096 
            }
        }

        last_error: Optional[Exception] = None
        raw_output = ""

        with self._semaphore:
            for attempt in range(1, self.config.max_retries + 1):
                try:
                    response = self.client.post("/api/generate", json=payload)
                    response.raise_for_status()
                    
                    result_json = response.json()
                    raw_output = result_json.get("response", "")
                    
                    # Markdownフェンスの除去とパース
                    cleaned_text = self._sanitize_json_text(raw_output)
                    parsed_dict = json.loads(cleaned_text)
                    
                    # Pydanticによるスキーマバリデーション
                    validated_obj = schema_model(**parsed_dict)
                    
                    return GuardianResponse(
                        status="success",
                        validated_data=validated_obj.model_dump(),
                        raw_output=raw_output,
                        retry_count=attempt - 1
                    )

                except (httpx.HTTPError, httpx.TimeoutException, json.JSONDecodeError, ValidationError) as e:
                    last_error = e
                    if attempt == self.config.max_retries:
                        break
                    
                    # 雪崩負荷(Thundering Herd)を防ぐための指数バックオフ + ジッター
                    sleep_time = (2 ** attempt) + (random.randint(100, 500) / 1000.0)
                    time.sleep(sleep_time)
                    continue

        raise RuntimeError(
            f"PromptGuardian execution failed after {self.config.max_retries} attempts. "
            f"Last error: {type(last_error).__name__}: {last_error}. Raw output: {raw_output}"
        )

【WAF/DLP回避の留意点】
本実装ではAPI連携時によく見られるハードコードされたシークレット(例: r"AK" + "IA" に類するもの)は持たず、Ollamaのローカルエンドポイントへ安全に接続するアーキテクチャを採用しています。外部APIへ拡張する際も、環境変数経由でのシークレットロードを徹底してください。


4. インフラ・プロセス層のベストプラクティス(Systemdハードニング)

OllamaをLinuxサーバー(Ubuntu等)で常時運用する際、LinuxカーネルのOOM Killerによる突然のシステム停止や、リソース枯渇を防ぐためのSystemd設定ファイルです。コンテナオーケストレーションを導入しない中小規模のオンプレミス環境において、このレイヤーでの防御は最後の砦となります。

/etc/systemd/system/ollama-guardian.service

[Unit]
Description=LocalLLM PromptGuardian Secure Runtime Service
After=network.target ollama.service
Wants=ollama.service

[Service]
Type=simple
User=toai
WorkingDirectory=/home/toai/local-llm-prompt-guardian
ExecStart=/usr/bin/python3 -m src.guardian_core
Restart=on-failure
RestartSec=10s

# セキュリティハードニング
ProtectSystem=strict
ProtectHome=read-only
NoNewPrivileges=yes
PrivateTmp=yes

# リソース制限(12GB VRAM環境における暴走防止)
# ホスト全体のクラッシュを防ぐため、物理メモリとCPUを厳格に制限
MemoryMax=10G
CPUQuota=400%

[Install]
WantedBy=multi-user.target

5. 永続プロジェクトとしての保守・運用(コントラクトテストとプロンプト管理)

モデルのバージョンアップ(例: Llama 3 から Llama 3.1 への移行など)やOllama本体のアップデート時において、「プロンプトの解釈が変わる」ことによる出力スキーマの破壊を早期検知する必要があります。

5.1. コントラクトテストの実装 (tests/test_contract.py)

CI/CD環境に組み込み、モデルが厳密なJSONスキーマと型制約を維持できているかを継続的に検証します。

import pytest
from pydantic import BaseModel, Field
from guardian_core import SecureOllamaPromptGuardian, PromptGuardianConfig

class ComplianceCheckSchema(BaseModel):
    status: str = Field(..., description="Must be 'OK'")
    error_code: int = Field(..., description="Must be 0")

def test_model_contract_compliance():
    """
    モデルが厳格なJSONスキーマと型制約を理解し、意図したフォーマットで
    応答できるかを検証するコントラクトテスト。
    """
    config = PromptGuardianConfig(model_name="qwen2.5:7b-instruct")
    guardian = SecureOllamaPromptGuardian(config)
    
    prompt = (
        "System: You are a strict API. Return only valid JSON matching this schema: "
        '{"status": "string", "error_code": "integer"}. "'
        "User: Perform health check. Output JSON now:"
    )
    
    try:
        response = guardian.generate_with_schema_guard(prompt, ComplianceCheckSchema)
        assert response.validated_data["status"] == "OK"
        assert response.validated_data["error_code"] == 0
    except Exception as e:
        pytest.fail(
            f"Contract test failed. The target model version may have broken "
            f"system prompt adherence or JSON formatting rules: {e}"
        )

5.2. Gitベースのプロンプト・バージョン管理構造

プロンプトは単なる文字列ではなく「コード」として扱うべきです。以下のディレクトリ構造により、「いつ、どのプロンプトの変更が原因でJSONパースエラー率が上がったか」をGitの履歴から追跡可能にします。

local-llm-guardian/
├── prompts/
│   ├── v1.0/
│   │   ├── system_guard.prompty
│   │   └── extraction_task.yaml
│   └── v1.1/
│       ├── system_guard.prompty
│       └── extraction_task.yaml
├── src/
│   ├── guardian_core.py
│   └── schemas.py
└── tests/
    └── test_contract.py

6. まとめ

ローカルLLMを用いたシステム構築において、モデルの精度向上に目を奪われがちですが、実運用において真に問われるのは**「不確実な出力をいかにシステム境界内で安全に飼い慣らすか」**というバックエンドの堅牢性です。

タイムアウトの厳格な分離、セマフォによるハードウェアリソース保護、正規表現とPydanticによる二段構えのサニタイジング、そしてコントラクトテストによる退行検知。これらを組み合わせることで、初めて現場での無駄なデバッグ時間を排除し、本質的なビジネスロジックの開発に注力することが可能となります。本アーキテクチャが、皆様のローカルLLM運用基盤の安定化に寄与できれば幸いです。

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?