同一篇内容可以面向浏览器返回 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=htmlHTML显式参数优先
format=mdMarkdown适合 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) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    "\"": "&quot;"
  })[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 客户端,关键不是维护越来越长的机器人名单,而是建立清晰的表示协商协议:

  • Acceptq 表达能力与偏好,format 作为显式覆盖入口;
  • 不把 User-Agent 当作核心分流依据,只用于观测或有限兼容;
  • 返回 Vary: Accept,同时在 Cloudflare Cache API 中显式按表示构造缓存键;
  • 对缺省请求回退到 HTML,对不可接受类型返回 406,对非法参数返回 400;
  • 用交错、重复请求验证 HTML 与 Markdown 不会因边缘缓存而串内容;
  • 内容一旦受语言、租户、登录态等因素影响,就继续扩展缓存键或直接跳过公共缓存。

这样建立的是一套面向表示的 HTTP 契约。未来增加 JSON、精简 HTML 或其他机器友好格式时,只需扩展表示列表、缓存维度和测试用例,而不必重新猜测每一种客户端身份。