Co-Authored-By: Claude <noreply@anthropic.com>
EvalHarness — 插件化评测框架完全指南
万物皆插件的 LLM/Agent 评测框架。28 个内置 benchmark,与 evalscope 同题对齐验证 (Qwen3-8B 23/28 达标;DeepSeek-V4-Flash 全量 20+/25 达标)。
〇、从零到跑完 28 个 bench(Quick Start)
0.1 安装
git clone <repo> EvalHarness
cd EvalHarness
pip install -e . # editable 安装:改源码立即生效
# 可选重依赖(只有 BFCL 官方判定器需要):
pip install '.[bfcl]'
安装后命令行直接可用(无需 sys.path hack):
evalharness --help
0.2 看看有什么
evalharness data list # 28 个数据集插件(零网络)
evalharness eval list # 28 个判分 recipe
0.3 拉数据(惰性,也可以跳过让跑批时自动拉)
evalharness data fetch gsm8k mmlu arc --workers 8 # 常用 bench 预拉
evalharness data fetch bbh --subset word_sorting # 单个子集
evalharness data stats cmmlu # 条数/长度/答案分布
evalharness data show gsm8k -n 2 # 看前 2 条样本长什么样
evalharness data unload gsm8k # 删缓存
0.4 跑一个 bench(三种方式)
方式 A:CLI 一条命令
evalharness eval run gsm8k --model openai/http://localhost:8000/v1?qwen3-8b \
--limit 200 --resume
方式 B:Python 三行
from evalharness import get_dataset
from evalharness.model import run_eval
import asyncio
rep = asyncio.run(run_eval(
get_dataset('gsm8k'),
'openai/http://localhost:8000/v1?qwen3-8b', # 单端点
limit=200,
))
print(rep.metrics) # {'acc': 0.95, ...}
方式 C:多端点池 + 生成参数 profile
rep = asyncio.run(run_eval(
get_dataset('mmlu'),
'openai-pool/http://gpu1:{8123..8130}/v1,gpu2:{8200..8203}/v1?qwen3-8b!nothink',
gen_profile='qwen3-es-parity', # 命名参数集(温度/max_tokens per bench)
limit_per_task=10, # 每科目 10 条(evalscope --limit 语义)
))
0.5 需要 judge 的 bench(hle / simple_qa / imo)
evalharness eval run hle --model openai/...?qwen3-8b \
--judge openai/https://api.example.com/v1?deepseek-v4-flash \
--limit-per-task 25
0.6 代码执行类 bench(humaneval / bigcodebench / live_code_bench)
自动走 docker 沙箱(需要本机 docker):
evalharness eval run humaneval --model openai/...?qwen3-8b
# bigcodebench 需要官方镜像:
docker build -f docker/Dockerfile.bigcodebench -t bigcodebench-sandbox:latest .
evalharness eval run bigcodebench --model openai/...?qwen3-8b
# swe 需要 per-instance sweb.eval.* 镜像:
evalharness sandbox prefetch swe_bench_verified --limit 20
evalharness eval run swe_bench_verified --model openai/...?qwen3-8b --limit 20
0.7 Agent 类 bench(bfcl_v3 / general_fc / tau2_bench)
evalharness eval run bfcl_v3 --model openai/...?qwen3-8b --env bfcl_mock
# tau2 需要官方数据 + TAU2_DATA_DIR 环境变量:
TAU2_DATA_DIR=/path/to/tau2-bench/data \
evalharness eval run tau2_bench --model openai/...?qwen3-8b
0.8 长上下文 bench(lb2 / mrcr,128k 截断)
rep = asyncio.run(run_eval(
get_dataset('longbench_v2', subset='medium'),
'openai/http://bigctx:30000/v1?model', # 需要 262k ctx 端点
gen_kwargs={'max_input_tokens': 128000}, # 128k 中截(同 evalscope)
))
0.9 多轮采样(temp=1 × N 次取均值,aime/hmmt 系列)
runs = []
for i in range(12):
rep = asyncio.run(run_eval(get_dataset('aime25'), MODEL,
gen_kwargs={'temperature': 1.0}))
runs.append(rep.metrics['acc'])
print(f'mean: {sum(runs)/len(runs):.4f}')
0.10 查看结果
evalharness viz show gsm8k.report.json # 控制台表格
evalharness viz show r1.json r2.json --style md_compare # 多模型对照
evalharness viz show report.json --style excel # 4-sheet Excel 仪表盘
0.11 跑全部 28 个(编排脚本模板)
"""full_28.py — 用跑批脚本编排全部 bench"""
import asyncio, json, os
from evalharness import get_dataset
from evalharness.model import run_eval
MODEL = 'openai-pool/http://gpu1:{8123..8130}/v1?qwen3-8b!nothink'
JUDGE = 'openai/https://judge-api.example.com/v1?judge-model'
OUT = 'results'
os.makedirs(OUT, exist_ok=True)
BENCHES = [
# (name, dataset, kwargs)
('wino', 'winogrande', dict(limit=1267)),
('arc', 'arc', dict()),
('gsm8k', 'gsm8k', dict(limit=1319)),
('hswag', 'hellaswag', dict(limit=10042)),
('cmmlu', 'cmmlu', dict(subset='all')),
('mmlu', 'mmlu', dict()),
('mmlu_pro', 'mmlu_pro', dict()),
('trivia', 'trivia_qa', dict()),
('drop', 'drop', dict()),
('math', 'competition_math', dict(subset='all')),
('humaneval', 'humaneval', dict()),
('bcb', 'bigcodebench', dict()),
('lcb', 'live_code_bench', dict(subset='release_latest')),
('bfcl', 'bfcl_v3', dict(env='bfcl_mock')),
('gfc', 'general_fc', dict()),
# judge 类
('sqa', 'simple_qa', dict(judge_spec=JUDGE)),
('hle', 'hle', dict(judge_spec=JUDGE)),
('imo', 'imo_answerbench', dict(judge_spec=JUDGE)),
# 长上下文
('lb2', 'longbench_v2', dict(subset='short', gen_kwargs={'max_input_tokens': 128000})),
('mrcr', 'openai_mrcr', dict(gen_kwargs={'max_input_tokens': 128000})),
# agent
('tau2', 'tau2_bench', dict()),
('swe', 'swe_bench_verified', dict(limit=70)),
]
async def run_one(tag, name, kw):
out = f'{OUT}/{tag}.json'
if os.path.exists(out):
print(f'skip {tag}'); return
subset = kw.pop('subset', None)
ds = get_dataset(name, subset=subset) if subset else get_dataset(name)
rep = await run_eval(ds, MODEL, checkpoint=True, **kw)
json.dump({'n': rep.num_samples, 'metrics': rep.metrics}, open(out, 'w'), default=str)
print(f'## {tag}: {rep.metrics}', flush=True)
async def main():
for tag, name, kw in BENCHES:
await run_one(tag, name, kw)
# bbh: 27 子集循环 + 聚合
BBH = ['boolean_expressions', 'causal_judgement', ...] # 27 个
vals = []
for sub in BBH:
await run_one(f'bbh_{sub}', 'bbh', dict(subset=sub, limit_per_task=10))
vals.append(json.load(open(f'{OUT}/bbh_{sub}.json'))['metrics']['acc'])
json.dump({'acc': sum(vals)/len(vals)}, open(f'{OUT}/bbh.json', 'w'))
# aime × 3 + hmmt: t1 × 12 轮均值
for b in ['aime24', 'aime25', 'aime26', 'hmmt26']:
runs = []
for i in range(12):
rep = await run_eval(get_dataset(b), MODEL,
gen_kwargs={'temperature': 1.0, 'max_tokens': 32768})
runs.append(rep.metrics['acc'])
json.dump({'runs': runs}, open(f'{OUT}/{b}.partial.json', 'w')) # 断点
json.dump({'mean': sum(runs)/len(runs)}, open(f'{OUT}/{b}.json', 'w'))
asyncio.run(main())
# 后台跑 + 崩溃自动续(ckpt 断点):
setsid python -u full_28.py > full_28.log 2>&1 < /dev/null &
tail -f full_28.log
一、每个插件怎么写、怎么用(每类一个完整 case)
1.1 数据集插件 —— "这个 benchmark 的题目长什么样"
写(data/datasets/mybench.py,放进去就被自动发现,无需改任何中央文件):
from ..sample import Sample
from ..registry import register_dataset
from ..spec import DatasetSpec
@register_dataset(DatasetSpec(
name='mybench',
source='org/mybench', # HF id / ModelScope id / 本地路径
split='test',
task_type='mcq', # 决定判分 recipe 的大类路由
prompt_style='cot_letter', # 引用哪个 prompt 渲染插件(见 1.2)
few_shot_split='dev', # 范例从哪个 split 取
few_shot_num=5,
gen_config={'temperature': 0.0, 'max_tokens': 4096}, # 生成默认参数
))
def mybench():
# 写法 A:字段名刚好对得上 → 一行声明式
return FieldSpec(input='question', choices='options', target='answer_key')
# 写法 B:需要清洗/重排/增强 → 返回转换函数
# def to_sample(record):
# return Sample(input=record['q'], choices=record['opts'],
# target='ABCD'[record['label']], metadata={'subject': record['sub']})
# return to_sample
用:
from evalharness import get_dataset
ds = get_dataset('mybench') # 惰性:零网络
len(ds) # 首次使用才下载→转换→缓存
for s in ds: print(s.input, s.target)
ds2 = get_dataset('mybench', subset='hard') # spec 覆盖 → 独立缓存条目
缓存规则:subset/split/source/params 全部参与 hash —— 改任何一项自动新缓存目录, 永远不用写缓存失效逻辑。
1.2 Prompt 渲染插件 —— "题目怎么渲染给模型"
为什么独立成层而不塞进数据插件? 渲染是生成层的关注点:同一个数据集可能被 不同协议渲染(zero-shot / CoT / 官方 few-shot),而数据插件只该回答"题目是什么"。 但注册表是全局的 —— renderer 完全可以写在数据插件同一个文件里。
写(任意文件,包括数据插件同文件):
from evalharness.model.prompt_renderers import register_prompt_renderer
@register_prompt_renderer('mybench_cot') # ← DatasetSpec.prompt_style 填这个名字
def mybench_cot(question, sample, spec, prompt_style):
# 输入:裸题面 + Sample + DatasetSpec
# 输出:{'question': 改写后的题面},可选 'system'(变成 system 消息)
if not sample.choices:
return {} # 返回空 → 走通用兜底
letters = 'ABCD'
opts = '\n'.join(f'{letters[i]}) {c}' for i, c in enumerate(sample.choices))
return {'question': f'Answer the question.\n\n{question}\n\n{opts}'}
用:DatasetSpec(prompt_style='mybench_cot') —— 之后所有 run_eval 自动走它;
没注册的 style 走通用 MCQ/QA 兜底。验证工具:golden prompt 快照 —— 渲染输出
逐字节存档,改渲染器后跑对比,保证不悄悄变。
1.3 few-shot 钩子 —— "官方手写范例"
数据插件同文件加一个约定名函数即可(注册时自动被发现):
def mybench_few_shot(split, subset, n):
return official_cot_text[subset] # 返回 None 则回退到 few_shot_split 自动取
1.4 模型适配插件 —— "怎么调用模型"
@register_adapter('myproto')
class MyProto(ModelAdapter):
async def generate(self, messages, tools=None, **kw) -> ModelOutput:
# 任何协议:gRPC、私有 SDK、云 API……
return ModelOutput(text=..., tool_calls=[...], usage=Usage(...))
用:spec 字符串 'myproto://host:port?model-id'。
1.5 流量管理 —— "多端点怎么打满不打死"(内建,无需写)
spec: openai-pool/http://51.3:{30014..30014}/v1,http://51.4:{30000..30000}/v1?dp4-flash
→ 每端点一个 AdaptiveGate(AIMD):
/metrics 显示没喂饱 → 并发 +1(每 5s)
服务端排队 → 并发 -1
请求失败 → 并发 ×0.7(保命)
+ 连续失败健康冷却 60s + 端点假死探活(哨兵 docker restart)
1.6 判分三件套 —— extractor / scorer / aggregator
@register_extractor('my_answer')
def my_answer(raw, sample): # → (value, ok, note)
m = re.search(r'MY ANSWER: (.+)', raw)
return (m.group(1), True, 'regex') if m else ('', False, 'no match')
@register_scorer('my_metric')
def my_metric(pred, target, sample, ctx): # → ({metric: 分数}, {metric: 详情})
return ({'acc': float(pred == target)}, {'acc': {'pred': pred}})
@register_aggregator('my_group')
def my_group(results, metric):
... # → float 或 {子组名: 分数}
# recipe = 三件套的声明式组合(每 bench 5-20 行)
@register_eval('mybench')
def mybench():
return EvalRecipe(
name='mybench',
extract=['my_answer', 'answer_phrase'], # 级联:首个成功者胜
scorers={'acc': 'my_metric'},
aggregators={'acc': 'my_group'},
exec_workers=8, # execution 类并行判分
)
1.7 沙箱插件 —— "在哪儿跑模型生成的代码"
@register_sandbox('myvm')
class MyVM:
def exec(self, files: Dict[str, str], entry: str,
timeout_s: int, image: str) -> ExecResult:
# files: {filename: content} 写入容器
# entry: 容器里跑的入口文件
# 返回 ExecResult(exit_code, stdout, stderr, timed_out, duration_s)
...
内建两个:
docker:硬隔离(--network none+ cpu/mem/pids 上限 + tmpfs /tmp),支持任意镜像local:子进程直跑(开发调试用,无隔离)
1.8 Agent 环境插件 —— "多轮工具调用的世界"
两种模式:
@register_env('my_sim')
class MySim(Environment):
# 模式 A:消息泵(框架驱动循环)
def reset(self, sample) -> List[ChatMessage]:
return [] # 初始观察
async def step(self, tool_calls, text, sample) -> List[ChatMessage]:
# 执行模型的 tool_calls,返回观察消息
return [ChatMessage(role='tool', content=json.dumps(result))]
def final_state(self) -> dict:
return {'calls': self.calls} # 传给 env_reward scorer
# 模式 B:自跑旁路(官方引擎 bundle)
async def run_task(self, adapter, sample, **kw) -> Optional[dict]:
# 整个模拟在引擎内部完成,返回 prediction dict
# 返回 None 则回退到模式 A 的消息泵
内建:bfcl_mock(BFCL 官方 ast_checker 判定)、tau2_official(tau2 官方引擎)。
1.9 渲染插件 —— "报告怎么展示"
@register_renderer('my_style')
def my_style(reports: List[EvalReport]) -> str:
return '...' # 任意格式的字符串
内建:text(控制台表格)、md/md_compare(单/多模型 Markdown 对照)、
excel(4-sheet 仪表盘)、radar、errors(失败样本下钻)。
1.10 生成参数 Profile —— "不同模型不同参数"
# gen_profiles.yaml(当前目录或 ~/.config/evalharness/)
my-protocol:
default:
temperature: 0.0
max_tokens: 32768
simple_qa: # 单 bench 覆盖
max_tokens: 512
evalharness eval run hle --model ... --profile my-protocol
优先级:插件默认 < profile.default < profile[bench] < 显式 kwargs。
二、还不够插件化的地方(Before → After 全对照)
P0 跑批编排层 —— 最大的硬编码
现在:所有编排逻辑住在 /tmp/opencode/dp4_run.py + 5 个 tail 脚本 + 哨兵 bash,
共 ~400 行一次性代码。
改后:
@register_protocol('dp4-full')
def dp4_full():
return Protocol(
model='openai-pool/...',
runs=[FullRun('mmlu'), MeanRun('aime24', k=12), JudgedRun('hle', judge='...')],
sentinel=True)
evalharness run --protocol dp4-full
evalharness status
P1 重判分 CLI
现在:判分出问题手写 40 行 rejudge 脚本(处理 ckpt key 三种形态)。
改后:
evalharness eval rescore hle --ckpt latest --judge pool:judge
P2-P18(详见各节)
- P2 数据源 variant + 选项排列策略(gpqa 的 es-dump 已实现)
- P3 ckpt key 统一(指纹匹配)
- P4 judge 走池容灾
- P5 沙箱 warm pool(容器复用,bcb 再快 3-5×)
- P6 截断策略插件
- P7 选样语义插件
- P8 few-shot 渲染进 renderer
- P9 运行时心跳监控
- P10 模型策略外置
- P11 协议 profile(已实现 gen_profiles)
- P12 依赖校验
- P13 judge prompt 版本化
- P14 聚合视图插件
- P15 Web/API
- P16 工具层(filter/synthesis/dedup)
- P17 Skill 层
- P18 结果对比器
三、系统运行全景
get_dataset('mmlu') ──惰性物化+flock缓存──▶ Dataset[Sample]
│
run_eval(ds, model_spec, judge_spec, gen_profile)
│ few-shot hook / 域匹配范例
│ prompt renderer 插件改写题面
│ 截断(token 中截,budget = ctx − max_tokens − 2k)
▼
PooledAdapter ──round-robin──▶ N 端点 × AdaptiveGate(AIMD)
│ 失败:换端点 × N + gate ×0.7 + 冷却
│ 断网:run_one 六次分钟级退避
│ 每条预测 append 进 ckpt(key 含 prompt 语义)
▼
evaluate(samples, preds, recipe)
│ extractor 级联 → scorer → aggregator
│ execution 类:exec_workers 线程并行 docker/subprocess
▼
EvalReport(raw_prediction 永不丢 → 换 recipe 重判不重跑)
▼
viz render(text/md_compare/excel/radar/errors)
四、对齐战绩与残差定性
- Qwen3-8B:23/28 同题达标
- DeepSeek-V4-Flash:20+/25 达标;mmlu_pro diff 0.0000
- es 侧无效分:imo 0.0(judge 白跑)、bigcodebench 0.9956(执行器空跑)
- 已定性残差:drop(es 多金标)、gpqa(排列敏感,es-dump 口径 0.046 ✅)