前阵子在给团队做 LLM 网关改造时,遇到了一个很典型的问题:业务方同时接入了两家大模型厂商的 API,一套走 OpenAI 兼容协议,一套走 Anthropic 协议。上层业务代码不想关心底层到底调的是哪家,这就需要有一个路由层把请求按规则分发到不同的后端,同时把协议差异在路由层消化掉。于是就有了 llm-d-router 这个项目。今天我把它的核心设计——两种 API 协议底层的 E/P/D 流程——完整拆开讲一遍,包括协议差异、三段式处理流程、关键代码实现和踩过的坑,给正在做类似网关、路由层或者协议适配的朋友做个参考。
1. 项目概述与设计背景
1.1 llm-d-router 是什么,解决什么问题
llm-d-router 本质上是一个轻量级的 LLM 请求路由器。它接收上层业务方发来的请求,根据预设的路由策略(模型名匹配、按权重分发、按业务线分流等),把请求转发到后端不同的大模型服务,并把响应原样返回给调用方。听起来像一个标准的反向代理,但真正麻烦的地方在于:后端服务并不遵守同一套 API 规范。
行业里 OpenAI 兼容协议几乎成了事实标准,大部分开源模型服务(vLLM、Ollama、LM Studio)都提供/v1/chat/completions接口。但 Anthropic 的 Messages API 从请求体到响应结构都和 OpenAI 差异巨大。业务方不可能同时维护两套调用代码,也不可能在两套代码之间来回切换,那就必须在路由层做协议归一化。
llm-d-router 的定位就是:
- 向上对业务方暴露统一的 OpenAI 风格接口,业务方只认这一套;
- 向下支持 OpenAI 协议和 Anthropic 协议两类后端,路由层自动完成请求转换和响应转换;
- 提供模型名到具体后端的映射能力,业务方写
gpt-4o还是写claude-3-5-sonnet都不用改代码,由路由层决定发到哪里。
1.2 为什么选择 E/P/D 三段式架构
在实现协议转换时,我一开始的直觉是写一个简单的适配函数:进来一个 OpenAI 请求,转成 Anthropic 请求发出去,再把响应转回来。但真正动手后发现这根本不够,因为协议差异分布在请求层、流式响应层、错误处理层和工具调用层,零散地写适配逻辑很快会变成一堆 if-else 的泥潭。
E/P/D 是把整个请求生命周期切成三个阶段,每个阶段只关心一件事:
- E(Extract):从原始请求中提取结构化数据,把"协议相关"和"业务语义"剥离开;
- P(Process):针对目标协议做映射换算,生成目标协议的请求对象;
- D(Dispatch):执行网络分发,处理流式响应和错误,把响应转回统一格式。
这样做的好处是每一层都可以独立测试。E 阶段不关心后端是 OpenAI 还是 Anthropic,D 阶段也不关心请求当初长什么样。改动 E 阶段不会影响 D,新增一种协议只需要加一个 P 阶段的转换器。后文我会把每个阶段的具体职责、关键代码和边界场景逐一展开。
2. 两种 API 协议的核心差异拆解
要理解 E/P/D 流程为什么这么设计,必须先搞清楚 OpenAI 和 Anthropic 协议之间的差异到底在哪些维度。我挑几个路由层躲不开的差异点重点讲。
2.1 请求体结构差异
OpenAI 的/v1/chat/completions请求体大概是这样的:
{ "model": "gpt-4o", "messages": [ { "role": "system", "content": "你是助手" }, { "role": "user", "content": "你好" } ], "temperature": 0.7, "max_tokens": 1024, "stream": true }Anthropic 的/v1/messages请求体则是:
{ "model": "claude-3-5-sonnet-20241022", "system": "你是助手", "messages": [ { "role": "user", "content": "你好" } ], "max_tokens": 1024, "temperature": 0.7, "stream": true }差异点非常明显:
- system 消息位置不同:OpenAI 把 system 作为 messages 数组里的一个元素,role 是
system;Anthropic 把 system 单独拆成顶层字段。 - max_tokens 必填性不同:Anthropic 的 max_tokens 是必填参数,不填直接报错;OpenAI 的 max_tokens 可以省略,由服务端决定生成长度。
- model 命名粒度不同:OpenAI 生态里
gpt-4o这种短名很常见,Anthropic 的完整模型名通常带版本后缀,比如claude-3-5-sonnet-20241022。 - 角色体系差异:OpenAI 有 system/user/assistant/tool 四种角色,Anthropic 的 messages 里只有 user/assistant 两种,system 独立出去,tool 消息靠 tool_use 和 tool_result 块来承载。
这些差异在 E 阶段做数据提取时都要考虑进去,否则到了 P 阶段才发现缺字段,处理起来就很被动。
2.2 流式响应协议差异
如果说请求体差异是"看得见的坑",流式响应的差异就是"踩进去才知道深"的坑。
OpenAI 的流式响应是 SSE(Server-Sent Events)格式,每个事件是一个data: {...}行,内容是:
{ "choices": [ { "delta": { "content": "你" }, "finish_reason": null } ] }每来一个 token,就发一个带choices[0].delta.content的事件。最后有一个finish_reason不为 null 的事件,以及一个data: [DONE]结束标记。
Anthropic 的流式响应同样是 SSE,但事件类型丰富得多,每个事件有type字段:
event: message_start data: { "type": "message_start", "message": { "id": "...", "model": "..." } } event: content_block_start data: { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } } event: content_block_delta data: { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "你" } } event: message_delta data: { "type": "message_delta", "delta": { "stop_reason": "end_turn" } } event: message_stop data: { "type": "message_stop" }注意几个关键区别:
- OpenAI 只有一种 data 块,你不需要解析 event 行;Anthropic 的 event 行携带事件类型,data 里的 type 字段也要校验。
- Anthropic 一个响应会先发 message_start 和 content_block_start,再发内容增量。如果路由层不缓存这个状态,直接把 content_block_delta 转发给上游,客户端收到的第一个事件就不是 OpenAI 格式。
- 结束标记也不同:OpenAI 是
[DONE],Anthropic 是 message_stop 事件。
这就意味着 D 阶段处理流式响应时,必须做事件级的协议转换,而不只是把字节流透传。
2.3 工具调用与多模态的差异
工具调用是另一个绕不开的差异点。OpenAI 的 tool_calls 在流式响应里是增量拼接的:
{ "choices": [ { "delta": { "tool_calls": [ { "index": 0, "id": "call_abc", "function": { "name": "get_weather", "arguments": "" } } ] } } ] }后面每个增量事件里的 arguments 都是片段,需要按 index 聚合起来才能得到完整的 JSON 参数字符串。
Anthropic 的工具调用走的是 content_block 机制,流式响应中 tool_use 块同样有 start、delta、stop 三个事件,delta 里的 partial_json 也是分批到达。两套机制的语义相同,但 JSON 结构和聚合逻辑完全不同。
多模态方面,OpenAI 的 image_url 是嵌在 message content 数组里的;Anthropic 的 image 也是 content block 的一种,但格式是{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}。请求里的图片怎么从 OpenAI 格式映射到 Anthropic 格式,是 P 阶段的重点工作。
3. E/P/D 流程的阶段职责与边界
3.1 E(Extract)阶段:统一数据结构的定义
E 阶段做的事,是把进入路由层的请求解析成一套与协议无关的中间数据结构。我把它叫做 RequestContext,它会贯穿整个请求生命周期。
RequestContext 至少包含这些字段:
type RequestContext struct { Model string // 业务方传入的模型名 SystemPrompt string // 拆出来的 system 提示词 Messages []MessageContext // 归一化后的消息列表 Temperature *float64 MaxTokens int Stream bool Tools []ToolContext // 工具定义 RawParams map[string]interface{} // 无法归一的原始参数 BackendID string // 路由决策后选中的后端 ID }E 阶段的关键动作有三个:
第一,从原始请求体里抽取字段。如果进来的是 OpenAI 请求,就从 messages 里把 role 为 system 的那条单独提出来,剩余的生成 MessageContext 列表。如果进来的是 Anthropic 请求,system 字段直接对应 SystemPrompt,messages 里的 content 如果是数组块,要合并成文本或转成统一的消息表示。
第二,做基本校验。模型名是否在路由表中匹配到后端?缺少必要参数时要不要直接拒绝?我一般会把校验放在 E 阶段做,因为这时候请求还是原始格式,报错信息可以直接透传给调用方,语义最清晰。
第三,生成请求追踪 ID。这一步很重要,后面 D 阶段分发时,无论成功失败,都要靠这个 ID 把日志串起来。
3.2 P(Process)阶段:协议映射与参数换算
P 阶段是 llm-d-router 的核心。它的输入是 RequestContext,输出是目标协议的具体请求对象。针对 OpenAI 后端和 Anthropic 后端,需要分别实现转换器。
OpenAI 转换器的逻辑相对简单,因为 RequestContext 本来就从 OpenAI 风格抽取而来。注意的细节是:
- MaxTokens 为空时,不写入请求体,让后端自己决定;
- Temperature 为空时写默认值 1.0 或者不写,取决于后端版本;
- Tools 按 OpenAI 的 tools 结构还原回去。
Anthropic 转换器麻烦一些:
- MaxTokens 必须填,如果业务方没传,给一个默认值,我用的是 4096;
- SystemPrompt 放到顶层 system 字段;
- 消息内容如果是纯文本,转成
{"type": "text", "text": "..."}的 content_block 数组结构; - Tool 定义需要从 OpenAI 的 function 结构转成 Anthropic 的 tool_use 结构,其中
parameters字段名要改成input_schema; - 如果消息里有 image 内容,OpenAI 的
image_url要转成 Anthropic 的 image block,这里要注意图片 URL 和 base64 数据的处理差异。
P 阶段还负责处理路由注入的额外字段,比如给某些后端特定参数(top_p、stop_sequences 等)做透传或映射。这些逻辑都放在转换器里,和 E、D 阶段保持隔离。
3.3 D(Dispatch)阶段:分发、流式转换与错误归一
D 阶段处理三件事:网络请求、响应转换、错误归一。
网络请求是标准 HTTP 客户端行为,但在流式场景下不能简单用现成的http.Client.Do一把梭。需要把响应体包装成带缓冲的 Reader,逐行读取 SSE 事件,边读边转换边写回上游响应。这样才能做到真正的流式转发,避免等全部 token 生成完才返回。
响应转换的核心是"状态机"。Anthropic 流式响应的多个事件类型需要按顺序组装,最终才能构造出 OpenAI 风格的 delta 事件。我在转换器里维护一个流状态结构:
type StreamState struct { MessageID string Model string ContentIndex int TextBuffer strings.Builder StopReason string ToolCalls map[int]*ToolCallAccumulator }收到 anthropic message_start 事件时,记录 MessageID 和 Model。收到 content_block_start 时,判断块类型:text 块忽略,tool_use 块就在 ToolCalls 里注册一个累积器。收到 content_block_delta 时,根据 delta.type 分派:text_delta 转成 OpenAI 的choices[0].delta.content事件,input_json_delta 追加到对应的 tool call arguments。收到 message_delta 时,记录 stop_reason。收到 message_stop 时,把累积的 tool_calls 作为最终事件输出,然后输出结束标记。
错误归一也放 D 阶段。OpenAI 后端的错误响应体是{"error": {...}},Anthropic 是{"type": "error", "error": {"type": "...", "message": "..."}}。路由层要统一转成 OpenAI 风格的错误格式返回给上游,同时把原始状态码透传。特殊的是 429 限流和 5xx 错误,这时 D 阶段还可能触发重试或熔断逻辑。
4. 实操:路由转换的核心实现
4.1 协议适配器的接口设计
我用 Go 实现了这个路由,因为团队基础设施是 Go 栈。适配器的接口设计非常关键,它决定了新增一个协议后端要写多少代码。
我定义了一个 BackendAdapter 接口:
type BackendAdapter interface { ConvertRequest(ctx *RequestContext) ([]byte, error) ParseResponse(resp *http.Response, stream bool) (*UnifiedResponse, error) ParseSSEChunk(chunk []byte) ([]*UnifiedEvent, error) }每个后端(OpenAI 后端、Anthropic 后端)实现这个接口。路由核心只依赖接口,不依赖具体实现。ConvertRequest 对应 P 阶段,ParseResponse 和 ParseSSEChunk 对应 D 阶段。
UnifiedResponse 是一个内部统一的响应结构,最终会转成 OpenAI 格式写回上游。UnifiedEvent 是流式事件的结构体,包含 EventType、Content、ToolCalls、FinishReason 等字段。
4.2 流式转换的完整流程
流式转换是最容易出错的地方,我直接贴一段经过生产验证的核心逻辑,注释里写了关键决策点。
func (a *AnthropicAdapter) ParseSSEChunk(chunk []byte) ([]*UnifiedEvent, error) { // chunk 是一行以 data: 开头的 SSE 负载 raw := strings.TrimSpace(strings.TrimPrefix(string(chunk), "data:")) if raw == "" { return nil, nil } // 处理流结束标记 if raw == "[DONE]" { return nil, nil } var msg AnthropicStreamMessage if err := json.Unmarshal([]byte(raw), &msg); err != nil { return nil, fmt.Errorf("unmarshal anthropic chunk: %w", err) } // 这里不能只按 type 转发,message_start 的 message 字段里 // 带着 model 和 usage 信息,需要缓存下来,后面组装响应要用 switch msg.Type { case "message_start": a.state.MessageID = msg.Message.ID a.state.Model = msg.Message.Model return nil, nil case "content_block_start": if msg.ContentBlock.Type == "tool_use" { a.state.ToolCalls[msg.Index] = &ToolCallAccumulator{ ID: msg.ContentBlock.ID, Name: msg.ContentBlock.Name, Arguments: strings.Builder{}, } } return nil, nil case "content_block_delta": var events []*UnifiedEvent if msg.Delta.Type == "text_delta" { events = append(events, &UnifiedEvent{ EventType: "content", Content: msg.Delta.Text, }) } else if msg.Delta.Type == "input_json_delta" { acc := a.state.ToolCalls[msg.Index] if acc != nil { acc.Arguments.WriteString(msg.Delta.PartialJSON) } } return events, nil case "message_delta": // message_delta 的 stop_reason 要存起来, // 因为最终输出的 finish_reason 要用它 a.state.StopReason = msg.Delta.StopReason return nil, nil case "message_stop": // 流结束时把累积的 tool_calls 一次发出去 var events []*UnifiedEvent for _, acc := range a.state.ToolCalls { events = append(events, &UnifiedEvent{ EventType: "tool_call", ToolCall: &ToolCallCompleted{ ID: acc.ID, Name: acc.Name, Arguments: acc.Arguments.String(), }, }) } finishReason := a.state.StopReason if finishReason == "" { finishReason = "end_turn" } events = append(events, &UnifiedEvent{ EventType: "finish", FinishReason: finishReason, }) return events, nil } return nil, nil }这里我最想强调的一个坑是:content_block_delta 里返回的 events 必须是片段式的,第一个 text_delta 到最后一个 text_delta 之间,不能做任何缓存或等待语义完整的操作。否则客户端会明显感觉到首字延迟变高。Anthropic 的 message_start 和 content_block_start 事件则相反,必须消费掉不转发,因为 OpenAI 协议里没有这两个事件类型。
4.3 请求转换的参数映射细节
请求体转换的核心在于字段映射表。我实际用的 OpenAI 到 Anthropic 转换逻辑里,最需要注意的几个映射关系如下:
| OpenAI 字段 | Anthropic 字段 | 注意事项 |
|---|---|---|
messages中 role=system 的内容 | 顶层system | 多条 system 消息用\n\n拼接 |
max_tokens | max_tokens | Anthropic 必填,缺省给 4096 |
temperature | temperature | 两边都不必填 |
top_p | 无直接对应 | Anthropic 支持 top_p 但语义略有不同,需原样透传并测试 |
tools的 function.parameters | tools的 input_schema | 字段改名,JSON Schema 内容一致 |
messages里 content 为数组的多模态块 | content_block 数组 | image_url 需转 base64 或 URL 格式 |
我在 P 阶段的 ConvertRequest 里,特意用了显式的字段赋值,而不是 JSON 标签的简单重映射。原因有两个:一是 system 消息需要从 messages 数组里拆出来,这属于结构性操作;二是图片内容需要同时访问请求头里的原始数据,纯 JSON 标签做不到这种逻辑。
4.4 D 阶段的路由策略与后端管理
D 阶段除了协议转发,还承担了路由决策的执行。我在路由表里维护了一个模型名到后端映射的列表:
routes: - model: "gpt-4o" backend: "openai-main" - model: "claude-3-5-sonnet" backend: "anthropic-main" - model: "default" backend: "openai-fallback"路由表的 match 规则支持精确匹配和通配前缀匹配。anthropic/*这类写法可以直接把所有 Anthropic 模型都指向指定后端。匹配失败时走 default 路由,默认路由再失败就返回 404 错误。
健康检查也放在 D 阶段。我每 30 秒对后端做一次带超时的 ping 请求,失败的标记为不可用,路由层自动跳过。这个机制在线上帮过大忙——有一次 Anthropic 后端出现区域性故障,路由层自动把流量切到了备用通道,业务方几乎无感知。
5. 常见问题与排查记录
5.1 流式 SSE 解析错位:事件提前截断
第一次联调流式接口时,我遇到一个诡异的 bug:客户端收到的内容偶尔会缺少前几个 token。排查半天发现是 SSE 的\n\n分隔符处理问题。有些 SSE 消息的 data 行和 event 行之间是\r\n分隔,我没做兼容处理,导致bufio.Scanner按\n切割时,把 event 行和 data 行切成两半,一部分被误认为是空行跳过了。
解决办法是换用bufio.Reader.ReadString('\n')逐行读取,同时对每行做strings.TrimSuffix(line, "\r")。这是 SSE 解析里的经典坑,建议所有做流式协议对接的人都先把换行符处理写进落地规范。
5.2 模型名映射混乱
业务方传的模型名和后端实际部署的模型名经常不一致。比如业务方写claude-3.5-sonnet,Anthropic 后端要求的完整名是claude-3-5-sonnet-20241022。刚开始我在路由表里硬编码这个映射,后来模型版本一更新就得改配置。
我的最终方案是:路由表里支持模型名别名(alias)和后端口径(canonical)两套字段。业务方传别名,路由层根据别名查表拿到 canonical 名称,再替换请求体里的 model 字段。这样模型服务升级时,只需要改 alias 表的 canonical 值,业务方零改动。
5.3 工具调用聚合竞态
Anthropic 流式响应中,同一个 index 的 input_json_delta 事件可能被分片发送,但网络传输中无法保证严格顺序。我在 ToolCallAccumulator 里用 strings.Builder 追加时,没有加锁。在并发环境下(两个不同的工具调用交错返回),会出现 arguments 内容互相污染。
解决方式是在解析循环里增加按事件顺序的处理队列,或直接给每个 accumulator 加把读写锁。我选的是后者,因为事件天然是顺序到达的,锁的额外开销可以忽略。
5.4 HTTP 超时设置不当导致流式中断
还有一个高频问题:HTTP 客户端的超时设置。如果设置了整个请求的绝对超时(比如http.Client{Timeout: 30 * time.Second}),流式连接 30 秒会被强制断开,长对话生成超过 30 秒就直接失败。正确的做法是设置 DialContext 超时和 TLS 握手超时,但不要设置全请求超时,改用 context 来做流式连接的生命周期管理。
我现在用的参数是:连接超时 3 秒,空闲连接超时 30 秒,无整体超时。流式响应由 deadline 检测单独管理,这样既能防止连接泄漏,又不会误杀长对话。
6. 实操中的一些个人体会
做 llm-d-router 这个项目的过程中,我最大的感受是:协议路由这个事儿,表面上看是格式转换,实际上是对整个 LLM 请求生命周期的一次梳理。E/P/D 三段式架构的价值,不在于它多花哨,而在于它把"读请求、改请求、发请求"这三个职责死死地隔离开来。后面不管是新增一个后端协议,还是调整路由策略,我只需要动对应的一个阶段,其余代码完全不受影响。
如果你也要做类似的协议层网关,我的建议是:先把两种协议的完整请求示例和完整流式响应示例打印出来,对照着写字段映射表,比对着文档瞎猜高效得多。另外,流式这块一定要用真实的流式响应去测,不要拿非流式响应假装转成流式来验证。最后,日志里务必记录每个请求的 E/P/D 三个阶段耗时,线上出问题时,这组数据能帮你快速定位瓶颈到底在协议转换还是网络分发。
这个项目目前还在持续打磨,后续我计划加上基于延迟和令牌吞吐的动态路由权重调整,以及更细粒度的用量统计。如果你们团队也在做类似网关,欢迎一起交流踩坑心得。