你将做出什么
在自己电脑上跑开源模型,最常见的三个问题是:放不放得下、该选哪个量化档、到底有多快。本课把这三个问题变成可以算、可以测的东西。
完成后你会得到:一张按你显卡容量算好的“量化档位 × 上下文长度”显存表;一条写明理由的量化选择记录;一个能复用的计时脚本,测首字延迟和生成速度,并输出一行可存档的记录。
本站没有在任何显卡上实跑模型。计算器和计时脚本都在本机用 Python 实际运行过,计时脚本测的是脚本自带的本地假服务;把它指向你自己的推理服务后得到的数字,属于练习方案(本站未实跑),要由你自己记录。
准备什么
- Python 3.12。两段代码都只用标准库,不联网。
- 你的显卡显存容量。Windows 可在任务管理器“性能”页查看专用显存;装有 NVIDIA 驱动时也可以运行 nvidia-smi。
- 想做实测时,再准备一个已经装好的本地推理服务和一个 GGUF 模型文件。GGUF 是 llama.cpp 使用的模型文件格式,除了权重还带有层数等元数据。
- 计时脚本访问的是 OpenAI 兼容的 /v1/chat/completions 流式接口,常见入口有两个:
- llama.cpp 的 llama-server:新版文档写明默认监听 127.0.0.1:9931(2026-10 核对官方页面)。旧教程常写别的端口,以你的启动日志为准,或者启动时用 --port 自己指定。
- Ollama:本机默认地址是 http://localhost:11434,OpenAI 兼容接口的基础地址是 http://localhost:11434/v1/,支持流式输出(2026-10 核对官方页面)。
第一步:显存花在哪里
推理时显存主要有三块:
- 权重:参数量 × 每个参数的有效位数 ÷ 8。8B 模型用 16 位存储约 15 GiB,量化到 4 位多就降到 5 GiB 以内(本课显存都按 GiB 计算)。
- KV cache:模型为已经读入的每个 token 保存键(K)和值(V),上下文越长占得越多。公式是 2 × 层数 × KV 头数 × 每头维度 × 上下文长度 × 每元素字节。
- 其他开销:计算缓冲、运行时本身等。它和实现、版本、批大小有关,本课用一个假设值,再用启动日志校正。
为什么 KV 头数要单独看?很多新模型使用分组查询注意力,多个查询头共用一组 K、V,KV 头数比注意力头数少,KV cache 就小很多。这些数字在模型的 config.json 里,常见字段是 num_hidden_layers、num_key_value_heads、head_dim;没有 head_dim 时用 hidden_size 除以 num_attention_heads。
有效位数怎么来?量化档位的名字不等于真实位数。llama.cpp 的量化文档给出过某个 8B 模型各档位的有效位数,例如 Q4_K_M 约 4.89、Q5_K_M 约 5.70、Q6_K 约 6.56、Q8_0 约 8.50、F16 约 16.0(2026-10 核对官方页面)。K 系列档位会给不同张量用不同位宽,所以名义“4 位”实际接近 5 位。换一个模型,这些数会略有不同;最可靠的办法是用你下载的文件大小反推。
第二步:运行显存计算器
保存为 vram.py,把开头几个数字换成你的模型,再运行 python vram.py:
GIB = 1024 ** 3
# 示例 8B 配置:换成你的模型 config.json 中的数字
PARAMS = 8.03e9 # 参数量
LAYERS = 32 # num_hidden_layers
KV_HEADS = 8 # num_key_value_heads(没有这个字段时等于 num_attention_heads)
HEAD_DIM = 128 # head_dim(没有时用 hidden_size / num_attention_heads)
OVERHEAD = 0.8 # 计算缓冲等其他占用,假设值,以启动日志为准
USABLE = 0.9 # 只按显存的 90% 规划,给桌面和其他程序留余量
# llama.cpp 量化文档中某个 8B 模型的有效位数(bits per weight)
BPW = {"Q4_K_M": 4.89, "Q5_K_M": 5.70, "Q6_K": 6.56, "Q8_0": 8.50, "F16": 16.0}
KV_BYTES = {"f16": 2.0, "q8_0": 8.5 / 8}
def weights_gib(bpw):
return PARAMS * bpw / 8 / GIB
def kv_gib(ctx, kv_type="f16", parallel=1):
# K 和 V 各一份:2 x 层数 x KV 头数 x 每头维度 x 上下文长度 x 每元素字节
return 2 * LAYERS * KV_HEADS * HEAD_DIM * ctx * KV_BYTES[kv_type] * parallel / GIB
def fits(total):
return " ".join(f"{g}G" for g in (8, 12, 16, 24) if total <= g * USABLE) or "都放不下"
print(f"每 1000 个 token 的 KV cache(f16):{kv_gib(1000) * 1024:.0f} MiB")
print("量化 上下文 权重 KV 合计 可放入")
for name, bpw in BPW.items():
for ctx in (8192, 32768):
w, kv = weights_gib(bpw), kv_gib(ctx)
total = w + kv + OVERHEAD
print(f"{name:<8} {ctx:>6} {w:6.2f} {kv:5.2f} {total:6.2f} {fits(total)}")
total_q8kv = weights_gib(BPW["Q4_K_M"]) + kv_gib(32768, "q8_0") + OVERHEAD
print(f"Q4_K_M + 32768 上下文 + q8_0 KV cache:{total_q8kv:.2f} GiB,可放入 {fits(total_q8kv)}")
def bpw_from_file(size_bytes, params=PARAMS):
return size_bytes * 8 / params
print(f"一个 4,920,000,000 字节的文件,有效位数约 {bpw_from_file(4_920_000_000):.2f}")
本站实际运行输出(单位 GiB,“可放入”列按显存的 90% 判断):
每 1000 个 token 的 KV cache(f16):125 MiB
量化 上下文 权重 KV 合计 可放入
Q4_K_M 8192 4.57 1.00 6.37 8G 12G 16G 24G
Q4_K_M 32768 4.57 4.00 9.37 12G 16G 24G
Q5_K_M 8192 5.33 1.00 7.13 8G 12G 16G 24G
Q5_K_M 32768 5.33 4.00 10.13 12G 16G 24G
Q6_K 8192 6.13 1.00 7.93 12G 16G 24G
Q6_K 32768 6.13 4.00 10.93 16G 24G
Q8_0 8192 7.95 1.00 9.75 12G 16G 24G
Q8_0 32768 7.95 4.00 12.75 16G 24G
F16 8192 14.96 1.00 16.76 24G
F16 32768 14.96 4.00 19.76 24G
Q4_K_M + 32768 上下文 + q8_0 KV cache:7.50 GiB,可放入 12G 16G 24G
一个 4,920,000,000 字节的文件,有效位数约 4.90
对照输出读三件事:
- 每 1000 个 token 的 KV cache 约 125 MiB。上下文从 8192 加到 32768,KV cache 从 1GiB 涨到 4GiB,增量比 Q4_K_M 和 Q6_K 两档权重之差还大。显存紧张时,先问“我真的需要这么长的上下文吗”。
- KV cache 也能量化。把它从 f16 换成 q8_0,同样 32768 上下文的合计从 9.37 降到 7.50 GiB,省下近 2 GiB;但对 8G 显卡仍超过 90% 的规划线,这时应该缩短上下文,而不是硬撑。
- 最后一行是反推法:知道文件大小和参数量,就能算出这个文件实际的有效位数,不必相信档位名字。
这张表只是估算。参数量用的是示例值,其他开销是假设值;实际启动后,推理服务通常会在日志里打印权重、KV cache 和缓冲区的大小,用它们校正 OVERHEAD,再重新算一遍。
第三步:选量化档位
| 档位 | 什么时候选 | 要注意 |
|---|---|---|
| Q8_0 | 显存充裕,想尽量接近原始质量 | 体积约为 4 位档的 1.7 倍 |
| Q6_K、Q5_K_M | 中等显存,想在质量和体积间折中 | 先和 Q4_K_M 对比,确认差异值得多花的显存 |
| Q4_K_M | 显存有限时常见的起点 | 数字计算、代码、长推理更容易先出问题 |
| 3 位及以下 | 实在放不下时的退路 | 质量下降通常更明显,参数越少的模型越敏感 |
表里是一般经验,不是对你模型的测量。真正的选择要靠自己的固定题:用 AI 评测实操 的方法准备 5~10 道你常用的题(包含算数和代码),分别在两个档位上各跑一次,记录对错。写成这样一条记录:
模型:<名称与版本>,显卡:<型号与显存>
候选:Q4_K_M(估算 6.37 GiB,8192 上下文)与 Q6_K(估算 7.93 GiB)
固定题 8 道:Q4_K_M 答对 <实际数>,Q6_K 答对 <实际数>;差异集中在 <哪类题>
选择:<档位>,理由:<显存余量 / 质量差异 / 速度>
尖括号里填你实际测到的内容;没测就写“未测”,不要按印象填。
第四步:用计时脚本测首字延迟和生成速度
两个指标要分开:首字延迟是从发出请求到收到第一个字的时间,主要花在读入提示词上,提示词越长越慢;生成速度是收到第一个字之后每秒产生多少个 token,主要受显存带宽和模型大小影响。把两者混成一个“总耗时”,就看不出该优化哪一边。
下面的脚本留空 BASE_URL 时,会先在本机随机端口启动一个假服务。假服务先等待 0.03 秒再加上与提示词长度成正比的时间(模拟读入提示词),然后每 0.01 秒吐一个片段、共 6 个,所以理论上短提示首字约 0.03 秒、长提示约 0.13 秒,生成速度约每秒 100 个 token。先用它确认脚本算得对,再去测真实服务。保存为 bench.py,运行 python bench.py:
import json
import statistics
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
BASE_URL = "" # 测真实服务时填写,例如 "http://127.0.0.1:11434/v1"
MODEL = "" # 你的服务里的模型名
DIRECT = urllib.request.build_opener(urllib.request.ProxyHandler({})) # 本机请求不走系统代理;只建一次
class FakeServer(BaseHTTPRequestHandler):
"""只用来检验计时逻辑:等待时间随提示词变长(模拟读入提示词),然后每 0.01 秒吐一个片段。"""
disable_nagle_algorithm = True # 小数据包立即发送,避免计时被 TCP 合包拖慢
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
prompt = payload["messages"][-1]["content"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
time.sleep(0.03 + len(prompt) / 20000)
for i in range(6):
self.send_event({"choices": [{"delta": {"content": f"词{i}"}}]})
time.sleep(0.01)
self.send_event({"choices": [], "usage": {"prompt_tokens": len(prompt), "completion_tokens": 6}})
self.wfile.write(b"data: [DONE]\n\n")
def send_event(self, event):
self.wfile.write(f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8"))
self.wfile.flush()
def log_message(self, *args):
pass
def measure(base_url, model, prompt, max_tokens=128):
if urlparse(base_url).hostname not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("本脚本只测本机服务,避免把请求误发到收费的云接口")
body = json.dumps({
"model": model, "stream": True, "max_tokens": max_tokens, "temperature": 0,
"stream_options": {"include_usage": True},
"messages": [{"role": "user", "content": prompt}],
}).encode("utf-8")
request = urllib.request.Request(base_url.rstrip("/") + "/chat/completions", data=body,
headers={"Content-Type": "application/json"})
start, first, last, pieces, usage = time.perf_counter(), None, None, 0, None
with DIRECT.open(request, timeout=300) as response:
for raw in response:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
event = json.loads(data)
usage = event.get("usage") or usage
for choice in event.get("choices", []):
if choice.get("delta", {}).get("content"):
pieces += 1
last = time.perf_counter()
first = first or last
if first is None:
raise RuntimeError("没有收到任何正文片段,先检查模型名和服务日志")
tokens = usage["completion_tokens"] if usage else pieces
rate = (tokens - 1) / (last - first) if tokens > 1 and last > first else float("nan")
return {"ttft": first - start, "tokens": tokens, "source": "usage" if usage else "片段数", "rate": rate}
server = None
if not BASE_URL:
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeServer)
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE_URL, MODEL = f"http://127.0.0.1:{server.server_address[1]}/v1", "fake"
short = "用三句话解释什么是 KV cache。"
long = "下面是一段学习笔记,请总结成三条要点。" + "注意力机制会为每个已读入的 token 保存键和值。" * 80
results = {}
for label, prompt in (("短提示", short), ("长提示", long)):
runs = [measure(BASE_URL, MODEL, prompt) for _ in range(4)]
for i, r in enumerate(runs):
tag = "预热" if i == 0 else f"第{i}次"
print(f"{label} {tag}:首字 {r['ttft']:.2f}s,{r['tokens']} 个 token({r['source']}),"
f"生成 {r['rate']:.1f} token/s")
measured = runs[1:] # 第一次可能包含加载模型,不计入
results[label] = {"ttft_median_s": round(statistics.median(r["ttft"] for r in measured), 2),
"rate_median": round(statistics.median(r["rate"] for r in measured), 1)}
if server:
server.shutdown()
print("记录:", json.dumps({"base_url": BASE_URL, "model": MODEL, **results}, ensure_ascii=False))
本站对假服务实际运行一次的输出(端口每次随机,时间会有几十毫秒以内的浮动):
短提示 预热:首字 0.03s,6 个 token(usage),生成 94.5 token/s
短提示 第1次:首字 0.03s,6 个 token(usage),生成 95.4 token/s
短提示 第2次:首字 0.03s,6 个 token(usage),生成 94.7 token/s
短提示 第3次:首字 0.03s,6 个 token(usage),生成 94.6 token/s
长提示 预热:首字 0.15s,6 个 token(usage),生成 94.3 token/s
长提示 第1次:首字 0.15s,6 个 token(usage),生成 94.4 token/s
长提示 第2次:首字 0.14s,6 个 token(usage),生成 94.8 token/s
长提示 第3次:首字 0.14s,6 个 token(usage),生成 94.7 token/s
记录: {"base_url": "http://127.0.0.1:50254/v1", "model": "fake", "短提示": {"ttft_median_s": 0.03, "rate_median": 94.7}, "长提示": {"ttft_median_s": 0.14, "rate_median": 94.7}}
这组数字只证明脚本的计时逻辑正确:短提示首字约 0.03 秒、长提示约 0.14 秒,与假服务设定的 0.03 秒和 0.13 秒基本一致;生成速度约 95,比理论值 100 略低,是因为每次等待之外还有发送开销。长提示的首字延迟明显更高,而生成速度几乎不变,这正是要把两个指标分开记录的原因。假服务没有加载过程,所以预热和后几次差不多;真实服务的第一次请求可能包含加载模型,会慢得多。所以脚本把每组第一次当预热,用后三次的中位数做记录。
几个设计说明:
- 生成速度用 (token 数 − 1) ÷ (最后一个字时间 − 第一个字时间) 计算,把首字延迟排除在外。
- token 数优先用服务返回的 usage。流式片段不一定是一个 token 一片;服务不返回 usage 时,输出会标明“片段数”,这时的速度只能粗看。
- 脚本拒绝非本机地址。计时会连续发 8 次请求,误填成按量收费的云接口就会产生费用,本课只测本机。
- temperature 设为 0、max_tokens 固定,是为了让每次生成的长度和内容尽量一致,便于比较。
- DIRECT 连接器不走系统代理,并且在开头只创建一次。开着代理软件时,本机请求有可能被转给代理;而每次请求都新建连接器,会多花一段准备时间。在本站调试用的 Windows 电脑上,每次新建会让首字延迟多出约 0.09 秒,这段时间与模型无关,却会混进测量结果。
第五步:测你自己的服务(练习方案,本站未实跑)
- 先确认服务就绪。llama-server 提供 /health,加载中返回 503,就绪后返回 200(2026-10 核对官方页面);Ollama 可以先用
ollama list确认模型名。 - 填写 BASE_URL 和 MODEL。Ollama 填
http://localhost:11434/v1和列表里的模型名;llama-server 填http://127.0.0.1:<你的端口>/v1。 - 测试期间关掉其他占用显卡的程序(游戏、视频剪辑、浏览器里的大模型页面),然后运行 bench.py。
- 用服务自报的数据对照。llama-server 的响应里有 timings 对象,含 predicted_per_second 等字段;Ollama 的 /api/generate 最终响应含 eval_count 和 eval_duration,时长单位是纳秒,eval_count ÷ eval_duration × 10⁹ 就是生成速度(均为 2026-10 核对官方页面)。脚本结果和自报结果差得很多时,先怀疑 token 计数口径。
- 把“记录:”那一行连同显卡型号、模型文件名、量化档位、上下文长度、推理服务版本存进 bench_log.txt。缺了这些条件,数字无法和别人的、也无法和你下个月的结果比较。
常见失败与修订
一启动就显存不足,哪怕选了 Q4_K_M。 常见原因是上下文开得过大。llama-server 的 -c(--ctx-size)默认值 0 表示使用模型自带的上下文长度(2026-10 核对官方页面),长上下文模型因此可能一启动就按很长的上下文分配 KV cache。修订:显式加上 -c 8192,用计算器确认合计在显存 90% 以内;仍不够再把 KV cache 改成 q8_0(-ctk q8_0 -ctv q8_0,可选值以你版本的 --help 为准),启动报错时按日志提示调整。
能跑,但每秒只有几个 token。 多半是有些层放在了内存而不是显存里,由 CPU 计算。llama-server 用 -ngl(--n-gpu-layers)控制放进显存的层数,默认 auto(2026-10 核对官方页面)。修订:查看启动日志里有多少层放到了 GPU;没有全部放下就降一档量化或缩短上下文,让全部层放进显存后再测一次,对比生成速度。
对话越长,首字等待越久。 每一轮都把完整历史重新发给模型,提示词越来越长,读入时间随之增加。修订:用计时脚本的短提示与长提示对比,确认是长度导致;再在应用里只保留必要的历史,或改为先总结旧对话再继续。不要把首字变慢误判为显卡降速。
量化后答案明显变差,尤其是数字和代码。 修订:用第三步的固定题在高一档量化上重跑,确认是否由量化引起;是的话换高一档,同时用缩短上下文或量化 KV cache 腾出显存。不要只凭一道题下结论。
同样设置,几次测出的速度差很多。 原因可能是第一次包含加载、后台程序抢占显卡、散热降频或生成长度不同。修订:保留预热、至少测三次取中位数、固定 max_tokens 与 temperature,并记录每次的 token 数;生成 token 数差异大的轮次单独标注。
保存和使用结果
保存三样东西:改过参数的 vram.py 和它的输出、量化选择记录、bench_log.txt。以后换显卡、换模型或升级推理服务,用同一套脚本重算重测,前后才有可比性。
在学习小组分享时,写清“估算”和“实测”:显存表是估算,计时数字是在某张卡、某个版本、某个设置下的实测。不要把假服务的约 95 token/s 当作任何模型的速度。
自测与答案
- 上下文从 8192 改成 32768,示例配置的 KV cache 增加多少?从 1 GiB 增加到 4 GiB,多出 3 GiB。
- Q4_K_M 的“4”能直接用来算权重大小吗?不能,K 系列混合了不同位宽,有效位数约 4.9,最好用文件大小反推。
- 首字延迟高、生成速度正常,优先看什么?提示词长度和是否首次加载,而不是显卡算力。
- 为什么计时脚本要拒绝非本机地址?避免误把多次测试请求发到收费接口。
加练:把计算器里的 KV_HEADS 改成 32(相当于不使用分组查询注意力),重新运行,看 32768 上下文的 KV cache 变成多少,并解释为什么很多新模型要减少 KV 头数。
来源与内容说明
AIScope 中文手册 · AIScope 编辑
本篇为本站原创学习内容,引用资料保留参考来源。
收录:2026年10月19日
适用范围:尚未标注,请结合原文与实际环境核对
人工核验:尚无核验记录