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?

LLMは魔法じゃない — 本番で構造化をやって踏んだ4つの穴(既存のテストでは拾えない)

0
Last updated at Posted at 2026-09-04

📚 スキルシートと市場価値 シリーズ ⑤:LLM固有の問題とは?


はじめに

弊社プロダクトのcnvSkillsheet(コンバートスキルシート)ではエンジニアの経歴書(スキルシート)を、Geminiで構造化しています。PDFやExcelから取り出したテキストを渡し、案件ごとに「期間・役割・工程・チーム規模・使った技術」へ分解して、こちらのスキーマに詰め直す。よくある「非定型テキストの構造化」です。
LLMは、まるで魔法のように何でもこなす道具に見えます。ですが本番に載せると、「魔法」では片付かない罠がいくつも待っていました。今回はこの構造化を題材に、LLM固有の問題——既存のテストでは拾えない落とし穴を、踏んだ順に並べていきます。

いまのLLMには、これを助ける機能がひととおり揃っています。JSONモード(response_mime_type)を指定すれば整形されたJSONが返るし、スキーマを渡す方法もある。最初は「これでほぼ終わりだろう」と思っていました。

ですが実際には、本番で何度も落ちました。落ちた場所はどれも、コードを書く前には「まさかそこでは落ちないだろう」と思っていた場所です。共通していたのは、LLMは行儀よく、決まった形で返してくれるという前提でした。LLMを魔法だと思っていると、この前提を疑いません。でも出力にはゆらぎがあり、決して絶対ではない。そこを過信してはいけません。

この記事は、経歴書の構造化で実際に踏んだ穴を4つ、踏んだ順に並べたものです。どれも「防いだあと」の話ではなく、本番で踏んでから直した話です。


前提:経歴書は、Geminiに向いていて、かつGemini泣かせ

まず、なぜ手書きのパーサではなくLLMなのか。経歴書は書式が人によってばらばらだからです。

  • 案件が5件の人もいれば、50件の人もいる
  • 担当工程が「基本設計〜結合テスト」と文で書いてある人もいれば、を並べたマトリクス表の人もいる
  • 「VBA」「Oracle」「Windows」が、言語なのかDBなのかOSなのか、列がずれて崩れている

こういう「崩れ」を吸収して意味を汲むのは、正規表現では手に負えず、LLMが得意とする領域です。実際、プロンプトには「表が崩れていても、VBAは言語、OracleはDB、WindowsはOSと推論して補正せよ」と書いています。ここはLLMの強みに素直に頼ります。

一方で、LLMならではの罠もあります。経歴書の案件が多い人ほど、モデルが勝手に間引くのです。だからプロンプトの冒頭で、しつこく釘を刺しています。

5件や10件で打ち切るな。30件あれば30件、50件あれば50件、全件出力せよ。
出力トークン数の制約でプロジェクトを間引くな。

構造化の指示はresponse_schemaではなく、プロンプトの文章でスキーマを定義しています。理由は後述しますが、先に結論だけ言うと、キャッシュとgrounding併用の都合です。

ここまでは、LLMの得意を活かす話。ここから、苦手にぶつかる話です。


穴①:「純粋なJSONだけを返せ」と書いても、純粋なJSONは返ってこない

最初の穴は、いちばん素朴なところにありました。返ってきた文字列が、そのままjson.loadsに通らない

プロンプトには「出力は純粋なJSONオブジェクトのみ」と明記しています。それでも、次の3種類が混ざってきました。

(a) 散文の謝罪文が返ってくる

いちばん怖かったのがこれです。配信スタックを切り替えた(VertexAIから別のAPI経由へ移した)途端、返信取り込みの処理で、JSONではなく「申し訳ございません、うまく取り込めませんでした」という散文が返ってくるようになりました。当然json.loadsは例外を投げ、本番で500になります。

対策は、JSONモードを常時オンにすることでした。

