两张 A100 摆在服务机房里Ollama 也装好了模型能聊天、能生成看起来一切正常。但等真要接生产流量的时候问题就一个一个冒出来两张卡只有一张在跑另一张显存占用却是 0请求稍微一多客户端直接超时服务挂了也没人知道下一次调用还在往黑洞里打。这个场面我见过不止一次。前阵子帮一个内部团队搭推理服务就是用双卡 A100 跑 Ollama把轮询分发、失败重试、健康检查、吞吐压测这四件事完整过了一遍才把链路稳定下来。这篇文章就把这轮实践里沉淀的东西写出来给同样在双卡机器上跑 Ollama 的团队做个参考。1. 双卡 A100 跑 Ollama先搞清瓶颈卡在哪1.1 显存、算力与并发能力的匹配关系先说一个有点反直觉的事实单张 A100 80GB 的显存足够装下很多 70B 量级的量化模型但“装得下”和“跑得快”是两回事。显存决定你能加载多大的模型算力决定你每秒能生成多少个 token这两者的匹配关系恰恰是多卡部署最先要搞明白的事。以 70B Q4 量化模型为例权重大约 40GB加上 KV cache 和激活值单卡 80GB 勉强能放下但推理时算力会成为明显瓶颈。单张 A100 的 FP16/BF16 算力大概在 312 TFLOPS跑 70B 模型时单请求的生成速度往往只有 20-40 tokens/s。如果业务侧有几十个并发请求排队单卡就会把队列拖得很长而显存其实还有剩余。另一种情况是小模型比如 7B-13B。这时候单卡显存绰绰有余瓶颈完全在算力上一张卡跑满也就几百 tokens/s。这种规模下双卡 A100 的正确用法不是把模型切开放两张卡而是让两张卡各自独立跑一个模型副本通过分发把请求摊开。这是整篇文章后面所有设计的前提双卡的价值取决于你是在做“模型并行”还是“请求并行”。1.2 原生 Ollama 在多卡场景下的调度局限Ollama 本身支持多卡环境设置 OLLAMA_NUM_GPU 环境变量就能把模型切分到多张 GPU 上。但实测下来要泼一盆冷水Ollama 的多卡主要是“模型并行”意义上的多卡——一个请求进来多张卡协同计算同一个模型它并不是一个生产级的负载均衡器不会自动感知卡的健康状态也不会做请求级的分发。这就带来三个实际痛点。第一Ollama 实例的并发处理能力有限。虽然新版本有 OLLAMA_NUM_PARALLEL 可以控制单个模型的并行请求数但默认情况下它内部仍有一套排队机制请求多了会在内存里堆积。第二单实例单端口没有从外部可感知的健康状态暴露机制——你无法知道某张卡是不是已经 OOM或者驱动是不是崩了。第三也是最重要的单实例跨卡并行时如果一张卡故障整个实例直接不可用容错能力为零。所以我的结论很明确在生产环境里与其把 Ollama 当“多卡调度器”用不如把它当“单卡模型服务”用——在两张卡上各起一个 Ollama 实例各自用 CUDA_VISIBLE_DEVICES 绑定一张卡然后在 Python 侧写一个轻量调度层来做轮询、重试、健康检查和监控。这个思路看着绕了一步却是稳定性最好的方案。2. 环境准备与 Ollama 多卡部署配置2.1 驱动、CUDA 与镜像版本组合先说环境版本这个组合我们验证过比较稳宿主机驱动版本 535.104.05 以上Ollama 使用官方 Docker 镜像 0.3.x 以上版本Python 侧用 3.10请求库用 httpx 而不是 requests——原因后面讲重试时会提到。有一点要特别注意宿主机驱动、容器内 CUDA、以及 Python 侧可能用到的 GPU 加速库三者的兼容矩阵必须提前统一。Ollama 官方镜像自带 CUDA 运行时所以它本身对宿主机驱动版本足够宽容但如果你在 Python 侧还要用 GPU 做 embedding 或者校验那就得保证宿主机驱动版本不低于容器内 CUDA 所需的最低驱动。我见过太多“镜像拉下来了但 GPU 不可用”的问题最后十有八九是驱动版本不匹配容器日志里报的 CUDA error 只是表象。如果你不想用 Docker直接在宿主机装 Ollama 也可以二进制安装后它会自动探测 GPU。但生产环境我强烈建议容器化原因有三环境隔离换版本不污染宿主机、资源限制用 --gpus 控制设备可见性、以及多实例部署方便同一台机器跑两个 Ollama 容器各绑一张卡互不干扰。这些优势在后续加节点、换版本的时候会特别明显。2.2 让 Ollama 真正用上两张 A100多实例部署的关键就一句话用 CUDA_VISIBLE_DEVICES 做硬隔离。下面这套 docker 启动命令是实际项目里跑稳了的版本两个实例各占一张卡端口也分开了# 实例 A绑定 GPU 0监听 11434 端口 docker run -d --name ollama-gpu0 \ --gpus device0 \ -e CUDA_VISIBLE_DEVICES0 \ -e OLLAMA_NUM_GPU1 \ -e OLLAMA_KEEP_ALIVE24h \ -p 11434:11434 \ -v /data/ollama0:/root/.ollama \ ollama/ollama:latest # 实例 B绑定 GPU 1监听 11435 端口 docker run -d --name ollama-gpu1 \ --gpus device1 \ -e CUDA_VISIBLE_DEVICES1 \ -e OLLAMA_NUM_GPU1 \ -e OLLAMA_KEEP_ALIVE24h \ -p 11435:11434 \ -v /data/ollama1:/root/.ollama \ ollama/ollama:latest两个实例的模型存储目录也分开/data/ollama0 和 /data/ollama1这样每张卡各自维护一份模型缓存。同一个模型可以分别在两个实例里拉取一次也可以用 docker cp 把已经下好的模型文件复制过去省得二次下载。Ollama 的模型文件就是一系列 blobs 目录直接 rsync 过去即可注意目录权限要和容器内用户一致。部署完要验证。先跑nvidia-smi看两张卡的显存占用再分别 curl 两个端口的/api/tags确认模型列表一致。这里有个细节OLLAMA_NUM_GPU1 是为了让实例明确只使用一张卡配合 CUDA_VISIBLE_DEVICES0/1 做双保险防止某个版本行为异常把两张卡都占了。双保险的意思就是即便环境变量被覆盖设备层的限制也兜得住。注意两个实例的端口必须不同而调度层眼里它们就是两个独立的后端节点。不要试图给两个实例配同一个端口再靠内部路由区分那会把问题复杂度提升一个量级。2.3 模型加载策略常驻 vs 冷启动Ollama 默认把模型加载到显存后空闲一段时间会自动卸载时间由 OLLAMA_KEEP_ALIVE 控制默认 5 分钟。生产环境如果请求是分散的5 分钟空闲就会把模型卸载下一次请求要重新走后端加载流程冷启动时间可能长达几十秒大模型尤其夸张直接把 P99 延迟打爆。所以生产环境必须让模型常驻显存。做法就是上面命令里的 OLLAMA_KEEP_ALIVE24h或者在调用 API 时通过 keep_alive 参数单独控制。实测下来24h 常驻的代价是显存一直被占着但换来的是请求延迟稳定在几百毫秒到几秒的量级而不是偶尔冒出 30 秒的冷启动尖刺。对绝大多数线上推理服务来说这个交换是值得的。另外要留神 OLLAMA_MAX_LOADED_MODELS 这个参数它控制同时加载的模型数量上限。如果你一张卡上既要跑生成模型又要跑 embedding 模型最好把 embedding 放在 CPU 上跑或者用单独的实例避免两个模型在显存里反复换入换出。我自己的一般原则是一张卡只常驻一个主力模型其余小模型临时加载用完即走。3. Python 调度层的核心设计轮询分发与健康检查3.1 为什么不直接打单一端口有人可能会问两个 Ollama 实例为什么不搞个反向代理轮询两个 upstream 就完事了Nginx 确实能轮询但它只能做连接层的负载均衡做不了业务层的判断。比如一张卡显存满了、推理速度已经掉到 1 token/sNginx 看不到再比如某个实例正在加载新模型请求会一直挂着直到超时Nginx 也不会把它临时摘掉。生产环境需要的调度层至少包含三件事按请求分发的策略、对后端真实状态的感知健康检查、以及出错后的兜底重试和熔断。这三件事组合在一起用 Python 写一个轻量调度层最灵活因为可以直接调用 Ollama 的 REST API拿到每次请求的耗时、token 数和返回码这些数据对后续压测和监控都很有价值。还有一点调度层本身要无状态。这样才能在它上面再叠一层横向扩容——比如起两个调度进程前端用负载均衡打过去。无状态的关键是分发决策不依赖本地历史记录而是依赖后端健康状态的实时快照。后面给出的健康检查机制就是为了支撑这个特性而设计的。3.2 轮询分发器的实现思路轮询分发听起来简单但实现时要考虑几个细节。最朴素的写法是维护一个索引每次调用 1 取模但生产环境不能这么裸奔因为后端可能瞬时不可用。我建议维护一个“活跃端点列表”健康检查发现某个实例挂了就把它从列表里摘掉恢复后再加回来。轮询只在活跃列表里做。import asyncio import itertools from typing import Dict, List, Optional class OllamaRouter: def __init__(self, endpoints: List[str]): # endpoints 形如 [http://127.0.0.1:11434, http://127.0.0.1:11435] self._endpoints list(endpoints) self._alive {ep: True for ep in endpoints} self._cursor itertools.cycle(range(len(endpoints))) self._lock asyncio.Lock() def mark_down(self, endpoint: str) - None: self._alive[endpoint] False def mark_up(self, endpoint: str) - None: self._alive[endpoint] True async def next(self) - Optional[str]: async with self._lock: alive_eps [ep for ep in self._endpoints if self._alive.get(ep, False)] if not alive_eps: return None start next(self._cursor) % len(alive_eps) return alive_eps[start]这个类不直接依赖 Ollama任何 REST 后端都能用。核心逻辑是分发时只从存活列表里选避免把请求打到已知故障的实例上。锁的作用是防止多协程并发取索引时互相覆盖虽然这里的影响不大但加上更稳妥。如果要支持权重轮询——比如一张卡性能强、一张卡弱——可以把列表改成按权重重复存放或者用带权重的轮询算法WRR。实测中两张同型号 A100 不需要权重平均分就行。需要说明的是当前实现是“基于索引的轮询”如果你希望严格保证每个请求都依次命中不同端点可以考虑维护一个 deque 来轮转但对双端点场景索引法完全够用。3.3 健康检查的粒度探活接口与推理探针健康检查不能只做“进程活着没”要做两层liveness存活和 readiness就绪。存活探针回答“这个实例还响应吗”就绪探针回答“这个实例现在能接推理请求吗”——两者差别很大混为一谈是很多故障的根源。存活探针最简单的方式是请求/api/tags这个接口只返回模型列表开销极小几十毫秒内完成。如果连续三次请求失败就把端点标记为 down。就绪探针则要用一个真实的极小请求去试探比如让模型跑一个极短的生成任务看它能否在超时时间内返回。这里有个技巧探针请求的模型要和主力模型分开展示或者直接用主力模型但把 num_predict 设成 1让开销最小化。HEALTH_PROBE_PAYLOAD { model: 7b-chat, # 用预先常驻的探针模型 prompt: ping, stream: False, options: {num_predict: 1}, } async def check_endpoint(client, ep: str, timeout: float 10.0) - bool: try: resp await client.post( f{ep}/api/generate, jsonHEALTH_PROBE_PAYLOAD, timeouttimeout, ) return resp.status_code 200 except Exception: return False健康检查的频率要控制好不要每秒钟去打探针本身也会消耗资源。我建议 5-10 秒做一轮存活探针每 30 秒做一次就绪探针。注意探针超时要比正常推理请求的超时短得多否则健康检查本身会堆积在慢请求后面导致误判。这里有几个容易踩的坑第一探针用流式还是非流式会影响超时判定我用非流式因为单 token 的生成时间很短第二探针模型如果和主力模型共存加载动作会污染显存所以最好预先把探针模型拉好并常驻第三健康检查一定要独立于业务调用路径运行不能“边调用边顺便测”否则后端故障的时候健康检查主路径一起阻塞调度层就彻底瞎了。4. 失败重试与熔断生产环境的必修课4.1 哪些异常值得重试哪些不能碰重试不是万能药而且用错了会变成毒药。我的经验是先把异常分类只对“幂等且大概率瞬时失败”的异常做重试。值得重试的连接超时、连接被拒绝后端可能在重启、5xx 错误、以及探针刚标记 down 但列表还没刷新的窗口期。不值得重试的4xx 请求错误参数错了重试一万遍也是 400、token 层面的业务错误输出被截断之类重试解决不了、以及超时特别长的生成任务——如果用户等了一个 30 秒的生成才超时重试意味着用户还要再等 30 秒体验极差。所以我在调度层里把超时分两层connect timeout 设为 3 秒read timeout 根据模型大小设为 30-120 秒。连接超时直接重试读超时看场景如果业务可接受可以重试一次否则直接返回用户错误。这个细节很关键因为 Ollama 的长生成任务本来就是秒级到分钟级一刀切用短超时会导致大量误杀。4.2 重试策略指数退避与抖动重试必须带退避否则故障恢复瞬间会有一波重试洪峰把后端再次打挂。标准做法是指数退避加抖动import random import asyncio async def retry_with_backoff(coro_factory, max_retries3, base_delay0.5, max_delay8.0): for attempt in range(max_retries): try: return await coro_factory() except Exception as exc: if attempt max_retries - 1: raise delay min(base_delay * (2 ** attempt), max_delay) # 全抖动等待时间均匀分布在 [0, delay]打散重试峰值 await asyncio.sleep(random.uniform(0, delay))退避公式是 delay base * 2^attempt第一次重试等 0.5s第二次 1s第三次 2s。抖动的作用是避免多个客户端在同一时刻重试——如果 50 个请求同时失败没有抖动它们会同时醒来形成新一轮请求峰值把刚恢复的后端再次压垮。全抖动full jitter把等待时间均匀分布在 [0, delay] 区间实测比固定抖动更能打散流量。使用 httpx 的另一个原因在这里httpx 的 AsyncClient 支持连接池复用和精确的超时控制而 requests 的同步模型在 asyncio 里会阻塞事件循环做重试调度会非常别扭。异步重试的整个要点就是“等待时不占线程”如果底层是同步阻塞那么并发一高重试本身就成了瓶颈。4.3 熔断与降级避免雪崩重试处理的是“偶发故障”但如果是后端彻底挂了重试只会放大问题。这时候需要熔断器连续失败达到阈值就“断开”一段时间期间直接快速失败不再请求后端。我用的是一种简单的三态熔断器关闭正常请求、打开快速失败、半开试探性放行少量请求。核心参数是失败阈值比如连续 5 次失败打开熔断、打开持续时间比如 30 秒、半开状态下放行的请求数量比如 3 个全部成功则关闭否则重新打开。在双实例场景下熔断要和轮询分发配合熔断器摘掉一个实例后所有流量都打到剩余实例这本身可能撑不住。所以我还加了一个“降级”逻辑如果可用实例只剩一个就把大模型请求自动降级为小模型或者直接返回提示让用户稍后再试。降级策略要根据业务容忍度来定宁可直接拒绝也不要让请求在队列里无限堆积。注意熔断器打开期间健康检查仍然要照常跑。熔断负责“挡住流量”健康检查负责“探测恢复”两者配合才能实现半开状态的自动恢复。如果熔断之后就停止探活半开状态就永远无法退出。5. 吞吐压测从单请求到并发洪峰5.1 压测工具与指标口径做完调度层必须用数据验证它到底行不行。我压测用的是自研的 asyncio 脚本——不是不用现成工具而是因为它要精确模拟“并发用户 随机间隔 不同 prompt 长度”的生产流量。现成的压测工具比如 wrk、ab更适合 HTTP 短连接场景对长耗时推理请求的反而不太友好而且它们很难统计 token 级吞吐。压测脚本的核心设计是信号量控并发 速率控制器模拟真实到达规律import asyncio, time, statistics import httpx async def worker(semaphore, client, url, payload, results, rate_limiter): async with semaphore: await rate_limiter.wait() # 控制请求到达速率 t0 time.perf_counter() try: resp await client.post(url, jsonpayload, timeout120) dt time.perf_counter() - t0 results.append((resp.status_code, dt, resp.json().get(eval_count, 0))) except Exception as exc: dt time.perf_counter() - t0 results.append((error, dt, str(exc)))指标口径要统一我建议至少记录这几项QPS每秒完成的请求数、token 吞吐每秒生成的 token 总数、平均延迟、P50/P95/P99 延迟、错误率。并发数从 1 到 32 递增每档持续 2 分钟。为什么用 2 分钟因为太短数据不稳定太长又浪费时间2 分钟足够让队列和显存进入稳态也能覆盖到慢请求的长尾。5.2 实测数据解读吞吐、延迟与资源水位压测结果整理成表格最有说服力。下面这组数据来自某 7B 量化模型单请求平均生成约 200 token两张 A100 各跑一个实例调度层轮询分发。数据本身做过脱敏但趋势是真实的并发数QPS平均延迟(ms)P99延迟(ms)错误率11.28209100%22.386010500%44.198013500%85.6143024000%166.3254052000%326.8470098000.5%从这个表能看出几个关键规律。第一QPS 随并发数上升但在并发 8 之后趋于饱和说明瓶颈在 GPU 算力而非调度层——调度层本身每秒能处理几千个转发远不是瓶颈。第二平均延迟和 P99 随并发数上升而恶化这是排队效应的体现而且 P99 的增长比平均更快说明长尾请求在堆积。第三并发 32 开始出现错误率这通常是单个请求生成时间超过读超时被掐断造成的。资源水位方面压测时盯着 nvidia-smi 看两张卡的利用率在并发 8 时达到 90% 以上显存占用稳定在模型大小附近没有出现 OOM。这说明双实例方案成功地把两张卡的算力都吃满了——这正是前面说“请求并行”想要的效果两张卡各自满负荷而不是一张卡忙死、一张卡闲得发慌。5.3 压测中暴露的问题压测最大的价值不是得到漂亮的数字而是暴露问题。这次压测就挖出了三个。第一个是健康检查的探针污染了主力模型。压测刚开始时就绪探针用的模型在显存里和主力模型抢空间导致偶发延迟尖刺。解决方法是把探针模型和主力模型彻底分开或者干脆把探针频率降到 30 秒一轮。第二个是 Ollama 内部的请求队列没有上限。并发 32 时虽然调度层只往两个实例各发了 16 个请求但 Ollama 内部还会对同模型的并发请求做排队导致个别请求等待时间超过业务容忍度。这个问题的解法是在调度层再加一个并发上限控制asyncio.Semaphore让整条链路的并发进入可控范围。第三个是流式响应的背压问题。压测早期我们用的是 streamTrue 的流式响应但客户端读得慢时后端会阻塞进而拖慢其他请求。后来在压测场景统一用非流式生产场景则要求客户端必须及时消费 SSE 流否则连接池会被慢消费者占满。这个问题在文档里几乎没人提但线上真的会发生。6. 复盘与最佳实践6.1 性能调优的几个关键参数整套方案里参数调优的优先级是这样的先保证显存常驻OLLAMA_KEEP_ALIVE再调并发参数OLLAMA_NUM_PARALLEL最后才是调整请求参数num_predict、temperature 这些业务参数。顺序不能反因为前两个决定系统的稳定性底座后面的只影响单请求体验。关于 OLLAMA_NUM_PARALLEL我建议先保持默认用压测数据来决定要不要调。它决定单个模型实例同时处理的请求数调高了能提升 GPU 利用率但也可能让单个请求变慢——因为并发请求共享算力。实际项目中我们最终把它调到 4配合调度层的信号量控制在每个实例 4-8 个并发请求这组参数在压测下表现最稳。另外一个容易忽略的参数是 num_predict最大生成 token 数。很多团队在 API 层不设上限结果模型一跑就是几千 token把生成时间拖到分钟级。生产环境一定要根据业务场景限定最大生成长度否则延迟和成本都不可控。我一般建议生成类场景设 512-1024摘要类场景设 256-512具体值根据你的 prompt 长度和业务需要来调。6.2 我踩过的坑和最后的建议最后分享几个不写进文档的教训都是真金白银换来的。第一不要迷信“默认配置”。Ollama 开箱即用确实方便但生产环境必须逐项检查 KEEP_ALIVE、NUM_GPU、NUM_PARALLEL 这些参数。默认值是为开发调试设计的不是为生产流量设计的。用默认配置直接上线的后果就是间歇性的冷启动尖刺和莫名其妙的超时。第二健康检查要区分存活和就绪。只做存活探针的最直接后果是实例进程活着但模型没加载完或者显存满了请求照样失败。就绪探针虽然多花一点资源但换来的稳定性非常值。第三重试一定要看业务场景。生成式 AI 的重试不是普通 HTTP 接口的重试——一个生成请求可能耗时几十秒重试一次就是翻倍的成本和延迟。重试次数宁少勿多熔断阈值宁紧勿松。把“重试”当作兜底手段而不是常规手段。第四压测不要只看平均延迟。生成式服务的延迟分布极不均匀P99 才是用户体验的真相。我们压测中并发 16 时平均延迟 2.5 秒听起来还行但 P99 已经到了 5.2 秒——如果只看平均上线后一定会被长尾请求投诉。这套“双卡 A100 双实例 Ollama Python 调度层”的架构用下来的体会是它不复杂但每个环节都需要想清楚“为什么这样做”。轮询分发解决的是流量怎么分的问题健康检查解决的是怎么知道后端状态的问题重试和熔断解决的是挂了怎么办的问题压测解决的是这一切到底行不行的问题。四个问题都回答清楚了服务才敢上生产。