PythonでのOutlines活用術:正規表現と文脈自由文法(CFG)で保証するLLMの構造化出力

AI tutorial - IT technology blog
AI tutorial - IT technology blog

半年前、私たちのチームは商用LLM APIの限界に直面していました。日次でおよそ4万件の法的契約書やサーバーのテレメトリログを処理する抽出パイプラインを運用していましたが、ベンダーのJSONモードを有効にし、temperatureを0.0に固定していても、リクエストの約2.3%がサイレントに失敗していたのです。末尾の余計なカンマ、予期しないMarkdownのコードブロック、存在しないキーのハルシネーションが発生し、深夜3時にPydanticのバリデータがクラッシュする事態が続いていました。

そこでプロンプトの揺らぎと格闘するのをやめ、コアとなる抽出ワークロードをOutlinesを実行するセルフホスト型モデルへと移行しました。Outlinesは、logit biasのハックやソフトプロンプトに頼るのではなく、サンプリング時のトークンレベルでフォーマットを制御します。生成されたトークンが指定した文法に違反する場合、Outlinesは即座にその確率をゼロにします。これにより、生成されるすべての出力が構造的に有効であることが保証されます。

クイックスタート(5分)

最初のOutlinesジェネレーターの立ち上げには、5分もかかりません。Hugging Face Transformers、llama.cpp、vLLMなど、主要なローカルランタイムとスムーズに連携します。

まずはTransformersバックエンドおよびPyTorchと共にOutlinesをインストールします:

pip install outlines transformers torch

以下のスクリプトは、ローカルモデルに対して厳格なIPv4アドレスとポート番号の組み合わせのみを出力させ、それ以外の出力を一切排除する例です:

import outlines

# 1. コンパクトなオープンウェイトモデルをロード
model_name = "Qwen/Qwen2.5-1.5B-Instruct"
model = outlines.models.transformers(
    model_name,
    model_kwargs={"device_map": "auto"}
)

# 2. ポート番号付きIPv4アドレスの明示的な正規表現パターンを定義
regex_pattern = r"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):[0-9]{2,5}"

# 3. 正規表現ガイド付きジェネレーターをコンパイル
generator = outlines.generate.regex(model, regex_pattern)

# 4. 構造化テキストを生成
prompt = "ここで言及されているプロキシホストを特定してください: サーバーは昨日、リスニングポート8443の10.0.4.15経由でリクエストを転送しました。\nプロキシアドレス: "
result = generator(prompt, max_tokens=30)

print(f"強制された出力: {result}")
# 出力: 10.0.4.15:8443

注目すべきは、不要なテキストが一切含まれていない点です。会話調の前置きも、IPアドレスはこちらです:のような余計な文言も、末尾の引用符もありません。デコードループが、ステートマシンを進めることのできないトークンを物理的に排除しているためです。

ディープダイブ

大半の商用APIは、システムプロンプトとリトライループを組み合わせて構造化出力を処理しています。API側でトークンマスクを適用する場合でも、多くはオンザフライで文字列マッチングを行っています。このアプローチではマルチトークンの境界を見落としやすく、レイテンシの増大や突発的な500エラーの原因となります。

有限状態機械(FSM)によるトークンレベルのマスキング

現代の自己回帰型LLMは、大規模な語彙(通常32,000〜128,000トークン以上)にわたるソフトマックス確率を計算してトークンを選択します。Outlinesは、Pydanticで記述されたスキーマや生の正規表現を、決定性有限オートマトン(DFA)へとコンパイルします。

各デコードステップでは以下の処理が行われます:

  1. 状態の初期化: 生成を開始する前に、モデルの語彙とコンパイル済みDFAの状態をマッピングするインデックスを構築します。
  2. Logitマスキング: ステップtにおいて、エンジンは現在のDFA状態を検査し、有効な状態遷移として認められる語彙トークンを特定します。
  3. プルーニング(枝刈り): 無効なトークンのロジットスコアをすべて-infinityに設定します。
  4. サンプリング: 通常のtemperatureおよびtop-pパラメータを使用し、残った有効なトークンセットのみからモデルがサンプリングを行います。
  5. 遷移: 選択されたトークンに基づいてオートマトンが次の状態へ遷移します。このループを、シーケンスが有効な終端状態に達するまで繰り返します。

効率化のメリットは絶大です。モデルは導入の挨拶や不正な構文にトークンを浪費することがありません。目的の構造が完成した瞬間に生成が停止するため、制約なしのサンプリングと比較して中央値レイテンシが15%〜30%削減されます。

Pydanticによる型バリデーション

フラットな文字列であれば単純な正規表現で十分ですが、本番パイプラインでは型定義されたネスト構造のペイロードが求められることがほとんどです。OutlinesはPydanticスキーマを直接ガイド付きJSONパーサーへと変換します:

from typing import List, Literal
from pydantic import BaseModel, Field
import outlines

class VulnerabilityReport(BaseModel):
    cve_id: str = Field(pattern=r"^CVE-\d{4}-\d{4,7}$")
    severity: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
    affected_ports: List[int]
    summary: str = Field(max_length=120)

# Pydanticスキーマをガイド付きジェネレーターへコンパイル
generator = outlines.generate.json(model, VulnerabilityReport)

prompt = "レポートを分析してください: ポート22およびポート8080へのCVE-2023-38606を介した不正アクセスを検知しました。影響度はCriticalです。\n構造化出力: "
report: VulnerabilityReport = generator(prompt)

