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?

LangChainの構造化出力がnullを返して夜間バッチが全滅した — OutputParserExceptionを防ぐ3つのガードと完全実装

0
Posted at

「1件目は成功するのに、30件目あたりからnullが返ってきてバッチが全滅する」——300件の問い合わせメールをLangChainで構造化出力に分類していた夜、78件がOutputParserExceptionで落ちて朝までに気づけず、手動仕訳に3時間溶かしました。原因は、LLMがたまに文章を混ぜて返すことに、パース前の検証を1行も入れていなかったことです。この記事では、実測ログと、コピペで動く3層ガードの完全実装を載せます。

何が起きていたか: 「たまに崩れる」出力を放置していた

まず実測ログです。300件を連続実行したときの失敗の分布を表にまとめました。

発生箇所 件数 出力の実際の中身 パース結果
1〜220件 220 正しいJSON 成功
221〜298件のうち74件 74 JSONの前に「分類します:」という前置き文 OutputParserException
299件前後の4件 4 カテゴリが「その他」固定で category がnull バリデーションエラー

つまり74件は「JSON以外の文字が前後に付く」だけで死に、4件は「JSONとしては valid だが中身が null」で死にました。成功した220件に安心して、ガードを書いていなかったことが全滅の原因です。構造化出力の失敗は「確率ゼロではない」前提で設計する必要がありました。

失敗した設計: パースと検証をモデル呼び出しの直後に繋げた

最初に書いた駄目なコードがこちらです。これは失敗パターンなのでそのままコピペしないでください(正しい形は次章に完全形があります)。

# ❌ 失敗設計: 前置き文も null もそのままパースに投げる
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import PydanticOutputParser

class MailClass(BaseModel):
    category: str = Field(description="分類カテゴリ")
    priority: int = Field(description="重要度1-5")

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = PydanticOutputParser(pydantic_object=MailClass)

def classify(subject: str, body: str) -> MailClass:
    prompt = f"""次のメールを分類してください。
subject: {subject}
body: {body}

{parser.get_format_instructions()}"""
    raw = llm.invoke(prompt).content   # ←文字列がそのまま返る
    return parser.parse(raw)           # ←前置き文で即死・nullも素通り

このコードの決定的な失敗は次の3点です。

  1. 前置き文の混入を想定していない — parser.parse()は「Parse possible? NO」で即OutputParserExceptionを投げ、リトライもフォールバックも無い
  2. JSONは valid だが値が null のケースを素通りさせる — category に null が入ったまま下流(Slack通知・スプレッドシート書き込み)へ流れ、夜中に別の場所で壊れる
  3. 失敗を夜間バッチが握りつぶす — 例外をtryで囲まず1件落ちたら全停止、逆に広くtryで囲むと失敗件数がログに残らない

直し方1: Pydanticの検証を厚くして「nullの素通り」を入口で止める

値段の欠落はPydantic側で出口を閉じます。Optional を許さず、validation を明示的に使うのがポイントです。

# ✅ 完全形1: Pydanticモデル自体をガードにする
from pydantic import BaseModel, Field, field_validator

class MailClass(BaseModel):
    category: str = Field(description="分類カテゴリ")
    priority: int = Field(description="重要度1-5")

    @field_validator("category")
    @classmethod
    def category_not_empty(cls, v: str) -> str:
        # null・空文字・「その他」固定を入口で排除
        if v is None or not str(v).strip() or v == "その他":
            raise ValueError(f"category is invalid: {v!r}")
        return str(v).strip()

    @field_validator("priority")
    @classmethod
    def priority_in_range(cls, v: int) -> int:
        if v is None or not 1 <= int(v) <= 5:
            raise ValueError(f"priority out of range: {v!r}")
        return int(v)

この厚いバリデーションを入れると、「JSONは valid だが中身が崩れている」ケースがパースの段階で例外として表面化します。夜中の下流事故を防いでいるのがこの層です。

直し方2: OutputFixingParserで「崩れたJSON」を自動修復する

LangChainには崩れた出力を修復する専用パーサーがあります。前置き文が付いた出力は、まず本文からJSON部分だけを抜き出してから修復に渡すのが実測での安全圏でした。

# ✅ 完全形2: JSON抜き出し + OutputFixingParser の統合
import json
import re
from langchain.output_parsers import OutputFixingParser
from langchain_core.output_parsers import PydanticOutputParser
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
base_parser = PydanticOutputParser(pydantic_object=MailClass)
fixing_parser = OutputFixingParser.from_llm(parser=base_parser, llm=llm)

def extract_json_block(raw: str) -> str:
    # 前置き文・後置き文が付いても、最初の { から最後の } だけを取り出す
    m = re.search(r"\{.*\}", raw, re.S)
    if not m:
        raise ValueError(f"no JSON block found: {raw[:120]!r}")
    json.loads(m.group(0))  # valid か否かをここで確定させる
    return m.group(0)

def parse_safely(raw: str) -> MailClass:
    try:
        return base_parser.parse(extract_json_block(raw))
    except Exception:
        # 値の欠落・型崩れはLLMに修復させてから再検証
        return fixing_parser.parse(extract_json_block(raw))

実測では、前置き文付き74件のうち71件が extract_json_block だけで復活し、残り3件が修復パーサーで通りました。

直し方3: 例外の種類で「捨てるか・直すか・止めるか」を分ける

全部の失敗を同じ扱いにするのが一番危険でした。表にすると使い分けはこうです。

例外の種類 放っておいたときの症状 この実装での扱い
OutputParserException(前置き文混入) 1件落ちてバッチ全体停止 JSON抜き出し→修復→再パース
Pydantic ValidationError(null・範囲外) 下流のSlack通知・表書き込みで夜中に壊れる 修復→再検証、ダメなら失敗リストへ
その他の例外(APIタイムアウト等) 失敗件数が握りつぶされ気づかない 失敗リストに記録して処理は継続

実測: 78/300失敗 → 3/300失敗

3層ガードを統合した完全形で、同じ300件を再実行しました。

構成 成功数 手動仕訳の所要 備考
ガードなし(失敗設計) 222 / 300 約3時間 78件が夜間に全滅
JSON抜き出しのみ 296 / 300 約10分 値nullの4件が下流で事故
抜き出し+修復+厚いバリデーション 297 / 300 約5分 失敗3件だけ朝の確認対象に

残った3件は「本文自体が短文すぎて分類不能」なメールで、失敗リストに明示的に残るのが重要です。人間が見るべき場所が先頭に並ぶ形になりました。

まとめ

  • Pydanticのバリデーションを厚くして、null・範囲外を入口で止める(直し方1)
  • 前置き文は正規表現でJSON部分だけ抜き出し、崩れた値はOutputFixingParserに渡す(直し方2)
  • 例外の種類ごとに「直す・捨てる・記録する」を分け、失敗リストを必ず残す(直し方3)

78件全滅の朝、私は「temperature=0 なら崩れない」と思い込んでいました。構造化出力の失敗は確率の問題ではなく、崩れたときにどう受け止めるかの設計の問題です。この記事の3層ガードはコピペで動くので、LangChainの構造化出力にそのまま載せてください。

🎁 読者特典を受け取る(無料・メール登録)

参考書籍: LangChain × AI実践入門 — Pythonで構築するAIアプリケーション(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

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?