_gen_kwargs = {"response_mime_type": "application/json"}
if temperature is not None:
    _gen_kwargs["temperature"] = temperature

そのうえで、プロンプトの最後にも最終防波堤を置きます。

返信メールに取り込める回答が1件も見つからない場合でも、
謝罪文・説明文などを返すな。どんな入力に対しても、
応答は必ずこのJSONスキーマに従うこと。

「該当なし」を散文で報告させず、空のJSONという"決まった形"で返させる。散文が返る余地を、機能(JSONモード)と指示(スキーマ厳守)の両方で塞ぎます。

LLMは知性を模している以上、ときに「それらしい嘘」でごまかします。しかしシステムはそれを許容できません。魔法に見えても、最終的にはシステム側で押さえ込む必要がある、ということです。

(b) コードフェンスで包んで返してくる

LLMは、JSONを三連バッククォートのコードフェンスで囲んで返してくることがあります。おなじみのやつです。剥がせばいいだけ。ただ、ここで一度データを壊しました。

最初の実装は、フェンスを剥がす正規表現にre.MULTILINEを付けていました。すると、行頭・行末のフェンスを表すパターンがあらゆる行にマッチしてしまいます。経歴書や返信メールの本文に、正当にコードブロックが含まれていると(エンジニアの経歴なので普通にある)、その行まで削れてしまう。結果は、データ汚染か、json.loads失敗で500です。

直し方は単純で、MULTILINEを外し、文字列全体の先頭と末尾のラッパだけを剥がすようにしました。本文の途中に出てくるフェンスには触れない。

ただ本当に怖いのは、LLMが何を返してくるかを事前に読み切れないことです。結局、数多くのテストで引っかかった分の壊れ方しか、先回りして潰せません。

(c) 正常なJSONの直後に、もう1個JSONを返してくる

モデルが不調な日に、正しいJSONオブジェクトの直後に、もう一つ重複したオブジェクトを返してくることがあります。json.loadsExtra data: line N column Mで落ちます。

これはExtra dataのときだけ、先頭の有効なオブジェクトを1つ採って残りを捨てる、と割り切りました。

# Extra data のときだけ raw_decode で先頭の1オブジェクトだけ採る
obj, _idx = json.JSONDecoder().raw_decode(s)
return obj

ただし、途中で切れた(truncateした)JSONは復元してはいけません。中身が欠けたまま「それらしく」通ってしまうと、静かに壊れたデータが入るからです。壊れ方によって、救うか諦めるかを分けるExtra dataは救う、truncateは諦めて投げ直す。

この穴の教訓は、「純粋なJSONだけを返せ」はプロンプトのお願いであって、保証ではない、ということです。受け取り側に、剥がす・拾う・諦めるの3手を用意して初めて、json.loadsに届きます。

これもLLM固有の問題で、これまでのプログラムでは起こり得なかったものです。


穴②:形は合っているのに、型が揺れる

json.loadsを通ったら次はスキーマ(Pydantic)に詰めます。ここで2つ目の穴。JSONとしては正しいのに、フィールドの型が揺れる

いちばん多いのが、文字列のはずが配列で返ってくるケースです。

"overview": ["・要件定義を担当", "・結合テストを推進"]

こちらはoverviewを文字列(str)で受けたい。でもモデルは、箇条書きにしたくなると配列で返してくる。PydanticはこれをValidationErrorにします。そして厄介なのは、この例外が出ると、本来200 OKで返せるはずの解析結果が丸ごと422で落ちることです。1フィールドの型揺れで、経歴書1冊の解析が失敗する。

対策は、詰める前に正規化を1枚かませることです。

def _as_text(v) -> str:
    """Gemini が箇条書きを配列で返す揺れを吸収して文字列へ正規化する。
    リストは改行結合、None は空文字にする。"""
    if v is None:
        return ""
    if isinstance(v, list):
        return "\n".join(str(x).strip() for x in v if str(x).strip())
    return str(v)

