去年我们把大模型能力接到内部工单系统时第一版代码是直接对着 Ollama 裸接口写的。开发环境跑得飞快可等到要正式上线、多个业务团队共用同一批 GPU 服务器时我才意识到面前缺了一个企业级网关。没有统一入口、没有身份校验、没有配额概念负载高了谁先到谁抢到出了问题也查不到是谁在什么时间调了哪个模型。后来我花了三周时间自研了一个 Ollama 企业级网关用这层薄薄的治理层把本地大模型从“能跑的 demo”变成了“可以对外承诺的服务”。这篇文章把整套方案从设计、实现到部署运维的过程完整复盘一遍重点讲我为什么自研、网关内部怎么拆、生产环境怎么落地以及那些日志里查不到的坑。适合正在做本地大模型生产化落地、或准备给 Ollama 加一层网关的团队参考。1. 裸奔的 Ollama 撑不起生产环境三个真实事故在讲网关怎么做之前先说说我为什么非做不可。Ollama 本身是一个很好的本地大模型运行时安装简单、模型管理方便、API 也直观但它默认绑在 11434 端口上几乎是“裸奔”状态。我们最初把它部署在内部测试机上问题是一步步暴露的每一个都很典型。1.1 事故一没有任何认证请求来源完全失控Ollama 默认没有内置的 API Key 机制局域网里只要能访问到这个端口谁都能调用模型。我们当时把服务部署在内网测试机上某天模型响应延迟突然高到离谱排查后才发现是另一个组的自动化脚本配错了地址把压测流量全打到了我们的模型节点上。没有认证意味着没法判断请求来自哪里也没法拒绝。开发阶段这个缺陷无所谓一旦业务方开始依赖这个接口就是定时炸弹。更何况企业内部的 GPU 服务器不像云上的 HTTP 服务直接暴露出来的风险不只是资源耗尽还涉及模型资产访问权限的问题。1.2 事故二多团队共用 GPU显存互相抢占多个团队接入之后抢资源的问题立刻放大了。有人跑长文本抽取有人做 embedding有人在对话机器人里调试 prompt。Ollama 对并发请求不是没有控制但它的调度逻辑更偏向“保住模型不崩”而不是“让多个团队的请求公平排队”。所以当时出现的现象是某个团队发起的 72B 模型请求把显存占满后其他团队的 7B 模型请求就开始疯狂等待有的请求十几秒都出不来第一个 token。问题的根源不是 Ollama 本身而是缺少一个上游的“配额分配机制”。我们需要按团队、按应用去约束并发而不是让所有人把整块 GPU 当成公共池子随便抢。1.3 事故三模型版本升级完全靠自觉我们最开始用的是 qwen2.5:7b后来团队有人为了试新特性直接从 Ollama 拉取了新 tag。第二天就有业务方反馈答案风格变了排查之后才发现是有人升级了默认模型 tag导致所有没写死版本的调用全部切到了新模型。这个事故的本质是模型版本管理缺失。Ollama 的 tag 就像一个指针它可能指向不同的模型文件生产环境必须把“当前应该用哪个模型”这件事变成可配置、可灰度、可回滚的流程而不是靠运维人员口头通知。1.4 从事故清单到能力清单这三件事让我形成了一个明确的判断Ollama 是优秀的模型运行时但不是企业服务系统。生产化落地需要的是在它前面加一层控制面也就是网关。这一层至少要补齐五个能力统一入口业务方只对接网关不直接触碰 Ollama 节点认证鉴权区分调用方控制访问范围限流配额按团队或应用分配并发与速率智能路由同一个模型名可以路由到不同后端节点审计可观测每一次请求都能追溯关键指标都能量化如果说本地大模型生产化是盖一栋房子Ollama 提供的是地基和承重墙网关就是水电管网和门禁系统。房子能不能住人取决于后者。2. 网关的职责边界该管的管不该管的不碰2.1 网关只是“治理层”不是“推理层”我见过一些团队做类似东西时容易把网关越做越重想在网关里做 prompt 模板、想缓存模型 output、甚至想把模型加载逻辑搬进来。我的建议是明确一个边界——网关不加载模型、不推理、不缓存生成结果它只做请求的接入、控制、转发和记录。为什么因为推理层有 Ollama 自己处理模型加载、KV cache、采样参数这些逻辑如果拿到网关层重做既复杂又容易和 Ollama 的调度产生冲突。网关做的是“质量保障”而不是“业务实现”。把完整 LLM 服务的复杂度拆开看就是“模型运行时 一面治理墙”网关就是那面墙。2.2 网关内部模块怎么拆我最终落地时把网关分成六个模块模块职责关键点接入层处理 HTTP 请求、TLS、路由分发统一暴露 443外部访问不接触 11434认证模块校验 API Key识别调用方身份应用名注入上下文便于后续审计限流模块令牌桶 并发信号量同时控制 QPS 和在途请求数路由模块根据 model 字段映射后端节点支持模型 tag 锁定和灰度切换审计模块记录请求全链路日志结构化 JSON便于进 ELK观测模块Prometheus 指标 健康检查首字时延、排队数、节点状态这六个模块都在同一个 Go 程程序里不需要额外依赖数据库状态全部放在内存和 YAML 配置中。无状态设计让网关可以横向扩容重启不丢数据运维成本很低。2.3 为什么自研而不是魔改通用网关做之前我也调研过 Kong、APISIX、Envoy 这些现成方案。它们在 HTTP 转发、负载均衡、动态路由方面确实强大但 Ollama 的流量形态有个特殊性SSE 流式响应。Ollama 的/api/chat和/api/generate返回的是text/event-stream需要逐行 flush。通用网关默认会做缓冲、聚合、压缩这些特性在普通 HTTP 场景没问题碰到 SSE 就可能导致第一个 token 迟迟不返回。此外Ollama 的路由决策依赖请求体里的model字段这不是基于 URL 或 header 就能简单完成的转发逻辑用通用网关要么得写一堆插件要么就得靠正则匹配硬凑。与其在通用产品上打补丁不如自己用一个轻量级语言写一个不到 2000 行的薄网关。这种“薄网关”模式在基础设施界很常见数据库前面的 Proxy、K8s 的 Ingress 都是这个思路。它不取代通用网关只针对特定后端做特定治理。2.4 技术选型为什么用 Go选 Go 而不是 Java、Node 或 Python主要考虑了三点单二进制部署编译完丢到服务器就能跑没有依赖地狱特别适合内网部署环境并发模型契合 SSE 场景goroutine 处理上千个长连接毫无压力和 Ollama 的流式交互很自然中间件生态成熟Prometheus client、JWT、YAML 解析都有成熟库开发效率不比 Python 低如果你所在团队更熟 Java 或 Node也不是不行但处理 SSE 逐行 flush 时一定要确认技术栈的 HTTP 框架不会帮你做缓冲。3. 核心实现细节认证、限流、路由与流式转发3.1 认证从 API Key 到应用身份我们的内部系统还没有完全上统一的 OAuth所以第一版就用 API Key。思路很简单网关启动时加载一个 key 映射表每个 key 对应一个应用名和所属团队校验通过后把应用名注入请求上下文。代码骨架大概是这样的func AuthMiddleware(next http.HandlerFunc, store *KeyStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth : r.Header.Get(Authorization) if !strings.HasPrefix(auth, Bearer ) { writeError(w, http.StatusUnauthorized, missing api key) return } app, ok : store.Lookup(strings.TrimPrefix(auth, Bearer )) if !ok { writeError(w, http.StatusUnauthorized, invalid api key) return } r r.WithContext(context.WithValue(r.Context(), ctxAppName{}, app.Name)) next(w, r) } }这里有个细节API Key 本身也要有生命周期管理不能一个 key 用三年。我们的做法是每季度轮换一次并强制要求业务方在请求头里带X-App-Name网关会校验它和 key 映射的应用名是否一致防止有人拿别人的 key 乱贴标签。3.2 限流令牌桶和并发信号量缺一不可限流很容易只做 QPS但 GPU 推理场景真正的瓶颈是显存。我最终同时上了两套限制令牌桶控制应用的平均请求速率解决“每秒请求数暴涨”的问题并发信号量控制在途请求数量解决“多个推理同时占显存”的问题令牌桶用 Go 标准库golang.org/x/time/rate实现信号量则是一个带缓冲的 channel。为什么两者都要举个例子某应用每秒只发 2 个请求看着 QPS 很低但如果两个请求都是长上下文推理同时加载两个模型到显存照样会 OOM。所以并发信号量的上限要按 GPU 显存容量来设。比如一张 24G 的卡同时跑 4 个 7B 模型是安全的那么路由层就把该节点可接收的在途并发卡到 4。一旦超过就返回 429 Retry-After让调用方退避重试而不是塞进一个无限队列里干等。3.3 动态路由从“裸 model 名”到“注册表映射”Ollama 的请求体里带一个model字段网关不能直接拿它去请求后端要先查注册表。比如models: - name: qwen2.5:7b backend: gpu-node-01:11434 max_concurrency: 4 concurrency_guard: true - name: qwen2.5:72b backend: gpu-node-02:11434 max_concurrency: 1 - name: text-embedding backend: embed-node:11434路由决策实际上是一个精确匹配 默认兜底的过程。业务方请求modelqwen2.5:7b网关把请求转发到gpu-node-01如果注册表里没有这个模型就直接拒绝不往下游透传。这样做的核心收益是后端节点的 IP、端口可以随时调整业务方完全没有感知。另一个关键点是模型 tag 锁定。我在注册表里不允许出现latest这种可变 tag业务方调接口时传的 model 必须是明确版本。想上线新模型先在注册表里加一条新映射跑通测试后再调整路由权重实现灰度发布。前面说的“事故三”就是因为这一步严格卡住了再也没有出现过“模型莫名升级”的情况。3.4 流式转发千万别吞掉 SSE 的 flush这是网关最容易写错的地方。Ollama 返回text/event-stream它是逐行 flush 的。如果网关用了带缓冲的io.Copy或者自己攒一段再转发前端会出现“第一个字等很久”的体感问题。正确做法是逐行读取上游响应拿到一行就立刻写入下游 ResponseWriter 并调用 Flushfunc streamCopy(dst http.ResponseWriter, src io.Reader) { buf : bufio.NewReader(src) for { line, err : buf.ReadBytes(\n) if len(line) 0 { _, _ dst.Write(line) if f, ok : dst.(http.Flusher); ok { f.Flush() } } if err ! nil { return } } }同时要注意两点响应头的Content-Type必须设成text/event-stream网关这一层绝对不能启用任何 response 压缩或缓冲中间件。压缩和 Flush 在 Go 的 httputil 里会互相干扰一旦开启压缩前端收到的就不是实时流而是被缓冲的块。还有一个容易被忽略的问题超时设置。推理请求可能几十秒不出包尤其是长上下文因此读超时不能设得太短。我的做法是保留默认 TCP 连接空闲检测但不给 HTTP 层设全局读超时只在路由层单独用 context 控制单次请求的最大时长。4. 模型资产管理从单机目录到企业级注册表4.1 模型注册表入口处的“统一目录”网关的路由模块已经内含一个模型注册表我把它升级成了企业级的配置中心。除了后端地址每个模型还可以声明自己的上下文长度、是否对外可见、所属团队。这样做的意义在于模型列表不再停留在“某台服务器上装了哪些模型”这个层级而是变成“企业内可用模型能力目录”。新业务接入时只需要问网关要一份模型清单不用关心模型实际部署在哪台机器上。4.2 版本发布与灰度切换前面提到 tag 锁定这里展开说一下发布流程。我们现在的标准流程是在任意一台 Ollama 节点上 pull 新模型 tag用离线评审工具跑一组自动化测试用例摘要、问答、代码生成在网关注册表新增目标模型映射但权重设为 0从管理接口手动触发一两个测试请求打到新模型上验证通过后把权重调成 10%灰度放量观察首字时延和业务反馈逐步提升到 100%需要回滚时直接改注册表权重即可这个流程听起来朴素但真正让“模型升级”变成一次可控制的发布而不是牵一发动全身的冒险。4.3 存储与导入的实操问题Ollama 的模型文件默认放在~/.ollama/models开发机可以无所谓生产环境必须用OLLAMA_MODELS环境变量指向独立数据盘否则系统盘会被模型文件撑爆。我们用的 72B 模型量化后也有几十 GB加上多版本并存磁盘规划从一开始就要想清楚。模型拉取慢的问题在生产环境也遇到过。内网机器访问外网带宽有限一个大模型可能拉几个小时。我的经验是优先在公司内部搭建一个模型文件分发点在一台带宽充裕的机器上提前下载好打包后分发到各 GPU 节点导入。这个方法不折腾、可靠也方便离线环境使用。4.4 Ollama 生产环境的关键环境变量部署 Ollama 时有几个环境变量你最好在 systemd unit 或容器编排里显式配置环境变量作用建议值OLLAMA_MODELS模型存储目录指向大容量数据盘OLLAMA_NUM_PARALLEL每个模型最大并行请求数按显存和模型大小谨慎调整OLLAMA_MAX_LOADED_MODELS同时加载的模型数量显存足够时增加避免频繁换入换出OLLAMA_KEEP_ALIVE模型保持加载的时间短请求多则调大减少加载开销这几个参数直接影响生产体验。默认的并行数偏低导致我们第一版上线时同一个模型只能串行处理请求业务方稍微并发一高就排队。5. 生产部署与可观测性拿数据证明网关是稳定的5.1 部署架构一个二进制 systemd网关本身没有数据库依赖所以第一版我用 systemd 部署一个二进制文件加一份 YAML 配置就够。所有配置通过环境变量注入日志走 stdout统一交给 journald 收集。对于 K8s 环境网关注定为无状态服务可以直接做成 Deployment 加 HorizontalPodAutoscaler通过 CPU 指标扩展副本数。不过网关本身很轻瓶颈从来不在网关 CPU而在后端 GPU 节点的推理能力所以扩网关副本的意义有限更重要的是做好路由层的后端节点健康检查。5.2 健康检查不能只探 TCP要探真实推理一个很容易踩的坑是健康检查只看 TCP 端口通不通结果后端 Ollama 进程已经假死端口还通着网关依然把请求打过去前端全部超时。我用的是 HTTP 级健康检查网关每隔 30 秒向后端 Ollama 发一个极小的 embedding 请求确认它能真实完成一次推理如果连续三次失败就把该节点从路由表中摘除。这个检查方法比 TCP 探活可靠得多因为它探测的是 GPU 推理链路的完整可用性而不只是网络连通性。5.3 Prometheus 指标与首字时延网关暴露/metrics给 Prometheus 采集。我最看重的几个指标ollama_gateway_http_requests_total按应用、模型、状态码维度计数ollama_gateway_first_token_latency_seconds从请求进入到首个 token 返回的耗时直方图ollama_gateway_request_duration_seconds完整请求耗时直方图ollama_gateway_queued_requests当前排队请求数ollama_gateway_backend_health后端节点健康状态首字时延这个指标特别重要。用户体感上的“卡不卡”几乎完全取决于它。SSE 流开始返回之前的时间包括排队、路由、模型加载、预填充全部浓缩在这个数字里。我们在 Grafana 面板上把它和 QPS、显存使用率放在同一张图基本能一眼定位性能问题出在哪一环。5.4 审计日志让每一次调用都有据可查审计日志是生产化落地的另一根支柱。每个请求我都会记录以下字段{ ts: 2025-05-12T10:11:22.432Z, app: ticket-assistant, team: platform, model: qwen2.5:7b, backend: gpu-node-01:11434, status: 200, prompt_tokens: 1284, completion_tokens: 256, first_token_ms: 420, total_ms: 3850, client_ip: 10.10.2.18 }日志统一写到本地文件再由 Filebeat 收集进 ELK。之前一次业务方反馈“响应变慢”我们就是靠审计日志快速定位到是某个模型节点在那一时段负载过高而不是靠各种猜。有这套审计系统之后排查问题的成本显著下降。它带来的不仅是技术上的可观测性更是组织上的信任——基础设施团队能拿出证据证明“你的请求被正确处理了”业务方才会放心地把核心流程接进来。6. 踩坑记录与教训日志里不会告诉你的细节6.1 并发的坑Ollama 默认参数不一定适合你第一版上线后某个应用反映并发一高就明显变慢但看网关指标 QPS 并不高。后来才发现是OLLAMA_NUM_PARALLEL默认值太小同一个模型的多个请求会串行排队。调整到合适值之后并发能力明显改善。还有一个细节OLLAMA_MAX_LOADED_MODELS如果设得太大Ollama 会把多个模型都塞进显存导致每个模型的可用显存变小推理反而变慢甚至触发换入换出。这个参数不能盲目调大需要结合具体模型大小和显存容量反复压测。6.2 SSE 压缩一个让流式响应变卡的小问题我们一开始没有意识到压缩中间件会和 SSE 冲突。上线后前端反馈“打字机效果没了一句话一次性出来”排查半天发现是 Nginx 到网关之间开启了 gzip。流式接口一旦被压缩就失去了逐行 flush 的意义体验直接退化。解决方法是明确在 Nginx 里针对/api/chat、/api/generate关闭 gzip并设置X-Accel-Buffering: no。这个问题的隐蔽性在于它不会导致功能报错只会让体验悄悄变差如果业务方没有反馈你可能一直意识不到。6.3 限流的 429 策略夜间任务把配额打满上线初期我们收到过一次告警某应用的 429 错误突然飙高。通过审计日志还原后发现是一个数据同步任务在凌晨以低并发方式把所有团队共享的 API Key 配额打满了。这个问题的根因不是网关故障而是配额策略太粗——所有应用共享一套全局限流。后来我改成按应用独立配额并给批处理任务单独划分了一个低优先级队列。这里想说的是没有审计日志的话你很可能只能盲目加配额而看不到真正的原因是什么。6.4 健康检查的假阳性TCP 通不代表能推理前文提到健康检查要用真实推理请求这里补充一个案例。某次 GPU 驱动状态异常Ollama 进程还活着TCP 端口也通但任何推理请求都会报错。如果只看端口网关不会摘除节点所有打到这个节点的请求都会持续失败。改成 HTTP 推理探测后这类故障的恢复时间从“业务方发现”变成了“30 秒内自动摘除”。我个人在实际操作中的体会是网关这类基础设施项目最怕两件事一是想太多把模型推理逻辑也搬进来二是不愿意花时间把可观测性做扎实。其实把认证、路由、限流、审计这四件事做好Ollama 生产化落地就已经走过一大半了。后续可以继续扩展的方向包括在网关层做输入输出语义审核、把模型切换做成 A/B 实验平台、甚至接入统一模型 Cost 统计。基础设施是一层一层搭起来的网关就是让本地大模型真正变成企业服务的那道门槛。
