本文部分内容由 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 并发,把这条曲线在你自己机器上跑出来。该看的是形状和倍数,不是绝对值。
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