はじめに
問い合わせメールの本文から「お客様の要望」を抜き出し、種別 ID 付きの JSON にする処理を LLM で作る、という場面を考えます。
LLM にはこんな配列を返してもらいます。
[
{"type_id": 11, "text": "返品理由:サイズが合わない"},
{"type_id": 21, "text": "請求書再発行:10月分"}
]
プロンプトをどれだけ工夫しても、LLM の出力は 100% 思いどおりにはなりません。
そこで、LLM の出力を受け取るコード側で、出力が崩れた場合に備えた防御を入れています。この記事では、その中から用途を問わず使える3つを紹介します。
- JSON を壊れにくく取り出す
- ID は LLM に任せず、名称から表で引き直す
- 「パースに失敗した」と「パースできたが0件」を区別する
筆者は Dify のコードノード(Python 標準ライブラリのみ)で実装していますが、中身は普通の Python なので、LangChain や API を直接呼ぶ構成でもそのまま使えます。
防御1:JSON を壊れにくく取り出す
これはよく知られた話と思うので、手短に済ませます。
「JSON だけを返して」と指示しても、LLM は次のような出力を返してくることがあります。
```json
[{"type_id": 11, "text": "返品理由:サイズが合わない"}]
```
以下が抽出結果です。
[{"type_id": 11, "text": "返品理由:サイズが合わない"}]
そこで、パースを試す候補を複数用意し、最初に配列として読めたものを採用します。
- 出力全体
- コードフェンス(
```)の中身 - 最初の
[から最後の]まで
import json
import re
_FENCE_RE = re.compile(r"```(?:\w+)?\s*(.*?)\s*```", re.DOTALL)
def parse_llm_list(raw):
"""LLM出力からJSON配列を取り出す。取り出せなければ None(空配列 [] とは区別する)。"""
text = (raw or "").strip()
if not text:
return None
candidates = [text]
fenced = _FENCE_RE.search(text)
if fenced:
candidates.append(fenced.group(1))
start, end = text.find("["), text.rfind("]")
if 0 <= start < end:
candidates.append(text[start:end + 1])
for c in candidates:
try:
value = json.loads(c)
except json.JSONDecodeError:
continue
if isinstance(value, list):
return value
return None
一つ大事な点があります。失敗したときに [] ではなく None を返していることです。この理由は防御3で説明します。
防御2:ID は LLM に任せず、名称から表で引き直す
ここでいう ID は、種別 ID・商品 ID・ステータスコードなど、「名称に対応して決まっている値」のことです。以下では要望の種別 ID を例に説明します。
何が起きるか
LLM には type_id と、「種別名:内容」の形式の text の両方を出力させています。
{"type_id": 20, "text": "請求書再発行:10月分"}
この例では、text は「請求書再発行」(21)なのに、type_id は「請求書」(20)を指しています。
ただ、実際に何百ケースもテストしてみると、たまにLLMの採番が誤っていることがありました。
- 種別名は意味のある言葉なので、LLM は文脈から正しく選べる
- IDは人間が決めた単なる記号で、意味がない。名称と ID の対応表をプロンプトに書いても、取り違えることがある
そこで方針を変えました。
- LLM には名称だけを正しく書いてもらい、ID はコード側で名称から引く
対応表はコードに持っているので、名称さえ合っていれば ID は必ず正しくなります。
実装
# 種別名 -> 種別ID
TYPE_IDS = {
"返品": 10,
"返品理由": 11,
"交換": 12,
"請求書": 20,
"請求書再発行": 21,
"その他": 99,
}
# 長い名前から試すことで「請求書」より「請求書再発行」を優先する
_NAMES_LONGEST_FIRST = sorted(TYPE_IDS, key=len, reverse=True)
def find_type_id(text):
"""text 先頭の種別名から種別IDを引く。見つからなければ None。"""
head = (text or "").strip()
for name in _NAMES_LONGEST_FIRST:
if head.startswith(name):
return TYPE_IDS[name]
return None
先頭一致で引いているので、text の書き方が多少揺れても対応できます。
| LLM の出力 | 引かれる ID |
|---|---|
返品理由:サイズが合わない |
11(返品理由) |
返品理由(サイズ違い) |
11(返品理由) |
交換 色違いへ |
12(交換) |
請求書再発行希望 |
21(区切り文字がなくても引ける) |
先頭一致は「長い名称から」
先頭一致では、比較する順番が重要です。
請求書 と 請求書再発行 のように、ある名称が別の名称の先頭部分になっている場合があるからです。
"請求書再発行:10月分".startswith("請求書") # True ← 短い方が先に当たると誤判定
"請求書再発行:10月分".startswith("請求書再発行") # True ← こちらを先に試したい
そのため、sorted(TYPE_IDS, key=len, reverse=True) で長い名称から順に比較しています。
表で引けないときだけ LLM の値を使う
表にない名称が来たときは、LLM が出した ID にフォールバックします。
def pick_type_id(item):
"""表で引けたら表の値、引けなければ LLM の値を使う。"""
looked_up = find_type_id(item.get("text"))
if looked_up is not None:
return looked_up
try:
return int(item.get("type_id"))
except (TypeError, ValueError):
return None
pick_type_id({"type_id": 20, "text": "請求書再発行:10月分"})
# => 21 LLM は 20 と言っているが、名称から 21 に補正
pick_type_id({"type_id": "99", "text": "謎の項目:x"})
# => 99 表にないので LLM の値(文字列でも int に変換)
この考え方は応用できる
「LLM は意味の判断だけ担当し、決まった変換はコードが担当する」という分担は、この例の種別 ID に限らず、マスタの ID やコード値全般に使えます。
- 都道府県名 → 都道府県コード
- 商品名 → 商品 ID
- 「至急」「急ぎ」「優先」 →
priority: high
LLM に ID や番号を直接出させている箇所があれば、名称を出させてコードで引く形に変えられないか検討してみてください。
防御3:「パースに失敗した」と「パースできたが0件」を区別する
両方とも [] にすると何が起きるか
よくある書き方として、JSON の取り出しに失敗したときに [] を返す実装があります。
def parse_llm_list(raw):
try:
return json.loads(raw)
except Exception:
return [] # ← 失敗も「0件」として扱われてしまう
一見すると安全そうですが、この書き方では次の2つが区別できなくなります。
| 状況 | 本来の結果 |
|---|---|
メールに要望が書かれていなかった(LLM が [] を返した) |
成功(0件) |
| LLM がエラーで落ちた、または壊れた出力を返した | 失敗 |
利用者から見ると、どちらも「要望は0件でした」という成功に見えてしまいます。
本当は要望が書いてあったのに、LLM が失敗して黙って0件になっていても、誰も気づけません。
特に Dify では、LLM ノードのエラー処理に「デフォルト値」を設定すると、LLM が失敗してもフローは止まらず、指定した値(空文字など)が次のノードに渡されます。フローを止めないための設定ですが、そのぶん後段のコードで失敗を見分ける必要があります。
実装:None と [] を使い分ける
防御1の parse_llm_list が None を返すようにしておいたのは、このためです。
-
None… 取り出せなかった(失敗) -
[]… 取り出せたが0件だった(正常)
さらに、入力テキストが空だったかどうかも合わせて判定します。
def build_result(input_text, llm_output):
"""抽出結果を ok / error に振り分ける。"""
parsed = parse_llm_list(llm_output)
if parsed is None:
if (input_text or "").strip():
# 入力があるのに読める出力が無い → LLM 側の失敗
return {"status": "error", "error": "LLM_OUTPUT_INVALID", "requests": None}
# 入力がそもそも空 → 抽出するものが無かっただけ
return {"status": "ok", "requests": []}
requests = [
{"type_id": pick_type_id(item), "text": str(item.get("text", ""))}
for item in parsed
if isinstance(item, dict)
]
# パースできて 0 件 → 「要望が書かれていなかった」という正常な結果
return {"status": "ok", "requests": requests}
判定をまとめると、次のようになります。
| 入力テキスト | LLM 出力 | 結果 |
|---|---|---|
| あり | パースできた(1件以上) | ok |
| あり | パースできた(0件) | ok(要望なし) |
| あり | 空・壊れている |
error(LLM_OUTPUT_INVALID) |
| なし | ― | ok(抽出するものがない) |
build_result("本文", "崩れた出力") # => error / LLM_OUTPUT_INVALID
build_result("本文", "[]") # => ok / requests: []
build_result("", "") # => ok / requests: []
一般化すると
「失敗」と「該当なし」を同じ値で表さない、という話です。LLM に限らず、外部 API の呼び出しや検索処理でも同じ問題が起きます。
- 戻り値の型で区別する(
None/[]、Optional、Result 型など) - 「本来なら結果があるはずか」(ここでは入力テキストの有無)と組み合わせて判定する
まとめ
| 防御 | やること | 守れるもの |
|---|---|---|
| 1. JSON の取り出し | 複数の候補を順にパースする | 出力形式の揺れ |
| 2. 表で引き直す | ID は LLM に任せず、名称からコードで引く | 名称と ID の取り違え |
| 3. 失敗と0件の区別 |
None と [] を使い分ける |
失敗が黙って成功扱いになること |
プロンプトの改善は大事ですが、LLM の出力がたまに崩れることは避けられません。
崩れることを前提に、受け取るコード側で「決められることは決め、失敗は失敗として見えるようにする」と、運用がかなり楽になります。