「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点です。
-
前置き文の混入を想定していない —
parser.parse()は「Parse possible? NO」で即OutputParserExceptionを投げ、リトライもフォールバックも無い -
JSONは valid だが値が null のケースを素通りさせる —
categoryに null が入ったまま下流(Slack通知・スプレッドシート書き込み)へ流れ、夜中に別の場所で壊れる - 失敗を夜間バッチが握りつぶす — 例外を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読み放題対象)(葉山悠希 著)