系列定位:这是 16 篇「从普及到源码」阶段三的第 10 篇。上一篇(第 9 篇)我们把镜头怼到数据面的运行时模型上,看清了一个请求进到网关后,在事件循环、协程调度和超时机制里被「搬运」的全过程。这一篇沿着同一条线索往上走一层,进入 AI 网关和普通 API 网关真正拉开差距的地方------LLM 协议层。
我会带着大家翻一段真实的协议接入层源码,回答三个问题:一个请求怎么被认出「这是 OpenAI 还是 Anthropic」?各家协议怎么被桥接转换?流式响应怎么被一块块透传出去?看完这篇,你会拿到一个能复用的判断框架:评估一个 AI 网关的协议层,到底该看什么。
一、为什么协议层才是 AI 网关的分水岭
先说一个我自己的认知转折。
刚接触 AI 网关时,我以为它的价值和普通 API 网关差不多------无非是转发、鉴权、限流那一套,只不过后端换成了大模型。直到我在项目里想加一个「提示词脱敏」的需求,才发现事情不对:普通网关根本不知道请求体里哪一段是用户输入、哪一段是系统提示、哪一段是工具调用的参数。 它对 body 的全部认知就是「一坨字节」。
这个瞬间让我意识到:普通 API 网关和 AI 网关的分水岭,不在性能、不在并发,而在它能不能读懂这坨字节在语义上是什么。
一个普通网关看到的永远是同一件事:客户端发来一个请求体,转发给后端,再把响应体转发回去。它不需要知道 body 里是 messages 还是 input,是 choices 还是 output。但 AI 网关不行。它要在转发之前做审计、做脱敏、做 token 计费、做提示词缓存------这些都要求它先把「这是谁家的协议、正文在哪、系统提示在哪、工具调用在哪」认出来。认不出来,后面的治理全是空转。
而现实是,市面上的 LLM 协议不止一家,而且它们的分歧不在皮不在骨:
| 供应商 | 请求正文 | 系统提示 | 工具调用 |
|---|---|---|---|
| OpenAI Chat | messages |
消息序列里的 system 角色 |
tool_calls 数组 |
| OpenAI Responses | input + instructions |
顶层 instructions |
input 里的特殊元素 |
| Anthropic Messages | content 数组 |
顶层 system 字段 |
tool_use / tool_result 内容块 |
| Amazon Bedrock Converse | messages |
{ { text = ... } } 数组 |
toolUse / toolResult |
它们不只是字段名不一样,连消息的结构哲学 都不一样:OpenAI 用 role 字符串,Anthropic 用 content block 的 type 标签,Bedrock 的 system 是个数组包裹的文本。
一个能让你少走弯路的类比
如果客户端有 N 种协议、后端供应商支持 M 种协议,朴素的思路是写 N×M 个转换函数。10 个协议就是 90 个函数,组合爆炸会随着第 7 种协议、第 8 个供应商的到来迅速失控。
后来我发现,这个问题编译器早就解决过了。编译器的经典架构是:多种源语言 → 统一的中间表示(IR)→ 多种目标机器码。前端负责把源语言解析成 IR,中端在 IR 上做优化,后端把 IR 生成目标代码。这样 N 种语言 + M 种架构,只需要 N + M 个模块,而不是 N×M。
AI 网关的协议层,几乎是这个架构的逐字投影:
#mermaid-svg-yDQrrELZKQp7J1eM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yDQrrELZKQp7J1eM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yDQrrELZKQp7J1eM .error-icon{fill:#552222;}#mermaid-svg-yDQrrELZKQp7J1eM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yDQrrELZKQp7J1eM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yDQrrELZKQp7J1eM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yDQrrELZKQp7J1eM .marker.cross{stroke:#333333;}#mermaid-svg-yDQrrELZKQp7J1eM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yDQrrELZKQp7J1eM p{margin:0;}#mermaid-svg-yDQrrELZKQp7J1eM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yDQrrELZKQp7J1eM .cluster-label text{fill:#333;}#mermaid-svg-yDQrrELZKQp7J1eM .cluster-label span{color:#333;}#mermaid-svg-yDQrrELZKQp7J1eM .cluster-label span p{background-color:transparent;}#mermaid-svg-yDQrrELZKQp7J1eM .label text,#mermaid-svg-yDQrrELZKQp7J1eM span{fill:#333;color:#333;}#mermaid-svg-yDQrrELZKQp7J1eM .node rect,#mermaid-svg-yDQrrELZKQp7J1eM .node circle,#mermaid-svg-yDQrrELZKQp7J1eM .node ellipse,#mermaid-svg-yDQrrELZKQp7J1eM .node polygon,#mermaid-svg-yDQrrELZKQp7J1eM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yDQrrELZKQp7J1eM .rough-node .label text,#mermaid-svg-yDQrrELZKQp7J1eM .node .label text,#mermaid-svg-yDQrrELZKQp7J1eM .image-shape .label,#mermaid-svg-yDQrrELZKQp7J1eM .icon-shape .label{text-anchor:middle;}#mermaid-svg-yDQrrELZKQp7J1eM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yDQrrELZKQp7J1eM .rough-node .label,#mermaid-svg-yDQrrELZKQp7J1eM .node .label,#mermaid-svg-yDQrrELZKQp7J1eM .image-shape .label,#mermaid-svg-yDQrrELZKQp7J1eM .icon-shape .label{text-align:center;}#mermaid-svg-yDQrrELZKQp7J1eM .node.clickable{cursor:pointer;}#mermaid-svg-yDQrrELZKQp7J1eM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yDQrrELZKQp7J1eM .arrowheadPath{fill:#333333;}#mermaid-svg-yDQrrELZKQp7J1eM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yDQrrELZKQp7J1eM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yDQrrELZKQp7J1eM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yDQrrELZKQp7J1eM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yDQrrELZKQp7J1eM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yDQrrELZKQp7J1eM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yDQrrELZKQp7J1eM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yDQrrELZKQp7J1eM .cluster text{fill:#333;}#mermaid-svg-yDQrrELZKQp7J1eM .cluster span{color:#333;}#mermaid-svg-yDQrrELZKQp7J1eM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yDQrrELZKQp7J1eM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yDQrrELZKQp7J1eM rect.text{fill:none;stroke-width:0;}#mermaid-svg-yDQrrELZKQp7J1eM .icon-shape,#mermaid-svg-yDQrrELZKQp7J1eM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yDQrrELZKQp7J1eM .icon-shape p,#mermaid-svg-yDQrrELZKQp7J1eM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yDQrrELZKQp7J1eM .icon-shape .label rect,#mermaid-svg-yDQrrELZKQp7J1eM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yDQrrELZKQp7J1eM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yDQrrELZKQp7J1eM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yDQrrELZKQp7J1eM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 后端:生成与桥接
前端:识别与解析
OpenAI Chat 请求
detect() 协议检测
Anthropic 请求
Bedrock 请求
中端:canonical 中间表示
(统一语义结构)
→ OpenAI Chat
→ Anthropic Messages
→ Bedrock Converse
上游供应商
一句话本质:协议无关层,就是 AI 网关的「编译器前端 + 中端 IR」。它的使命是把「协议」从「治理」里解耦出来------让上层所有治理插件只跟一种统一结构打交道,而不必认识 N 家协议。
所以协议层必须回答三件事,也正是本篇要拆的三段流水线:
- 检测------请求进来了,怎么认出它是谁?
- 转换------认出之后,怎么把它变成后端供应商要的样子?
- 透传------响应回来了,怎么在流式场景下无损、低延迟地搬回去?
先讲原理,再带大家读一段真实源码,最后把踩过的坑摊开。我翻的代码就是一套真实网关里的协议接入层,目录结构长这样(路径我隐去产品名,只保留模块边界):
ai-protocols/
├── init.lua # 协议注册表 + detection_order + detect()
├── canonical/init.lua # 中间表示 + 一批公共换算函数
├── converters/init.lua # 转换器注册表 + find() + TARGET_PRIORITY
├── converters/*.lua # 若干 [from]→[to] 转换器
├── openai-chat.lua # 各协议适配器(matches / is_streaming /
├── openai-responses.lua# parse_sse_event / build_* / extract_*)
├── openai-embeddings.lua
├── anthropic-messages.lua
├── bedrock-converse.lua
└── passthrough.lua # 兜底:不转换、只透传
这套结构几乎可以当成协议无关层的标准范式来读,因为它把「识别」「归一」「桥接」三个职责分得很干净。
二、原理剖析:检测、转换、透传三件事
2.1 协议检测:从 URL + body 认出「这是谁」
检测的入口只有一个函数------detect()。它的实现朴素到让人意外:按固定顺序,逐个问每个协议「这像你吗?」,第一个点头的胜出。
lua
-- 检测顺序:先 URL+body 联合判定,再纯 body 判定,passthrough 兜底
local detection_order = {
{ name = "bedrock-converse", protocol = registered["bedrock-converse"] },
{ name = "anthropic-messages", protocol = registered["anthropic-messages"] },
{ name = "openai-responses", protocol = registered["openai-responses"] },
{ name = "openai-chat", protocol = registered["openai-chat"] },
{ name = "openai-embeddings", protocol = registered["openai-embeddings"] },
{ name = "passthrough", protocol = registered["passthrough"] },
}
function _M.detect(body, ctx)
for _, entry in ipairs(detection_order) do
if entry.protocol.matches(body, ctx) then
return entry.name
end
end
return nil, "unsupported request format: no protocol matched"
end
detect() 自己不写任何判断逻辑,它只负责两件事:维护顺序 、依次调用各协议的 matches() 。真正的判定口径藏在六个 matches 里。把它们拉出来看,能清晰看到一个「证据强度递减」的设计:
| 顺序 | 协议 | matches() 判定口径 |
证据强度 |
|---|---|---|---|
| 1 | bedrock-converse | URI 后缀 /converse 且 body.messages 是表 |
URL + body 联合 |
| 2 | anthropic-messages | URI 后缀 /v1/messages 且 body 是表 |
URL + body 联合 |
| 3 | openai-responses | URI 后缀 /v1/responses,或 body 含 Responses 形态 |
URL + body 联合 |
| 4 | openai-chat | body.messages 是表(不看 URI) |
纯 body |
| 5 | openai-embeddings | body.input 非空且无 Responses 特征 |
纯 body |
| 6 | passthrough | body 是非空表(兜底) | 兜底 |
#mermaid-svg-KbfAM2YbL16uJad4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KbfAM2YbL16uJad4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KbfAM2YbL16uJad4 .error-icon{fill:#552222;}#mermaid-svg-KbfAM2YbL16uJad4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KbfAM2YbL16uJad4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KbfAM2YbL16uJad4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KbfAM2YbL16uJad4 .marker.cross{stroke:#333333;}#mermaid-svg-KbfAM2YbL16uJad4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KbfAM2YbL16uJad4 p{margin:0;}#mermaid-svg-KbfAM2YbL16uJad4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KbfAM2YbL16uJad4 .cluster-label text{fill:#333;}#mermaid-svg-KbfAM2YbL16uJad4 .cluster-label span{color:#333;}#mermaid-svg-KbfAM2YbL16uJad4 .cluster-label span p{background-color:transparent;}#mermaid-svg-KbfAM2YbL16uJad4 .label text,#mermaid-svg-KbfAM2YbL16uJad4 span{fill:#333;color:#333;}#mermaid-svg-KbfAM2YbL16uJad4 .node rect,#mermaid-svg-KbfAM2YbL16uJad4 .node circle,#mermaid-svg-KbfAM2YbL16uJad4 .node ellipse,#mermaid-svg-KbfAM2YbL16uJad4 .node polygon,#mermaid-svg-KbfAM2YbL16uJad4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KbfAM2YbL16uJad4 .rough-node .label text,#mermaid-svg-KbfAM2YbL16uJad4 .node .label text,#mermaid-svg-KbfAM2YbL16uJad4 .image-shape .label,#mermaid-svg-KbfAM2YbL16uJad4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-KbfAM2YbL16uJad4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KbfAM2YbL16uJad4 .rough-node .label,#mermaid-svg-KbfAM2YbL16uJad4 .node .label,#mermaid-svg-KbfAM2YbL16uJad4 .image-shape .label,#mermaid-svg-KbfAM2YbL16uJad4 .icon-shape .label{text-align:center;}#mermaid-svg-KbfAM2YbL16uJad4 .node.clickable{cursor:pointer;}#mermaid-svg-KbfAM2YbL16uJad4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KbfAM2YbL16uJad4 .arrowheadPath{fill:#333333;}#mermaid-svg-KbfAM2YbL16uJad4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KbfAM2YbL16uJad4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KbfAM2YbL16uJad4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KbfAM2YbL16uJad4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KbfAM2YbL16uJad4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KbfAM2YbL16uJad4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KbfAM2YbL16uJad4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KbfAM2YbL16uJad4 .cluster text{fill:#333;}#mermaid-svg-KbfAM2YbL16uJad4 .cluster span{color:#333;}#mermaid-svg-KbfAM2YbL16uJad4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KbfAM2YbL16uJad4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KbfAM2YbL16uJad4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-KbfAM2YbL16uJad4 .icon-shape,#mermaid-svg-KbfAM2YbL16uJad4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KbfAM2YbL16uJad4 .icon-shape p,#mermaid-svg-KbfAM2YbL16uJad4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KbfAM2YbL16uJad4 .icon-shape .label rect,#mermaid-svg-KbfAM2YbL16uJad4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KbfAM2YbL16uJad4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KbfAM2YbL16uJad4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KbfAM2YbL16uJad4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
是
否
是
否
是
否
请求进入 detect()
是 Bedrock?
URI 以 /converse 结尾
bedrock-converse
是 Anthropic?
URI 以 /v1/messages 结尾
anthropic-messages
是 Responses?
URI 或 body 形态
openai-responses
body 有 messages?
(纯 body 判定)
openai-chat
body 有 input 且非 Responses?
openai-embeddings
body 是非空表?
passthrough(兜底)
返回错误:无协议匹配
这里有三处细节值得单独拎出来,因为它们不是「写对了就行」,而是「写错一次就出生产事故」的地方。
第一处:URL 后缀用的是精确截断匹配,不是 find。 Bedrock 和 Anthropic 的判定是这样的:
lua
-- bedrock-converse.lua
if ctx.var.uri and ctx.var.uri:sub(-9) == "/converse" then ... end
-- anthropic-messages.lua
if ctx.var.uri and ctx.var.uri:sub(-12) == "/v1/messages" then ... end
用 string.sub(uri, -N) 取末尾 N 个字符做精确相等 比较,而不是 uri:find("/converse") 这种子串搜索。区别在于:find 会把 /api/converse/v2/foo 也命中(子串在中间出现了),而 sub(-9) 只认「字符串恰好以 /converse 结尾」。对协议识别来说,「以什么结尾」是路由语义,「中间出现过什么」是巧合,后者会误伤。
第二处:openai-chat 是纯 body 判定,是四家里唯一不看 URI 的。 它的判定只有一行:
lua
-- openai-chat.lua:默认/兜底适配器,内部格式就是 OpenAI Chat,无需转换
function _M.matches(body, ctx)
return type(body) == "table" and type(body.messages) == "table"
end
它故意不看 URI------因为很多客户端会把 base_url 配成不带 /v1 的网关地址,URI 五花八门,但 body 里有 messages 数组这件事是稳定的。它认的是「语义上是不是 chat」,不是「路径上像不像 chat」。
第三处:openai-embeddings 和 openai-responses 之间有一组对称的排除,这是安全关键。 这两个协议共用同一个字段名 input,如果不做区分,会互相误抢。先看 embeddings 的判定:
lua
-- openai-embeddings.lua
return type(body) == "table" and body.input ~= nil
and type(body.instructions) ~= "string"
and not canonical.has_responses_input_item(body.input)
再看 responses 的判定:
lua
-- openai-responses.lua
if type(body) ~= "table" or body.input == nil then return false end
local uri = ctx and ctx.var and ctx.var.uri
if uri and uri:sub(-13) == "/v1/responses" then
return true
end
-- URI 不命中时,退回 body 形态判定
return canonical.has_responses_input_item(body.input)
or type(body.instructions) == "string"
两边的注释都指向同一个场景:客户端 base_url 不带 /v1,或者 URI 带尾斜杠时,/v1/responses 后缀匹配会必然失败 。这时候如果 responses 只认 URI,这个合法的 Responses 请求体就会被放走,落到只看 input ~= nil 的 embeddings 手里。而 embeddings 的 extract_request_content 只收字符串项、把 message 对象项全丢掉,extract_system_content 恒为空(它根本不读 instructions 字段)。结果就是:审核插件拿到一个空字符串,静默放行------一段有真实文本的请求,没经过审核就转发给了上游,且零留痕。
所以源码里的解法是「对称排除」:embeddings 主动排除掉 Responses 的两个特征(instructions 是字符串、input 里有 Responses 形态元素),responses 则在 URI 不命中时退回 body 形态判定。这段代码的注释明确写着「这条排他与 responses 的放宽互为对称」,并且强调:就算靠检测顺序单方面也拦得住,谓词自身也必须成立 ------因为顺序可能被后人调整,或者有人绕过 detect() 直接调某个 matches(),防线不能只剩一处。这是一个非常工程化的安全思维,值得记住。
一句话本质:检测顺序是「兜底」,谓词排他性是「本体」。两者都可靠,才算可靠。
2.2 canonical 中间表示:把 N×M 收敛成两条腿
认出了请求是谁,下一步是怎么把它变成后端要的样子。前面说过,直连转换是 N×M 的组合爆炸,标准解法是引入中间表示(canonical):所有客户端协议先归一化成一种统一结构,再由统一结构换算成各家目标协议。复杂度从 N×M 降到 N+M。
#mermaid-svg-l8mH83iAXJK3MTct{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l8mH83iAXJK3MTct .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l8mH83iAXJK3MTct .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l8mH83iAXJK3MTct .error-icon{fill:#552222;}#mermaid-svg-l8mH83iAXJK3MTct .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l8mH83iAXJK3MTct .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l8mH83iAXJK3MTct .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l8mH83iAXJK3MTct .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l8mH83iAXJK3MTct .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l8mH83iAXJK3MTct .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l8mH83iAXJK3MTct .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l8mH83iAXJK3MTct .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l8mH83iAXJK3MTct .marker.cross{stroke:#333333;}#mermaid-svg-l8mH83iAXJK3MTct svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l8mH83iAXJK3MTct p{margin:0;}#mermaid-svg-l8mH83iAXJK3MTct .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-l8mH83iAXJK3MTct .cluster-label text{fill:#333;}#mermaid-svg-l8mH83iAXJK3MTct .cluster-label span{color:#333;}#mermaid-svg-l8mH83iAXJK3MTct .cluster-label span p{background-color:transparent;}#mermaid-svg-l8mH83iAXJK3MTct .label text,#mermaid-svg-l8mH83iAXJK3MTct span{fill:#333;color:#333;}#mermaid-svg-l8mH83iAXJK3MTct .node rect,#mermaid-svg-l8mH83iAXJK3MTct .node circle,#mermaid-svg-l8mH83iAXJK3MTct .node ellipse,#mermaid-svg-l8mH83iAXJK3MTct .node polygon,#mermaid-svg-l8mH83iAXJK3MTct .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l8mH83iAXJK3MTct .rough-node .label text,#mermaid-svg-l8mH83iAXJK3MTct .node .label text,#mermaid-svg-l8mH83iAXJK3MTct .image-shape .label,#mermaid-svg-l8mH83iAXJK3MTct .icon-shape .label{text-anchor:middle;}#mermaid-svg-l8mH83iAXJK3MTct .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l8mH83iAXJK3MTct .rough-node .label,#mermaid-svg-l8mH83iAXJK3MTct .node .label,#mermaid-svg-l8mH83iAXJK3MTct .image-shape .label,#mermaid-svg-l8mH83iAXJK3MTct .icon-shape .label{text-align:center;}#mermaid-svg-l8mH83iAXJK3MTct .node.clickable{cursor:pointer;}#mermaid-svg-l8mH83iAXJK3MTct .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l8mH83iAXJK3MTct .arrowheadPath{fill:#333333;}#mermaid-svg-l8mH83iAXJK3MTct .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l8mH83iAXJK3MTct .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l8mH83iAXJK3MTct .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l8mH83iAXJK3MTct .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l8mH83iAXJK3MTct .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l8mH83iAXJK3MTct .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l8mH83iAXJK3MTct .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l8mH83iAXJK3MTct .cluster text{fill:#333;}#mermaid-svg-l8mH83iAXJK3MTct .cluster span{color:#333;}#mermaid-svg-l8mH83iAXJK3MTct div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l8mH83iAXJK3MTct .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l8mH83iAXJK3MTct rect.text{fill:none;stroke-width:0;}#mermaid-svg-l8mH83iAXJK3MTct .icon-shape,#mermaid-svg-l8mH83iAXJK3MTct .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l8mH83iAXJK3MTct .icon-shape p,#mermaid-svg-l8mH83iAXJK3MTct .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l8mH83iAXJK3MTct .icon-shape .label rect,#mermaid-svg-l8mH83iAXJK3MTct .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l8mH83iAXJK3MTct .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l8mH83iAXJK3MTct .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l8mH83iAXJK3MTct :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} N×M:直连转换
(不采用)
客户端协议 A
canonical
统一中间表示
客户端协议 B
客户端协议 C
目标协议 X
目标协议 Y
目标协议 Z
这套代码里,canonical 是一个纯函数库,有两条硬边界,直接决定了它的定位:
- 它只存在于转换过程中,不落盘、不进审计。 canonical 是「过程量」,不是「持久量」。审计、脱敏、计费这些治理动作发生在协议识别之后、目标协议构建之前的语义层,但它们读的是被识别出来的协议结构,不是 canonical 本身。canonical 唯一的价值,是让转换函数之间有个共同的「语言」。
- canonical 模块不 require 任何协议模块,只 require 核心库和一个 uuid 工具。 这是为了避免循环 require。源码里有一段注释专门点出这一点:某个协议模块在 require canonical 时特意说明「canonical 不 require 任何协议模块,因此这里引入不会形成循环 require」。这是一个很细的模块边界约束,但正是这种约束让整套转换层能保持单向依赖、可测试。
canonical 承载的核心换算,我挑几个最典型的看它的职责边界:
chat_request_to_messages:把 OpenAI Chat 的messages数组提成 canonical 消息数组,顺带抽出tools、temperature等参数。split_system:把消息数组头部的连续 system 消息 折叠成一个顶层 system 文本。注意是「头部连续」------一旦遇到非 system 消息就停止,后面再出现的 system 不再归并。这个语义来自 Anthropic 的system是顶层字段、不是消息序列一员。messages_to_anthropic:canonical → Anthropic Messages。这里能看到 Anthropic 独有的结构约束:system是顶层;tool消息要翻译成 role=user 的tool_result内容块;相邻同角色必须合并(Anthropic 要求 user/assistant 严格交替)。messages_to_bedrock/build_bedrock_request:canonical → Bedrock Converse。Bedrock 的system是{ { text = ... } }的数组包裹,inferenceConfig承载温度/topP/maxTokens,model不进 body(走 URL path),tools要翻译成toolConfig。
这几组函数看着只是字段搬运,但真正难的地方是边界条件的翻译------各家对「什么消息合法」的定义不同。后面讲转换器时会具体展开。
2.3 转换器桥接:客户端协议 → 供应商能力
canonical 解决了「怎么算」的问题,但没解决「从哪到哪」的问题------一个被识别为 openai-responses 的请求,后端供应商可能支持 openai-chat,也可能只支持 anthropic-messages。这个「选哪条路」的职责,在 converters/ 目录里。
转换器注册表是一张二维表:registry[from][to] = converter_module。每个转换器是一个独立文件,声明自己的 from 和 to。要新增一个转换器,只需新建一个文件、在注册表里加一行,核心代码不用动。
但注册表是二维的,意味着同一个 from 可能对应多个 to ------比如 openai-responses 同时注册了转 openai-chat、转 anthropic-messages、转 bedrock-converse 三个目标。这时候该选哪个?答案取决于后端供应商的 capabilities(能力表):先看供应商支持哪些目标协议,再从交集里选一个。
#mermaid-svg-flM3ZdZ9r2zg8GXW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-flM3ZdZ9r2zg8GXW .error-icon{fill:#552222;}#mermaid-svg-flM3ZdZ9r2zg8GXW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-flM3ZdZ9r2zg8GXW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .marker.cross{stroke:#333333;}#mermaid-svg-flM3ZdZ9r2zg8GXW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-flM3ZdZ9r2zg8GXW p{margin:0;}#mermaid-svg-flM3ZdZ9r2zg8GXW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster-label text{fill:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster-label span{color:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster-label span p{background-color:transparent;}#mermaid-svg-flM3ZdZ9r2zg8GXW .label text,#mermaid-svg-flM3ZdZ9r2zg8GXW span{fill:#333;color:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .node rect,#mermaid-svg-flM3ZdZ9r2zg8GXW .node circle,#mermaid-svg-flM3ZdZ9r2zg8GXW .node ellipse,#mermaid-svg-flM3ZdZ9r2zg8GXW .node polygon,#mermaid-svg-flM3ZdZ9r2zg8GXW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .rough-node .label text,#mermaid-svg-flM3ZdZ9r2zg8GXW .node .label text,#mermaid-svg-flM3ZdZ9r2zg8GXW .image-shape .label,#mermaid-svg-flM3ZdZ9r2zg8GXW .icon-shape .label{text-anchor:middle;}#mermaid-svg-flM3ZdZ9r2zg8GXW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .rough-node .label,#mermaid-svg-flM3ZdZ9r2zg8GXW .node .label,#mermaid-svg-flM3ZdZ9r2zg8GXW .image-shape .label,#mermaid-svg-flM3ZdZ9r2zg8GXW .icon-shape .label{text-align:center;}#mermaid-svg-flM3ZdZ9r2zg8GXW .node.clickable{cursor:pointer;}#mermaid-svg-flM3ZdZ9r2zg8GXW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .arrowheadPath{fill:#333333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flM3ZdZ9r2zg8GXW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-flM3ZdZ9r2zg8GXW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flM3ZdZ9r2zg8GXW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster text{fill:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW .cluster span{color:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-flM3ZdZ9r2zg8GXW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-flM3ZdZ9r2zg8GXW rect.text{fill:none;stroke-width:0;}#mermaid-svg-flM3ZdZ9r2zg8GXW .icon-shape,#mermaid-svg-flM3ZdZ9r2zg8GXW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flM3ZdZ9r2zg8GXW .icon-shape p,#mermaid-svg-flM3ZdZ9r2zg8GXW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-flM3ZdZ9r2zg8GXW .icon-shape .label rect,#mermaid-svg-flM3ZdZ9r2zg8GXW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flM3ZdZ9r2zg8GXW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-flM3ZdZ9r2zg8GXW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-flM3ZdZ9r2zg8GXW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 已识别:openai-responses
查 registryopenai-responses
候选目标:
openai-chat / anthropic / bedrock
供应商 capabilities
支持哪些?
按 TARGET_PRIORITY 排序
(同优先级按名称)
选定唯一目标协议 + 转换器
这里藏着一个极易被忽略的坑------遍历顺序 。Lua 的 pairs() 遍历顺序是不确定的。如果直接 for target, converter in pairs(from_map) 找出第一个供应商支持的,那同一个供应商、同一个请求,不同进程甚至不同时刻可能选出不同目标协议,结果就是行为随机漂移。源码里专门加了一段注释和一段排序:
lua
-- 同一 from 可能注册多个目标转换器。pairs() 遍历顺序不定,
-- 会让多能力供应商随机选目标------按固定优先级选择,优先级内按名称排序保证确定性。
local TARGET_PRIORITY = {
["openai-chat"] = 1,
["anthropic-messages"] = 2,
["openai-responses"] = 3,
["bedrock-converse"] = 4,
["vertex-predict"] = 5,
}
function _M.find(client_protocol, capabilities)
local from_map = registry[client_protocol]
if not from_map then return nil, nil end
local candidates = {}
for target_protocol, converter in pairs(from_map) do
if capabilities[target_protocol] then
candidates[#candidates + 1] = {
name = target_protocol,
priority = TARGET_PRIORITY[target_protocol] or 100,
converter = converter,
}
end
end
if #candidates == 0 then return nil, nil end
table.sort(candidates, function(a, b)
if a.priority ~= b.priority then return a.priority < b.priority end
return a.name < b.name
end)
return candidates[1].converter, candidates[1].name
end
TARGET_PRIORITY 是一份硬编码的优先级表:openai-chat 最优先(1),因为它和内部 canonical 同构、损耗最小;anthropic-messages 次之(2);依次往后。未列出的目标协议默认优先级 100,排到最后。优先级内再按名字排序,保证即便优先级相同,结果也是确定性的。
这十几行代码,是「生产环境可预测性」最朴素的体现:遍历顺序不确定的坑,用显式排序填平。
一个完整的转换器长什么样?我拿 openai-chat → anthropic-messages 举例,它是四个方向里最典型、也最能暴露结构差异的一个。它的职责可以拆成四件事:
- 折叠 system 到顶层 :Chat 的
messages里 system 是消息序列一员,Anthropic 要求它是顶层字段,于是走split_system把头部 system 提出来。 - 翻译工具调用 :Chat 的
assistant.tool_calls数组要翻译成 Anthropic 的tool_use内容块,tool角色的消息要翻译成 role=user 的tool_result块。arguments字符串要先json.decode成表再塞进input。 - 出站裁剪 :Anthropic 要求首条消息必须是
user(所以裁掉前导 assistant,并随之丢弃因此变成孤儿的 tool 消息),同时保留尾条 assistant------因为 Anthropic 把尾条 assistant 当作合法的 prefill。 - 补齐头字段 :这个方向有个经典坑,就是
anthropic-version头。如果转换器只翻译 body 不补头,上游 Anthropic 会因为缺版本头而挂起流式响应。这是「协议转换不只是 body 的事」的最直接证据。
关于第 3 点的「保留尾条 assistant」,它和第 4 章要讲的 Bedrock 形成鲜明对照:Bedrock Converse 不支持 assistant prefill,尾条 assistant 会 400 ,所以 drop_trailing_assistant 只挂在 Bedrock 出站口,Anthropic 侧不挂。同一个结构特征,在两家是相反的合法性------这正是协议转换里最隐蔽的坑:转换器必须「按目标协议的性格」办事,而不是按客户端协议的性格。
2.4 流式透传:SSE 的编解码与缓冲
请求转换好了、发出去了,响应回来时,网关要做的是流式透传 。这比请求转换更容易出问题,因为流式响应是「一截一截」来的,网关必须在不缓存完整响应的前提下,一边收一边解析一边转。
先说格式。绝大多数 LLM 供应商的流式响应走 SSE(Server-Sent Events),本质是 data: ...\n\n 的文本帧序列;但 Bedrock Converse 的流式走的是 aws-eventstream 二进制帧 ,每个事件是 {headers, payload} 的结构。网关的传输层要同时伺候这两种完全不同的帧格式,并且把它们统一成内部的事件模型,再喂给各协议的 parse_sse_event()。
SSE 的编解码有一个绕不开的问题:TCP 是字节流,不是消息流。 一个 \n\n 分隔的事件可能被拆成多个网络包到达,也可能几个事件粘在一个包里。所以传输层必须做缓冲切割。
#mermaid-svg-XexYwKHnDdH8R3IJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XexYwKHnDdH8R3IJ .error-icon{fill:#552222;}#mermaid-svg-XexYwKHnDdH8R3IJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XexYwKHnDdH8R3IJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XexYwKHnDdH8R3IJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XexYwKHnDdH8R3IJ .marker.cross{stroke:#333333;}#mermaid-svg-XexYwKHnDdH8R3IJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XexYwKHnDdH8R3IJ p{margin:0;}#mermaid-svg-XexYwKHnDdH8R3IJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster-label text{fill:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster-label span{color:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster-label span p{background-color:transparent;}#mermaid-svg-XexYwKHnDdH8R3IJ .label text,#mermaid-svg-XexYwKHnDdH8R3IJ span{fill:#333;color:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ .node rect,#mermaid-svg-XexYwKHnDdH8R3IJ .node circle,#mermaid-svg-XexYwKHnDdH8R3IJ .node ellipse,#mermaid-svg-XexYwKHnDdH8R3IJ .node polygon,#mermaid-svg-XexYwKHnDdH8R3IJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XexYwKHnDdH8R3IJ .rough-node .label text,#mermaid-svg-XexYwKHnDdH8R3IJ .node .label text,#mermaid-svg-XexYwKHnDdH8R3IJ .image-shape .label,#mermaid-svg-XexYwKHnDdH8R3IJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-XexYwKHnDdH8R3IJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XexYwKHnDdH8R3IJ .rough-node .label,#mermaid-svg-XexYwKHnDdH8R3IJ .node .label,#mermaid-svg-XexYwKHnDdH8R3IJ .image-shape .label,#mermaid-svg-XexYwKHnDdH8R3IJ .icon-shape .label{text-align:center;}#mermaid-svg-XexYwKHnDdH8R3IJ .node.clickable{cursor:pointer;}#mermaid-svg-XexYwKHnDdH8R3IJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XexYwKHnDdH8R3IJ .arrowheadPath{fill:#333333;}#mermaid-svg-XexYwKHnDdH8R3IJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XexYwKHnDdH8R3IJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XexYwKHnDdH8R3IJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XexYwKHnDdH8R3IJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XexYwKHnDdH8R3IJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XexYwKHnDdH8R3IJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster text{fill:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ .cluster span{color:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XexYwKHnDdH8R3IJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XexYwKHnDdH8R3IJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-XexYwKHnDdH8R3IJ .icon-shape,#mermaid-svg-XexYwKHnDdH8R3IJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XexYwKHnDdH8R3IJ .icon-shape p,#mermaid-svg-XexYwKHnDdH8R3IJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XexYwKHnDdH8R3IJ .icon-shape .label rect,#mermaid-svg-XexYwKHnDdH8R3IJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XexYwKHnDdH8R3IJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XexYwKHnDdH8R3IJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XexYwKHnDdH8R3IJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 网络包 1
data: {a}
data: {b
缓冲区
网络包 2
}
data: {c}
split_buf:按
切完整帧
完整帧:data: {a}
完整帧:data: {b}
残帧 {c} → 留在缓冲区
等下一包拼接
这套代码里的做法是 split_buf / decode_buf 两个函数配合:split_buf 把收到的字节流按 \n\n 切成完整事件帧,decode_buf 把残留在缓冲区里不完整的半截帧留着,等下一个数据块来了再拼。缓冲不是无限的,源码里设了一个上限------1 MiB------超过这个上限说明上游发了一个异常大的单帧,或者是恶意流量,直接按错误处理,防止内存被拖垮。
事件帧切出来之后,各协议的 parse_sse_event() 负责「看懂」这一帧是什么。三家的解析逻辑差异极大,正好能看出协议层的复杂:
- openai-chat :SSE 帧里通常没有
event:行,只有data:行,所以统一按type == "message"处理。空帧跳过;data: [DONE]是流结束信号;否则 JSON 解码后从choices[1].delta里取content(文本增量)、tool_calls(工具调用增量),或从usage取计费数据。 - anthropic-messages :它有明确的
event:行,事件类型多------message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop、ping、error。核心是content_block_delta,解析器要根据delta.type再分派:text_delta→ 文本增量,thinking_delta→ 推理文本(进 reasoning),input_json_delta→ 工具调用的参数增量。 - bedrock-converse :解析的是 aws-eventstream 的
{headers, payload}形态。有一个细节特别值得说:当它收到exception或error事件时,只记录事件类型和载荷大小,故意不记录载荷内容,然后返回一个「完成」信号。注释里的理由是防提示词泄漏------上游报错时可能把用户输入的一部分塞进错误信息里,如果网关把错误载荷记进日志,等于把敏感内容写进了审计系统。
这里已经能看出一个关键结论:流式透传不是「把字节原样转发」,而是「解析---识别---重发」。 网关必须解析每一帧,才能做审计增量、脱敏、计费这些治理;解析完再按目标协议重新编码发出去。所以「透传」这个词在这里有点误导性------它透传的是语义,不是字节。
三、实践落地:一段真实链路走查
原理讲完了,这一节把前面的东西串起来,走一遍「一个请求从进来到派发」的完整生命周期,再讲一个流式字段清洗的真实案例。
3.1 一个请求从进来到派发的完整链路
把检测、转换、透传三段流水线首尾接起来,一个 AI 请求在协议层里的生命周期是这样的:
#mermaid-svg-IxpaEO6In4I7w4di{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IxpaEO6In4I7w4di .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IxpaEO6In4I7w4di .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IxpaEO6In4I7w4di .error-icon{fill:#552222;}#mermaid-svg-IxpaEO6In4I7w4di .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IxpaEO6In4I7w4di .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IxpaEO6In4I7w4di .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IxpaEO6In4I7w4di .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IxpaEO6In4I7w4di .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IxpaEO6In4I7w4di .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IxpaEO6In4I7w4di .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IxpaEO6In4I7w4di .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IxpaEO6In4I7w4di .marker.cross{stroke:#333333;}#mermaid-svg-IxpaEO6In4I7w4di svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IxpaEO6In4I7w4di p{margin:0;}#mermaid-svg-IxpaEO6In4I7w4di .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IxpaEO6In4I7w4di .cluster-label text{fill:#333;}#mermaid-svg-IxpaEO6In4I7w4di .cluster-label span{color:#333;}#mermaid-svg-IxpaEO6In4I7w4di .cluster-label span p{background-color:transparent;}#mermaid-svg-IxpaEO6In4I7w4di .label text,#mermaid-svg-IxpaEO6In4I7w4di span{fill:#333;color:#333;}#mermaid-svg-IxpaEO6In4I7w4di .node rect,#mermaid-svg-IxpaEO6In4I7w4di .node circle,#mermaid-svg-IxpaEO6In4I7w4di .node ellipse,#mermaid-svg-IxpaEO6In4I7w4di .node polygon,#mermaid-svg-IxpaEO6In4I7w4di .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IxpaEO6In4I7w4di .rough-node .label text,#mermaid-svg-IxpaEO6In4I7w4di .node .label text,#mermaid-svg-IxpaEO6In4I7w4di .image-shape .label,#mermaid-svg-IxpaEO6In4I7w4di .icon-shape .label{text-anchor:middle;}#mermaid-svg-IxpaEO6In4I7w4di .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IxpaEO6In4I7w4di .rough-node .label,#mermaid-svg-IxpaEO6In4I7w4di .node .label,#mermaid-svg-IxpaEO6In4I7w4di .image-shape .label,#mermaid-svg-IxpaEO6In4I7w4di .icon-shape .label{text-align:center;}#mermaid-svg-IxpaEO6In4I7w4di .node.clickable{cursor:pointer;}#mermaid-svg-IxpaEO6In4I7w4di .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IxpaEO6In4I7w4di .arrowheadPath{fill:#333333;}#mermaid-svg-IxpaEO6In4I7w4di .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IxpaEO6In4I7w4di .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IxpaEO6In4I7w4di .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IxpaEO6In4I7w4di .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IxpaEO6In4I7w4di .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IxpaEO6In4I7w4di .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IxpaEO6In4I7w4di .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IxpaEO6In4I7w4di .cluster text{fill:#333;}#mermaid-svg-IxpaEO6In4I7w4di .cluster span{color:#333;}#mermaid-svg-IxpaEO6In4I7w4di div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IxpaEO6In4I7w4di .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IxpaEO6In4I7w4di rect.text{fill:none;stroke-width:0;}#mermaid-svg-IxpaEO6In4I7w4di .icon-shape,#mermaid-svg-IxpaEO6In4I7w4di .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IxpaEO6In4I7w4di .icon-shape p,#mermaid-svg-IxpaEO6In4I7w4di .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IxpaEO6In4I7w4di .icon-shape .label rect,#mermaid-svg-IxpaEO6In4I7w4di .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IxpaEO6In4I7w4di .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IxpaEO6In4I7w4di .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IxpaEO6In4I7w4di :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端请求体
(JSON 已解析为 Lua table)
detect(body, ctx)
返回协议名
find_converter(client_protocol,
supplier capabilities)
选目标协议 + 转换器
convert_request()
canonical 中转
build_request()
生成目标协议请求体
prepare_outgoing_request()
出站前最后一手
发送到上游供应商
parse_streaming_response()
逐帧解析 SSE 事件
转码回客户端协议
逐帧下发
这条链路上有两个环节最容易被人低估,我把源码细节摊开讲。
第一是「识别」和「派发」的衔接处。 detect() 返回协议名之后,怎么找到对应的模块和转换器?代码里有一个 get_proto() 的便捷包装,把「检测」和「取模块」两步合成一步:
lua
local function get_proto(body, ctx)
local name = _M.detect(body, ctx)
return name and registered[name]
end
别小看这三行。它定义了协议层的单一入口约定 :谁想识别协议,都走 detect();识别出名字,再查注册表。那些需要在网关转发之前运行的插件(比如提示词守卫、提示词装饰器)也复用这一个入口,保证整个网关对「这个请求是哪个协议」这件事的判断只有一种声音。反过来,如果每个插件各写一套自己的识别逻辑,很快就会冒出「审计插件认为是 chat、转换插件认为是 responses」的分裂------同一个请求在不同环节被认成不同的人,治理就崩了。
第二是 prepare_outgoing_request() 这个「出站前最后一手」的钩子。 它的定位是在转换完成之后、真正发出之前,做那些不属于结构转换、但影响出站行为的事。各协议对它的实现不同:
bedrock-converse用它做一件很实际的事:剥离body.stream字段 。因为 Bedrock Converse 不认stream这个 body 字段(它会拒绝未知字段),流式要改走 URL 的/converse-stream后缀。所以网关侧的is_streaming()读的是body.stream == true,但出站前要把这个字段剥掉、把 URL 换成 stream 变体。passthrough和openai-chat这里都是什么都不做。但注意openai-chat的这个函数有个注释与实现不一致的细节,我在 4.5 单独说。
这两个环节的共同点在于:它们都不是「转换主逻辑」,而是「转换前后必须补上的边角」。协议层的质量,很多时候就体现在这些边角是不是被认真对待了。
3.2 流式字段清洗:剥离那个「还剩多少 token」的标签
实践里有个很有意思的案例,正好能说明「流式透传不等于原样转发」。
先看一段很短的公共函数:
lua
-- 剥离不断累积的 "<total_tokens>N tokens left</total_tokens>" 这类 UI 提示标签
function _M.strip_total_tokens(s)
if type(s) ~= "string" then return s end
return (s:gsub("<total_tokens>[^<]*</total_tokens>%s*", ""))
end
这个函数干的事极简单:把文本里 <total_tokens>...</total_tokens> 这样一段标签整段删掉。为什么要删?源码注释讲得很清楚:某个编码助手的桌面客户端,会在每一轮的系统提示里注入 <total_tokens>N tokens left</total_tokens> 这样的展示标签,而协议转换会原样转发它,一轮追加一个。这个标签纯粹是给用户看的 UI 提示,对模型语义毫无意义。如果不清洗,它有两个害处:
- 污染系统提示 :模型会在上下文里看到一串越来越长的
<total_tokens>...历史标签,既浪费 token,又可能干扰输出。 - 破坏前缀缓存命中:提示词缓存(prefix-cache)的 key 依赖文本的字节稳定性。这个标签每轮都在变(N 在变),如果不删,前缀缓存永远命中不了------同样的历史对话,因为标签不同,字节串就不同了。
所以 strip_total_tokens 被做成了单一共享实现 ,在多个出站口(协议层、供应商层、前缀缓存计算处)统一调用,保证「同一个文本在任何环节看到的都是清洗后的稳定字节」。这个函数本身只有一行 gsub,但它的存在理由,是整个协议层「为什么要解析流式内容」的最好注脚:不解析,你就无从清洗;不清洗,治理就失效。
同理,响应方向也有清洗。流式响应里有些字段是「供应商私货」,网关要么转成客户端协议的标准字段,要么剥掉。下一节我会讲几个具体翻车的案例。
四、踩坑与边界
这一节我把翻源码和做选型时真实踩过的坑摊开。协议层的问题大多不是「功能没实现」,而是「边界条件没对齐」------单看每个函数都对,串起来就错。
4.1 检测顺序错一档,请求就被别人「抢」走
第 2.1 节讲了 embeddings/responses 的对称排除,那是防御「语义被误抢」的正解。但反过来看,这个坑本身才是最值得警惕的:两个协议共享同一个字段名 input,一旦谓词写得宽松一点,就会发生静默误判。
最典型的翻车路径是这样:客户端用 OpenAI Responses 协议(body 是 input 数组 + instructions 字段),但把 base_url 配成了网关地址、且网关地址不带 /v1,或者请求 URI 带了尾斜杠。此时 responses 的 URI 后缀匹配必然失败。如果 responses 的谓词是「只看 URI」,这个请求就会落到 embeddings 手里。而 embeddings 的语义是「向量化一段文本」,它提取正文时只收 input 里的字符串项、把 message 对象项丢掉;它也不读 instructions(那是 Responses 的系统提示语义)。最终结果是:审核插件拿到空字符串,判定「无事发生」静默放行,而一段含真实指令的请求未经审核就进了上游,系统里没有任何记录。
这个坑的可怕之处在于它全程没有报错------不是 500,不是告警,而是「悄悄做错了该做的事」。治理层的静默失效,比功能层的显式报错更危险,因为你根本不知道它失效了。
4.2 SSE 各家差异大,透传时最容易断流
流式响应是协议层事故的高发区,因为不同供应商对「流什么时候结束、事件怎么命名、分片怎么切」有各自的理解。我挑三个最典型的。
一是缺 anthropic-version 头导致流式挂起。 Anthropic 的 API 强依赖请求头里的 anthropic-version 字段。转换器如果只翻译了 body、没补这个头,上游会直接挂起流------不是报错,而是不返回任何内容地一直挂着。这类 bug 在测试时极难发现,因为只有流式场景才触发,而且表现为「卡住」而非「报错」。
二是 tool_calls 和 finish_reason 同帧到达时,工具调用被吞。 OpenAI 的流式协议里,当模型决定调用工具时,最后一个数据帧会同时携带 tool_calls 的增量和 finish_reason="tool_calls"。如果网关的解析逻辑把「出现 finish_reason」当作「流已结束、可以收尾」,就会在收尾的同时丢掉还没处理的 tool_calls 增量------工具调用凭空消失,客户端永远等不到工具结果。正确的做法是:finish_reason 只标记「语义结束」,但仍要先把当前帧里的 tool_calls 增量拼完再收尾。
三是 data: [DONE] 被拆成两帧。 OpenAI 的流结束信号是 data: [DONE] 这一行。如果网络把这个字符串拆成两半(比如 data: [DO 和 NE] 分两次到达),缓冲逻辑没写好就会漏掉结束信号,导致下游客户端以为流还在继续、一直挂着。这就是为什么第 2.4 节强调 split_buf/decode_buf 的缓冲切割必须按 \n\n 边界来做、残帧必须正确留到下个数据块拼接,而不是「收到多少就解析多少」。
这三类问题有个共同点:都是「协议没对齐」而非「代码没写对」。 每个单独的解析函数在自己的协议语境下都是对的,但协议之间的边界(头、结束信号、字段时序)没对齐,就会在流式场景下暴露。
4.3 passthrough 兜底不是万能,它是「有限兜底」
passthrough 是检测链的最后一环,也是很多人对它的误解最大的一环。它的 matches() 极其宽松:
lua
-- passthrough.lua
function _M.matches(body, ctx)
return type(body) == "table" and next(body) ~= nil
end
任何非空表都命中。听起来像是「什么都接」的万能兜底,但它的注释明确划了能力边界:passthrough 只做 model 重写、鉴权头注入、目标地址改写,不做任何协议转换。
这意味着什么?意味着一个被 passthrough 接住的请求,parse_sse_event() 永远返回「跳过」(跳过解析),extract_request_content / extract_system_content 返回空,extract_response_text / extract_usage 返回空。翻译成人话:这个请求可以转发,但网关对它的语义一无所知。 审计拿不到正文、脱敏匹配不到敏感字段、计费拿不到 usage、提示词缓存拿不到 key。
所以 passthrough 的正确理解是:它是最后一道防线,保证「不认识的东西也能转发出去」,但代价是「不认识的治理全失效」 。生产上不能指望它兜住所有奇形怪状的客户端,而应该把真正要支持的协议显式写进 detection_order、写清楚 matches()。passthrough 的存在价值是可用性兜底,不是治理兜底。这一条也是我做过企业落地后最想强调的边界:别把「能转发」当成「能治理」。
4.4 遍历顺序不定 = 生产事故
第 2.3 节提到的 TARGET_PRIORITY 排序,背后是个真实的翻车教训。Lua 的 pairs() 遍历顺序不确定,如果直接用它遍历 registry[from] 找第一个供应商支持的目标协议,就会导致:同一个供应商、同一个请求,在不同进程、不同 GC 状态、甚至不同 Lua 版本下,可能选出不同的目标协议。
表面看这只是「结果偶尔不一样」,但放到生产上就是灾难:同一个客户端请求,这一秒被转成 anthropic-messages,下一秒被转成 bedrock-converse,下游的计费、日志、行为全都随机漂移,复现不了、排查不了。凡是依赖遍历顺序做决策的地方,都必须显式排序或显式索引 ------这是这条代码用十几行注释和一次 table.sort 换来的教训。
4.5 注释承诺了、代码却没做
最后一个是纯工程质量的坑,很有代表性。openai-chat.lua 的 prepare_outgoing_request() 注释写着:
注入 stream_options 让上游返回 usage,覆盖 passthrough 和 convert 两种场景。
但函数体实际是:
lua
function _M.prepare_outgoing_request(_, _, _)
return false
end
直接 return false,什么都没做。注释承诺了一个行为(注入 stream_options 以便在流式响应里也能拿到 usage 做计费),实现却是空桩。
这个不一致的影响要分两面看。一方面,它意味着「流式场景下 usage 一定拿得到」这个假设不成立------如果你依赖这个注释去做计费接入,会发现流式响应的 usage 字段时有时无,取决于上游供应商是否主动返回。另一方面,它也是一个绝佳的提醒:读源码不能读注释,注释是意图,代码才是事实。 尤其是做企业评估时,看到「我们支持流式计费」这种能力声明,一定要回到代码里确认「是 return false 还是真的注入了字段」。这类「意图与实现的分叉」在开源项目里并不罕见,往往是功能演进过程中「先立桩、后填实现、结果桩一直没填」留下的。
五、小结
这一篇我们把 AI 网关的协议层从头到尾走了一遍。回顾最核心的几条认知:
检测的本质是「证据强度排序」。 URL+body 联合判定的排前面,纯 body 判定的排中间,兜底排最后。顺序是兜底,谓词才是本体------当两个协议共享同一个字段名时,靠顺序拦不住,必须靠谓词自身的排他性。
转换的本质是「引入中间表示,收敛 N×M」。 canonical 把组合爆炸压成 N+M,转换器 registry 再按供应商能力选目标协议。但转换真正难的不是字段搬运,而是边界条件的翻译------同样是尾条 assistant,Anthropic 认它是合法 prefill,Bedrock 却要裁掉,否则 400。
透传的本质是「解析---识别---重发」,不是字节原样转发。 网关必须解析每一帧流式内容,才能做审计、脱敏、计费、清洗。那个 strip_total_tokens 的例子最好地说明了这一点:不解析,就无从清洗;不清洗,治理就失效。
协议层的坑,几乎都是「边界没对齐」而非「功能没实现」。 检测顺序错一档导致静默误判、SSE 结束信号被拆帧导致断流、遍历顺序不定导致行为漂移、passthrough 兜底导致治理空转、注释与实现分叉导致能力误判------这些都是「每个函数单独看都对,串起来就错」的典型。
最后,我把这一路的观察收敛成一个能直接复用的判断框架。评估一个 AI 网关的协议层成熟度,别看它宣称支持多少家协议------那只是「广度」,真正决定生死的是三看:
- 看检测谓词的谨慎度 :它对共享字段(比如
input)的排他性写得有多严格?是不是只靠检测顺序兜底? - 看转换器的边界处理细度:它对各家「相反合法性」的边界(比如尾条 assistant)有没有逐家单独处理,还是一刀切?
- 看流式解析的容错度:它对「结束信号被拆帧」「同帧多事件」「异常大帧」这些异常,是显式处理还是隐式侥幸?
广度可以靠注册表堆出来,这三样只能靠踩坑踩出来。 这才是一个协议层真正的护城河。
下一篇预告(第 11 篇):这一篇我们解决了「请求进来,怎么被认出来、翻译好、透传回去」的问题。但有个更根本的问题还悬着:网关自己是怎么「长」出来的------它的路由规则、上游供应商、插件配置,是谁、通过什么机制同步到每一个运行实例上的?下一篇进入第 11 篇《配置同步:etcd 的 watch 机制与一致性》,我们把镜头从数据面切到控制面,看看一套分布式配置是怎么做到「改了立刻生效、并且每个实例看到的是同一个世界」。