print(f"パース済みオブジェクト: {report.cve_id} | {report.severity}")
print(f"影響を受けたポート: {report.affected_ports}")

出力は検証済みのPydanticモデルインスタンスとして得られます。そのため、防御的なjson.loads()のラッパー処理や、スキーマ検証失敗時のリトライ用デコレータを完全に排除できます。

高度な応用例

実際の開発現場では、正規表現だけでは表現しきれない出力が必要になるケースが多々あります。ネストした式、コードブロック、カスタムクエリなどを出力する場合は、文脈自由文法(CFG)が必要になります。

Lark文法による出力の制約

CFGは、対応する括弧のネストや算術式、SQL句のような再帰的構文を柔軟に扱うことができます。OutlinesはLark構文解析ライブラリと直接統合されており、カスタムのEBNF文法を適用することが可能です。

例えば、抽出エージェントに対してElasticsearchクラスタ用の有効なブール値フィルタ文字列のみを返させることができます:

import outlines

# ネストされた検索クエリ用のEBNF文法を定義
boolean_grammar = """
    ?start: expr
    ?expr: term ((" AND " | " OR ") term)*
    ?term: NOT? atom
    NOT: "NOT "
    ?atom: FIELD ":" VALUE | "(" expr ")"
    FIELD: "status" | "tag" | "env"
    VALUE: "\"" /[a-zA-Z0-9_-]+/ "\""
    %import common.WS
    %ignore WS
"""

cfg_generator = outlines.generate.cfg(model, boolean_grammar)

prompt = "アーカイブされていない本番環境のエラーを検索するElasticsearchフィルタクエリを生成してください:\nクエリ: "
query_result = cfg_generator(prompt, max_tokens=50)
print(f"文法検証済みクエリ: {query_result}")
# 出力: env:"prod" AND NOT status:"archived"

モデルが引用符を閉じ忘れたり、サポートされていないフィールド名を挿入しようとしたりしても、Outlinesはそのトークンがコンテキストウィンドウに到達する前に無効化します。

vLLMによる高スループットなサービング

単体のTransformersモデルでOutlinesを実行するのは、開発やバッチ処理には最適です。しかし、複数ユーザーが同時利用する本番環境では、Continuous BatchingやPagedAttentionが不可欠です。vLLMは、コアとなるガイド付きデコードエンジンにOutlinesをネイティブで組み込んでいます。

ポート8000でOutlinesを有効にしたOpenAI互換のvLLMインスタンスを起動します:

python3 -m vllm.entrypoints.openai.api_server \
    --model mistralai/Mistral-7B-Instruct-v0.3 \
    --guided-decoding-backend outlines \
    --port 8000

次に、標準のOpenAIクライアントライブラリを使用し、guided_choiceパラメータを渡してインスタンスにリクエストを送信します:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="empty")

response = client.completions.create(
    model="mistralai/Mistral-7B-Instruct-v0.3",
    prompt="顧客の感情を抽出してください: '移行作業は最悪で、データベースが破損しました。'\n感情: ",
    extra_body={
        "guided_choice": ["POSITIVE", "NEUTRAL", "NEGATIVE"]
    }
)

print(response.choices[0].text.strip())
# 出力: NEGATIVE

本番運用における考慮事項

本番環境で何百万ものトークンを処理する中で、ガイド付きデコーダーの大規模運用に関する実践的な知見がいくつか得られました。

1. コンパイル済みDFAインデックスのキャッシュ

最新のトークナイザーに対して正規表現や大規模スキーマをコンパイルする処理は、決して無視できない計算負荷がかかります。語彙サイズが128,256トークンあるLlama 3のようなモデルでは、コールドスタート時のインデックス構築に1.5〜4秒ほど消費することがあります。ジェネレーターは必ずサービス初期化時に事前コンパイルしてインスタンス化し、リクエストハンドラー内で都度構築しないようにしてください。

2. トークナイザーのプレフィックスと空白文字の考慮

トークナイザーによって空白文字のエンコード方法は異なります。先頭スペースのトークンを使用するものもあれば、バイトレベルの表現にフォールバックするものもあります。r" [A-Z]+ "のように厳格な空白をハードコードすると、生成が失敗することがよくあります。予期せぬトークンのデッドロックを防ぐため、r"\s*([A-Z]+)\s*"のように柔軟な空白パターンを記述してください。

3. トークンの途切れ(カットオフ)の防止

スキーマ上、有効な閉じ括弧に到達するまでに80トークン必要なのに対し、リクエストのmax_tokensを50に制限してしまうと、ペイロードの途中で生成が打ち切られてしまいます。その結果、構文的に不完全な出力となってしまいます。ピーク時のペイロードサイズを事前にベンチマークし、max_tokensには25%程度の安全マージンを設定してください。

4. 明確なプロンプトによる小型モデルの誘導

ガイド付きデコードは構文の正確性を保証しますが、モデルの意味的推論能力の低さまでカバーすることはできません。1.5Bクラスの小型モデルに過度に厳格な文法を適用すると、プロンプトが不明瞭な場合に非論理的なトークン分岐へと強制的に追い込まれる可能性があります。プロンプトに2〜3個の簡潔なFew-shotの例を追加することで、推論品質とスループットの双方を高く維持できます。

Share: