AgentGrade
EnglishEspañol日本語中文
← ナレッジベース

AIエージェント向けコンテンツネゴシエーション

エージェント向けコンテンツネゴシエーションとは

コンテンツネゴシエーションとは、クライアントが要求する内容に応じて同じURLから異なるレスポンスを返すことです。AIエージェントの場合、エージェントからのリクエストには Markdown やプレーンテキスト(LLMが既にうまくパースできる形式)を返し、ブラウザには引き続き HTML を返します。同じURL、異なる表現 — RFC 9110 §12.5 に基づきます。

なぜ重要か

LLM駆動のエージェントは通常、HTTPヘッダー、ステータスコード、リダイレクトチェーンではなく、レスポンスボディのみを受け取ります。HTML を提供すると、エージェントは DOM 構造をパースし、レイアウトを取り除き、ナビゲーションを破棄する必要があり — モデルが有用なものを見る前にトークンを浪費します。クリーンなテキストレスポンスはモデルに実際のコンテンツを直接渡します。

Accept 優先順位の落とし穴

最も一般的なコンテンツネゴシエーションのバグは、Accept ヘッダーを単純な部分文字列チェックとして扱うことです。Claude Code の WebFetch ツールは次を送信します:

Accept: text/markdown, text/html, */*

これはクライアントが優先順位順に次のように言っています:「あれば markdown を、なければ HTML を、なければ何でも」。if (accept.includes('text/html')) のような単純なチェックは文字列内の text/html を見て HTML を返します — text/markdown が最初にリストされていることを無視して。

RFC 9110 §12.5.1 によると、q値が指定されていない場合、メディアタイプの順序が優先順位を表します。正しい実装は Accept リストをパースし、q値を適用し、サーバーが提供できる最も左の型を選択します。

AgentGrade が確認すること

エージェント UA が非 HTML を取得 — ホームページに User-Agent: claude-code/1.0.0Accept: text/markdown, text/html, */* を送信します。text/markdowntext/plain、または application/json を 20 バイト以上のボディで返せばチェックに合格します。Accept を部分文字列でマッチして HTML を返すサイトは失敗します。

Accept: JSON が JSON を返すAccept: application/json を送信して有効な JSON が返るか確認。

Accept: text がテキストを返すAccept: text/plain を送信してプレーンテキストか markdown を確認。

Accept: markdown が markdown を返すAccept: text/markdown を送信して markdown かプレーンテキストを確認。

Vary: Accept 設定 — ネゴシエーションする場合、共有キャッシュがエントリーを正しくキー付けできるよう、レスポンスに Vary: Accept を含める必要があります。

正しい実装方法

部分文字列マッチングの代わりに、適切な Accept ネゴシエーターを使用します:

// Express — req.accepts は内部で negotiator パッケージを使用
app.get('/', async (req, res) => {
  res.vary('Accept');
  const best = req.accepts(['text/html', 'text/markdown', 'text/plain', 'application/json']);
  if (best === 'text/markdown' || best === 'text/plain') {
    return res.type(best).send(await buildLlmsTxt());
  }
  if (best === 'application/json') {
    return res.json({ name: 'あなたのサービス', api: '/openapi.json' });
  }
  res.sendFile('index.html');
});

他のエコシステム:

インライン vs リダイレクト — インラインを選ぶ

エージェント向けコンテンツを提供する方法は2つあります。インラインの方が優れています:

インライン(推奨): 同じURLが Accept に応じて異なるボディを提供。

GET / → 200 OK
  Content-Type: text/html (ブラウザ) | text/markdown (エージェント)

リダイレクト(レガシー): エージェントを /llms.txt に送る。

GET / → 302 Found, Location: /llms.txt
GET /llms.txt → 200 OK

インラインが勝つ理由:(1) 2回ではなく1回のフェッチ — レイテンシ半減;(2) エージェントがユーザーに報告するURLはユーザーが尋ねたURL自身であり、リダイレクト先ではない;(3) Vary: Accept でキャッシュがよりクリーン。/llms.txt ルートは直接フェッチするツール向けに引き続き存在 — 両方のルートが同じコンテンツ関数を呼び出します。

Vary: Accept は重要

同じURLが Accept に応じて異なるボディを返す場合は常に Vary: Accept を設定します。これにより共有キャッシュ(CDN、プロキシ、ブラウザ)に、キャッシュキーに Accept ヘッダーの値を含める必要があると伝えます。

これがないと、CDN は1つのエージェントフェッチからの markdown レスポンスをキャッシュし、ブラウザ訪問にそれを提供する — またはその逆 — 可能性があります。Vary ヘッダーは、ボディが異なるときにキャッシュエントリが交換可能にならないようにする唯一のものです。

既知の AI エージェント User-Agent

