面向 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.0 和 Accept: text/markdown, text/html, */* 发送到你的主页。如果你提供 text/markdown、text/plain 或 application/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');
});
其他生态系统:
- Node.js(无框架):
negotiatornpm 包 - Python:
werkzeug.wrappers.AcceptMixin或request.accept_mimetypes.best_match - Go:
github.com/markusthoemmes/goautoneg - Ruby on Rails:
respond_to do |format|块处理响应生成,但 Rails 将 Accept 中的*/*视为提供 HTML 的许可 —Accept: text/markdown, */*即使 markdown 是首选也返回 HTML。修复方法:在respond_to运行之前,在before_action中根据第一个非通配符 Accept 类型显式设置request.format。仅重新排序format.X块不会覆盖*/*回退。 - Cloudflare Workers: 手动解析
request.headers.get('Accept')或使用acceptnpm 包
内联 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 | 用途 |
|---|---|---|
| ClaudeBot | Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com) | Anthropic 训练爬虫 |
| Claude-User | Mozilla/5.0 ... (compatible; Claude-User/1.0; +Claude-User@anthropic.com) | claude.ai web_fetch、Claude API web_search 页面读取 |
| Claude-SearchBot | (字符串未公布) | Anthropic 搜索索引爬虫 |
| claude-code | claude-code/<version> | Claude Code CLI WebFetch 工具 |
| ChatGPT-User | Mozilla/5.0 ... (compatible; ChatGPT-User/1.0; +https://openai.com/bot) | 用户发起的 ChatGPT 浏览 |
| OAI-SearchBot | Mozilla/5.0 ... (compatible; OAI-SearchBot/1.3; +https://openai.com/searchbot) | OpenAI 搜索索引 |
| OAI-AdsBot | Mozilla/5.0 ... (compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot) | OpenAI 广告爬虫 |
| GPTBot | Mozilla/5.0 ... (compatible; GPTBot/1.3; +https://openai.com/gptbot) | OpenAI 训练爬虫 |
| PerplexityBot | Mozilla/5.0 ... (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot) | Perplexity 搜索结果爬虫 |
| Perplexity-User | Mozilla/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 包验证签名。出于内容协商目的,仅标头的存在就是足够的软信号。
了解更多
- RFC 9110 §12.5 — 内容协商
- llms.txt 规范
- Anthropic 爬虫文档
- OpenAI 机器人文档
- Perplexity 爬虫文档
- Cloudflare Web Bot Auth
首选类型 vs 非 HTML — 下一道门槛
"代理 UA 获得非 HTML" 是基本检查: 服务器是否提供了 HTML 之外的任何内容?"返回首选 Content-Type" 是严格版本: 响应 Content-Type 是否与客户端发出信号的领先类型匹配?
例如,当客户端发送 Accept: text/markdown, text/html, */* 时:
- 服务器返回
Content-Type: text/markdown→ 通过两项检查 - 服务器返回
Content-Type: text/plain→ 通过基本检查,未通过严格检查 - 服务器返回
Content-Type: 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/plain 和 Accept: 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/
- 如果只有第一个返回 markdown: 模式 1 (子字符串匹配)。
- 如果前三个返回 markdown 但第四个返回 HTML: 模式 2 (
*/*回退)。 - 如果第五个返回 HTML 而第三个返回 markdown (或反之取决于你认为你提供的内容): 模式 3 (服务器偏好 + q 值忽略)。
修复配方在所有三种情况下都是相同的: 用上面列表中合适的 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 中记录的最佳实践,它明确列出了基于重定向 (反应式) 协商的缺点: "受到传输备选列表的影响... 需要第二个请求来获取替代表示" 和 "未定义支持自动选择的机制"。
我们测试的所有主要内容协商感知站点都使用内联:
- GitHub API — 同一 URL 根据 Accept 变化,无重定向
- Stripe docs —
docs.stripe.com/api从同一 URL 返回 HTML 或 markdown,带Vary: Accept - Cloudflare developer docs — 边缘转换,同一 URL
- Vercel、Mintlify、Sanity — 都在公开指南中推荐内联
为什么内联具体上获胜
- 延迟减半。 一次 HTTP 提取而不是两次。HTTP/2 和 HTTP/3 多路复用不会消除重定向成本。
- URL 保真度。 代理报告给用户的 URL 就是用户询问的 URL。使用 302 时,代理最终位于
/llms.txt(不同的 URL)。 - 更清洁的缓存。 内联与
Vary: Accept让缓存在一个 URL 键下存储两种表示。 - 没有专门的代理 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 的站点。当行业对内联的采用足够广泛时,它将升级为必需。