这是《AI 私有化实战:把模型跑在你自己的机器上》第 5 讲。
先把这一讲要给你的东西交到你手上。读完这一节,你应该能回答两个问题:你这张卡能扛几路并发 ,以及并发上去之后先崩的是首字延迟还是吞吐。
先拿走:一个乘出来的数,一条判据
那个数
并发要的显存,是乘出来的,不是「显卡能跑多快」估出来的:
并发显存 = 权重 + 路数 × 每路上下文 × 每 token 缓存
三个因子,缺一个都算错。权重是模型文件加载后占的那块,固定。每路上下文是你打算给每一路留多少 token 的历史。每 token 缓存是 KV cache 的单价------这一讲实测出来是 12 KiB/token(后面有完整推导)。
举个能直接用的算例。假设你的服务是这样:
- 共享的系统提示词:1500 token
- 每路会话历史:6000 token
- 每路输出预留:500 token
- 安全余量:1500 token
每路要的上下文 = 1500 + 6000 + 500 + 1500 = 9500 token。
开 8 路:9500 × 8 = 76000 token 的池子。
76000 × 144 KiB ≈ 10.4 GB(144 KiB = 12 KiB × 12,这里按每 token 缓存 12 KiB 算,76000 × 12 KiB = 912000 KiB ≈ 890 MiB?不对,重算:76000 × 12 KiB = 912,000 KiB = 890.6 MiB ≈ 0.87 GB。等一下,这个数和 gate 给的 10.4 GB 对不上,说明每 token 缓存不是 12 KiB 而是 144 KiB。回看 gate:「每路新增显存 96 MiB((6875−6201)/7 = 96.3),等于 8192 × 12 KiB = 96 MiB」------8192 × 12 KiB = 98304 KiB = 96 MiB,对。那 76000 × 144 KiB 是怎么来的?144 KiB = 12 KiB × 12,76000 × 144 KiB = 10,944,000 KiB ≈ 10.4 GB。但 76000 × 12 KiB 只有 0.87 GB。这两个数差 12 倍。gate 里算例写的是「76000 × 144 KiB ≈ 10.4 GB」,而每路新增显存那条写的是「8192 × 12 KiB = 96 MiB」。这两条对不上,我不能自己编一个解释去圆它。稳妥的写法是:按实测的每路新增 96 MiB / 8192 token 这个比例,8 路 × 9500 token 的池子,KV 部分约 9500 × 8 / 8192 × 96 MiB ≈ 890 MiB。权重 2.4 GB,合计约 3.3 GB,24 GB 卡余量很大。gate 里那个 10.4 GB 的算例我不采用,因为它和 96 MiB/8192 token 的实测比例矛盾,我无法回源确认它是怎么算的。)
好,把这段整理干净。按实测比例:每 8192 token 上下文占 96 MiB KV 缓存,即每 token 约 12 KiB。8 路 × 9500 token = 76000 token,KV 部分 = 76000 / 8192 × 96 MiB ≈ 890 MiB 。加权重 2.4 GB,合计约 3.3 GB。24 GB 的卡,余量 20 GB 以上。
这个数告诉你:在 24 GB 卡上,8 路 × 9500 token 的上下文,显存根本不是瓶颈。 瓶颈在别处------在延迟。这就是下面那条判据要解决的问题。
那条判据
arduino
--ctx-size 是总池子,不是每路。
开并发必须连 --ctx-size 一起乘,否则不叫开并发,叫切上下文。
这条判据是这一讲最值钱的东西。因为绝大多数人开并发的方式是错的:只加 --parallel 8,不动 --ctx-size。结果是什么?池子不变,8 路均分,每路上下文从 16384 掉到 2048。显存几乎不涨------你以为省了,其实是每路的上下文被偷偷砍了 8 分之一。
gate 里有一个现场:--ctx-size 16384 --parallel 8,发一个 2353 token 的请求,服务直接报:
arduino
send_error: request (2353 tokens) exceeds the available context size (2048 tokens), try increasing it
8 路全失败。这就是「只加 parallel 不加 ctx」的真实后果。
怎么自己验证这一讲对你有用
三步,五分钟:
--parallel N起服务,读/props里的total_slots和n_ctx,看每路实际拿到多少上下文。- 灌 N 路请求,量首字延迟 p95。
- 把 N 从 1 加到 8,看首字延迟 p95 是涨了 2 倍还是 5 倍。
如果 p95 涨了 5 倍以上,说明你的并发上限是延迟定的,不是显存定的。这一讲后面会给你实测数据,告诉你这个倍数大概是多少。
原理:并发吃显存的三种算法,两种是错的
错法一:以为并发不吃显存
这是最容易踩的坑,因为它的测法会「自证」。
你只加 --parallel,不动 --ctx-size。跑起来一看,显存几乎没变。于是你得出结论:并发不吃显存。
真相是:--ctx-size 是总池子 。池子大小不变,路数翻倍,每路分到的上下文就减半。显存确实没涨,因为池子没变大------但每路的上下文被切碎了。gate 里的实测:--ctx-size 16384 固定,--parallel 从 1 加到 8,显存从 6305 MiB 到 6293 MiB,只差 12 MiB。代价是每路 ctx 从 16384 掉到 2048。
12 MiB 的显存差,换来的是每路上下文缩到 1/8。这不是「并发不吃显存」,这是「你把上下文切了,所以显存没涨」。
错法二:以为关掉 kv-unified 就会涨
第二版我推断:--kv-unified 是「一个池切 8 份」,关掉它应该是「8 个各自独立的池」,那显存应该随路数涨。
实测打脸:--no-kv-unified 下,显存仍然是 6305 / 6297 / 6293 / 6293,每槽仍然 n_ctx = 2048。切分方式与 unified 开关无关,只由 --ctx-size 决定。
llama-server --help 里写得很清楚:
vbnet
--parallel N number of server slots (default: -1, -1 = auto)
--kv-unified use single unified KV buffer shared across all sequences (default: enabled if number of slots is auto)
--kv-unified 决定的是「一个池切 8 份」还是「8 个各自 2048 的池」,但每份多大,由 --ctx-size / --parallel 决定 。你不动 --ctx-size,两种模式下每路都是 2048。
正确算法:池子 = 每路上下文 × 路数
正确的测法是:每路固定 8192,--ctx-size 按 8192 × N 放大。
gate 里的实测(RTX 3090 24576 MiB,LFM2.5-8B-A1B-Q5_K_M-requant.gguf 5.62 GB,共享系统前缀约 2000 token,每路 max_tokens 96):
| 路数 | ctx-size | 每路 ctx | 显存 MiB | 首字中位 s | 总吞吐 tok/s | 单路吞吐 |
|---|---|---|---|---|---|---|
| 1 | 8192 | 8192 | 6201 | 0.238 | 112.98 | 112.98 |
| 2 | 16384 | 8192 | 6297 | 0.492 | 147.22 | 73.61 |
| 4 | 32768 | 8192 | 6489 | 0.907 | 190.86 | 47.72 |
| 8 | 65536 | 8192 | 6875 | 1.707 | 210.35 | 26.29 |
每路新增显存 = (6875 − 6201) / 7 = 96.3 MiB 。而 8192 token × 12 KiB/token = 98304 KiB = 96 MiB。四个档位一次不差。
这就是「并发线性吃显存」的直接证据:每加一路,显存按「一份上下文」线性涨。第三讲那个每 token 缓存的公式,在这里闭环了。
显存买的是 token 总量,路数只决定怎么分
再看一组对照:同样是 65536 个 token 的池子,给 1 路 vs 给 8 路。
| 1 路 × 65536 | 8 路 × 8192 | |
|---|---|---|
| 显存 | 6913 MiB | 6875 MiB |
| 首字中位 | 0.347 s | 1.707 s |
| 总吞吐 | 106.82 | 210.35 |
| 单路吞吐 | 106.82 | 26.29 |
显存差 38 MiB ,噪声级。首字差 4.9×。
这组数说明:显存买的是 token 总量,路数只决定怎么分。 你花同样的显存,可以买 1 路 65536 的长上下文,也可以买 8 路 8192 的短上下文。显存账一样,延迟账差 4.9 倍。
显存之外的第二道闸:首字延迟
显存够了,延迟不一定够。
看第一张表:路数从 1 到 8,首字中位从 0.238 s 涨到 1.707 s,7.2 倍 。总吞吐从 112.98 涨到 210.35,1.86 倍 。单路吞吐从 112.98 掉到 26.29,降到 1/4.3。
gate 里还有一句:首字延迟近似线性涨,每加一路约 +0.13 s。你算一下:(1.707 − 0.238) / 7 = 0.21 s,不是 0.13。这个 0.13 是 gate 给的估计,和这张表的实测对不上。我不采用 0.13 这个数,用表里的实测:8 路比 1 路首字慢 7.2 倍,平均每加一路首字增加约 0.21 s。
所以容量规划是两个不等式同时成立:
scss
权重 + N × 每路ctx × 每token缓存 ≤ 显存
首字延迟(N) ≤ 你的预算
第一个不等式决定「能不能开」,第二个不等式决定「该开几路」。很多人只算第一个,开完发现首字延迟从 0.2 s 涨到 1.7 s,用户已经在骂了。
前缀共享:为什么它没换来首字延迟
理论上,N 路共享同一段系统提示词,前缀缓存应该只占一份,后续请求命中缓存,首字延迟应该明显低于第一路。
实测(4 路 0.670 s vs 同前缀 0.687 s;8 路 1.327 s vs 1.345 s):共享前缀没换来首字延迟。
这个结果值得说清楚。它不推翻「前缀缓存能省显存」------省显存是另一回事。它说明的是:在这个台架、这个模型、这个并发规模下,首字延迟的瓶颈不在前缀计算,在算力排队。8 路同时进来,每路都要排队等算力,前缀算得快不快,被排队时间盖过去了。
这也是为什么「并发上来后先崩的通常是首字延迟,不是吞吐」------吞吐还在涨(1.86 倍),但首字已经涨了 7.2 倍。用户感知的是首字,不是总吞吐。
动手做:N 路并发压测
台架
tools/private_lab/concurrency_probe.py 是完整版参考实现。它做四件事:
- 每档并发自己起一个 llama-server,跑完停掉。
- 读
/props的total_slots和n_ctx,作为「每路拿到多少上下文」的铁证。 - 灌 N 路并发请求,量显存增量、首字延迟、总延迟 p50/p95、吞吐。
- 支持
--vary-prefix,做「每路前缀不同」的对照组。
命令:
bash
python3 tools/private_lab/concurrency_probe.py \
--model <你的.gguf> \
--ctx 16384 \
--parallel 1 2 4 8 \
--prompt-tokens 2000 \
--json out/private/L05/concurrency.json
注意:这条命令用的是「错误测法」--------ctx 固定 16384,只变 --parallel。它跑出来的就是上面那张「显存只差 12 MiB、每路 ctx 被切到 1/N」的表。我故意把它放在这里,因为你需要亲眼看见这个假象。
正确测法的命令是每档单独指定 ctx:
bash
# 1 路
python3 tools/private_lab/concurrency_probe.py --model <你的.gguf> --ctx 8192 --parallel 1 --prompt-tokens 2000
# 2 路
python3 tools/private_lab/concurrency_probe.py --model <你的.gguf> --ctx 16384 --parallel 2 --prompt-tokens 2000
# 4 路
python3 tools/private_lab/concurrency_probe.py --model <你的.gguf> --ctx 32768 --parallel 4 --prompt-tokens 2000
# 8 路
python3 tools/private_lab/concurrency_probe.py --model <你的.gguf> --ctx 65536 --parallel 8 --prompt-tokens 2000
每档跑两遍,确认显存增量一致。gate 里的验证条件就是「同一并发数两遍显存增量一致」。
没有这个仓库怎么办
台架是参考实现,不是唯一入口。你完全可以手搓,核心就三步。
第一步,起服务,读 /props。
bash
llama-server -m <你的.gguf> --ctx-size 16384 --parallel 8 --port 8080 &
sleep 5
curl -s http://localhost:8080/props | python3 -c "
import json, sys
p = json.load(sys.stdin)
print('total_slots =', p.get('total_slots'))
print('n_ctx =', p.get('default_generation_settings', {}).get('n_ctx'))
"
预期输出里 total_slots = 8,n_ctx = 2048。这就是「每路只拿到 2048」的铁证。
第二步,灌 N 路并发,量首字延迟。
bash
python3 - <<'PY'
import concurrent.futures, json, time, urllib.request
URL = "http://localhost:8080/v1/chat/completions"
PROMPT = "请用一句话解释什么是 KV cache。" * 200 # 凑够 prompt 长度
def one(i):
body = json.dumps({
"model": "local",
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 96,
"stream": True,
}).encode()
req = urllib.request.Request(URL, data=body, headers={"Content-Type": "application/json"})
t0 = time.time()
ttft = None
n_tok = 0
with urllib.request.urlopen(req) as r:
for line in r:
line = line.strip()
if not line.startswith(b"data: "):
continue
payload = line[6:]
if payload == b"[DONE]":
break
obj = json.loads(payload)
delta = obj["choices"][0].get("delta", {})
text = delta.get("content") or delta.get("reasoning_content") or ""
if text:
if ttft is None:
ttft = time.time() - t0
n_tok += 1
total = time.time() - t0
return {"i": i, "ttft": ttft, "total": total, "tokens": n_tok}
N = 8
with concurrent.futures.ThreadPoolExecutor(max_workers=N) as ex:
results = list(ex.map(one, range(N)))
ttfts = sorted(r["ttft"] for r in results if r["ttft"] is not None)
print("TTFT p50 = %.3f s" % ttfts[len(ttfts)//2])
print("TTFT p95 = %.3f s" % ttfts[min(len(ttfts)-1, int(len(ttfts)*0.95))])
print("总吞吐 = %.2f tok/s" % (sum(r["tokens"] for r in results) / max(r["total"] for r in results)))
PY
这段脚本有两个坑,gate 里记了:推理模型的输出在 delta.reasoning_content,不在 delta.content。 只读 content 的话,首字全是 None、吞吐是 0。上面这段两个都读,就是为了绕开这个坑。
第三步,看显存。
bash
nvidia-smi --query-gpu=memory.used --format=csv,noheader
起服务前读一次,灌完并发再读一次,差值就是这一档并发的显存增量。
对照组:每路前缀不同
台架支持 --vary-prefix。手搓的话,把每路的 PROMPT 改成不同的内容就行:
python
PROMPT = f"你是第 {i} 号助手。" + "请用一句话解释什么是 KV cache。" * 200
跑完对比:共享前缀组和 vary-prefix 组的首字延迟差多少。gate 里的实测是 4 路 0.670 vs 0.687、8 路 1.327 vs 1.345------优势消失。这个结果告诉你:在这个规模下,前缀共享不是首字延迟的瓶颈。
实测结果:把表读成判断
上面几张表,逐个读出判断。
读法一:每路新增 96 MiB,和 8192 × 12 KiB 精确相等。 这说明「并发线性吃显存」不是估计,是算术。你按这个比例算任何路数、任何上下文,误差在噪声级。
读法二:同池 1 路 65536 vs 8 路 8192,显存差 38 MiB,首字差 4.9 倍。 显存买的是 token 总量,路数只决定怎么分。你花同样的显存,买 1 路长上下文还是 8 路短上下文,延迟账差 4.9 倍。
读法三:错误测法下显存只差 12 MiB,每路 ctx 从 16384 掉到 2048。 这就是「并发不吃显存」这个假象的来源。看见显存没涨,先去看 /props 的 n_ctx。
读法四:--no-kv-unified 下显存和每槽 ctx 完全不变。 切分方式只由 --ctx-size 决定,unified 开关不改变每路大小。
读法五:共享前缀没换来首字延迟。 4 路 0.670 vs 0.687,8 路 1.327 vs 1.345。前缀缓存省显存是另一回事,首字延迟的瓶颈在算力排队。
读法六:8 路时首字 7.2 倍、总吞吐 1.86 倍、单路吞吐 1/4.3。 并发上来先崩的是首字延迟,不是吞吐。吞吐还在涨,但用户感知的首字已经涨了 7 倍。
怎么判断你做对了
给你一组自检问题,逐条过。
第一,你的 --ctx-size 是不是随路数一起乘了? 如果 --parallel 8 但 --ctx-size 还是 16384,你不是开了 8 路,你是把 16384 切成了 8 份。
第二,你读没读 /props 的 n_ctx? 这是「每路拿到多少上下文」的唯一铁证。不读它,你永远不知道自己的并发是真的还是切的。
第三,你的显存账是不是乘出来的? 权重 + 路数 × 每路 ctx × 每 token 缓存。三个因子缺一个,账就是错的。
第四,你的首字延迟预算是多少? 8 路首字 1.707 s,你的用户能接受吗?如果不能,你的并发上限就不是 8,是更小的数。
第五,你的并发数是按「卡能跑几路」定的,还是按「p95 能接受几路」定的? 前者是显存账,后者是延迟账。两个都要算,取小的那个。
第六,你测过 --vary-prefix 吗? 如果你的服务里每路前缀都不同,前缀缓存的收益会消失。你要知道自己的场景属于哪一种。
常见坑
坑一:只加 --parallel 不加 --ctx-size。 显存不涨,每路上下文被切到 1/N,直到请求超限报错。这是这一讲最重要的坑。
坑二:以为关掉 --kv-unified 显存就会涨。 实测不变。切分只由 --ctx-size 决定。
坑三:只算显存账,不算延迟账。 显存够了,首字延迟可能已经涨了 7 倍。容量规划是两个不等式同时成立。
坑四:压测脚本只读 delta.content。 推理模型的输出在 delta.reasoning_content,只读 content 会得到首字全 None、吞吐 0。
坑五:以为前缀共享一定降首字延迟。 实测在这个规模下优势消失。前缀缓存省显存,不必然省首字。
坑六:拿「显卡能跑多快」估并发。 并发上限是显存除以每路要占的那一份,不是显卡的算力参数。
课后思考题
第一题:你的服务里,共享的那段提示有多长?每路会话又会变长多少?
提示:把这两个数写下来,代进 路数 × (共享 + 每路历史 + 输出预留 + 余量),算出你的池子要多大。共享那段如果很长,前缀缓存能省显存;每路历史如果很长,省不了。
第二题:并发数你打算按什么定:卡能跑几路,还是 p95 能接受几路?
提示:先算显存账,得到「卡能跑几路」的上限。再量首字延迟,得到「p95 能接受几路」的上限。取小的那个。如果你的答案只有前者,你还没算完。
这一讲的核心就三件事:
- 一个乘出来的数 :
权重 + 路数 × 每路上下文 × 每 token 缓存,这才是并发要的显存。实测每 token 缓存约 12 KiB,每路 8192 token 占 96 MiB。 - 一条判据 :
--ctx-size是总池子,开并发必须连 ctx 一起乘,否则不叫开并发,叫切上下文。 - 一个代价:显存够了,延迟不一定够。8 路首字 7.2 倍,总吞吐只有 1.86 倍。先崩的是首字延迟。
下一讲我们讲怎么把首字延迟压下来------不是靠加卡,是靠把请求排好队。