AI 工具

用 Ollama 测量本地模型推理速度:缓存校正与生成吞吐

想知道一台电脑跑哪个模型合适,最直接的办法是测速。 这篇做 bench.py:用 Ollama 返回的计时字段测冷启动加载时间、输入处理速度和生成速度,生成 Markdown 表格;再用官方 openai SDK 通过兼容接口调用同一个本地模型。

BINBHO · 图文教程

想知道一台电脑跑哪个模型合适,最直接的办法是测速。 这篇做 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

先测速再选模型,选完再看质量。