ARTICLE DETAIL

资讯详情

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

统一调度本地与云端大模型:OpenAI兼容网关实践

统一调度本地与云端大模型:OpenAI兼容网关实践 接到需求时我愣了一下一套接口又要走本地模型又要走云端API还要能随时切换。第一反应是这需求是不是有点折腾但仔细一想这个场景现在越来越常见——敏感数据想在本地处理复杂推理想交给云端大模型成本还要控在预算内。真正落地时最大的坎不是模型效果而是接口层怎么设计。这篇文章我就把用一套OpenAI兼容协议同时调度Ollama本地模型和多家云端API的完整过程写出来包括配置、路由、降级、代码实现和踩坑记录按顺序照做就能跑通。本文适合正在做AI应用落地、被本地和云端模型切换折磨的开发者。不管你用的是FastAPI还是Node.js核心思路都通用用一套标准协议屏蔽底层差异用模型别名把“业务要什么”和“背后用哪个模型”完全解耦再用一层轻量网关把调度、降级、流式透传都收住。1. 本地与云端混用的真实痛点为什么业务代码会被模型拖死1.1 一次让我彻底崩溃的业务改造上个月我在做一个内部知识库问答系统业务逻辑本身不复杂用户提问后系统先做意图识别和关键词抽取再决定走哪个检索分支最后把检索结果拼进prompt让大模型生成答案。一开始所有请求都走云端API代码写得很痛快一个SDK搞定。结果上线两周后问题来了意图识别和标题抽取这种高频轻量任务按token计费的成本非常高。而且有些内部文档不能出内网必须用本地模型处理。于是产品经理提了个需求同一套业务代码有些模型调用走本地有些走云端还要能随时切。我原以为只是把云端API的base_url换成Ollama的地址就行真正动手才发现完全不是这么回事。业务代码里已经写死了云端SDK的请求格式、鉴权方式、异常处理逻辑换成本地模型后接口路径不一样、认证方式不一样、返回结构也有细微差异。更要命的是模型名不一样云端叫deepseek-chat本地叫deepseek-r1:7b业务侧如果写死模型名以后换模型又得改代码。1.2 本地模型与云端API的差异远不止“延迟”和“价格”要把本地和云端统一调度起来首先得搞清楚它们到底差在哪。我整理了一张对比表这是我做技术选型和路由设计的基础维度本地模型云端API延迟取决于GPU通常百毫秒到秒级无网络波动受网络和厂商排队影响通常秒级成本硬件一次性投入边际成本极低基本接近0按token计费高频调用成本增长很快数据安全数据不出内网可处理敏感数据数据要发给第三方有合规和隐私风险模型能力参数量小7B/13B居多复杂推理弱参数量大100B复杂推理和长上下文能力强可用性依赖本机资源OOM、断电、显存不足都可能挂依赖厂商SLA可能限流、故障但整体可用性高上下文长度通常4k/8k/16k长文档受限动辄32k/128k/200k长文本处理能力强这表说明一件事本地和云端不是替代关系是互补关系。轻量高频任务适合放本地省钱又保隐私复杂推理、长文档总结适合放云端效果好但需要控制调用量。既然要互补调度层就必须能够根据路由规则自动决定请求去向而不是靠开发者在代码里写死。1.3 不用统一接口后面会踩哪些坑如果只是临时用一下直接在业务代码里写两套调用逻辑也不是不行但长期下来会很痛苦业务代码里混着两套SDK、两套异常处理、两套超时配置维护成本翻倍换个模型要改代码重新发布测试回归流程走一遍半天就过去了无法自动降级。本地模型OOM了云端也不一定能接得住云端限流了本地也没办法兜底监控和成本统计是两本账想搞清楚“哪个环节花了多少钱”非常麻烦。所以我的结论很明确必须在业务代码和具体模型之间插一层调度代理让上游只认识一套接口、一个模型名。下面先讲协议选型这是整个方案的地基。2. 统一调度的破局点让所有模型先讲同一种“语言”2.1 为什么我选中OpenAI兼容协议而不是各家私有协议选接口协议是个战略决策选错了后面适配工作量非常大。我的选择是OpenAI兼容协议原因有四个事实标准无论是Ollama、vLLM、LM Studio这些本地推理工具还是DeepSeek、阿里云百炼、智谱这些国内云厂商几乎都原生提供OpenAI兼容端点。也就是说大家都愿意向这个协议靠拢。生态最全LangChain、LlamaIndex、Dify、FastGPT等主流框架默认就能对接OpenAI兼容接口基座替换成本极低。SDK成熟OpenAI官方Python/Node SDK支持度最好改个base_url和api_key就能切换目标不需要额外引包。排错资料多遇到问题搜索时OpenAI兼容协议的报错信息在网上有大量讨论排查效率高。你可以把OpenAI兼容协议理解为AI接口界的USB-C。过去各家充电口都不一样出门得带一堆线现在接口统一了只要设备支持USB-C一根线就能通用。这里也一样只要模型服务提供商愿意暴露一个OpenAI兼容端点上层就能用统一方式调用。2.2 OpenAI兼容协议的核心构成端点、请求体、鉴权要自己写网关核心协议的四个要素必须吃透端点路径通常是/v1/chat/completions聊天补全主入口还有/v1/models用于列出可用模型。请求体主要字段包括model模型名、messages对话消息数组、temperature采样温度、max_tokens生成上限、stream是否流式返回。返回体非流式时是完整JSON里面有choices[0].message.content生成内容和usagetoken用量流式时是SSE格式的增量事件流。鉴权方式HTTP Header里加Authorization: Bearer api_key。本地Ollama不校验key但格式上必须带上这个字段方便统一处理。这里有个细节很关键请求体里的model字段是整个调度路由的入口。网关截获请求后第一件事就是读这个字段然后根据别名映射决定把请求转发到哪里。所以这个字段的值不能随便用应该在团队内统一约定。一个标准Chat Completion请求长这样{ model: balanced, messages: [ {role: system, content: 你是一个知识库问答助手}, {role: user, content: 请总结这份合同的关键条款} ], temperature: 0.3, max_tokens: 1024, stream: false }业务代码只认这个格式至于model是本地还是云端、实际背后的模型叫什么业务完全不关心。这就是隔离的威力。3. 本地侧接入让Ollama和vLLM暴露一个标准端点3.1 Ollama接入最适合单机起步的方案本地模型部署我最推荐先用Ollama因为对新手最友好命令少、坑也少。以deepseek-r1:7b为例完整接入步骤是这样的第一步安装OllamamacOS和Linux都支持Windows也有安装包然后设置两个环境变量# 允许局域网内其他机器访问Ollama export OLLAMA_HOST0.0.0.0:11434 # 指定模型存储目录建议放数据盘 export OLLAMA_MODELS/data/ollama/models第二步拉取模型并启动服务ollama pull deepseek-r1:7b ollama serveollama serve启动后Ollama会同时监听/api和/v1两套端点。其中/v1就是OpenAI兼容端点支持/v1/models和/v1/chat/completions。第三步用curl验证OpenAI兼容端点是否正常curl http://127.0.0.1:11434/v1/models curl http://127.0.0.1:11434/v1/chat/completions \ -H Content-Type: application/json \ -d { model: deepseek-r1:7b, messages: [{role: user, content: 你好简单介绍一下自己}], stream: false }能正常返回模型回复就说明这个端点已经可以作为网关的后端provider了。这里有个我踩过的坑提醒一下Ollama默认的context length上下文长度通常比较保守实际任务里如果prompt很长容易出现“生成内容截断”甚至直接报错。解决办法是创建一个自定义Modelfile显式设置num_ctx。比如cat EOF Modelfile FROM deepseek-r1:7b PARAMETER num_ctx 16384 EOF ollama create deepseek-r1-16k -f ./Modelfile后续请求里直接用deepseek-r1-16k这个模型名就行。这一步不做长文档类任务基本跑不通。3.2 vLLM接入更适合GPU服务器的选择Ollama的最大问题是并发吞吐一般如果团队有多人同时用或者QPS要求较高我更推荐上vLLM。vLLM的OpenAI兼容服务启动命令也很简单python -m vllm.entrypoints.openai.api_server \ --model /data/models/deepseek-r1-7b \ --served-model-name local-llm \ --port 8000 \ --tensor-parallel-size 1 \ --max-model-len 16384这里有两个参数值得多花一句话解释--served-model-name给模型起一个对外暴露的名字。这非常有用因为在网关的配置里provider内部的模型名和对外暴露的模型名可以完全分离以后换真实模型文件时对外名字可以保持不变。--max-model-len控制上下文长度按实际显存和任务需求调整不要超过显卡能承受的极限。vLLM的优势在学术界和工业界已经有大量验证PagedAttention显存管理、continuous batching请求调度吞吐量比朴素的推理脚本高一个数量级。如果你的模型文件是AWQ/GPTQ量化格式vLLM也支持加载。3.3 本地端点验证清单上线前必须确认的几件事本地服务挂起来不等于能用我建议在上配置之前先按这个清单过一遍非流式请求返回正常响应时间是预期量级流式请求正常SSE事件持续输出没有中断连续并发2-3个请求确认不会OOM、不会互相阻塞模型是否支持function calling/tools调用Ollama和vLLM对tools的支持程度跟模型本身强相关7B小模型经常表现不稳定这个直接影响路由设计后面细说上下文长度是否符合业务需求超出后是截断还是报错要做到心里有数。这些验证做完本地侧就绪接下来处理云端API侧。4. 云端API适配层用模型别名把多家厂商收进一个名字空间4.1 各家云端API的OpenAI兼容情况好消息是现在国内外的云厂商基本都提供了OpenAI兼容端点这大大减少了适配工作量。我整理了一份主流厂商的接入参数厂商OpenAI兼容端点典型模型名备注OpenAIhttps://api.openai.com/v1gpt-4o、gpt-4o-mini原生接口DeepSeekhttps://api.deepseek.com/v1deepseek-chat、deepseek-reasoner完整兼容阿里云百炼https://dashscope.aliyuncs.com/compatible-mode/v1qwen-plus、qwen-max需要开启兼容模式智谱https://open.bigmodel.cn/api/paas/v4glm-4-plus、glm-4-flashv4版本基本兼容你会发现只要厂商提供OpenAI兼容端点适配层根本不需要做协议转换真正要做的只有三件事统一鉴权Header格式、统一请求体格式、统一模型命名空间。前两个基本上白拿重点在模型命名空间上。4.2 模型别名的设计业务名字和真实模型彻底解耦我强烈建议不要直接在业务代码里写具体的模型名比如deepseek-chat、gpt-4o这类。原因很简单模型名是厂商的产品名是会变的业务语义才是稳定的。今天你用qwen-max明天想换deepseek-reasoner如果业务代码里写死了模型名又得改代码。我的做法是引入一层“模型别名”按业务用途和SLA分级fast高频轻量任务默认路由到本地7B模型省钱balanced通用推理任务默认路由到云端大模型效果好ultra复杂推理、长文档任务路由到最强云端模型能不用就不用。业务代码里永远写model: fast或model: balanced至于fast背后是deepseek-r1:7b还是以后换成的qwen3-8b那是网关配置文件里的一个字段而已。用配置驱动而不是代码驱动这是整个方案里最值得坚持的设计原则。配置文件长这样# 模型路由配置 providers: local-ollama: type: local base_url: http://127.0.0.1:11434/v1 api_key: ollama model: deepseek-r1-16k max_context: 16384 health_check_path: /v1/models cloud-deepseek: type: cloud base_url: https://api.deepseek.com/v1 api_key: ${DEEPSEEK_API_KEY} model: deepseek-chat max_context: 65536 cloud-openai-gpt4o: type: cloud base_url: https://api.openai.com/v1 api_key: ${OPENAI_API_KEY} model: gpt-4o max_context: 128000 routes: fast: primary: local-ollama fallback: cloud-deepseek balanced: primary: cloud-deepseek fallback: cloud-openai-gpt4o ultra: primary: cloud-openai-gpt4o注意到几个设计细节API Key从环境变量读取${DEEPSEEK_API_KEY}不要硬编码进配置文件避免误提交到Git仓库每个provider维护自己的max_context网关在做长文本路由时要有能力根据这个值判断当前请求是否超限routes里的primary和fallback是实现自动降级的关键这个下面细讲。4.3 兼容层要不要写代码什么时候需要自定义适配器大部分情况配置就够用了。但有一个场景需要写代码适配厂商的OpenAI兼容端点做得不完整或者参数语义有差异。比如某些厂商不支持frequency_penalty或者对max_tokens有特殊限制。如果网关直接把请求体透传过去可能会收到4xx错误。解决方法是在网关里做一次“参数清洗”根据provider配置动态调整请求体字段。举例来说如果provider配置里声明supports_temperature: false网关转发前就把temperature删掉如果max_tokens上限是4096而请求里传了8192就得在网关层截断或告警。这些规则可以做成独立适配器模块每个厂商一个类但绝大多数情况下不推荐一开始就写。先把最小闭环跑通遇到真实报错再补适配器才是务实的做法。5. 调度网关的完整实现从转发到降级一次说清5.1 网关选型自研轻量服务还是用开源项目本地和云端的provider都就绪后核心问题来了这一层调度网关怎么做市面上已经有一些优秀的开源方案比如one-api、new-api、LiteLLM Gateway。它们开箱即用自带配额管理、key管理、多渠道聚合适合团队内部快速搭一个模型代理平台。我自己也用过确实省了不少时间。但如果你和我一样需要精确控制路由策略、要给网关嵌入内部监控体系、要做非常细的上下文截断或成本控制自研一个轻量网关其实代码量并不大。下面我用FastAPI写一个最小可用版本只做四件事接收OpenAI兼容请求、解析模型别名、转发到真实provider、失败时自动降级。5.2 网关主逻辑配置加载与路由分发先定义配置结构。我还是用YAML方便非开发同学直接改不用碰代码。初始化时把配置文件读进来构建一个routes字典key是模型别名value是对应的路由规则。import os import yaml def load_config(pathgateway.yaml): with open(path, r, encodingutf-8) as f: config yaml.safe_load(f) providers config.get(providers, {}) # 将环境变量形式的 api_key 替换为真实值 for name, p in providers.items(): if isinstance(p.get(api_key), str) and p[api_key].startswith(${): env_name p[api_key].strip(${} ) p[api_key] os.getenv(env_name, ) return config然后写路由函数这一步是整个网关最核心的地方根据model字段查路由表找到primary和fallback然后决定请求往哪儿发。如果模型别名不存在直接返回4xx避免把错误请求透传到后端。def resolve_route(model_alias: str): config load_config() route config[routes].get(model_alias) if not route: return None providers config[providers] primary providers.get(route[primary]) fallback providers.get(route.get(fallback, )) if route.get(fallback) else None return primary, fallback5.3 请求转发非流式与流式透传都怎么实现网关接收到请求后要把OpenAI兼容请求原样转发到目标provider只是把请求里的model字段替换成provider的真实模型名。这个替换非常关键否则provider会报“model not found”。非流式转发实现如下import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse, StreamingResponse app FastAPI() async def forward_non_stream(provider, body): target_url provider[base_url].rstrip(/) /chat/completions body dict(body) body[model] provider[model] # 替换为 provider 真实模型名 headers { Authorization: fBearer {provider[api_key]}, Content-Type: application/json, } async with httpx.AsyncClient(timeout300) as client: resp await client.post(target_url, jsonbody, headersheaders) return JSONResponse(status_coderesp.status_code, contentresp.json())流式转发稍有不同要启用SSE流式透传把provider返回的增量内容一段一段喂给上游调用方。用一个异步生成器处理async def forward_stream(provider, body): target_url provider[base_url].rstrip(/) /chat/completions body dict(body) body[model] provider[model] body[stream] True headers { Authorization: fBearer {provider[api_key]}, Content-Type: application/json, } async with httpx.AsyncClient(timeout600) as client: async with client.stream(POST, target_url, jsonbody, headersheaders) as resp: if resp.status_code 400: error_text await resp.aread() yield json.dumps({error: {message: error_text.decode(utf-8)}}) return async for line in resp.aiter_lines(): if line.strip(): yield line \n注意到流式这里有个细节即使后端出错了在流式场景下也还是要把错误信息以SSE格式返回给上游因为直接抛异常会导致客户端挂起。这是我在实际联调时踩过的坑。主入口函数把路由解析和非流式/流式转发串起来同时负责异常捕获和降级逻辑app.post(/v1/chat/completions) async def chat_completions(request: Request): body await request.json() model_alias body.get(model, ) resolved resolve_route(model_alias) if not resolved: raise HTTPException(status_code404, detailfunknown model alias: {model_alias}) primary, fallback resolved try: if body.get(stream): return StreamingResponse(forward_stream(primary, body), media_typetext/event-stream) return await forward_non_stream(primary, body) except Exception as e: if fallback: print(fprimary provider failed, fallback to {fallback[base_url]}: {e}) if body.get(stream): return StreamingResponse(forward_stream(fallback, body), media_typetext/event-stream) return await forward_non_stream(fallback, body) raise HTTPException(status_code502, detailfprovider error: {str(e)})这段逻辑短小但是完整。你的业务代码只需指向这个网关的地址比如http://gateway-host:8000/v1传什么模型名都行网关会照顾后面的一切。5.4 降级与重试策略不要让一个失败拖垮全局降级这里有个很重要的分寸问题不是所有失败都适合自动切到云端。我的原则是超时/连接失败provider完全不可用可以自动降级到fallback4xx错误请求本身有问题不要降级直接把错误返回给调用方否则会把错误请求发到另一个provider浪费一次调用本地OOM/显存不足可以降级到云端但要在日志里打上标记方便后续给本地服务加容量或重启模型内容安全风险或输出异常不要自动重试需要人工介入。另外必须给网关加一个全局并发限制。本地模型的并发能力远弱于云端特别是Ollama在GPU上跑一个7B模型时同时进来三四个请求就可能排队或OOM。我在网关里用一个asyncio.Semaphore控制每个provider的最大并发数超出时直接返回429或排队等待import asyncio # 每个 provider 一个信号量按配置里 concurrency 字段初始化 semaphores {} def get_semaphore(provider_name): if provider_name not in semaphores: semaphores[provider_name] asyncio.Semaphore(2) # 从配置读取 return semaphores[provider_name]这一步不做本地模型服务很容易在高并发下被打挂而且Ollama进程本身不一定能自动恢复还得手动重启非常麻烦。6. 从跑通到跑稳实测数据、典型坑和几条建议6.1 我实测的一组参考数据我在自己的环境中做了一组简单对比测试。测试任务是用模型做一段合同文本的摘要prompt约1500个中文字符。机器配置是一张RTX 4090 24GB本地模型是deepseek-r1:7bint4量化版本云端模型是deepseek-chat。指标本地7BRTX 4090云端deepseek-chat单次请求延迟约2-4秒约1.5-3秒单次token成本约0元电费和硬件折旧不算约0.003-0.005元输出质量一般偶有事实性错误较好逻辑更连贯处理敏感数据可以原则上不可以长文本8k吃力需要截断轻松实测结论和我的设计预期一致轻量任务用本地模型不仅省钱延迟也不吃亏但涉及复杂推理和长文档本地7B还是扛不住。所以路由规则的配置很关键不能一刀切。6.2 几个一定要提前知道的坑这个方案跑通不难跑稳需要跨过几道坎我逐个说一下。第一本地模型的function calling支持程度差异很大。Ollama和vLLM都支持tools参数但模型本身如果不擅长工具调用经常会出现“该调的没调不该调的瞎调”。7B小模型尤其明显。我的建议是需要function calling的任务尽量不要路由到本地7B至少等模型能力提升或换用更大参数量的本地模型。第二上下文长度不一致会导致部分请求失败。本地模型max_context只有16k云端模型可以到128k如果用户一次性上传超长文档并走了本地模型就会报错。我建议网关在路由时加一个判断如果请求中的预估token数超过了provider的max_context自动改路由到云端更高配模型而不是让请求失败。第三本地模型冷启动延迟非常高。Ollama第一次请求一个模型时需要把权重从磁盘加载到显存这个过程可能要等几十秒。网关的超时时间如果设成10秒会直接判定请求失败。解决方法是启动时主动预热一次或者把超时设置给足并且把这条路径纳入降级策略。第四流式响应在本地模型上更容易中断。Ollama的流式输出如果推理中途出错连接会直接断开但SSE协议本身没有结束时标记客户端会一直转圈。处理办法是在网关层监控流是否持续产生数据如果一段时间没有数据就主动掐断连接并返回错误。第五成本控制不能只靠路由还得靠“不重试”的纪律。上面说过不是所有失败都适合降级。如果网关把所有异常都自动重试到云端那成本会快速失控。我在生产环境的策略是本地失败降级到云端云端失败直接返回错误让调用方决定是否重试。6.3 网关上线后我还做了两件增值的事网关跑通之后我顺手做了两件事收益很大。第一是加了详细的请求日志记录每次调用的模型别名、实际provider、延迟、耗时、token消耗、是否发生降级。有了这些数据我就能清楚地看到“fast路由每天处理了多少请求多少次走了fallback云端”。这既是对成本的审计也是调整模型的依据。第二是加了一个健康检查接口持续探测所有provider的可用性。本地Ollama如果挂了健康检查能第一时间发现并在监控里告警同时网关还可以通过配置把路由权重临时切换到云端保证业务连续性。这两件事做出来后这套统一调度体系才算完整不只是“能把请求发出去”而是“知道请求走到哪儿了、为什么失败、成本花在哪了”。如果只让我给一条最核心的建议那就是先把模型别名机制定好不要急着写代码。别名是整条链路的共同语言产品经理说“轻量任务”开发写model: fast网关配置定义fast的走向。这个解耦一旦做好后续不管加新模型、切厂商还是做成本控制、故障降级都是在配置层面就能解决的事情不用再动业务代码。这也是本地模型和云端API混用这件事真正值得投入的地方。
返回列表