📝 Summary
- 「AIがあれば知識ゼロでもサービスが作れる」という言説に、LLMが出力を組み立てる仕組み(JIT的な具体化)とハルシネーションの関係から反論する
- ハルシネーションは、抽象から具体への変換過程で情報が不足した箇所を「もっともらしさ」で埋めてしまう現象であり、モデルの欠陥というより構造的な帰結に近い。プロンプトの具体性とハルシネーション率の相関を示した研究や、OpenAIの2025年の分析を根拠として紹介する
- 個人サイトの実装で実際に起きた、「動いているように見えて何も守っていなかった」Referer/Originチェックのバグを実例に、ドメインの抽象語彙を持たないままAIに実装を任せることの危うさを示す
1. 導入:「視界を絞る」は誰にでもできる操作ではない
「そのAI開発、ハルシネーションに耐えられる?」と聞かれて、即答できる人は多くないと思う。少なくとも自分は、この記事で書く実例に本番環境で行き当たるまで、即答できなかった。
以前、AIの「視界」をハックせよ。という記事を書いた。要旨は、AIに何でも見せるのではなく「今どこを見るべきか」をこちらが明示的に区切ってやると、推論のノイズが減って精度が上がる、というものだった。
この記事を書いたあとで、一つ気になっていたことがある。あの「視界を絞る」という操作は、実はかなり高度な前提の上に成り立っている。「ログイン機能のバグだから src/components/Auth/ だけを見ろ」と指示できるのは、指示する側が「これはログインの問題で、関係するのは認証まわりのコードだ」とすでに判断できているからだ。この判断ができない人――つまり自分が今何を触っているのか言語化できない人には、そもそも「絞るべき範囲」自体が見えない。
つまり「AIの視界を制御する」というテクニックは、「知識不要でAIに何でも作らせる」という言説とは正反対の場所に立っている。今回はこの違和感を、LLMが出力を組み立てる仕組みそのものから説明してみる。
2. 抽象と具体、そしてJIT的具体化
LLMは、知識をデータベースのようにそのまま保存して取り出しているわけではない。むしろ、必要になった瞬間に意味空間から情報を組み立てる、Just-In-Time(JIT)的な動作に近い。
JIT(ジャストインタイム)はもともとトヨタ生産方式に由来する言葉で、「必要なものを、必要なときに、必要な分だけ」作る方式を指す。あらかじめ大量に作り置き(在庫)しておくのではなく、必要になった瞬間にその場で作る、という考え方だ。プログラミング言語の実行環境(JavaScriptエンジンやJVMなど)の「JITコンパイラ」も同じ発想で、コードをあらかじめ全部コンパイルしておくのではなく、実行に必要になった箇所だけをその場でコンパイルする。この記事でも同じ発想の比喩として使う。LLMは具体的な出力をあらかじめ全部作り置きして記憶しているのではなく、出力が必要になった瞬間に、その場で具体を組み立てている。
大まかに言うとこういう流れになる。
入力
↓
抽象化(何についての話か、構造・関係性を把握する)
↓
抽象空間での推論
↓
JITによる具体化(固有名詞・数値・実装の詳細へ変換)
↓
出力
ここでの「抽象」とは構造・関係性・意味そのもの、「具体」とは現実の事実・固有名詞・実装の詳細を指す。LLMの推論の多くは抽象レベルで行われ、最後の最後に具体へレンダリングされる。
この見方を裏付ける研究もある。プロンプトの「具体性(concreteness)」「形式性(formality)」がハルシネーション発生率と相関することを検証した研究では、より具体的で形式的なプロンプトほどハルシネーションが減少する傾向が報告されている(Rawte et al., 2023)。裏を返せば、プロンプト側に十分な具体・抽象の足場が無いと、モデルは自分でその隙間を埋めにいく、ということでもある。
3. ハルシネーションの正体:尤もらしい穴埋め
ハルシネーションは、しばしば「AIが嘘をつく」「推論を間違える」ように語られる。だが実態にもう少し近い説明は、こうだ。
抽象から具体へ変換する際に、具体情報が不足していると、もっともらしい内容で補完してしまう現象
OpenAIが2025年に公開した研究では、この現象をより踏み込んで説明している。事前学習は「データの分布を再現する」ことを目的にしており、真実性を検証するようには作られていない。ある事実についてモデルが十分な情報を見ていなければ、「もっともらしいが誤った」文を生成してしまう。さらに評価・ベンチマークの側も「わかりません」と答えるより自信満々に間違えたほうがスコアが高くなる設計になっており、これが構造的にハルシネーションを助長している、と指摘されている(Kalai, Nachum & Vempala, "Why Language Models Hallucinate", OpenAI, 2025)。
つまりハルシネーションは、モデルの欠陥というより「具体的な情報がないところを、抽象的なパターン(尤もらしさ)だけで埋めた結果」だと捉えたほうが実態に近い。
ここで重要なのは、次のことだ。
抽象は答えではなく、具体へ導く「道標」である。
動物 → 犬 → 日本原産 → 立ち耳 → 巻き尾 → 柴犬、のように、抽象から具体へ降りるたびに候補は絞られていく。この道標が不足していたり、途中で飛ばされたりすると、モデルは足りない部分を自分の手持ちの「もっともらしさ」で埋めるしかなくなる。
これを逆向きに読むと、こうなる。具体に正しくアクセスしたいなら、まず十分に狭い抽象(=ドメインの語彙・概念)が必要だ。 抽象は具体より劣った、曖昧なものではない。具体への唯一のアクセス経路そのものだ。「知識ゼロでAIに任せれば具体的な実装ができあがる」という発想は、この経路をすっ飛ばそうとしている。
4. 実例:一度は正しかった防御が、気づかないまま壊れていた
理論だけでは実感が湧きにくいので、実際に自分のサイトで起きた話をする。
個人サイト(motimotinotch.com、Astro + Cloudflare Workers + R2構成)には、画像・動画の直リンクを防ぐために、リクエストの Referer / Origin ヘッダーが自サイトのドメインから来ているかをチェックするコードがある。この防御は最初、sec-fetch-dest によるアドレスバー直打ちの遮断・Referer/Originの厳密チェック・Vary ヘッダーの3点セットとして設計・検証済みだった。検証項目も一通りクリアしていた実装だ。
ところが、その後AIに実装を任せた別の改修のどこかで、この防御が静かに壊れていた。数ヶ月後、別件の調査中に本番環境を実際に確認したところ、次のような状態になっていた。
// 意図: Referer/Origin が許可ドメインのものだけを通す
const isAllowed = allowedDomains.some(domain => requestHost.includes(domain));
一見、普通のチェックに見える。「許可ドメインのリストに含まれているか」を includes() で見ている。動かしてみても、エラーは出ない。403で弾かれるべきでないケース(サイト内での画像表示)は問題なく通るし、パッと見の動作確認では違和感が出ない。
しかし実際には、このチェックはReferer/Originが無いリクエストも含めて常に通過していた。原因は、requestHost が「リクエストの送信元(Referer/Origin)」ではなく「リクエストの宛先=自分のサイト自身のホスト名」を参照していたことだった。自分のサイトのホスト名は、当然ながら許可ドメインのリストに含まれる(=含まれて当然の文字列を includes() で見ていた)。つまりこのチェックは、誰が呼び出したかに関係なく、実質的に常に true を返す条件式になっていた。
これは「悪意ある第三者のドメイン名が偶然サブ文字列として一致してしまう」という古典的な部分一致バイパスとも少し違う。もっと基本的なところで、「どの変数を検証すべきか」という抽象的な区別(=『リクエストの宛先』と『リクエストの送信元』は別物である、という理解)が抜け落ちていた、という話だ。コードの見た目は「チェックをしている」形をしているのに、中身は何も検証していない。一度は正しく組まれていた防御が、AIに書かせた改修のどこかでこの区別を失い、動いているように見える(=コードとして存在し、エラーも出ない)まま、誰にも気づかれずに本番へ残り続けていた。
修正後のコードは、Referer/Origin ヘッダーの値そのものからホスト名を取り出し、許可ドメインと厳密一致するかを見るように直している。
let refererHost: string | null = null;
try { refererHost = referer ? new URL(referer).hostname : null; } catch { /* 不正な値は無視 */ }
const isAllowedHost = (hostname: string) =>
allowedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
const isFromAllowedSite =
(!!refererHost && isAllowedHost(refererHost)) ||
(!!originHost && isAllowedHost(originHost));
「Referer/Originとは何を検証するためのものか」「部分一致と厳密一致はどう違うか」という抽象的な理解が無ければ、このバグには気づけない。テストを流しても、通常のアクセス経路では正常に動いて見えるからだ。AIに「直リンクを防いで」とだけ頼んでも、この区別を持っていなければ、レビューする側もAIの出力する「それらしいコード」を見分けられない。
(この実装をめぐる9層の防御の全体像や、他の実装の詳細は「画像・動画を守る9層の実装:Cloudflare Workers + R2で作る多層防御」で扱っている。)
5. では、どうすればいいか
理屈は分かっても、次に何をすればいいのかが見えないと意味がない。今回の実例から言えることは、そう複雑ではない。
-
実装を頼む前に、抽象を一文で書き出す。「このチェックは何を検証するものか」を、コードを書く前に自分の言葉で言い切る。今回であれば「Referer/Originは“誰が呼んだか”を検証するものであり、“どこに届いたか”ではない」という一文があれば、
requestHostという変数名を見た瞬間に違和感に気づけたはずだ -
書き出した抽象と、実際のコードが本当につながっているかを、変数レベルで追跡する。「求めるべき仕様」と「書かれた具体」が一致しているかを、動かす前にコードそのもので確認する。今回のバグは、
allowedDomains.some(domain => requestHost.includes(domain))という、見た目には「それらしい」ロジックの中で起きていた。変数名やコードの形が仕様に対応しているように見えても、実際に検証している対象(requestHost)が仕様の主語(Referer/Originの送信元)とズレていないかを、1行ずつ辿って確認しないと見抜けない -
「動いているか」ではなく「その一文を満たしているか」でレビューする。通常のブラウザ操作での動作確認は、この種のバグを素通りさせる。意図的にその抽象的な保証を破る操作(Refererを送らない
curlでのアクセス、別タブでの直打ちなど)で確認する必要がある - AIに実装の意図を説明させ、自分の一文と照合する。前回記事で書いた「座標の自己言及」と同じ発想だ。実装後に「このコードは何を検証していますか」とAI自身に言わせてみると、書いたコードと本来の意図とのズレが言語化されて浮かび上がることがある
- 一度正しく実装されたものも、定期的に同じ確認をやり直す。今回の防御も、最初は検証済みだった。後続の改修でその前提が静かに壊れることがある以上、実装した時点をゴールにしてはいけない
6. まとめ
- LLMは知識をそのまま取り出すのではなく、抽象空間で推論し、出力の瞬間にJIT的に具体へレンダリングする
- ハルシネーションは、この具体化の過程で情報が不足した箇所を「もっともらしさ」で埋めてしまう現象であり、モデルの欠陥というより構造的な帰結に近い(Rawte et al., 2023; Kalai et al., 2025)
- 抽象は具体より曖昧で劣ったものではなく、具体へ正しくアクセスするための道標であり、アクセスキーである
- ドメインの抽象語彙(今回の例では「Referer/Originが何を意味するか」)を持たないまま実装を進めると、AIは動いているように見えて何も守っていないコードをもっともらしく出力し、レビューする側もそれを見抜けない
- 「AIがあれば知識不要でサービスが作れる」は、この道標を持たずに具体へアクセスしようとする試みであり、うまくいかない場合が多い。AI時代にも「何を求めるべきかを言語化する=抽象化する力」は本質的なスキルであり続ける
冒頭の問いに戻ると、正直に言えば、今回の実例に気づくまでの自分の答えは「はい」ではなかった。「動いている」と「安全である」は別物だと言えるようになったのは、抽象という道標を意識するようになってからだ。
📘 今回の実例(Referer/Originチェック)を含む、9層の防御の全体像と実装コードは、こちらの本で解説している。