
最近三个月我一直在收拾团队内部那堆AI推理服务最终沉淀出一个内部代号叫OpenRig的东西。把它叫平台可能有点大更准确的说法是一套开放的推理服务基础套件把多种开源模型、多块GPU、多个调用方收敛到同一个API入口后面让自建AI推理这件事变得可管、可查、可排障。这篇文章把整个OpenRig的搭建过程、架构取舍、性能调优和生产环境排障经验完整复盘一遍。如果你正被各种模型启动脚本、不同推理框架、互相抢显存的问题折磨这篇文章应该能帮你少走很多弯路。1. 为什么我会想自建一套OpenRig——入手动机与整体目标1.1 散装模型调用方式带来的管理成本一切的起因很朴素团队里用开源模型的姿势太散了。有人用Qwen做对话助手有人用Llama做文本分类还有人离不开BGE系列的Embedding模型。每个人都是在自己机器或者自己容器里启动一套推理服务调用方式五花八门。我整理了一下当时的真实状况场景调用方式典型问题对话助手直接调用transformers的pipeline每个请求都要重新加载分词器首包延迟高文本分类本地写死的模型路径换模型要改代码再重启服务Embedding单独一套服务和主模型分开部署运维成本翻倍内部评测自己启动临时进程进程不退显存一直被占着而且大家各自启动模型的时候根本不看GPU卡上有谁。曾经出现过一张A100上挂了四个不同的推理进程互相抢显存谁先OOM谁就偷偷把别人的请求拖垮。这种能跑就行的状态维护了几个月所有人都想改但谁也没有动力去牵头。1.2 OpenRig要解决的核心问题所以我给OpenRig定的目标非常朴素解决三个问题不解决多余问题。第一是统一入口。所有模型请求都从一个API进去对外只暴露一套标准接口。调用方不再关心模型在哪个节点、用什么框架启动、环境变量是什么只要知道模型别名就能调用。第二是资源池化。GPU不再按人划分而是按模型实例划分。每个推理worker独占一张或者半张卡由控制平面依据请求量调度。谁也不想再盯着nvidia-smi手动分配显存了。第三是可观测。每个请求走了哪条链路、在哪个worker上排队、首token花了多久、吞吐多少这些指标要能从日志和监控里拉出来而不是等到用户报障再去猜。1.3 目标边界不做什么比做什么更重要OpenRig在最初阶段刻意不做的事我也要提一下。不做训练和微调它只负责推理不做复杂的多租户计费先让内部用起来再说不做自动弹性伸缩因为初期模型和显存都是固定的不要求高可用允许单点重启。把边界划清楚有一个巨大的好处不会在起步阶段被平台化的野心拖死。很多自建推理平台失败不是因为技术不够好而是因为一开始就想做到生产级高可用、多租户、自动伸缩结果三个月了UI还没画完。OpenRig能在一周内跑通完全是因为我只做最小闭环。2. OpenRig整体架构与关键模块设计2.1 控制平面模型注册与路由决策OpenRig的整体骨架分成三块控制平面、数据平面和协议层。控制平面是我自己写的一个基于FastAPI的轻量服务职责是接收外部请求、判断请求要访问哪个模型、把请求转发给对应worker。控制平面同时维护一张路由表记录模型别名、worker地址、状态和每个worker的并发情况。路由表的结构很简单类似这样{ qwen2.5-72b: { type: chat, engine: vllm, base_url: http://127.0.0.1:8000/v1, healthy: true }, llama3.1-8b: { type: chat, engine: vllm, base_url: http://127.0.0.1:8001/v1, healthy: true }, bge-m3: { type: embedding, engine: vllm, base_url: http://127.0.0.1:8002/v1, healthy: true } }路由决策的核心原则是模型名必须完整匹配不搞模糊匹配。我踩过一个坑调用方写错一个字母系统把请求路由到了别的模型生成的答案风格不对排查了很久才知道是路由写错了。所以路由表里宁可报404也不要做模糊代理。2.2 数据平面推理引擎与GPU资源池数据平面就是实际执行推理的worker。每个worker一个Docker容器内部运行推理引擎映射到特定的GPU卡。worker和GPU的关系是固定绑定的至少在这个阶段我不做热迁移因为热迁移推理状态太复杂收益又很低。推理引擎选型上我做了一次对比。当时主流的选择有四类vLLM、TGIText Generation Inference、SGLang和llama.cpp。引擎优势劣势适合场景vLLM连续批处理效率高OpenAI协议兼容好对某些自定义算子支持一般通用开源模型批量服务TGIHuggingFace生态紧密量化支持丰富调度策略相对保守HF模型重度用户SGLang结构化输出和RadixAttention有特色社区更新节奏偏快需要复杂结构化约束的场景llama.cpp资源占用极小CPU也能跑高并发GPU场景吞吐上限低个人设备、边缘盒子我最终默认选择vLLM理由很实际它对外暴露的接口几乎和OpenAI一致这让我后面的API封装几乎不用额外转换同时它的连续批处理在高并发场景下吞吐优势明显。Embedding模型也用vLLM跑一张卡可以同时加载一个Chat模型和一个小Embedding模型通过--port隔离端口。这里我不建议无脑跟风。如果你的主要场景是单用户、低并发、追求极低资源占用llama.cpp反而更合适。如果是需要很强的结构化输出控制SGLang值得多研究。工具选型没有最好的只有最适合当前约束的。2.3 兼容OpenAI API协议的取舍理由既然数据平面已经默认是vLLM那OpenRig对外协议直接对齐OpenAI的API格式就成了最自然的选择。也就是/v1/chat/completions、/v1/embeddings这两个接口。调用方不需要感知底层的vLLM还是TGI只要代码里填一个OpenRig的地址像调OpenAI一样调就行。这样做还有一个现实好处团队里基于OpenAI协议写的代码、测试脚本、监控脚本几乎可以零改动迁移。很多同事的项目里已经用了openai这个Python包OpenRig上线后只需要把base_url换一下其他逻辑不用动。这种无缝替换对推广一套内部平台来说非常重要。平台再强迁移成本高大家也会抵触。3. 落地过程环境准备、推理引擎接入、统一API封装3.1 从零初始化依赖安装与基础配置OpenRig最初跑在一台双卡服务器上配置是两张A100 80G系统是Ubuntu 22.04。我从这台机器的初始化讲起。先检查NVIDIA驱动和CUDA状态nvidia-smi这一步很重要。推理容器跑在Docker里容器内的CUDA依赖用的是镜像自带的但如果宿主机驱动版本太老容器内CUDA再新也没用。vLLM官方镜像一般要求驱动支持CUDA 12.x以上。我这次踩过坑宿主机驱动还是535版本跑vLLM最新镜像一直报CUDA error: no kernel image is available for execution on the device后来把驱动升到545以上才解决。接着安装Docker和NVIDIA Container Toolkit这一步决定Docker容器能不能拿到GPU# 安装nvidia-container-toolkit sudo apt-get update sudo apt-get install -y nvidia-container-toolkit sudo systemctl restart docker # 验证GPU是否能在容器里工作 docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi如果容器里的nvidia-smi能正常输出GPU信息说明GPU passthrough没问题。这一步跑不通后面全白搭值得单独花时间验证。3.2 接入主流推理引擎的标准步骤OpenRig的数据平面上每个模型worker对应一个vLLM容器。我用Docker Compose管理所有worker比纯命令行启动清晰得多。先看一个典型Chat模型worker的启动配置services: openrig-qwen: image: vllm/vllm-openai:latest container_name: openrig-qwen runtime: nvidia environment: - CUDA_VISIBLE_DEVICES0 - VLLM_LOGGING_LEVELinfo volumes: - /data/models:/models ports: - 8000:8000 command: --model /models/Qwen2.5-72B-Instruct-AWQ --max-model-len 32768 --gpu-memory-utilization 0.85 --port 8000关键参数我逐个说明一下。CUDA_VISIBLE_DEVICES0限制这个容器只看得到物理GPU 0号避免两个worker抢一张卡。max-model-len 32768把上下文长度限制在32K不要一股脑设置为模型支持的上限因为KV Cache是跟着Context长度走的设置越长显存越紧张能并发处理的请求就越少。gpu-memory-utilization 0.85表示最多用85%的显存作为推理用途剩余15%留给CUDA context、中间张量和不可控的显存碎片。启动worker后先做一个健康检查curl http://127.0.0.1:8000/v1/models返回模型列表即说明worker已经就绪。这个步骤虽然简单但我建议写进后端的启动脚本里不要纯手工curl因为后续接入多个模型时装到哪里都不知道。Embedding模型的worker类似只是模型路径换成bge-m3端口换到8002参数里加上--task embedding。3.3 统一API层实现要点与代码示例控制平面的核心是一段代理逻辑。我直接给出经过生产考验的精简版关键点都在代码注释里。# openrig_proxy.py from fastapi import FastAPI, Request, JSONResponse import httpx import asyncio app FastAPI() ROUTES { qwen2.5-72b: http://127.0.0.1:8000/v1, llama3.1-8b: http://127.0.0.1:8001/v1, } EMBED_ROUTES { bge-m3: http://127.0.0.1:8002/v1, } # 全局复用连接池避免每个请求新建连接 client httpx.AsyncClient(timeouthttpx.Timeout(600.0, connect10.0)) app.post(/v1/chat/completions) async def chat_completions(request: Request): body await request.json() model body.get(model, ) if model not in ROUTES: return JSONResponse( status_code404, content{error: {message: funknown model: {model}, type: invalid_request_error}} ) worker_url f{ROUTES[model]}/chat/completions try: resp await client.post(worker_url, jsonbody) return JSONResponse(status_coderesp.status_code, contentresp.json()) except httpx.ReadTimeout: return JSONResponse( status_code504, content{error: {message: worker timeout, type: timeout_error}} )这段代码有几个细节值得展开。第一必须用全局httpx.AsyncClient不能在函数内部重复创建。我之前在函数里new一个Client并发一高连接数直接飙升最终把系统的文件描述符打满所有请求全部卡死。连接池复用是代理类服务最基本的修养。第二超时时间要设置得足够大。大模型推理本来就是慢动作一次长文本生成跑几分钟很正常。600秒读超时、10秒连接超时是我压测后调出来的经验值。很多人把超时设成30秒结果生成长度超过某个值就频繁请求失败还以为是系统问题。第三错误码要映射明确。404表示模型不存在504表示worker超时500表示服务内部错误。调用方拿到清晰的错误码才能快速定位问题这比返回一堆堆栈强得多。3.4 首个模型端到端跑通后的自测清单OpenRig打通第一个模型之后我整理了一份自测清单每次接入新模型都用它验一遍省去很多回归排查时间。健康检查/v1/models 是否能返回当前模型单轮对话发一个简单请求确认返回结构和OpenAI格式一致多轮对话连续对话两轮确认历史上下文被正确带上并发测试用ab或Locust发80个并发请求确认没有超时或OOM错误路径请求不存在的模型确认返回404长文本测试请求生成一段超过2000 token的回答确认无中断清理机制删除容器再重启确认加载时间可接受这份清单看起来简单但正是它帮助我在接入第三个模型的时候提前发现了一个HTTP头部字段兼容问题。vLLM某些版本对stream参数的响应头处理不同OpenRig的代理层不做任何透传修改直接原样返回反而比加一层格式转换更稳。4. 吞吐、并发与延迟优化4.1 吞吐优先连续批处理与显存预算OpenRig上线第一周就遇到性能问题。调用方一次性灌入200个请求worker的吞吐直接躺平每个请求都要等很久。排查后发现是vLLM的参数没有针对并发优化默认的连续批处理队列上限压低了GPU的实际利用率。vLLM的连续批处理机制可以理解为当某个请求的生成步骤进入空闲时立刻把队列里等待的请求塞进同一个批次。它不要求整个批次同步结束而是token级动态进出。这个机制要发挥最优效果需要给足并发窗口也就是max_num_seqs。我把Qwen2.5-72B的max_num_seqs从默认的32调到了64同时保持gpu-memory-utilization在0.85。测试结果很明显单卡吞吐从每分钟约1100个请求上升到约1700个请求提升超过50%。这里有一个权衡要提醒max_num_seqs调大会提高吞吐但也会增大KV Cache的压力。如果同时把max-model-len也设置得很大显存很可能不够分配。我建议先固定max-model-len再逐步往上调max_num_seqs每调一档就跑一轮压测。不要一次性拉到极限否则OOM后vLLM会反复重新加载模型比吞吐低还痛苦。4.2 降低首token延迟预热、前缀缓存与投机解码吞吐提上后又发现另一个问题请求量大时p99首token延迟一度超过8秒。用户体验就是转了半天的圈才蹦出第一个字。如果不解决这个问题调用方会以为服务挂了。首token延迟主要由两个环节构成排队时间和prefill计算时间。排队时间过长是调度问题prefill慢是计算效率问题。针对排队我在控制平面加了一个最小并发预留策略。健康worker即使当前没有请求也要求vLLM保持一定数量的空闲slot避免极端情况下把所有slot占满新请求必须等尾部生成完毕才能进来。这个策略牺牲了一点极限吞吐换来了更稳定的延迟曲线值得。针对prefill我开了vLLM的前缀缓存功能参数是--enable-prefix-caching。它的原理是把请求前面重复的token序列的KV Cache缓存下来遇到相同前缀直接复用。这个功能对多轮对话场景收益特别大因为每轮都要重新把system prompt和上面的历史对话再算一遍有了前缀缓存几乎只计算新增的那一截。另外还有投机解码这个功能适合生成风格比较固定的场景。vLLM通过一个小草稿模型先猜下一步的多个token然后一次性交给大模型验证猜对了就能直接跳过几个token的串行计算。实测对代码生成类的请求收益明显但对自由对话场景收益一般。我没有默认开启而是做成按worker的开关因为投机解码会增加显存占用小模型也要吃一部分显存。4.3 并发资源隔离多模型如何在同卡共存实际场景里很少有人每张卡只跑一个大模型。OpenRig需要处理一张卡上跑一个Chat模型加一个Embedding模型这种组合。vLLM本身支持一个容器里加载多个模型吗答案是不支持直接加载两个不同的模型权重但我可以通过两张卡分区来实现。举个例子GPU 0跑Qwen2.5-72B的AWQ量化版GPU 1跑Llama3.1-8B加上BGE-M3。每个worker只通过CUDA_VISIBLE_DEVICES看到自己的卡互不干扰。如果必须在一个容器里服务多个模型vLLM支持LoRA动态加载。同一底座模型加载多个LoRA adapter切换时不用重新加载底座权重显存占用小很多。但这要求所有业务模型都基于同一个底座派生一般团队很少有这么整齐的技术体系。我建议普通用户老老实实按GPU物理隔离清晰简单出问题了也好排查。5. 上线后最常踩的坑与完整排查链路5.1 OOM与显存碎片导致的随机失败OpenRig上线第二天就出现了最经典的故障某些请求随机返回500日志里能看到CUDA out of memory。但这台卡明明还有好几G显存空闲为什么还会OOM我的排查思路是从日志开始的。vLLM在启动时会打印一行关键信息类似Maximum concurrency for X tokens is Y。打开之后发现KV Cache分配上限偏小部分请求的上下文长度超过限制vLLM需要临时申请额外的显存存储KV Cache。临时申请会有额外开销且容易遇到显存碎片最终表现为随机OOM。根因确定后我做的调整是降低max-model-len从65536改为32768减少KV Cache的峰值需求微调gpu-memory-utilization从0.85提到0.88给KV Cache留更多空间保留--enable-prefix-caching减少重复prefill的临时显存申请。调整后跑了整整48小时压测OOM率为0。这个问题的教训是vLLM让你填的参数不是越大越好max-model-len和显存预算是强耦合的必须一起调整。5.2 连接池与超时短路的完整排查链路第二个高频故障是偶发504。调用方反馈很直接OpenRig偶尔要等很久才有响应有时候直接报网关超时。我没有一上来就改代码而是先抓现场。用docker logs看worker侧发现vLLM的处理时间并不长这就说明瓶颈不在worker而在代理层。接着用ss -tn看连接状态发现大量连接处于SYN_SENT和TIME_WAIT状态同时有几百个ESTABLISHED连接没有释放。到这里基本可以锁定控制平面的HTTP连接池配置不对导致了连接耗尽。原本我在httpx.AsyncClient里没有明确设置连接池上限默认连接数太低高并发时所有请求都堵在等连接释放。修复方式是显式配置连接池limits httpx.Limits(max_connections100, max_keepalive_connections50) client httpx.AsyncClient(limitslimits, timeouthttpx.Timeout(600.0, connect10.0))同时worker侧vLLM也开启了--keep-alive参数避免转发层频繁断开连接。实测修复后并发从80拉高到300504的报错消失了。这个坑的教训是自建代理层时连接池的显式配置不能只是锦上添花而是高并发下的硬性要求。5.3 压测方法与持续监控要点OpenRig稳定运行之后我把压测和监控沉淀成了固定套路每次改动模型参数或者升级引擎版本都要跑一遍。压测我用的是Locust因为它可以模拟真实的对话请求而不是像ab那样只发固定payload。压测脚本里我区分了三种场景短对话输入短输出短主要测吞吐上限长上下文输入长输出短主要测前缀缓存和prefill能力长生成输入短输出长主要测生成阶段和连续批处理能力。每个场景跑10分钟记录每秒成功请求数、p50/p99首token延迟和端到端延迟。监控方面我坚持三件套GPU指标、请求延迟、错误码分布。GPU指标用Prometheus的DCGM exporter采集可以看到每张卡的利用率、温度和功耗请求延迟和错误码在控制平面打日志按分钟聚合到Grafana。刚开始的时候不用搞太复杂一张Dashboard能看三张图就够了太多图表反而让人不知道看哪个。6. 一点个人体会这套系统适合谁、不适合谁OpenRig做下来我最想说的是它不是银弹它有非常明确的适用边界。适合它的是这类团队手上有少量GPU需要服务的开源模型就那么三五个调用方都是内部团队追求的是低成本、快速收敛、接口统一。对你们来说OpenRig这种做法一到两周就能落地成本极低收益立竿见影。不适合的则是完全不同的情况如果你们有几十台GPU节点需要考虑多节点调度和容灾还要支持复杂的租户隔离和按量计费那OpenRig这种单机控制平面手工启动worker的方式就力不从心了。那样的场景应该直接参考Kubernetes加KServe的方案甚至上专门的大模型网关产品一步到位。不太建议做的下一步是立刻给OpenRig加多租户和计费。推理服务的复杂度和它要承担的责任直接挂钩计费一上就要有配额、隔离、审计等于把平台从工具升级成产品。工具阶段就该把工具做干净产品阶段再承担产品的复杂度。我自己接下来的打算是给OpenRig加上模型热加载功能让新模型通过配置中心注册后不用重启控制平面就能生效。如果你也在自建类似的推理服务平台我的建议是先跑通最小闭环再往里加功能保持一个足够精简、足够清晰的核心这比一开始就设计成庞然大物要靠谱得多。