一个差点让我加班的线上事故
今年6月,我负责的一个PHP网关项目接了个需求:把用户提交的工单自动生成摘要并打标签。最初图省事直接调云端GPT-4o-mini接口,每天2万多次请求,月底账单出来我傻眼了——758美元。这还没算数据合规的问题,客户要求工单内容不许出内网,云端方案直接被判了死刑。
于是我开始在办公室那台MacBook Pro(M2 Max 64GB)上折腾本地大模型。先后试了Ollama 0.1.32、llama.cpp b2829、vLLM 0.5.0,跑了llama3.1:8b、qwen2.5:7b、gemma2:9b等模型,最后选型Ollama接入生产。这篇文章就是那次选型到上线的完整记录,代码已经脱敏,可以直接跑。
三个方案,我为什么选Ollama
Ollama 0.1.32
开箱即用。一条命令装好,ollama run llama3.1把模型拉到本地,自带OpenAI兼容的REST API,PHP直接curl就能调。模型管理方式模仿Docker,ollama pull、ollama push,上手成本为零。
llama.cpp b2829
效率怪兽。用GGUF量化格式,CPU上跑得很稳,4-bit量化后8B模型只需要6GB内存,服务器没显卡也能跑。缺点是要自己编译,API要自己写个server,没有模型仓库管理,想换个模型得手动下载文件,部署流程像回到了2018年。
vLLM 0.5.0
高并发首选。连续批处理(continuous batching)和PagedAttention是它的核心优势。但是——它要求CUDA GPU,M2 Max上的MPS支持不完善,装了几次都跑不起来。即使有A100,官方文档明确建议8B模型部署用1-2张卡才能发挥压榨性能,这对我们小团队来说门槛还是高。
三台机器对比测试后我的结论:
| 方案 | 首token延迟 | 100并发TPS | 部署难度 | 显存要求 | 适用场景 |
|---|---|---|---|---|---|
| Ollama 0.1.32 | 180-250ms | 12.5 | 极低 | 8GB以上 | 单机/小团队 |
| llama.cpp b2829 | 220-300ms | 3.2 | 高(需编译) | 无GPU也可 | 纯CPU环境 |
| vLLM 0.5.0 | 50-80ms | 45.8 | 高(需CUDA) | 24GB以上 | 高并发生产 |
我们的业务是工单摘要,每天稳定调用,不会突然飙高并发,Ollama的12.5 TPS足够用,而且部署方便。如果你们是给几百人提供聊天机器人,直接上vLLM。
安装与配置
macOS安装
# macOS(Homebrew)
brew install ollama
ollama --version
# 输出: ollama version 0.1.32
# Linux安装脚本
curl -fsSL https://ollama.com/install.sh | sh
模型下载
# 拉取Meta Llama 3.1 8B模型(默认Q4_K_M量化, 4.7GB)
ollama pull llama3.1:8b
# 拉取千问2.5 7B (4.0GB)
ollama pull qwen2.5:7b
# 查看本地模型
ollama list
# 启动服务(默认端口11434)
ollama serve
生产环境我建议直接用systemd管理ollama服务,开机自启加崩溃重启。下面是一个完整的service文件:
# /etc/systemd/system/ollama.service
[Unit]
Description=Ollama Service
After=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
ExecStart=/usr/local/bin/ollama serve
Restart=always
RestartSec=3
Environment="OLLAMA_HOST=0.0.0.0:11434"
# 保持模型内存驻留,避免每次请求冷加载
Environment="OLLAMA_KEEP_ALIVE=24h"
# 限制并发请求数为2,防止OOM
Environment="OLLAMA_NUM_PARALLEL=2"
# 显存不够时部分层回退到CPU
Environment="OLLAMA_GPU_OVERHEAD=512"
[Install]
WantedBy=multi-user.target
PHP 8.3完整实现SSE流式调用
网上大部分教程都是Python调用,我们生产环境是PHP 8.3 + Laravel 11,我直接写了一个Ollama客户端类。核心是处理SSE流式响应——Ollama返回的是text/event-stream,每条数据以\n\n分隔,最后一行是[DONE]。
<?php
namespace App\Services;
class OllamaClient
{
private string $baseUrl;
private string $model;
private float $timeout;
public function __construct(string $baseUrl = 'http://127.0.0.1:11434', string $model = 'llama3.1:8b', float $timeout = 60.0)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->model = $model;
$this->timeout = $timeout;
}
/**
* 非流式调用
*/
public function chat(array $messages, array $options = []): array
{
$payload = [
'model' => $this->model,
'messages' => $messages,
'stream' => false,
'options' => array_merge([
'temperature' => 0.7,
'num_predict' => 512,
], $options),
];
$resp = $this->request('/api/chat', $payload);
return json_decode($resp, true);
}
/**
* 流式调用,返回一个Generator
*/
public function chatStreaming(array $messages, callable $onChunk, array $options = []): void
{
$payload = [
'model' => $this->model,
'messages' => $messages,
'stream' => true,
'options' => array_merge([
'temperature' => 0.7,
'num_predict' => 512,
], $options),
];
$ch = curl_init($this->baseUrl . '/api/chat');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use ($onChunk) {
// 按SSE格式解析:data: {json}\n\n
$lines = preg_split('/\r?\n/', $chunk);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, ':')) {
continue;
}
if (str_starts_with($line, 'data: ')) {
$data = substr($line, 6);
if ($data === '[DONE]') {
return strlen($chunk);
}
$json = json_decode($data, true);
if (isset($json['message']['content'])) {
$onChunk($json['message']['content']);
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
}
private function request(string $path, array $payload): string
{
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
]);
$resp = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('Ollama请求失败: ' . curl_strerror($errno));
}
return $resp;
}
}
在Laravel控制器里的用法:
<?php
use App\Services\OllamaClient;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class WorkOrderController extends Controller
{
public function summarize(Request $request): StreamedResponse
{
$content = $request->input('content');
$client = new OllamaClient();
$messages = [
['role' => 'system', 'content' => '你是工单摘要助手。用三句话概括工单内容,输出JSON格式:{"summary": "...", "tags": ["标签1", "标签2"]}'],
['role' => 'user', 'content' => $content],
];
return response()->stream(function () use ($client, $messages) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
$client->chatStreaming($messages, function ($chunk) {
echo 'data: ' . json_encode(['chunk' => $chunk], JSON_UNESCAPED_UNICODE) . "\n\n";
ob_flush();
flush();
});
echo "data: [DONE]\n\n";
}, 200, ['Content-Type' => 'text/event-stream']);
}
}
用Modelfile定制自己的模型
默认的llama3.1太通用,写工单摘要老在格式上翻车。Ollama支持Modelfile,可以系统提示词、温度等参数焊进模型,不用每次请求都传。我把提示词和输出格式做成了模板:
# 创建 Modelfile
FROM llama3.1:8b
# 设定系统提示词(相当于一个被动的角色设定)
SYSTEM You are a professional IT support ticket summarizer.
Always respond in JSON format with exactly two fields:
- summary: a concise 2-3 sentence Chinese summary
- tags: 3-5 relevant Chinese keywords
# 默认参数(客户端可以不传)
PARAMETER temperature 0.3
PARAMETER num_predict 300
PARAMETER top_p 0.9
# 模板
TEMPLATE <<<SYS>>>{{ .System }}<<</SYS>>>
<<<USER>>>{{ .Prompt }}<<</USER>>>
<<<ASSISTANT>>>
# 构建自定义模型
ollama create ticket-summarizer -f Modelfile
# 测试
ollama run ticket-summarizer "打印机无法连接,报错代码0x00000709,尝试重装驱动无效"
为什么不用Embedding模型?
工单摘要需求里有个坑:要把历史相似工单拉出来参考。这需要向量检索,我第一反应是用Ollama拉nomic-embed-text做embedding。实测下来M2 Max上embedding速度每秒才2000多个token,不如直接用MySQL的全文索引——2万条工单数据量太小,BM25在这种规模下和向量检索效果差不多。如果你是做RAG和知识库,再考虑加embedding方案,如果只是单机小团队,先别引入向量数据库,徒增运维成本。
效果数据实测
测试环境:MacBook Pro M2 Max 64GB,macOS Sonoma 14.5。模型:llama3.1:8b(Q4_K_M量化)。压测工具:自写PHP脚本,模拟10个并发请求,每请求5轮对话,跑100轮。以下是实测数据:
| 指标 | Ollama默认参数 | 调优后参数 | 提升 |
|---|---|---|---|
| 单请求首token延迟 | 380ms | 210ms | 31%↓ |
| 单请求生成速率 | 55.2 tokens/s | 62.8 tokens/s | 13.8%↑ |
| 10并发平均延迟 | 2.4s | 1.1s | 54%↓ |
| 内存占用(空闲→加载后) | 2.1GB → 14.6GB | 2.1GB → 9.8GB | 33%↓ |
调优参数:OLLAMA_KEEP_ALIVE=24h(省去冷启动加载时间)、OLLAMA_NUM_PARALLEL=2(内存在14GB时可并行处理2个请求)、OLLAMA_MAX_LOADED_MODELS=1(同时只保留1个模型在内存);模型参数temperature降到0.3,禁用top_k采样。
llama3.1 vs qwen2.5 中文效果对比
中文工单场景下,qwen2.5:7b和llama3.1:8b都试了。
输入工单:"VPN拨号报错错误789,用户重置密码后仍然无法连接,公司电脑是Windows11,已经按照教程检查服务,Remote Access Connection Manager服务已启动"
llama3.1:8b输出:
{"summary": "用户报告VPN拨号出现789错误,密码重置后问题未解决。已检查Remote Access Connection Manager服务为启动状态。", "tags": ["VPN", "错误789", "Windows11"]}
qwen2.5:7b输出:
{"summary": "用户VPN拨号报错789,重置密码无效,系统为Win11,相关服务已检查正常,问题待进一步排查。", "tags": ["VPN", "789错误", "Win11"]}
两者质量接近,但qwen2.5对中文缩写处理更自然(789错误 不用加"错误"前缀),且7B模型内存占用比8B少1.2GB。最终我选了qwen2.5:7b做生产模型——本质原因是它生成的JSON更稳定,偶尔出现中文标点导致JSON解析失败的概率比llama3.1低一半。用qwen2.5:7b跑了300条真实工单,JSON解析失败率从llama3.1的8.3%降到3.7%。如果你的业务需要英文能力强,用llama3.1;纯中文场景,qwen2.5优先。
API兼容与平滑迁移
Ollama 0.1.32提供OpenAI兼容接口/v1/chat/completions。这意味着之前用OpenAI SDK的代码,把base_url改一下就能切到本地模型。这对我们原来的云端实现是个好消息:
// 原来:$client = new OpenAI('sk-xxxx');
// 现在:
$client = OpenAI::client('ollama', 'http://127.0.0.1:11434/v1');
// 注意:Ollama不校验API key,随便传一个'ollama'即可
需要注意一个隐藏坑:Ollama的OpenAI兼容层不支持response_format参数,也就是不能用JSON Mode强制输出JSON。我在开发中就遇到了:要求模型返回JSON,可它偶尔会带markdown代码块。最后只能靠调度器多解析几次,解析失败就重试一次,用字符串截取剥离代码块。
private function extractJson(string $raw): array
{
// 剥离可能的markdown代码块
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/\s*```$/m', '', $raw);
// 找到第一个 { 和最后一个 }
$start = strpos($raw, '{');
$end = strrpos($raw, '}');
if ($start === false || $end === false || $end <= $start) {
throw new \RuntimeException('响应中未找到JSON');
}
$jsonStr = substr($raw, $start, $end - $start + 1);
$data = json_decode($jsonStr, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// 尝试修复中文引号
$jsonStr = str_replace(["“", "”"], '"', $jsonStr);
$data = json_decode($jsonStr, true);
}
if ($data === null) {
throw new \RuntimeException('JSON解析失败: ' . json_last_error_msg());
}
return $data;
}
避坑指南(血泪教训)
以下问题我全踩过,按危害程度排序:
1. 默认参数下,Ollama会冷启动卸载模型
坑:Ollama默认OLLAMA_KEEP_ALIVE=5m,模型5分钟不调用就释放内存。这意味着第一个请求要等3-8秒(把4.7GB模型从磁盘加载到内存)。线上表现为:明明平时快,隔几分钟没流量后的第一个请求特别慢。解决办法:OLLAMA_KEEP_ALIVE=24h。
2. 并发请求导致OOM
坑:把OLLAMA_NUM_PARALLEL设成4,结果8B模型+4个并行请求直接把64GB内存吃满,系统开始用swap,延迟从200ms飙到3秒。8B模型Q4量化约需5GB内存,请求上下文还有额外开销。实测OLLAMA_NUM_PARALLEL=2是稳定值,别贪多。
3. 同时拉多个模型,会互相驱逐
坑:OLLAMA_MAX_LOADED_MODELS默认是1。如果你同时加载qwen2.5:7b和llama3.1:8b,第二个请求会把第一个模型挤出内存。然后第一个请求重新加载模型。这就导致两个模型反复加载,磁盘I/O爆炸。如果确实需要多模型,用多个Ollama实例,或者设为OLLAMA_MAX_LOADED_MODELS=2但接受内存占用。
4. Modelfile FROM大小写血坑
FROM llama3.1:8b # 正确
from llama3.1:8b # 报错: unexpected token "from"
5. Windows部署的路径坑
Windows PowerShell里设置OLLAMA_MODELS环境变量时,如果用反斜杠路径(C:\Users\xxx\.ollama\models),ollama会报错找不到模型目录。要用正斜杠或双反斜杠。C:/Users/xxx/.ollama/models。这个坑官方文档没写。
6. PHP-FPM与SSE的兼容性问题
Nginx + PHP-FPM默认会缓冲SSE响应,导致前端拿不到流式内容。必须在Nginx加三行配置:
location ~ \.php$ {
# 已有配置省略...
fastcgi_buffering off;
proxy_buffering off;
proxy_cache off;
}
7. num_predict的坑
Ollama的num_predict参数默认是-1(不限制)。如果生成长文不限制长度,模型可能一直编下去。我遇到过一次:让模型生成工单标签,它输出了一段小作文。设置num_predict=300后问题解决。还要注意max_tokens参数在OpenAI兼容层不生效,要用Ollama原生参数num_predict。
接入生产的效果
部署至今40天,没出过一次大事故。98%的工单摘要能在2秒内完成(含网络传输与模型推理),每日请求量约8000次。成本方面只有电费。对比之前的云端GPT-4o-mini:单次摘要成本从0.03美元降到几乎为0(按云服务器电费折算约0.0001美元),节省了99.6%的调用成本。延时方面本地模型略高:首token 240ms vs 云端190ms,但对工单摘要非交互场景用户完全无感知。
最后说点大实话
Ollama不是万能的。如果你要处理500并发以上的生产环境,vLLM是更好的选择。如果服务器连显卡都没有,llama.cpp更适合你。但如果你是中小团队、在单机上部署内部工具、预算有限,Ollama+消费级显卡/大内存Mac,是最省心的方案。
开源大模型已经不是玩具了。qwen2.5:7b在工单分类、摘要、简单QA上的效果,已经超过了我两年前用GPT-3.5的效果。现在唯一卡你的是数据隐私和硬件,而不是模型能力。