配列で来たら改行で結合して文字列にする。Noneなら空文字。弾かずに、寄せる

逆向きの揺れもあります。「スキルは配列で返せ」と言っているのに、要素が1個だと文字列に潰して返してくる。

"skills": "Python"      ["Python"] のつもり

これも、詰める前に「文字列1個なら1要素の配列に包む」で吸収します。

さらに、数値フィールドに文字列の"不明"が入ってきたり、全角の600,000が来たり、Trueboolintのサブクラスなので、放っておくと1として通る)が来たりする。これらを受ける側の設計方針を、コードのコメントにこう書いています。

どのcoercerも「失敗しても None・空・元値を返す」非例外の方針。ここで例外を投げると、200 OKのはずの解析結果が422で落ちてしまう。

受け皿の関数は、例外を投げてはいけない。詰められなければNoneにして先へ通す。型の揺れは「捨てる理由」ではなく「寄せる対象」だ、という割り切りです。


穴③:受け皿を用意し忘れた欄は、エラーも出さずに消える

3つ目が、いちばん静かで、いちばん再発した穴です。

Pydanticのモデルに定義していないフィールドをモデルが返してきたとき、それはmodel_dumpの境界で黙って捨てられます。例外は出ません。ログにも残りません。プロンプトでは要求していて、Geminiはちゃんと返しているのに、こちらのスキーマに受け皿の欄が無いと、その値だけが音もなく消える

これを、同じ形で4回踏みました。

消えた欄 症状
team_size(チーム規模) プロンプトは要求、Geminiは返す。でも画面に出ない
tools(案件で使ったツール) 手で修正しても反映されない。保存したのに消える
summary_heading(要約見出し) プレビューにも帳票にも届かない
roles(複数役割・後述) フロントのフォールバックが誤発火

毎回、原因を探すのに時間を使いました。「Geminiが返していない」のか「表示側のバグ」なのかを疑ってから、最後に「そもそもスキーマに欄が無い=境界で消えている」に辿り着く。4回とも同じ道を通っています。

教訓は、プロンプトでフィールドを増やしたら、必ず受け側のスキーマにも欄を足す。片方だけ増やすと、増やした値は「返っているのに存在しない」という、いちばんデバッグしづらい状態になります。

これ自体はLLM固有の話ではありません。ただ、LLMのゆらぎに気を取られていると、こういう素の設計ミスまで「またLLMか」と見誤りやすくなります。

おまけの罠:空配列は、JavaScriptでは真

roles(1つの案件に複数の役割を持たせる欄)では、境界の黙殺に加えてもう一段踏みました。

役割が無い案件で、受け側が空配列[]を返すようにしていました。バックエンドとしては自然です。ところがフロントに渡ると、こんなフォールバックがある。

const roles = p.roles || _classifyCareerRoles(p.role);

[] はJavaScriptでは真(truthy)なので、p.rolesが空配列だと||の右側に落ちません。「役割が無いなら旧ロジックで推定する」が、永遠に発火しない。

直し方は、受け側で空配列ならNoneを返す。「無い」を[]で表さずNoneで表す。バックエンドの"空"とフロントの"偽"は、同じ形をしていない——このズレは、型が言語をまたいだ瞬間に牙をむきます。


穴④:同じ入力でも、毎回同じ答えとは限らない

最後は、構造化そのものではなく「再現性」の穴です。

判断や抽出の処理はtemperature=0.0にしています。「揺らぎをゼロにして、同じ入力なら同じ答えを返す」ためです。ところが、temperature=0でも、bit単位で同じ出力にはなりません。ここは、LLMを魔法だと思っている人がいちばん驚くところかもしれません。

