内容协商
同一个 URL,对人和对 agent 返回不同格式的同一份内容。
标准依据是 RFC 9110 §12.5.1(Accept 头)与 RFC 7763(text/markdown)。
这是 HTTP 从 1.1 就有的能力,不是新发明。
判定算法#
markdown = Accept 里显式声明的 text/markdown 或 text/x-markdown 中最优的
html = Accept 里 text/html、application/xhtml+xml 或通配符中最优的
每个表示先按「具体类型 > 类型通配符 > 全局通配符」确定有效 q
若 markdown 不存在 → 返回 HTML
若 markdown 的 q 为 0 → 返回 HTML(q=0 是明确拒绝)
若 html 不存在 → 返回 markdown
若两者 q 不同 → 返回 q 大的那个
若 q 相同 → 返回在头里排得更靠前的那个
三个会把实现写错的地方#
一、q 缺省为 1.0,平局比顺序。
Claude Code 发 text/markdown, text/html, <通配符>,不写 q 值。
两者都是 q=1.0。用严格 > 比较会判定为「无偏好」从而返回 HTML——
不报错、不警告、日志干净,只是永远不生效。
二、通配符不算「想要 markdown」。
只发通配符的客户端(Gemini CLI、Windsurf)意思是「给什么都行」。 若把通配符算作 markdown 偏好,它们会被塞 markdown。 所以 markdown 一侧只认显式声明。
三、text/plain 不是 markdown 请求。
OpenCode 会带 text/plain;q=0.8,那是它的降级选项。
宁可漏给(客户端仍拿到可用的 HTML),不可错给。
四、具体类型先于通配符。
如果 HTML 被具体类型明确降权或拒绝,不能再用一个 q 更高的宽泛通配符把它放行。 RFC 9110 要求先选匹配该表示的最具体媒体范围,再比较不同表示的 q。
在静态托管上怎么实现#
静态托管只吐文件,不解析请求头。所以需要两层:
- 构建期:为每个页面生成
.md孪生文件(零运行时成本) - 边缘函数:读
Accept,决定回哪个文件
项目里带了三个平台的实现:Cloudflare Pages Functions、Netlify Edge Functions、 Vercel Middleware。不用这一层站点照常工作,只是 agent 拿到 HTML。
必须做的两件事#
Vary: Accept——否则 CDN 会把 markdown 缓存下来发给浏览器。 这个 bug 只在缓存命中时出现,会让人以为「有时候网站会坏」。- 只在 markdown 是显式偏好时才返回——见上面第二点。
- 声明表示关系——
Content-Location指向.md孪生文件,Link同时声明 HTML canonical 与 markdown alternate,避免两种表示成为信息孤岛。
效果数据#
| 来源 | 结果 |
|---|---|
| Cloudflare(自己的文档) | 80% token 减少 |
| Vercel(自己的博客) | 500 KB → 2 KB,99.6% |
回到本项目的代码#
src/lib/negotiate/accept.ts。真值表用七个 agent 的真实 Accept 原文做回归,
固件在 accept.test.ts 里,注释写明了每一条的判定理由。
这一条指向(2)
已复核 · 2026-09-23 · 1 个来源
- RFC 9110: HTTP Semantics, §12.5.1 Acceptrfc9110§12.5.1 Accept
「已复核」指有人在上面那个日期读过这一页并确认了当时的正文。它不表示内容一定正确,也不表示每条引用的原文都逐句支持了这里的结论—— 后者只能由人判断。正文在那之后若被改动,复核会自动失效,构建会拦下来。
这一页也有 markdown 版本:/letterpress/wiki/content-negotiation.md
直接打开上面的 .md 链接即可读取;在支持内容协商的部署平台上, 也可以对页面 URL 发送 Accept: text/markdown。