假设你走进一家粤菜馆,招牌菜是“一条桂花鱼”。这同一道菜,后厨可以做成清蒸、红烧、剁椒、酸甜四种版本。你还没开口,服务员先问:“您吃辣吗?清蒸还是红烧?”你说:“清蒸,少放油。”——你表达的这句偏好,就像浏览器发出的 Accept 请求头;服务员把话传给后厨,厨师照着做,就像服务器根据客户端偏好挑选最合适的表示形式。这就是 HTTP 内容协商:同一个网址(URI),根据不同客户端的偏好,返回不同“口味”的版本。有些客人不挑食,服务员就按餐厅默认来;有些客人忌口,厨师绝不上带辣味的菜(q=0 不可接受)。送外卖时,盒子上还要贴一张小条(Vary 头),提醒快递小哥“这盒是清蒸的,别送给要吃剁椒的顾客”,否则缓存就会送错菜。这篇文章,就从“报菜名”开始,把内容协商的整套“点菜流程”讲明白。
掌握内容协商,你就能理解为什么同一个接口能同时服务网页、App 和 CDN——这是设计多端 API、做传输优化和排查缓存问题的基本功,也是 AI 服务以多种格式输出的关键能力。
可视化演示
展示服务端驱动协商的Accept头部匹配、代理驱动300响应选择、以及Vary缓存键对CDN命中率的影响。
本节配有交互动效(进入视口后自动加载,约 3KB)。
服务端驱动协商 客户端发送Accept头部,服务端按q值匹配最佳表示 q:0.0-1.0 q值越高表示偏好越强,q=0表示不可接受
协商基础概念
一盘桂花鱼,后厨能做出清蒸、红烧、剁椒、酸甜四种版本。菜单上只写一道“桂花鱼”,但端上桌的到底是什么,取决于你说了什么话。HTTP 的世界也一样:一个 URL(统一资源定位符)就像菜单上的菜名,它背后是一个抽象的资源——真实的数据实体;而资源的具体形态,可以是 HTML 网页、JSON 数据、XML 文档,也可以是图片或视频。这些具体形态,专业上称为“表示形式”。
为什么同一个 URL 要准备多种表示形式?因为客户端千差万别:浏览器想要 HTML,手机 App 想要 JSON,老旧的客户端可能只接受 XML。如果你让每个格式都开一个独立 URL(/user.html、/user.json、/user.xml),维护起来极其痛苦。内容协商的出现,就是让“一个菜名,多种做法”成为可能——客户端表达偏好,服务器返回最合适的那一版。
客户端怎么表达偏好?通过请求头。比如 Accept 头告诉服务器“我能吃哪些菜系”,Accept-Encoding 告诉它“我能接受多辣的压缩方式”,Accept-Language 告诉它“我看得懂哪种语言”。服务器拿到这些偏好后,在自己的“可做菜品”清单里寻找最佳匹配。整个过程对链接保持稳定——URL 不变,变的是响应内容。
理解这一点后你会发现,内容协商不是什么高深魔法,它就是餐厅里“客人表达需求、后厨按需出菜”的那套默契。接下来的问题只有一个:这个“后厨”到底是怎么决定做哪一版的?
- Response(响应)最终返回给客户端的表示形式,就像端上桌的那道菜——可能是 HTML,也可能是 JSON。
- Select(挑选函数)由服务器执行的一套匹配逻辑,输入偏好、输出最优表示。
- Resource(资源)URI 指向的抽象内容本体,不关心具体形态。
- Preferences(偏好集合)Accept 系列请求头携带的客户端偏好,相当于顾客那句“清蒸,少放油”。
三种协商方式
餐厅有三种方式知道你想吃什么。第一种:服务员主动问你,你说了偏好,厨师直接决定上哪道——这是服务端驱动。第二种:服务员端出一块“今日可选做法”的牌子,让你自己指着选——这是代理驱动。第三种:你常叫外卖,外卖平台已经记住了你的口味,直接在中间替你选了——这是透明协商。
服务端驱动协商(Server-driven / Proactive Negotiation)是最常见的方式。客户端把偏好写进 Accept 系列请求头,服务器读取后自己挑选表示形式,一次请求、一次响应,过程干脆利落。绝大多数浏览器请求都属于这一类。它的优点是快,缺点是把判断负担全部压给了服务器——服务器得知道每个客户端能接受什么。
代理驱动协商(Agent-driven / Reactive Negotiation)则更像“把选择权交还客户”。当服务器实在无法判断时,它会返回状态码 300 Multiple Choices(多个选择),并在响应体里列出一份可选表示清单,每个变体都标注自己的 Content-Location。客户端看到清单后,再发起一次请求去取选中的那一版。因为多了一轮往返,这种方式的延迟更高,实际使用较少。
第三种透明协商(Transparent Negotiation)发生在中间层:CDN 或网关代理替源服务器完成协商。它们利用 Vary 头和缓存,在代理侧直接返回合适的版本,对客户端和源服务器都是“透明”的。如今互联网上的大部分流量都经过 CDN,透明协商其实是最容易被感知到的一类——它和后面要讲的 Vary 缓存键紧密相连。
三种方式没有绝对优劣:服务端驱动快但不灵活,代理驱动灵活但慢,透明协商则在中间找到了平衡。回到餐厅的比喻,服务员的态度决定了点菜流程——而 HTTP 的设计者把三种流程都写进了协议,让场景去选择。
- 300 状态码HTTP 语义为“多个选择”,服务器无法替客户端做决定时使用。
- Multiple Choices状态文本,提示响应里携带了可选列表。
- 𝒱 变体集合服务器列出的全部可选表示,相当于端出来的“可选做法”牌子。
- V₁ 到 Vₙ 每个变体每个变体都配有各自的 Content-Location,客户端据此发起第二次请求。
Accept头部匹配
点菜时你可能会说:“清蒸优先,没有清蒸就红烧,实在不行来条油炸的也行,但千万别做辣的。”这段话翻译成浏览器的语言,就是一句 Accept: text/html, application/json;q=0.9, */*;q=0.1。逗号分隔的是媒体范围(你要的“菜系”),分号后面跟着的 q=0.9 就是质量因子——你有多想要这道菜。
质量因子 q 的取值范围是 0 到 1,默认值是 1(没说就代表最高优先级)。q 越大,客户端对该媒体类型越偏好。服务器在匹配时遵循两条铁律:第一,计算每个候选表示的 q 值——取所有能匹配到的媒体范围中的最大值;第二,在所有可提供的表示中,选择 q 值最大的那个。
匹配还有一个精细的优先级规则:精确匹配优于通配符。text/html 只能匹配 text/html;text/* 能匹配所有 text 子类的格式;*/* 能匹配一切。例如 Accept: text/*;q=0.8, application/json;q=0.9,服务器同时有 text/html 和 application/json 时,会选 q=0.9 的 JSON,而不是通配符匹配的 text/html(q=0.8)。如果某个媒体类型在请求头里出现 q=0,那它就是“完全不可接受”——厨师绝不会把这道菜端上桌。所有候选都不被接受时,服务器返回 406 Not Acceptable。
服务员把“清蒸优先、红烧其次”记成一张带权重的单子,厨师照单勾选,得分最高的那道菜出锅。这套规则保证了协商的确定性:同样的 Accept 头,永远得到同样的选择结果——这正是缓存能安全介入的前提。
- M\*(最优表示)最终被选中的表示形式,就是端上桌的那道菜。
- argmax(取最大者)在候选集合 𝒱 中寻找使 q(M) 达到最大值的元素。
- q(M)(质量值)表示 M 的最终偏好得分,得分来自它的所有匹配范围。
- rangeᵢ ∋ M(匹配关系)第 i 个媒体范围能够匹配表示 M,例如
text/*能匹配text/html。
编码语言协商
点完菜还要交代两件事:“少放油”和“给我中文菜单”。前者是压缩偏好,后者是语言偏好。HTTP 里分别对应 Accept-Encoding 和 Accept-Language——它们各自负责一个维度的协商,齐头并进。
Accept-Encoding: gzip, deflate, br 告诉服务器客户端能解压哪些压缩算法。服务器选择其中一个支持的算法压缩响应体,并在响应头 Content-Encoding 里注明实际用了哪种。文本类资源(HTML、CSS、JSON、API 响应)经过 gzip 或 brotli 压缩后通常能瘦身 60%-80%,是 Web 性能优化的第一桶金。注意,identity 代表不压缩,q=0 的编码同样意味着不可接受。
Accept-Language: zh-CN, zh;q=0.9, en;q=0.8 表示客户端的语言偏好。语言标签由主语言和地区组成,比如 zh-CN 是简体中文。服务器据此选择最匹配的语言版本,并通过响应头 Content-Language 声明返回的语言。至于字符集,Accept-Charset 头在 HTTP/1.1 时代已被基本废弃——UTF-8 一统天下后,字符集信息直接写在响应头 Content-Type 的 charset 参数里就够了。
浙江客人要“少油清蒸”,四川客人要“重麻重辣”,厨房各自记下口味,端上桌的菜自然人人满意。语言与压缩是内容协商里最“润物细无声”的两环——你几乎注意不到它们在工作,但每次打开网页,它们都在默默替你节省流量、切换语言。
- e\*(选中的维度值)被选中的编码方式或语言标签。
- argmax(取最大者)在所有候选编码/语言里取偏好得分最高的一项。
- 约束条件 q(e) > 0得分必须大于 0,确保客户端不会收到它完全不能处理的版本。
响应表示头部
菜端上来时,盘子上通常贴着一张小标签:“清蒸桂花鱼,少油。”这个标签是为客人准备的——确认这道菜就是你要的那一版。HTTP 响应里也有这样的标签,它们统称为响应表示头部,其中最核心的三个是 Content-Type、Content-Language 和 Content-Location。
Content-Type 声明响应体的真实媒体类型,比如 application/json; charset=utf-8。它是客户端的“说明书”:拿到响应后,浏览器或 App 根据它决定如何解析数据。如果服务器实际返回了 JSON 却声称 text/html,客户端解析必然出错——所以这个头必须与实体内容严格一致。
Content-Language 声明响应内容的语言,如 zh-CN 或 en,辅助用户代理做展示决策;Content-Location 则指向所选变体的具体 URL——在代理驱动协商中,它告诉客户端“你选的是这个地址”;在缓存场景里,它还能帮助缓存识别不同变体。三者共同构成响应的“表示元数据”,让任何拿到响应的人都能说清“我拿到的到底是什么”。
小标签让客人确认没有上错菜,响应头让客户端和缓存确认“拿到的是哪一版”。没有这套元数据,客户端就是盲人摸象;有了它,协商的结果才第一次变得可见、可验证、可缓存。
- type/subtype(媒体类型)格式的正式名称,如
application/json。 - charset(字符集)文本编码方式,现代 Web 默认 UTF-8。
- language(语言)内容使用的自然语言,如
zh-CN。 - location(定位地址)该变体可被访问的 URL,即 Content-Location 的值。
Vary缓存键
外卖平台把做好的菜放进保温箱,等着快递小哥来取。假设第一个顾客点的是清蒸,平台随手把清蒸的盒子递给了第二个点剁椒的顾客——这就是灾难。为了避免送错,聪明的平台会在盒子上贴便签:“本盒为清蒸口味,仅限忌辣顾客。”HTTP 里的这张便签,就是 Vary 响应头。
Vary: Accept-Encoding 的含义是:这个响应的内容取决于请求头 Accept-Encoding,缓存你必须把它的值算进缓存键。没有 Vary 时,第一个客户端请求得到的 gzip 版本可能被缓存直接发给一个不支持 gzip 的老客户端——乱码;中文版可能被发给英文用户——错版。Vary 的存在,就是为了让缓存“知道该区分谁”。
缓存键的构造规则因此变成了:URI 拼上 Vary 列出的各个请求头的值。但这里藏着一个微妙的权衡:Vary 列出的维度越多,缓存条目就越碎。比如 Vary: User-Agent,每个浏览器指纹都会独占一份缓存,CDN 命中率直线下降。所以工程上有一条共识:只在“必须区分”的维度上写 Vary,并把变体数量压到最少——比如 Accept-Encoding 通常只有 gzip、br、原样三个值,仍然可以共享大部分缓存。
便签写得太细,保温箱每个格子只有一份菜,命中率暴跌;写得太粗,又可能送错口味。优秀的缓存工程师,就是在“正确区分”和“聚合共享”之间走钢丝——而 Vary 就是他们手里的那支笔。
- K\_cache(缓存键)缓存条目在存储中的唯一标识,键不同则条目隔离。
- URI(资源地址)本地缓存键的基础部分。
- ‖(拼接符号)把多个字段首尾相接,组成一个完整键。
- h₁ 到 hₙ(请求头值)Vary 列出的每个请求头的具体取值,共同决定最终变体。
- Vary(响应头)声明“哪些请求头影响了本次响应选择”的白名单。
AI 应用场景
LLM 网关的多格式输出
阶段:服务端响应设计 / 网关层
解决的问题:同一个大模型接口要同时服务多种客户端:浏览器需要 SSE 流式输出(打字机效果),命令行工具需要纯文本,结构化调用方需要 JSON。传统做法是开三个端点,逻辑重复、难以维护。利用内容协商,一个端点根据 Accept 头自动切换输出格式:text/event-stream 走流式,application/json 返回完整结构化结果,同时配合 Accept-Encoding 下发 gzip 压缩流,带宽立省。
具体案例:某 AI 网关在 /v1/chat/completions 上根据请求头的 Accept 值分流,浏览器获得流式增量文本,自动化脚本获得 JSON 结果。Vary: Accept 让 CDN 把两种格式分桶缓存,流式请求不打爆源站。
多语言 AI 客服
阶段:请求预处理 / 多语言路由
解决的问题:跨国业务的客服机器人要按用户语言回复。传统方案靠 IP 猜测语言,经常把在日本的英文用户判成日语用户。内容协商提供更可靠的口令:浏览器自动携带 Accept-Language,服务器据此选择语言模板、提示词和响应语言。
具体案例:客服系统读取 Accept-Language: zh-CN, zh;q=0.9, en;q=0.8,优先用中文回答,并通过 Content-Language: zh-CN 声明响应语言。当用户只有 en 偏好时自动切换英文模型提示词。同一套接口服务全球,零额外路由代码。
CDN 图片格式分发
阶段:缓存与分发优化
解决的问题:同一张产品图,iPhone 支持 AVIF、Android 支持 WebP、老浏览器只支持 JPEG。传统方案为每种格式生成独立 URL,图片链接四散、难以管理。内容协商让一个 URL 动态适配:服务器根据 Accept: image/avif, image/webp 返回最优格式,并用 Vary: Accept 让 CDN 分桶缓存。
具体案例:某电商平台改造后,图片流量下降约 30%,首屏加载时间缩短 18%。做法是在图片服务上做格式协商,CDN 按 Accept 维度缓存各格式副本,用户无感切换。
Python 演示
# -*- coding: utf-8 -*-"""HTTP 内容协商最小实现: 根据 Accept 头部选取最佳媒体类型""" def parse_accept(header): """把 Accept 头部解析成 [(媒体范围, q值), ...], 默认 q=1""" if not header: return [("*/*", 1.0)] prefs = [] for item in header.split(","): parts = [p.strip() for p in item.split(";")] media_range = parts[0] q = 1.0 for param in parts[1:]: if param.startswith("q="): try: q = float(param[2:]) except ValueError: q = 0.0 prefs.append((media_range, q)) return prefs def matches(media_range, media_type): """判断媒体范围能否匹配具体媒体类型, 支持 * 通配符""" if media_range == "*/*": return True r_type, r_sub = media_range.split("/") m_type, m_sub = media_type.split("/") if r_type != m_type and r_type != "*": return False return r_sub == "*" or r_sub == m_sub def negotiate(available, header): """为给定的资源变体列表挑选 Accept 得分最高的表示""" prefs = parse_accept(header) best_type, best_q = None, 0.0 for media_type in available: q = max((qv for r, qv in prefs if matches(r, media_type)), default=0.0) if q > best_q: best_type, best_q = media_type, q return best_type, best_q if __name__ == "__main__": variants = ["text/html", "application/json", "application/xml"] cases = [ "text/html, application/json;q=0.9, */*;q=0.1", "application/json;q=0.8, application/xml;q=0.5", "text/plain", "text/html;q=0, application/json;q=0.9", ] for header in cases: chosen, q = negotiate(variants, header) if chosen is None: print(f"Accept: {header} -> 406 Not Acceptable") else: print(f"Accept: {header} -> {chosen} (q={q:.1f})")
def parse_accept(header):把 Accept 头拆成“媒体范围 + q 值”的列表,没有请求头时默认全接受。prefs.append((media_range, q))每个媒体范围都带上自己的权重,组成偏好清单。def matches(media_range, media_type):实现匹配规则,处理*/*、type/*和精确匹配三种情况。if r_type != m_type and r_type != "*":主类型不同且不是通配符,直接判定不匹配。q = max((qv for r, qv in prefs if matches(r, media_type)), default=0.0)对每个候选表示,取所有能匹配到的媒体范围中的最大 q 值。if q > best_q:只允许 q 大于 0 的表示参与竞争,q=0 表示被自动排除。if chosen is None:所有候选都不可接受,对应 HTTP 的 406 Not Acceptable。
常见误区
误区我在请求头里写了 `Accept: application/json`,那么服务器绝对不能给我返回 `text/html`,否则就是协议错误。
正解Accept 表达的是一套“匹配规则”,而不是白名单。服务器返回的格式只要能被 Accept 中的某个媒体范围匹配即可——精确行之外的 */* 通配符也会接住它。真正“不能返回”的类型是 q=0 的类型。比如只写 application/json 时,text/html 确实不匹配;但如果写的是 */*;q=0.1,text/html 就能以很低的优先级被接受。
为何常见:初学者把 Accept 理解成“我想要什么的清单”,忽略了它的本质是“哪些类型可以匹配”的规则集合。
误区`Accept: text/html;q=0, application/json;q=0.9` 里的 `q=0` 表示 text/html 优先级最低,服务器实在没有 JSON 时还是可以返回 HTML 的。
正解q=0 不是“最后选项”,而是“完全拒绝”。优先级最低的正数是 q 很小但仍大于 0(比如 q=0.1);q=0 意味着该类型绝不能被接受。服务器若所有候选都被拒绝,只能返回 406 Not Acceptable,绝无“退而求其次”一说。
为何常见:0 到 1 的连续刻度让人直觉地把 0 当作“最小的正数”,而协议把 0 定义为独立的“禁止”状态。
误区请求时用 `Content-Type: application/json` 告诉服务器“我要 JSON 格式”,这个头就是用来做内容协商的。
正解方向搞反了。请求方向上表达偏好用 Accept 系列头;Content-Type 是响应头(也可出现在请求体中,表示请求体的格式),它声明的是“我实际给你的是什么”。两者是一问一答:Accept 问“你能给什么”,Content-Type 答“我给的就是这个”。
为何常见:抓包时请求和响应两边都有很多 Content-* 开头的头,方向容易记混。
误区Vary 和缓存有关,那我在响应里加上 `Vary: Accept-Encoding`,CDN 的缓存命中率肯定嗖嗖往上涨。
正解Vary 是让缓存知道“要区分哪些请求头”,但区分维度越多,缓存桶越小。比如 Vary: User-Agent 会让每个浏览器各自成桶,命中率反而下降。正确做法是只在必要维度上区分,并把变体数量控制在 2-3 个(如 gzip、br、原样),这样才能兼顾正确性与命中率。
为何常见:看到“Vary 与缓存相关”就以为它提升缓存,忽略了它作用的对象是缓存键,而缓存键的粒度会直接影响命中率。
误区我想让服务器返回 WebP 图片,就在请求头里写 `Accept-Encoding: webp`,没返回就说明服务器不支持。
正解Accept-Encoding 协商的是内容编码,主要是 gzip、deflate、br 这类文本压缩算法;图片格式(WebP、AVIF)的协商走 Accept 头(如 image/avif、image/webp),并配合 Vary: Accept 分桶缓存。两者一个针对传输字节,一个针对资源表示格式,是完全不同的维度。
为何常见:两者都带着“压缩、换格式”的直觉印象,容易混为一谈。
面试题
Q1. 内容协商要解决的核心问题是什么?
- A.同一 URI 根据客户端偏好返回不同表示形式← 正确答案
- B.让服务器对响应进行加密传输
- C.提高 DNS 解析的速度
- D.自动压缩服务器的日志文件
答案:A。内容协商让一个 URL 背后挂多种表示形式,由客户端偏好决定返回哪一版——就像一道菜按客人要求换做法。加密、DNS、日志压缩都不是它的职责。
Q2. 服务端驱动协商中,客户端偏好主要通过什么传递?
- A.Cookie 头部
- B.Accept 系列请求头← 正确答案
- C.URL 查询参数
- D.请求体中的 JSON
答案:B。服务端驱动协商的核心就是读取 Accept、Accept-Encoding、Accept-Language 等请求头来决定返回哪种表示,相当于顾客口头报偏好。Cookie、URL 参数都不属于协商机制。
Q3. 请求头 Accept: text/html, application/json;q=0.8 中,q=0.8 表示什么?
- A.application/json 有 80% 的概率被返回
- B.application/json 优先级低于 text/html← 正确答案
- C.服务器只能返回 application/json 内容的 80%
- D.application/json 完全不可接受
答案:B。q 是质量因子,数值越大优先级越高。text/html 没写 q,默认 q=1,高于 JSON 的 0.8,所以服务器优先返回 HTML。q 不表示概率,也不表示截断。
Q4. 关于 q=0 的说法,正确的是?
- A.表示该媒体类型完全不可接受← 正确答案
- B.服务器绝不能返回该类型,只可返回其他可接受类型或 406← 正确答案
- C.表示最低优先级,仍可正常返回
- D.q=0 与不写该类型效果相同
答案:AB。q=0 是“禁止”,不是“最低优先级”。服务器遇到 q=0 的类型必须绕开,全部候选都被拒绝时返回 406。不写某类型只代表“没有显式偏好”,仍可能被通配符匹配,与 q=0 截然不同。
Q5. 代理驱动协商中,服务器返回 300 响应时,用于标识各可选变体地址的头部是?
- A.Content-Length
- B.Content-Location← 正确答案
- C.Location
- D.ETag
答案:B。代理驱动协商的 300 响应会在列表里给每个变体标注 Content-Location,客户端用它发起第二次请求。Location 用于重定向,Content-Length 表示长度,ETag 表示实体标签,都与变体定位无关。
Q6. 哪个响应头告诉缓存“响应的选择依赖哪些请求头”?
- A.Cache-Control
- B.Vary← 正确答案
- C.Expires
- D.Accept-Ranges
答案:B。Vary: Accept-Encoding 这类声明让缓存把对应请求头的值并入缓存键。Cache-Control 管缓存策略,Expires 管过期时间,Accept-Ranges 管范围请求——都不负责“区分维度”。
Q7. 以下哪些属于 Accept 系列请求头?
- A.Accept← 正确答案
- B.Accept-Encoding← 正确答案
- C.Accept-Language← 正确答案
- D.Content-Type
答案:ABC。Accept、Accept-Encoding、Accept-Language 分别是格式、压缩编码、语言三个维度的协商头,属于“客户端点菜”方向。Content-Type 是响应方向声明实际格式的头,不参与请求偏好表达。
Q8. 关于 Vary 与 CDN 缓存,说法正确的有?
- A.
Vary: Accept-Encoding会让 gzip 与 br 版本分桶缓存← 正确答案 - B.Vary 维度过多可能降低缓存命中率← 正确答案
- C.不带 Vary 时,缓存可能把压缩版发给不支持解压的客户端← 正确答案
- D.加了 Vary 后缓存命中率必然提升
答案:ABC。Vary 使缓存键加上请求头维度,不同编码自然分桶;维度多了桶就碎,命中率会下降;没有 Vary 时缓存不区分变体,确实可能发错版本。D 把“区分正确”误当成“命中率必然提升”,忽略了碎片化代价。
Q9. 服务器只有 text/html 可用,收到 Accept: */*;q=0.1, application/json;q=0.9,应返回什么?
- A.200 与 text/html← 正确答案
- B.406 Not Acceptable
- C.300 Multiple Choices
- D.强制返回 application/json
答案:A。text/html 可以匹配 */*(q=0.1),q 大于 0 说明可接受,服务器返回唯一可用的表示即可。只有当所有候选都被拒绝时才返回 406;这里没有不可接受的问题。
Q10. 同一 URL 想对中文用户返回中文版、对英文用户返回英文版,服务器应依据哪个头部?
- A.Accept-Language← 正确答案
- B.Accept-Encoding
- C.Accept-Charset
- D.Cookie
答案:A。语言偏好由 Accept-Language 携带,如 zh-CN, zh;q=0.9, en;q=0.8。Accept-Encoding 管压缩,Accept-Charset 管字符集已基本废弃,Cookie 不参与协商。