ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM消息路由核心:E/P/D三段式协议转换与网关设计

LLM消息路由核心:E/P/D三段式协议转换与网关设计 做LLM应用落地这一年多我越来越觉得消息路由这块是被低估的环节。你可能同时接了两三家模型厂商对外却要提供一套稳定的接口这时候llm-d-router这类组件就派上用场了。今天想聊的是它内部针对OpenAI兼容协议和Anthropic兼容协议这两套API体系如何把请求生命周期的E/P/D三段流程真正落地。所谓E/P/D是我在实际开发中习惯使用的拆解方式E是Entry/Encode入口协议解析与编码转换P是Process请求处理、Prompt组装与路由决策D是Dispatch出站分发与响应回传。很多路由组件文档里不会把这三段讲得很细但恰恰是这三段的边界划分决定了组件好不好扩展、好不好排查问题。这篇文章会从源码级的视角把两套协议在E/P/D每个阶段的表现差异、实现要点和踩坑记录完整过一遍。如果你正在自研网关、做模型聚合层或者只是好奇一个LLM路由器内部是怎么工作的这篇文章应该能给你一个比较完整的参考。文中涉及的关键路径我会配合代码片段和请求示例说明方便你直接对照自己的实现来改。1. 项目整体设计与路由核心思路1.1 为什么需要两套协议同时兼容先说一个痛点场景。假设你的业务已经接入了OpenAI的接口Prompt、流式返回、工具调用都调试好了某天老板说“我们也要接Anthropic的模型”你打开Anthropic的文档一看麻烦来了。请求路径不一样OpenAI是/v1/chat/completionsAnthropic是/v1/messages鉴权方式不一样OpenAI用Authorization: BearerAnthropic还要带x-api-key和anthropic-version头消息格式也不一样OpenAI的消息是messages数组里放system/user/assistant角色Anthropic把system单独拆出来消息里只有user/assistant就连max_tokens这种基础参数的语义都不同OpenAI部分模型叫max_completion_tokensAnthropic一直用max_tokens。如果业务层直接对接这两套协议你的代码里会到处是if provider openai这样的分支判断今天加一个模型明天调一个参数维护成本直接起飞。llm-d-router的解法是把入站请求统一解析成一套内部中间态再在出站时根据目标厂商重新组装。业务层永远只面对一套接口厂商差异全被隔离在路由层内部。这个思路并不新鲜API网关领域早就是这么干的难的是LLM场景下细节非常多Prompt有系统提示词、历史消息、工具定义、多模态内容流式返回有SSE、增量块、结束标记这些都要在E/P/D三段里处理干净。1.2 E/P/D三段式流程的设计动机我在最初设计这个组件时其实只用了一个很粗糙的转发函数入口拿到请求直接改一下URL和Key就转发出去。一两个供应商的时候没问题但供应商一多就出事了——每个厂商的参数映射逻辑都搅在一起一个流式中断的Bug查了我整整两天。后来我参考了网关设计里的管道模式把请求生命周期拆成了E/P/D三段E阶段只干“翻译”的活把HTTP请求体的字节流解析成具体的参数结构校验字段合法性转换成统一的中间态对象。这个阶段不关心发给谁只关心“来的是什么”。P阶段只干“决策”的活根据中间态里的模型名、Prompt长度、业务标签、预算策略决定把请求路由给哪个厂商的哪个模型同时完成Prompt归一化、参数换算、工具调用格式调整。D阶段只干“执行”的活把中间态重新序列化成目标协议的请求体发起HTTP调用处理流式SSE响应再把响应转换回统一的出站结构返回给调用方。这个拆法最大的好处是每一段的输入输出都非常明确可以分别做单元测试。E阶段跑一批不同协议的请求样例P阶段用mock的路由策略表验证决策结果D阶段对着录制的响应报文验证转换逻辑。排查问题的时候只要看是哪个阶段出的错范围瞬间缩小一大半。2. E阶段入站协议解析与标准中间态2.1 OpenAI协议入站解析要点OpenAI兼容协议目前是事实上的行业标准绝大部分开源模型服务比如vLLM、LocalAI、Ollama的兼容模式都实现了这套接口。E阶段处理OpenAI请求时核心是要把所有可能出现的字段都映射到中间态不能只处理model、messages、max_tokens这几个常见字段。请求路径需要区分/v1/chat/completions对话补全和/v1/embeddings向量化llm-d-router在路由表里会把这两个端点映射到不同的内部处理链。如果业务方自己封装了SDK可能还会请求/v1/completions这些老接口建议在E阶段的前置拦截器里做一次路径归一化统一转换成内部的TaskType枚举。请求体解析时需要特别关注这几个字段messages内容可能是字符串也可能是多模态内容数组type: text或type: image_url。中间态里建议把内容统一转换成[]ContentBlock结构每个块带类型标记后续P阶段再按目标厂商能力决定保留还是降级。toolsOpenAI的function calling格式是一套{type: function, function: {name: ..., parameters: ...}}结构而Anthropic的tool格式是{name: ..., input_schema: ...}。这一块差异很大E阶段不能只做透传必须把tools也转成中间态的ToolDef[]。stream布尔值决定后续D阶段是走普通JSON返回还是走SSE流式通道。temperature/top_p采样参数两个协议都支持但取值范围略有差异中间态统一用float64保存P阶段再做边界裁剪。入站鉴权也建议在E阶段完成。llm-d-router支持配置多个上游KeyE阶段解析出请求方传入的API Key后查表确认这个调用方有权使用哪些模型组。鉴权不过直接返回401省得请求一路走到D阶段才发现没权限白白浪费一次上游调用。2.2 Anthropic协议入站解析要点Anthropic的Messages API在结构上和OpenAI有几点明显的区别E阶段如果照搬OpenAI的解析逻辑大概率会挂。第一是system提示词的位置。OpenAI的system消息混在messages数组里Anthropic则是独立的顶层字段。llm-d-router在解析Anthropic请求时会把system字段单独提取转成中间态的SystemPrompt再和messages里的历史消息分开存放。后续向OpenAI协议出站时再合并回第一条system消息向Anthropic出站时放回顶层字段两条路径都不会丢信息。第二是messages的角色约束。Anthropic要求user和assistant消息必须交替出现不能连续两条同一个角色而且第一条必须是user。这个限制在E阶段就应该做一次校验不符合规则直接返回400避免请求到达上游后收到一条莫名其妙的400错误排查时还得去翻上游文档。第三是max_tokens是必填字段。OpenAI的请求里不传max_tokens可能还能用默认值Anthropic不传会直接报错。E阶段解析时必须做好默认值兜底llm-d-router的配置里建议设置一个default_max_tokens比如1024解析阶段检测到缺失就自动补上。请求头方面Anthropic需要三个信息x-api-key放密钥anthropic-version放版本号比如2023-06-01Authorization也可以放Bearer key。llm-d-router在E阶段统一提取这三种来源的密钥存入上下文Context方便D阶段出站时自动带上正确版本号。2.3 统一中间态设计两套协议的请求都解析完之后会落到一个统一的中间态结构体上。这个结构是整条E/P/D链路的“通用语言”设计得好不好直接决定了后续路由策略好不好写。我用的中间态核心字段大致长这样用Go的struct描述比较直观type RequestContext struct { TaskType TaskType // ChatCompletion / Embeddings / ... Model string // 业务侧请求的模型名 SystemPrompt string // 系统提示词已归一化 Messages []ChatMessage // 归一化后的历史消息 Tools []ToolDef // 工具定义已归一化 MaxTokens int // 已带默认值 Temperature *float64 // 采样温度 Stream bool // 是否流式 Metadata map[string]string // 业务标签供路由策略使用 RequestID string // 全链路追踪ID } type ChatMessage struct { Role string // system / user / assistant Content []ContentBlock // 文本与多模态内容块 } type ToolDef struct { Name string Description string InputSchema map[string]interface{} // JSON Schema }这里有几个细节值得说明。Metadata字段非常重要它用来携带业务侧传过来的路由标签比如tenant_id、priority、budget_groupP阶段的路由策略可以基于这些标签做灵活决策。RequestID建议在E阶段的最前面生成用UUID或者雪花算法都行后续所有日志、上游请求头X-Request-ID都带这个ID排查问题时能一路追踪到上游厂商的日志。对于多模态内容中间态统一用[]ContentBlock数组保存内容块类型至少需要定义text、image_url、image_base64三种。出站时如果目标厂商不支持某种类型P阶段需要决定是降级比如图片转文字描述还是直接报错。llm-d-router目前的实现是支持降级配置的默认行为是报错这样更安全不会静默丢弃用户数据。3. P阶段Prompt组装与路由策略执行3.1 Prompt归一化与最大token换算P阶段拿到中间态之后第一件事不是选路由而是校验和修正参数。很多业务方在请求里传的模型名是别名真正发给上游的模型名要通过路由表转换但system提示词拼装、历史消息裁剪、max_tokens换算这几件事在两套协议下规则并不一样。拿max_tokens举例。OpenAI的新版模型比如gpt-4o系列支持max_completion_tokens而Anthropic的max_tokens表示的是生成阶段的最大token数。中间态里统一存成“生成token上限”出站时按目标协议重新取名。但这里有个坑部分模型的总上下文窗口有限如果你请求的历史消息很长max_tokens又设得很大上游会直接报“context length exceeded”。llm-d-router在P阶段会做一次估算用tokenizer库对SystemPrompt和Messages做粗略计费如果“已用token 请求的max_tokens 目标模型上下文上限”就自动裁剪历史消息或按策略减小max_tokens。历史消息裁剪也有讲究。不能简单地从最前面砍system提示词要保留越靠近当前轮次的对话越要保留。我常用的策略是先保system和最近N轮对话再根据剩余预算向前补充更早的消息。这个策略在llm-d-router里是通过一个MessageWindow配置控制的默认保留最近10轮超出部分按摘要方式压缩。temperature参数也需要做边界裁剪。OpenAI和Anthropic都支持0到2的范围但部分开源模型的取值范围是0到1。P阶段会根据路由表里配置的max_temperature做一次min钳制避免因为参数越界导致上游4xx错误。这类问题在接入私有化部署的小模型时非常常见用户侧拿默认的1.5去调结果上游直接拒绝加了钳制之后这类告警基本清零。3.2 路由策略的几种典型实现P阶段的核心功能是把中间态路由到正确的上游。llm-d-router实现了几种路由策略按优先级从高到低排列如下。固定模型映射是最简单的策略维护一张表业务侧请求的模型名映射到具体的上游厂商、模型名、API地址、密钥。比如gpt-4o映射到openai/gpt-4o-2024-11-20claude-opus映射到anthropic/claude-opus-4-20250514。这套策略适合大多数业务场景稳定、可控、容易排查问题。llm-d-router的路由表支持热更新修改映射后无需重启服务。基于Prompt长度的动态路由解决的是成本优化问题。同一个业务请求长文本走上下文窗口大的高端模型短文本走便宜的快模型。实现上是在P阶段计算完token估算值后与路由规则的阈值比较。比如if estimated_tokens 2000: target route_table[cheap_model] elif estimated_tokens 8000: target route_table[mid_model] else: target route_table[long_context_model]这种策略适合RAG问答、文档摘要这类场景能明显降成本。要注意的是阈值不能拍脑袋定最好基于线上请求的token分布直方图来确定先跑一周日志把P50/P90的token长度算出来再配阈值。基于可用性的降级路由是稳定性兜底。配置一个主模型和多个备选模型当主模型返回5xx错误或超时时自动切换到备选模型重试。llm-d-router内部维护了一个滑动窗口健康状态连续失败超过阈值会触发熔断后续请求直接走备选模型不再等待主模型超时。这个策略在生产环境中非常重要上游厂商的API偶尔抽风是家常便饭没有降级路由你的服务就会跟着一起挂。基于业务标签的权重路由适合做A/B测试或灰度发布。P阶段读取Metadata里的tenant_id或experiment_group按配置的权重比例把请求分发给不同模型从而对比效果。这个策略还可以和固定映射组合使用形成两级路由先按业务标签分组再在组内按模型映射或权重分发。3.3 路由决策与模型别名解析实际编码时路由决策是一个独立的函数输入是*RequestContext输出是*RouteTarget包含厂商、协议类型、模型名、API地址、密钥引用。这样设计的好处是路由逻辑可以单独测试。我习惯把所有路由规则编译成一个有序列表逐条匹配第一条命中的生效。type RouteTarget struct { Provider string // openai / anthropic / custom ModelName string // 上游的真实模型名 BaseURL string APIKeyRef string // 密钥的引用ID不要明文存在内存里 Protocol string // 出站协议默认与Provider一致 } func Route(ctx *RequestContext, rules []Rule) (*RouteTarget, error) { for _, rule : range rules { if rule.Match(ctx) { return rule.BuildTarget(ctx), nil } } return nil, ErrNoRouteFound }模型别名解析要注意大小写和版本号问题。业务侧可能传gpt-4o也可能传GPT-4O路由表里尽量统一用小写存储匹配时做大小写归一化。有些模型名带日期后缀比如claude-3-5-sonnet-20241022建议在映射表里配置完整名避免上游要求精确匹配时踩坑。P阶段还要决定出站协议。llm-d-router在路由表里支持Protocol字段默认情况下OpenAI的模型走OpenAI协议Anthropic的模型走Anthropic协议但也支持强制指定。比如你接了一个只实现OpenAI兼容协议的代理服务但背后实际接的是Claude模型这种情况下路由表里就可以把Protocol设为openaiP阶段会按OpenAI协议做序列化。4. D阶段出站分发与响应流式转发4.1 出站请求适配与签名复用路由决策完成之后D阶段要把中间态对象序列化成目标协议的请求体。这个阶段最容易出的问题不是“不会转”而是“转得不够全”。以OpenAI出站为例序列化的核心函数需要把中间态恢复成完整请求体。messages数组里system角色要放在最前面多模态内容块要转换成OpenAI的content数组格式tools要转换回{type: function, function: {...}}的嵌套结构。如果业务侧上传的是base64图片同时要保留image_url的detail参数低/高/自动。以Anthropic出站为例需要把SystemPrompt放回顶层system字段messages数组里不能出现system角色tools要转换成{name: ..., input_schema: ...}格式同时加上anthropic-version请求头。还有一个容易漏的细节Anthropic对messages数组有角色交替要求如果你从中间态恢复出来的消息出现连续user需要做合并处理。密钥管理方面llm-d-router的做法是密钥不落盘配置里只存引用ID启动时从环境变量或密钥管理服务加载到内存。D阶段通过APIKeyRef取用密钥在请求头发送时不要把完整密钥打到日志里。实测下来日志里打全量Key这种事真的会出安全事故务必做脱敏。4.2 流式响应处理SSE格式转换流式响应是D阶段最复杂的部分也是两套协议差异最大的部分。OpenAI的SSE格式每一行是data: {...}最后以data: [DONE]结束。每一个data块里包含完整的增量结构包括choices[0].delta.content、finish_reason、usage等信息。Anthropic的SSE格式则是一系列命名事件每个事件用event:行标注类型比如message_start、content_block_delta、message_delta、message_stop。真正的生成内容在content_block_delta事件里内容类型是text_delta的text字段。如果llm-d-router同时对接这两类上游且业务侧要求统一的出站格式D阶段需要做一次事件驱动的状态转换。上游每推送一个事件内部更新一个StreamState对象包含当前消息内容缓冲区、停止原因、token使用量再按出站协议格式重新序列化发送给调用方。async def forward_stream(upstream_events, outbound_protocol): state StreamState() async for event in upstream_events: state.update(event) # 内部维护内容与状态 outbound_chunk state.to_outbound_chunk(outbound_protocol) if outbound_chunk: yield outbound_chunk这个状态机的关键点是缓冲区的控制。比如Anthropic的content_block_delta只包含一小段文本增量OpenAI的delta.content也是增量。如果直接透传业务侧拿到的内容就是乱的。必须把增量拼进状态缓冲区再以目标协议的格式切分输出。还有一个细节OpenAI的流式返回默认不包含usage统计除非显式传stream_options: {include_usage: true}。如果你需要在流式结束时拿到token统计出站请求里要加上这个参数同时处理最后一个包含usage的chunk。4.3 错误码归一化与超时熔断上游返回错误时llm-d-router需要做两件事把上游的错误信息转换成统一的错误结构同时根据错误类型触发不同的处理逻辑。OpenAI的错误格式是{error: {message: ..., type: ..., code: ...}}Anthropic的错误格式是{type: error, error: {type: ..., message: ...}}。D阶段要把这两种结构统一成内部错误对象保留原始错误信息方便排查但对外只返回规范化的err_code和可读message。常见的错误码映射关系场景OpenAI code/statusAnthropic type/status统一错误码密钥无效401 invalid_api_key401 authentication_errorAUTH_FAILED余额不足429 insufficient_quota429 permission_errorQUOTA_EXCEEDED请求超限429 rate_limit_exceeded429 rate_limit_errorRATE_LIMITED模型不存在404 model_not_found404 not_found_errorMODEL_NOT_FOUND上下文超长400 context_length_exceeded400 context_length_exceededCONTEXT_OVERFLOW上游内部错误500 server_error500 api_errorUPSTREAM_ERROR超时和熔断机制是D阶段稳定性的屏障。llm-d-router每个上游都配置了三个参数connect_timeout默认3秒、read_timeout默认60秒流式下代表首个字节超时、idle_timeout流式相邻两个块的最大间隔默认30秒。流式场景下上游如果长时间不推送数据业务侧会一直干等所以idle_timeout建议单拎出来配置不要和read_timeout混在一起。熔断策略我采用的是滑动窗口计数每10秒为一个窗口统计失败率。失败率超过50%且请求量超过阈值触发熔断10秒熔断期间直接返回503并唤醒降级路由。这个策略实测比较稳不会因为偶发抖动就频繁熔断。5. 常见问题与排查技巧实录5.1 协议字段差异导致的解析失败我遇到过最多的问题是业务侧用OpenAI的SDK但请求体里带了Anthropic风格的字段或者反过来。比如有开发者在messages数组里把system提示词写成了{role: system, content: ...}但在Anthropic出站时没有单独提取到system字段结果上游报错。排查这类问题的关键是E阶段要输出结构化日志记录入站协议类型、关键字段是否命中、中间态对象的大致内容脱敏后。llm-d-router在debug模式下会把转换前后的JSON打印出来一行日志就能看出是哪个字段丢了。建议所有接入的模型服务都在测试环境跑一遍“协议往返测试”用OpenAI格式请求走Anthropic出站再反向来一次确保字段无损。5.2 流式响应乱码与中断问题流式响应出现乱码大概率是字符编码问题尤其是中文内容。OpenAI和Anthropic的SSE都是UTF-8但如果你在上游响应和下游转发之间做了转码比如误用了encodinglatin-1中文就会变成乱码。我处理过一起事故排查半天发现是某个代理层默认用了iso-8859-1读取上游响应。llm-d-router内部统一强制UTF-8并且在读取SSE时只按\n\n切分事件不做额外解码猜测。流式中断问题通常是idle_timeout设置太短。Anthropic的流式响应在生成过程中如果遇到工具调用会出现一段较长的停顿模型在等待工具结果如果idle_timeout配了5秒就容易误判超时中断。建议idle_timeout设为30秒以上或者根据模型的最大工具调用时间动态调整。5.3 超时参数未逐级透传导致的误判你在llm-d-router里配了120秒的read_timeout但上游网关可能30秒就断开了连接。这类问题排查起来很隐蔽因为你看到的是llm-d-router返回超时错误但实际上是上游的前置网关断的。我的排查方法是在D阶段发起上游请求时显式设置请求头X-Timeout如果上游支持同时在日志里记录上游从建连到断开的耗时。如果上游耗时低于你配置的超时时间说明超时发生在更上游的链路。llm-d-router还支持为每个上游单独配置一个“超时比例系数”比如上游网关超时是15秒你这边就配置timeout_factor: 0.8确保在llm-d-router发起重试或降级之前留出足够的响应余量。5.4 排查速查表现象可能原因排查路径上游返回404模型名或路由表未匹配查P阶段路由日志确认RouteTarget.ModelName上游返回400消息角色顺序不符或字段类型不对查E阶段中间态日志检查Messages结构流式首块返回慢上游排队或首字节超时配置过短观察D阶段time_to_first_byte指标流式中途断开idle_timeout过短检查相邻SSE块的间隔时间日志中出现密钥明文请求头或URL被异常打印检查日志脱敏配置确认APIKeyRef未展开我在实际维护中还有一个习惯每个上游厂商都做了独立的“最小转发测试”用固定请求体录制回放跑CI时自动比对响应结构。这样无论上游API怎么升级只要我们的转换逻辑还跑得通就能第一时间感知到。结语说说我对E/P/D这套流程的体会整套llm-d-router的核心其实就是把“翻译”“决策”“执行”这三件事彻底解耦。当初我刚拆完E/P/D三个阶段时确实花了一些时间做重构但之后每一次接入新模型、新厂商都变成了只改一个阶段的事情——接Anthropic就只动D阶段的序列化器加路由策略就只在P阶段加规则这让后期维护轻松了很多。如果你正在做一个类似的网关组件我的建议是不要一上来就写转发代码先把中间态结构设计好收住两套协议的差异点再把路由决策做成可配置、可测试的独立模块。另一点小经验是日志里一定要带RequestID并在每一阶段的出入口打点平时看不出价值线上出问题的时候你会感谢当初多写的那几行日志的。
返回列表