一、最终落地目标
先别急着看脚本,先把最终目标定死。
我们希望交付出来的,不是一台"偶尔能跑模型的机器",而是一套具备下面这些特征的本地推理节点:
1. 服务层稳定
- 开机自启
- 进程异常自动重启
- 日志可追踪
- 端口职责明确
2. 模型层稳定
- 模型目录权限正确
- 模型可长期常驻
- 重启后能自动预热
- 避免冷启动抖动
3. GPU 利用稳定
- GPU0 跑一个实例
- GPU1 跑一个实例
- 请求通过上层分流打到两个实例
- 两张卡都能稳定接流量
4. 调用层稳定
- Python 侧有实例池
- 支持轮询
- 支持轻量探活
- 支持失败切换
- 支持预热感知
5. 调优层可解释
OLLAMA_NUM_PARALLEL、上下文长度、KV Cache、常驻策略都可控- 通过
/api/generate返回指标看性能 - 通过
/api/ps看模型运行状态 - 通过
ollama ps和nvidia-smi看 GPU 状态
这些目标背后,并不是"自己想当然设计"的,而是完全建立在 Ollama 官方已经提供的机制之上:Linux 上用 systemd 配环境变量,API 自动暴露,/api/ps 查看运行模型,/api/version 轻探活,/api/generate 返回 total_duration、load_duration、prompt_eval_duration、eval_duration 等指标,而 OLLAMA_KEEP_ALIVE、OLLAMA_NUM_PARALLEL、OLLAMA_MAX_LOADED_MODELS、OLLAMA_MAX_QUEUE、OLLAMA_FLASH_ATTENTION、OLLAMA_KV_CACHE_TYPE 这些服务行为也都属于官方支持项。(Ollama 文档)
二、最终目录结构建议
先把目录规划出来,这样后面所有脚本和服务文件都有固定落点。
我建议最终目录这样组织:
bash
/
├── data/
│ └── ollama/
│ └── models/
├── etc/
│ └── systemd/
│ └── system/
│ ├── ollama-gpu0.service
│ ├── ollama-gpu1.service
│ └── ollama-prewarm.service
└── usr/
└── local/
└── bin/
├── ollama
├── ollama-prewarm.sh
├── ollama-healthcheck.sh
└── ollama_bi_gpu_client.py
这样规划的好处是:
- 模型目录单独放在
/data/ollama/models - 服务文件全部放到
systemd标准目录 - 运行脚本、预热脚本、健康检查脚本全部统一在
/usr/local/bin - 后续迁移、备份、排障时都很清楚
这里尤其要记住一点:如果使用 OLLAMA_MODELS 指向自定义目录,官方 FAQ 明确要求运行 Ollama 的用户要对该目录有读写权限;而 Linux 上如果目录链路不可遍历,也会直接导致服务启动失败。(Ollama 文档)
三、一键部署脚本:先把"基础骨架"一次性搭起来
下面给你一份可直接改的部署脚本。
这份脚本做四件事:
- 创建目录
- 设置权限
- 写入两个
systemd实例 - 写入预热服务并启动
deploy_ollama_dual_a100.sh
bash
#!/usr/bin/env bash
set -euo pipefail
OLLAMA_BIN="/usr/local/bin/ollama"
MODEL_DIR="/data/ollama/models"
PREWARM_SCRIPT="/usr/local/bin/ollama-prewarm.sh"
if [[ ! -x "${OLLAMA_BIN}" ]]; then
echo "未找到 Ollama 可执行文件: ${OLLAMA_BIN}"
exit 1
fi
echo "[1/7] 创建目录"
sudo mkdir -p "${MODEL_DIR}"
sudo mkdir -p /usr/local/bin
echo "[2/7] 设置目录权限"
sudo chown -R ollama:ollama /data/ollama
sudo chmod 755 /data
sudo chmod 755 /data/ollama
sudo chmod 755 "${MODEL_DIR}"
echo "[3/7] 写入 gpu0 service"
sudo tee /etc/systemd/system/ollama-gpu0.service > /dev/null <<'EOF'
[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
EOF
echo "[4/7] 写入 gpu1 service"
sudo tee /etc/systemd/system/ollama-gpu1.service > /dev/null <<'EOF'
[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
EOF
echo "[5/7] 写入预热脚本"
sudo tee "${PREWARM_SCRIPT}" > /dev/null <<'EOF'
#!/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
EOF
sudo chmod +x "${PREWARM_SCRIPT}"
echo "[6/7] 写入预热 service"
sudo tee /etc/systemd/system/ollama-prewarm.service > /dev/null <<'EOF'
[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
EOF
echo "[7/7] 停用默认服务,加载新配置并启动"
sudo systemctl disable --now ollama || true
sudo systemctl daemon-reload
sudo systemctl enable --now ollama-gpu0
sudo systemctl enable --now ollama-gpu1
sudo systemctl enable ollama-prewarm
sudo systemctl start ollama-prewarm
echo "部署完成。"
echo "检查命令:"
echo " systemctl status ollama-gpu0 --no-pager -l"
echo " systemctl status ollama-gpu1 --no-pager -l"
echo " curl http://127.0.0.1:11434/api/ps"
echo " curl http://127.0.0.1:11435/api/ps"
这份脚本里每个关键动作,都有官方能力作为支撑:
- Linux 上推荐通过
systemd设置环境变量。(Ollama 文档) - 多 GPU 选择可用
CUDA_VISIBLE_DEVICES,且 UUID 更可靠。(Ollama 文档) - 模型目录可由
OLLAMA_MODELS指定,且目录权限必须正确。(Ollama 文档) - 模型常驻可通过
OLLAMA_KEEP_ALIVE或 API 的keep_alive控制,负数表示一直留在内存中。(Ollama 文档) - 空的
/api/generate请求可以用于预热模型,/api/ps用于确认模型已加载。(Ollama 文档)
四、为什么这套 systemd 配置要这样收敛
这套服务配置看起来参数不少,但其实思路非常统一:
1. OLLAMA_KEEP_ALIVE=-1
让模型常驻,避免反复冷启动。官方 FAQ 明确说明,负数表示一直保留在内存中。(Ollama 文档)
2. OLLAMA_FLASH_ATTENTION=1
官方 FAQ 明确提到,它可以显著减少大上下文场景下的内存使用。(Ollama 文档)
3. OLLAMA_KV_CACHE_TYPE=q8_0
官方 FAQ 说明,q8_0 大约只用 f16 一半的内存,通常几乎没有明显质量损失。(Ollama 文档)
4. OLLAMA_MAX_LOADED_MODELS=1
官方虽然允许一个服务同时加载多个模型,但对"双实例 + 固定主模型服务"场景来说,把它压到 1 更利于显存稳定和行为可控。默认值会按 GPU 数量乘 3 计算,但对专用服务来说没必要放开。(Ollama 文档)
5. OLLAMA_NUM_PARALLEL=4
官方 FAQ 明确说明,并行请求会按 OLLAMA_NUM_PARALLEL * OLLAMA_CONTEXT_LENGTH 放大内存需求。对双 A100 的双实例吞吐优先场景,4 是一个偏积极但仍可收敛的起点。(Ollama 文档)
6. OLLAMA_MAX_QUEUE=1024
官方默认是 512,过载时会返回 503 overloaded。这里稍微放大,是为了给短时高峰留一点缓冲,但它不是吞吐放大器,只是缓冲区。(Ollama 文档)
7. OLLAMA_CONTEXT_LENGTH=8192
官方上下文文档说明,默认上下文会随 VRAM 增大而变大,但更大的上下文意味着更高内存占用,同时建议通过 ollama ps 查看 PROCESSOR 和 CONTEXT,尽量保持 100% GPU,避免 offload 到 CPU。对吞吐优先服务来说,8192 是比超长上下文更容易收敛的起点。(Ollama 文档)
一句话概括就是:
这套参数不是为了"把数字调大",而是为了"把行为调稳"。
五、健康检查脚本:上线前和上线后都能用
我建议再补一份健康检查脚本,专门做三件事:
- 看端口是否可达
- 看 API 是否正常
- 看模型是否已装进内存
/usr/local/bin/ollama-healthcheck.sh
bash
#!/usr/bin/env bash
set -e
for port in 11434 11435; do
echo "====== checking ${port} ======"
echo "[version]"
curl -sf "http://127.0.0.1:${port}/api/version"
echo
echo "[running models]"
curl -sf "http://127.0.0.1:${port}/api/ps"
echo
done
赋权:
bash
sudo chmod +x /usr/local/bin/ollama-healthcheck.sh
这份脚本看起来简单,但非常实用,因为它正好用到了官方最适合巡检的两个接口:
上线前跑一遍,上线后巡检也跑这一遍,就够了。
六、Python 客户端:把双实例真正用起来
前面的服务和脚本解决的是"实例存在",Python 客户端解决的是"实例工作"。
如果你的业务程序永远只打一个端口,那第二个实例根本没有价值。所以最终打包里,Python 客户端必须至少具备四个能力:
- 轮询分流
- 轻量探活
- 失败摘除
- 预热感知
/usr/local/bin/ollama_bi_gpu_client.py
python
import itertools
import threading
import time
from typing import Any, Dict, List, Optional
import requests
class OllamaClient:
def __init__(self, base_url: str, timeout: int = 300):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def version(self) -> Dict[str, Any]:
r = requests.get(f"{self.base_url}/api/version", timeout=10)
r.raise_for_status()
return r.json()
def ps(self) -> Dict[str, Any]:
r = requests.get(f"{self.base_url}/api/ps", timeout=10)
r.raise_for_status()
return r.json()
def generate(
self,
model: str,
prompt: str = "",
stream: bool = False,
keep_alive: Optional[Any] = -1,
) -> Dict[str, Any]:
payload = {
"model": model,
"prompt": prompt,
"stream": stream,
"keep_alive": keep_alive,
}
r = requests.post(
f"{self.base_url}/api/generate",
json=payload,
timeout=self.timeout,
)
r.raise_for_status()
return r.json()
class ProductionOllamaPool:
def __init__(self, endpoints: List[str], timeout: int = 300, unhealthy_ttl: int = 10):
self.clients = [OllamaClient(ep, timeout=timeout) for ep in endpoints]
self._cycle = itertools.cycle(range(len(self.clients)))
self._lock = threading.Lock()
self._unhealthy_since: Dict[int, float] = {}
self.unhealthy_ttl = unhealthy_ttl
def _next_index(self) -> int:
with self._lock:
return next(self._cycle)
def _available(self, idx: int) -> bool:
ts = self._unhealthy_since.get(idx)
if ts is None:
return True
return time.time() - ts > self.unhealthy_ttl
def _mark_unhealthy(self, idx: int):
self._unhealthy_since[idx] = time.time()
def probe_all(self):
for idx, client in enumerate(self.clients):
try:
client.version()
self._unhealthy_since.pop(idx, None)
except Exception:
self._mark_unhealthy(idx)
def prewarm(self, model: str):
for idx, client in enumerate(self.clients):
try:
client.generate(model=model, prompt="", stream=False, keep_alive=-1)
except Exception:
self._mark_unhealthy(idx)
def generate(self, model: str, prompt: str, max_attempts: int = 2) -> Dict[str, Any]:
self.probe_all()
last_error = None
tried = set()
for _ in range(len(self.clients) * max_attempts):
idx = self._next_index()
if idx in tried and len(tried) >= len(self.clients):
break
if not self._available(idx):
continue
tried.add(idx)
client = self.clients[idx]
try:
return client.generate(
model=model,
prompt=prompt,
stream=False,
keep_alive=-1,
)
except Exception as e:
self._mark_unhealthy(idx)
last_error = e
raise last_error or RuntimeError("没有可用的 Ollama 实例")
if __name__ == "__main__":
pool = ProductionOllamaPool(
endpoints=[
"http://127.0.0.1:11434",
"http://127.0.0.1:11435",
]
)
pool.prewarm("gemma3")
resp = pool.generate("gemma3", "用中文介绍一下 Elasticsearch 的倒排索引。")
print(resp.get("response", ""))
print(resp)
这里的所有关键动作,都能对应到官方 API:
/api/version探活。(Ollama 文档)/api/ps查询运行模型。(Ollama 文档)/api/generate做生成与预热。(Ollama 文档)keep_alive=-1让模型保持驻留。(Ollama 文档)
如果你的项目本身大量使用 OpenAI SDK,也可以直接走 OpenAI 兼容接口,官方文档已经给出了 base_url="http://localhost:11434/v1/" 的用法。(Ollama 文档)
七、怎么验证这套打包方案真的跑通了
部署完以后,不要只看一句 active (running),而是按下面顺序完整验证。
1. 看服务状态
bash
systemctl status ollama-gpu0 --no-pager -l
systemctl status ollama-gpu1 --no-pager -l
systemctl status ollama-prewarm --no-pager -l
2. 看日志
bash
journalctl -u ollama-gpu0 --no-pager --follow --pager-end
journalctl -u ollama-gpu1 --no-pager --follow --pager-end
journalctl -u ollama-prewarm --no-pager --follow --pager-end
Linux 上通过 journalctl 查看日志,本来就是官方推荐的路径。(Ollama 文档)
3. 看 API 是否可达
bash
curl http://127.0.0.1:11434/api/version
curl http://127.0.0.1:11435/api/version
4. 看模型是否已装进内存
bash
curl http://127.0.0.1:11434/api/ps
curl http://127.0.0.1:11435/api/ps
5. 看 GPU 是否真正工作
bash
ollama ps
watch -n 1 nvidia-smi
官方上下文文档明确建议,通过 ollama ps 查看 PROCESSOR,确认是不是 100% GPU,并看当前上下文配置是否符合预期。(Ollama 文档)
八、为什么这套打包方案适合"生产起步版"
我把它定义成"生产起步版",不是因为它简单,而是因为它具备了正式环境最重要的几个基础特征:
1. 它不是手工命令堆出来的
而是有统一目录、统一服务、统一脚本。
2. 它不是冷启动式服务
而是支持模型常驻和开机预热。
3. 它不是单实例赌博
而是明确拆成双实例、双端口、双 GPU。
4. 它不是只会跑通
而是已经考虑了轮询、探活、失败切换。
5. 它不是"只能看显卡"
而是已经把 /api/generate 的使用指标、/api/ps 的运行状态和 ollama ps / nvidia-smi 的机器状态串起来了。官方 API Usage 文档对这些指标的定义已经很清楚,包括 total_duration、load_duration、prompt_eval_count、prompt_eval_duration、eval_count、eval_duration,而且时间单位统一是纳秒。(Ollama 文档)
所以,这套东西的价值,不在于"命令多",而在于:
它已经具备了从实验环境跨到可上线环境所需要的最小完整性。
九、最后给你一套上线前最终清单
这部分最实用,建议直接收藏。
服务层
ollama-gpu0.service正常运行ollama-gpu1.service正常运行ollama-prewarm.service可执行成功- 默认
ollama.service已停用
路径层
which ollama确认为真实路径/data/ollama/models已存在ollama用户对模型目录有读写权限- 父目录链路可遍历
端口层
11434监听正常11435监听正常- 无额外进程占用端口
API 层
/api/version正常返回/api/ps能看到运行模型/api/generate可成功响应
模型层
- 模型已预热
- 模型保持常驻
load_duration在预热后明显降低
GPU 层
ollama ps显示100% GPU- 两张卡都能稳定接流量
nvidia-smi显示双卡确实在工作
调用层
- Python 客户端不是写死单端口
- 具备轮询机制
- 具备探活机制
- 具备失败切换机制
这一整套检查,其实就是把官方支持的服务配置、运行接口、使用指标和 GPU 观察手段,全部串成了一个可执行的上线检查流程。(Ollama 文档)
十、结尾
写到这里,这个系列其实已经形成了一套完整闭环。
它不再是零散的问答,不再是"遇到一个坑解决一个坑",而是一整套从 安装、排障、服务治理、模型治理、调用治理到调优治理 的落地方案。
真正有价值的,不是你会不会写某个 service,而是你现在已经有了这样一套稳定认知:
- 双卡不等于自动吃满
- 双实例比单实例跨卡更适合很多吞吐优先场景
- 参数不是越大越快,而是越收敛越稳
- 调用层决定了第二张卡是不是摆设
- 日志、探活、预热、常驻和指标,是生产环境不可缺的一整套治理动作
说到底,真正的"最终落地手册"从来不是一份脚本,而是:
你终于知道,这套脚本为什么要这么写。