LLMハルシネーション対策の実装例|数値をplaceholderで注入する
LLM に「数値は正確に書いて」と念を押しても、幻覚や転記ミスは確率的に混ざる。有効なのはプロンプトの言い回しを工夫することではなく、正確さがモデルの出力品質に依存しない経路を作ることだと考えている。本稿は、統計値を placeholder で書いて決定的に注入する契約と、原稿と配信データを機械照合して不一致なら exit 1 で止める稼働中の組版ゲートを、本サイトの実物で解説する。
なぜプロンプト対策では足りないか
「気をつけて書いて」「ここは正確に」といったプロンプトでの指示は、モデルの出力分布を多少ずらすだけで、誤りの確率をゼロにはしない。LLM の生成は本質的に確率的である。数値の幻覚や、資料からの転記時の桁ずれは、低い確率であっても回数を重ねれば必ず起きる。
一方で、公開する記事は決定的でなければならない。とりわけデータを扱うサイトでは、一つの数値の間違いが記事全体の信頼を崩し、AdSense の審査でもコンテンツ品質の毀損とみなされうる。確率的にしか正しくない工程から、決定的に正しい成果物は生まれない。
だから対策の設計は、プロンプトの改善ではなく工程の設計に置く。「気をつける」は工程設計ではない。人間が気をつけることも、モデルに気をつけさせることも、見落としが確率的に残る点では同じである。必要なのは、誤りうる主体を、正確さが要求される経路から外してしまうことである。
設計 — 数値が LLM を経由しない経路
本サイトでは、統計値を扱う記事群のために数値注入契約を用意している。原則は一つ、集計出力に由来する統計値を LLM が本文へ直書きすることを禁じる、である。
本文中の統計値は、値そのものではなく placeholder で書く。記法は {{num:<article_id>.<key>}} で、たとえば {{num:01.tokyo_net_inflow_2025}} のように書く。この時点で原稿に生の数字は一つも無い。
値の供給源は集計処理の出力である values.json だけに限る。スキーマはこうなっている。
{ "tokyo_net_inflow_2025": { "value": 68285, "fmt": "int", "unit": "人" } }
value が実数、fmt が整形指定で、int(カンマ区切り)・float1・float2・pct1(%付与)・raw から選ぶ。桁区切りや小数桁の整形も、本文側の手作業ではなく決定的な処理へ寄せる。
注入は決定的なスクリプトが担い、原稿の placeholder を values.json の値で置換して配信用テキストを作る。流れは次のとおり。
原稿(placeholder) ─┐
├─→ 決定的な注入 ─→ 配信用テキスト
values.json ─────────┘ │
└─ 未解決の placeholder が1つでも残れば exit 1
要点は最後の一行にある。置換しきれなかった placeholder が一つでも残れば、注入は exit 1 で失敗し、記事ゲートを通さない。書き忘れやキー名の打ち間違いは、静かに空欄になるのではなく、明示的な失敗として止まる。
対象外の線引きも契約に書いてある。「2020年国勢調査」のような調査名・年次や、「47都道府県」「3つの仮説」のような集計値でない一般数は直書きしてよい。迷ったら placeholder に倒す、というのが運用の指針である。
ただしこの注入スクリプト自体は統計値を扱う記事トラック向けで、まだ運用開始前である。契約を先に文書化し、同じ原理の検証ゲートを別の場所ですでに稼働させている——次章がその実物である。
稼働実物 — 組版ゲートの整合検証
本サイトの記事は、原稿 articles/src/NN-<slug>.md を組版ツール tools/build-articles.php に通して配信用の HTML 断片へ変換し公開する。このツールが、数値注入契約と同じ原理の検証ゲートになっている。
記事のメタデータ、つまりタイトル・説明文・公開日は、原稿の frontmatter と配信データ data/sites.json の2箇所に存在する。二重に持つ以上、LLM が原稿を書き換えるときにどちらか一方だけを直してしまう転記ミスが起こりうる。組版ツールは両者を機械照合し、一致しなければエラーに積む。
if (!isset($fm['title']) || $fm['title'] !== $meta['title']) {
$errors[] = 'title が一致しません: frontmatter=' . ($fm['title'] ?? '(なし)') . ' / sites.json=' . $meta['title'];
}
タイトル・説明文・公開日・更新日それぞれに同じ照合があり、どこがどうずれているかをメッセージに残す。検証は最初の1件で止めない。全記事・全項目を検証し終えてから、エラーがあれば全件を列挙して exit 1 する。しかもディスクへの書き込みは全件成功を確認してからまとめて行う。途中まで書いて失敗し、古い断片を半端に上書きしたまま止まる事故を避けるためである。
if ($allErrors !== []) {
fwrite(STDERR, "組版失敗(" . count($allErrors) . "件のエラー)。ファイルは一切書き込んでいません:\n");
foreach ($allErrors as $e) {
fwrite(STDERR, " - {$e}\n");
}
exit(1);
}
もう一つ、本文の記法も検証する。対応する Markdown サブセット以外——画像記法 ![]() や <div> のような HTML タグの直書き、# による h1——を検出すると、やはりエラーに積んで止める。LLM が気を利かせて生の HTML を混ぜても、黙って通って壊れることはない。
if (preg_match('/<[a-zA-Z\/][^>]*>/', $outsideCode)) {
$errors[] = '未対応記法(HTML タグの直書き): ' . trim($rawLine);
}
抜粋はいずれも tools/build-articles.php の該当箇所そのままで、前後は省略している。そして、いま読んでいるこの記事自体も、この組版ゲートを通って公開されている。失敗を exit 1 でゲートに接続する運用そのものは別稿で扱った。
応用範囲
この原理は統計値に限らない。価格表・スペック表・ベンチマーク結果のように、正確さが要求される事実を LLM の文章に埋め込むあらゆる場面に効く。一般化すればこうなる——二重管理があるところに機械照合を、集計値があるところに決定的注入を。
料金やモデル名のような揮発情報も、本文に直書きすると更新漏れが静かに古い値を残す。出どころを一箇所に決めて placeholder で参照すれば、値の更新は供給源の1点で済み、本文を触らずに済む。
重要なのは、LLM に文章の生成という得意な仕事をさせつつ、事実の正確さという不得意な仕事を経路から外す、という分業である。文章と事実を同じ手に握らせないことが、構造で誤りを防ぐということである。
よくある質問
RAG との違いは。 RAG は参照する情報の正確さを高める手法で、生成そのものは LLM が行う。本方式は出力経路の決定性を担保するもので、層が異なる。併用できる——RAG で正しい値を引き、その値を決定的に注入すればよい。
人間のレビューだけでは足りないのか。 足りない。人間の見落としも確率的に起きる。数十の数値を毎回突き合わせる作業では、機械検証は疲れず、抜けない。レビューは機械検証の代わりではなく、その上に積むべきである。
まとめ
LLM の数値ミスは、プロンプトの工夫ではなく工程の設計で防ぐ。数値を placeholder で書いて決定的に注入し、二重に持つメタは機械照合し、未解決や不一致は exit 1 で止めてゲートに接続する。誤りうる主体を、正確さが要求される経路から外す——対策はこの一点に集約される。契約を先に書き、同じ原理の組版ゲートを先に稼働させておくのが、堅実な順序だと考えている。