Ollama 并发怎么设:NUM_PARALLEL 实测与四种崩法

先说结论:16GB 内存的 M4 上把 NUM_PARALLEL 设成 4,四个并发请求总耗时从 7.69 秒压到 3.06 秒,单请求只慢两成。设成 16 会掉到 0.12 tok/s,而且接口不报错,客户端只会收到一个空字符串。

这篇的每个数字都在同一台机器上跑出来:Apple M4、16GB 统一内存、macOS 27.3、Ollama 0.40.14、qwen3:8b(Q4_K_M,权重 4.44GB)。Windows 加独显的机器绝对值会变,但三个参数的因果关系一样。

NUM_PARALLEL 改的是槽位数,不是线程数

很多人把它当成并发线程数,于是往 8、16 上堆。实际改的是推理时开几个 slot,每个 slot 独占一份上下文空间。启动日志里能直接看到分配结果:

llama_context: n_ctx      = 8192
llama_context: n_ctx_seq  = 4096
srv load_model: n_slots = 2, n_ctx_slot = 4096

上面这段是 NUM_PARALLEL=2、OLLAMA_CONTEXT_LENGTH=4096 时的真实输出。两槽各 4096,总上下文 8192。KV 缓存按总上下文一次性分配,所以显存消耗跟并行数近似线性,跟单槽上下文也线性。

查当前生效值最省事的办法是看 /api/ps,它把每个常驻模型的显存占用、上下文长度、过期时间都列出来:

curl -s http://127.0.0.1:11434/api/ps | python3 -m json.tool

返回里 size_vram 是显存字节数,context_length 是单槽上下文,不是总数。这两个字段一除,就能反推自己机器能撑几个槽。

三个档位的实测:总耗时、聚合吞吐、单请求速度

测法是固定变量:同一个 prompt、num_predict=128、temperature=0、seed=42,先预热一次把权重读进内存,再让 4 个线程用 barrier 同时发请求。四档配置各起一个独立的 serve 进程,端口分开,互不干扰。

NUM_PARALLEL 显存占用 4 请求总耗时 聚合吞吐 单请求速度 最慢请求等待
1 4.73 GB 7.69 s 17.95 tok/s 19.6 tok/s 7.69 s
2 5.40 GB 5.01 s 27.54 tok/s 17.0 tok/s 5.01 s
4 6.49 GB 3.06 s 46.73 tok/s 15.8 tok/s 3.06 s
16(单槽 8192) 10.50 GB 313.5 s 0.45 tok/s 0.12 tok/s 313.5 s

并行买的是吞吐,卖的是延迟。从 1 涨到 4,聚合吞吐 17.95 变成 46.73,2.6 倍;单请求速度 19.6 掉到 15.8,只掉两成。原因很朴素,batch 里塞进更多序列,GPU 的矩阵乘法被填得更满,单位时间算的 token 变多,代价是每条序列自己分到的时间片变少。

从 1 到 2 的收益最大,总耗时砍掉 35%。2 到 4 再砍 39%。这个边际递减在 8 槽以后基本消失,而显存是实打实往上涨。

每加一个槽要多少内存:按 0.6GB 估

三档的显存差可以直接算出每槽成本:4.73 到 5.40 是 0.67GB,5.40 到 6.49 是 0.55GB。在 qwen3:8b、单槽 4096 上下文的条件下,按每槽 0.6GB 估算是安全的。上下文减半,这个数也大致减半。

机器的可用显存不用猜,启动日志第一行就写:

msg="inference compute" library=Metal name=MTL0 description="Apple M4" total="11.8 GiB" available="11.3 GiB"

16GB 的统一内存,Metal 只认 11.3GiB。所以这台机器的账是 11.3 减去权重 4.44,剩 6.9GB 分给 KV 缓存和计算缓冲,数学上能开出十来个槽。但装得下不等于跑得快,这一层要单独测。

保守估算:可用显存减去权重,再减去 2GB 计算缓冲,剩下的除以每槽 0.6GB。照这个算,这台机器约 8 槽。这个数只告诉你上限在哪,不告诉你拐点在哪。拐点必须靠压测确定,方法就是上面那张表的四行,换成自己的机器跑一遍,半小时能出结论,比抄别人的推荐值可靠。

