双卡A100 Ollama生产化:Python轮询分发与重试实战
双卡 A100 机器上把 Ollama 跑起来只是第一步真正麻烦的是怎么在生产环境里稳定、高效地调用它。我这边把一台双卡 A100 的推理服务从“能跑”折腾到“能扛生产流量”中间踩了一圈坑最后沉淀出一套 Python 调用层的完整方案包括轮询分发、失败重试、健康检查和吞吐压测。这篇文章把这套设计的来龙去脉、核心代码和实测数据全部摊开讲给同样在搞 Ollama 生产化的朋友做个参考。1. 为什么放着单实例不用非要搞客户端轮询1.1 单实例调用在生产环境的三宗罪先说一个很多人刚上手 Ollama 时会踩的坑默认装完它就是127.0.0.1:11434一个实例然后 Python 里直接requests.post(http://127.0.0.1:11434/api/generate)就完事了。单机单实例在 Demo 阶段完全没问题但一旦接到生产链路问题马上暴露。第一宗罪是单点故障。Ollama 进程崩溃、模型加载卡死、显存被某个异常请求打满任何一个意外都会让整条推理链路不可用。生产环境不是开玩笑上游任务队列里的请求会因为这一个端口被堵住而越积越多。第二宗罪是双卡资源利用率极低。A100 双卡机器默认 Ollama 只在一块卡上跑。模型如果不大比如 7B、13B 的量化版本单卡推理时另一张卡完全闲置。大模型推理的吞吐瓶颈通常在算力而不是显存容量一张卡跑满、另一张卡闲着等于白扔了一半的钱。第三宗罪是并发能力受单实例内部调度限制。Ollama 自己有一套并发请求排队机制单实例并发上去之后请求会排在同一个进程里首 token 延迟和端到端延迟都会明显恶化。你把并发线程从 8 调到 32吞吐不一定翻倍延迟反而先崩了。1.2 双实例 客户端分发到底解决了什么既然一张卡跑一个实例是自然思路那就在双卡机器上启动两个 Ollama 实例CUDA_VISIBLE_DEVICES0绑第一张卡监听11434另一张卡绑第二个实例监听11435。两张卡各干各的互不干扰。这样部署之后真正的决策点变成了谁来把请求分到这两个实例上三个选项摆在我面前一是用 Nginx、HAProxy 这类反向代理做负载均衡。好处是网关层解决Python 调用方无感知坏处是代理层只能做最基本的 round-robin 和 TCP 健康检查感知不到 Ollama 特有的状态比如模型是否已经加载、当前队列深度、显存是否接近打满。代理把请求发给一个显存快爆的实例照样 503。二是用 Ollama 自身的多 GPU 支持。它确实支持把一个大模型切到多张卡上但这个机制按层切分本来就不是为了负载均衡设计的而且两个请求共用一个模型副本队列竞争问题依然存在。故障隔离就更别想了一张卡出问题整个实例挂掉。三是客户端自己实现分发。Python 调用方维护实例地址列表自己做轮询、健康检查、故障摘除和重试。这种方式看起来工作量最大但灵活性最好而且调用方掌握着请求级别的实时反馈能做出最精准的判断。我自己最终选了这条路。1.3 整体架构分层拆解整个调用层我拆成了四个模块各管一件事互相之间通过内部状态协作实例注册表维护两个 Ollama 实例的地址、当前健康状态、最近失败次数、累计请求数。健康检查器独立线程定期探测各实例的/api/tags接口超时和失败次数达到阈值就把实例标记为不可用。轮询分发器从可用实例列表里按 round-robin 选下一个地址连续失败被摘除的实例直接跳过。重试策略器对可重试的异常连接错误、超时、5xx、429执行指数退避重试并且重试时强制切换实例。一句话概括这个设计健康检查负责把人放进名单轮询分发负责从名单里挑人失败重试负责在挑到坏人的时候换一个人。三层协作生产调用才算稳了。这几个模块的实现细节后面几节逐一展开。2. 部署层准备两张卡跑两个 Ollama 实例2.1 启动双实例的正确姿势环境变量与端口规划双实例部署的关键就三个环境变量CUDA_VISIBLE_DEVICES指定进程能看到的 GPU、OLLAMA_HOST指定监听地址端口、OLLAMA_KEEP_ALIVE控制模型驻留显存的时间。CUDA_VISIBLE_DEVICES这个东西很多人理解有偏差。它不是“把这个进程分配到某张卡”而是“让这张卡在这个进程的视野里消失”。实例 A 设置CUDA_VISIBLE_DEVICES0它只能看到物理卡 0实例 B 设置CUDA_VISIBLE_DEVICES1它只能看到物理卡 1。这样显存天然隔离两个进程不会抢同一块卡。我用一个脚本同时拉起两个实例完整版本长这样#!/usr/bin/env bash # start_ollama_dual.sh export OLLAMA_KEEP_ALIVE-1 # 实例 A绑定物理卡 0监听 11434 CUDA_VISIBLE_DEVICES0 OLLAMA_HOST127.0.0.1:11434 \ nohup ollama serve /var/log/ollama_11434.log 21 # 实例 B绑定物理卡 1监听 11435 CUDA_VISIBLE_DEVICES1 OLLAMA_HOST127.0.0.1:11435 \ nohup ollama serve /var/log/ollama_11435.log 21 echo dual ollama instances started几个细节值得说明。OLLAMA_KEEP_ALIVE-1表示模型加载后一直留在显存里不会被自动卸载。如果省略这个参数Ollama 默认在模型空闲 5 分钟后把显存释放掉生产流量一波动模型反复加载卸载请求延迟直接起飞。这一点在后面压测里体现得特别明显。日志分开两个文件也很重要。两个实例共享一个日志文件出了问题连是哪张卡报错都分不清。分开之后排查效率翻倍。端口规划方面我统一从 11434 开始往后排方便记忆也方便 Prometheus、Grafana 这类监控系统批量抓取。生产环境如果机器上还跑着别的东西端口冲突排查起来很痛苦建议在部署清单里把端口占用写清楚别等出问题了再来查。2.2 验证实例隔离与显存分配实例拉起来之后不是立刻就能用的。先做一轮验证确认两张卡确实被分别占用。我用两个命令组合验证curl http://127.0.0.1:11434/api/version curl http://127.0.0.1:11435/api/version两个端口都返回 version 信息说明实例进程都活着。然后看显存分配nvidia-smi这时候应该能看到两个ollama serve进程各占一张卡。如果两个进程都在同一张卡上说明环境变量没生效回去检查启动脚本的传参方式。接着拉一个真实模型做加载验证。我在两个实例上各拉同一个模型ollama pull qwen2.5:14b # 实例 A 上执行 ollama pull qwen2.5:14b # 实例 B 上执行这里有个容易忽略的点ollama pull默认连的是当前 shell 环境里的OLLAMA_HOST。两个实例各自拉一次本质上是各存了一份模型文件。这样每个实例加载模型时读本地文件不依赖网络也不存在文件锁冲突。模型文件在双实例场景下磁盘占用翻倍14B 模型量化版大概 9GB 左右两份就是 18GB磁盘吃紧的机器要提前规划。加载完成后用/api/ps接口确认模型已经驻留显存curl http://127.0.0.1:11434/api/ps curl http://127.0.0.1:11435/api/ps正常返回里能看到模型名、显存占用、进程 ID 等信息。各加载各的显存隔离没问题。2.3 模型加载与保活的几个细节双实例部署后模型管理有一个坑你不能用常规的ollama run去预加载模型因为ollama run是交互式命令会一直占着终端。我用的预加载方式是直接调用接口curl http://127.0.0.1:11434/api/generate \ -d {model: qwen2.5:14b, prompt: ping, stream: false} curl http://127.0.0.1:11435/api/generate \ -d {model: qwen2.5:14b, prompt: ping, stream: false}这个请求的作用只是让模型加载进显存。返回结果本身无所谓重要的是/api/ps能看到模型处于 loaded 状态。OLLAMA_KEEP_ALIVE-1设好了模型会一直驻留但生产环境还有另外一个问题Ollama 进程如果被系统 OOM Killer 杀掉或者机器重启模型全部要重新加载。所以我给两个实例各写了一个 systemd service 文件配置了Restartalways进程挂了自动拉起。这比 bare nohup 靠谱得多nohup在父进程退出后虽然能存活但系统层面没有守护挂了就是挂了。关于显存超卖也有个提醒。A100 80GB 跑 14B 量化模型单实例模型权重只占 10GB 不到看起来余量很大但 Ollama 默认会为每个并发请求分配 KV cache 空间并发越高KV cache 占的显存越多。如果某个时刻并发请求数量暴增显存一样会被打满。后面压测那一节我会给出具体的显存观察数据这里先提醒一句部署完一定要测高并发别只看空闲显存沾沾自喜。3. Python 调用层实现轮询、健康检查、重试3.1 健康检查模块实例状态的实时追踪健康检查是整个调用层的地基。轮询分发做得再好如果健康状态是假的一样会把请求发给一个已经死掉的实例。我的健康检查策略分两层。第一层是主动探测用独立线程每隔 5 秒请求一次/api/tags接口。这个接口不触发模型推理只返回模型列表开销极小适合做心跳。第二层是被动反馈每次实际调用如果抛异常或返回 5xx就把该实例的连续失败计数加一这个计数的权重比主动探测更高因为它是真实流量的反馈。健康状态有三个档位healthy主动探测正常连续失败计数为 0正常参与轮询。degraded连续失败 1 到 2 次暂时标记为低优先级仍有概率被选中但权重降低。down主动探测超时或连续失败达到 3 次直接摘除不再参与轮询。摘除的实例不会永久隔离。健康检查线程会继续探测已摘除的实例只要恢复响应且连续成功达到 2 次就重新回到可用列表。这个策略其实借鉴了熔断器Circuit Breaker的思路快速失败、缓慢恢复避免在故障实例上反复超时浪费时间。代码实现里我维护了一个实例状态字典class InstanceState: def __init__(self, base_url: str): self.base_url base_url self.status healthy # healthy / degraded / down self.consecutive_failures 0 self.consecutive_successes 0 self.total_requests 0 self.last_error 所有状态变更都走统一方法方便加日志。生产环境建议把状态变化打到结构化日志里将来排查问题会非常方便。3.2 轮询分发器round-robin 加故障摘除轮询分发器的核心逻辑不复杂维护一个健康实例列表用itertools.cycle循环选下一个。但光有基础轮询不够我加了两个细节。第一个细节是跳过已摘除实例。选地址时实时检查状态down的直接跳过不占用本次选路机会。这样即使健康状态更新有延迟主动探测间隔 5 秒调用时也能靠被动反馈及时止损。第二个细节是degraded 实例降权。我的实现是degraded 实例有 50% 概率被跳过让更多流量先涌向 healthy 实例。这个比例不需要太精确目的是在实例不稳定时自动把流量向健康节点倾斜。分发器的核心代码长这样import itertools import random class RoundRobinDispatcher: def __init__(self, states: dict): self.states states self._cycle itertools.cycle(states.keys()) def next(self): for _ in range(len(self.states) * 2): name next(self._cycle) state self.states[name] if state.status down: continue if state.status degraded and random.random() 0.5: continue return state return Nonenext()返回None的情况表示所有实例都不可用调用方此时不应该继续重试直接向上抛异常。这里有个边界值需要体会一下itertools.cycle是无限循环所以必须有循环上限否则全挂了之后会死循环。len(self.states) * 2这个上限保证至少把全列表扫过两遍足够做出判断。3.3 失败重试策略退避算法与可重试错误判定重试策略设计里最重要的不是“重试几次”而是“什么错误值得重试”。我按错误类型分类处理而不是一刀切错误类型是否重试原因连接错误ConnectionError、超时重试实例可能临时不可用或网络抖动HTTP 408 / 429重试请求超时或限流换实例通常能缓解HTTP 500 / 502 / 503重试Ollama 内部临时故障或过载HTTP 400 / 404 / 422不重试请求参数错误重试只会浪费资源业务层校验异常不重试问题在调用方不在服务端重试次数我定为 2 次整体最多发起 3 次尝试因为底层已经有健康检查和故障摘除了如果连着 3 次都失败大概率是全局性问题再多试也只是给系统添乱。重试间隔使用指数退避加抖动。指数退避保证重试不会像暴雨一样砸向刚恢复的实例抖动防止两个重试请求同时打过来import time import random def retry_interval(attempt: int) - float: base 0.5 * (2 ** attempt) jitter random.uniform(0, 0.3) return base jitter第一次重试等0.5s 抖动第二次等1.0s 抖动整体节奏在“快速恢复”和“避免雪崩”之间取了个折中。重试时一个关键约束是每次重试必须换一个实例。前面请求已经打到实例 A 并超时了重试还打实例 A大概率还是超时。我用了一个简单的技巧重试时跳过当前实例的时间戳让分发器在下一轮选择时自然避开它。实现上就是在分发器里加一个cooldown_until字段被标记冷却的实例在冷却窗口内不参与轮询。3.4 类封装代码一次看完整实现把上面的模块串起来最终封装成一个OllamaClient类对外只暴露generate()和chat()两个方法调用方完全感知不到底层的双实例、轮询和重试逻辑。核心代码框架如下import requests import threading import time import random import itertools from typing import Optional, Dict RETRYABLE_STATUS {408, 429, 500, 502, 503} class InstanceState: def __init__(self, base_url: str): self.base_url base_url self.status healthy self.consecutive_failures 0 self.consecutive_successes 0 self.cooldown_until 0.0 class HealthyChecker: def __init__(self, states: Dict[str, InstanceState], interval: float 5.0): self.states states self.interval interval def start(self): t threading.Thread(targetself._run, daemonTrue) t.start() def _run(self): while True: for name, state in self.states.items(): try: resp requests.get( f{state.base_url}/api/tags, timeout(2, 2), ) if resp.status_code 200: self._mark_success(state) else: self._mark_failure(state) except Exception: self._mark_failure(state) time.sleep(self.interval) def _mark_success(self, state: InstanceState): state.consecutive_failures 0 state.consecutive_successes 1 if state.consecutive_successes 2 and state.status down: state.status healthy elif state.status down: state.status degraded def _mark_failure(self, state: InstanceState): state.consecutive_failures 1 state.consecutive_successes 0 if state.consecutive_failures 3: state.status down elif state.consecutive_failures 1: state.status degraded class OllamaClient: def __init__(self, base_urls: list): self.states {url: InstanceState(url) for url in base_urls} self._dispatcher itertools.cycle(base_urls) self._checker HealthyChecker(self.states) self._checker.start() self.session requests.Session() adapter requests.adapters.HTTPAdapter(pool_connections10, pool_maxsize64) self.session.mount(http://, adapter) def _pick(self) - Optional[InstanceState]: for _ in range(len(self.states) * 2): url next(self._dispatcher) state self.states[url] if state.status down: continue if time.time() state.cooldown_until: continue if state.status degraded and random.random() 0.5: continue return state return None def generate(self, prompt: str, model: str qwen2.5:14b, **kwargs): last_exc None for attempt in range(3): state self._pick() if state is None: raise RuntimeError(all ollama instances are unavailable) state.cooldown_until time.time() attempt * 2.0 payload {model: model, prompt: prompt, stream: False, **kwargs} try: resp self.session.post( f{state.base_url}/api/generate, jsonpayload, timeout(3, 180), ) state.total_requests 1 if resp.status_code 200: state.consecutive_failures 0 state.consecutive_successes 1 return resp.json() if resp.status_code in RETRYABLE_STATUS: state.consecutive_failures 1 last_exc RuntimeError(fHTTP {resp.status_code}) continue resp.raise_for_status() except (requests.ConnectionError, requests.Timeout) as exc: state.consecutive_failures 1 last_exc exc continue if attempt 2: time.sleep(0.5 * (2 ** attempt) random.uniform(0, 0.3)) raise RuntimeError(follama request failed after 3 attempts: {last_exc})这个封装有几个值得注意的设计requests.SessionHTTPAdapter必须用否则每次请求新建 TCP 连接高并发下光是握手开销就把吞吐吃掉一大块。pool_maxsize64给足并发余量。每次调用结束后我都会更新consecutive_failures和consecutive_successes因为真实调用的反馈比主动探测更快、更准。超时我拆成了两个值连接超时 3 秒读取超时 180 秒。模型推理本身就很慢读取超时设太短会误杀正常请求但连接超时必须短否则实例挂了调用方会傻等。关于cooldown_until这里说明一下我让重试时给失败实例加一个冷却时间这样重试请求会自动绕过它。attempt * 2.0表示第 1 次重试冷却 0 秒立刻允许恢复选路第 2 次重试冷却 2 秒强制跳过该实例这个策略更像生产环境的表现而不是固定死板。4. 吞吐压测数据说话4.1 压测脚本设计与并发模型调用层写好了不压测心里没底。我给这套系统设计了一轮专门针对双实例轮询分发的压测目标不是跑出好看的数而是回答一个问题客户端轮询到底能不能把双卡利用率真正打上去压测脚本用concurrent.futures.ThreadPoolExecutor控制并发。线程模型比异步模型更贴合生产场景因为我的调用层内部用了requests同步库改成 asyncio 还需要整体换httpx改动面太大收益有限。测试参数设置如下固定问题集准备 30 个长度相近的真实业务问题保证输入侧 token 数量差异不要太大。输入长度控制平均约 120 tokens。输出长度控制num_predict256禁止模型自由发挥导致每个请求耗时方差过大。并发梯度2、4、8、16、24、32 六档每档跑 60 秒。流量特征每个线程独立从问题集随机抽取模拟无状态请求。压测脚本的大体结构如下import time import random import threading from concurrent.futures import ThreadPoolExecutor, as_completed questions [...] # 30 个真实问题 client OllamaClient([http://127.0.0.1:11434, http://127.0.0.1:11435]) def worker(_): prompt random.choice(questions) start time.perf_counter() try: result client.generate(prompt, num_predict256) latency time.perf_counter() - start return {ok: True, latency: latency, tokens: 256} except Exception: latency time.perf_counter() - start return {ok: False, latency: latency, tokens: 0} def run_concurrency(n): futures [] latencies [] errors 0 with ThreadPoolExecutor(max_workersn) as pool: for _ in range(50): # 预投一批任务 futures.append(pool.submit(worker, None)) end_time time.time() 60 while time.time() end_time or futures: done [f for f in futures if f.done()] for f in done: futures.remove(f) res f.result() if res[ok]: latencies.append(res[latency]) else: errors 1 futures.append(pool.submit(worker, None)) if not futures: break return latencies, errors压测期间我同时开着nvidia-smi -l 1盯着两张卡的利用率这是判断“轮询分发是否生效”最直接的证据。4.2 关键指标QPS、TTFT、TPOT 怎么算压测报告不能只看一个 QPS延迟分布和吞吐量要配合起来看。我这个场景里最关心的指标有三个QPS每秒请求数总成功请求数除以压测总时长。反映系统整体吞吐能力。计算公式为总请求数 / 总耗时压测脚本里可以直接用完成的 future 数量除以 60 秒但严谨一点应该统计实际压测窗口内的数据。端到端延迟单次请求从发出到拿到完整响应的时间。由于模型推理耗时占大头非流式场景下它约等于“首 token 延迟 生成 256 tokens 的总耗时”。延迟分位数P50 / P95 / P99把所有请求延迟排序后取对应百分位。P50 是大多数用户的体感P95 和 P99 决定系统在尾延迟上的表现。大模型推理的延迟波动很大只看 P50 会被骗P99 才能真正暴露系统扛高并发时的问题。我在每一档并发跑完之后直接把延迟列表排序手工算出 P50、P95、P99并记录失败请求数。整个测试过程大概两个多小时中间穿插了显存和 GPU 利用率的采样记录。4.3 一组实测数据与瓶颈分析下面这组数据来自我实际压测一轮的结果模型是 14B 量化版输入约 120 tokens输出固定 256 tokens双实例各绑定一张 A100 80GB。并发数总请求数QPSP50 延迟 (s)P95 延迟 (s)P99 延迟 (s)失败数2340.573.43.94.204671.123.54.14.5081322.203.64.65.10162383.974.05.36.20242684.475.48.110.81322744.575.910.315.65看到这组数据时第一反应是并发到 24 以后 QPS 不再线性增长反而 P99 延迟暴涨。我盯着 nvidia-smi 的输出分析了一番两张卡的利用率在并发 16 时接近对称都到了 80% 左右并发 24 时已经双双接近满负荷但 Ollama 排队机制导致的请求积压开始显现——GPU 利用率没到瓶颈瓶颈出在 Ollama 内部的调度等待上。换一个角度看单实例并发 8 的 QPS 我实测大概 1.2 左右双实例并发 16 到 3.97提升了 3 倍多不只是线性翻倍。这里有个细节值得说一下单卡推理时单实例内部其实也会对请求做排队两个并发请求共享同一个模型的 KV cache存在资源竞争拆成两个实例后每个请求独享一个模型的完整 KV cache避免了同类竞争所以吞吐提升超过了 2 倍。并发超过 16 之后的失败数不为 0主要是读取超时造成的。这是因为 Ollama 单实例内部的并发排队深度有限当队列塞满、请求迟迟无法获得推理资源时读取超时便触发了。这里暴露出的问题不是调用层设计缺陷而是单实例并行能力的上限属于物理边界。4.4 压测中容易踩的坑第一坑首次压测先不预加载模型。我把OLLAMA_KEEP_ALIVE-1设置好之后就觉得万事大吉但刚开始压测的数据极其难看首 token 延迟动不动上 10 秒。后来排查发现第一次并发打上去时模型正在从磁盘加载进显存所有请求全在等加载完成。这个问题到了生产环境同样存在服务重启后的第一波请求必然高延迟。解决办法就是压测前先跑一次预加载手动确认模型处于 loaded 状态再开始压。第二坑固定温度等采样参数。一开始压测时我没设temperature模型每次都随机采样输出 token 数量差异很大延迟方差爆炸数据根本没法对比。压测必须固定采样参数最好连seed也固定否则结果不可复现。第三坑并发线程数和实例数的关系。双实例场景下压测脚本一开始只开了 2 个线程结果发现其中一个实例始终没有流量另一个被轮询反复选中。这不是我的轮询算法有 bug而是并发线程数太少itertools.cycle在低并发下恰好不稳定分布。实际压测时并发应该设为实例数的整数倍比如双实例就从 8 起步让流量均匀散开。第四坑只看 QPS 不看 P99。第一次出结果时 QPS 到并发 24 还在涨我差点以为能一路涨到 40 并发。结果 P99 从 6 秒直接跳到 10 秒这个数在生产环境绝对不可接受。大模型服务的容量规划一定要把 P99 作为硬指标QPS 增长不能以牺牲尾延迟为代价。5. 常见问题与实战排障5.1 请求全打到一张卡上的原因与排查分布式系统最有意思的问题就是“看起来在均衡实际一点都不均衡”。我遇到过一种情况轮询分发器逻辑上每次选路都不同但实际请求全砸在同一张卡上。排查下来发现根因在两个地方。一是itertools.cycle的选路依赖于健康状态。如果某个实例的连续失败计数一直没清零状态停留在 degraded降权逻辑会让 50% 的请求跳过它流量自然全部倾斜到另一个实例。这种情况的典型特征是nvidia-smi 显示一张卡利用率 90%另一张卡只有 20%。解决方法是检查两个实例的/api/tags响应时间如果响应时间差距大问题可能在实例自身的负载上而不是调用层。二是连接池绑定问题。requests.Session默认会为每个 host 维护独立连接池但如果我在代码里不小心把两个 base_url 写成了同一个地址session 自然只会复用一个实例的连接。这个问题低级但隐蔽排查时可以先打印client._dispatcher的初始列表确认地址没有写错。5.2 Ollama 偶发 5xx 的边界与处理压测时经常看到偶发 5xx尤其是请求积压时出现 502、503。这里要区分两个层面Ollama 本身的错误和调用层的处理逻辑。Ollama 返回 502 的常见原因是上游模型推理超时或内部 pipe 关闭。503 则多是因为模型正在加载、卸载或请求队列溢出。这些错误在我的调用层里都被归类为可重试错误重试时会自动换到另一个实例。但有一个坑如果两个实例同时处于过载状态重试其实没有意义只会让两个实例的负载同时更高。我的处理策略是在调用层检测到连续失败实例数大于 0 时重试间隔动态拉长。具体实现是在retry_interval函数里加一个全局失败率参数失败率越高退避越激进。这也是压测中发现的一个改进点目前实现还比较朴素但思路是对的。另外一个更隐蔽的问题Ollama 生成类请求偶尔会返回 200 但 body 是错误信息。这种响应在resp.status_code 200时直接返回给调用方了业务侧拿到的可能是一个error字段。所以我建议在生产调用代码里加一层 body 解析校验response[error]存在就视为调用失败。这个校验在本地测试时不容易暴露因为单机稳定运行错误率低但生产流量大了之后很容易触发。5.3 超时怎么设置才不冤枉系统超时设置是调用层设计里争议最大的参数。设置太短模型推理稍慢一点就被中断重试次数暴增设置太长实例真正卡死时调用方也傻等半天。我的经验分成几个维度连接超时固定 3 秒。这是 TCP 握手加 HTTP 请求投递的时间正常局域网环境应该远低于这个值。超过 3 秒基本可以断定实例不可达或网络异常。读取超时对应首 token 的等待时间加推理时间。非流式模式下要按“最大输出 tokens / 最慢每秒生成速度”来估算。我这边 14B 模型在 A100 上生成速度约每秒 60-80 tokens256 tokens 最多 5 秒就能生成完但 Ollama 本身有排队等待所以我给了 180 秒的上限。这个值看着很宽松实际上是给极端排队场景留的余量。健康检查超时固定 2 秒。健康检查是轻量接口正常响应在毫秒级超过 2 秒说明实例负载已经很高了应该标记为不健康。超时设置的最终原则是连接超时从严读取超时从宽健康检查超时独立设置。很多人把整体请求超时设成一个值结果要么误杀正常请求要么故障时响应极慢两边不讨好。5.4 重试带来的幂等性隐患重试机制有一个大模型服务里特别容易被忽略的隐患重试会产生重复请求但生成类接口天然不具备幂等性。同一个 prompt 重试两次会得到两份内容不同的结果如果调用方把结果直接写入数据库或发给下游会造成重复数据。这个问题我在设计调用层时没有完全解决只能通过业务侧配合来规避。两个可行的方向一是调用方传入唯一的 request_idOllama 侧对同一个 request_id 做去重但这要求 Ollama 本身支持幂等能力目前原生版本并不具备。二是把重试语义降到“链路层”如果第一次请求已经发出并成功进入模型推理只是响应超时这时候重发是危险的但如果第一次请求根本没发出去连接错误重发是安全的。这个逻辑我目前是通过判断异常类型来粗略区分的requests.ConnectionError在请求发出前就会抛出而requests.Timeout则不一定。所以在重试条件里我把两类错误做了不同程度的放宽连接错误可以重试超时错误会在第二次尝试时打上标记由业务侧决定是否接受重复结果。这个问题不完美但属于生产环境里必须正视的现实。轮询分发的价值在于让双卡利用率最大化重试存在的意义是保证请求最终成功二者叠加之后幂等性这个坑需要往上追溯提前和业务方打招呼。最后一点实操体会这套双实例加客户端轮询的方案核心价值一句话就能概括用调用层的 100 行代码换来硬件利用率和系统可用性的双重提升而且整个过程是可观测、可控制的。相比把负载均衡交给代理层或者依赖 Ollama 自身的多卡机制客户端方案让每个请求的去向都有日志可查、有状态可看、有策略可调。我个人在实际操作中最深的一个体会是压测不是测试工作的收尾而是生产配置的开始。压测数据直接决定了我的超时参数、重试次数、健康检查阈值该设成多少也证明了双实例模式在吞吐上确实能达到单实例的 3 倍以上。如果没有这轮压测这些参数大概率会在生产环境里被流量惩罚之后才被动调整代价要高得多。如果你也在搞 Ollama 生产化建议先从双实例部署和这个调用层起步把健康检查、轮询、重试三件事吃透再逐步加监控、加指标、加容量规划。这套东西跑稳之后你会明显感觉到大模型服务其实和其他后端服务一样没有什么神秘魔法无非是把每一个请求的路径管好把每一个失败的原因搞清楚。