用业务提示词并排评测多家大模型
开源项目 ai-evals 支持用相同设置并排运行最多三个模型,并由大模型裁判评分、解释差异。项目基于 FastAPI 和 React,可混用 OpenAI、Anthropic、OpenRouter 及兼容 OpenAI 接口的本地服务。
AI 深度解读
榜单能告诉你模型在公共测试里排第几,却很难回答:团队每天使用的那条提示词,交给谁最合适?作者做了一个基于 FastAPI 和 React 的对比工具,让两到三个模型用同一组输入同时作答,再由独立模型评分,方便反复替换候选。
它值得看的地方,是把一次模型选型拆成了可检查的工程流程:参数怎样统一、流式响应怎样汇合、裁判怎样减少偏见。不过,这仍是概念验证,评分也不能直接当成业务结论。
同一条提示词,怎样才算公平比较
工具有三个模型槽位,每次至少填两个。候选可以来自 OpenRouter、OpenAI、Anthropic,也可以接入支持 OpenAI chat-completions API 的服务,例如 Ollama、vLLM 和 LM Studio。同一个模型通过不同供应商接入,也能放在一轮里比较。
所有候选共享用户提示词、可选的 system prompt,以及 temperature、top_p、max_tokens。下面这段定义约束了参数范围,也规定了空值的含义:
class SharedParams(BaseModel):
"""应用于同一轮所有模型的推理参数;未设置的参数使用模型默认值。"""
temperature: Optional[float] = Field(None, ge=0, le=2)
top_p: Optional[float] = Field(None, gt=0, le=1)
max_tokens: Optional[int] = Field(None, ge=1)
要留意 Field(None, ...):留空的参数不会发送,各模型使用自己的默认值。因此,「都没填 temperature」只能保证都没主动指定,不能保证实际采样行为一致。
同一份请求配置,是比较的起点,还不足以保证推理条件完全相同。
运行前,后端会检查每个模型是否支持已填写的参数。只要一个不支持,就拒绝整轮请求,提示清空该参数或移除模型,避免悄悄给不同候选发送不同配置。
但检查能力取决于供应商。OpenRouter 的目录提供 supported_parameters,可以据此校验;OpenAI 和 Anthropic 没有按模型列出这些采样参数,工具便先假定支持。这里存在明确缺口,原文也没有给出完整的逐模型兼容表。
模型身份采用 provider::model,例如 openrouter::anthropic/claude-haiku-4.5 和 anthropic::claude-haiku-4.5。后端用前半部分选择适配器,再把裸模型名交给适配器,兼顾路由与结果归属。
多路回答同时返回,关键是别互相拖垮
后端使用 Python、FastAPI 和 httpx,前端使用 React、TypeScript、Vite 和 Tailwind CSS。运行记录放在内存里,适合作者描述的交互式试用。
浏览器通过 POST /api/run 提交提示词、参数、候选和裁判。后端为每个模型创建一个 asyncio 任务,把带有模型标识的事件放入共享队列,再经同一条服务端发送事件(SSE)响应返回。
这段代码解决的是单路失败隔离,以及多路任务何时全部结束:
async def consume(model_id: str) -> None:
try:
adapter = adapter_for(model_id)
_, bare_model = parse_composite(model_id)
async for ev in adapter.stream_completion(bare_model, prompt, system_prompt, params):
await queue.put(TaggedEvent(model_id, ev))
except Exception as exc: # 一个模型崩溃时,不要终止其他并发任务
await queue.put(TaggedEvent(model_id, StreamEvent(kind="error", error=str(exc))))
finally:
await queue.put(None) # 哨兵值:这个模型已结束
重点看 finally 里的 None。它让消费端统计已结束的任务,无论某一路成功还是报错,都能正常收尾。无效密钥、未知模型或连接中断只影响对应面板,其余回答继续流式显示。
前端还有一个实用细节:原生 EventSource 不能设置 POST 方法和 JSON 请求体,因此这里使用 fetch 和流读取器,自行拆分 SSE 消息。需要提交复杂输入的 AI 页面,可以参考这个处理方向。
流结束后,才通过 POST /api/runs/{id}/evaluate 发起评分。Anthropic 的流中错误事件、OpenRouter 返回块里的 error 字段,也会被适配器识别为失败,避免拿半截回答参与评选。
裁判能省阅读时间,但分数怎么来的
裁判按照五项指标评分,每项都有权重、1–5 分量表,以及最低分和最高分的文字描述。每项指标单独调用一次裁判,并让它同时看到全部候选回答,因此评分带有相对比较性质。
各项分数归一化到 0–1,再按权重合成。如果前两名差距在 0.15 以内,就额外做一次两两比较。原文没有列出五项指标的具体名称和权重,读者无法仅凭文章判断它们是否适合自己的任务。
为减少位置偏差和裁判偏爱自身风格的问题,作者做了三层处理:
- 裁判只看到 A、B、C,不知道模型名称,映射关系留在服务端。
- 每轮随机分配字母,每项指标调用时再随机排列回答;可选 seed 用于让测试顺序可复现。
- 裁判不能与候选属于同一家族。例如
openai::gpt-4.1-mini和openrouter::openai/gpt-4o都算 OpenAI 家族。
家族识别依赖命名规则:OpenRouter 取模型名中的厂商前缀,直连 OpenAI、Anthropic 取 provider,openai-compatible 则取模型名。这能执行一条明确限制,但不等于准确识别所有本地别名背后的模型来源。
裁判必须为每个字母返回 JSON 分数与理由。供应商支持时使用严格 JSON schema,后端再用 Pydantic 校验,并检查每个字母恰好出现一次。失败重试一次;仍失败的指标会被排除,其权重分给成功指标,界面会显示缺项。
参数差异也会影响执行。Anthropic 要求每次提供 max_tokens,用户留空时,工具使用模型列表给出的上限,拿不到则回退到 8,192。原文还提到 Claude 4.7 及之后对非默认 temperature、top_p、top_k 的限制,以及 OpenAI 相关模型在 reasoning effort 不是 none 时对 temperature、top_p 的限制;这些都应按具体模型接口核对。
裁判默认请求 temperature=0。如果错误信息包含 temperature,就清空参数重试。这提高了调用成功率,也让最终评分配置可能发生变化。
我的判断
作者最有说服力的地方,是把真实提示词放到了选型中心。格式要求、团队语气和边界情况,确实需要拿实际输入检验。共享参数、保留评分理由、隔离失败,也让比较过程更容易检查。
但原文展示的是实现,没有给出选型准确率、裁判与人工判断的一致率,也没有提供和其他评估方案的对照数据。「更快找到合适模型」仍是作者的经验之谈。
裁判适合帮助筛选,最终采用哪个模型,还要回到业务验收。
匿名和乱序能缓解偏差,无法证明偏差已经消失。指标缺失后重新分配权重,也会改变总分含义:两轮各自缺了不同指标时,分数就不宜直接横比。
相比人工并排阅读,它适合先处理较长回答;相比固定测试集,它更方便探索候选。但团队要长期选型,还需要多条代表性样本、重复运行,以及成本、延迟和稳定性记录。原文没有展示这些完整数据,不能把一次胜出扩展成长期优势。
能拿来做什么
- 先选两到三个候选,用日常 AI 编程中反复出现的真实提示词比较。把输出格式和边界条件写清楚,人工检查裁判给出的理由是否符合验收要求。
- 为 React 或全栈项目选择模型时,可以复用你们实际使用的代码生成、解释或修改提示词。涉及生成代码时,还要运行项目现有检查,不能只凭回答是否流畅决定。
- 做多模型流式页面时,可以参考「独立异步任务、共享事件队列、模型标识、结束哨兵」的组合,并单独处理流中报错,防止残缺结果进入后续评分。
- 本地试用至少需要一个供应商密钥,或一个 OpenAI-compatible 服务。后端依次执行
cd backend、python3 -m venv .venv、source .venv/bin/activate、pip install -r requirements.txt、cp .env.example .env,配置后用uvicorn app.main:app --reload --port 8000启动;另开终端执行cd frontend、npm install、npm run dev,再访问本机 5173 端口。