四种崩法,以及各自的判据

这一段是全文最实用的部分。四种失败的表现差别极大,但都不会在 HTTP 状态码上体现。

崩法一:HTTP 200,返回一个空壳

显存真被吃干净时,Metal 后端会在第一次 decode 就失败。serve 日志里的原始报错是这行:

ggml_metal_synchronize: error: command buffer 0 failed with status 5
error: Insufficient Memory (00000008:kIOGPUCommandBufferCallbackErrorOutOfMemory)
srv decode: Compute error. off = 0, n_batch = 512, ret = -3

接口这边收到的是 200,body 长这样:

{"model":"","created_at":"0001-01-01T00:00:00Z","response":"","done":false}

判据有三条,凑齐就基本可以定性:done 是 false,created_at 是全零时间,响应时间 0.02 秒。正常请求不可能在 20 毫秒内吐出完整 JSON。

更坑的是后续请求。runner 进了 error state 之后不会自愈,日志里明写 backend is in error state from a previous command buffer failure,之后每个请求都是 0.02 秒返回同一个空壳。客户端如果只判断 HTTP 200,会把它当成模型回了空字符串,然后一直重试,一直拿到空。

触发条件我复现了两次,都跟内存被别的东西占着有关。第一次是同一台机器上跑着 Ollama 桌面版(11434)和命令行 serve(11502)两个实例,两边都驻留了 qwen3:8b,第二个实例一 decode 就 OOM。第二次是桌面版卸载模型后留下一个 llama-server 进程没退,/api/ps 显示模型列表为空,但内存没还。

pgrep -fl llama-server
curl -s http://127.0.0.1:11434/api/ps

两行对照着看:ps 列表空但进程还在,说明内存没释放,重启 serve 再来。这也是我建议一个机器只留一个 serve 实例的原因。

崩法二:不报错,但慢到不能用

NUM_PARALLEL=16 配 OLLAMA_CONTEXT_LENGTH=8192,总上下文 131072,显存吃到 10.5GB,离 11.3GiB 的上限只差 800MB。没有 OOM,模型正常加载,请求全部 200,token 一个不少地返回。速度是 0.12 tok/s,16 个 token 花了 186.9 秒。

这比崩溃更难查,因为所有监控指标都是绿的。判断方法只有一个,端到端计时。任何一次请求超过你设定的超时时间,先去看 /api/ps 的 size_vram 是不是吃掉了可用显存的九成。

崩法三:串行排队,指标却完全正常

NUM_PARALLEL=1 时四个请求真实耗时 2.01、3.69、5.69、7.69 秒,严格排队,一个接一个。但每个请求返回的 eval_duration 只统计自己算 token 的时间,排队时间不在里面,所以每个请求自己算出来的速度还是 19.6 tok/s。

如果你在服务端日志里看 tok/s,会得出一切正常的结论。这是这一整套参数里最容易误判的地方。评估并发表现必须用客户端墙钟,不能用 eval_count 除以 eval_duration。

崩法四:以为常驻,其实被换出

设了 MAX_LOADED_MODELS=2,载入 qwen3:8b 再载入 qwen3.5:9b,然后回头看第一个。实测 /api/ps 里始终只有一个模型,回到 8b 时 load_duration 是 3.20 秒,说明它被驱逐过,又重新读盘。

载入 qwen3:8b       load 0.78s   ps: qwen3:8b 4.44GB
载入 qwen3.5:9b     load 3.86s   ps: qwen3.5:9b 5.07GB
回到 qwen3:8b       load 3.20s   ps: qwen3:8b 4.44GB

同一组操作在 MAX_LOADED_MODELS=1 下再跑一遍,三次 load 是 0.78、4.62、3.13 秒,行为几乎一样,第二个模型进来第一个照样被赶走。

MAX_LOADED_MODELS=2 是上限,不是承诺。调度器还要看显存余量,装不下就驱逐。16GB 机器上同时常驻两个 8B 级模型不现实,要么分两个端口跑,要么接受每次切换付 3 秒。

keep_alive:省的是 0.8 秒还是 3 秒

