想知道一台电脑跑哪个模型合适,最直接的办法是测速。 这篇做
bench.py:用 Ollama 返回的计时字段测冷启动加载时间、输入处理速度和生成速度,生成 Markdown 表格;再用官方 openai SDK 通过兼容接口调用同一个本地模型。
先看清成品
- 在 box(8 核 CPU、无 GPU)上真实测了 3 个模型:0.6B 生成约 133 tokens/s,1.5B 约 44–48 tokens/s。
- 发现并修复两个真实问题:提示词缓存让输入速度虚高到 2804 tokens/s;以及计算公式没减掉缓存 token。
- OpenAI 兼容接口真实调用成功;模型名写错时 SDK 抛
openai.NotFoundError(404)。pytest 3 项通过。
合规提醒:模型在本机运行,数据不出本机;但每个模型有自己的许可证(在 ollama.com/library 的模型页面查看),商用前先确认。本文只用 Ollama 官方安装方式和它自带的本地 API(默认 http://localhost:11434),不使用任何非官方镜像或破解版本。
运行说明:本文所有输出都是在 box 上真实跑出来的(Ollama 0.40.7,8 核 Intel Xeon CPU、15 GB 内存、无 GPU,2026-10-21 20:38–20:56,UTC+8)。box 的网络不允许从官方模型库下载:
ollama pull qwen2.5:1.5b报错redirect target not allowed: …r2.cloudflarestorage.com resolves to non-public 198.18.0.1。所以我用的是 box 上之前从 GGUF 文件导入的模型,名字带-local(qwen2.5-local:1.5b、qwen3-local:0.6b、deepseek-r1-distill:1.5b)。你在自己电脑上直接ollama pull qwen2.5:1.5b,把命令里的模型名换掉即可。速度数据只代表这台 CPU 机器。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
bench.py |
测速 |
openai_compat.py |
用官方 openai SDK 调本地模型 |
bench.md |
结果表 |
test_bench.py |
3 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Ollama | 官网安装(Linux:`curl -fsSL https://ollama.com/install.sh |
| 模型 | ollama pull qwen2.5:1.5b(约 1 GB,CPU 也能跑) |
| Python | 3.9 以上,pip install requests pytest(C 篇另需 openai) |
| openai SDK | pip install openai(本文为 3.35.1) |
关键事实
| 事实 | 来源 |
|---|---|
load_duration、prompt_eval_duration、eval_duration 单位为纳秒;prompt_eval_cached_count 是从缓存读的输入 token 数 |
官方 Usage 文档 |
prompt_eval_duration 只统计未命中缓存的输入 token |
同上 |
keep_alive: 0 立即卸载模型 |
官方 FAQ |
兼容接口地址 http://localhost:11434/v1,api_key 必填但不校验 |
官方 OpenAI compatibility |
文件结构
80-ollama-C-src/
├── bench.py
├── openai_compat.py
└── test_bench.py
第 1 步:测速脚本
"""本地模型速度基准:用 /api/generate 返回的 eval_count / eval_duration 计算 tokens/s。"""
import argparse, statistics, sys, time
import requests
HOST = "http://localhost:11434"
PROMPT = "用大约 100 个汉字介绍一下二十四节气中的“霜降”。"
def run_once(model, *, run_id=0, num_predict=128, session=requests):
# 每次运行加不同前缀:否则 Ollama 复用上一次的提示词缓存,输入速度会虚高
body = {"model": model, "prompt": f"[第{run_id}次测试] {PROMPT}", "stream": False,
"options": {"temperature": 0, "num_predict": num_predict, "seed": 1}}
t0 = time.perf_counter()
r = session.post(f"{HOST}/api/generate", json=body, timeout=600)
r.raise_for_status(); d = r.json()
wall = time.perf_counter() - t0
ns = 1e9
return {"load_s": d.get("load_duration", 0) / ns,
# 官方 Usage 文档:prompt_eval_duration 只统计“未命中缓存”的输入 token,所以分子要减掉缓存数
"prompt_tps": (d.get("prompt_eval_count", 0) - d.get("prompt_eval_cached_count", 0)) / max(d.get("prompt_eval_duration", 1), 1) * ns,
"gen_tps": d.get("eval_count", 0) / max(d.get("eval_duration", 1), 1) * ns,
"tokens": d.get("eval_count", 0), "wall_s": wall,
"cached": d.get("prompt_eval_cached_count", 0), "prompt_tokens": d.get("prompt_eval_count", 0)}
def unload(model, session=requests):
"""keep_alive=0 让模型立即卸载,下一次就是冷启动。"""
session.post(f"{HOST}/api/generate", json={"model": model, "keep_alive": 0}, timeout=60)
def bench(model, repeats=3, *, session=requests):
unload(model, session=session)
runs = [run_once(model, run_id=i, session=session) for i in range(repeats + 1)]
cold, warm = runs[0], runs[1:] # 第一次含加载时间,单独报告,不参与平均
return {"model": model, "cold_load_s": cold["load_s"], "cold_wall_s": cold["wall_s"],
"gen_tps": statistics.median(r["gen_tps"] for r in warm),
"prompt_tps": statistics.median(r["prompt_tps"] for r in warm),
"warm_wall_s": statistics.median(r["wall_s"] for r in warm),
"tokens": warm[0]["tokens"], "cached": max(r["cached"] for r in warm),
"prompt_tokens": warm[0]["prompt_tokens"]}
def table(rows):
out = ["| 模型 | 冷启动加载 s | 冷启动总耗时 s | 热启动总耗时 s(中位数) | 输入 tokens(命中缓存) | 输入 tokens/s | 生成 tokens/s | 生成 tokens |",
"| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
for r in rows:
out.append(f"| {r['model']} | {r['cold_load_s']:.2f} | {r['cold_wall_s']:.2f} | {r['warm_wall_s']:.2f} | {r['prompt_tokens']}({r['cached']}) | {r['prompt_tps']:.0f} | {r['gen_tps']:.1f} | {r['tokens']} |")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("models", nargs="+")
ap.add_argument("--repeats", type=int, default=3); ap.add_argument("-o", default="bench.md")
a = ap.parse_args(argv)
rows = []
for m in a.models:
print(f"测试 {m} …", file=sys.stderr); rows.append(bench(m, a.repeats))
md = table(rows); print(md)
open(a.o, "w", encoding="utf-8").write(f"# 本地模型速度\n\n提示词:{PROMPT}\n\n{md}\n")
if __name__ == "__main__":
main()
第 2 步:OpenAI 兼容调用
"""同一个本地模型,通过 Ollama 的 OpenAI 兼容接口调用(官方 openai SDK)。"""
import sys
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") # 本地接口不校验 key,但 SDK 要求非空
model = sys.argv[1] if len(sys.argv) > 1 else "qwen2.5:1.5b"
resp = client.chat.completions.create(model=model, temperature=0, max_tokens=80,
messages=[{"role": "user", "content": "用一句话解释什么是霜降。"}])
print(resp.choices[0].message.content)
print("usage:", resp.usage.prompt_tokens, "+", resp.usage.completion_tokens, "tokens")
第 3 步:测试
import bench as m
class R:
def __init__(self, d): self.d = d
def raise_for_status(self): pass
def json(self): return self.d
class S:
def __init__(self): self.bodies = []
def post(self, url, json=None, **k):
self.bodies.append(json)
n = len(self.bodies)
return R({"load_duration": 3e9 if n == 2 else 1e7, "prompt_eval_count": 50, "prompt_eval_cached_count": 10,
"prompt_eval_duration": 4e8, "eval_count": 100, "eval_duration": 2e9})
def test_cold_excluded_and_math():
s = S()
r = m.bench("x", repeats=3, session=s)
assert s.bodies[0] == {"model": "x", "keep_alive": 0} # 先卸载
assert r["cold_load_s"] == 3.0 and r["gen_tps"] == 50.0 and r["prompt_tps"] == 100.0
def test_prompts_unique_to_avoid_cache():
s = S(); m.bench("x", repeats=3, session=s)
prompts = [b["prompt"] for b in s.bodies[1:]]
assert len(set(prompts)) == len(prompts) == 4
def test_table_has_cached_column():
s = S(); md = m.table([m.bench("x", 1, session=s)])
assert "命中缓存" in md and "| x |" in md
第 4 步:运行
$ python bench.py qwen3-local:0.6b qwen2.5-local:1.5b deepseek-r1-distill:1.5b
测试 qwen3-local:0.6b …
测试 qwen2.5-local:1.5b …
测试 deepseek-r1-distill:1.5b …
| 模型 | 冷启动加载 s | 冷启动总耗时 s | 热启动总耗时 s(中位数) | 输入 tokens(命中缓存) | 输入 tokens/s | 生成 tokens/s | 生成 tokens |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| qwen3-local:0.6b | 3.57 | 4.57 | 1.02 | 33(5) | 750 | 133.0 | 128 |
| qwen2.5-local:1.5b | 2.81 | 4.28 | 1.53 | 54(26) | 509 | 47.6 | 65 |
| deepseek-r1-distill:1.5b | 2.57 | 5.58 | 2.97 | 30(4) | 500 | 44.1 | 128 |
$ python openai_compat.py qwen2.5-local:1.5b
霜降是二十四节气之一,标志着秋季的结束和冬季的开始,通常在每年公历10月23日左右。
usage: 36 + 31 tokens
$ python openai_compat.py qwen2.5:1.5b # box 上没有这个名字
raise self._make_status_error_from_response(err.response) from None
openai.NotFoundError: Error code: 404 - {'error': {'message': "model 'qwen2.5:1.5b' not found", 'type': 'not_found_error', 'param': None, 'code': None}}
$ python -m pytest -q
3 passed
怎么检查结果?
- 冷启动(第一次)单独报告,不参与平均。
- “生成 tokens”不同的模型(本例 qwen2.5 只生成 65 个就停了)不能只看总耗时比较,要看 tokens/s。
- deepseek-r1 类推理模型会先输出思考过程,实际可用的回答更少。
- qwen2.5 的回答“霜降标志着秋季的结束和冬季的开始”不准确(冬季从立冬开始):测速不代表质量。
调试示例
1. 输入速度虚高。 第一版每次用同一个提示词,结果:
| qwen3-local:0.6b | … | 2804 | 125.8 |
| qwen2.5-local:1.5b | … | 2038 | 40.9 |
我直接查了返回字段:
{'prompt_eval_count': 47, 'prompt_eval_cached_count': 46, 'prompt_eval_duration': 57515000, 'eval_count': 69}
47 个输入 token 中 46 个来自缓存,只算了 1 个 token 的时间。修复一:每次运行加不同前缀 [第N次测试],并在表中显示命中缓存数。
2. 公式本身也不对。 加了前缀后 qwen2.5 仍有 26/54 命中缓存(聊天模板的开头每次都一样),输入速度显示 968。查官方 Usage 文档:prompt_eval_duration 只统计未命中缓存的 token。修复二:输入速度 = (prompt_eval_count − prompt_eval_cached_count) / prompt_eval_duration。修正后为 509。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 第一次很慢 | 模型加载 | 看冷启动列;常用模型可设 keep_alive |
| NotFoundError 404 | 模型名不对 | ollama list 核对名字 |
| 速度波动大 | 其他程序占用 CPU | 多跑几次看中位数 |
模板规则
- 冷启动和热启动分开报告。
- 注意缓存对输入速度的影响。
- 比较时看 tokens/s,不只看总耗时。
- 测速和质量分开评估。
项目资源
| 用途 | 地址 |
|---|---|
| Ollama Chat API(官方) | https://docs.ollama.com/api/chat |
| Ollama 用量字段说明(官方) | https://docs.ollama.com/api/usage |
| Ollama GitHub | https://github.com/ollama/ollama |
| Ollama 模型库 | https://ollama.com/library |
| OpenAI 兼容接口(官方) | https://docs.ollama.com/api/openai-compatibility |
| Generate API(官方) | https://docs.ollama.com/api/generate |
先测速再选模型,选完再看质量。