エージェントUser-Agent目的
ClaudeBotMozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)Anthropic 訓練クローラー
Claude-UserMozilla/5.0 ... (compatible; Claude-User/1.0; +Claude-User@anthropic.com)claude.ai web_fetch、Claude API web_search ページ読み取り
Claude-SearchBot(文字列未公開)Anthropic 検索インデックスクローラー
claude-codeclaude-code/<version>Claude Code CLI WebFetch ツール
ChatGPT-UserMozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot)ユーザー主導 ChatGPT ブラウズ
OAI-SearchBotMozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot)OpenAI 検索インデックス
OAI-AdsBotMozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot)OpenAI 広告クローラー
GPTBotMozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot)OpenAI 訓練クローラー
PerplexityBotMozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)Perplexity 検索結果クローラー
Perplexity-UserMozilla/5.0 ... (compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)ユーザー主導 Perplexity フェッチ
Google-Extended(Googlebot UA を使用)Google Gemini 訓練、robots.txt で制御

Web Bot Auth — 次のシグナル

増加するエージェントリスト(ChatGPT Agent は本日確認済み;Anthropic、Perplexity、Google が予定)が、RFC 9421 HTTP Message Signatures に従ってリクエストを暗号学的に署名します。シグナルは Signature-Agent リクエストヘッダーです:

Signature-Agent: "https://chatgpt.com"
Signature-Input: sig=("@authority" "signature-agent"); keyid="..."; tag="web-bot-auth"
Signature: sig=...

受信リクエストに Signature-Agent が見える場合、UA がブラウザのように見えてもクライアントを既知のエージェントとして扱います。完全な検証のために、Signature-Agent で指定されたホスト(/.well-known/http-message-signatures-directory)から JWKS をフェッチし、web-bot-auth npm パッケージで署名を検証します。コンテンツネゴシエーション目的では、ヘッダーの存在だけで十分なソフトシグナルです。

さらに学ぶ

優先タイプ vs 非 HTML — 次のバー

「エージェント UA は非 HTML を取得」は基本チェックです: サーバーは HTML 以外のものを提供しましたか?「優先 Content-Type を返す」は厳密版です: レスポンスの Content-Type は、クライアントがシグナルした先頭タイプと一致しましたか?

例えば、クライアントが Accept: text/markdown, text/html, */* を送信したとき:

スキャナーは 4 つのプローブを実行します: markdown 先頭 (Claude Code / Cursor のパターン)、HTML 先頭で markdown を 2 番目にリスト (ブラウザ / ChatGPT Agent のパターン — クライアント順序を無視してサーバー側の優先順位を使用するサイトを検出)、HTML を markdown より優先する明示的な q値 (q値を完全に無視するサイトを検出)、JSON 先頭 (プログラマティック検出パターン)。4 つすべてが合格する必要があります。今日、Content-Type ラベルは LLM ベースのエージェントにとってほとんど装飾的です — MIME タイプに関係なく、本体のバイトを解析します。しかし、ブラウザベースの AI 拡張機能と新興の MCP ツールは Content-Type で分岐し、エコシステムが成熟するにつれてギャップは広がります。

修正はハンドラー内の 1 行の変更です: ハードコードされた値ではなく、ネゴシエートされたタイプからレスポンス Content-Type を設定します。コードが Accept: text/plainAccept: text/markdown の両方に対して text/plain を返している場合は、ネゴシエートされたタイプで分岐し、適切にラベル付けします。

このチェックは必須です — 失敗するとコンテンツネゴシエーショングループでポイントを失います。

バグの診断 — q値と3つのパターン

q値の仕組み

クライアントが Accept で複数のタイプを送信する場合、0.0 から 1.0 の q値 (品質係数) を付加して相対的優先度を表現できます:

Accept: text/markdown;q=1.0, text/html;q=0.5, */*;q=0.1

意味: 「markdown が本当に欲しい。バックアップとして HTML を受け入れる。それ以外は最後の手段。」