这个参数决定请求结束后模型在内存里待多久。默认 5 分钟,到期自动卸载。三种取值实测:

keep_alive 第一次请求 load 3 秒后再请求 load 3 秒后墙钟 8 秒后是否还在
0 0.80 s 0.80 s 2.20 s 否
5s 0.80 s 0.00 s 1.39 s 否(已过期)
10m 0.80 s 0.00 s 1.39 s 是

命中常驻时省 0.8 秒,占单次请求总耗时 37%。这个 0.8 秒是页缓存热的情况下,磁盘上 4.44GB 的权重已经在系统缓存里。冷盘第一次加载是 3.3 到 6.3 秒,实测第一次启动后第一请求 load_duration 4.91 秒,重启后 6.31 秒。所以「第一次特别慢」属于正常,跟 keep_alive 无关。

取值建议按场景分:交互式聊天设 30m,让人感觉不到加载;批处理跑完就走设 0,把显存立刻还给系统;多模型轮询的场景设 0 反而更快,因为反正要换出,留着只会让下一个模型装不下。

上线前的四条检查

第一条,确认环境变量真的生效。serve 启动日志里有一行 server config,把全部生效值打出来,0 表示自动:

msg="server config" env="map[OLLAMA_NUM_PARALLEL:4 OLLAMA_MAX_LOADED_MODELS:0
OLLAMA_CONTEXT_LENGTH:0 OLLAMA_KEEP_ALIVE:30m0s]"

第二条,用 /api/ps 看三个数:size_vram 有没有超过可用显存的八成,context_length 是不是你想要的单槽上下文,expires_at 是不是你期望的过期时间。

第三条,压测只用客户端墙钟。下面这段可以直接跑,改 base 和并发数即可:

import json, threading, time, urllib.request
base = "http://127.0.0.1:11434"
def call(i, out):
    body = json.dumps({"model":"qwen3:8b","prompt":"ping","stream":False,
                       "options":{"num_predict":128}}).encode()
    req = urllib.request.Request(base+"/api/generate", body,
                                 {"Content-Type":"application/json"})
    t = time.time()
    r = json.loads(urllib.request.urlopen(req, timeout=600).read())
    out[i] = (round(time.time()-t,2), r.get("eval_count"), r.get("error"))
out = [None]*4
ts = [threading.Thread(target=call, args=(i,out)) for i in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(out)

第四条,检查响应体是不是空壳。把 done 和 created_at 一起看,不要只看状态码。生产环境里这一条能省掉一整天的排查。

顺带一个容易踩的点:embedding 模型不能走 /api/generate,会返回 400 和一句 does not support generate,必须换成 /api/embed。我第一次做多模型常驻测试就是被这句拦住的,跟内存配置完全无关。想了解 KV 缓存本身怎么算,可以看 KV 缓存估算与实际占用的差距;把 Ollama 接到桌面客户端上的具体配置见 Harness 接本地 Ollama 的三个坑;想对比图像模型在同样显存下的表现,那篇 22GB 卡跑 1024 的实测给的峰值数据是同一套测法。另一个方向的用例,Mac 上用 Ollama 跑离线语音,见 Phonon 内置 TTS 的记录。

FAQ

NUM_PARALLEL 设得越大越好吗?到显存上限之前,总吞吐涨、单请求变慢,四个并发是个划算的平衡点。越过上限有两种下场,慢到 0.12 tok/s,或者直接返回 200 空壳。16GB 内存跑 8B 模型,4 是实测上限。

为什么服务端 tok/s 正常,用户还在抱怨慢?因为 eval_count 除以 eval_duration 不包含排队时间。并行数设成 1 时每个请求自称 19.6 tok/s,而最慢的那个请求实际等了 7.69 秒。判断并发性能必须用客户端端到端计时。

两个模型要同时用,怎么配?16GB 上做不到真常驻,MAX_LOADED_MODELS 只是上限,装不下照样驱逐,切换一次付 3 秒左右。可行的做法是分端口跑两个 serve 实例,但每个实例的显存预算要各自留够,两个实例都驻留 8B 模型的 16GB 机器会直接 OOM。