なぜ完全一致しないのか。モデル自身に聞くと、GPUでの並列計算における「足し算の順序のゆらぎ」が主因だと説明します。

  • 浮動小数点の足し算は結合法則が厳密には成り立たない。(A + B) + CA + (B + C) で末尾がわずかにズレる
  • GPUは数千のコアで並列に計算するため、値の到着順やスレッドの完了順が実行のたびに微妙に変わる
  • 順序が変われば、行列計算の最終スコア(各トークンの点数)の最下位ビットに極小の差が出る
  • temperature=0は「スコア1位のトークンを選ぶ」処理だが、1位と2位が僅差だと、この誤差で順位が入れ替わる
  • トークンが1つ入れ替わると、以降の文脈が変わり、最終的な出力全体が変わる

つまり、決定的にしたつもりでも、ハードウェアの底のほうに揺らぎの種が残っています。

ここを勘違いすると、テストの書き方を間違えます。「同じ入力→完全一致」でアサートすると、CIがランダムに落ちる。なので、テストは完全一致ではなく不変条件で判定します。「金額が範囲内か」「必須キーが全部あるか」「役割の集合が変わっていないか」を見て、文字列の完全一致は見ない。

モデル選定でも、地味な現実にいくつかぶつかりました。

  • 無印のflashが、こちらのAPIキーでは404(提供されておらず、lite系しか使えない時期があった)
  • text-embedding-004も、API切替後は404。GA版へ移行した
  • liteは素だと日本語で誤爆する。「お客様」を金融機関と解釈したり、WMS/MESを無理に日本語化したり。プロンプトに「お客様・利用者は一般語」「WMS/MES/ERPなどの略語は保持」と明示列挙して、ようやく安定した
  • 出力の最大トークン数をコードに焼くと罠になる。モデルによって上限が違う(あるモデルは65536でよく、別のモデルは65535まで)ので、ハードコードするとモデル切替のたびに直す羽目になる

これらは「Geminiの投げ方」というより「LLMの現実」ですが、構造化を本番で回すなら避けて通れません。モデルは仕様書どおりの箱ではなく、キーとバージョンで挙動が変わる相手として設計します。


設計:受け側を「非例外」に倒し、プロンプトは頭を固定する

4つの穴に共通する対処を、2つの原則にまとめます。

受け側は、絶対に例外で落とさない

穴①〜③はすべて、受け取り側が「想定外」に出会ったときの振る舞いの問題でした。ここで例外を投げると、モデルの1回の気まぐれが、そのまま500や422になってユーザーに届きます。

なので、詰める手前の正規化はすべて「失敗してもNone・空・元値を返す」に統一しています。剥がす・拾う・寄せる・諦める、のどれかで受けきって、素のロジックで通らないものだけを落とす。プロンプトは「守ってくれたらいいな」の層でしかないので、その外側に、モデルの機嫌に左右されないロジックを一枚置く。これは市場単価の記事(本連載④)で書いた「AIに依存しない層を前後に置く」と、まったく同じ考え方です。

プロンプトは、頭を1文字も動かさない

構造化の指示にresponse_schemaを使っていない理由が、ここに関わります。

プロンプトは「先頭に長い静的な指示(スキーマ定義・ルール)を固定し、可変な原文を必ず後ろに置く」構成にしています。理由はプロンプトキャッシュです。先頭側が1文字でも変わるとキャッシュが壊れる。静的prefixを完全固定にすると、入力の単価が実測で約1/8になりました(同じ指示文を毎回投げ直さずに済む)。

だから、可変になりがちなスキーマ指定も文章として静的prefixに畳み込み、原文(信頼できない入力)はいちばん後ろに、境界マーカーで囲って置く。

⟦⟦UNTRUSTED_INPUT⟧⟧
(ここに経歴書の原文)
⟦⟦UNTRUSTED_INPUT⟧⟧

このマーカーは入力側からは除去してあるので、原文に同じ文字列を仕込んでも境界を偽装できません(プロンプトインジェクション対策)。構造化のプロンプトは、キャッシュとインジェクションの両方から「頭を動かさない」形に落ち着きました


余談:Temperatureゼロは、魔法を止めるスイッチではない

