别再拿十道题测模型了:一个能真正比较三家 API 的 200 题评测脚手架

模型选型最常见的“伪严谨”,是准备十几道题,然后把 GPT、Claude、Gemini 各问一遍。

最后得到一句:

A 更聪明,B 更稳,C 更便宜。

这种测试对聊天体验有点用,对企业选型几乎不够。

因为真实系统真正需要比较的是:

任务成功率
结构化输出成功率
失败类型
延迟
输入/输出 Token
重试
人工修改量
单成功任务成本

今天不讲理论,直接做一个轻量脚手架。

目标不是造一个“通用 Benchmark 平台”,而是让你两三个小时内能把公司自己的 200 条任务跑起来。

我建议先准备 200 条,而不是 20 条

一个比较实用的分布:

40 条 RAG / 文档问答
40 条结构化抽取
40 条 Coding / Debug
30 条 Tool Calling
20 条复杂分析
15 条长上下文
10 条拒绝 / 安全边界
5 条极端长尾

为什么是 200?

因为 20 条太容易被偶然结果影响;200 条已经足够看到明显 Slice 差异,同时成本又没有大到离谱。

更重要的是:这些题不要自己现编。

优先从:

  • 已脱敏生产问题;
  • 历史失败案例;
  • 人工客服问题;
  • 真实代码 Issue;
  • 真实合同和报表任务;
  • 用户反复追问的问题;

里面抽。

数据文件别设计得太复杂

先用 JSONL:

{"id":"rag-001","type":"rag","input":"...","must_include":["..."],"must_not_include":["..."],"max_latency_ms":12000}
{"id":"extract-001","type":"extract","input":"...","json_schema":"invoice-v1","expected":{"currency":"CNY"}}
{"id":"code-001","type":"coding","input":"修复...","repo_fixture":"repo-01","required_tests":["test_x"]}

目录:

benchmark/
├── cases.jsonl
├── adapters/
│   ├── openai_adapter.py
│   ├── anthropic_adapter.py
│   └── gemini_adapter.py
├── evaluators/
│   ├── rule_eval.py
│   ├── json_eval.py
│   └── judge_eval.py
├── results/
└── run.py

不要一上来引入数据库、消息队列和 Web UI。

先把评测跑通。

统一一个模型适配接口

from dataclasses import dataclass
from typing import Protocol, Any

@dataclass
class ModelResponse:
    text: str
    input_tokens: int
    output_tokens: int
    latency_ms: int
    raw: Any

class ModelAdapter(Protocol):
    name: str

    async def generate(
        self,
        prompt: str,
        *,
        max_output_tokens: int = 4096,
    ) -> ModelResponse:
        ...

这样以后无论接:

GPT-5.6
Claude Opus 5
Gemini 3.7 Flash
Kimi K3
DeepSeek
Qwen

评测层都不用改。

OpenAI Adapter 示例

import time
from openai import AsyncOpenAI

class OpenAIAdapter:
    def __init__(self, model: str):
        self.name = model
        self.client = AsyncOpenAI()
        self.model = model

    async def generate(
        self,
        prompt: str,
        *,
        max_output_tokens: int = 4096,
    ) -> ModelResponse:
        started = time.perf_counter()

        resp = await self.client.responses.create(
            model=self.model,
            input=prompt,
            max_output_tokens=max_output_tokens,
        )

        latency_ms = int(
            (time.perf_counter() - started) * 1000
        )

        usage = resp.usage

        return ModelResponse(
            text=resp.output_text,
            input_tokens=usage.input_tokens,
            output_tokens=usage.output_tokens,
            latency_ms=latency_ms,
            raw=resp,
        )

注意:不同 SDK 版本的 Usage 字段可能有变化,真正运行时以你安装的当前 SDK 为准。

Anthropic 和 Gemini 不要硬凑成完全一样的参数

一个常见错误是:

temperature=0.2
max_tokens=4096

然后三家模型全部用同一套参数。

这叫“参数一样”,不叫“条件公平”。

不同模型的推荐 reasoning / effort / sampling 方式不同。

更合理的是给每个模型一份 Profile:

models:
  gpt-5.6-terra:
    effort: medium
    max_output_tokens: 4096

  claude-opus-5:
    effort: high
    max_output_tokens: 4096

  gemini-3.7-flash:
    max_output_tokens: 4096

你要比较的是:

在各自合理配置下,谁更适合这个业务。

而不是强迫所有模型使用相同内部机制。

Runner:控制并发,不要把 API 打爆

import asyncio
import json
from pathlib import Path

async def run_case(
    sem,
    adapter,
    case,
):
    async with sem:
        try:
            resp = await adapter.generate(
                case["input"]
            )
            return {
                "case_id": case["id"],
                "model": adapter.name,
                "ok": True,
                "text": resp.text,
                "input_tokens": resp.input_tokens,
                "output_tokens": resp.output_tokens,
                "latency_ms": resp.latency_ms,
            }
        except Exception as e:
            return {
                "case_id": case["id"],
                "model": adapter.name,
                "ok": False,
                "error": type(e).__name__,
                "message": str(e),
            }

async def run_model(adapter, cases, concurrency=5):
    sem = asyncio.Semaphore(concurrency)
    tasks = [
        run_case(sem, adapter, case)
        for case in cases
    ]
    return await asyncio.gather(*tasks)

为什么默认并发先用 5?

不是因为 5 最科学,而是因为第一次跑评测,先把:

Rate Limit
429
超时
连接池
重试

这些问题看清楚,再逐步提高。

第一层评测一定用确定性规则

