この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/llm-internal-links-placeholder-sanitize )。
何を作ったか
カテゴリ内の記事を束ねる「ハブページ」をLLMに書かせる機能を、WordPressプラグインとして実装しました。ハブページの価値は本文よりもリンクが全部正しいことにあります。存在しない記事へのリンク、他社サイトへのリンク、もっともらしいけれど1文字違うパーマリンクが1本でも混ざれば、そのページは公開できません。
そこで方針を「LLMの出力をチェックして、おかしなリンクを直す」ではなく、**「LLMにはURLを1文字も書かせない。リンクはすべてこちらで組み立てる」**にしました。本記事はその設計と、敵対的な応答を食わせた実測結果です。
設計:プレースホルダ → 全削除 → 組み立て → 補完
処理は4段です。
- プロンプトでは記事にURLを渡さず、
[[POST:1]]のような番号つきプレースホルダだけを渡す - 応答から
<a>タグ・http(s)://・www.で始まる文字列をすべて削除する(アンカーの中の文字だけ残す) - 残ったプレースホルダを、DBから読んだ実パーマリンクで
<a>に置き換える。範囲外の番号は捨てる - LLMが書き漏れた記事は、末尾に「このカテゴリの他の記事」リストとして必ず追加する
プロンプトに渡す一覧はこうなります。URLはどこにも出てきません。
## The articles, each with the placeholder that stands for it
1. [[POST:1]] "記事A" (published 2026-09-01)
(抜粋)
2. [[POST:2]] "記事B" (published 2026-09-05)
(抜粋)
そして末尾に「絶対に破ってはいけない規則」として、リンク・URL・ドメイン名を書かないこと、記事はプレースホルダで参照すること、書いたアドレスは削除されることを明記します。
ポイントは、プロンプトの指示は守られなくても成立するように作ることです。指示は品質を上げるためのもので、安全性は後段の決定論的な処理だけで担保します。
後段の処理(PHP)
実装の中心部分です(WordPress環境なので esc_url() / esc_html() を使っています)。
const TOKEN = '/\[\[\s*POST\s*:\s*(\d{1,3})\s*\]\]/';
function apply_real_links( $html, $posts, &$linked ) {
$linked = array();
// 1. LLMが書いた<a>は外して中の文字だけ残す
$html = (string) preg_replace( '#<a\b[^>]*>(.*?)</a>#isu', '$1', $html );
// 2. 閉じ忘れ・片割れの<a>も消す
$html = (string) preg_replace( '#</?a\b[^>]*>#i', '', $html );
// 3. アドレスに見えるものは場所を問わず消す
$html = (string) preg_replace( '#\bhttps?://[^\s<>"\'\]\)]+#iu', '', $html );
$html = (string) preg_replace( '#\bwww\.[^\s<>"\'\]\)]+#iu', '', $html );
// 4. ここで初めて、DBのパーマリンクからリンクを作る
$html = (string) preg_replace_callback(
TOKEN,
function ( $m ) use ( $posts, &$linked ) {
$i = (int) $m[1] - 1;
if ( ! isset( $posts[ $i ] ) ) {
return ''; // 範囲外の番号は捨てる
}
$linked[] = (int) $posts[ $i ]['id'];
return '<a href="' . esc_url( $posts[ $i ]['url'] ) . '">'
. esc_html( $posts[ $i ]['title'] ) . '</a>';
},
$html
);
// 5. LLMが勝手に作ったプレースホルダ風の文字列を消す
return (string) preg_replace( '/\[\[[^\]]{0,60}\]\]/', '', $html );
}
順序が重要です。削除(1〜3)を先に、組み立て(4)を後にしています。逆にすると、自分で作った正しいリンクまで3の正規表現で消してしまいます。また4でリンク文字列に記事タイトルを入れるのは、LLMに「タイトルを書かないで」と指示しているのと対になっています(タイトルの二重表示と、タイトルの言い換えを防ぐため)。
書き漏れの補完は $linked に入らなかった記事を集めて末尾に <ul> を足すだけです。これでハブページは常にカテゴリの完全な索引になります。
実測:敵対的な応答13パターン
この関数を単体で切り出し、LLMが「やりそうなこと」を13パターン食わせました(PHP 8.5.7 CLI、記事は3本、パーマリンクは https://example.test/a/ 等)。判定は「出力中の href がすべて実在の3URLのどれかか」と「攻撃側ドメインの文字列が残っていないか」です。
| パターン | 入力の例 | 外部href | 外部ドメイン残存 | リンクされた記事 |
|---|---|---|---|---|
| 正常 |
[[POST:1]]〜[[POST:3]]
|
0 | 0 | 3本 |
他社の <a>
|
<a href="https://evil.example/x">こちら</a> |
0 | 0 | 1本+補完2本 |
| 裸URL | 参考: https://evil.example/path?q=1 |
0 | 0 | 1本+補完2本 |
www. 始まり |
www.evil.example/page |
0 | 0 | 1本+補完2本 |
| 偽の内部リンク | <a href="https://example.test/not-exist/"> |
0 | 0 | 1本+補完2本 |
| 範囲外の番号 |
[[POST:9]] [[POST:0]]
|
0 | 0 | 1本+補完2本 |
| 表記ゆれ |
[[ post : 2 ]] [[Post:2]]
|
0 | 0 | 1本+補完2本 |
| 捏造プレースホルダ |
[[LINK:1]] [[記事A]]
|
0 | 0 | 0本+補完3本 |
| 書き漏れ |
[[POST:1]] だけ |
0 | 0 | 1本+補完2本 |
<a> の閉じ忘れ |
<a href="https://evil.example/">… |
0 | 0 | 1本+補完2本 |
| Markdownリンク | [こちら](https://evil.example/md) |
0 | 0 | 1本+補完2本 |
| ドメイン名だけ | evil.example を検索 |
0 | 1 | 1本+補完2本 |
| プロトコル相対 | <img src="//evil.example/p.png"> |
0 | 1 | 1本+補完2本 |
外部への href は13パターンすべてで0でした。偽の内部リンク(自サイトのドメインだが存在しないパス)も消えています。これは「URLの妥当性を検査する」方式では取りこぼしやすいケースで、「LLMが書いたURLは正誤を問わず全部捨てる」方式だから確実に落ちます。
どのパターンでも、最終的に3本すべての記事へのリンクが揃っています(本文中+末尾補完)。
カナリア試験:除去を止めると何が起きるか
テストが本当に除去処理を検査しているかを確かめるため、1〜3と5の除去を無効化して同じ13パターンを流しました。
他社aタグ hrefs=4 外部href=1 残存evil=1
裸URL hrefs=3 外部href=0 残存evil=1
www hrefs=3 外部href=0 残存evil=1
偽内部リンク hrefs=4 外部href=1 残存evil=0
閉じ忘れa hrefs=4 外部href=1 残存evil=1
外部 href が3パターンで復活し、アドレス文字列の残存も増えます。除去を外すとテストが落ちる=テストが除去処理を実際に見ていることの確認です。
残った穴と、その塞ぎ方
表の最後の2行が、この方式の限界です。
-
ドメイン名だけ(
evil.example)はURLの形をしていないので残ります。リンクにはならないので被害は「本文に他社名が出る」程度ですが、ゼロではありません -
プロトコル相対の
<img src="//...">はhttp(s)://にもwww.にも当たらず残ります。これはリンクではありませんが、ページを開いた人のブラウザが外部へリクエストを送るので、<a>より性質が悪い残り方です。WordPressのwp_kses()(投稿用の許可リスト)もimgと相対プロトコルを許すため、ここでは落ちません
教訓は、「URLの形」を列挙して消す拒否リストは、URLを持てる属性を持つタグを見落とすということです。対処として次の2行を足し、同じ13パターンで再実測しました。
// URLを属性に持てるタグは、LLMの出力からは丸ごと捨てる
$html = (string) preg_replace( '#<(img|iframe|source|video|audio|embed|object|link)\b[^>]*>#i', '', $html );
// 中身を消されたMarkdownリンクの殻 [文字]() を文字だけに戻す
$html = (string) preg_replace( '#\[([^\]]*)\]\(\s*\)#u', '$1', $html );
プロトコル相対の行は外部ドメイン残存が 1→0 になり、Markdownリンクは [こちら]() という殻が こちら に戻りました。根本的には、LLMに許すタグを p / h2 / h3 / ul / ol / li / strong / em 程度の許可リストに絞る方が筋がよく、拒否リストの追記は応急処置と考えています。
実モデルでの挙動(1回の観測)
Gemini(gemini-2.5-flash)で、記事6本のカテゴリに対して実際にハブページを1回生成しました。応答は約44秒、本文は約3,000字で、本文中のリンク14箇所はすべて実在のパーマリンク(全てHTTP 200)、6本すべてが本文中で参照され、末尾補完は発動しませんでした。
ただしこれは1回の観測で、モデルが指示を守った結果にすぎません。この設計の要点は、守らなかった回でも壊れないことの方にあります。
まとめ
- LLMにURLを書かせると、存在しない記事・他社サイト・1文字違いのパーマリンクが混ざり得る
- URLの正誤を検査するより、LLMにはプレースホルダだけを渡し、書かれたURLは正誤を問わず全部捨て、DBから組み立てる方が確実
- 削除→組み立ての順序を守る。書き漏れは末尾リストで補完してページの完全性を保証する
- 拒否リストは「URLの形」だけでなく「URLを持てるタグ」も見る。最終的にはタグの許可リストへ寄せる
検証の続きや関連ツールはACS Developerで公開しています → ACS Developer