📚 スキルシートと市場価値 シリーズ ⑤: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.loadsはExtra 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が来たり、True(boolはintのサブクラスなので、放っておくと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) + CとA + (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
📚 関連記事(スキルシートと市場価値)
- PMだと思っていた自分が、スキルシートをAIに解析させたらPdMでDXコンサルだった話
- VertexAIを利用しようとして速度改善(経歴書解析を280秒→24秒にした)結果、結局VertexAIを止める羽目になった話
- AIに評価させると全員が優秀になる — 経歴判定で盛りを止めた設計
- ハルシネーションとは何か — 実在しない職種に60万円。ハルシネーションが起きづらい方法を使ってもLLMは嘘をつく。
- LLMは魔法じゃない — 本番で構造化をやって踏んだ4つの穴(既存のテストでは拾えない) ← 本記事
また、同じプロダクトを別の角度から書いた連載もあります。
📚 経営目線のSaaS開発