
# 面向智能体的 Markdown 内容协商

智能体请求你的页面，拿到的是 HTML。然后它丢掉其中的绝大部分：导航、页脚、Cookie 横幅、内联脚本、class 属性、SVG 图标。留下的只是几段正文。

它想要的那个版本，你早就有了。只是几乎没人把它提供出去。

## 标记到底花掉了什么

把你发出去的东西和最后留下来的东西比一比：

```bash
curl -s https://example.com/about | wc -c
curl -s -H "Accept: text/markdown" https://example.com/about | wc -c
```

如果两个数字一样，说明你对所有客户端只给一种表示，清理工作由智能体自己做。这份工作并不免费。`<head>` 里的一切、每一层包裹用的 `<div>`、每个 `data-` 属性、每段统计代码，都会先读进上下文窗口，然后才被判定为噪声。一个真实内容只有一个标题加八个段落的页面，大部分字节都是脚手架。

Markdown 孪生版保留标题层级、链接、列表、表格和代码块。丢掉的是导航、样式和脚本，而这些本来也不会被引用。

## Accept 协商是怎么工作的

客户端声明自己能处理什么：

```
GET /about HTTP/1.1
Host: example.com
Accept: text/markdown
```

服务器为同一个 URL 挑选一种表示：

```
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
Vary: Accept
```

浏览器发送 `Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8`，拿到 HTML。请求 `text/markdown` 的客户端拿到 Markdown。规范链接不变，所以可供链接的地址仍然只有一个，可供分享的页面也只有一个。

## Vary: Accept，也就是浏览器为什么会下载到一个文本文件

`Vary` 不是可选项，而这恰恰是大家最容易跳过的一步。

你和读者之间的每一层缓存（你的 CDN、反向代理、浏览器自己的存储）都按 URL 索引响应。如果你在同一个 URL 上返回两种不同的正文却不作声明，最先被缓存的那份就会发给所有人。智能体带着 `Accept: text/markdown` 请求 `/about`，你的 CDN 存下 Markdown，下一个打开 `/about` 的人看到的就是满屏星号，或者一个下载提示。

```
Vary: Accept
```

这一行告诉缓存把 `Accept` 头也算进缓存键里，于是 HTML 和 Markdown 分开存放。**两种**响应都要带上它，不能只给 Markdown 那一份。

代价是真实存在的：`Accept` 头在不同客户端之间差异很大，按它分片会把缓存打碎。这是支持下面那种替代方案的一个诚实理由。

## .md 孪生链接

更简单的做法是再开一个 URL。`/about` 提供 HTML，`/about.md` 用 Markdown 提供同样的内容。没有协商，没有 `Vary`，缓存里也没什么可出错的地方。它就是一个普通 URL，可以被链接、被收藏、写进站点地图，也能被任何客户端抓取，哪怕对方根本不发送有用的 `Accept` 头。

然后再把它的存在说出来。在 HTML 里：

```html
<link rel="alternate" type="text/markdown" href="https://example.com/about.md">
```

在响应头里，给那些只发 `HEAD`、从不解析正文的客户端：

```
Link: <https://example.com/about.md>; rel="alternate"; type="text/markdown"
```

两处都写各花一行，正好覆盖只看一个地方的客户端。

## 自查一下

```bash
# 1. 同一个 URL 会给出不同响应吗？
curl -s -H "Accept: text/markdown" https://example.com/about | head -20

# 2. HTML 响应承认孪生版存在吗？
curl -sI https://example.com/about | grep -i "vary\|link"

# 3. .md 链接单独访问能用吗？
curl -sI https://example.com/about.md | grep -i "content-type\|vary"
```

第二条命令抓的是最常见的那种失败。一个站点可以有完美的 `.md` 孪生版，却永远没人找得到，因为没有任何响应头或标签指向它。

## 我们自己是怎么做的

本站每个页面都有 Markdown 孪生版，全部逻辑都在 `packages/web/src/lib/content-negotiation.js` 里，大约九十行。

一张固定的映射表列出静态路径和它们的孪生链接：`/` → `/index.md`、`/about` → `/about.md`、每个工具页面，依此类推。带有 ID 或 slug 的路径（`/r/<uuid>` 的报告、指南、像这样的博客文章）改用正则匹配，得到 `<路径>.md`。

对请求的判断是刻意粗糙的：只要 `Accept` 头里含有 `text/markdown` 这个字符串，就返回 Markdown，不按 `q` 值排序。浏览器从不发送这个字符串，所以实际使用中两种情况分得很干净，但一个把 `text/markdown` 排在低优先级的客户端同样会拿到 Markdown。

每个 Markdown 响应都会带上这些头：

```
Content-Type: text/markdown; charset=utf-8
Content-Signal: search=yes, ai-input=yes, ai-train=no
Vary: Accept
X-Markdown-Tokens: 4213
Cache-Control: public, max-age=3600
```

`Content-Signal` 声明的是内容可以被拿去做什么，而不只是能不能被抓取。`X-Markdown-Tokens` 是一个体积标记，目前放的是正文的字符数，并不是真正的 token 数。

有孪生版的页面，其 HTML 响应会带上 `Vary: Accept` 和 `Link` 头，`<head>` 里则从同一张映射表取出 `rel="alternate"` 标签。

最后这个细节咬过我们一口。模板读的就是那张映射表，而正则匹配的路由并不在表里。于是每一份分析报告都在提供自己的 Markdown 孪生版，也在 `Link` 头里公布了它，可它的 HTML 里只字未提。孪生版一直存在，对任何读取标记的客户端却始终隐形。

## 这是惯例，不是标准

`Accept: text/markdown` 用的是普通的 HTTP 内容协商，那部分确实是标准。把它用在 Markdown 上则是从 llms.txt 提案里长出来的惯例，没有哪个爬虫有义务这么请求。诚实的说法是：`.md` 孪生链接才是那个稳妥有效的部分，因为它和别的 URL 没有区别，什么客户端都抓得动；而协商只花一个响应头，在那些真的会问的客户端身上得到回报。两者都不会害你，至于实际被用的是哪一种，看日志，别看公告。

[测试你的 Markdown 协商](/zh/tools/markdown-tester) · [生成你的 llms.txt](/zh/tools/llms-txt-generator)
