面向智能体的 Markdown 内容协商
智能体请求你的页面,拿到的是 HTML。然后它丢掉其中的绝大部分:导航、页脚、Cookie 横幅、内联脚本、class 属性、SVG 图标。留下的只是几段正文。
它想要的那个版本,你早就有了。只是几乎没人把它提供出去。
标记到底花掉了什么
把你发出去的东西和最后留下来的东西比一比:
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 里:
<link rel="alternate" type="text/markdown" href="https://example.com/about.md">
在响应头里,给那些只发 HEAD、从不解析正文的客户端:
Link: <https://example.com/about.md>; rel="alternate"; type="text/markdown"
两处都写各花一行,正好覆盖只看一个地方的客户端。
自查一下
# 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 没有区别,什么客户端都抓得动;而协商只花一个响应头,在那些真的会问的客户端身上得到回报。两者都不会害你,至于实际被用的是哪一种,看日志,别看公告。