一、为什么这套方案值得认真做
很多人一看到双卡 A100,就会下意识觉得:
这还不简单吗?两张卡,性能肯定够,装上就能飞。
现实恰恰相反。
因为硬件越强,你越容易犯一个错:
误以为资源足够大,所以很多问题都可以被"蛮力掩盖"。
实际上,生产环境里最容易出问题的,从来不是"卡不够强",而是这些更基础的地方:
- 服务治理不清晰
- 端口管理混乱
- 模型加载策略不稳定
- 参数没有收敛
- 调用层分流做得很烂
- 日志、探活、重试都没做
结果就是:
- 机器配置看着很豪华
- 服务偶尔也确实很快
- 但一上压测、一上并发、一上真实流量,就开始抖
所以这套方案真正的目标,不是"让双卡亮起来",而是:
把双卡 A100 变成一套可长期稳定提供本地推理服务的生产节点。
二、项目目标:不是能跑,而是能上线
这次方案的目标,最终收敛成了四句话:
1. 服务要稳定
- 开机自启
- 异常自动拉起
- 日志可追踪
- 进程状态可观测
2. 模型要稳定
- 模型目录权限正确
- 模型支持常驻
- 避免反复冷启动
- 支持开机自动预热
3. 双卡要真正吃起来
- 不是一张卡忙、一张卡闲
- 不是"理论双卡",而是"实际双实例分流"
- 请求要能均匀打到两个端口
4. 参数要可收敛
- 吞吐优先,而不是参数堆满
- 并发、上下文、KV Cache、队列要一起看
- 最终形成可解释、可复制的配置
说白了,这套方案追求的不是"演示",而是"交付"。
三、最终结论先说:双卡 A100 更适合双实例方案
这次实践里,一个非常重要的结论就是:
对于能单卡放下的模型,双卡 A100 上更适合用"双实例双端口分流",而不是指望一个实例自动把两张卡优雅吃满。
所以最终架构是这样的:
实例一:GPU0
- 绑定
CUDA_VISIBLE_DEVICES=0 - 监听
11434
实例二:GPU1
- 绑定
CUDA_VISIBLE_DEVICES=1 - 监听
11435
上层
- Python 客户端做轮询分发
- 支持健康检查
- 支持失败切换
- 支持预热感知
这样做的好处非常直接:
- 每张卡各自维护一份模型副本
- 两张卡可同时接流量
- 单实例状态更清晰
- 调优边界更明确
- 故障影响更可控
一句话概括就是:
把复杂调度问题,拆成两个简单实例问题。
四、从"安装成功"到"服务可用",中间至少隔了五个坑
这一段很重要,因为很多文章都喜欢直接跳到"最终配置",但真正有价值的,恰恰是这些坑。
坑一:systemd 服务文件根本没创建好
最开始的报错并不复杂:
bash
Failed to enable unit: Unit file ollama-gpu0.service does not exist.
这说明不是 Ollama 坏了,而是:
- 服务文件没创建成功
- 文件名写错了
- 或者放错目录了
这个坑看起来初级,但实际非常常见。因为一旦你开始用双实例,服务就不再是默认的 ollama.service,而是你自己维护的:
ollama-gpu0.serviceollama-gpu1.service
也就是说,从这一步开始,你就已经进入"自己要对 systemd 配置负责"的阶段了。
坑二:服务文件存在,但进程一启动就退出
后面更进一步,进入了这种状态:
bash
ExecStart=/usr/local/bin/ollama serve (code=exited, status=1/FAILURE)
这时候最容易误判:
- 是不是路径不对
- 是不是 Ollama 装坏了
- 是不是 CUDA 有问题
但真正的正确动作,不是猜,而是:
先确认路径,再看日志。
最终确认结果是:
bash
which ollama
/usr/local/bin/ollama
也就是说,这台机器上 ExecStart=/usr/local/bin/ollama serve 是对的,不需要盲目改成 /usr/bin/ollama。
这一步其实给了我们一个很实用的经验:
文档路径不是机器路径,机器路径才是真路径。
坑三:11434 端口被占用
后面日志里又出现了一个很经典的问题:
text
Error: listen tcp 0.0.0.0:11434: bind: address already in use
这说明什么?
说明 gpu0 起不来不是因为 GPU,不是因为配置,也不是因为权限,而是:
11434 已经被别的进程占了。
而这种情况在 Ollama 场景里特别常见,因为默认 ollama.service 很可能已经在跑。
也就是说,你一边想搞双实例,一边默认实例还在默默占着 11434,那 gpu0 永远起不来。
这一步最后得出的治理原则非常明确:
既然你决定走双实例,就不要再保留默认单实例服务。
坑四:目录明明写了,为什么还是没权限
端口问题解决之后,最关键的报错终于出现了:
text
Error: mkdir /data/ollama: permission denied: ensure path elements are traversable
这个错误特别有迷惑性。
很多人第一反应是:
哦,
/data/ollama/models没创建,我建一下就好了。
其实不够。
因为这里最关键的不是 mkdir,而是后面这个词:
traversable
它的意思是:
运行服务的 ollama 用户,不仅要对目标目录有权限,还必须能穿过整条上级目录链。
也就是说,只要下面任意一级目录没有可进入权限,就会报错:
//data/data/ollama/data/ollama/models
这也是这次排障里最有代表性的一个收获:
Linux 目录权限问题,很多时候不是目标目录本身,而是父目录链路权限不通。
坑五:双实例起来了,不代表双卡真的工作了
就算服务正常起来,也不代表双卡就真的在吃流量。
因为如果 Python 调用层写成这样:
python
base_url = "http://127.0.0.1:11434"
那 11435 那个实例就只是"存在",而不是"在工作"。
这一点非常关键。
很多双卡部署失败,不是败在服务层,而是败在调用层:
- 服务拆成两份了
- 但业务代码永远只调一个端口
- 结果就是一张卡累死,一张卡养老
所以真正让双卡工作起来的,不只是两个 systemd,而是:
请求分流机制。
五、最终生产版架构长什么样
复盘到最后,整套方案最终定型成这样:
1. 服务层
ollama-gpu0.serviceollama-gpu1.service
2. 模型层
- 自定义模型目录:
/data/ollama/models ollama用户拥有目录读写权限- 模型常驻
- 支持预热
3. 参数层
OLLAMA_KEEP_ALIVE=-1OLLAMA_FLASH_ATTENTION=1OLLAMA_KV_CACHE_TYPE=q8_0OLLAMA_MAX_LOADED_MODELS=1OLLAMA_NUM_PARALLEL=4OLLAMA_MAX_QUEUE=1024OLLAMA_CONTEXT_LENGTH=8192
4. 调用层
- Python 实例池
- 轮询分发
/api/version轻探活/api/ps检查运行模型- 失败自动切换
- 预热感知
5. 观测层
systemctl statusjournalctlollama psnvidia-smi/api/generate返回指标
这套结构不是"看起来复杂",而是"职责清晰":
- 服务负责活着
- 模型负责热着
- 调用层负责分流
- 观测层负责解释问题
六、最终版 systemd 配置
/etc/systemd/system/ollama-gpu0.service
ini
[Unit]
Description=Ollama GPU0 Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="CUDA_VISIBLE_DEVICES=0"
Environment="OLLAMA_MODELS=/data/ollama/models"
Environment="OLLAMA_KEEP_ALIVE=-1"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OLLAMA_MAX_LOADED_MODELS=1"
Environment="OLLAMA_NUM_PARALLEL=4"
Environment="OLLAMA_MAX_QUEUE=1024"
Environment="OLLAMA_CONTEXT_LENGTH=8192"
[Install]
WantedBy=multi-user.target
/etc/systemd/system/ollama-gpu1.service
ini
[Unit]
Description=Ollama GPU1 Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="OLLAMA_HOST=0.0.0.0:11435"
Environment="CUDA_VISIBLE_DEVICES=1"
Environment="OLLAMA_MODELS=/data/ollama/models"
Environment="OLLAMA_KEEP_ALIVE=-1"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OLLAMA_MAX_LOADED_MODELS=1"
Environment="OLLAMA_NUM_PARALLEL=4"
Environment="OLLAMA_MAX_QUEUE=1024"
Environment="OLLAMA_CONTEXT_LENGTH=8192"
[Install]
WantedBy=multi-user.target
这一版配置的核心思想很简单:
- 每张卡一个实例
- 每个实例一个主模型
- 模型尽量常驻
- 并发适度放开
- 上下文控制在吞吐友好区间
- 队列留一点缓冲,但不指望它创造性能
七、权限这件事,必须一次性处理到位
这一步是上线前的必做项:
bash
sudo mkdir -p /data/ollama/models
sudo chown -R ollama:ollama /data/ollama
sudo chmod 755 /data
sudo chmod 755 /data/ollama
sudo chmod 755 /data/ollama/models
再验证:
bash
namei -l /data/ollama/models
sudo -u ollama bash -lc 'cd /data/ollama/models && touch .perm_test && rm -f .perm_test && echo ok'
为什么我这里特别强调这一块?
因为很多问题看起来像:
- 模型目录不存在
- 服务权限有问题
- systemd 用户不对
本质上,最终往往都能归结为一句话:
目录链路没打通。
而这一类问题,如果你不一次性彻底处理好,它会在每次重启、每次换目录、每次换用户时反复找你。
八、自动预热,是真正让服务变"顺滑"的关键一步
服务起来了,不代表第一批请求就好用。
如果你不做预热,常见现象就是:
- 刚启动后第一批请求很慢
- 两个实例首包时延不一致
- 压测前几轮数据很丑
- 用户第一波请求体验不好
所以最终加入了一个预热脚本:
/usr/local/bin/ollama-prewarm.sh
bash
#!/usr/bin/env bash
set -e
MODEL="${1:-gemma3}"
for port in 11434 11435; do
echo "[prewarm] checking ${port}"
curl -sf "http://127.0.0.1:${port}/api/version" > /dev/null
echo "[prewarm] loading ${MODEL} on ${port}"
curl -sf "http://127.0.0.1:${port}/api/generate" \
-d "{\"model\":\"${MODEL}\",\"keep_alive\":-1}" > /dev/null
echo "[prewarm] verifying ${MODEL} on ${port}"
curl -sf "http://127.0.0.1:${port}/api/ps"
done
然后再把它接入 systemd:
/etc/systemd/system/ollama-prewarm.service
ini
[Unit]
Description=Prewarm Ollama Models
After=ollama-gpu0.service ollama-gpu1.service
Wants=ollama-gpu0.service ollama-gpu1.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ollama-prewarm.sh gemma3
[Install]
WantedBy=multi-user.target
这样做完以后,整条链路就闭环了:
- 服务启动
- 模型预热
- 模型常驻
- 再开始接流量
这时第一批用户就不再是你的"免费预热器"。
九、吞吐调优的真正核心:不是调参数,而是在做显存预算分配
这是整套方案里最值得记住的一句话:
吞吐调优,本质上是在做显存预算分配。
你在分配的其实是这几项:
1. 给并发多少预算
对应 OLLAMA_NUM_PARALLEL
2. 给单请求上下文多少预算
对应 OLLAMA_CONTEXT_LENGTH
3. 给模型驻留多少预算
对应 keep_alive / OLLAMA_KEEP_ALIVE
4. 给 KV Cache 压缩多少预算
对应 OLLAMA_KV_CACHE_TYPE
5. 给高峰排队多少预算
对应 OLLAMA_MAX_QUEUE
6. 给多模型竞争多少预算
对应 OLLAMA_MAX_LOADED_MODELS
这些参数不是彼此独立的。
尤其这一条必须牢牢记住:
并发和上下文是乘法关系。
也就是说,实例真正承受的压力,不是:
- 4 并发
- 8192 上下文
这么简单,而是:
4 × 8192 这个级别的上下文预算。
所以很多调优失败,并不是因为参数不够大,而是因为:
- 上下文太大
- 并发太高
- 两个一起把显存预算吃爆了
最终表现为:
- 时延抖动
- 队列堆积
- 503 overloaded
- CPU offload
- 双卡看起来很忙,但整体吞吐不高
十、真正会看压测结果的人,看的是"慢在哪里"
很多人压测只看一句:
- 平均耗时多少秒
这不够。
真正有价值的是接口返回里的这些字段:
total_durationload_durationprompt_eval_durationeval_duration
它们分别能告诉你:
1. 是不是模型没热好
看 load_duration
2. 是不是输入太长
看 prompt_eval_duration
3. 是不是生成本身慢
看 eval_duration
4. 是不是整体链路在抖
看 total_duration
你只有把这几个值一起看,才能知道:
- 慢是因为没预热
- 慢是因为 prompt 太大
- 慢是因为生成长度太长
- 慢是因为实例过载
否则你只知道"慢",却不知道"为什么慢"。
十一、ollama ps 和 nvidia-smi,一个都不能少
很多人喜欢只看 nvidia-smi。
它当然重要,但它只能告诉你:
- 显存占多少
- GPU 利用率怎么样
它不能直接告诉你:
- 模型是不是 100% GPU
- 有没有偷偷 offload 到 CPU
- 当前实例实际拿到的上下文是多少
这时候就必须结合:
bash
ollama ps
和:
bash
watch -n 1 nvidia-smi
一起看。
你真正应该关心的是:
ollama ps里模型是不是100% GPUCONTEXT是多少- 两个实例是不是都挂了模型
- 两张卡是不是都在稳定出力
真正的调优,不是单看一个指标,而是把 API 指标、运行状态、GPU 状态拼起来看。
十二、Python 调用层,决定了双卡到底是不是双卡
服务层解决的是"实例存在"。
调用层解决的是"实例工作"。
最终的 Python 调用方案要具备四个能力:
1. 轮询分流
不能永远打一个端口
2. 探活
先看 /api/version
3. 失败切换
一个实例失败时自动切到另一个
4. 预热感知
两个实例都要先热起来
最终思路其实很朴素:
- 准备两个客户端
- 轮询拿客户端
- 先探活
- 失败标记不健康
- 过一段时间再恢复探测
这套东西写出来不复杂,但价值非常大。因为它让双实例真正变成了:
一个可调度的本地推理池。
十三、上线前的最终检查清单
如果要把这套方案交付上线,我建议最后按下面这张清单走一遍。
服务层
-
ollama-gpu0.service正常运行 -
ollama-gpu1.service正常运行 -
ollama-prewarm.service可正常执行 - 默认
ollama.service已停用
目录层
-
/data/ollama/models已创建 -
ollama用户对目录有读写权限 - 父目录链路可遍历
端口层
-
11434正常监听 -
11435正常监听 - 没有额外进程占端口
模型层
- 模型可正常拉取
- 两个实例都能预热
-
/api/ps能看到运行模型 - 模型保持常驻
性能层
-
ollama ps显示100% GPU - 两张 A100 都有稳定利用率
-
load_duration在预热后明显下降 - 没有持续性 503 overloaded
调用层
- Python 客户端支持轮询
- Python 客户端支持失败切换
- Python 客户端支持探活
- 压测时两个实例都能吃到请求
只要这一套检查清单都过了,这套方案基本就已经脱离"实验环境",进入"可上线环境"了。
十四、这次实践最重要的五个收获
最后,把整次方案压成五句话。
收获一:默认服务和自定义双实例不能混用
否则端口冲突几乎是必然的。
收获二:目录权限问题,很多时候卡在父目录
不是目标目录不存在,而是 ollama 用户过不去。
收获三:双卡要真正工作,调用层必须做分流
服务拆两份没意义,请求不分流,一样是一张卡干活。
收获四:吞吐调优本质是显存预算分配
不是把参数调大,而是让每一份显存花得值。
收获五:生产化的关键,不是服务能起,而是服务能一直稳
systemd、常驻、预热、探活、重试、压测、观测,缺一个都可能让"看起来能跑"的方案,在真实流量里变得不好用。
十五、结尾:这套方案到底算不算完成
如果只是从"把 Ollama 安装到服务器上"这个角度看,这套方案早就该结束了。
但如果你从"能不能作为一套真正可用的本地推理服务上线"这个角度看,到这一篇,才算真正收官。
因为我们最终交付的,已经不是一条命令、一个模型、一个端口。
而是一整套完整能力:
- 双实例服务治理
- 双卡资源利用
- 模型常驻与预热
- 目录权限治理
- Python 调用封装
- 轮询分流与失败切换
- 压测与性能判读
- 最终上线检查清单
这才是一套像样的生产方案。
说到底,真正的技术能力,不是"你会不会装 Ollama",而是:
当它不顺的时候,你能不能把它一步一步拉回到正确的位置。
而这次双卡 A100 + Ollama 的完整实践,本质上就是这样一场从"能装",走到"能打"的过程。