📚 スキルシートと市場価値 シリーズ ④:LLMに「知らない」と言わせる
はじめに
LLMにはハルシネーションという性質があります。知らないことを「知らない」と言わず、それらしい答えを作って返してくる。悪癖と言っていいものです。
対策はいくつか知られています。なかでも、「公開されている事実や最新の市場情報」を扱うケースでよく使われるのが、検索グラウンディング(Google検索を噛ませた生成)です。学習済みデータではなく、その場で検索した一次情報を根拠に答えさせるため、一見するとハルシネーションを防ぎやすいアプローチに見えます。
今回は技術者の市場単価を調査するためにLLMに最新情報を取得させる必要があり、この検索グラウンディングが最適だと思って適用したのですが、それでもハルシネーションが起きたという話を記載します。
条件が揃うとエラーも警告も出さず、検索結果に存在しない数字を、いかにも実測したような顔ででっち上げてきます。金額が絡むぶん推測値が混じると実害が出るため、システム設計側でどう潰したかの記録です。
金額について
単価は自動でインターネットから取得しています。実際の相場と異なる可能性があります(実システム内ではすべて補正をかけ、現実的な金額に寄せています)。この記事では説明のため、補正をかける前に、インターネットから取得してきた値をそのまま使います。
1. 現象:架空の職種に60万円の値札がついた
職種マスタの動作確認のために TEST という職種を1件足しました。中身は空で、名前も TEST です。
そのまま同期を実行したときの、投げたプロンプトがこれです。
"""
日本のITエンジニア市場における、以下の職種それぞれの
「最新の市場実測 平均月額単価(円)」を調査してください。
対象職種:
- RANK_PM: PM
- RANK_DBA: DBA (データベース管理者)
- RANK_TEST: TEST
...
【制約】
- Google検索で最新の求人・フリーランス案件サイト等の一次情報を根拠に実測値を反映する。
- 出力は純粋なJSONオブジェクトのみ。
- price はカンマ無しの整数(円)。月額単価とする。
出力形式:
{ "RANK_PM": {"price": <整数>}, "RANK_DBA": {"price": <整数>}, ... }
"""
返ってきたのがこれです。
{
"RANK_PM": {"price": 1000000},
"RANK_DBA": {"price": 650000},
"RANK_TEST": {"price": 600000}
}
TEST に月60万円。検索して見つかるはずがありません。そんな職種は存在しないからです。それでもエラーにも null にもならず、他の職種とまったく同じ形式で、いかにも実測のような数字が並んで返ってきました。
2. 原因:「わからない」と言う手段を渡していなかった
原因は1つです。モデルに「わからない」と答える方法が用意されていませんでした。
出力形式を見てください。
出力形式: { "RANK_TEST": {"price": <整数>} }
price は整数、と指定しています。この形式には「値が無い」を表現する方法がありません。制約にも「一次情報を根拠に実測値を反映する」とは書いてありますが、根拠が見つからなかったときにどうするかが書いてありません。
そして検索グラウンディングは、検索結果を文脈に入れる機能であって、検索結果が無いときに生成を止める機能ではありません。TEST で検索しても求人は出てきませんが、文脈には「日本のIT職種の単価を調べている」という状況と、他の職種の検索結果が入っています。そこに TEST というIT職種一覧の中に置かれた名前が来れば、周辺情報から妥当な値を合成できてしまいます。
形式にも、指示にも、機能にも、「答えない」という逃げ道がない。だから埋めました。モデルは嘘をついたというより、埋めるしかなかったわけです。
検索が空振りしたことは、出力に現れません。 根拠つきの数字と、根拠なしの数字が、同じ形式で並んで返ってきます。
3. どのケースが本当に困るのか
職種名の種類ごとに、挙動と実害を整理しました。
| 入力(職種名) | 例 | 挙動 | 実害 |
|---|---|---|---|
| 実在する一般的な職種名 |
PM DBA インフラエンジニア
|
検索が効く。安定して取れる | なし |
| 実在するが社内表記 |
CE(カスタマーエンジニア) 上級CE
|
揺れる。取れたり null になったりする |
★これが一番困る |
| 社内造語 | 開発アシスタント |
推測値が返る | 造語だと気づけば直せる |
| 無意味語・テスト用 | TEST |
推測値が返る | 論外だが、見れば分かる |
TEST に60万円がつくのは論外です。ただ、運用上の実害としては小さい。見れば一発で分かるし、実データに紛れ込まないからです。
本当に困るのは CE(カスタマーエンジニア) の方でした。 これは実在する職種です。ハードウェアの保守やフィールド対応をする人たちで、求人もあります。にもかかわらず、うちの表示名が括弧つきの社内表記だったせいで検索に引っかからず、取れたり取れなかったりしていました。
架空の値札は目立ちます。取れるはずのものが静かに欠けるほうが、気づかれません。
4. 対策①:「わからない」を決まった形で返させ、それを許容する
まず出力形式に null を入れました。
- 出力形式: { "RANK_TEST": {"price": <整数>} }
+ 出力形式: { "RANK_TEST": {"price": <整数 または null>} }
そのうえで、プロンプトにこう書きます。
- 日本のIT市場に実在する職種名だと確信できない場合
(検索しても求人・案件が見つからない造語・略号・テスト用の名前など)は、
推測で数字を作らず price を null にすること。
汎用のIT職相場で埋めてはならない。null は誤りではなく正しい回答である。
- 対象職種は全て出力に含める(相場を確認できないものは price: null)。
やっていることは、「わからない」を一定の形で書かせ、それを正常な回答として許容することです。
-
price: null… 不明を表す決まった形。例外でもエラーでもなく、取りうる値の1つとして扱う -
null は誤りではなく正しい回答である… その形で返してよい、という許容の宣言 -
対象職種は全て出力に含める… これが無いと、答えられない職種をキーごと落としてきます。キー欠落は「取得漏れ」と区別がつかないので、必ず全キーを返させ、値の側で不明を表現させる
不明を「起きてはいけないこと」にすると、モデルは不明を隠して埋めます。不明に居場所を作ると、そこに入ってくれます。
5. 対策②:AIの外側で、ロジックが検査する
プロンプトはAIへのお願いであって、守られる保証はありません。指示は破られる前提で、受け取り側でも弾きます。ここはAIを使わず、素のロジックです。
_MIN_PLAUSIBLE_PRICE = 100_000 # 円・月額
_MAX_PLAUSIBLE_PRICE = 10_000_000
def _coerce_price(value) -> int | None:
"""採用できる整数(円) か None(=採用しない) に正規化する。"""
if value is None or isinstance(value, bool): # bool は int のサブクラス
return None
if isinstance(value, str):
t = value.strip().translate(str.maketrans("0123456789", "0123456789"))
if t.lower() in ("", "null", "none", "n/a", "-", "不明"):
return None
for ch in (",", ",", "¥", "¥", "円", " ", " "):
t = t.replace(ch, "")
value = t
try:
n = int(float(value))
except (TypeError, ValueError):
return None
if n < _MIN_PLAUSIBLE_PRICE or n > _MAX_PLAUSIBLE_PRICE:
return None
return n
| 返ってきた値 | 扱い | 理由 |
|---|---|---|
null "不明"
|
採用しない | 想定どおりの「わからない」 |
60 |
採用しない | 単位違い(60万円のつもり) |
99999999 |
採用しない | 桁化け |
True |
採用しない |
bool は int のサブクラス。1円 として通ってしまう |
"600,000" "¥600000円" 600,000
|
採用する | 表記ゆれは吸収する(弾くと取りこぼす) |
床と天井は「日本のIT月額単価としてありえない値」を落とすためのもので、正当な相場を落とさない幅に置いています。
採用しなかったときに「消さない」
採用しなかった職種には、何も書きません。
raw = _coerce_price(item.get("price"))
if raw is None:
skipped.append({"id": rid, "name": name_map[rid]})
continue # ← UPSERT しない = 前回の値がそのまま残る
単価テーブルは (職種, 日付) を主キーにしたUPSERTです。書かなければ前回値が生き残ります。「取れなかった」を「値が無い」にしてはいけません。一度黙られただけでデータが消えると、可用性の問題が正確性の問題に化けます。
落ちたデータを明示的に表示する
そのうえで、採らなかった対象を画面に名前で出します。
市場実測値バッチ完了(更新 29 件)。
⚠️ 相場取得不可(実在を確認できず単価を付けていません): TEST
null を握り潰すと、ガードが効きすぎているのか、正しく効いているのかを判定できません。実際、この表示が次の発見につながりました。
6. 対策③:保険として、検索キーワードを別に持たせる
ガードを入れて再実行したら、警告が増えていました。
⚠️ 相場取得不可: TEST、開発アシスタント
開発アシスタント は実在する自社マスタの職種で、過去には金額が入っていました。
開発を手伝わせているAIは「実在性の指示が厳しすぎるので緩めましょう」と提案してきました。却下しました。それはさっき捨てた推測値を拾い直す案です。
原因は逆側にありました。開発アシスタント は、実務1〜2年目の若手のために私たちが作った呼称です。求人サイトにその名前の募集は存在しません。つまりシステムは、社内造語をそのまま市場に問い合わせていました。
2026-07-20 raw 40万円 ← 人間が調べて決め、手で投入した値
2026-07-24 raw 45万円 ← 自動取得。LLMが返した値
45万円は測った数字ではなく、作られた数字でした。null が返るようになったのは、正しく動くようになったからです。
対処は保険をかけることです。表示に使う呼称はそのままに、市場に問い合わせるためのキーワードを別に持たせました。
| 呼称(画面・帳票に出る名前) | 市場検索語(LLMに聞く名前) |
|---|---|
| 開発アシスタント | 開発補助 / アシスタントエンジニア / 若手エンジニア / 新人エンジニア |
| CE(カスタマーエンジニア) | カスタマーエンジニア / フィールドエンジニア / フィールドサービスエンジニア |
| 上級CE | シニアカスタマーエンジニア / 上級カスタマーエンジニア / フィールドエンジニア リーダー |
対象職種:
- RANK_PM: PM
- RANK_DEV_ASSISTANT: 開発アシスタント (市場での募集名: 開発補助 / 若手エンジニア)
【制約】
- 「市場での募集名」が併記されている職種は、その募集名で検索して相場を答えること。
左側の職種名は当社独自の呼称であり、市場に同じ名前が存在しなくてよい。
- 併記が無い職種は職種名そのままで検索する。
社内造語の保険と、社内表記の保険。それだけです。併記が無ければ従来どおり職種名で検索します。
ひとつ注意点があって、保険に入れる言葉は適当に選べません。この呼称を決めたときに候補を market にかけた実測がこれです。
| 名乗り | 返ってきた相場 |
|---|---|
| 補佐(開発補佐) | 85万円 |
| アシスタント | 30万円 |
| 開発アシスタント | 40万円 |
指しているのは同じ層ですが、言葉によって引っ張ってくる相場が違います。「補佐」が高いのは、市場で PM補佐・経営補佐といった準幹部を指すからです。だから検索キーワードは実在していて、かつ狙った層の求人が出てくる語を人が選んで入れます。
7. 検証:境界は消えず、移動する
分離を入れる前、CE(カスタマーエンジニア) と 上級CE で、同じ日の2回の実行がこうなりました。
17:38:21 CE = 600,000 / 上級CE = 750,000 取得成功
19:47:22 CE = null / 上級CE = null 相場取得不可(前回値が残る)
この2回のあいだで、CE系の問い合わせ内容は1文字も変えていません(別の職種に検索語を足しただけです)。それでも、取れたり取れなかったりしました。
つまりこの2職種は「実在しない」のではなく、回答が揺れる境界のちょうど上に乗っていました。
ここで分かったのは、プロンプトを厳しくしても揺れは消えず、境界が移動するだけということです。なので揺れと粘り合うのはやめ、実在する募集名を入れて境界から遠ざける方向に倒しました。
対策の前後で、有効30職種の取得結果はこうなりました。
| 状態 | 結果 |
|---|---|
| 対策なし |
TEST に60万円。架空の値札が混入
|
| ガードのみ |
TEST = null(正)/開発アシスタント = null(正しいが値が取れない) |
| ガード+検索語の分離 | 30職種すべて取得。取りこぼし0 |
8. 採らなかった手
揺れを多数決で吸収する
同じ質問を3回投げて多数決を取る、というやり方もあります。採りませんでした。
リトライなんていうのは結局サイコロをふっているだけで、正解はでないからです。揺れる入力を投げ続けるより、揺れない入力に変える方が確実です。
検索キーワードをLLMに作らせる
「呼称から検索語を自動生成させればいい」とは考えました。やっていません。
造語の言い換えをLLMに作らせると、そこにまた推測が入ります。「開発アシスタント」に近い募集名を挙げさせれば候補は出ますが、それが実際に求人市場にある名前かは別の問いです。推測の上に推測を積むことになります。
固定値はブレが出ないように人間の仕事です。数十件のマスタなら、それで十分間に合います。
まとめ
ハルシネーションは、対策1つで止まることもあります。今回も、出力形式に null を足すだけで TEST は落ちました。
ただ、1枚のロックで止まったかどうかは、外からは分かりません。プロンプトはAIへのお願いであって、守られる保証がない。だから何重にもロックをかけます。
今回の3層は、こう並んでいます。
| 層 | 何を置いたか | AIに依存するか |
|---|---|---|
| 入口 | 検索キーワードを別に持たせる(保険) | しない(人が入れた固定値) |
| プロンプト |
null という「わからない」の形と、その許容 |
する |
| 出口 | 型・単位・桁・範囲の検査、採らなければ書かない | しない(素のロジック) |
肝は、AIに依存しない層を前後に置くことです。プロンプトだけを積み上げても、それは全部「守ってくれたらいいな」の層でしかありません。正規表現でも、範囲チェックでも、モデルの機嫌に左右されないロジックを外側に置いて初めて、通らないものは通らなくなります。
別の機能でも同じ考え方をしています。経歴からの役割判定では、AIが挙げた根拠が元の文章に実在するかをLLMを使わず照合し、さらに入力のハッシュで結果をキャッシュして、同じ入力なら必ず同じ答えを返すようにしています。AIの答えを、AIで検算しない。
最後に、今回いちばん効いた入口の層について。社内用語をそのまま外部APIのクエリにしていないかは、LLMに限らず見直す価値があります。人間が検索すれば「0件だ」と分かりますが、LLMは0件を報告せず、それらしい答えで埋めてくれます。
自分で検索して出てこない言葉は、LLMにも出てきていません。
※この記事の設計は、実際に運用しているサービスのものです。動いているものを見たい方はこちら(無料で試せます)。
→ https://dbv.co.jp/cnvskillsheetlp/?utm_source=qiita&utm_medium=article&utm_campaign=market_price
📚 関連記事(スキルシートと市場価値)
- PMだと思っていた自分が、スキルシートをAIに解析させたらPdMでDXコンサルだった話
- VertexAIを利用しようとして速度改善(経歴書解析を280秒→24秒にした)結果、結局VertexAIを止める羽目になった話
- AIに評価させると全員が優秀になる — 経歴判定で盛りを止めた設計
- ハルシネーションとは何か — 実在しない職種に60万円。ハルシネーションが起きづらい方法を使ってもLLMは嘘をつく。 ← 本記事
- LLMは魔法じゃない — 本番で構造化をやって踏んだ4つの穴(既存のテストでは拾えない)
また、同じプロダクトを別の角度から書いた連載もあります。
📚 経営目線のSaaS開発