# EvalHarness — 插件化评测框架完全指南 > 万物皆插件的 LLM/Agent 评测框架。28 个内置 benchmark,与 evalscope 同题对齐验证 > (Qwen3-8B 23/28 达标;DeepSeek-V4-Flash 全量 20+/25 达标)。 --- # 〇、从零到跑完 28 个 bench(Quick Start) ## 0.1 安装 ```bash git clone 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 ✅)