双卡 A100 + Ollama 最终落地手册一键部署脚本、配置文件、预热脚本与 Python 客户端完整打包

一、最终落地目标

先别急着看脚本,先把最终目标定死。

我们希望交付出来的,不是一台"偶尔能跑模型的机器",而是一套具备下面这些特征的本地推理节点:

1. 服务层稳定

  • 开机自启
  • 进程异常自动重启
  • 日志可追踪
  • 端口职责明确

2. 模型层稳定

  • 模型目录权限正确
  • 模型可长期常驻
  • 重启后能自动预热
  • 避免冷启动抖动

3. GPU 利用稳定

  • GPU0 跑一个实例
  • GPU1 跑一个实例
  • 请求通过上层分流打到两个实例
  • 两张卡都能稳定接流量

4. 调用层稳定

  • Python 侧有实例池
  • 支持轮询
  • 支持轻量探活
  • 支持失败切换
  • 支持预热感知

5. 调优层可解释

  • OLLAMA_NUM_PARALLEL、上下文长度、KV Cache、常驻策略都可控
  • 通过 /api/generate 返回指标看性能
  • 通过 /api/ps 看模型运行状态
  • 通过 ollama psnvidia-smi 看 GPU 状态

这些目标背后,并不是"自己想当然设计"的,而是完全建立在 Ollama 官方已经提供的机制之上:Linux 上用 systemd 配环境变量,API 自动暴露,/api/ps 查看运行模型,/api/version 轻探活,/api/generate 返回 total_durationload_durationprompt_eval_durationeval_duration 等指标,而 OLLAMA_KEEP_ALIVEOLLAMA_NUM_PARALLELOLLAMA_MAX_LOADED_MODELSOLLAMA_MAX_QUEUEOLLAMA_FLASH_ATTENTIONOLLAMA_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 文档)

三、一键部署脚本:先把"基础骨架"一次性搭起来

下面给你一份可直接改的部署脚本。

这份脚本做四件事:

  1. 创建目录
  2. 设置权限
  3. 写入两个 systemd 实例
  4. 写入预热服务并启动

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 查看 PROCESSORCONTEXT,尽量保持 100% GPU,避免 offload 到 CPU。对吞吐优先服务来说,8192 是比超长上下文更容易收敛的起点。(Ollama 文档)

一句话概括就是:

这套参数不是为了"把数字调大",而是为了"把行为调稳"。

五、健康检查脚本:上线前和上线后都能用

我建议再补一份健康检查脚本,专门做三件事:

  1. 看端口是否可达
  2. 看 API 是否正常
  3. 看模型是否已装进内存

/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 客户端必须至少具备四个能力:

  1. 轮询分流
  2. 轻量探活
  3. 失败摘除
  4. 预热感知

/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:

如果你的项目本身大量使用 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_durationload_durationprompt_eval_countprompt_eval_durationeval_counteval_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,而是你现在已经有了这样一套稳定认知:

  • 双卡不等于自动吃满
  • 双实例比单实例跨卡更适合很多吞吐优先场景
  • 参数不是越大越快,而是越收敛越稳
  • 调用层决定了第二张卡是不是摆设
  • 日志、探活、预热、常驻和指标,是生产环境不可缺的一整套治理动作

说到底,真正的"最终落地手册"从来不是一份脚本,而是:

你终于知道,这套脚本为什么要这么写。

相关推荐
vx_biyesheji00012 小时前
计算机毕业设计:Python网约车订单数据可视化系统 Django框架 可视化 数据大屏 数据分析 大数据 机器学习 深度学习(建议收藏)✅
大数据·python·机器学习·信息可视化·django·汽车·课程设计
AC赳赳老秦2 小时前
OpenClaw实战案例:用1个主控+3个Agent,实现SEO文章日更3篇
服务器·数据库·python·mysql·.net·deepseek·openclaw
cch89182 小时前
汇编VS C++:底层控制与高效开发之争
java·开发语言
智算菩萨2 小时前
PyCharm版本发展史:从诞生到AI时代的Python IDE演进历程
ide·人工智能·python·pycharm·ai编程
Khsc434ka2 小时前
LeetCode-001:Python 实现哈希表求两数之和:初识哈希表
python·leetcode·散列表
lifewange2 小时前
代码托管平台
开发语言
yangyanping201082 小时前
Go语言学习之配置管理库Viper
开发语言·学习·golang
橘子编程2 小时前
UniApp跨端开发终极指南
开发语言·vue.js·uni-app
冬至喵喵2 小时前
构建 CLI 的 Python 框架:Typer技术介绍
开发语言·chrome·python