504 lines
17 KiB
Markdown
504 lines
17 KiB
Markdown
# EvalHarness — 插件化评测框架完全指南
|
||
|
||
> 万物皆插件的 LLM/Agent 评测框架。28 个内置 benchmark,与 evalscope 同题对齐验证
|
||
> (Qwen3-8B 23/28 达标;DeepSeek-V4-Flash 全量 20+/25 达标)。
|
||
|
||
---
|
||
|
||
# 〇、从零到跑完 28 个 bench(Quick Start)
|
||
|
||
## 0.1 安装
|
||
|
||
```bash
|
||
git clone <repo> EvalHarness
|
||
cd EvalHarness
|
||
pip install -e . # editable 安装:改源码立即生效
|
||
# 可选重依赖(只有 BFCL 官方判定器需要):
|
||
pip install '.[bfcl]'
|
||
```
|
||
|
||
安装后命令行直接可用(无需 sys.path hack):
|
||
|
||
```bash
|
||
evalharness --help
|
||
```
|
||
|
||
## 0.2 看看有什么
|
||
|
||
```bash
|
||
evalharness data list # 28 个数据集插件(零网络)
|
||
evalharness eval list # 28 个判分 recipe
|
||
```
|
||
|
||
## 0.3 拉数据(惰性,也可以跳过让跑批时自动拉)
|
||
|
||
```bash
|
||
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 一条命令**
|
||
```bash
|
||
evalharness eval run gsm8k --model openai/http://localhost:8000/v1?qwen3-8b \
|
||
--limit 200 --resume
|
||
```
|
||
|
||
**方式 B:Python 三行**
|
||
```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**
|
||
```python
|
||
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)
|
||
|
||
```bash
|
||
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):
|
||
```bash
|
||
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)
|
||
|
||
```bash
|
||
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 截断)
|
||
|
||
```python
|
||
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 系列)
|
||
|
||
```python
|
||
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 查看结果
|
||
|
||
```bash
|
||
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 个(编排脚本模板)
|
||
|
||
```python
|
||
"""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())
|
||
```
|
||
|
||
```bash
|
||
# 后台跑 + 崩溃自动续(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`,放进去就被自动发现,无需改任何中央文件):
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
**用**:
|
||
|
||
```python
|
||
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 完全可以写在数据插件同一个文件里。
|
||
|
||
**写**(任意文件,包括数据插件同文件):
|
||
|
||
```python
|
||
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 钩子 —— "官方手写范例"
|
||
|
||
数据插件同文件加一个约定名函数即可(注册时自动被发现):
|
||
|
||
```python
|
||
def mybench_few_shot(split, subset, n):
|
||
return official_cot_text[subset] # 返回 None 则回退到 few_shot_split 自动取
|
||
```
|
||
|
||
## 1.4 模型适配插件 —— "怎么调用模型"
|
||
|
||
```python
|
||
@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
|
||
|
||
```python
|
||
@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 沙箱插件 —— "在哪儿跑模型生成的代码"
|
||
|
||
```python
|
||
@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 环境插件 —— "多轮工具调用的世界"
|
||
|
||
两种模式:
|
||
|
||
```python
|
||
@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 渲染插件 —— "报告怎么展示"
|
||
|
||
```python
|
||
@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 —— "不同模型不同参数"
|
||
|
||
```yaml
|
||
# gen_profiles.yaml(当前目录或 ~/.config/evalharness/)
|
||
my-protocol:
|
||
default:
|
||
temperature: 0.0
|
||
max_tokens: 32768
|
||
simple_qa: # 单 bench 覆盖
|
||
max_tokens: 512
|
||
```
|
||
|
||
```bash
|
||
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 行一次性代码。
|
||
|
||
**改后**:
|
||
```python
|
||
@register_protocol('dp4-full')
|
||
def dp4_full():
|
||
return Protocol(
|
||
model='openai-pool/...',
|
||
runs=[FullRun('mmlu'), MeanRun('aime24', k=12), JudgedRun('hle', judge='...')],
|
||
sentinel=True)
|
||
```
|
||
```bash
|
||
evalharness run --protocol dp4-full
|
||
evalharness status
|
||
```
|
||
|
||
## P1 重判分 CLI
|
||
|
||
**现在**:判分出问题手写 40 行 rejudge 脚本(处理 ckpt key 三种形态)。
|
||
|
||
**改后**:
|
||
```bash
|
||
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 ✅)
|