本地部署大模型:8 人同时用,速度会变成 1/8 吗?实测 3.35 倍
本文部分内容由 AI 辅助整理,所有配置、命令、数据均标注了出处;压测脚本为本人在本机真实运行并贴出原始输出。
接到一个问题:本地部署的大模型,要是 8 个人同时用,速度会不会变成原来的 1/8?
直觉上好像是–一台机器的算力就那么多,8 个人分,每人 1/8,天经地义。我在「2 台 DGX Spark 跑 DeepSeek V4 Flash 0731」这套部署上实测了一下,不是 1/8,是 3.35 倍。8 个人同时用,整体吞吐不是掉到 1/8,反而是单人的 3.35 倍;每个人单个变慢了一点,但加起来快了。
这篇就把这件事讲清楚:3.35 倍怎么来的、为什么不是 8 倍也不是 1/8、拐点在哪、怎么在自己机器上测。文末附一个零依赖的并发压测脚本,打 1/2/4/8 并发,把这条曲线跑给你看。
一、先说清楚:哪台机器、哪份数据
模型是 deepseek-ai/DeepSeek-V4-Flash-0731,上下文 100 万 token,纯文本。机器是两台 DGX Spark(单台 128 GB 统一内存,合计 256 GB),网线直连组成 TP=2,跑 vLLM 的 DSpark 镜像,权重用官方 FP8,没有自行量化。
这篇里所有 tok/s,都是我们自己机器上实测的,只代表这一套环境、这一个模型版本、这一组参数,不是行业通用值。 换镜像、换网络、换并发数,结果都会变–下面的数是参照,不是承诺。
上篇讲的是「上下文一拉长,吞吐掉到 9%」–那是输入变长对速度的影响。这篇讲的是另一个维度:用户变多对速度的影响。两件事都关乎吞吐,但变量不同,别混。
先说一个反直觉的前提,它决定了后面所有结论:
大模型生成文字的速度,瓶颈是内存带宽,不是算力。 自回归解码每吐 1 个 token,都要把激活的权重从内存读一遍。GPU 算那个乘加快得很,但要等数据从内存搬过来。所以 decode 阶段的速度,约等于「内存带宽 ÷ 激活权重大小」。这条规律是后面「为什么是 3.35 倍而不是 8 倍」的全部根源。
二、8 个人同时用,速度会变成 1/8 吗?
先说结论,我方实测的两个点:
| 并发 | 总吞吐(tok/s) | 单流(tok/s) | 相对 1 并发 |
|---|---|---|---|
| 1 人 | 84.89 | 84.89 | 1.00× |
| 8 人 | 284.80 | 35.60 | 3.35× |
两个数都是 decode 阶段(生成阶段,从首 token 到末 token),不含建连和首 token 等待。
看出来了吗:
- 整体没变慢,反而快了 3.35 倍。 8 个人一起用,总吞吐 284.80,是 1 个人的 3.35 倍,不是 1/8。
- 每个人单个变慢了,但没慢到 1/8。 单流从 84.89 降到 35.60,降了约 58%;如果真是「1/8 速度」,单流该掉到 10.6(降 87.5%)。实际没掉那么多。
- 也不是 8 倍。 如果加并发零成本,8 个人该是 8×84.89=679。实际只有 284.80,不到一半。
所以「8 人同时用 = 1/8 速度」这个直觉,两头都不沾。真实情况是:整体变快、个人变慢、但都不是按人数等比例。
三、3.35 倍这个数怎么算的
把上面那张表的数掰开:
倍数 = 8 并发总吞吐 ÷ 1 并发总吞吐 = 284.80 ÷ 84.89 = 3.35
单流降幅 = 35.60 ÷ 84.89 = 0.419(降到原来的 42%,降了 58%)
关键不是 3.35 这个数本身,而是它夹在 1 和 8 之间–既不是 1(说明多人用确实吃了更多带宽),也不是 8(说明带宽不是无限的)。这个「夹在中间」的性质,正是内存带宽在起作用。
四、为什么不是 8 倍:带宽是共享的
先想「为什么不是 8 倍」–这是大多数人没想到的那一头。
1 个人用的时候,那台机器的内存带宽其实没吃满。84.89 tok/s 离带宽上限还远得很,相当一部分带宽闲着没人用。所以你再加几个人,总吞吐还能继续涨–闲着的带宽被用上了。
但带宽是有上限的。加到某个人数,带宽吃满了,再往上加,总吞吐就不涨了。我方这套环境的带宽上限,恰好就在 284.80 tok/s 附近–8 个人已经把它顶满了。
这就是为什么不是 8 倍:第 1 个人没把带宽用完,第 2、3、4 个人在用剩下的;但用完之后,第 5、6、7、8 个人没法再多榨出带宽,只能一起挤在这条上限上,每人分到的更少。
五、为什么不是 1/8:总吞吐反而涨
再想「为什么不是 1/8」–这是更普遍的误解。
如果真的是「8 个人分 1 份算力」,那 8 并发的总吞吐该等于 1 并发,都是 84.89–只是被切成 8 份。但实测 8 并发总吞吐是 284.80,是 1 并发的 3.35 倍。
原因就是上一节说的:1 个人用的时候带宽没吃满,有大量带宽被浪费了。8 个人一起用,把那些原本浪费的带宽用上了,所以整体反而比 1 个人快。
打个比方:一条 8 车道的高速公路,1 辆车跑的时候只占 1 条道,另外 7 条空着。8 辆车一起上,能占满 8 条道,总流量是 1 辆车时的 8 倍吗?不是–因为收费站(带宽上限)一次只放行这么多。但总流量肯定比 1 辆车大,因为闲置的车道被用上了。3.35 倍,就是「8 条道用上了、但收费站卡住了」这个状态。
所以「多人同时用」不会让整体变慢,反而整体变快。代价是每个人单个慢一点–这是带宽共享的必然,不是机器不行。
六、拐点在哪:3.35 之后再加,只摊薄
那加到几个人就到头了?
临界点是个除法:带宽上限 ÷ 单流速度 = 284.80 ÷ 84.89 = 3.35。
- 并发数 ≤ 3.35(线性段):每加 1 个人,总吞吐涨 84.89。2 人=169.78,3 人=254.67。带宽还没吃满,加人就增产。
- 并发数 > 3.35(饱和段):总吞吐封顶在 284.80,再加人总吞吐不涨,只是把同样的总吞吐切成更多份–每个人单流继续降。4 人时单流约 71,8 人时单流 35.6。
这个拐点就是「再加并发还有没有意义」的分界线。过了它,加用户不再提升整体速度,只摊薄每个人的体验。
⚠️ 我方只实测了 1 并发和 8 并发这两个点,中间的 2、3、4 没单独测。上面的拐点 3.35 是用带宽模型推的(拿实测的 84.89 和 284.80 反推),方向可信,精确值以你自己机器实测为准。文末脚本能帮你把中间点跑出来。
另外补一句配置层面的背景:我方这套环境的连续批处理槽位设的是 MAX_NUM_SEQS=6,意思是引擎同时处理 6 个请求,超过的排队。8 并发其实已经超出槽位了–那超出的 2 个会怎样、排多久,这篇不展开,因为它涉及排队策略,我没测全。留个问题在这,文末会再说。
七、脚本:在你自己机器上跑一遍
光看我的数没用,你的机器配置不同,拐点就不同。写了个零依赖的压测脚本,打 1/2/4/8 并发,每个并发同时发流式请求,测 decode 总吞吐和倍数。
脚本只测 decode 阶段(首 token 到末 token),不碰首 token 延迟–因为首 token 延迟受 prefill 影响,是另一个维度的事,混在一起测不清。
我用一个本地 mock 服务跑了一遍。这个 mock 按我方实测的单流上限 84.89 和带宽上限 284.80 建了个「带宽争抢」模型:n 个请求同时在场时,每个请求的速度自动降到 min(84.89, 284.80/n)。它不是真实推理引擎,只是用这两个实测参数把曲线形状演出来。
跑出来的四档(主输出):
压测目标 : http://127.0.0.1:8888/v1
模型 : deepseek-v4-flash-0731
每请求输出 100 token,并发档位 [1, 2, 4, 8]
并发 单流 tok/s 总吞吐 tok/s vs 1 并发
----------------------------------------------------
1 84.67 84.67 1.00x
2 84.59 169.18 2.00x
4 71.06 284.22 3.36x
8 35.74 285.94 3.38x
----------------------------------------------------
口径说明:
· 总吞吐 = 各请求单流 tok/s 之和;vs 1 并发 = 本档总吞吐 / 1 并发总吞吐。
· 只测 decode(首 token->末 token),不含建连与 prefill。
· 倍数 = 1.00x 线性增长 -> 带宽没吃满;亚线性(< N 倍)-> 带宽争抢;
到某档后总吞吐不再涨 -> 带宽饱和,再加并发只摊薄单流。
对照着看:
- 1 并发 84.67、8 并发 285.94,和我方实测的 84.89 / 284.80 几乎重合–因为 mock 就是拿这俩参数建的,首尾两点是实测锚定的。
- 2 并发 169.18、2.00x,线性段:带宽没吃满,加人等比例增产。
- 4 并发 284.22、3.36x,刚好踩进饱和段,总吞吐和 8 并发几乎一样了–拐点确实在 3 到 4 之间。
- 8 并发 285.94、3.38x,总吞吐没比 4 并发多,但单流从 71 掉到 35.74–纯摊薄。
中间的 2、4 两档是 mock 按带宽模型推的,不是真机实测。真机的中间点我没测,你拿脚本连自己机器跑,能看到属于你那条真实曲线。
连不上时(断连输出):
压测目标 : http://127.0.0.1:9999/v1
模型 : deepseek-v4-flash-0731
每请求输出 50 token,并发档位 [1, 2, 4, 8]
并发 单流 tok/s 总吞吐 tok/s vs 1 并发
----------------------------------------------------
1 连接失败 / 无有效数据
2 连接失败 / 无有效数据
4 连接失败 / 无有效数据
8 连接失败 / 无有效数据
----------------------------------------------------
服务没起、URL 写错、端口不对,都是这个样子。脚本不会崩,逐档标出来。
脚本全文(h20_conc_bench.py,零依赖):
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""h20_conc_bench.py -- 本地部署大模型并发压测。
打 1 / 2 / 4 / 8 个并发流式请求,测每个并发的 decode 总吞吐(tok/s),
看「用户变多,速度怎么变」----会不会变成 1/N,还是另有规律。
只测 decode 阶段(从首 token 到末 token 的生成速度),不碰首 token 延迟。
零依赖,只用标准库。
用法:
# 连你自己的本地服务
python3 h20_conc_bench.py --base-url http://127.0.0.1:8888/v1 \
--model deepseek-v4-flash-0731
# 自定义并发档位与每请求输出长度
python3 h20_conc_bench.py --levels "1 2 4 8" --tokens 100
退出码:0 = 正常完成;1 = 连不上目标。
"""
import argparse
import json
import sys
import threading
import time
import urllib.error
import urllib.request
def stream_decode(base_url, model, n_tokens, timeout):
"""发一个流式 chat 请求,返回 (token数, decode耗时秒) 或 (token数, 错误串)。
decode 耗时 = 末 token 时间 - 首 token 时间(不算建连 + prefill)。
"""
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": "count from one to n"}],
"stream": True,
"temperature": 0,
"max_tokens": n_tokens,
}).encode()
req = urllib.request.Request(
base_url.rstrip('/') + '/chat/completions',
data=body, headers={'Content-Type': 'application/json'}, method='POST')
tok_count = 0
t_first = None
t_end = None
try:
resp = urllib.request.urlopen(req, timeout=timeout)
buf = b''
done = False
while not done:
chunk = resp.read(64)
if not chunk:
break
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
line = line.strip()
if not line.startswith(b'data:'):
continue
data = line[5:].strip()
if data == b'[DONE]':
done = True
break
try:
obj = json.loads(data)
except Exception:
continue
choices = obj.get('choices') or [{}]
delta = choices[0].get('delta', {})
if delta.get('content'):
if t_first is None:
t_first = time.time()
tok_count += 1
t_end = time.time()
except urllib.error.URLError as e:
return (tok_count, '连不上: %s' % e.reason)
except Exception as e:
return (tok_count, '出错: %s' % e)
if t_first is None or t_end is None:
return (tok_count, '没收到流式数据')
return (tok_count, t_end - t_first)
def run_level(base_url, model, level, n_tokens, timeout):
"""并发 level 个请求,barrier 对齐启动,返回单流 tok/s 列表(失败为 None)。"""
results = [None] * level
barrier = threading.Barrier(level)
def worker(i):
try:
barrier.wait()
except threading.BrokenBarrierError:
return
tok, dur = stream_decode(base_url, model, n_tokens, timeout)
results[i] = (tok, dur)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(level)]
for t in threads:
t.start()
for t in threads:
t.join()
tps = []
for tok, dur in results:
if isinstance(dur, str) or tok <= 0 or dur <= 0:
tps.append(None)
else:
tps.append(tok / dur)
return tps
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--base-url', default='http://127.0.0.1:8888/v1')
ap.add_argument('--model', default='deepseek-v4-flash-0731')
ap.add_argument('--levels', default='1 2 4 8')
ap.add_argument('--tokens', type=int, default=100)
ap.add_argument('--timeout', type=int, default=60)
args = ap.parse_args()
levels = [int(x) for x in args.levels.split()]
print('压测目标 : %s' % args.base_url)
print('模型 : %s' % args.model)
print('每请求输出 %d token,并发档位 %s\n' % (args.tokens, levels))
print('%-6s %-14s %-16s %-12s' % ('并发', '单流 tok/s', '总吞吐 tok/s', 'vs 1 并发'))
print('-' * 52)
base_total = None
any_ok = False
for lv in levels:
tps = run_level(args.base_url, args.model, lv, args.tokens, args.timeout)
good = [t for t in tps if t is not None]
if not good:
print('%-6d %s' % (lv, ' 连接失败 / 无有效数据'))
continue
any_ok = True
total = sum(good)
if lv == levels[0]:
base_total = total
single = total / len(good)
mult = (total / base_total) if base_total else 0.0
print('%-6d %-14.2f %-16.2f %s' % (
lv, single, total, ('%.2fx' % mult) if base_total else '-'))
print('-' * 52)
print('\n口径说明:')
print(' · 总吞吐 = 各请求单流 tok/s 之和;vs 1 并发 = 本档总吞吐 / 1 并发总吞吐。')
print(' · 只测 decode(首 token->末 token),不含建连与 prefill。')
print(' · 倍数 = 1.00x 线性增长 -> 带宽没吃满;亚线性(< N 倍)-> 带宽争抢;')
print(' 到某档后总吞吐不再涨 -> 带宽饱和,再加并发只摊薄单流。')
return 0 if any_ok else 1
if __name__ == '__main__':
sys.exit(main())
跑法就一行:
python3 h20_conc_bench.py --base-url http://你的服务地址:端口/v1 --model deepseek-v4-flash-0731
默认打 1/2/4/8 四档,每请求输出 100 token。改 --levels "1 2 3 4 6 8" 能把拐点附近测得更细。
八、读到不同结果怎么办
跑出来的数和我的不一样是正常的–绝对值取决于你的机器,该看的是形状和倍数。几种常见情况:
| 你跑出来的 | 多半是什么 | 怎么确认 |
|---|---|---|
| 8 并发是 8 倍(线性) | 你的服务没撞带宽墙,或测的是 prefill 不是 decode | 检查脚本是不是真的收到流式 chunk;8 倍说明带宽还远没吃满 |
| 还没到 3.35 倍就饱和了 | 你的带宽更窄,或单流更快(带宽/单流更小) | 看拐点 = 总吞吐上限 ÷ 单流速度,对上就是带宽卡住 |
| 加并发单流几乎不降 | 请求可能没真并发,被服务端串行处理了 | 看服务端日志的批处理槽位 MAX_NUM_SEQS;或网络把请求挤成队列 |
| 某几档全 0 或「连接失败」 | 服务没起、URL 错、端口不对、超时太短 | 先 curl /v1/models 确认通,再跑脚本 |
| 倍数忽高忽低不稳定 | 请求太短,噪声盖过信号 | 把 --tokens 调大(如 200),每档多跑几轮取稳态 |
九、什么时候不用按这篇做
- 就你一个人用:不用管并发,单流速度就是你的速度,这篇不涉及。
- 你调的是云上 API,不是本地部署:API 后面是弹性集群,你这一台压不出带宽墙,这套测法没意义。
- 你的瓶颈是首 token 等待:这篇只测 decode(生成阶段)。首 token 慢是 prefill 的问题,是另一篇的事(上篇讲的「上下文一长吞吐掉到 9%」就沾这个边)。
- 你的服务并发上限设得很低:比如
MAX_NUM_SEQS=1,那 8 并发会被排队成串行,测出来是「线性 + 排队延迟」,不是带宽曲线。先把槽位调开再测。
十、如果你不是自己搭,是买一套装好的
这一节是判断,不是实测。
如果你的场景是「好几个人同时用这台本地部署的机器」,那交付的时候,多用户并发是个绕不开的验收点–不是看它能不能跑,是看它几个人一起跑还扛不扛得住。我们交付的是 2 台 DGX Spark 装好调通的状态,多用户并发是这套东西最常见的使用方式,上面的 84.89 / 284.80 就是这套配置实测出来的。
但有一说一:如果你的实际同时在线人数就一两个,完全没必要为「支持高并发」这一层多花钱–单流速度才是你每天体感的东西,而单流速度和并发能力是两个独立的指标,别被「支持几十并发」的说法带偏。反过来,如果你确实有十来个人要同时顶,那别只看单流数,得压一下并发曲线看拐点,否则买回来发现加到 4 个人就不涨了。
小结
- 8 个人同时用本地部署的大模型,速度不会变成 1/8。实测整体吞吐是 1 人的 3.35 倍,每个人单个降到约 42%。
- 不是 8 倍,因为内存带宽有上限,加到一定人数就吃满了;不是 1/8,因为 1 个人时带宽根本没吃满,多人用把闲置带宽用上了,整体反而更快。
- 拐点 ≈ 带宽上限 ÷ 单流速度 = 284.80 ÷ 84.89 ≈ 3.35。过了它,加并发只摊薄单流,不再提升总吞吐。
- 文末脚本打 1/2/4/8 并发,把这条曲线在你自己机器上跑出来。该看的是形状和倍数,不是绝对值。
更多推荐

所有评论(0)