ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

借助OpenRouter将新模型评测从一周缩短到两小时

2026/9/24 22:09:14 拓冰建站 浏览量
借助OpenRouter将新模型评测从一周缩短到两小时 Descript 借助 OpenRouter 将新模型评测从一周缩短到两小时做 AI 应用的人应该都有这种体会模型评测这件事看起来只是把新模型拉出来跑一遍但实际上它往往是整个项目迭代里最容易被低估的瓶颈。我之前在 Descript 的工作里就踩过这么大的坑——每次想评估一个新模型是否能替代当前方案前后要耗掉整整一周。后来我们换了一套基于 OpenRouter 的评测流水线把整个周期压缩到了两小时以内。这篇文章就把我们当时的完整思路、踩坑过程和最终的实现细节都拿出来聊聊希望能给同样被模型评测拖慢节奏的团队一些参考。先说清楚背景。Descript 是一家做音频和视频编辑工具的公司产品里大量依赖语音转录、说话人识别、字幕生成、文本润色这些 AI 能力。我们对模型的需求不是哪个模型分数高就用哪个而是要找到在真实业务场景下表现最稳定、延迟可接受、成本可控的组合。这就意味着每次有新模型发布或者有新的开源模型被托管平台收录我们都得快速回答一个问题这模型在我们的任务上到底行不行在过去这个问题的答案需要一周。不是因为我们不勤奋而是整个评测流程里充满了手工操作和等待。1. 新模型评测的旧链路一周时间究竟耗在了哪里很多团队评测模型的方式本质上还是项目制的先选一个要评估的模型然后写测试脚本、准备测试数据、跑推理、收集结果、人工分析、写报告。听起来每一步都有明确产出但真正做起来你会发现时间的消耗点非常反直觉。1.1 接入阶段每一个模型都是一次新的集成最耗时的一环其实是接入模型本身。我们当时要评估的模型来源五花八门有 OpenAI 的闭源 API、有 Anthropic 的 Claude、有开源模型通过不同托管商暴露的接口还有一些只在特定云平台提供了 Serverless 端点。每一家都有自己的 API 格式、鉴权方式、请求限制和计费逻辑。比如同样是要传一段音频让它转写OpenAI 的接口和某个开源模型托管的接口请求体结构完全不一样。我们得为每个模型单独写一个适配层。如果这次评测涉及五个模型那就是五个适配层外加五份文档阅读、五次鉴权调试、五个不同的超时处理机制。光是把所有模型都跑通一个最小请求往往就要花两到三天。1.2 评测阶段串行执行和人工盯盘接入之后是正式评测。我们的测试集包含几百条真实业务数据涵盖不同口音、背景噪音、语速、专业术语等场景。旧流程里评测脚本是串行跑的——一个模型测完再测下一个因为当时的脚本设计比较简陋所有输出都打到同一个日志文件里不分开就没法判断结果归属。更痛苦的是某些外部 API 在跑大规模任务时经常报 429 限流错误或者超时。当时我们没有做自动重试和队列管理一旦脚本中途挂掉就得人工发现、手动从断点续跑。有一次评测跑到半夜 3 点因为某个模型的请求频率限制跟我们预想的不一样整个任务卡了六个小时第二天早上才发现。1.3 分析报告人工对比与主观判断跑完所有模型后还需要人工把每个模型的输出和标准答案做对比。转录类的任务还好有一些客观指标如词错误率WER可以算但涉及摘要生成、文字润色这类主观性较强的任务时就没有统一标准了只能靠几个人分头看输出然后开会讨论、打分、写结论。这个环节最少也要一整天。整个流程梳理下来你会发现真正花在评测思考上的时间其实很少大量时间都消耗在适配、等待、排错和人工汇总上。而 OpenRouter 的价值恰好就是把这些脏活累活集中解决掉。2. OpenRouter 在评测链路里解决的三个关键断点OpenRouter 是一个模型路由平台它做的事情可以简单理解为通过一个统一的 API 接口让你访问市面上几乎所有主流的大模型服务。你不用再为每个模型单独注册账号、单独适配接口只需要跟 OpenRouter 打交道就够了。但在实际使用中它对评测工作流的价值远不止方便这一点。我认为真正关键的是下面三个断点。2.1 统一接口评测脚本只需要写一次OpenRouter 的 API 在设计上兼容 OpenAI 的接口格式。这意味着你如果写过调用 OpenAI 的代码那么切换到 OpenRouter 只需要改 base_url 和 API key 两处。这对评测的意义非常直接我们可以把模型接入从评测流程中彻底剥离出去。评测脚本不再针对某个具体模型写适配层而是面向OpenRouter 接口编程。要做多模型对比时只需要在请求里传入不同的模型标识符例如openai/gpt-4o、anthropic/claude-3.5-sonnet、meta-llama/llama-3.1-70b-instruct等其余逻辑完全复用。2.2 多模型并行调度评测从串行变成并发OpenRouter 平台本身托管了多家模型供应商因此它天然支持并发请求多个不同模型。旧流程里的一个模型测完再测下一个被直接改成了一次性把所有模型丢上去并发跑。配合 OpenAI SDK 的异步客户端或者简单的ThreadPoolExecutor就能把整个评测时间从所有模型耗时之和压缩到最慢的那个模型的耗时。这一步是整个评测时间从一周缩短到两小时的最大功臣。2.3 可观测性与 Fallback 机制脚本不再轻易挂掉OpenRouter 提供了每个请求的耗时、token 用量、模型供应商等元数据。这些信息直接解决了我们旧流程里的两大痛点一是日志混乱不知道结果属于哪个模型二是任务中断后恢复困难。另外OpenRouter 支持在请求参数里指定provider的优先级甚至可以在某个供应商不可用时自动 fallback 到备选供应商。这意味着我们的评测脚本不再需要为某个模型挂了怎么办写大量异常处理逻辑——让平台去处理这些故障我们只关心评测本身。3. 两小时评测流水线的整体架构设计明确了 OpenRouter 解决的关键问题后我们来拆一下新流水线的架构。整个方案围绕着把评测当成一个自动化管道来设计而不是一个个独立的脚本。3.1 评测任务的输入输出规范第一步我们定义了一套统一的评测任务格式。每一行是一个评测样本包含以下字段{ task_id: eval_0001, task_type: transcription, audio_url: https://example.com/audio/sample_0001.wav, reference: 标准转写文本内容, metadata: { accent: american, noise_level: low, speaker_count: 2 } }对于文本类任务比如摘要、润色则把audio_url替换为input_text。task_type字段用于告诉评测框架该用哪种评分逻辑。这里有一个值得注意的细节所有评测样本先统一成 JSON Lines 格式每一行一个样本。这样后续无论是并发处理、断点续跑还是结果分析都能用最简单的文本操作完成。3.2 评测框架的执行流程整个流水线分为四个阶段按照依赖关系依次执行阶段一样本预处理。把原始评测数据加载进来过滤掉无效样本按任务类型分组分配唯一 ID。这个阶段基本秒级完成。阶段二并发推理。评测框架读取模型列表为每个模型创建一个独立的评测任务组。每个任务组内部通过异步方式并发处理所有评测样本。每个样本完成后把结果写到一个独立的 JSONL 文件里路径按模型名/任务类型/批次.jsonl组织。阶段三自动评分。等所有推理任务完成后框架遍历结果文件根据task_type调用对应的评分器。转录任务用 WER 计算分类任务用准确率生成类任务则调用一个评委模型进行打分。阶段四报告生成。把所有模型的评分汇总成一张对比表输出 Markdown 格式的评测报告同时把样本级别的结果存入本地数据库备用。3.3 为什么不再需要评测群旧流程里每次评测都要拉一个临时群有人在群里汇报进度有人负责盯日志有人最后整理结论。新流水线把这一切全部自动化后评测期间的群聊内容只剩一条消息评测已启动预计一小时后出报告大家忙自己的去吧。这个变化听起来不大但实际体验完全不一样。当评测不再需要人肉盯守团队成员就可以同时推进其他工作只有最终报告出来时才需要回到评测结果上。4. 核心实现从并发调用到自动评分的完整代码接下来是实操部分。我会给出完整可参考的代码结构基于 Python 和 OpenAI SDK 实现配合 OpenRouter 作为后端。4.1 环境准备与基础配置首先安装依赖pip install openai1.30.1 tqdm然后创建一个配置文件config.yaml管理模型列表和参数models: - id: openai/gpt-4o max_tokens: 1024 temperature: 0.2 - id: anthropic/claude-3.5-sonnet max_tokens: 1024 temperature: 0.2 - id: meta-llama/llama-3.1-70b-instruct max_tokens: 1024 temperature: 0.2 eval_batch_size: 16 max_concurrent_tasks: 32 retry_times: 3 timeout_seconds: 120这个配置文件的关键点在于max_concurrent_tasks参数。根据我们实际测试OpenRouter 的免费/基础套餐对并发数有一定容忍度设置 32 个并发任务时绝大多数请求都能在 30 秒内返回。不建议一开始就上 100 并发容易被限流后面我会专门讲这个问题。4.2 统一的推理调用函数因为 OpenRouter 兼容 OpenAI 接口所以推理函数非常简单import asyncio import json import time from openai import AsyncOpenAI from tqdm.asyncio import tqdm client AsyncOpenAI( base_urlhttps://openrouter.ai/api/v1, api_keyYOUR_OPENROUTER_API_KEY, timeout120.0, ) async def infer_one(model_id: str, prompt: str, max_tokens: int, temperature: float) - dict: start time.time() try: response await client.chat.completions.create( modelmodel_id, messages[ {role: user, content: prompt} ], max_tokensmax_tokens, temperaturetemperature, extra_headers{ HTTP-Referer: https://descript.com/model-eval, # 可选展示来源 X-Title: Descript Model Eval, }, ) elapsed time.time() - start return { text: response.choices[0].message.content, usage: response.usage.model_dump(), elapsed_seconds: elapsed, error: None, } except Exception as e: elapsed time.time() - start return { text: None, usage: None, elapsed_seconds: elapsed, error: str(e), }这段代码里最核心的设计有两点第一异常被捕获并作为正常返回值的一部分。这样即使某个样本请求失败也不会中断整个评测循环而是记录在结果里最后统一分析。第二每一次请求都记录耗时和 token 用量。这些信息对后续的成本评估和速度评估非常重要别等到评测完才发现没有记录这些数据。4.3 带重试和并发控制的评测主循环单纯的并发调用很简单但实际生产环境中需要处理限流、超时、网络抖动等问题。我们的做法是在每个样本级别做最多 3 次重试重试之间指数退避async def infer_with_retry(model_id: str, prompt: str, max_tokens: int, temperature: int, retry_times: int 3) - dict: for attempt in range(retry_times): result await infer_one(model_id, prompt, max_tokens, temperature) if result[error] is None: return result wait_time 2 ** attempt * 1.5 await asyncio.sleep(wait_time) return result async def run_eval_for_model(model_id: str, samples: list[dict], config: dict) - list[dict]: sem asyncio.Semaphore(config[max_concurrent_tasks]) async def sem_infer(sample: dict) - dict: async with sem: prompt build_prompt(sample) result await infer_with_retry(model_id, prompt, config[max_tokens], config[temperature]) return { model_id: model_id, sample: sample, result: result, } results [] for future in tqdm.asyncio.as_completed([sem_infer(s) for s in samples], descmodel_id, leaveFalse): results.append(await future) return results这里的build_prompt函数负责把不同类型的评测样本转换为模型输入。比如转录任务我们先把音频文件用 Whisper 之外的专用模型转成文本或者直接交给支持音频输入的模型再把文本和任务指令拼成 prompt。具体任务不同这部分逻辑会有所差异但它不影响整个框架的设计。并发数为什么这里用信号量控制而不是直接一口气全开因为 OpenRouter 背后连接的是不同的供应商有些供应商对单用户并发非常敏感。如果我们一次性发起 1000 个并发请求OpenRouter 层可能扛得住但下游供应商会直接拒绝服务导致大量 429 报错反而拖慢评测速度。实测下来 32 并发是一个比较稳的值。4.4 基于指标自动评分所有模型的推理结果收集完成后进入评分环节。对于转录任务我们使用jiwer库计算词错误率from jiwer import wer def score_transcription(reference: str, hypothesis: str) - dict: error_rate wer(reference, hypothesis) return { metric: wer, value: round(error_rate, 4), reference_length: len(reference.split()), }对于摘要生成、文本润色这类没有标准答案的生成任务我们采用评委模型方案。即用一个较强的模型比如 GPT-4o 或 Claude充当评委给其他模型的输出打分。评分维度包括相关性、忠实度、流畅度三项每项 1-5 分。JUDGE_PROMPT 你是一个严格的评测员。下面是一个任务的要求和两个输出请对输出进行打分。 【任务要求】 {task_instruction} 【标准输出】 {reference} 【待评模型输出】 {candidate} 请从以下三个维度分别打分1-5分5分为最好并用JSON格式输出 {{ relevance: 5, faithfulness: 4, fluency: 5, overall: 4.7 }} 只输出JSON不要输出其他内容。 async def score_generation(reference: str, candidate: str, task_instruction: str) - dict: prompt JUDGE_PROMPT.format( task_instructiontask_instruction, referencereference, candidatecandidate, ) result await infer_one(openai/gpt-4o, prompt, max_tokens100, temperature0) if result[error]: return {error: result[error]} # 解析 JSON注意模型可能会输出 markdown code fence需要清理 content result[text].strip() content content.removeprefix(json).removesuffix().strip() try: return json.loads(content) except json.JSONDecodeError: return {error: fFailed to parse judge response: {content[:200]}}这里有一个非常实际的坑模型输出的 JSON 经常带着 markdown 的代码块标记直接json.loads会报错。必须在解析前先清除json 和标记否则这个环节会莫名消耗很多调试时间。4.5 报告生成与结果存储最后一步是把所有模型的评分汇总成对比表输出 Markdown 报告并保存原始结果。def generate_report(all_results: dict) - str: lines [# 模型评测报告, ] lines.append(## 1. 总体对比) lines.append(| 模型 | 平均耗时(s) | 总Token | WER | 综合得分 |) lines.append(|------|-------------|---------|-----|----------|) for model_id, model_results in all_results.items(): # 计算各项指标... lines.append(f| {model_id} | {avg_time:.1f} | {total_tokens} | {wer_score:.4f} | {overall:.2f} |) lines.append() lines.append(## 2. 样本级结果) # 每个模型挑几个有代表性的样本展示... return \n.join(lines)整个流水线的主入口只需要这样几行代码samples load_samples(eval_data.jsonl) config load_config(config.yaml) all_results {} for model in config[models]: model_results asyncio.run(run_eval_for_model(model[id], samples, config)) all_results[model[id]] model_results save_raw_results(all_results, results/) report generate_report(all_results) save_report(report, report.md)从启动到出报告一个包含 5 个模型、500 条评测样本的评测任务在我们的实测中只需要 70-100 分钟。配合 OpenRouter 的接口整个流程不再需要任何人工介入。5. 实测数据80 分钟跑完 5 个模型前后差异有多大指标才是最有力的说服证据。我们把旧流程和新流程进行了对比以下是基于一次实际评测任务的数据。5.1 新旧流程耗时对比这次评测任务包含 5 个候选模型、300 条真实业务样本任务类型以语音转录和标题生成为主。环节旧流程耗时新流程耗时变化模型接入与适配2-3 天30 分钟写配置大幅缩短评测脚本开发1-1.5 天约 20 分钟复用框架首次搭建后几乎为零推理执行6-10 小时约 80 分钟约 5-7 倍提速人工对比与报告8 小时5 分钟自动生成几乎归零总计4-6 个工作日2 小时左右约 20 倍提速这里最让我意外的是新流程里最耗时的部分反而变成了写评测报告前的指标复核。因为报告自动生成后我们还是需要人工抽看几个样本确认评分器没有出现明显误判。这个环节花的时间很少约 20-30 分钟但它是必要的质量保障。5.2 Token 成本与速度从成本角度看基于 OpenRouter 做评测其实非常划算。一次 300 样本、5 模型的评测大约消耗输入 token约 150 万主要是每个模型的 prompt 一样输入侧消耗固定输出 token约 50 万取决于生成长度总费用约 $20-40具体取决于所选模型的单价相比人工一周的时间成本这几十美元几乎可以忽略不计。而且我们可以在低峰时段跑评测进一步降低部分模型的调用价格。速度方面300 个样本按 32 并发跑单个模型大约 10-15 分钟完成全部推理。5 个模型并发跑整体 80 分钟出头和文档里说的两小时完全吻合。5.3 与旧流程的隐藏对比这里有一个容易被忽略的对比维度旧流程虽然耗时长但中间有人工介入所以人脑会对数据有一个模糊校验。新流程完全自动化后一旦评分器本身有问题比如 prompt 写得不清晰导致评委模型胡乱打分就会产生看起来漂亮但实际不可信的报告。针对这一点我们后来在评测框架里加了一个简单的抽检模块每次评测完成后随机抽取 10 个样本把模型输出和评分结果打印成表格强制要求相关负责人扫一眼。这个模块大概多花 10 分钟但能有效避免自动化跑出一份谁都没验证过的报告这类问题。6. 接入 OpenRouter 过程中的典型坑与应对OpenRouter 虽然大大简化了多模型接入但它并不是银弹。实际使用中我们也遇到了不少问题这里挑几个有代表性的展开讲讲。6.1 上游供应商的响应差异极其悬殊OpenRouter 统一了接口但它底层连接的供应商质量参差不齐。有的供应商响应稳定平均延迟 1-2 秒有的供应商在高峰时段平均延迟高达 20-30 秒而且经常超时。我们一开始在 OpenRouter 上选择某个开源模型的标识符时以为所有供应商都一样。后来发现同一个模型标识符在不同时段可能被路由到不同的供应商评测结果在延迟和成功率上会波动。解决方案是通过 OpenRouter API 的/models端点查看该模型的可用供应商列表并在请求中显式指定provider参数。比如extra_body{ provider: { order: [DeepInfra, Together, Novita], allow_fallbacks: True, } }这样就能让请求优先走我们验证过比较稳定的供应商又保留了降级兜底。在评测场景里这个参数的配置基本可以保证推理阶段不会因为供应商波动而跑飞。6.2 部分模型的输出格式漂移不同模型对指令的理解和输出格式的遵循程度差别很大。在我们的评测任务里要求模型输出 JSON 格式结果时有的模型总会在 JSON 前面加上Here is the JSON:或者解释性文字导致解析失败。这个问题在人工评测时不会暴露但在自动化流水线里就很麻烦。我们的处理办法是两层保险第一层在 prompt 最后强调只输出 JSON不要输出任何其他内容第二层在解析阶段做容错处理尝试提取文本中第一个{到最后一个}之间的内容再解析。import re def extract_json(text: str) - dict: # 优先直接解析 text text.strip() try: return json.loads(text) except json.JSONDecodeError: pass # 提取大括号内容 pattern r\{.*\} match re.search(pattern, text, re.DOTALL) if match: return json.loads(match.group(0)) raise ValueError(f无法从模型中提取JSON: {text[:200]})这个方法在实际评测中帮我们避免了很多次重跑一轮的冲动。6.3 并发数不是越大越好我们在早期版本里为了追求速度把max_concurrent_tasks设成了 200。结果 5 分钟后OpenRouter 返回大量 429 限流错误而且部分供应商直接把我们暂时屏蔽了。后来我们把并发降到 32一切恢复正常整体耗时反而更短了。这个经验的普适性很强在评测这种任务里稳定压倒一切。与其用 200 并发换来一堆超时重试不如用 32 并发稳定跑完一轮。毕竟我们的目标是两小时出报告而不是 10 分钟出报告但附带 30% 的重试率。6.4 别忘了记录每次请求的 meta 信息最后一条经验从第一天起就要把每个请求的模型 ID、供应商、latency、token 用量全部落盘。我们后来在分析某个模型为什么贵的时候靠的就是这些历史数据。如果只在评测报告里保留最终指标一旦想反查某个样本的输入输出或耗时就得重新跑一遍评测非常耗时。评测数据是资产别丢。7. 这套方案在 Descript 内部的扩展应用基本评测流水线跑通后我们很快发现这套模式可以扩大到评测之外的地方。7.1 从评价竞品模型到回归自己的微调模型Descript 内部有相当多的微调模型每次更新训练数据或调整超参数后都需要验证新版本是否比旧版本好。以前这也是一个手工活现在直接复用评测框架把旧版模型和新版模型当作两个待评模型丢进去跑同一个测试集自动生成对比报告。区别只在模型来源微调模型的调用走的是 Descript 内部的推理服务但接口形式同样兼容 OpenAI 格式因此评测框架不需要改动任何逻辑。7.2 线上监控与评测的联动后来我们还做了一个更大胆的尝试把线上流量的一小部分实时导入评测队列用同样的评分器对线上模型的输出进行持续抽检。这样如果某个模型在某类输入上性能突然下降比如上游供应商更新了底层模型版本我们能在几小时内发现而不是等用户投诉后才知道。这个场景下评测流水线跑出来的报告不是要不要换模型的决策依据而是是否需要告警的触发信号。两小时的延迟在监控场景下依然属于可接受范围。7.3 有没有局限当然这套方案并非万能。有一个明显的局限是它依赖评测集设计和评分器的合理性。如果评测样本不能代表真实业务分布或者评分器本身有偏那自动化的效率提升反而会放大错误——因为以前人工介入多至少能在过程里发现这几个样本不太合理。所以我们现在的流程里保留了一个角色每次评测报告生成后由最了解业务的那个人花 15 分钟看一下评测集本身是否需要调整。这 15 分钟无法自动化但它保证了自动化的产出始终朝着正确的方向。8. 如果你也想搭一套类似的评测流水线我的建议根据我们这几次实践的经验如果你所在团队也有高频模型评测需求我的建议是分三步走。8.1 第一步先把数据格式理清楚在写任何评测代码之前花一周时间把已有的评测数据格式统一成标准 JSON Lines。这一步是最不性感但最值得做的事。数据格式一旦统一后面所有的处理逻辑都可以通用化。8.2 第二步不要自己造多模型接入轮子直接接入 OpenRouter 或者类似的网关服务。核心原因不是省钱而是省时间——你不想在每个新模型发布时都去读一遍新文档、写一遍适配层。统一网关让你把精力放在评测逻辑本身。8.3 第三步从最小闭环开始迭代不要一开始就追求完美的并发框架、完善的评分器、优雅的报告模板。先用手头最棘手的那个评测任务跑通最小闭环数据 → 并发推理 → 评分 → 报告。跑通之后再逐步增加并发控制、断点续跑、历史对比这些功能。我们第二次迭代时增加了断点续跑功能第三次增加了自动重试第四次才加的历史数据对比。每一步都有明确收益而不是一上来就做一个大而全的平台。8.4 最后时刻记住评测的目标评测不是为了证明某个模型好也不是为了展示自动化能力而是为了在有限资源下快速、可信地回答该用哪个模型、为什么。任何阻碍这个目标的流程设计都应该被毫不犹豫地简化掉。我们最终落地这套方案时团队里最资深的工程师说了句话我一直记得评测流水线跑得越快我们做技术决策的胆量就越大。因为选项再多也就两小时试出结果。这就是从一周到两小时带来的真正价值——不是在省时间而是在解放决策效率。