穴④で、ゆらぎの理由をモデル自身(Gemini)に聞きました。「GPUの足し算の順序が実行ごとに変わるからだ」と。技術的には、そのとおりです。

ただ、開発を手伝ってもらっているもう一体のAI(Claude。ここでは「クロ」と呼んでいます)に同じ話を振ると、設計者の視点で、こう補足してきました。

temperature=0は「いちばんもっともらしいトークンを選ぶ」設定であって、「毎回まったく同じ答えを返す」設定ではありません。決定性のスイッチだと思い込むと、足をすくわれます。

しかも、揺らぎの種はハードウェアだけではありません。送ったリクエストは、サーバ側で他の人のリクエストと一緒にバッチ処理されます。その"混み具合"で数値の通り道が変わる。つまり、自分の入力を1文字も変えていなくても、サーバの都合で出力が揺れることがあります。

結局、LLMはf(x) = yの関数ではなく、確率分布からサンプルを1つ引く装置です。Temperatureはサンプリングの鋭さを変えるダイヤルであって、決定性を約束するダイヤルではない。だから設計は「毎回同じ」に賭けてはいけません。「毎回、妥当な範囲に収まる」に賭ける。テストが完全一致ではなく不変条件を見るのは、その現れです。

魔法だと思っていると、「temperature=0という呪文を唱えれば固定される」と考えてしまいます。実際には、確率の分布が、GPUの計算誤差とサーバの運用という二重の現実の上に乗っている。呪文で固定できるほど、LLMは行儀よくありません


まとめ

経歴書の構造化で踏んだ穴を4つ、並べました。

前提としていたこと 現実
① 純粋なJSON 「JSONだけ返せ」と書けば返る 散文・フェンス・重複が混ざる
② 型の揺れ JSONが正しければ型も合う 配列⇄文字列・"不明"・全角が来る
③ 欄の黙殺 返した値は届く 受け皿が無い欄は無言で消える
④ 再現性 temp0なら毎回同じ bit一致はしない・モデルで挙動が違う

4つとも、根っこは1つです。モデルは決まった形で返してくれるという前提。言い換えれば、LLMを魔法だと思ってしまうことです。これを外して、「だいたい正しい形で返ってくるが、たまに崩れる相手」として受け側を設計すると、穴はふさがりました。

肝は、プロンプトを厚くすることではなく、受け側を非例外にすることです。プロンプトはいくら丁寧に書いても「お願い」の層でしかない。剥がす・拾う・寄せる・諦めるを受け取り側に持たせて、通らないものだけを素のロジックで落とす。ここまでやって初めて、モデルが多少ご機嫌ななめでも、構造化は倒れなくなります。

そして——AIが返した値を、AIで検算しない。型の検査も、範囲の検査も、境界の黙殺を防ぐ受け皿も、全部ただのコードです。モデルの外側にコードの層を一枚置くことが、構造化を本番で回すということでした。

※この記事の設計は、実際に運用しているサービスのものです。動いているものを見たい方はこちら(無料で試せます)。
 → https://dbv.co.jp/cnvskillsheetlp/?utm_source=qiita&utm_medium=article&utm_campaign=structuring


📚 関連記事(スキルシートと市場価値)

  1. PMだと思っていた自分が、スキルシートをAIに解析させたらPdMでDXコンサルだった話
  2. VertexAIを利用しようとして速度改善(経歴書解析を280秒→24秒にした)結果、結局VertexAIを止める羽目になった話
  3. AIに評価させると全員が優秀になる — 経歴判定で盛りを止めた設計
  4. ハルシネーションとは何か — 実在しない職種に60万円。ハルシネーションが起きづらい方法を使ってもLLMは嘘をつく。
  5. LLMは魔法じゃない — 本番で構造化をやって踏んだ4つの穴(既存のテストでは拾えない) ← 本記事

また、同じプロダクトを別の角度から書いた連載もあります。

📚 経営目線のSaaS開発

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?