1
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?

【LLM】出力は「崩れる前提」で受け取る(コード側で守る2つのルール)

1
Posted at

はじめに

問い合わせメールの本文から「お客様の要望」を抜き出し、種別 ID 付きの JSON にする処理を LLM で作る、という場面を考えます。
LLM にはこんな配列を返してもらいます。

[
  {"type_id": 11, "text": "返品理由:サイズが合わない"},
  {"type_id": 21, "text": "請求書再発行:10月分"}
]

プロンプトをどれだけ工夫しても、LLM の出力は 100% 思いどおりにはなりません。
そこで、LLM の出力を受け取るコード側で、出力が崩れた場合に備えた防御を入れています。この記事では、その中から用途を問わず使える3つを紹介します。

  1. JSON を壊れにくく取り出す
  2. ID は LLM に任せず、名称から表で引き直す
  3. 「パースに失敗した」と「パースできたが0件」を区別する

筆者は Dify のコードノード(Python 標準ライブラリのみ)で実装していますが、中身は普通の Python なので、LangChain や API を直接呼ぶ構成でもそのまま使えます。

防御1:JSON を壊れにくく取り出す

これはよく知られた話と思うので、手短に済ませます。
「JSON だけを返して」と指示しても、LLM は次のような出力を返してくることがあります。

```json
[{"type_id": 11, "text": "返品理由:サイズが合わない"}]
```
以下が抽出結果です。
[{"type_id": 11, "text": "返品理由:サイズが合わない"}]

そこで、パースを試す候補を複数用意し、最初に配列として読めたものを採用します。

  1. 出力全体
  2. コードフェンス(```)の中身
  3. 最初の [ から最後の ] まで
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 の出力がたまに崩れることは避けられません。
崩れることを前提に、受け取るコード側で「決められることは決め、失敗は失敗として見えるようにする」と、運用がかなり楽になります。

1
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
1
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?