AI 私有化实战:一张卡能扛几个并发,答案不在显卡参数里

这是《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」的真实后果。

怎么自己验证这一讲对你有用

三步,五分钟:

  1. --parallel N 起服务,读 /props 里的 total_slots 和 n_ctx,看每路实际拿到多少上下文。
  2. 灌 N 路请求,量首字延迟 p95。
  3. 把 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 是完整版参考实现。它做四件事:

  1. 每档并发自己起一个 llama-server,跑完停掉。
  2. 读 /props 的 total_slots 和 n_ctx,作为「每路拿到多少上下文」的铁证。
  3. 灌 N 路并发请求,量显存增量、首字延迟、总延迟 p50/p95、吞吐。
  4. 支持 --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 能接受几路」的上限。取小的那个。如果你的答案只有前者,你还没算完。


这一讲的核心就三件事:

  1. 一个乘出来的数 :权重 + 路数 × 每路上下文 × 每 token 缓存,这才是并发要的显存。实测每 token 缓存约 12 KiB,每路 8192 token 占 96 MiB。
  2. 一条判据 :--ctx-size 是总池子,开并发必须连 ctx 一起乘,否则不叫开并发,叫切上下文。
  3. 一个代价:显存够了,延迟不一定够。8 路首字 7.2 倍,总吞吐只有 1.86 倍。先崩的是首字延迟。

下一讲我们讲怎么把首字延迟压下来------不是靠加卡,是靠把请求排好队。

相关推荐
用户360055579001 小时前
Kestrel_VLLM - 21 · 收益区间实测界定:KV 分页与 L3 驻留为何被判「不做」
人工智能
长弓三石1 小时前
企业级开源Harness平台BizBuddy 0.9.9 发布:知识库混合检索与长期记忆托管
人工智能
一线数智1 小时前
来腾讯云AI开放日 加入人类职业进化计划
大数据·人工智能·腾讯云
桦说编程1 小时前
别再追“最强模型”了,先想清楚你要什么
人工智能·llm·agent
loulanyue_1 小时前
机器换人不是终点,混合智能体重塑制造范式:从自主四要素到组织扁平化破局——读周济李培根院士2026东湖论坛国际智能制造创新论坛演讲实录
大数据·人工智能·制造
龙亘川2 小时前
适应人工智能发展、构建就业友好型发展方式
大数据·运维·人工智能·信息可视化·智慧城市
实验室管理云平台2 小时前
某国家重点实验室应用经费合同管理系统,实现经费实时监控
大数据·人工智能
武子康2 小时前
HunyuanVideo-1.5 首帧视频怎么做提示词对照?先查清 rewrite 是否生效
人工智能·后端·agent