
今年年初接了一个内部AI中台的活把GPT、Claude、DeepSeek三家的模型同时接进现有的业务系统。当时天真地以为就是调三个SDK的事结果两个月后代码里到处是if model gpt、elif model claude这种散弹枪式分支每个服务各自维护一套API Key和重试逻辑改一个模型的参数要动四五个服务。痛到极致之后我专门做了层API网关把所有模型统一收口这篇文章就是这次实践中沉淀下来的一些经验。我看很多团队都有类似的烦恼——今天想换个便宜点的模型明天想让Claude处理长文本、GPT处理复杂推理、DeepSeek做批量任务如果每次切换都在业务代码里改迟早要出事。网关层的核心价值不是少写几行代码而是把模型差异、密钥管理、限流重试、成本控制全部收口到一个点上让业务方只关心一个稳定的接口。这篇文章不聊虚的直接讲我在自研多模型网关过程中踩过的坑、改过的代码、总结出的取舍。1. 统一网关要解决的四个真实问题协议、参数、错误、成本先说清楚我为什么要做网关而不是继续在业务代码里打补丁。表面上看OpenAI出了个兼容格式很多模型都宣称支持但实际用起来根本不是一回事。以下四个问题是我真实遇到的每一个都足够让人崩溃。1.1 协议差异OpenAI、Claude、DeepSeek各说各话OpenAI的接口是POST /v1/chat/completions请求体是messages数组带role和content返回结构是choices[0].message.content。Claude的Messages API走的是/v1/messages请求体虽然也叫messages但system消息需要单独拆出来放到顶层参数里而且必须带上anthropic-version请求头。DeepSeek的接口基本照抄OpenAI但细节上又有些偏移。格式不一致带来的冲击比想象中大。最典型的例子是你在OpenAI格式里写messages: [{role: system, content: ...}, ...]直接丢给Claude会报错或产生诡异的输出因为Claude要求system得放在system字段里反过来Claude返回的content可能是一个数组里面有多段text和tool_use而OpenAI返回的是字符串。这些差异如果靠业务层去适配每个对接方都要把各家文档读一遍写一堆重复的转换逻辑这纯粹是浪费人力。1.2 参数差异温度、max_tokens、top_p的微妙差别参数映射比协议映射更隐蔽。OpenAI的max_tokens在Claude那边叫max_tokens_to_sample新版本也兼容了max_tokens但老版本不是这样DeepSeek虽然也叫max_tokens但它的temperature范围是0到2OpenAI的范围是0到1.2不同模型还有差异。这导致同样一组参数在三个模型上的实际表现完全不同。最坑的一个案例我早期做转换时直接把OpenAI请求里的max_tokens透传给Claude结果发现Claude生成到一半就停了。查了半天发现OpenAI的max_tokens默认没有强制下限而Claude的max_tokens是必填项而且模型内部对它的语义理解略有差异。这类问题在文档上根本看不出来只有跑到线上流量里才会暴露。1.3 错误模型差异错误码不统一导致故障误判三个模型报错的方式完全不一样。OpenAI超限返回429Claude负载高返回529DeepSeek偶尔返回503而且错误体结构也不一样——有的带error.message有的带error.type有的干脆在非JSON的文本里告诉你service overloaded。如果不做统一封装业务方接到的就是一个莫名其妙的HTTP状态码根本不知道怎么处理。我见过最离谱的情况是下游同学把Claude的529当成业务错误去查代码查了两小时才发现是上游模型负载高。网关层把错误码标准化之后这些问题基本绝迹了。1.4 成本控制不同模型的价格差了一个数量级GPT-4级别的模型和DeepSeek的价差大约在10到20倍如果业务方能自由选择模型成本会完全失控。网关层可以在路由策略上做文章——默认走便宜的模型复杂任务才路由到贵的模型也可以在Key管理层面给不同部门配不同的模型白名单防止有人把贵模型当默认配置。这个问题放在业务代码里几乎没法解决因为你不可能在每个服务里都做一套成本策略。2. 选型之争One API、LiteLLM还是自研轻量网关做之前我调研过现成的开源方案主要看了One API和LiteLLM最后没直接用而是自研了一个精简版。我不想无脑推荐自研但确实有些场景下现成方案不如自己写来得好。2.1 三个方向的真实测试结果先说One API。功能确实全界面、密钥管理、令牌系统都有对OpenAI格式的兼容做得不错也支持Claude和DeepSeek。但问题在于它是个全家桶部署起来有依赖配置项多改个逻辑要看很多源码。如果你只是内部小范围用One API是完全OK的但如果要嵌入现有系统做深度定制比如要根据业务标签路由、要做细粒度的审计日志One API的二次开发成本会有点高。LiteLLM是Python写的一个代理层理念和我想要的非常接近——把各家模型转成OpenAI兼容格式。它也支持很多模型。实测下来LiteLLM对Claude的转换做得不错对DeepSeek这种OpenAI兼容模型也基本是透传。但它有个痛点要在环境变量里配很多上游Key而且它的请求转换逻辑是写死的我想改一下路由策略要么提PR要么fork。我最终选择自研的原因很简单第一我只需要支持三个模型不需要上百个第二我需要和公司内部的鉴权体系打通网关要接统一身份认证第三我需要非常灵活的路由和重试策略现成方案里都要改源码才能实现。2.2 自研网关的核心模块拆分自研网关我用的是Python FastAPI异步框架天然适合做IO密集型的转发层。核心模块拆成三块入站适配层接收OpenAI格式请求、上游转换层把统一请求转成各模型格式、策略层路由、重试、限流、审计。架构上最核心的一个取舍入站格式统一用OpenAI格式。理由不是因为它最好而是因为它是当前事实标准生态里大量工具都支持业务方学习成本最低。下游模型支持OpenAI格式的就直接透传不支持的比如Claude就在上游转换层做翻译。这样业务方和网关之间的契约是唯一的任何模型升级替换都不会影响业务代码。2.3 需要写多少代码没你想的那么多三个模型其实本质上都是HTTP转发核心适配代码不多。OpenAI和DeepSeek格式相似度高我大概花了100多行做细节点对齐Claude稍多一些因为system转换、头部处理、返回结构变换都要单独写。整个网关的Python代码最终是2000行左右其中一大半是错误处理、流式转发和限流逻辑纯适配代码只占一小部分。提示如果你们的团队人手少、模型只有一两个、业务也不复杂别自研直接用One API就够了。自研的收益在你需要深度定制的时候才真正体现出来。3. OpenAI格式统一层的实现Claude和DeepSeek的适配补丁这一节是全文技术密度最高的部分我会把适配层的关键细节讲透。适配层做的事情很简单入口收OpenAI格式的请求出去时根据模型名转换成对应格式。3.1 一个输入转换器的基本骨架我先定义了一个统一的中间结构体这个结构体有model、messages、temperature、max_tokens、stream、tools这些字段然后在往上游发请求之前根据目标模型做序列化。下面是我用的核心转换逻辑骨架def convert_to_openai(raw: dict) - dict: # 统一格式这个就是网关内部的标准请求 return { model: raw.get(model), messages: raw.get(messages, []), temperature: raw.get(temperature, 1.0), max_tokens: raw.get(max_tokens, 4096), stream: raw.get(stream, False), tools: raw.get(tools), } def convert_to_claude(req: dict) - dict: messages [] system for msg in req[messages]: if msg[role] system: system msg[content] else: messages.append(msg) payload { model: map_model_name(req[model]), messages: messages, max_tokens: req[max_tokens], stream: req[stream], } if system: payload[system] system if req.get(temperature) is not None: payload[temperature] req[temperature] if req.get(tools): payload[tools] req[tools] return payload这个骨架说明了两件事第一system消息要单独拆出来这是Claude和OpenAI最大的结构性差异第二max_tokens在Claude里是必填不能省略。这两个点如果你直接用透传跑一次就会踩坑。3.2 Claude适配头部处理与返回结构转换Claude要求的请求头和OpenAI差很多。OpenAI用Authorization: Bearer keyClaude用x-api-key: key而且要额外加anthropic-version头。如果你用Authorization方式去调Claude会直接401。返回结构转换同样是重点。OpenAI的返回长这样{choices: [{message: {role: assistant, content: ...}}]}Claude的返回是{content: [{type: text, text: ...}], role: assistant, model: ...}我在适配层做了一件事把Claude的返回转换成OpenAI格式再返回给业务方。这样业务方的代码只需要解析一种结构。具体来说def claude_response_to_openai(resp: dict) - dict: text .join( item.get(text, ) for item in resp.get(content, []) if item.get(type) text ) return { id: resp.get(id, ), object: chat.completion, model: resp.get(model, ), choices: [ { index: 0, message: {role: assistant, content: text}, finish_reason: resp.get(stop_reason, stop), } ], usage: resp.get(usage, {}), }这里有个隐藏点Claude返回的content可能是数组里面不只有text类型还有tool_use类型——当你开启function calling的时候会同时返回多段内容。我在早期版本里只取了第一段text导致工具调用场景下文本丢失后来改成拼接所有text字段才解决。这类问题在文档里写得并不明显只有真实跑过工具调用才会发现。3.3 DeepSeek适配基本透传但有两个隐藏槽点DeepSeek的接口非常接近OpenAI这是我的第一印象但仔细测下来有两个槽点。第一DeepSeek的temperature建议范围是0到2而OpenAI很多模型的range是0到1.2。如果你把业务方传的temperature1.5透传给OpenAI假设是某个不允许超过1.2的模型OpenAI会直接报参数错误但DeepSeek接受1.5。所以适配层要做参数范围收敛——当目标模型是OpenAI系时把超过范围的temperature压到上限内而不是直接报错。第二DeepSeek对response_format的兼容不如OpenAI完整。OpenAI的response_format: {type: json_object}在DeepSeek上部分版本支持部分版本会忽略导致业务方收到的JSON没有被严格约束。我在适配层对DeepSeek做了个处理如果业务方要求json_object而我检测到DeepSeek不支持或表现不稳定我会在prompt层追加请仅输出JSON的约束同时把response_format字段剥掉避免上游报错。3.4 实测记录max_tokens语义不一致导致的截断问题这是我在适配时踩的最深的一个坑单独拎出来说。OpenAI的max_tokens在不同版本模型里的语义有变化老模型是生成的最大token数新模型比如某些带有reasoning的模型可能还要占思考token。DeepSeek的max_tokens是最终输出的最大token数但如果你不设置它部分上下文较长的请求会触发生成提前停止。我遇到过的情况是业务方传了比较大的max_tokens比如8192但DeepSeek在生成到2000多token时突然停止。看了半天日志发现请求里的max_tokens被阶梯式的开销给占掉了——DeepSeek官方文档里也提过需要根据模型类型设置合适的最大值。最终的解决方法是在网关层维护一个模型能力表里面记录每个模型支持的max_tokens上限和推荐值。请求进来时如果发现业务方传的值超过了目标模型的上限不是直接报错而是按上限截断并加一条warning日志。这样既不会让业务方感知到错误又能在日志里定位到参数异常。4. 路由、重试与限流网关的策略层设计适配层解决的是格式不一样策略层解决的是用哪个模型、怎么调用、出错了怎么办。这一层决定了网关的智能化程度也是我自研而非用开源方案的核心原因。4.1 模型别名的动态路由业务方不需要知道真实的模型名。我们可以给模型起一个业务名或别名比如default-chat、long-context、cheap-fast网关层维护一张映射表routes: - alias: default-chat providers: - model: deepseek-chat weight: 7 - model: gpt-4o-mini weight: 3 - alias: complex-reasoning providers: - model: claude-sonnet-4-20250514 weight: 1 - alias: batch-task providers: - model: deepseek-chat weight: 100这个设计对业务方的收益是他们不需要跟着模型的发布节奏改代码。今天DeepSeek发布了新版本我只改网关的映射表把所有deepseek-chat的流量切到新版本上业务方无感知。权重路由是另一个实用功能。在便宜的模型和贵的模型之间我会设一个流量比例默认七成走DeepSeek、三成走GPT-4o-mini做交叉验证这样既能保证大部分请求的性价比又能在便宜模型效果不好时让一部分请求自动用更贵的模型。这个比例我是动态调整的每天看一次线上数据如果发现DeepSeek在某个场景的评分下降就临时把gpt的权重调高。4.2 按成本、优先级、可用性的分流策略权重是静态的还不够。我还做了两个动态分流维度优先级和可用性。优先级场景比如用户手动指定这个请求必须用GPT-4级别的模型那就忽略权重直接走贵模型如果指定了可以用便宜模型则按成本最低策略走。这个能力通过请求里的一个扩展字段控制比如x-model-tier: cheap|standard|premium网关层根据这个字段做路由。可用性分流的实现是每个上游模型在做健康检查连续几次失败就把它的权重临时降为0流量自动切到同组的另一个模型。这个机制在Claude 529频发的那段时间救了我——当Claude负载高接近不可用时网关自动把复杂推理的流量临时切到GPT业务方只会感觉慢了不会报错。4.3 失败重试的坑Claude 529错误不能盲目重试很多人写重试逻辑就是一梭子429等一秒重试500等两秒重试5xx都重试。但不同厂商的5xx语义不一样盲目重试适得其反。DeepSeek的503通常意味着服务过载等几秒重试成功率会提升。OpenAI的429表示限流等一段时间重试是合理的但重试太频繁会触发更严格的限流。Claude的529说overloaded这个错误不是完全不能重试但如果你在它已经过载的情况下依然立刻重试只会让情况变更糟。我在网关里对每个上游配置了不同的重试策略上游触发错误重试策略放弃时间OpenAI429指数退避最多2次30sClaude529只重试1次等5秒15sDeepSeek503线性退避最多3次60s还有一个关键点流式请求的重试比非流式复杂得多。如果SSE流已经给客户端发了部分内容你再重试就会导致客户端收到重复片段。所以我的策略很简单——流式请求只在连接建立之前重试一旦开始发数据就绝不重试直接报错让客户端重新发起。4.4 用户级与上游级双重限流限流要分两层。第一层是用户级限流按API Key或用户ID限制每分钟请求数防止某个业务方把网关打爆。第二层是上游级限流因为上游模型API也有QPS限制网关要做排队和预取。上游级限流我用的是信号量 队列的方式。每个模型配一个信号量假设并发上限是50超过的请求进入等待队列队列满则直接返回429给客户端。为什么要做这层因为你不知道业务方有多少个服务在同时调用如果每个服务的并发叠加起来超过上游限制会被上游限流所有请求一起失败。网关的排队机制让总体并发保持在上游允许的范围内这是单独在业务方做限流做不到的。5. 流式SSE转发的完整实现坑比想象的多如果只是非流式转发网关的工作量少一半。但现在的业务场景里打字机效果的流式输出几乎是刚需。流式转发的坑非常多我单独开一节讲。5.1 SSE协议基础与逐帧解析SSEServer-Sent Events本质上是HTTP响应体里的一段特殊文本流每一帧以data:开头以两个换行分隔。OpenAI流式返回的每一段都是data: {choices: [{delta: {content: ...}}]}Claude的流式返回帧格式则不同它用的是event: content_block_delta加上data: {...}。网关要做的事情是把上游的流逐帧读进来转换成OpenAI流式格式再推给客户端。核心代码长这样async def stream_openai_response(resp, request_id): async for line in resp.aiter_lines(): if not line.startswith(data: ): continue data line[6:] if data [DONE]: yield data: [DONE]\n\n break # 这里根据上游不同做转换 converted convert_stream_frame(data) if converted: yield fdata: {converted}\n\n关键点是别直接透传因为Claude的帧格式和OpenAI不一样。你需要解析每一帧把Claude的content_block_delta转成OpenAI的choices[0].delta.content结构再重新封装成SSE帧。5.2 流式转发时需要改写的字段流式转换最常见的问题是finish_reason丢失。OpenAI的流式帧里最后一段会带finish_reason客户端靠这个判断生成是否结束。Claude的流式结束信号是message_delta事件里面带着stop_reason如果你不转换客户端可能一直等不到结束信号表现就是前端转圈转个没完。另一个容易丢的是usage信息。OpenAI的流式响应里新版本会有一个独立的usage帧DeepSeek也有但Claude的usage是放在message_delta事件里的。如果你要做Token统计和计费必须在流式转换层把各个来源的usage信息都收集起来在流结束前汇总返回。5.3 超时、中断、心跳的兜底处理流式连接是长连接比普通请求更容易出问题。我在网关里做了三个兜底机制第一个是上游空闲超时。如果上游超过一定时间比如60秒没有推送任何数据网关主动断开并向客户端发送一个错误帧。这个机制防止上游假死导致客户端永远转圈。第二个是客户端连接检测。如果客户端已经断开网关需要停止继续读上游数据并释放连接否则上游的token还在哗哗消耗钱还在烧。这个用异步任务取消来实现检测到客户端断连后取消当前的读取任务。第三个是心跳帧。网关每隔一段时间向下游客户端发送一个注释行SSE协议里是: ping保持中间代理层的连接存活防止被负载均衡器因闲置而掐断。这个细节在长时间生成任务中特别有用。6. 上线后的实测效果三个模型在同一网关下的对比网关写完后我把所有业务流量都切了过来跑了大概两周数据比预想的要理想。6.1 三模型在网关中的实际表现对比同一批真实请求我分别路由到三个模型做了评测对比。结果如下表指标GPT-4o-miniClaude SonnetDeepSeek-V3首Token延迟P50380ms420ms250ms生成速度Token/s453855上下文处理能力强最强中上价格每百万Token中高极低错误率网关统计0.8%1.2%1.5%DeepSeek的生成速度快、价格便宜但在复杂推理和超长文本一致性上还是不如Claude和GPT。所以我最终给业务方的建议是日常对话和批量任务默认走DeepSeek复杂推理走Claude通过别名complex-reasoning切换需要最强上下文理解的部分走GPT。网关把这三个选择收敛成两个业务别名业务方只感知到标准和增强两档。6.2 网关本身的资源占用与压测结果用FastAPI写的异步网关在4核8G的容器里压测跑到了1000 QPSP95延迟增加不到8ms。这个数据说明纯转发型网关的开销非常小瓶颈几乎全在上游API本身。资源占用方面峰值内存大概1.2GCPU平均20%左右。单实例可以支撑大部分中小团队的业务量。压测中暴露的唯一问题是当上游模型出现大面积故障时网关的等待队列会堆积大量请求内存增长明显。后来我加了队列上限和快速失败策略队列满了直接返回上游繁忙的错误避免网关本身被打挂。6.3 后续扩展方向提示词模板化与多模态接入网关稳定运行之后我开始往里面加一些更软的功能。提示词模板化是其中一项业务方不用每次请求都带一大段system prompt只要传一个模板ID网关自动把模板和用户消息组装成完整的messages。这既减少了业务方的payload大小也方便运营同学统一调整prompt。多模态的接入是下一个计划。现在热词里都在聊clip、多模态模型和图像生成GPT和Claude都支持图片输入DeepSeek也在迭代。多模态接入的核心问题和文本类似——图片的传递格式不统一OpenAI用image_urlClaude用source.typebase64适配层同样需要做一次转换。好消息是之前搭建的网关架构不需要大改核心转换逻辑多写一层就够。最后再分享一个小经验网关不是写出来就完事的务必要把日志和监控做实。每个请求耗时、模型名、Token消耗、错误类型都要记录到链路追踪系统里。有了这些数据你才能判断路由策略合不合理、哪个模型真正在赚钱、哪个模型的错误率开始上升。我后期调整路由权重全靠这些日志没数据之前全是拍脑袋。