0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMにURLを1文字も書かせない — 内部リンクはプレースホルダで受けてDBから組み立てる

0
Posted at

この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/llm-internal-links-placeholder-sanitize )。

何を作ったか

カテゴリ内の記事を束ねる「ハブページ」をLLMに書かせる機能を、WordPressプラグインとして実装しました。ハブページの価値は本文よりもリンクが全部正しいことにあります。存在しない記事へのリンク、他社サイトへのリンク、もっともらしいけれど1文字違うパーマリンクが1本でも混ざれば、そのページは公開できません。

そこで方針を「LLMの出力をチェックして、おかしなリンクを直す」ではなく、**「LLMにはURLを1文字も書かせない。リンクはすべてこちらで組み立てる」**にしました。本記事はその設計と、敵対的な応答を食わせた実測結果です。

設計:プレースホルダ → 全削除 → 組み立て → 補完

処理は4段です。

  1. プロンプトでは記事にURLを渡さず、[[POST:1]] のような番号つきプレースホルダだけを渡す
  2. 応答から <a> タグ・http(s)://・www. で始まる文字列をすべて削除する(アンカーの中の文字だけ残す)
  3. 残ったプレースホルダを、DBから読んだ実パーマリンクで <a> に置き換える。範囲外の番号は捨てる
  4. 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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?