先说个真实场景
2024年11月,我们公司内部要做个「合同条款自动审查」的内部工具。合同数据属于商业机密,不能走API。老板拍板:本地部署开源大模型。
我一开始天真地以为:公司有台双路Xeon + 128GB内存 + 四张Tesla T4的服务器,足够了。结果呢?T4显存只有16GB一张,而且服务器上还跑着两个线上服务,GPU显存已经被占了一半。也就是说,我最多只能拿到32GB显存。
更操蛋的是,我一开始用FastChat + vLLM部署Qwen2.5-72B,直接OOM。换7B,审查结果狗屁不通,合同里的「违约金」条款完全漏掉。后来换成Ollama + Qwen2.5-14B-Instruct Q4_K_M,才勉强能用。
这篇文章记录的就是这个过程中的方案对比、完整代码、性能数据和踩过的坑。
环境与模型清单
| 项 | 配置 |
|---|---|
| 操作系统 | Ubuntu 22.04.4 LTS |
| CPU | Intel Xeon Gold 6248R @ 3.0GHz(双路 48核) |
| 内存 | 128GB DDR4 ECC |
| GPU | NVIDIA Tesla T4 × 2(16GB/张) |
| 驱动 | NVIDIA Driver 535.104.05 |
| CUDA | 本地未安装(Ollama内部自带) |
| Docker | 24.0.7 |
| Ollama | 0.5.4(当前最新) |
| 模型 | qwen2.5:14b-instruct-q4_K_M |
问题:传统方案的三个坑
很多教程教你「先装CUDA、再装cuDNN、再搞Python虚拟环境」,我全照做了,踩了一地雷。
坑1:vLLM在T4上不支持FlashAttention2(T4是图灵架构,需要Ampere以上),得加参数 --disable-flash-attn,吞吐掉40%。
坑2:FastChat + transformers 加载14B Q4模型,光加载就要3分半。
坑3:依赖冲突到爆炸。PyTorch要CUDA 12.1,vLLM要11.8,FastChat要12.0,为了兼容我浪费了两天。
我当时就想:有没有一个东西,模型管理、量化、内存调度全打包好,一条命令就能跑?
方案对比:Ollama / vLLM / FastChat
收集了三个方案在同一台服务器上的实测数据。
1. Ollama 0.5.4
- 安装:一条curl命令
- 模型管理:内部自动下载、量化
- 显存策略:自动将不用的层卸载到CPU,充分利用内存
- 并发:自带队列
2. vLLM 0.6.2
- 依赖:需要Python 3.9+,指定CUDA版本
- 性能:PagedAttention确实快,但T4上无法开启FlashAttention
- 坑:版本兼容矩阵复杂到想骂人
3. FastChat
- 用途:主要是做对话前端+多模型调度,推理效率一般
- 表现:显存管理比Ollama差,容易OOM
数据对比(同一台T4,Qwen2.5-14B Q4_K_M,请求64条)
| 指标 | Ollama | vLLM | FastChat |
|---|---|---|---|
| 模型加载耗时 | 2.1s | 11.4s | 210s |
| 首Token延迟(均值) | 0.52s | 0.38s | 1.23s |
| 吞吐量(tokens/s) | 28.6 | 39.2 | 11.4 |
| 峰值显存 | 9.8GB | 10.4GB | 14.2GB(不稳定) |
| 部署耗时 | 5分钟 | 2小时+ | 3小时+ |
vLLM吞吐确实最强,但部署成本高、依赖地狱。FastChat各方面都不行。Ollama属于「85分性能,200%易用性」。我们内部工具并发量不高,Ollama完全够用。
完整部署:Docker Compose方案
我们最终选择了Ollama。为了可复现、可运维,用了Docker Compose。
第一步:部署Ollama服务
Docker版本需要配好NVIDIA Container Toolkit才能让容器用上GPU。
# 安装NVIDIA Container Toolkit
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
# 配置Docker识别GPU
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
写docker-compose.yml,指定NVIDIA GPU运行时,限制日志大小防止撑爆磁盘。
version: "3.8"
services:
ollama:
image: ollama/ollama:0.5.4
container_name: ollama
restart: unless-stopped
ports:
- "11434:11434"
volumes:
- ./ollama_models:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=600
- OLLAMA_NUM_PARALLEL=1
- OLLAMA_MAX_LOADED_MODELS=1
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
启动并验证。
docker compose up -d
docker logs ollama --tail 20
# 验证GPU是否可用
docker exec ollama nvidia-smi
看到CUDA版本和显存信息就说明没问题。
第二步:下载并导入模型
直接pull会用官方仓库地址,在国内网络环境真是折磨,动不动几KB/s。正确的姿势是走国内镜像或者直接下载GGUF模型文件手动导入。
我们用的是手动导入方式,官方仓库的Q4_K_M格式GGUF文件。手动导入的好处是可以放到内网,其他服务器不用重复下载。
mkdir -p ~/ollama-models && cd ~/ollama-models
# 下载 qwen2.5-14b-instruct-q4_k_m.gguf
wget https://modelscope.cn/models/qwen/Qwen2.5-14B-Instruct-GGUF/resolve/master/qwen2.5-14b-instruct-q4_k_m.gguf
# 编写Modelfile
cat > Modelfile <<'EOF'
FROM ./qwen2.5-14b-instruct-q4_k_m.gguf
TEMPLATE """{{- if .System }}
<|im_start|>system
{{ .System }}<|im_end|>
{{- end }}
<|im_start|>user
{{ .Prompt }}<|im_end|>
<|im_start|>assistant
"""
PARAMETER temperature 0.2
PARAMETER top_p 0.9
PARAMETER stop "<|im_end|>"
EOF
# 创建到Ollama本地仓库
ollama create qwen2.5-14b -f Modelfile
# 验证可用
ollama list
注意Ollama容器访问宿主机模型文件时需要挂载路径一致。如果模型文件在宿主机 ~/ollama-models,需要把该目录挂载到容器内,或者直接把模型文件拷到 ./ollama_models 目录下面。
# 如果模型文件在 ~/ollama-models 需额外挂载
docker exec -it ollama /bin/bash
ollama create qwen2.5-14b -f /root/.ollama/Modelfile
第三步:内部服务调用
用PHP调用,因为公司主力技术栈是PHP。Ollama原生API接口很干净。
$ch = curl_init('http://127.0.0.1:11434/api/generate');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'model' => 'qwen2.5-14b',
'prompt' => '请审阅以下合同条款中的违约责任部分,指出其中的法律风险点,并给出修改建议。',
'stream' => false,
'options' => [
'temperature' => 0.2,
'top_p' => 0.9,
'num_predict' => 2048
]
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json'
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
die('Curl error: ' . curl_error($ch));
}
curl_close($ch);
$result = json_decode($response, true);
echo $result['response'] ?? 'No response';
生产环境用了HTTPS、连接池和超时控制,这里给出一个更符合生产要求的版本(加上了超时和错误处理):
function callOllama(string $prompt, array $options = []): string {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'http://127.0.0.1:11434/api/generate',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_POSTFIELDS => json_encode([
'model' => 'qwen2.5-14b',
'prompt' => $prompt,
'stream' => false,
'options' => array_merge([
'temperature' => 0.2,
'top_p' => 0.9,
'num_predict' => 2048
], $options)
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json']
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new RuntimeException('Ollama请求失败: ' . $error);
}
if ($httpCode !== 200) {
throw new RuntimeException('Ollama返回HTTP ' . $httpCode . ':' . $response);
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('解析Ollama响应失败: ' . json_last_error_msg());
}
return $data['response'] ?? '';
}
第四步:批量审查脚本
合同审查不是单条请求,要批量跑。这里用shel脚本并发控制,一次处理多份合同。
#!/usr/bin/env bash
# batch_review.sh - 批量审查合同条款
set -euo pipefail
INPUT_DIR="./contracts"
OUTPUT_DIR="./reviews"
mkdir -p "$OUTPUT_DIR"
for file in "$INPUT_DIR"/*.txt; do
filename=$(basename "$file" .txt)
echo "[$(date +%H:%M:%S)] 正在审查: $filename"
# 使用Python调用Ollama API,避免curl转义地狱
python3 - "$file" > "$OUTPUT_DIR/$filename.review" <<'PYEOF'
import json
import sys
import time
import urllib.request
filepath = sys.argv[1]
with open(filepath, 'r') as f:
contract = f.read()
prompt = f"""你是资深法律顾问。请审阅以下合同,重点检查:
1. 违约责任条款是否明确
2. 赔偿上限是否合理
3. 争议解决条款是否完整
合同内容:
{contract[:4000]}
请给出:风险点列表 + 修改建议。
"""
data = json.dumps({
"model": "qwen2.5-14b",
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.2,
"num_predict": 2048
}
}).encode("utf-8")
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=data,
headers={"Content-Type": "application/json"}
)
for retry in range(3):
try:
with urllib.request.urlopen(req, timeout=180) as resp:
result = json.loads(resp.read().decode("utf-8"))
print(result["response"])
break
except Exception as e:
if retry == 2:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
time.sleep(2 ** retry)
PYEOF
echo "[$(date +%H:%M:%S)] 完成: $filename"
sleep 2 # 防止并发过高压垮Ollama
done
echo "批量审查完成,结果保存在 $OUTPUT_DIR"
第五步:持续运行的前端API封装
如果要做成内部Web系统,用Node.js写一个轻量API网关,统一鉴权、限流、日志。
// server.js - Ollama API网关
const express = require('express');
const app = express();
const axios = require('axios');
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'ollama-gateway.log' })
]
});
app.use(express.json());
app.post('/api/review', async (req, res) => {
const { content, type } = req.body;
if (!content) {
return res.status(400).json({ error: '缺少 content 字段' });
}
try {
const ollamaResp = await axios.post('http://127.0.0.1:11434/api/generate', {
model: 'qwen2.5-14b',
prompt: `你是合同审查专家。请审查以下${type || '合同'}条款:\n\n${content}\n\n`,
stream: false,
options: {
temperature: 0.2,
num_predict: 2048
}
}, { timeout: 120000 });
const result = ollamaResp.data.response;
logger.info({
type: 'review_complete',
contentLength: content.length,
responseLength: result.length,
timestamp: new Date().toISOString()
});
res.json({ review: result });
} catch (err) {
logger.error({
type: 'review_error',
error: err.message,
timestamp: new Date().toISOString()
});
res.status(502).json({ error: '模型服务暂不可用' });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`API网关已启动: http://127.0.0.1:${PORT}`);
});
第六步:K8s部署(可选)
测试到后面,我们发现一个严重问题:Ollama默认的OLLAMA_NUM_PARALLEL如果设为2或更高,模型会被多份加载,显存不够。需要谨慎配置。后来我们跑通了K8s部署,把关键yaml贴出来,给有需要的团队参考。
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
namespace: ai
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: ollama/ollama:0.5.4
ports:
- containerPort: 11434
volumeMounts:
- name: model-store
mountPath: /root/.ollama
resources:
limits:
nvidia.com/gpu: 1
env:
- name: OLLAMA_NUM_PARALLEL
value: "1"
- name: OLLAMA_MAX_LOADED_MODELS
value: "1"
volumes:
- name: model-store
persistentVolumeClaim:
claimName: ollama-models
---
apiVersion: v1
kind: Service
metadata:
name: ollama-service
namespace: ai
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434
type: ClusterIP
效果数据:合同审查实测
压测工具用Locust,50个并发用户,模拟合同审查请求。
压测参数与结果
| 指标 | 数值 |
|---|---|
| 测试时长 | 10分钟 |
| 总请求数 | 352 |
| RPS(每秒请求数) | 0.59 |
| 平均响应时间 | 84.2s |
| P95响应时间 | 127s |
| P99响应时间 | 158s |
| 成功请求数 | 341 |
| 失败请求数 | 11(均为超时) |
| 成功率 | 96.9% |
| 模型输出长度(均值) | 812 tokens |
单个请求的模型推理时间大概70-90秒,看着慢,但这是14B模型 + T4推理速度的极限。如果只是内部少数人用,体验尚可。追求速度就把模型换成7B或者加多张GPU,但效果会有一定下降。
量化方式对比
| 量化方式 | 模型大小 | 推理速度(tokens/s) | 显存占用 | 审查质量(人工评分) |
|---|---|---|---|---|
| Q8_0 | 15.4GB | 22.1 | 11.9GB | 8.5/10 |
| Q4_K_M | 9.0GB | 28.6 | 9.8GB | 8.2/10 |
| Q4_0 | 8.4GB | 31.2 | 9.1GB | 7.8/10 |
| Q3_K_M | 7.3GB | 33.5 | 8.2GB | 6.9/10 |
结论:质量敏感场景选Q8_0,一般场景选Q4_K_M性价比最高。Q3及以下不推荐,会明显丢失合同细节理解能力。
显存和内存监控
部署运行了48小时后,用 nvidia-smi 和 free -h 观察到一个关键现象:Ollama的缓存策略会把模型一直驻留显存,即使连续几小时没有请求,显存也不会释放。
用OLLAMA_KEEP_ALIVE环境变量控制模型在空闲后的保持时间。我们设置为600秒(10分钟),10分钟无请求后自动释放显存。实测效果:空闲后10分钟,显存占用从9.8GB降到约0.3GB,对同一台服务器上跑其他GPU任务很有帮助。
# 查看当前Ollama环境变量
docker exec ollama env | grep OLLAMA
# 如果没有设置KEEP_ALIVE,默认是5分钟
# 修改docker-compose.yml后重建
docker compose up -d --force-recreate
避坑指南
这一路踩了太多坑。挑几个最典型的,希望你不用重复走。
坑1:OLLAMA_NUM_PARALLEL 设置过高直接OOM
网上有人建议把这个值调高提升并发。我有一次设成4,Ollama直接加载了4份模型副本,第二份就OOM了。查看日志发现GPU显存不足,进程被内核kill。
正确做法:单卡就设1,有并发需求时用外部队列排队,比靠Ollama内部并发靠谱。
坑2:T4不支持FlashAttention2
vLLM在T4上会报错说requires Ampere or later architecture,有人建议加 --disable-flash-attn 解决,但性能损失明显。后来直接放弃vLLM改用Ollama,因为Ollama内部用了英伟达GPU架构检测,不会硬撞不支持的算子。
坑3:Modelfile参数覆盖问题
写Modelfile时定义的 PARAMETER temperature 只在用 ollama run 时生效。如果通过API调用且请求体里带了 options,API参数优先,会覆盖Modelfile里的设置。这其实是特性,但容易让人困惑,调参时不确定是哪个值生效,建议API调用时显式携带所有参数。
坑4:手动导入GGUF模型必须用完整路径
如果Modelfile里的文件路径写错了,Ollama不会报错,而是会尝试从网上把这个路径当成模型名下载,等好久才发现。避免办法:导入前 ls -lh Modelfile.gguf 确认文件存在,然后改成绝对路径。
坑5:docker volume权限
Ollama容器内以root运行,宿主机挂载卷的目录权限不对会导致模型下载或导入失败。如果遇到 permission denied,直接 chmod -R 777 ./ollama_models,内部工具不用太讲究。
坑6:中文Prompt问题
Ollama在中文场景下偶尔会输出英文甚至乱码,尤其是在调用系统提示词时。我们最终固定了模板,给系统消息加了「请始终使用中文回答」,效果改善很多。此外,Qwen2.5系列的tokenizer对中文支持明显好过Llama系列,这也是我们选Qwen的原因。
坑7:流式输出坑
如果客户端用 stream: true,Ollama返回的是NDJSON格式流,每一行是独立的JSON。很多同事用 json_decode 解析整个响应体,直接解析失败。要么用 stream: false,要么按行拆开解析。
坑8:请求队列无限堆积
Ollama自带队列机制,但并发高时请求会排队几十上百条。我们碰到过排队时间超过300秒,客户端误认为超时重试,结果把队列又填满了。解决办法:在网关层做流量控制,超过10个排队直接拒绝并返回「系统繁忙」。
总结建议
Ollama不是性能最强的,但一定是最省心的。对内部工具、小团队、单卡场景,它就是最优解。vLLM适合吞吐量要求极高、有充足开发时间的场景。FastChat这个方案基本可以不用考虑了,部署最慢、性能最差、坑还多。
最终生产环境我们跑的是Ollama 0.5.4 + Qwen2.5-14B-Instruct Q4_K_M,Docker Compose管理,网关层用PHP封装API。稳定性方面,连续运行了45天无崩溃,内存溢出次数为零。
如果这篇文章帮到你了,那我这些坑算没白踩。有问题评论区聊。