EvalHarness — 插件化评测框架完全指南

万物皆插件的 LLM/Agent 评测框架。28 个内置 benchmark与 evalscope 同题对齐验证 Qwen3-8B 23/28 达标DeepSeek-V4-Flash 全量 20+/25 达标)。


〇、从零到跑完 28 个 benchQuick 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三种方式

方式 ACLI 一条命令

evalharness eval run gsm8k --model openai/http://localhost:8000/v1?qwen3-8b \
    --limit 200 --resume

方式 BPython 三行

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 的 benchhle / 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 代码执行类 benchhumaneval / 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 类 benchbfcl_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 长上下文 benchlb2 / mrcr128k 截断)

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
      → 每端点一个 AdaptiveGateAIMD
        /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_mockBFCL 官方 ast_checker 判定)、tau2_officialtau2 官方引擎)。

1.9 渲染插件 —— "报告怎么展示"

@register_renderer('my_style')
def my_style(reports: List[EvalReport]) -> str:
    return '...'   # 任意格式的字符串

内建:text(控制台表格)、md/md_compare(单/多模型 Markdown 对照)、 excel4-sheet 仪表盘)、radarerrors(失败样本下钻)。

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 进 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-8B23/28 同题达标
  • DeepSeek-V4-Flash20+/25 达标mmlu_pro diff 0.0000
  • es 侧无效分imo 0.0judge 白跑、bigcodebench 0.9956(执行器空跑)
  • 已定性残差dropes 多金标、gpqa排列敏感es-dump 口径 0.046
Description
No description provided
Readme 2.7 MiB
Languages
Python 99.2%
Shell 0.8%