如果你手里也有两张 A100也正在用 Ollama 对外提供模型服务那你八成遇到过这几个问题明明两张卡都插着实际跑起来只有一张在工作请求稍微多几个就排队偶尔超时一次整个业务跟着报错想扩容又不想立刻上复杂框架只能用 Python 继续扛。这篇文章就把我最近在双卡 A100 环境里做的一套生产调用方案完整拆开讲包括轮询分发怎么写、失败重试怎么做、健康检查怎么探以及压测数据怎么解读。适合那些把 Ollama 当推理服务用、但还没把调度和稳定性补起来的团队尤其是 Python 后端为主、不想轻易换技术栈的场景。1. 背景双卡 A100 上的 Ollama生产链路里真正难的是什么1.1 先交代场景单机双卡、70B 量化模型先说我的环境一台双卡 A100 的服务器每张卡 80G 显存跑的是一个 70B 级别的量化开源模型。业务的调用方是七八个 Python 服务有的走 RAG 流程有的做多轮对话还有定时任务在夜间批量生成内容调用形态五花八门。刚开始部署 Ollama 很顺利ollama serve起一个进程ollama run测一下能跑通就觉得完事了。真正拿到生产环境立刻变味。第一个问题是显存规划70B 量化模型加载后单卡放不下Ollama 默认会按需把模型加载到一张卡上如果显存不够会尝试往第二张卡扩展。听起来没问题但实际观察下来跨卡加载之后模型权重会跨 NVLINK 通信推理延迟比单卡完全加载要高不少而且一旦模型加载到两张卡上Ollama 的调度行为就不那么透明了。第二个问题是并发。Ollama 的 HTTP 接口对外暴露得很简单/api/generate、/api/chat、/v1/chat/completions都有然而服务端默认情况下对同一个模型的并发处理并不是想当然的“来多少处理多少”。它内部有自己的排队和调度逻辑没有调好之前高并发下一个小请求可能被排在长任务后面等到超时客户端就报错了。所以我后来决定不在 Ollama 自带能力上做太多文章而是把调度层拿到 Python 侧自己做。双卡 A100 在这里最大的价值就是可以拆成两个独立推理实例用一套 Python Router 在业务和 Ollama 之间做轮询分发顺带把重试、健康检查、压测这些生产必备的东西补齐。1.2 生产调用要过的三关调度、稳定性、可观测很多人觉得生产调用无非就是requests.post一下其实真正落地要过的关卡有三道。第一道是调度。多实例存在时请求到底发给谁如果固定只打一个实例另一张卡就是摆设如果随机打一旦某个实例处于加载模型或者满载状态这个请求的响应时间会异常。生产场景下请求特征不是均匀的有时候连续来一堆长上下文有时候全是短请求调度策略必须能扛住这种波动。第二道是稳定性。Ollama 实例会崩溃吗我遇到过。显存不足时进程直接崩溃或者加载新模型把旧模型从显存里挤掉导致正在跑的请求突然报错。这种情况下 Python 侧不能把错误直接抛给业务方得有重试、超时、熔断这一套。第三道是可观测。你得知道每个实例现在是否健康、最近一次请求耗时多少、连续失败几次。没有这些数据出了故障只能靠猜。这篇文章后面所有内容都是围绕这三道关卡展开的。2. 方案选型为什么是轮询分发而不是直接压一个实例2.1 Ollama 自身的并发机制先搞清楚它怎么排队在决定自己做调度之前我先把 Ollama 服务端的并发机制摸了一遍。Ollama 是 Go 写的对外提供 HTTP 接口内部有一个调度器负责把请求路由到已经加载的模型实例上。当同一个模型收到多个请求时Ollama 会尽量复用已经加载的模型上下文而不是为每个请求单独加载一份。然而“尽量复用”不等于“无限并发”。Ollama 加载模型后是否允许同一个模型同时处理多个请求受几个环境变量影响OLLAMA_NUM_PARALLEL控制同一个模型最多并行处理的请求数默认值是 1 或根据显存自动估算OLLAMA_MAX_LOADED_MODELS控制同时最多加载几个模型OLLAMA_SCHED_SPREAD控制多卡场景下模型是否分散到多张卡。这些参数直接决定了服务端行为但问题在于即使你把OLLAMA_NUM_PARALLEL调大了真正跑到高并发时一张卡上模型计算的资源争抢会导致每个请求都变慢整体吞吐并没有线性提升。我做过一个实验单实例双卡、OLLAMA_NUM_PARALLEL4并发打 8 个请求结果每个请求的 TTFT首 token 时延从 200ms 飙升到 3 秒以上整体 QPS 也没有明显变好。原因就是 GPU 算力是固定的并行处理请求只会让计算切片单张卡的计算单元被多个请求瓜分。2.2 单实例双卡和双实例轮询怎么选既然单实例并行效果一般那多卡怎么用我对比过两种方式。方式一是单实例双卡也就是只启动一个 Ollama让模型自己分布在两张卡上配合OLLAMA_NUM_PARALLEL做并行。这种做法的好处是部署简单一个端口搞定但缺点是调度黑盒你很难控制某个请求具体落在哪张卡而且跨卡通信会拖慢速度。一旦模型体积接近两张卡总显存上限Ollama 容易因为显存碎片导致莫名其妙的重载。方式二是双实例轮询也就是每张卡启动一个独立的 Ollama 进程分别绑定不同的端口和 GPU模型完整加载到单卡上。Python 侧维护一个实例列表按轮询顺序把请求分别发给两个实例。这种做法表面上看多了一层路由但好处非常明显每个实例自己独占一张卡显存规划干净模型不会跨卡通信单实例崩溃不会拖垮全部流量。我最后选了双实例轮询。这里有个前提模型量化后能完整塞进单张 80G A100。如果你的模型单卡放不下那只能单实例多卡但依然建议在 Python 侧做限流和队列不要让 Ollama 自己硬扛。2.3 轮询不是最优但对这个场景足够稳轮询分发在负载均衡算法里算是最简单的一种比最少连接数、加权轮询、一致性哈希都简单。我为什么没用更复杂的因为当前场景有两个特点第一两个实例配置完全相同模型也一样处理能力基本对称第二请求本身是无状态的不存在必须粘滞到某个实例的需求。这种场景下轮询已经能达成最好的资源利用率加了复杂算法反而增加出问题的地方。当然轮询最大的问题是它不感知实例的实际负载。如果实例 A 还在加载一个长上下文请求实例 B 已经空闲了轮询依然会机械地轮流发导致 A 堆积。所以我后面补了健康检查和连续失败摘除相当于给轮询加了一个“自适应感知”的外壳让它在正常情况下保持简单在异常情况下能做出反应。这是我觉得最务实的组合。3. 轮询分发实现一个 Python Router 把调用路由到两台 Ollama3.1 初始版本线程安全的 RoundRobin先给一个最核心的轮询 Router 实现。这个类不依赖任何第三方库只用了标准库的threading和itertools方便你直接抄到现有项目里。import itertools import threading import time import requests class OllamaRouter: def __init__(self, instances): instances: [{name: ollama-1, base_url: http://127.0.0.1:11434}, {name: ollama-2, base_url: http://127.0.0.1:11435}] self.instances instances self._lock threading.Lock() self._counter itertools.cycle(range(len(instances))) self._healthy {inst[name]: True for inst in instances} self._fail_count {inst[name]: 0 for inst in instances} def _next_instance(self): with self._lock: idx next(self._counter) return self.instances[idx] def chat(self, payload, timeout(10, 120)): inst self._next_instance() url inst[base_url] /api/chat resp requests.post(url, jsonpayload, timeouttimeout) resp.raise_for_status() return resp.json()这段代码的思路很简单itertools.cycle维护一个无限循环的索引每次调用取下一个实例threading.Lock保证多个 Python 线程同时调用时索引递增是原子的。这里要强调一下Python 的 GIL 虽然让很多操作天然有互斥性但next(self._counter)这类复合操作在并发下仍可能出问题显式加锁是最省心的做法。timeout参数我建议显式传不要依赖 requests 默认值。连接超时和读超时要分开设连接超时短一点比如 5~10 秒读超时长一点因为大模型生成几十秒是正常的。生产环境里我曾经因为没设读超时导致一个请求卡在 Ollama 侧 3 分钟把整个线程池都占满了。3.2 比轮询多一点实例摘除与恢复纯轮询的缺陷上文提到了实例故障时会把请求打到坏实例上。我加了一个简单机制连续失败次数到达阈值就把实例标记为不健康不再参与轮询同时启动一个后台线程定期探测实例恢复后再重新放回轮询池。def _mark_failure(self, inst): self._fail_count[inst[name]] 1 if self._fail_count[inst[name]] 3: with self._lock: self._healthy[inst[name]] False def _mark_success(self, inst): self._fail_count[inst[name]] 0 with self._lock: self._healthy[inst[name]] True def _next_healthy_instance(self): for _ in range(len(self.instances)): inst self._next_instance() if self._healthy.get(inst[name], False): return inst return None def chat_with_failover(self, payload, timeout(10, 120)): inst self._next_healthy_instance() if inst is None: raise RuntimeError(no healthy ollama instance) try: result self.chat(payload, timeouttimeout) self._mark_success(inst) return result except requests.exceptions.RequestException: self._mark_failure(inst) # 尝试下一个实例 for _ in range(len(self.instances) - 1): inst self._next_healthy_instance() if inst is None: break try: result self.chat(payload, timeouttimeout) self._mark_success(inst) return result except requests.exceptions.RequestException: self._mark_failure(inst) raise实现里有一个小技巧_next_healthy_instance在轮询池里最多循环完整一圈避免出现“轮了一轮全都健康但一个都没取到”的极端情况。连续失败阈值我取 3太低容易因为一次网络抖动就摘除实例太高会让故障实例多扛几次请求。实测下来 3 次是个比较平衡的值。恢复探测我单独放在后面的健康检查章节里讲这里只说一下架构上要注意的摘除状态一定要加锁保护因为后台探测线程和业务线程会同时读写_healthy字典。获取实例和标记状态不要在同一个锁里做重操作否则高并发下锁竞争会很明显。3.3 时间戳、队列深度、粘滞会话要不要考虑有同事问过我轮询里要不要记录每个实例最近一次请求的时间戳或者统计排队长度这样请求可以打到“最闲”的实例。理论上很好实际上我放弃了。原因有两个第一Ollama 服务端本身有排队逻辑客户端统计到的“最近请求时间”不能代表服务端的真实负载可能最后一个请求刚发完服务端还在处理一个长上下文第二要做这种自适应调度就得维护滑动窗口、计算平均响应时间代码复杂度上去了收益却不大因为双实例对称场景下轮询已经能拉平负载。粘滞会话要不要做如果业务层有会话状态比如多轮对话的上下文 id理论上可以把同一个 session 的请求始终发到同一个实例利用 Ollama 的连续请求优化。但实际中 Ollama 会对每个请求重新走 tokenization 和 prefill多轮对话的历史是通过 messages 数组传过去的不是靠服务端保存 KV cache所以粘滞的意义不大。除非你在调用/api/chat时显式传了keep_alive且依赖长驻模型那才是另一个话题。这里我建议把 Router 保持纯粹只做分发、失败标记、摘除不要塞业务逻辑。业务逻辑比如 prompt 构造、响应解析、上下文管理放在 Router 外面这样 Router 可以复用静态方法也方便单测。4. 失败重试你不能假设 Ollama 永远一次成功4.1 错误分类先知道哪些才值得重试重试不是无脑把同样的请求再发一遍。如果错误类型不对重试只会加重服务端压力。我按经验把 Ollama 调用里的异常分成三类。第一类是可以安全重试的网络连接失败、读超时、HTTP 500 / 502 / 503。这些属于服务端暂时过载或进程不稳定等几百毫秒再试一次通常能成功。第二类是不能重试的HTTP 400 参数错误、401 鉴权失败、404 路径不存在。这类错误说明请求本身有问题重试一万次结果一样应该直接抛给上层调用方去修 bug。第三类是要谨慎重试的HTTP 429 限流以及 Ollama 返回的业务错误比如模型未加载、显存不足。429 可以等一小段时间再试但要控制次数否则会放大服务端压力。显存不足这类错误重试前最好先确认其他实例是否健康或者触发实例的模型卸载逻辑而不是同一个实例上死磕。代码上的处理比较好做的是封装一个自定义异常把可重试和不可重试区分开class OllamaError(Exception): def __init__(self, message, retryableFalse): super().__init__(message) self.retryable retryable class OllamaUnavailable(OllamaError): def __init__(self, message): super().__init__(message, retryableTrue) class OllamaBadRequest(OllamaError): def __init__(self, message): super().__init__(message, retryableFalse)4.2 指数退避与抖动代码实现可重试错误要重试但不能立刻重试也不能固定间隔重试。立刻重试会导致多个请求同时打在故障实例上形成“重试风暴”固定间隔则会让服务端负载出现周期性尖峰。指数退避加抖动是标准做法。import random import time def retry_with_backoff(func, max_retries3, base_delay0.5, max_delay10.0): for attempt in range(max_retries): try: return func() except OllamaUnavailable as e: if attempt max_retries - 1: raise delay min(max_delay, base_delay * (2 ** attempt)) jitter random.uniform(0, delay * 0.3) time.sleep(delay jitter)这里的base_delay0.5表示第一次重试前等约 0.5 秒第二次约 1 秒第三次约 2 秒每次叠加一个 30% 以内的随机抖动。抖动很关键它可以打散多个线程在同一时刻重试的节奏。max_retries3是我压测后觉得比较合适的次数再多意义不大因为如果服务端持续过载等 10 秒和重试 5 次都是一样的结果不如尽快返回错误给上层让上层决定是否走降级逻辑。有一点要提醒重试函数要放在 Router 层调用还是在业务层调用我建议放在业务层和 Router 之间的一个 service 层。Router 负责选实例和快速失败切换service 层负责真正的重试策略。因为 Router 的失败切换本身就相当于一次重试再包一层重试可能会导致同一个请求被打到多个实例上各执行一次浪费算力。但如果你的业务对最终结果有强一致要求必须确保至少执行一次那就要在业务层做幂等标记避免重试导致生成结果重复。4.3 连续失败熔断别把重试变成雪崩重试到了上限之后还不应该立即返回错误而是要考虑熔断。熔断的思路是如果一个实例在短时间内连续失败次数超过阈值就把它从路由池里摘掉并且在一段时间内不再发请求给它而是让所有流量打到健康实例上。等冷却时间过了再放少量探测请求看它是否恢复。我在 Router 里加过摘除逻辑其实那就是一个轻量熔断。完整一点的做法是半开状态冷却期到了之后放一个请求进去如果成功就恢复如果失败就继续冷却。Python 里不需要引入复杂框架用一个time.time()记录冷却截止时间就够了。self._cooldown_until {inst[name]: 0 for inst in instances} def _is_in_cooldown(self, inst): return time.time() self._cooldown_until.get(inst[name], 0) def _enter_cooldown(self, inst, duration30): self._cooldown_until[inst[name]] time.time() duration冷却时间我一般设 30 秒。如果 Ollama 实例是进程崩溃后由 systemd 自动拉起的30 秒足够完成重启和模型加载。如果模型加载需要几分钟比如 70B 模型从磁盘加载冷却时间就要相应拉长否则探测请求会频繁失败导致熔断和恢复之间来回抖动。5. 健康检查两层探活而不是只 ping 一下端口5.1 基础健康检查/api/ps 能看出什么对 Ollama 做健康检查第一反应是请求GET /api/ps或者/v1/models看返回码是不是 200。但这是最基础的探活只能说明 Ollama 进程还活着HTTP 服务还能响应不能说明模型是可用的。曾经有一个事故Ollama 进程没挂/api/ps也正常返回但实际上模型在加载过程中被 OOM 杀掉显存里只剩残留的进程业务请求一直报“model not loaded”。所以基础探活必须有但不能只有它。/api/ps返回内容里有一个关键字段是正在加载的模型列表以及它们占用的显存。健康检查可以解析这个接口确认目标模型名称出现在列表里。如果模型不在列表里说明调用时需要重新加载而重新加载可能耗时几十秒前面的请求都会排队甚至超时这种情况应该让 Router 暂时少往这个实例发请求。5.2 业务健康检查用最小请求验证服务真正可用第二层健康检查我会用一个固定的小 prompt 真实调用一次生成接口验证模型确实能推理。这个调用不需要等完整生成可以设置streamTrue只要拿到第一个 token 就认为服务正常然后立刻关闭连接。这样做的好处是能验证模型是否加载、显存是否够用、生成链路是否通坏处是会给正常业务穿插一点额外负载。我在实际中是这样处理额外负载的健康检查频率不用太高每 10 秒一次prompt 很短比如“hi”streamTrue模式下一个 token 几乎瞬时返回设置keep_alive5m让模型保持驻留。这样一个健康检查请求大概只会让实例增加几毫秒的 prefill 负担基本可以忽略。def health_check(instance): try: url instance[base_url] /api/chat resp requests.post( url, json{ model: your-model-name, messages: [{role: user, content: hi}], stream: True, keep_alive: 5m, }, streamTrue, timeout(5, 15), ) if resp.status_code ! 200: return False # 读取第一个 chunk 即可 for line in resp.iter_lines(): if line: return True return False except requests.exceptions.RequestException: return False这里有个细节resp.iter_lines()在流式模式下会持续阻塞等待新数据所以健康检查线程不能只读一行就一直等到生成完。解决方法是拿到第一行后立刻resp.close()把连接释放掉。如果不关闭连接会一直被占着健康检查线程多了之后反而拖垮服务端。5.3 keep_alive 与健康检查的联动健康检查结果直接影响调度而keep_alive参数决定了一个实例上模型能不能长期驻留。这两个必须联动起来看。Ollama 默认keep_alive是 5 分钟意思是一个模型空闲 5 分钟之后会被从显存里卸载。如果业务流量有潮汐现象高峰一过模型就被卸载了下一次健康检查时模型不在内存里检查结果自然就是失败。为了避免这种情况我在所有业务调用和健康检查里都显式传了keep_alive30m或者更长让模型在高峰期保持驻留。但你也不能把keep_alive设成无限大因为长驻模型占着显存如果需要加载第二个模型显存不够就会把第一个挤掉。一个实测场景白天跑对话模型夜里跑批量生成模型两个模型加起来超过单卡显存结果频繁互相挤掉健康检查一会儿失败一会儿成功Router 在摘除和恢复之间反复横跳。后来我在部署上做了区分白天只允许对话模型实例对外服务夜间任务走另一个实例池彻底避免模型竞争。6. 吞吐压测用纯 Python 场景脚本把真实水位测出来6.1 压测脚本的思路压测不是拿curl戳两下就算完。我用 Python 写了一个简单的压测脚本核心用ThreadPoolExecutor控制并发线程数每个线程循环发送请求记录响应时间最后汇总统计数据。from concurrent.futures import ThreadPoolExecutor import time import statistics import requests def send_one(prompt, base_url): start time.perf_counter() resp requests.post( base_url /api/chat, json{ model: your-model-name, messages: [{role: user, content: prompt}], stream: False, options: {num_predict: 256}, }, timeout(10, 300), ) elapsed time.perf_counter() - start data resp.json() prompt_tokens data.get(prompt_eval_count, 0) eval_tokens data.get(eval_count, 0) return elapsed, prompt_tokens, eval_tokens def run_load_test(base_urls, concurrency, requests_per_thread10, prompt写一段二百字的产品介绍): total_requests 0 total_tokens 0 latencies [] errors 0 start_wall time.perf_counter() def worker(base_url): nonlocal total_requests, total_tokens, errors for _ in range(requests_per_thread): try: elapsed, p_tok, e_tok send_one(prompt, base_url) total_requests 1 total_tokens p_tok e_tok latencies.append(elapsed) except Exception: errors 1 with ThreadPoolExecutor(max_workersconcurrency) as pool: for base_url in base_urls: pool.submit(worker, base_url) wall_time time.perf_counter() - start_wall qps total_requests / wall_time tps total_tokens / wall_time avg_latency statistics.mean(latencies) if latencies else 0 p95_latency sorted(latencies)[int(len(latencies) * 0.95)] if latencies else 0 print(f并发{concurrency} 总请求{total_requests} 成功{total_requests} 失败{errors} fQPS{qps:.2f} TPS{tps:.2f} 平均时延{avg_latency:.2f}s p95{p95_latency:.2f}s)注意一个坑如果每个线程的循环是无限跑的话需要用as_completed或者别的调度方式我这里直接用requests_per_thread限制每个线程的请求数保证压测能按时结束。另外send_one里加了num_predict: 256控制生成 token 长度这样每次请求的耗时才有可比性。实际压测场景中你还要考虑 prompt 长度、输入上下文大小最好准备几档不同的样例来测。6.2 三组关键指标TTFT、TPOT、QPS压测最容易犯的错误是只盯着 QPS忽略用户真正感受到的指标。我通常分成三个维度看TTFTTime To First Token从发送请求到收到第一个 token 的耗时代表用户等待的“响应感”。Ollama 非流式接口很难直接测 TTFT必须用streamTrue模式在iter_lines()里对第一行计时。TTFT 变差通常是因为 prefill 太长或者排队太长。TPOTTime Per Output Token生成每一个 token 的平均耗时代表生成速度。TPOT 可以通过eval_count / (elapsed - first_token_time)算出来也可以用非流式接口的eval_count / eval_duration近似。它反映的是 GPU 推理速度主要受模型大小、量化方式、并发抢占影响。QPSQueries Per Second每秒钟完成的请求数代表系统整体吞吐能力。QPS 高不代表体验好可能在 QPS 上升的同时TTFT 已经爆炸了。所以你要同时打印这三类指标判断当前系统是“健康地忙”还是“过载地忙”。6.3 实测数据解读与调参方向我给出一次实测数据这是双实例轮询、每实例单卡加载同一个 70B 量化模型的压测结果。测试 prompt 大约 300 tokennum_predict256客户端并发线程从 1 加到 16实际部署的 Router 轮询两个实例。客户端并发TTFT均值TPOT均值QPS失败率1180ms26ms0.80%4650ms31ms1.90%81.3s42ms2.40%162.8s63ms2.20.5%数据反映了一个很典型的现象并发从 1 涨到 8 时QPS 稳步上升但 TTFT 一直在恶化并发到 16 时QPS 反而下降因为服务端排队和上下文切换开始消耗算力。TPOT 从 26ms 涨到 63ms说明 GPU 计算被并发请求瓜分每个请求的生成速度下降了。这张表告诉我们几个调参方向。第一如果业务对交互时延敏感客户端并发保持在 4~6 比较合适再多就会显著影响 TTFT。第二如果追求吞吐可以接受并发到 8 左右QPS 接近峰值。第三如果 QPS 掉头向下说明服务端已经过载继续增加客户端并发没有任何意义反而会抬高失败率。生产中可以在这个数据基础上给业务侧加一个信号量限制最大并发数避免调用方在高峰期把服务打爆。另外实测中发现双实例轮询相比单实例整体吞吐大约提升了 1.7 倍没有到理论上的 2 倍。原因可能是两个实例共用 CPU、内存、磁盘 IO加载模型和 tokenization 阶段会产生资源争抢还有一个原因是请求大小不均匀两个实例实际负载并不是完全均衡的。不过 1.7 倍已经比单实例好很多这也是我坚持双实例轮询而不是单实例撕裂并行的原因。7. 生产化必须注意的坑7.1 环境变量OLLAMA_NUM_PARALLEL、OLLAMA_SCHED_SPREADOllama 有四个环境变量值得在部署前就想清楚。OLLAMA_NUM_PARALLEL控制同一个模型并行处理请求的数量默认是 1。在双实例架构下我建议保持 1 或者小幅度调到 2不推荐调太高。因为每个实例之上Python Router 已经做了负载均衡如果每个实例内部再并行几个请求GPU 算力被瓜分会导致单请求生成速度下降整体收益很小。OLLAMA_SCHED_SPREAD控制多卡时模型是否分散加载。双实例场景下你可以不设这个变量因为每个实例已经通过CUDA_VISIBLE_DEVICES绑定了单卡模型只会加载到那一张卡上。如果只有一个实例多卡设成 1 可以让模型尽量使用多卡显存但正如前面说的跨卡通信会有损耗。OLLAMA_MAX_LOADED_MODELS默认是同时加载的模型数量上限默认按显存自动估算。如果你显存紧张建议显式设成 1避免多个模型互相挤占导致频繁卸载重载。OLLAMA_KEEP_ALIVE是默认 keep_alive 值单位秒比如OLLAMA_KEEP_ALIVE1800表示 30 分钟。建议显式设置不要依赖默认的 5 分钟。7.2 实例重启、Docker 映射与 GPU 亲和双实例部署如果直接跑在宿主机上要分别设置端口和 GPU。比如实例一用CUDA_VISIBLE_DEVICES0 OLLAMA_HOST127.0.0.1:11434 ollama serve实例二用CUDA_VISIBLE_DEVICES1 OLLAMA_HOST127.0.0.1:11435 ollama serve。如果跑 Docker--gpus device0和--gpus device1分别映射两张卡同时把不同端口映射到宿主机。这件事看似简单但很容易出错Docker 容器内nvidia-smi看到的是容器内虚拟设备编号如果不显式指定Ollama 会自动探测所有可见 GPU模型可能加载到同一张卡上另一张卡完全闲置。进程守护也要做好。我用 systemd 管理两个 Ollama 实例加了Restartalways实例崩溃后会自动拉起来。但要注意Ollama 启动后加载模型需要时间如果健康检查脚本和进程重启逻辑没有配合好会出现一种情况进程起来了模型还没加载完健康检查连续失败Router 把它隔离了然后冷却时间过后再探又失败反复循环。我建议健康检查脚本在模型加载完成之前返回一个“初始化中”的状态Router 检测到这种状态后延长冷却时间而不是立刻进入熔断。7.3 日志、监控与告警生产环境如果没有监控等于闭着眼睛开车。我给每个实例都配了日志记录记录请求开始时间、结束时间、模型名、token 数、是否成功、耗时。日志格式用 JSON方便接入日志平台。Router 侧也埋了指标每个实例的请求总数、失败数、连续失败数、平均耗时、冷却状态。监控指标比告警更重要的一点是它可以帮你发现瓶颈。比如我发现某个实例的 p95 时延比另一个高很多排查后发现是 Docker 容器 CPU 限额导致 tokenization 速度下降再比如通过连续失败指标我发现在模型即将占满显存时Ollama 会偶发超时于是调整了OLLAMA_MAX_LOADED_MODELS问题就消失了。告警规则不需要太多盯住三个就够了实例健康状态变化、错误率超过 5%、p95 时延超过 10 秒。8. 最近维护这套系统的一点感受这套双卡 A100 Ollama 的调度方案上线跑了小半年我最大的体会是做一个稳定的推理网关代码量其实不大但每一个细节都是踩坑踩出来的。轮询分发写起来不超过一百行难的是在实例故障、模型重载、高并发排队这些场景下它还能按预期工作。你如果只是开发期自己调接口这些都可以不看但只要把服务交给业务方并发一上来稳定性要求立刻就不一样了。我还想强调一个观点不要迷信把复杂的负载均衡算法堆上去。协商好的轮询加上失败剔除、冷却恢复已经能覆盖绝大多数 Ollama 单机场景。如果哪天你的规模大到需要更好的调度那时候大概率也不是在 Router 上加加权轮询能解决的了而是要引入更重量级的推理引擎或者分布式服务。在那之前用这套轻量方案顶住生产压力是性价比最高的选择。最后分享一个装完系统后马上要做的小事把 Router 和两个 Ollama 实例的启动命令、环境变量、端口映射写成一个 README 放进仓库包括压测脚本的使用方法。这个 README 会在三个月后你忘掉细节时救你一命。
阅读完成 · 觉得有帮助?