q値が指定されていない場合、デフォルトは 1.0 です。したがって Accept: text/markdown, text/html, */* は 3 つすべてが等しく優先されることを意味します — そしてヘッダー内の順序がタイブレーカーになります。正しいサーバーは markdown を選択します。

適切な Accept ネゴシエーター (Express req.accepts()、npm の negotiator パッケージ、Python werkzeug、Go goautoneg) はこれらすべてを自動的に処理します: q値の解析、タイ時の順序尊重、サーバーが提供できる最良の一致の選択。

実環境で見られる 3 つのバグパターン

サイトが「エージェント UA は非 HTML を取得」チェックに失敗した場合、原因はほぼ常に次のいずれかです:

パターン 1: 部分文字列マッチング。 Accept ヘッダーが特定のタイプを含むかどうかを固定の if-else 順序でチェックするコード。例:

// 誤り — Accept の順序ではなく、チェックの順序が勝つ
if (accept.includes('text/html')) return html;
else if (accept.includes('text/markdown')) return markdown;

クライアントが Accept: text/markdown, text/html を送信 → text/html が文字列内にあるため、サーバーは HTML を返します。クライアントの優先順位は完全に無視されます。

パターン 2: */* で HTML を提供するフレームワークのデフォルト。 一部のフレームワークは、明示的な非 HTML タイプが先にリストされていても、Accept 内の */* を HTML にフォールバックするライセンスとして扱います。Rails 8 の respond_to は注目すべき例です:

Accept: text/markdown, */*       → Rails は HTML を返す (markdown 無視)
Accept: text/markdown, text/html → Rails は markdown を返す (*/* なし、順序尊重)

パターン 3: サーバー内部の優先順位 + q値の無視。 サーバーには独自の優先順位リスト (どこかにハードコードされていることが多い) があり、Accept ヘッダーからサーバーのリスト上で最も高いタイプを選びます — クライアントのリスト上ではありません。q値はまったく解析されません:

Accept: text/plain;q=0.9, text/html;q=0.5 → HTML を返す
                                            (q値が明示的に plain を優先しているにもかかわらず
                                             サーバーは html を優先する)

パターン 3 の決定的証拠は、q値が無視されていることです。同じサイトが上の行で HTML を返し、Accept: text/plain, text/markdown (plain が最初にリストされているにもかかわらず markdown が勝った) で markdown を返す場合、それはパターン 3 です。

簡単な診断テスト

ホームページに対してこれら 5 つの curl コマンドを実行してください。レスポンスのパターンが、どのバグがあるかを教えてくれます:

curl -sI -H "Accept: text/markdown" YOUR_SITE/
curl -sI -H "Accept: text/markdown, text/html, */*" YOUR_SITE/
curl -sI -H "Accept: text/plain, text/markdown" YOUR_SITE/
curl -sI -H "Accept: text/markdown, */*" YOUR_SITE/
curl -sI -H "Accept: text/plain;q=0.9, text/html;q=0.5" YOUR_SITE/

修正方法は 3 つのケースすべてで同じです: アドホックな選択ロジックを上記のリストの適切な Accept ネゴシエーターに置き換えてください。

インライン vs 302 リダイレクト — どうすべきか

ホームページでエージェント向けコンテンツを提供する 2 つのパターン:

インライン — 同じ URL が Accept ヘッダーに応じて異なるボディを返します。

GET /  + Accept: text/html      →  200 + HTML
GET /  + Accept: text/markdown  →  200 + markdown

リダイレクト — サーバーはエージェントリクエストを別の正規 URL に送信します。

GET /  + Accept: text/markdown  →  302, Location: /llms.txt
GET /llms.txt                   →  200 + markdown

インラインを使用してください。 これは RFC 9110 §12.2 で文書化されたベストプラクティスであり、リダイレクトベース (リアクティブ) ネゴシエーションの欠点を明示的に列挙しています: 「代替リストの送信に苦しみ... 代替表現を取得するために 2 回目のリクエストが必要」、「自動選択をサポートするメカニズムを定義していません」。

テストしたすべての主要なコンテンツネゴシエーション対応サイトはインラインを使用しています:

なぜインラインが具体的に優れているか

  1. レイテンシ半分。 2 回ではなく 1 回の HTTP フェッチ。HTTP/2 および HTTP/3 のマルチプレキシングはリダイレクトコストを排除しません。
  2. URL の忠実性。 エージェントがユーザーに報告する URL は、ユーザーが尋ねた URL です。302 では、エージェントは /llms.txt (異なる URL) で終わります。
  3. クリーンなキャッシュ。 インラインと Vary: Accept により、キャッシュは両方の表現を 1 つの URL キーで保存できます。
  4. エージェント専用のマジック URL がない。 インラインは URL 空間を統一されたままにします。

AgentGrade が確認すること

Inline content negotiation チェックはエージェント形式のリクエスト (claude-code/1.0.0 UA + Accept: text/markdown, text/html, */*) を送信し、レスポンスがブラウザリクエストとは異なる URL で終わらないことを確認します。普遍的なリダイレクト (HTTPS アップグレード、末尾スラッシュ正規化) はペナルティを受けません — エージェント固有のリダイレクトのみです。

修正方法

302 ロジックをインラインネゴシエーションに置き換えます。Express の例:

app.get('/', async (req, res) => {
  res.vary('Accept');
  const best = req.accepts(['text/html', 'text/markdown', 'text/plain']);
  if (best === 'text/markdown') {
    return res.type('text/markdown').send(await buildLlmsTxt());
  }
  res.sendFile('index.html');
});

/llms.txt ルートは別の URL として引き続き存在できます — 両方のルートが同じコンテンツ関数を呼び出します。

このチェックは新興 (オプション) です — 今日は 302 を使用するサイトをペナルティしません。業界でインラインの採用が十分に広まったときに必須に昇格します。