例如 must_include:

def eval_must_include(text, keywords):
    missing = [
        k for k in keywords
        if k not in text
    ]

    return {
        "passed": len(missing) == 0,
        "missing": missing,
    }

must_not_include:

def eval_must_not_include(text, keywords):
    hit = [
        k for k in keywords
        if k in text
    ]

    return {
        "passed": len(hit) == 0,
        "hit": hit,
    }

结构化任务直接解析 JSON:

import json


def eval_json(text):
    try:
        obj = json.loads(text)
        return True, obj, None
    except Exception as e:
        return False, None, str(e)

能用代码判断的,不要先交给 LLM-as-a-Judge。

数字类任务不要只做字符串比较

比如参考答案:

12.50%

模型回答:

0.125

语义一样,字符串不同。

可以给 Case 写数值规则:

{
  "field": "gross_margin",
  "expected": 0.125,
  "tolerance": 0.001
}

Evaluator:

def within_tolerance(actual, expected, tolerance):
    return abs(actual - expected) <= tolerance

RAG 任务至少拆成两层

不要只评“最后回答好不好”。

拆:

Retrieval
Answer

记录:

{
  "retrieved_doc_ids": ["doc-18", "doc-42"],
  "expected_doc_ids": ["doc-42"],
  "answer": "..."
}

否则一个模型最终答错了,你根本不知道:

检索没找到
还是
模型拿到证据后仍然答错

Tool Calling 测什么

我会记录四个字段:

tool_name
arguments
call_count
final_outcome

例如:

{
  "expected_tool": "get_order_status",
  "required_args": ["order_id"],
  "maximum_tool_calls": 3,
  "forbidden_tools": ["refund_order"]
}

这比让 Judge 看一眼最终文本靠谱得多。

Coding Case 别只看生成文本

Coding 最好在隔离环境里真正跑:

checkout fixture
→应用 patch
→install
→compile
→test
→lint
→安全检查

结果:

{
  "build": true,
  "tests_total": 28,
  "tests_passed": 28,
  "forbidden_files_changed": 0,
  "security_findings": 0
}

这就是上一篇 GH200 论文最值得借鉴的地方:Artifact 能不能跑,比模型描述自己“已经修复完成”重要。

再加一层 Judge,但只评开放式部分

Judge 适合:

  • 完整性;
  • 可读性;
  • 是否真正回应用户目标;
  • 开放式分析质量。

Judge 不适合替代:

  • 测试结果;
  • 数据库状态;
  • JSON Schema;
  • 数字;
  • Tool 是否真实成功。

一个简单 Judge 输出:

{
  "score": 4,
  "blocking_issue": false,
  "reason": "..."
}

建议同一边界样本跑两三次,尤其是用于模型迁移时。

把成本算到“成功任务”上

先算单请求:

def token_cost(
    input_tokens,
    output_tokens,
    input_price_per_m,
    output_price_per_m,
):
    return (
        input_tokens / 1_000_000
        * input_price_per_m
        + output_tokens / 1_000_000
        * output_price_per_m
    )

但最后报表不要只看平均请求成本。

看:

Cost per Successful Task

例如:

模型A:平均请求 $0.03,成功率 70%
模型B:平均请求 $0.05,成功率 96%

如果失败任务还要人工补救,A 可能反而更贵。

我会加一个“人工修改分钟数”

这个字段很土,但很有价值:

human_edit_minutes

让评测人员完成最终交付后,记录大概修改时间。

有些模型:

Judge评分 4.5

但人工要花 8 分钟重写。

另一个模型:

Judge评分 4.3

人工只改 1 分钟。

生产里我会选后者。

汇总报表不要只有一个 Winner

按 Slice 输出:

RAG
Extraction
Coding
Tool
Analysis
Long Context
Safety

例如:

Slice Model A Model B Model C
RAG Task Success 94% 91% 95%
Coding 71% 83% 76%
Tool 88% 82% 90%
JSON Valid 99% 97% 99%
P95 8.1s 13.4s 5.8s

最终架构可能不是:

Model B wins

而是:

RAG → C
Coding → B
默认 Tool → C
高风险 Review → B

这才是多模型路由的基础。

结果文件建议长这样

{
  "case_id": "tool-018",
  "slice": "tool",
  "model": "candidate-c",
  "success": true,
  "input_tokens": 18120,
  "output_tokens": 930,
  "latency_ms": 4231,
  "tool_calls": 2,
  "retry_count": 0,
  "rule_score": 1.0,
  "judge_score": 4.4,
  "human_edit_minutes": 0.5,
  "cost_usd": 0.0172
}

保留每条明细。不要只保存最终 Excel 平均数。

以后模型升级时,你会需要逐条 Diff。

最后一个建议:先跑 20 条 Debug,再跑 200 条

第一次脚手架通常会有自己的 Bug:

  • Token 字段解析错;
  • Provider 超时;
  • 某家 SDK 返回结构不同;
  • JSON 输出包了 Markdown;
  • 并发太高;
  • Case 本身答案有问题。

所以执行顺序:

20 条 Debug Set
↓
修评测框架
↓
200 条正式集
↓
查看失败
↓
再抽 20 条人工复核

不要第一次就花几百美元跑完,然后才发现统计脚本写错了。

真正可靠的模型选型,没有“神奇 benchmark”。

它其实就是一件很朴素的工程工作:拿真实任务、固定输入、记录结果、自动验证、人工抽查,然后算成功率和成本。

做到这一步,模型宣传页就只是一份候选名单,而不是采购结论。


更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界

https://www.zyentor.com/