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/plainapplication/json(HTML 之外的任何内容)且正文 ≥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 重定向 — 选择内联

提供代理友好内容有两种方式。内联更好:

内联(推荐): 同一 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) 代理向用户报告的 URL 是用户询问的 URL,而不是重定向目标;(3) 使用 Vary: Accept 缓存更清洁。/llms.txt 路由继续存在,供直接提取的工具使用 — 两个路由调用相同的内容函数。

Vary: Accept 至关重要

每当同一 URL 根据 Accept 返回不同正文时,设置 Vary: Accept。这告诉共享缓存(CDN、代理、浏览器)缓存键必须包含 Accept 标头值。

否则,CDN 可能会缓存一个代理提取的 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, */* 时:

扫描器运行四个探测: markdown 在前 (Claude Code / Cursor 模式)、HTML 在前并将 markdown 列在第二 (浏览器 / ChatGPT Agent 模式 — 检测忽略客户端顺序而使用服务器端偏好的站点)、明确的 q 值偏好 HTML 而非 markdown (检测完全忽略 q 值的站点)、和 JSON 在前 (编程式发现模式)。四个都必须通过。如今 Content-Type 标签对基于 LLM 的代理来说大多是装饰性的 — 无论 MIME 类型如何,它们都会解析正文字节。但基于浏览器的 AI 扩展和新兴的 MCP 工具会根据 Content-Type 分支,随着生态系统的成熟,差距会扩大。

修复是处理程序中的一行更改: 从协商类型而不是硬编码值设置响应 Content-Type。如果你的代码对 Accept: text/plainAccept: text/markdown 都返回 text/plain,请根据协商类型分支并相应地标记。

此检查是必需的 — 未通过会在内容协商组中失分。

诊断你的 bug — q 值与三种模式

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, */* 意味着这三个都是同等优先的 — 然后标头中的顺序打破平局。正确的服务器会选择 markdown。

合适的 Accept 协商器 (Express req.accepts()、npm 的 negotiator 包、Python werkzeug、Go goautoneg) 会自动处理所有这些: 解析 q 值,在平局时尊重顺序,选择服务器可以提供的最佳匹配。

我们在生产中看到的三种 bug 模式

如果你的网站未通过"代理 UA 获得非 HTML"检查,原因几乎总是以下之一:

模式 1: 子字符串匹配。 在固定的 if-else 顺序中检查 Accept 标头是否包含某种类型的代码。示例:

// 错误 — 检查的顺序,而不是 Accept 中的顺序,决定胜负
if (accept.includes('text/html')) return html;
else if (accept.includes('text/markdown')) return markdown;

客户端发送 Accept: text/markdown, text/html → 服务器返回 HTML,因为 text/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 返回 markdown (尽管 plain 列在前面,markdown 还是赢了),那就是模式 3。

一个快速的诊断测试

对你的主页运行这五个 curl 命令。响应中的模式会告诉你你有哪个 bug:

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/

修复配方在所有三种情况下都是相同的: 用上面列表中合适的 Accept 协商器替换你的临时选择逻辑。

内联 vs 302 重定向 — 该怎么做

在主页提供代理友好内容的两种模式:

内联 — 同一 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 中记录的最佳实践,它明确列出了基于重定向 (反应式) 协商的缺点: "受到传输备选列表的影响... 需要第二个请求来获取替代表示" 和 "未定义支持自动选择的机制"。

我们测试的所有主要内容协商感知站点都使用内联:

为什么内联具体上获胜

  1. 延迟减半。 一次 HTTP 提取而不是两次。HTTP/2 和 HTTP/3 多路复用不会消除重定向成本。
  2. URL 保真度。 代理报告给用户的 URL 就是用户询问的 URL。使用 302 时,代理最终位于 /llms.txt (不同的 URL)。
  3. 更清洁的缓存。 内联与 Vary: Accept 让缓存在一个 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 的站点。当行业对内联的采用足够广泛时,它将升级为必需。