在大模型应用从原型走向生产的过程中,模型选型是一项绕不开的工作。同一个 Prompt,不同模型给出的回答在质量、速度、成本和稳定性上往往差异很大;同一个业务场景,换一个模型可能效果更好,也可能引入新的格式问题。要把这种差异变成可量化的结论,靠手工逐个试不现实,评测这件事应该交给工具完成。这篇博客围绕一个轻量级、开源思路的 LLM Benchmark 工具展开:它通过 OpenRouter 作为统一接入层,使用同一组测试用例和评分规则,批量对比任意开源或闭源模型的表现,并输出结构化报告。读完这篇内容,可以理解它的工作原理、在本地复现完整流程、排查常见报错,再把脚本改造成属于自己的模型选型工具。
1. 为什么模型对比需要 Benchmark 工具,而不是人肉试验
1.1 模型选型的真正难点是口径不统一
很多团队的模型对比,最初是从“人工提问”开始的。问三五个问题,看哪个模型回答得顺眼,就决定上线用哪个。这种做法在原型阶段可以理解,但放进生产决策里会有明显问题:不同人提问的措辞不同,评分标准不同,对回答的偏好也不同。同一个模型,上午测和下午测,结果都可能不一样。
变量还远不止这些。一个完整的大模型推理请求,至少包含以下可变量:
- 系统提示词是否设置,内容是否一致;
- 用户提问的表述是否有细微差异;
- generation 参数是否相同,比如 temperature、max_tokens、top_p;
- 模型输出是否被截断;
- 网络延迟和重试策略是否一致;
- 评测用例数量是否足够,是否覆盖了业务核心场景。
Benchmark 工具要解决的核心问题,就是把上述变量全部固定下来,只留一个变量:模型本身。它保证所有被测模型拿到完全相同的输入,在相同的参数条件下运行,再用相同的规则判断输出是否合格。这样得到的对比结果才有讨论价值。
1.2 OpenRouter 在对比场景里的优势
OpenRouter 是一个大模型 API 聚合平台,它把大量模型统一成一个 OpenAI 兼容的接口。对 Benchmark 工具来说,这种形态有几个很实际的好处:
- 一次接入,可以测试大量模型,不需要为每个模型单独注册服务商、单独维护 SDK;
- 接口协议统一,请求体和响应结构基本一致,评测代码只写一遍;
- 模型 ID 是稳定可枚举的,可以放在配置文件里批量执行;
- 每个模型有自己的计费标准,工具可以顺便统计 token 消耗和调用成本。
需要提醒的是,OpenRouter 本质上是一个路由网关,不同模型的实际供应商可能不同,因此模型在某个时间点是否可用、限流策略如何,都会影响评测。工具本身要做好重试、超时和错误记录,避免某个模型抖动影响整体数据。
1.3 轻量开源工具的边界:只做评测,不做平台
既然要“轻量”,就要克制功能范围。这类工具的目标不是替代 OpenAI Evals、LangSmith 这类完整评测平台,而是满足三类高频场景:
- 接到一个新需求时,快速比较 3 到 5 个候选模型;
- 上线新模型后,用固定回归用例确认效果没有回退;
- 调整 Prompt 或参数后,验证对模型输出的影响。
所以最小功能集应该是:读取配置、加载用例、调用模型、按规则打分、输出表格。不需要数据库,不需要 Web 界面,不需要在线看板。下面的实现就按这个边界来设计。
注意:评测结果的价值取决于用例质量。工具只负责执行和统计,如果用例本身选得不好,再精确的数值也不能代表业务效果。
2. 理解一次 OpenRouter 推理请求与你真正要采集的指标
2.1 API 调用格式与认证
OpenRouter 的接口路径是/api/v1/chat/completions,请求格式与 OpenAI 的 Chat Completions 接口保持一致。认证方式是在请求头中携带Authorization: Bearer <API_KEY>。
最小请求体如下:
{ "model": "openai/gpt-4o-mini", "messages": [ { "role": "system", "content": "你是数据抽取助手,只输出 JSON。" }, { "role": "user", "content": "从这句话中抽取日期和金额:订单2025-03-08,金额998元。" } ], "temperature": 0.0, "max_tokens": 1024 }用 curl 验证一次调用是否通:
curl https://openrouter.ai/api/v1/chat/completions \ -H "Authorization: Bearer $OPENROUTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-4o-mini", "messages": [{"role": "user", "content": "只回复两个字:正常"}], "temperature": 0 }'正常响应里会包含choices和usage两个关键部分,前者是模型输出内容,后者是 token 消耗。这个结构是后面所有统计的基础。
2.2 请求参数如何影响评测结果
Benchmark 要对所有模型使用一致的参数,因此每个参数都要想清楚“固定成什么值、为什么”。
| 参数 | 建议默认值 | 作用 | 影响说明 |
|---|---|---|---|
| temperature | 0.0 | 控制随机性 | 0 时输出最确定,适合多数题面型评测;调高后同一 Prompt 多次结果波动会变大 |
| max_tokens | 1024 | 限制最大输出长度 | 太小会截断回答,导致正确内容被判为失败;太大可能抬高成本 |
| top_p | 1.0 | 核采样概率阈值 | 与 temperature 通常二选一调整,评测时保持默认 |
| seed | 不设置 | 部分模型支持固定随机种子 | 并非所有模型支持,设置后不保证行为一致,反而可能引入误导 |
| response_format | 不设置 | 强制 JSON 输出 | 只有部分模型支持,Benchmark 工具尽量不依赖模型专有能力 |
| timeout | 60 秒 | 单次请求超时 | 过短容易误报失败,过长会让整体评测时间失控 |
| concurrency | 4 | 并发请求数 | 受 API 限流影响,数值过大会触发 429 |
对模型评测来说,temperature 是最需要统一的口径。如果模型 A 用 0、模型 B 用 0.7,最终对比的就不是模型能力,而是随机性差异。默认统一成 0 是最稳妥的做法。
2.3 指标从哪里来
一次评测需要记录三类数据:
- 响应质量:由评测函数根据规则判断通过还是失败;
- 性能指标:调用耗时,最好是客户端从发起请求到拿到完整响应的时间;
- 资源消耗:
usage里的prompt_tokens、completion_tokens、total_tokens,再结合模型单价换算成本。
价格数据属于外部信息,OpenRouter 的模型列表接口会返回按 token 计费的价格字段。工具不需要内置价格表,可以把价格查询和评测分成两步:先拿模型列表,再跑评测;或者只输出 token 数,成本在报告阶段用脚本计算。
3. 环境准备与项目结构设计
3.1 Python 环境与依赖
实现语言选 Python,因为生态成熟、写脚本速度快。建议使用 Python 3.10 及以上版本,依赖尽量少。
mkdir llm-bench cd llm-bench python3 -m venv .venv source .venv/bin/activate pip install httpx python-dotenv PyYAMLhttpx:发送 HTTP 请求,支持超时和连接复用;python-dotenv:从.env文件读取 API Key;PyYAML:解析 YAML 配置文件。
如果更习惯用 OpenAI 官方 SDK,也可以把它作为依赖,因为 OpenRouter 协议兼容。这里选择直接用 httpx,能让依赖更少,也更清楚请求链路。
3.2 目录结构
llm-bench/ ├── .env ├── .env.example ├── requirements.txt ├── config.yaml ├── cases/ │ └── bench_cases.json ├── bench.py └── report/.env存放密钥,.env.example只放变量名和占位符,用于提交到代码仓库;config.yaml放 Benchmark 运行参数;cases/bench_cases.json放测试用例;report/是输出目录。这个结构保证密钥、配置、用例和结果分离。
3.3 密钥与配置管理
.env内容:
OPENROUTER_API_KEY=sk-or-v1-这里替换成你的密钥 OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 OPENROUTER_TITLE=llm-bench.gitignore里必须包含.env。密钥一旦提交到公开仓库,等于把接口额度公开给所有人。
config.yaml内容:
timeout_seconds: 60 max_retries: 3 concurrency: 4 output_dir: "report" default_temperature: 0.0 default_max_tokens: 1024在本地学习环境,配置从.env和config.yaml读取是够用的。进入生产环境后,密钥应该来自密钥管理服务,配置应该来自配置中心,日志要接监控系统。这些不是工具本身的能力,而是部署环境的边界。
4. 核心实现:从单模型调用到批量对比报告
4.1 封装客户端请求
封装一个call_model函数,统一处理请求头、超时、耗时记录和异常抛出。
import os import time import httpx from dotenv import load_dotenv load_dotenv() API_KEY = os.getenv("OPENROUTER_API_KEY") BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") def call_model(model: str, messages: list, temperature: float = 0.0, max_tokens: int = 1024, timeout: float = 60.0) -> dict: if not API_KEY: raise RuntimeError("未配置 OPENROUTER_API_KEY") url = f"{BASE_URL}/chat/completions" headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", "HTTP-Referer": os.getenv("OPENROUTER_REFERER", ""), "X-Title": os.getenv("OPENROUTER_TITLE", "llm-bench"), } payload = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } started = time.perf_counter() with httpx.Client(timeout=timeout) as client: resp = client.post(url, headers=headers, json=payload) elapsed = time.perf_counter() - started resp.raise_for_status() data = resp.json() data["_elapsed_seconds"] = round(elapsed, 3) return dataHTTP-Referer和X-Title是 OpenRouter 可选的来源标识头,方便在控制台识别请求来源。_elapsed_seconds是客户端实测耗时,它会包含网络延迟,所以同一模型的多次耗时波动是正常的,不能只看单次。
4.2 定义基准测试用例
用例文件用 JSON 描述,每个用例包含身份信息、Prompt 和检查规则。
cases/bench_cases.json:
[ { "id": "json_extract", "name": "JSON 字段抽取", "system_prompt": "你是数据抽取助手,只输出 JSON。", "user_prompt": "从这句话中抽取日期和金额,输出 JSON:\n订单2025-03-08,金额998元。", "check": { "type": "json_object", "required_keys": ["date", "amount"] } }, { "id": "math_reason", "name": "数学计算", "system_prompt": "你是一个数学助手,只回答数字。", "user_prompt": "一个商品打八折后是 320 元,原价是多少?", "check": { "type": "numeric", "expected": 400, "tolerance": 0.5 } }, { "id": "short_answer", "name": "简洁回答", "system_prompt": "用不超过 50 个字回答问题。", "user_prompt": "什么是 API?", "check": { "type": "max_length", "limit": 50 } } ]用例设计有几个原则:一是每个用例只验证一种能力;二是检查规则必须机器可判,不能依赖人工阅读;三是用例数量不宜过少,少于 10 个用例的 Benchmark 结论很容易被偶然误差带偏。
4.3 评测执行器与并发控制
run_case负责组装消息、调用模型、执行检查规则并返回结构化结果。
import dataclasses import re import json @dataclasses.dataclass class BenchResult: model: str case_id: str status: str latency: float prompt_tokens: int completion_tokens: int total_tokens: int passed: bool detail: str def run_case(model: str, case: dict, config: dict) -> BenchResult: messages = [] if case.get("system_prompt"): messages.append({"role": "system", "content": case["system_prompt"]}) messages.append({"role": "user", "content": case["user_prompt"]}) try: data = call_model( model, messages, temperature=config.get("default_temperature", 0.0), max_tokens=config.get("default_max_tokens", 1024), timeout=config.get("timeout_seconds", 60), ) except Exception as exc: return BenchResult(model, case["id"], "error", 0.0, 0, 0, 0, False, str(exc)) content = data["choices"][0]["message"]["content"] usage = data.get("usage", {}) passed, detail = evaluate_case(case, content) return BenchResult( model=model, case_id=case["id"], status="ok", latency=data.get("_elapsed_seconds", 0.0), prompt_tokens=usage.get("prompt_tokens", 0), completion_tokens=usage.get("completion_tokens", 0), total_tokens=usage.get("total_tokens", 0), passed=passed, detail=detail, )检查规则集中在evaluate_case里:
def evaluate_case(case: dict, content: str): check = case.get("check", {}) ctype = check.get("type", "plain") if ctype == "json_object": text = extract_json(content) if text is None: return False, "未找到 JSON 片段" try: obj = json.loads(text) except json.JSONDecodeError as exc: return False, f"JSON 解析失败: {exc}" missing = [k for k in check.get("required_keys", []) if k not in obj] if missing: return False, f"缺少字段: {missing}" return True, "JSON 字段齐全" if ctype == "numeric": numbers = re.findall(r"-?\d+(?:\.\d+)?", content.replace(",", "")) if not numbers: return False, "未找到数字" value = float(numbers[0]) expected = float(check["expected"]) tolerance = float(check.get("tolerance", 0.01)) return (True, f"数值={value}") if abs(value - expected) <= tolerance \ else (False, f"数值={value}, 期望={expected}") if ctype == "max_length": limit = int(check.get("limit", 100)) if len(content) <= limit: return True, f"长度={len(content)}" return False, f"长度={len(content)} 超过 {limit}" return True, "无检查规则" def extract_json(content: str): content = content.strip() if content.startswith("```"): content = re.sub(r"^```(?:json)?\s*", "", content) content = re.sub(r"\s*```$", "", content) try: return json.loads(content) except json.JSONDecodeError: pass start = content.find("{") end = content.rfind("}") if start != -1 and end != -1 and end > start: try: return json.loads(content[start:end + 1]) except json.JSONDecodeError: return None return Noneextract_json处理了模型输出里最常见的两种非标准情况:带 Markdown 代码块包裹、以及输出里混入了额外文字。很多模型在低温下仍然喜欢在 JSON 前后加说明,因此这一步是必须的。
4.4 结果汇总与报告输出
主流程用线程池并发执行所有“模型 x 用例”组合,最后汇总成 JSON 和 Markdown 表格。
import argparse import concurrent.futures as cf import os import yaml def load_config(path: str) -> dict: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) or {} def build_report(results: list, output_dir: str = "report") -> dict: os.makedirs(output_dir, exist_ok=True) summary = {} for r in results: entry = summary.setdefault(r.model, { "ok": 0, "error": 0, "pass": 0, "fail": 0, "total_latency": 0.0, "total_tokens": 0, }) if r.status == "ok": entry["ok"] += 1 if r.passed: entry["pass"] += 1 else: entry["fail"] += 1 entry["total_latency"] += r.latency entry["total_tokens"] += r.total_tokens else: entry["error"] += 1 with open(os.path.join(output_dir, "results.json"), "w", encoding="utf-8") as f: json.dump([dataclasses.asdict(r) for r in results], f, ensure_ascii=False, indent=2) with open(os.path.join(output_dir, "summary.json"), "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) return summary def render_markdown_table(summary: dict) -> str: lines = [ "| 模型 | 通过率 | 平均耗时(s) | 总Token | 错误数 |", "| --- | --- | --- | --- | --- |", ] for model, s in summary.items(): done = s["ok"] pass_rate = s["pass"] / done if done else 0 avg_latency = s["total_latency"] / done if done else 0 lines.append( f"| {model} | {pass_rate:.1%} | {avg_latency:.2f} " f"| {s['total_tokens']} | {s['error']} |" ) return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="OpenRouter LLM Benchmark") parser.add_argument("--models", nargs="+", required=True, help="模型 ID 列表,例如 openai/gpt-4o-mini") parser.add_argument("--cases", default="cases/bench_cases.json") parser.add_argument("--config", default="config.yaml") parser.add_argument("--output", default="report") args = parser.parse_args() with open(args.cases, encoding="utf-8") as f: cases = json.load(f) config = load_config(args.config) tasks = [(m, c) for m in args.models for c in cases] results = [] with cf.ThreadPoolExecutor(max_workers=config.get("concurrency", 4)) as pool: futures = [pool.submit(run_case, m, c, config) for m, c in tasks] for fut in cf.as_completed(futures): results.append(fut.result()) summary = build_report(results, args.output) print(render_markdown_table(summary)) if __name__ == "__main__": main()这个实现已经是一个能跑的最小闭环。实际项目中还需要补充:请求重试、失败用例的完整输出留档、成本统计和日志级别控制。
5. 运行验证与结果分析
5.1 单模型快速验证
先不要急着批量跑。用一个模型、一个用例验证整条链路是否通:
python bench.py --models openai/gpt-4o-mini --cases cases/bench_cases.json预期会在终端打印一张只有一行的 Markdown 表格。如果模型返回了 JSON 字段,通过率就是 100%;如果出现error,优先检查 API Key、模型 ID 和请求格式。
这一步是调试阶段的关键检查点:链路通了之后,再增加模型和用例。否则一次引入十几个变量,出现问题很难定位。
5.2 多模型批量对比
链路验证通过后,批量执行:
python bench.py \ --models openai/gpt-4o-mini anthropic/claude-3.5-haiku meta-llama/llama-3.1-8b-instruct \ --cases cases/bench_cases.json终端输出的表格大致是:
| 模型 | 通过率 | 平均耗时(s) | 总Token | 错误数 |
|---|---|---|---|---|
| openai/gpt-4o-mini | 100.0% | 1.23 | 1890 | 0 |
| anthropic/claude-3.5-haiku | 66.7% | 1.45 | 1725 | 0 |
| meta-llama/llama-3.1-8b-instruct | 33.3% | 2.01 | 2010 | 1 |
同时report/目录下会生成results.json和summary.json。results.json保存每条用例的详细结果,是排查失败原因的第一手资料;summary.json是聚合统计,适合脚本继续处理。
5.3 怎样判断评测结果可信
看到对比表格之后,先别急着下结论。要确认以下几点:
- 每个模型是否用了相同的 temperature 和 max_tokens;
- 失败用例的失败原因是否合理,比如 JSON 解析失败与模型能力关系大,而超时更多与网络相关;
- 通过率差距是否足够大。两个模型相差 3% 到 5% 时,先增加用例数量或重复轮次,避免被单次随机波动误导;
- 平均耗时要结合完成 token 数看。一个模型输出 500 个 token 用时 2 秒,和一个模型输出 50 个 token 用时 2 秒,体验完全不同。
可信的评测不是跑一次就结束,而是同一套用例、同一套参数、同一个数据输出目录,隔一段时间再跑一次,形成回归记录。
注意:不要只验证程序能启动,还要验证输入、输出、异常分支和日志是否符合预期。一次成功的评测,应该同时包含“通过用例的证据”和“失败用例的原因”。
6. 常见问题排查:从报错到结论
6.1 模型名 404 或 model not found
现象:请求返回 404,提示模型不存在。
可能原因:模型 ID 写错;模型已下线;模型 ID 带空格或多余斜杠;OpenRouter 上该模型名称与厂商侧命名不一致。
检查方式:调用模型列表接口确认当前可用的模型 ID。
curl https://openrouter.ai/api/v1/models \ -H "Authorization: Bearer $OPENROUTER_API_KEY" | python -m json.tool | head -100处理建议:从列表接口里复制完整 ID,不要凭记忆手写。同时留意模型 ID 里的小写规则,比如厂商名/模型名的大小写是固定的。
6.2 请求被限流,返回 429
现象:批量跑几个模型时,部分请求返回 429 Too Many Requests。
可能原因:并发数过高;免费额度下的速率限制;同账号短时间请求过多。
检查方式:查看响应头里的限流字段,常见的是x-ratelimit-remaining和x-ratelimit-reset;也可以在config.yaml里把concurrency降到 1 再跑一次,看是否消失。
处理建议:把concurrency调小,例如 2 或 4;在call_model里增加指数退避重试;只对可重试的状态码重试,5xx 和 429 可以重试,4xx 一般不需要。
6.3 模型输出 JSON 解析失败
现象:用例类型是json_object,但模型报“JSON 解析失败”。
可能原因:模型在 JSON 前后输出了解释文字;JSON 被 Markdown 代码块包裹;字段值里带了换行或转义问题;模型输出被 max_tokens 截断,导致 JSON 不完整。
检查方式:打开results.json,找到对应用例的detail,把原始content打印出来看。
处理建议:先用extract_json自动剥离代码块和多余文本;如果仍然失败,检查max_tokens是否太小;不要在所有用例里强制response_format,因为该字段并非所有 OpenRouter 上托管的模型都支持。
6.4 评测结果忽高忽低
现象:同一个模型、同一个用例,两次跑出的通过率不一样。
可能原因:temperature 没有固定为 0;部分模型不保证 seed 生效;网络超时导致部分请求失败;用例数量太少,单次失败对通过率影响过大。
检查方式:对比两次results.json,看差异集中在哪些用例;确认所有模型都使用了相同的参数配置。
处理建议:基准评测统一 temperature 为 0;每个用例可以跑 2 到 3 轮取多数结论;报告里同时记录模型 ID 和评测日期,方便回溯。
6.5 成本比预期高
现象:跑一次多模型对比,token 消耗远超预算。
可能原因:max_tokens设置过大,模型生成了大量无关内容;用例 Prompt 过长,每次请求都携带完整上下文;并发和重试放大了请求次数。
检查方式:查看summary.json里的total_tokens,按模型拆分成本;检查是否有重试导致的重复计费请求。
处理建议:先跑一个模型并查看实际输出长度,再决定max_tokens;对不需要长输出的用例设置更小上限;批量前先估算单位 token 价格和用例总量。
7. 工程化改造与最佳实践
7.1 从脚本到可维护工具
上面的代码在本地跑通没有问题,但要作为团队工具使用,还需要补几个能力:
- CLI 参数完善:支持
--format json、--repeat 3、--only-case xxx,方便局部调试; - 请求缓存:对相同模型、相同 Prompt、相同参数的结果做本地缓存,避免重复计费;
- 日志系统:用标准 logging 输出请求耗时、错误和重试信息,而不是全部 print;
- 原始响应留档:把每个请求的原始响应保存到
report/raw/{model}/{case_id}.json,失败排查时不需要重新调用接口; - 类型化用例库:把用例按能力分类,例如抽取、计算、摘要、分类、长上下文、多轮对话,形成可复用的回归集。
7.2 参数选型清单
跑 Benchmark 之前,建议按这张表逐项确认:
| 检查项 | 推荐做法 |
|---|---|
| API Key | 从.env读取,不硬编码,不提交仓库 |
| 模型 ID | 从/api/v1/models接口复制,确认可用 |
| temperature | 固定为 0,除非要测随机性场景 |
| max_tokens | 先跑样例确认长度,再设置合适上限 |
| 并发数 | 先 1 后 4,根据限流情况调整 |
| 用例数量 | 单个能力至少 10 条用例 |
| 失败重试 | 429、5xx 做指数退避重试 |
| 结果留档 | 保存 JSON 原文和评测日期 |
| 成本记录 | 记录每个模型总 token 数和费用估算 |
7.3 检查清单:发布或归档一次 Benchmark 前
- [ ] 用例文件是否已评审,检查规则是否机器可判;
- [ ] 所有模型是否使用相同参数配置;
- [ ] 是否先跑单模型验证链路;
- [ ] 失败用例是否能在原始响应里查到原因;
- [ ] 是否记录模型 ID、配置版本和评测日期;
- [ ] 是否保存了
results.json、summary.json和报告表格; - [ ] 成本估算是否在预算内;
- [ ] 如果结果将用于生产选型,是否考虑了业务特殊场景的补充用例。
模型选型不会一劳永逸。新模型发布、旧模型调整、业务 Prompt 变化,都可能导致之前的最佳选择失效。一个轻量 Benchmark 工具真正能带来的,是把“哪个模型更好”从主观印象变成可重复、可留档、可回归的工程数据。下一步可以做的扩展方向包括:把评测接入 CI,在新模型加入时自动跑回归;增加 LLM-as-Judge 的开放题评分模式;按业务场景组装用例集,形成团队内部的模型能力基线。对新手来说,最值得做的练习是先把这个最小版本跑通,再往里面加一个你觉得最需要的检查规则,这比直接套用重量级评测框架更能理解 Benchmark 的本质。