
在大模型应用从原型走向生产的过程中模型选型是一项绕不开的工作。同一个 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 要对所有模型使用一致的参数因此每个参数都要想清楚“固定成什么值、为什么”。参数建议默认值作用影响说明temperature0.0控制随机性0 时输出最确定适合多数题面型评测调高后同一 Prompt 多次结果波动会变大max_tokens1024限制最大输出长度太小会截断回答导致正确内容被判为失败太大可能抬高成本top_p1.0核采样概率阈值与 temperature 通常二选一调整评测时保持默认seed不设置部分模型支持固定随机种子并非所有模型支持设置后不保证行为一致反而可能引入误导response_format不设置强制 JSON 输出只有部分模型支持Benchmark 工具尽量不依赖模型专有能力timeout60 秒单次请求超时过短容易误报失败过长会让整体评测时间失控concurrency4并发请求数受 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 KeyPyYAML解析 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_KEYsk-or-v1-这里替换成你的密钥 OPENROUTER_BASE_URLhttps://openrouter.ai/api/v1 OPENROUTER_TITLEllm-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: fBearer {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(timeouttimeout) as client: resp client.post(url, headersheaders, jsonpayload) 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, temperatureconfig.get(default_temperature, 0.0), max_tokensconfig.get(default_max_tokens, 1024), timeoutconfig.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( modelmodel, case_idcase[id], statusok, latencydata.get(_elapsed_seconds, 0.0), prompt_tokensusage.get(prompt_tokens, 0), completion_tokensusage.get(completion_tokens, 0), total_tokensusage.get(total_tokens, 0), passedpassed, detaildetail, )检查规则集中在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, fJSON 解析失败: {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, encodingutf-8) as f: return yaml.safe_load(f) or {} def build_report(results: list, output_dir: str report) - dict: os.makedirs(output_dir, exist_okTrue) 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, encodingutf-8) as f: json.dump([dataclasses.asdict(r) for r in results], f, ensure_asciiFalse, indent2) with open(os.path.join(output_dir, summary.json), w, encodingutf-8) as f: json.dump(summary, f, ensure_asciiFalse, indent2) 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(descriptionOpenRouter LLM Benchmark) parser.add_argument(--models, nargs, requiredTrue, help模型 ID 列表例如 openai/gpt-4o-mini) parser.add_argument(--cases, defaultcases/bench_cases.json) parser.add_argument(--config, defaultconfig.yaml) parser.add_argument(--output, defaultreport) args parser.parse_args() with open(args.cases, encodingutf-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_workersconfig.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-mini100.0%1.2318900anthropic/claude-3.5-haiku66.7%1.4517250meta-llama/llama-3.1-8b-instruct33.3%2.0120101同时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 的本质。