同一篇内容可以面向浏览器返回 HTML,面向 AI 客户端返回 Markdown,但分流依据不应只依赖 User-Agent。本文用 Cloudflare Workers 实现 Accept 协商、表示级缓存隔离、明确降级和自动化验证。
先定义分流契约,而不是猜客户端身份
最直接的实现往往是检查 User-Agent:发现字符串里有某个机器人名称,就返回 Markdown,否则返回 HTML。这个方案短期可用,长期却很脆弱:名称会变化,代理可能改写请求头,普通脚本也能伪装,新的 AI 客户端则默认无法识别。
更稳定的做法是协商“客户端需要什么表示”,而不是推测“客户端是谁”。HTTP 已经提供了 Accept 请求头,例如:
Accept: text/markdown, text/html;q=0.8
它表达的是客户端优先接受 Markdown,也可以接受 HTML。服务端还可以提供显式参数,方便调试、分享和不便设置请求头的客户端:
/articles/content-negotiation?format=md
本文采用以下契约:
| 输入 | 返回表示 | 说明 |
|---|---|---|
format=html | HTML | 显式参数优先 |
format=md | Markdown | 适合 AI、知识库和命令行工具 |
format=txt | 纯文本 | 最低能力表示 |
无参数,Accept 可匹配 | 按质量值选择 | 支持 q 权重 |
无 Accept 或 */* | HTML | 对浏览器和通用客户端友好 |
| 没有任何可接受表示 | 406 | 不静默返回错误格式 |
User-Agent 仍可用于日志统计和兼容性观察,但不进入核心路由判断。这样即使同一个 AI 产品更换名称,只要继续声明 Accept: text/markdown,服务端行为就不需要改变。
实现可预测的 Accept 协商
协商不能只写成 accept.includes("markdown")。请求可能带多个媒体类型、通配符和质量值,例如 text/*;q=0.6, text/html;q=0。更具体的规则应覆盖通配规则,q=0 则表示明确拒绝。
下面的 Worker 实现一个实用子集:解析媒体类型与 q,按具体程度确定候选质量,再用服务端顺序解决同分情况。默认顺序是 HTML、Markdown、纯文本,因此 */* 会稳定回退到 HTML。
用 Cloudflare Workers 完成分流与缓存隔离
将以下内容保存为 src/index.js,然后执行 npx wrangler dev src/index.js 即可在本地运行。示例只服务一个固定路径,实际项目可以把 ARTICLE 替换为 CMS、KV 或数据库查询结果。
const ARTICLE = {
title: "内容协商示例",
summary: "同一份源内容可以生成 HTML、Markdown 和纯文本表示。",
paragraphs: [
"浏览器通常更适合接收带语义结构的 HTML。",
"AI 客户端可以通过 Accept 请求结构更简洁的 Markdown。"
]
};
function escapeHTML(value) {
return value.replace(/[&<>\"]/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
"\"": """
})[char]);
}
function renderHTML(article) {
const paragraphs = article.paragraphs
.map((item) => `<p>${escapeHTML(item)}</p>`)
.join("\n");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHTML(article.title)}</title>
</head>
<body>
<main>
<h1>${escapeHTML(article.title)}</h1>
<p>${escapeHTML(article.summary)}</p>
${paragraphs}
</main>
</body>
</html>`;
}
function renderMarkdown(article) {
return `# ${article.title}\n\n${article.summary}\n\n${article.paragraphs.join("\n\n")}\n`;
}
function renderText(article) {
return `${article.title}\n\n${article.summary}\n\n${article.paragraphs.join("\n\n")}\n`;
}
const REPRESENTATIONS = [
{ id: "html", type: "text/html", render: renderHTML },
{ id: "md", type: "text/markdown", render: renderMarkdown },
{ id: "txt", type: "text/plain", render: renderText }
];
function parseAccept(value) {
if (!value || !value.trim()) {
return [{ type: "*/*", q: 1, order: 0, specificity: 0 }];
}
return value.split(",").map((part, order) => {
const [rawType, ...parameters] = part.split(";");
const type = rawType.trim().toLowerCase();
let q = 1;
for (const parameter of parameters) {
const [name, rawValue] = parameter.trim().split("=");
if (name && name.toLowerCase() === "q") {
const parsed = Number(rawValue);
q = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 0;
}
}
const specificity = type === "*/*" ? 0 : type.endsWith("/*") ? 1 : 2;
return { type, q, order, specificity };
});
}
function matches(range, mediaType) {
if (range === "*/*") return true;
if (range.endsWith("/*")) {
return mediaType.startsWith(`${range.slice(0, -1)}`);
}
return range === mediaType;
}
function qualityFor(mediaType, ranges) {
const matched = ranges
.filter((range) => matches(range.type, mediaType))
.sort((a, b) => b.specificity - a.specificity || a.order - b.order);
return matched.length > 0 ? matched[0].q : 0;
}
function negotiate(request, url) {
const explicit = url.searchParams.get("format");
if (explicit !== null) {
const representation = REPRESENTATIONS.find((item) => item.id === explicit);
return representation ? { representation } : { error: 400 };
}
const ranges = parseAccept(request.headers.get("Accept"));
const candidates = REPRESENTATIONS
.map((representation, serverOrder) => ({
representation,
serverOrder,
q: qualityFor(representation.type, ranges)
}))
.filter((item) => item.q > 0)
.sort((a, b) => b.q - a.q || a.serverOrder - b.serverOrder);
return candidates.length > 0
? { representation: candidates[0].representation }
: { error: 406 };
}
function errorResponse(status, message) {
return new Response(message, {
status,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
"Vary": "Accept"
}
});
}
function outgoingResponse(response, method, cacheState) {
const headers = new Headers(response.headers);
headers.set("X-Cache", cacheState);
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers
});
}
export default {
async fetch(request, env, ctx) {
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method Not Allowed", {
status: 405,
headers: { "Allow": "GET, HEAD" }
});
}
const url = new URL(request.url);
if (url.pathname !== "/articles/content-negotiation") {
return new Response("Not Found", { status: 404 });
}
const result = negotiate(request, url);
if (result.error === 400) {
return errorResponse(400, "format must be html, md, or txt");
}
if (result.error === 406) {
return errorResponse(406, "No acceptable representation");
}
const representation = result.representation;
const cacheURL = new URL(request.url);
cacheURL.search = "";
cacheURL.searchParams.set("__representation", representation.id);
const cacheKey = new Request(cacheURL.toString(), { method: "GET" });
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) {
return outgoingResponse(cached, request.method, "HIT");
}
const generated = new Response(representation.render(ARTICLE), {
headers: {
"Content-Type": `${representation.type}; charset=utf-8`,
"Cache-Control": "public, max-age=300",
"Vary": "Accept",
"X-Representation": representation.id
}
});
ctx.waitUntil(cache.put(cacheKey, generated.clone()));
return outgoingResponse(generated, request.method, "MISS");
}
};
这里有两个容易混淆的层次。Vary: Accept 告诉遵循 HTTP 语义的下游缓存:响应会随 Accept 变化;但在 Worker 中使用 Cache API 时,不应假设它会自动把任意 Vary 字段加入缓存键。因此代码显式生成 __representation=html|md|txt 的内部缓存键,从根源上避免 HTML 命中 Markdown 缓存。
示例清空了原始查询参数,因为正文不受其他参数影响。真实业务只有在确认参数不影响内容时才能这样做;如果 lang、版本号或租户参数会改变结果,也必须进入缓存键。登录态、Cookie 或授权头会产生个性化内容时,应跳过公共缓存,不能只增加一个表示维度就直接缓存。
降级策略与自动化验证
降级需要明确而稳定。缺少 Accept 或发送 */* 时返回 HTML,保证普通导航不会失败;客户端明确只接受 application/json 时返回 406,避免把 HTML 伪装成可处理结果;非法 format 返回 400,便于调用方尽早发现拼写错误。显式参数覆盖 Accept,因此也适合作为排障开关。
下面的 Node.js 20+ 脚本会验证默认回退、质量值、显式覆盖、406,以及交错请求不会串内容。保存为 test.mjs,先启动 Worker,再执行 node test.mjs。
import assert from "node:assert/strict";
const base = process.env.BASE_URL ?? "http://127.0.0.1:8787";
const endpoint = `${base}/articles/content-negotiation`;
async function request(url, accept) {
const headers = accept ? { Accept: accept } : {};
return fetch(url, { headers });
}
const fallback = await request(endpoint);
assert.equal(fallback.status, 200);
assert.match(fallback.headers.get("content-type"), /^text\/html/);
const markdown = await request(
endpoint,
"text/markdown, text/html;q=0.5"
);
assert.equal(markdown.headers.get("x-representation"), "md");
assert.match(await markdown.text(), /^# 内容协商示例/);
const explicit = await request(`${endpoint}?format=md`, "text/html");
assert.match(explicit.headers.get("content-type"), /^text\/markdown/);
const rejected = await request(endpoint, "application/json");
assert.equal(rejected.status, 406);
for (let round = 0; round < 2; round += 1) {
const [html, md] = await Promise.all([
request(endpoint, "text/html"),
request(endpoint, "text/markdown")
]);
assert.equal(html.headers.get("x-representation"), "html");
assert.equal(md.headers.get("x-representation"), "md");
assert.match(await html.text(), /^<!doctype html>/);
assert.match(await md.text(), /^# 内容协商示例/);
}
console.log("content negotiation tests passed");
测试不强制断言本地环境一定出现 X-Cache: HIT,因为本地开发模式与线上缓存行为可能不同;它验证的是更重要的外部契约:无论请求顺序如何,每种表示都必须返回正确的类型和正文。部署后可以再结合日志观察 X-Cache,确认缓存是否实际生效。
总结
同一站点服务用户与 AI 客户端,关键不是维护越来越长的机器人名单,而是建立清晰的表示协商协议:
- 用
Accept和q表达能力与偏好,format作为显式覆盖入口; - 不把
User-Agent当作核心分流依据,只用于观测或有限兼容; - 返回
Vary: Accept,同时在 Cloudflare Cache API 中显式按表示构造缓存键; - 对缺省请求回退到 HTML,对不可接受类型返回 406,对非法参数返回 400;
- 用交错、重复请求验证 HTML 与 Markdown 不会因边缘缓存而串内容;
- 内容一旦受语言、租户、登录态等因素影响,就继续扩展缓存键或直接跳过公共缓存。
这样建立的是一套面向表示的 HTTP 契约。未来增加 JSON、精简 HTML 或其他机器友好格式时,只需扩展表示列表、缓存维度和测试用例,而不必重新猜测每一种客户端身份。
评论