消费级GPU跑LLM并发测试:显存、KV Cache与性能调优实践

消费级GPU跑LLM并发测试:显存、KV Cache与性能调优实践 如果你手里正好有一块 RTX 5060 系列的显卡可能已经发现一个现象单用户去调用本地大模型时响应速度还能接受可一旦用脚本做并发请求比如给 Agent 的多个子任务同时调用本地模型服务马上变得迟钝甚至直接在日志里看到CUDA out of memory或者流式响应中途断开。这类问题通常被简单归结为“显卡不够用”但更准确的说法是你没有给本地推理框架设置合理的并发边界也没有控制好显存中最大的消耗项——KV Cache。RTX 5060 这类消费级显卡在做 LLM 并发测试时真正的难点不是“能跑多少并发”而是“在显存上限和计算上限之间找到延迟和吞吐都可接受的平衡点”。这篇文章会从消费级硬件的资源约束出发讲清楚 LLM 并发测试到底该测什么指标、如何搭建本地推理服务、怎样用脚本压测出并发上限以及遇到 OOM、流式断开、响应超时后怎么排查。重点不是给出某个“标准答案”而是给你一套可以在 RTX 5060 上复用的测试方法论。1. 这篇文章真正要解决的问题很多人在本地部署 LLM 时会陷入两个极端要么只开一个对话窗口完全没接触并发问题要么照着云端 API 的写法用高并发压测脚本去“考验”本地服务然后把显存打爆。实际上本地推理的并发问题和云端 API 有本质区别。云端的concurrency limit exceeded报错通常由平台账户配额决定你交多少钱就给你多少并发。本地没有账户配额限制但 GPU 的显存和算力就是硬约束。你可以在本地随意把并发数调到 50、100但服务可能早就开始排队甚至直接崩溃。这篇文章要解决的核心问题有三个如何理解本地推理的并发瓶颈显存、计算单元、KV Cache 分别是怎么限制并发的。如何设计一个可复现的并发测试环境怎么搭、脚本怎么写、指标怎么读。如何在消费级显卡上稳定地跑并发请求该限制什么、优化什么、注意什么。如果你正在做本地 Agent 调度、小团队共享推理、或者想评估自己手里的显卡到底能不能扛住多个任务同时调用这篇文章值得仔细看一遍。2. 消费级 GPU 跑 LLM 的并发瓶颈在哪里2.1 显存是第一个天花板本地跑 LLM 时显存主要被三块内容占用模型权重整个模型的参数矩阵。比如一个 7B 模型在 fp16 精度下约占 14GB 显存如果换用 4bit 量化可以降到 4GB 左右。KV Cache每个请求生成过程中产生的 Key 和 Value 缓存它随着上下文长度和并发请求数量增长。默认情况下每个并发请求都会占用独立的 KV Cache 空间。激活值前向计算中临时产生的中间变量通常占显存比例较小但在长上下文时会明显增加。当你只发一个并发请求时模型权重占大头当并发数上升时KV Cache 会成为真正的“显存吞噬者”。假设一个并发请求在 2048 上下文下需要 1GB 的 KV Cache那么 8GB 显存的显卡在加载模型后很可能只够支持 2-3 个并发请求。这也是为什么 RTX 5060 系列显卡虽然有新一代架构和更高的显存带宽但只要模型体积和上下文长度固定并发能力仍然会被显存容量卡住。2.2 算力是第二个天花板就算显存足够GPU 的计算单元也是有限的。RTX 5060 这类消费级显卡的 SM 数量和 Tensor Core 规模远不如数据中心卡。多个并发请求虽然可以同时进入 GPU但 GPU 同一时间能并行计算的 token 数量是有限的。这里要区分两个概念并行多个请求真正同时在不同计算单元上执行。并发多个请求在时间线上交错执行可能排队也可能被合并成 batch 一起计算。本地推理框架普遍采用 continuous batching连续批处理用动态 batch 的方式提高 GPU 利用率。但这不意味着并发数越高越好。当并发请求超过一定阈值batch 太大单个请求的等待时间会上升最终表现为 TTFT首 Token 延迟变长甚至出现超时。2.3 消费级显卡与数据中心卡的根本差异维度数据中心 GPU如 A100/H100消费级 GPU如 RTX 5060显存容量40GB-80GB8GB-16GB 级别显存带宽更高够用但差距明显KV Cache 余量充裕可承载长上下文高并发容易被打满并发设计目标高吞吐、多用户低功耗、单机轻量使用典型应用生产环境 API 服务本地调试、个人 Agent、小范围共享看到这个对比你就明白了消费级显卡不是不能做并发而是“并发余量”很小。测试的真正目的是找到这个余量边界而不是追求无限并发。3. 并发、吞吐与延迟先搞清指标做 LLM 并发测试不能只看“同时发出几个请求”。你至少需要关注以下指标3.1 并发数Concurrency指同一时间处于执行或等待状态的请求数量。注意并发数不等于 QPS每秒请求数。一个请求如果耗时 10 秒那么 10 并发可能只带来 1 QPS如果耗时 0.1 秒10 并发可能带来 100 QPS。3.2 首 Token 延迟TTFT从发出请求到收到第一个 token 的时间。这个指标直接影响用户感知尤其是流式输出场景。并发数升高时TTFT 会明显变长因为新请求需要排队等待前面请求释放 GPU 资源。3.3 生成速率TPOT 和吞吐TPOT每个 token 的生成时间通常用 tokens/s 表示。吞吐量整个推理服务每秒生成的 token 总数可以用“并发数 × 单请求每秒 token 数”来粗略估算。并发并不是越高越好。通常你会看到一个现象并发从 1 升到 2 时总体吞吐量提升明显继续升到 4、8吞吐量可能还在增加但一旦超过显存或计算极限吞吐量下降请求错误率上升。3.4 请求成功率本地服务没有云端那种“配额限制”但也一样会失败。失败原因通常包括 OOM、超时、客户端断开。并发测试必须记录失败率否则只看平均延迟会被“幸存者偏差”误导。3.5 云端 API 与本地服务的并发差异如果你看到stream disconnected before completion: concurrency limit exceeded for account这是云端 API 的典型报错意思是你的账户并发额度超了。本地推理服务通常不会报这种错但会出现更直接的后果显存 OOM、进程被杀、连接被重置。所以在本地做并发测试要以“显存占用”“进程是否存活”“请求是否超时”为主要判断标准而不是套用云端的配额逻辑。4. 环境准备在 RTX 5060 上搭建推理服务4.1 系统与驱动我先说一个通用结论Windows 配合 WSL 2 是很多本地部署开发者常用的选择因为 Linux 环境下的 CUDA 支持和性能损耗更可控如果你直接在 Windows 上用 Ollama 或 LM Studio也能跑但遇到问题时的排查路径会略有不同。开始之前先确认三件事NVIDIA 驱动已安装并且版本足够新。显存状态正常可以用nvidia-smi查看。如果你的机器支持 WSL建议在 WSL 2 里安装 Ubuntu 22.04 或更新版本。nvidia-smi输出里应该能看到你的显卡型号和显存信息。记住这一行里的“Total”显存容量后续观察 OOM 时非常有用。4.2 选择推理框架本地 LLM 推理框架非常多我的建议是Ollama上手最快适合先快速验证模型能不能跑。vLLM对高并发、连续批处理支持更深入适合做严格并发测试。llama.cpp server轻量适合在低显存环境下使用 GGUF 量化模型。LM Studio有图形界面适合个人本地体验。本文的测试思路不绑定某个框架。我会以 Ollama 和 vLLM 为例分别给出启动方式因为这两者在并发处理策略上比较有代表性。4.3 用 Ollama 启动本地推理服务安装 Ollama 后先拉取一个适合你显存大小的模型。7B 级别的 Q4_K_M 量化模型比较稳妥如果你的显存只有 8GB建议从 3B 或 7B 量化开始。# 安装并启动 ollama 服务 curl -fsSL https://ollama.com/install.sh | sh ollama serve然后在另一个终端拉取模型并测试ollama pull qwen2.5:7b ollama run qwen2.5:7b 你好Ollama 默认监听11434端口网络接口默认绑定在本地回环地址。如果你需要局域网内其他机器访问需要设置OLLAMA_HOST0.0.0.0。不过做本机并发测试时默认配置即可。4.4 用 vLLM 启动本地推理服务vLLM 更适合模拟“多请求并发”的场景。它的 continuous batching 机制比 Ollama 更主动可以设置--max-num-seqs来控制最大并发序列数。安装pip install vllm启动一个 OpenAI 兼容服务vllm serve Qwen/Qwen2.5-7B-Instruct \ --dtype auto \ --max-model-len 4096 \ --max-num-seqs 8 \ --gpu-memory-utilization 0.9 \ --served-model-name test-model参数说明--max-model-len最大上下文长度。限制这个值可以控制 KV Cache 上限。--max-num-seqs允许的并发序列数上限。这是本文最关心的参数。--gpu-memory-utilization允许 vLLM 使用显存的比例。0.9 表示最多占用 90%留下一些余量给其他进程。--served-model-name客户端访问时的模型别名方便测试时统一使用。这里真正容易踩坑的地方是如果max-model-len设置过大vLLM 在启动阶段就会为 KV Cache 预留大量显存导致可用的并发序列数反而变少。4.5 最小验证先确认服务可用无论用哪个框架启动后先发一个最简单的请求确认服务正常curl http://localhost:11434/api/chat \ -H Content-Type: application/json \ -d {model:qwen2.5:7b,messages:[{role:user,content:hello}]}如果是 vLLMcurl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d {model:test-model,messages:[{role:user,content:hello}],stream:false}确认返回正常后再进入并发测试阶段。不要跳过这一步很多并发问题其实是服务没起来。5. 并发测试的完整方法与示例代码5.1 设计实验并发测试最忌讳“一把梭”。你需要在测试前固定以下变量模型不变。请求内容不变最好固定长度方便对比上下文大小的影响。请求总数固定比如每个并发档位跑 100 个请求。并发档位递增比如 1、2、4、8、12、16。记录每个档位的成功率、平均 TTFT、平均生成速率、显存峰值。如果你用的是 vLLM可以额外观察--max-num-seqs对结果的影响如果你用的是 Ollama可以观察默认状态下的排队行为。5.2 用 Python 编写并发压测脚本这里我给出一个用aiohttp写的并发测试脚本支持调节并发数、请求总数、是否使用流式输出。# 文件路径concurrency_test.py import asyncio import time import json import csv import statistics import aiohttp API_URL http://localhost:8000/v1/chat/completions # vLLM MODEL_NAME test-model MESSAGE 请用三句话介绍什么是并发编程。 async def send_one_request(session, request_id, results): start_time time.monotonic() first_token_time None generated_text try: payload { model: MODEL_NAME, messages: [{role: user, content: MESSAGE}], stream: True, max_tokens: 256, } async with session.post(API_URL, jsonpayload) as resp: if resp.status ! 200: results.append({ request_id: request_id, success: False, status: resp.status, ttft: None, total_time: time.monotonic() - start_time, output_tokens: 0, }) return # 解析 SSE 流 async for line in resp.content: if line.startswith(bdata: ): data line[6:].strip() if data b[DONE]: break try: obj json.loads(data) delta obj[choices][0][delta].get(content, ) if delta: if first_token_time is None: first_token_time time.monotonic() generated_text delta except json.JSONDecodeError: continue results.append({ request_id: request_id, success: True, status: 200, ttft: first_token_time - start_time if first_token_time else None, total_time: time.monotonic() - start_time, output_tokens: len(generated_text), }) except Exception as exc: results.append({ request_id: request_id, success: False, status: str(exc), ttft: None, total_time: time.monotonic() - start_time, output_tokens: 0, }) async def run_concurrency(concurrency: int, total_requests: int): results [] semaphore asyncio.Semaphore(concurrency) async def bounded_request(session, request_id): async with semaphore: await send_one_request(session, request_id, results) async with aiohttp.ClientSession() as session: tasks [asyncio.create_task(bounded_request(session, i)) for i in range(total_requests)] await asyncio.gather(*tasks) return results def summarize_results(results, concurrency): success [r for r in results if r[success]] failed [r for r in results if not r[success]] total_tokens sum(r[output_tokens] for r in success) total_time sum(r[total_time] for r in results) ttfts [r[ttft] for r in success if r[ttft] is not None] row { concurrency: concurrency, total_requests: len(results), success: len(success), failed: len(failed), success_rate: 100.0 * len(success) / len(results) if results else 0, avg_total_time: statistics.mean([r[total_time] for r in results]) if results else 0, avg_ttft: statistics.mean(ttfts) if ttfts else 0, total_output_tokens: total_tokens, throughput_tokens_per_s: total_tokens / total_time if total_time else 0, rps: len(results) / total_time if total_time else 0, } return row async def main(): total_requests 50 concurrency_levels [1, 2, 4, 8, 12, 16] all_rows [] for concurrency in concurrency_levels: print(fTesting concurrency{concurrency} ...) results await run_concurrency(concurrency, total_requests) row summarize_results(results, concurrency) print(f success_rate{row[success_rate]:.1f}%, avg_ttft{row[avg_ttft]:.2f}s, fthroughput{row[throughput_tokens_per_s]:.1f} tokens/s) all_rows.append(row) with open(concurrency_results.csv, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnamesall_rows[0].keys()) writer.writeheader() writer.writerows(all_rows) if __name__ __main__: asyncio.run(main())这段脚本做了四件事用asyncio.Semaphore控制并发数。使用流式输出并记录首个 token 的到达时间也就是 TTFT。统计成功数、失败数、总 token 数和整体吞吐。把结果写入 CSV方便后续画图分析。5.3 不用脚本也能快速压测如果你只想知道服务的极限可以用hey或wrk这类工具。但它们主要测 HTTP 层的吞吐很难解析 SSE 流中的 token 级指标。所以我更推荐前面那段 Python 脚本。如果你想做更专业的性能分析可以把输出改成 JSON 格式然后用 Prometheus Grafana 监控 GPU 和服务的运行状态。这属于后续进阶内容本文先不展开。6. 运行结果与验证如何判断你的推理服务是否正常6.1 观察指标的变化趋势假设你在 RTX 5060 上直接运行了上面的脚本最可能出现三种结果低并发阶段1-4成功率 100%TTFT 和吞吐量稳步上升显存占用在安全范围内。中并发阶段8-12显存占用接近上限TTFT 明显变长吞吐量增长变缓偶尔出现超时。高并发阶段16出现CUDA out of memory、连接重置或响应超时成功率下降。这里不要被“吞吐量还在涨”迷惑。如果 TTFT 已经变得不可接受比如从 0.5 秒涨到 10 秒那么即使吞吐量再高这个并发档位也不适合作为生产环境配置。6.2 用 nvidia-smi 观察显存变化压测时在另一个终端持续观察显存watch -n 1 nvidia-smi重点关注Memory-Usage是否接近 100%。GPU-Util是否保持在高位。进程会不会在某个瞬间突然消失如果消失很可能是 OOM 被杀。如果你的推理服务跑在 WSL 2 里nvidia-smi同样可用。如果你用的是 Windows 原生环境也可以使用 Windows 任务管理器查看 GPU 显存但nvidia-smi的信息更直观。6.3 判断测试是否成功成功不是“所有请求都不失败”而是你能够回答这几个问题这个模型在当前显存下最大安全并发数是多少超过该并发后失败是超时、OOM 还是排队导致哪个并发档位下吞吐量和延迟最均衡只要你能回答出这三个问题这次并发测试就算完成了。7. 常见问题与排查思路问题现象可能原因排查方式解决方案日志出现CUDA out of memory并发请求创建的 KV Cache 超过显存查看nvidia-smi确认显存占用峰值降低并发数、缩短max-model-len、使用更低精度的量化模型流式响应中途断开服务端超时、OOM 进程重启、网络超时查看推理框架日志确认是否有进程被杀记录增加客户端超时时间、降低并发、检查显存余量请求排队但一直不结束推理框架按顺序处理请求并发队列过长查看日志中的排队时间增加--max-num-seqs如果显存允许或限制客户端并发首 Token 延迟很高batch 中等待前序请求释放计算资源查看 TTFT 指标对比低并发档位降低并发数或使用更小的模型本地服务无响应max-num-seqs设置过大导致启动时显存不足重新计算显存占用降低--gpu-memory-utilization或减小max-num-seqs服务突然退出无明确报错系统 OOM Killer 介入查看dmesg或系统日志限制并发、减少其他显存占用进程先说一个最容易被忽略的点很多“并发把显卡打爆”的问题不是模型权重太大而是上下文长度太长。比如你设置了 8192 的max-model-len每个并发请求都可能为 8192 token 的 KV Cache 预留空间。如果把上下文限制在 2048能支持的并发数可能翻倍。再说流式断开。在本地推理中stream disconnected before completion这类报错往往不是“并发额度超了”而是服务端进程在生成过程中崩溃。你可以先看推理服务的 stdout/stderr 有没有CUDA error或Killed字样。如果有优先排查显存如果没有再看客户端是不是设置了过短的读超时。8. 最佳实践与工程建议8.1 给并发设置一个明确的显式上限不要觉得“服务能启动”就等于“服务能处理无限请求”。在实际项目里推荐在推理服务前方加一个队列或代理限制最大并发数。即便你用的是 vLLM它虽然默认支持高并发但过高的--max-num-seqs会让单请求的延迟变得不可控。8.2 用量化换并发空间这是消费级显卡上最有效的优化手段之一。同样一个 7B 模型fp16 精度下显存占用约 14GB用 4bit 量化后降到 4GB 左右。省下来的显存可以给 KV Cache 使用从而支持更高的并发。在 Ollama 中你可以拉取带量化的标签例如qwen2.5:7b-q4_K_M。在 vLLM 中可以加载 AWQ 或 GPTQ 量化模型。量化会带来一定精度损失但大多数对话和 Agent 场景完全够用。8.3 显式限制上下文长度很多本地服务默认使用模型的最大上下文长度。如果你实际业务只需要 2048 或 4096就在启动参数里显式限制。对并发测试而言这一步必不可少否则 KV Cache 会被预设长度撑爆。8.4 使用流式输出但不要忽略首 Token 延迟流式输出能显著改善用户等待体验但并发测试时不要只看“第一批数据到达时间”。如果 TTFT 已经很长说明服务端计算压力大或队列堆积严重此时流式输出只能掩盖问题不能解决问题。8.5 在测试中记录显存和日志建议每次压测都保存三样东西压测脚本输出的 CSV 指标。同一时间段内nvidia-smi的采样记录。推理服务的完整日志。以后遇到性能下降或崩溃这三样东西能帮你快速定位问题。8.6 不要忽略精度选择热词里提到 fp16、fp32、bf16 的精度问题这里有必要说明消费级显卡上推理时fp16 和 bf16 是常见选择fp32 显存占用更高速度不一定更快。对于不支持的精度格式框架会自动做转换。如果你不是在做模型训练只是为了本地推理优先选择支持度好、显存占用低的量化格式即可。9. 总结与后续实践方向这篇文章把“在 RTX 5060 这类消费级显卡上测试 LLM 并发”这件事拆成了四个部分理解资源瓶颈、搭建推理服务、设计压测脚本、分析和排查结果。核心判断是消费级显卡的并发能力不是由“软件允许多少并发”决定的而是由显存容量、KV Cache 策略和模型量化程度共同决定的。如果你刚拿到自己的显卡建议按这个顺序动手先跑一遍 Ollama 或 vLLM确认单请求正常。用文中的 Python 脚本从并发 1 开始逐步加压找到成功率开始下降的那个点。记录这个点对应的显存占用和 TTFT。尝试调整模型量化级别或上下文长度再次测试观察并发上限是否变化。后续你还可以测试不同模型在不同并发下的表现或者把 Ollama 和 vLLM 在相同并发档位下的吞吐量做一个横向对比。真正有价值的不是跑出一个“最大并发数”而是建立一套可复用的压测方法让你换任何硬件、任何模型都能快速判断它的并发边界。建议把文章里的脚本保存下来下次换显存、换模型时直接拿来用。