EvalHarness/README.md

504 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# EvalHarness — 插件化评测框架完全指南
> 万物皆插件的 LLM/Agent 评测框架。28 个内置 benchmark与 evalscope 同题对齐验证
> Qwen3-8B 23/28 达标DeepSeek-V4-Flash 全量 20+/25 达标)。
---
# 〇、从零到跑完 28 个 benchQuick 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三种方式
**方式 ACLI 一条命令**
```bash
evalharness eval run gsm8k --model openai/http://localhost:8000/v1?qwen3-8b \
--limit 200 --resume
```
**方式 BPython 三行**
```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 的 benchhle / 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 代码执行类 benchhumaneval / 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 类 benchbfcl_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 长上下文 benchlb2 / mrcr128k 截断)
```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
→ 每端点一个 AdaptiveGateAIMD
/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 进 ckptkey 含 prompt 语义)
evaluate(samples, preds, recipe)
│ extractor 级联 → scorer → aggregator
│ execution 类exec_workers 线程并行 docker/subprocess
EvalReportraw_prediction 永不丢 → 换 recipe 重判不重跑)
viz rendertext/md_compare/excel/radar/errors
```
# 四、对齐战绩与残差定性
- **Qwen3-8B**23/28 同题达标
- **DeepSeek-V4-Flash**20+/25 达标mmlu_pro diff 0.0000
- es 侧无效分imo 0.0judge 白跑)、bigcodebench 0.9956执行器空跑
- 已定性残差dropes 多金标)、gpqa排列敏感es-dump 口径 0.046 ✅)