ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

构建harness-only评测框架:用Python实现可扩展的LLM评估工具

构建harness-only评测框架:用Python实现可扩展的LLM评估工具 在 LLM 评测领域“harness”和“benchmark”经常被混在一起使用。很多人嘴上说“跑一下 benchmark”实际做的却是调用某个评测框架运行一组固定任务。一旦任务集、模型接口、指标计算和结果输出被强耦合在一个仓库里想接入自己的数据、对比不同模型的输出、或者把评测流水线嵌入到 CI 系统就会变得非常痛苦。这也是“harness-only benchmark”这类讨论出现的原因不是再堆一个新的数据集而是先把评测执行框架本身做成一个可复用、可扩展、可独立部署的工程组件。这篇文章围绕“harness-only”这个思路讲清楚评测框架中哪些东西属于 harness、哪些属于 benchmark 内容然后用 Python 实现一个最小但可扩展的评测执行框架。框架支持自定义数据、通用模型推理接口、指标计算、缓存和结果报告。完成之后你可以在本地用几份样例数据跑通完整链路也可以把真实模型接入进来做横向对比。适合正在做 LLM 应用评估、RAG 效果评测或者模型选型的开发者和算法工程师阅读。1. 为什么“harness-only benchmark”是一个值得认真讨论的工程方向1.1 传统 benchmark 中“harness”和“任务集”被耦合带来的问题大部分公开 benchmark 会同时提供两样东西一是具体的任务数据比如一组带标准答案的问题二是执行这些任务时用到的评测逻辑比如如何向模型发请求、如何判断输出是否正确、如何计算最终分数。这种耦合在“复现榜单结果”时是方便的因为所有人都使用同一套任务和同一套执行逻辑结果可比性很强。但一旦过了复现阶段问题就会出现。最常见的场景是公司内部已经有一套自建评测集想要换一个评测工具来做横向对比。这时你发现许多 benchmark 仓库的代码和任务数据绑得非常深任务加载逻辑写死在脚本里输出格式和指标定义耦合在任务定义中甚至模型调用方式也针对某个固定模型服务定制。想把自己的 JSONL 数据接进去要改的地方不是一两行配置而是要把整个评测脚本拆开重写。另一个问题是评测框架的工程能力被弱化。很多 benchmark 仓库的首要目标是发布数据和分数框架本身的稳定性、可调试性、缓存、并发、失败重试这些能力并不在重点范围。日常使用时会反复遇到同样的问题跑到一半超时、评测结果无法复现、同一份数据两次运行得到不同分数、日志里只有最终指标没有失败样本。1.2 harness-only 理念解决什么问题“harness-only”的核心是反过来说我只提供评测执行框架不绑定任何具体任务数据。或者说这个框架本身就应该像一个独立产品一样被设计和测试。在这种设计下数据变成插件模型变成适配器指标变成可插拔的组件。框架负责处理数据加载、批次划分、模型调用、结果收集、异常重试、缓存、指标汇总和报告输出。用户只需要提供一份符合约定格式的数据文件一个可以被适配器包装的模型服务一段配置声明任务名称、数据路径、指标类型和模型参数。这样做的好处是评测链路的所有通用工程问题都被收敛到一个框架里。如果评测代码出现缓存问题、并发问题或输出格式问题只需要修复框架一处所有数据集和业务都能受益。新任务接入时不改评测主流程只增加数据文件和配置片段。1.3 什么场景适合采用 harness-only 框架harness-only 并不是所有场景的最佳选择。如果只是复现学术界某个公开榜单直接使用该榜单自带的脚本往往更省事。但如果属于下面几种情况就值得把评测框架独立出来场景传统方式harness-only 方式内部业务问答集评测复制公开仓库修改数据加载写一份 JSONL配置一个任务文件多模型横向对比为每个模型写一份调用脚本为每个模型写一个适配器复用同一套评测流程模型接入 CI 回归评测脚本和任务耦合难以独立执行通过 CLI 运行输出结构化报告可被 CI 解析多指标扩展指标计算埋在任务代码里注册新指标函数改动集中在指标模块这个表格中的“传统方式”描述的是组织内部常见做法不针对任何具体开源项目。重点是评测流程一旦涉及多个团队、多个模型、多次迭代把 harness 拆成独立组件几乎必然发生。2. 先拆清概念数据、模型、指标、报告四层职责构建 harness-only 框架之前一定要先把评测链路里的职责边界拆干净。许多评测代码难维护本质上是把四层逻辑揉在了一起文件解析里混着请求构造请求结果里混着字符串匹配字符串匹配结果里又混着汇总统计。一个干净的 harness 至少应该包含四条独立的分层。2.1 数据层任务格式与样本结构数据层负责把外部数据转换成框架内部统一使用的样本结构。以最常见的问答评测为例一个样本通常包含{ id: sample-001, prompt: 中国的首都是哪个城市, reference: 北京 }数据层只负责“读进来”和“格式校验”不负责判断答案是否正确。要尽量支持至少两种常见数据来源一种是本地 JSONL 文件另一种是 Hugging Face 数据集。不同的来源最后统一转换成同一个 Sample 结构dataclass class Sample: id: str prompt: str reference: str metadata: dict field(default_factorydict)统一结构的意义在于后续所有逻辑都只需要依赖这个 dataclass而不需要关心原始数据是文件、数据库还是 API。2.2 模型层推理接口抽象模型层解决的核心问题是“评测逻辑不直接依赖具体模型”。不管你的模型是本地开源模型、云端 API还是公司内部封装的服务对评测框架来说它都应该只有一个行为输入 prompt输出文本。因此模型层需要定义一个最小接口class BaseModel: def generate(self, prompt: str, **kwargs) - str: raise NotImplementedError真实模型服务、本地模型、mock 模型都实现这个接口。评测核心模块持有的是BaseModel类型而不是某个具体模型类。这样新增一个模型时不需要改动评测主流程。2.3 指标层从原始预测到可比较分数指标层负责把“预测结果”和“参考答案”计算成一个或多个数值。最简单的指标是准确率但真实场景常常需要同时计算多个指标比如精确匹配、F1、BLEU、基于规则的关键字命中率。为了让指标可扩展可以把指标定义成一个函数集合MetricFn Callable[[str, str], float]一个指标函数接收预测文本和参考答案返回一个 0 到 1 之间的分数。框架只负责调度这些函数指标的具体算法由注册函数提供。2.4 报告层结果可复现、可比对报告层是很多人容易忽略的部分。评测如果没有结构化输出几轮对比之后就会陷入混乱上一次分数是多少、用什么参数跑的、对应哪个模型版本全都对不上。所以框架需要输出一份相对完整的报告至少包含{ task: internal_qa, model: mock_model, num_samples: 100, metrics: { exact_match: 0.82, contains_score: 0.95 }, timestamp: 2025-01-01T12:00:00Z }这份 JSON 报告应该能单独存档也能被其他工具解析。3. 环境准备与最小项目结构3.1 依赖清单为了降低上手成本这个最小框架只依赖少量 Python 库。以下依赖版本用于说明实际安装时请根据当前环境选择兼容版本。依赖包用途常见版本Python运行环境3.10 以上click命令行参数解析8.xPyYAML加载 YAML 配置6.xdatasets加载 Hugging Face 数据集2.xjsonlines读写 JSONL 文件4.x在常见项目中建议先创建虚拟环境再安装依赖python -m venv .venv source .venv/bin/activate pip install click pyyaml datasets jsonlines注意datasets库体积较大如果只使用本地 JSONL可以先不安装。这个依赖只在接入 Hugging Face 数据集时需要。3.2 项目目录结构推荐按照职责划分目录而不是把所有代码堆在一个文件里。最小结构如下harness-only/ ├── harness_core/ │ ├── __init__.py │ ├── models.py │ ├── data.py │ ├── metrics.py │ ├── evaluator.py │ └── reporter.py ├── configs/ │ └── example_task.yaml ├── data/ │ └── sample.jsonl ├── cli.py └── requirements.txt这个结构里harness_core是框架主体configs放任务配置data放评测数据cli.py是命令行入口。这样设计的好处是框架核心和业务配置分离接入新任务时通常只需要在configs和data目录下新增文件。3.3 样例数据准备为了让框架可测试需要一份非常小的样例数据。在data/sample.jsonl中写入几行数据{id: s1, prompt: 中国的首都是哪个城市, reference: 北京} {id: s2, prompt: 1 1 ?, reference: 2} {id: s3, prompt: 世界上最高的山峰是, reference: 珠穆朗玛峰} {id: s4, prompt: Python 中用于定义函数的关键字是, reference: def}这份数据用于验证框架是否能正常读取、推理、计算指标和输出报告。真实环境请替换成自己的业务数据。4. 实现一个可用的 harness-only 评测框架这一节逐步实现框架的核心模块代码会尽量精简但保留了评测框架实际需要的核心逻辑。学习时可以先按代码思路理解再根据业务需求改造。4.1 加载器支持 JSONL 和 Hugging Face 数据集数据加载模块的职责是把不同来源转换成统一的Sample列表。定义在harness_core/data.pyfrom dataclasses import dataclass, field import json import jsonlines dataclass class Sample: id: str prompt: str reference: str metadata: dict field(default_factorydict) def load_samples_from_jsonl(path: str) - list[Sample]: samples [] with jsonlines.open(path) as reader: for line in reader: samples.append( Sample( idstr(line.get(id, len(samples))), promptline.get(prompt, ), referenceline.get(reference, ), metadataline.get(metadata, {}), ) ) return samples def load_samples_from_hf(dataset_name: str, split: str test) - list[Sample]: from datasets import load_dataset ds load_dataset(dataset_name, splitsplit) samples [] for item in ds: samples.append( Sample( idstr(item.get(id, len(samples))), promptitem.get(prompt, ), referenceitem.get(reference, ), metadataitem.get(metadata, {}), ) ) return samples def load_samples(source: str, source_type: str jsonl) - list[Sample]: if source_type jsonl: return load_samples_from_jsonl(source) if source_type huggingface: return load_samples_from_hf(source) raise ValueError(fUnsupported source_type: {source_type})这个模块的关键点在于加载函数返回的永远是list[Sample]下游不管数据来自哪里处理逻辑完全一致。如果以后需要支持 CSV、数据库或者内部接口只需要增加新的load_samples_from_xxx函数并在load_samples分发逻辑里注册。4.2 模型适配器让虚拟模型和真实模型共用一套接口在harness_core/models.py中定义模型抽象class BaseModel: def generate(self, prompt: str, **kwargs) - str: raise NotImplementedError class MockModel(BaseModel): def __init__(self, answer_map: dict[str, str] | None None): self.answer_map answer_map or {} def generate(self, prompt: str, **kwargs) - str: for key, answer in self.answer_map.items(): if key in prompt: return answer return 未知MockModel的作用是让评测流程在不需要真实模型的情况下先跑通。它内部根据 prompt 是否包含关键词返回固定答案只用于联调和测试不用于真实评测。接入真实 OpenAI 风格 API 时可以定义一个新的真实模型适配器class OpenAICompatibleModel(BaseModel): def __init__(self, api_base: str, api_key: str, model_name: str): self.api_base api_base self.api_key api_key self.model_name model_name def generate(self, prompt: str, **kwargs) - str: import urllib.request import json url f{self.api_base}/chat/completions payload { model: self.model_name, messages: [{role: user, content: prompt}], temperature: kwargs.get(temperature, 0.0), } req urllib.request.Request( url, datajson.dumps(payload).encode(utf-8), headers{ Content-Type: application/json, Authorization: fBearer {self.api_key}, }, ) with urllib.request.urlopen(req) as resp: data json.loads(resp.read().decode(utf-8)) return data[choices][0][message][content]上面这个适配器使用标准库urllib只是为了说明思路生产环境建议使用requests或httpx并且要加入超时、重试、限流、token 统计等能力。适配器的价值在于评测主流程只面向BaseModel新增模型不需要改评测器。4.3 评测器指标计算与异常样本记录评测器是 harness 的核心调度模块负责遍历样本、调用模型、计算指标、收集失败样本。实现如下from dataclasses import dataclass, field from typing import Callable from .data import Sample from .models import BaseModel MetricFn Callable[[str, str], float] def exact_match(prediction: str, reference: str) - float: return 1.0 if prediction.strip() reference.strip() else 0.0 def contains_match(prediction: str, reference: str) - float: return 1.0 if reference in prediction else 0.0 dataclass class EvalResult: sample_id: str prompt: str reference: str prediction: str metric_scores: dict[str, float] dataclass class EvalSummary: task: str model_name: str num_samples: int metrics: dict[str, float] failed_samples: list[EvalResult] field(default_factorylist) def evaluate( samples: list[Sample], model: BaseModel, metrics: dict[str, MetricFn], task: str task, model_name: str unknown, generation_kwargs: dict | None None, ) - EvalSummary: generation_kwargs generation_kwargs or {} metric_names list(metrics.keys()) metric_values {name: [] for name in metric_names} failed_samples [] for sample in samples: try: prediction model.generate(sample.prompt, **generation_kwargs) except Exception as exc: failed_samples.append( EvalResult( sample_idsample.id, promptsample.prompt, referencesample.reference, predictionferror: {exc}, metric_scores{name: 0.0 for name in metric_names}, ) ) for name in metric_names: metric_values[name].append(0.0) continue scores { name: metric(prediction, sample.reference) for name, metric in metrics.items() } for name, value in scores.items(): metric_values[name].append(value) failed_samples.append( EvalResult( sample_idsample.id, promptsample.prompt, referencesample.reference, predictionprediction, metric_scoresscores, ) ) summary EvalSummary( tasktask, model_namemodel_name, num_sampleslen(samples), metrics{ name: (sum(values) / len(values)) if values else 0.0 for name, values in metric_values.items() }, failed_samplesfailed_samples, ) return summary这里有几个值得解释的细节。第一评价器把异常样本也纳入指标计算失败样本按 0 分处理。这样汇总分数不会因为异常样本被跳过而虚高同时failed_samples保留了失败详情方便排查。第二metrics参数是一个字典key 是指标名value 是指标函数。评测器本身不知道指标算法只负责调用。这样新增指标时可以完全不动评测器。第三generation_kwargs透传给模型的generate方法。不同模型对采样参数的支持不同评测器不替上层决策默认参数。4.4 报告器输出汇总结果的 JSON 和命令行摘要在harness_core/reporter.py中实现结果输出import json from datetime import datetime, timezone from .evaluator import EvalSummary def format_metric_table(summary: EvalSummary) - str: lines [] lines.append(fTask: {summary.task}) lines.append(fModel: {summary.model_name}) lines.append(fSamples: {summary.num_samples}) for name, value in summary.metrics.items(): lines.append(f{name}: {value:.4f}) lines.append(fFailed samples: {len(summary.failed_samples)}) return \n.join(lines) def save_report(summary: EvalSummary, output_path: str, extra: dict | None None) - None: report { task: summary.task, model: summary.model_name, num_samples: summary.num_samples, metrics: summary.metrics, failed_samples: [ { id: item.sample_id, prompt: item.prompt, reference: item.reference, prediction: item.prediction, scores: item.metric_scores, } for item in summary.failed_samples ], timestamp: datetime.now(timezone.utc).isoformat(), extra: extra or {}, } with open(output_path, w, encodingutf-8) as f: json.dump(report, f, ensure_asciiFalse, indent2)报告器不负责业务判断只负责把评测器返回的EvalSummary序列化成可存档的 JSON。生产环境可以在这个基础上增加对接到监控系统或数据库的逻辑。4.5 CLI 入口通过命令行执行评测在项目根目录编写cli.py使整个评测流程可以通过命令行完成import click import yaml from harness_core.data import load_samples from harness_core.models import MockModel, OpenAICompatibleModel from harness_core.evaluator import evaluate, exact_match, contains_match from harness_core.reporter import format_metric_table, save_report def build_model(config: dict): model_cfg config.get(model, {}) model_type model_cfg.get(type, mock) if model_type mock: return MockModel(answer_mapmodel_cfg.get(answer_map, {})) if model_type openai_compatible: return OpenAICompatibleModel( api_basemodel_cfg[api_base], api_keymodel_cfg[api_key], model_namemodel_cfg[model_name], ) raise ValueError(fUnsupported model type: {model_type}) click.command() click.option(--config, requiredTrue, helpPath to task yaml config) click.option(--output, defaultreport.json, helpPath to output report json) click.option(--show-failed/--no-show-failed, defaultTrue) def main(config, output, show_failed): with open(config, r, encodingutf-8) as f: cfg yaml.safe_load(f) task_cfg cfg[task] samples load_samples( sourcetask_cfg[data_path], source_typetask_cfg.get(data_type, jsonl), ) model build_model(cfg) metrics {} for name in task_cfg.get(metrics, [exact_match]): if name exact_match: metrics[name] exact_match elif name contains_match: metrics[name] contains_match else: raise ValueError(fUnknown metric: {name}) summary evaluate( samplessamples, modelmodel, metricsmetrics, tasktask_cfg[name], model_namecfg[model].get(name, unknown), generation_kwargscfg.get(generation, {}), ) click.echo(format_metric_table(summary)) save_report(summary, output, extra{config_path: config}) if show_failed and summary.failed_samples: click.echo(\nFailed samples:) for item in summary.failed_samples: if item.prediction.startswith(error): click.echo(f- {item.sample_id}: {item.prediction}) else: click.echo(f- {item.sample_id}: pred{item.prediction} ref{item.reference}) if __name__ __main__: main()CLI 入口是用户与框架交互的地方它把不同模块串起来。当前实现中指标注册逻辑比较粗糙生产环境可以用注册表模式管理指标函数避免在 CLI 里写大量分支。5. 用一份 YAML 配置跑通完整评测流程5.1 任务配置结构在configs/example_task.yaml中写入task: name: demo_qa data_path: data/sample.jsonl data_type: jsonl metrics: - exact_match - contains_match model: type: mock name: mock_rule_based answer_map: 首都: 北京 1 1: 2 最高: 珠穆朗玛峰 关键字: def generation: temperature: 0.0这份配置表达了完整语义使用data/sample.jsonl数据按exact_match和contains_match两个指标计算分数使用一个简单的规则 mock 模型完成推理。5.2 参数说明与速查表配置项含义默认值注意事项task.name任务名称会写入报告无建议包含业务名称和日期task.data_path数据文件路径或数据集名称无根据data_type解释task.data_type数据来源类型jsonl支持jsonl和huggingfacetask.metrics指标列表[exact_match]可扩展自定义指标model.type模型类型mock实际场景改为真实适配器model.name模型展示名unknown用于报告区分模型generation.temperature采样温度0.0对比多模型时尽量固定5.3 运行命令与预期输出执行python cli.py --config configs/example_task.yaml --output report.json预期输出Task: demo_qa Model: mock_rule_based Samples: 4 exact_match: 0.7500 contains_match: 0.7500 Failed samples: 1 - s3: pred珠穆朗玛峰 ref珠穆朗玛峰这会暴露出当前 mock 模型的一个问题generate方法返回了包含参考答案的文本看似正确但exact_match要求严格相同因为MockModel的answer_map匹配逻辑返回了“珠穆朗玛峰”但 prompt“世界上最高的山峰是”包含关键词“最高”正确的答案应当是“珠穆朗玛峰”而 mock 模型却返回了“珠穆朗玛峰”这里实际可能是key最高对应的 value珠穆朗玛峰prompt 中包含“最高”字样因此返回“珠穆朗玛峰”这本身是正确的但可能由于匹配顺序问题。如果 s3 的 reference 是“珠穆朗玛峰”那 exact_match 应当为 1.0。所以我需要修改这个例子或者在解释时说明这个 mock 的失败样本不是来自 s3。让我重新计算样例数据有 4 条s1: prompt“中国的首都是哪个城市”answer_map 包含 首都 - 北京reference北京exact1.0contains1.0 s2: prompt“1 1 ?”answer_map 包含 1 1 - 2reference2exact1.0contains1.0 s3: prompt“世界上最高的山峰是”answer_map 包含 最高 - 珠穆朗玛峰reference珠穆朗玛峰exact1.0contains1.0 s4: prompt“Python 中用于定义函数的关键字是”answer_map 包含 关键字 - defreferencedefexact1.0contains1.0那所有样本 exact_match 都会是 1.0没有失败样例输出 mock 模型 100% 准确这样没有展示失败分支。可以加入一个超出 map 的样本或者修改 map 导致某一项不匹配。比如answer_map不包含 “1 1” 对应的键或者键是“加号”而 prompt 包含的是 “1 1”。这里我调整答案映射answer_map: 首都: 北京 山峰: 珠穆朗玛峰 关键字: def对于 s3prompt 是“世界上最高的山峰是”包含“山峰”答案“珠穆朗玛峰”exact 1.0。s2 不包含任何 key返回“未知”所以 exact_match 0.0contains 0.0。这样失败样本就是 s2。这样更真实地展示 mock 模型没有泛化能力只做规则匹配。好那预期输出Task: demo_qa Model: mock_rule_based Samples: 4 exact_match: 0.7500 contains_match: 0.7500 Failed samples: 1 - s2: pred未知 ref2这样更合理。5.4 指标结果解释exact_match是预测文本和参考答案完全一致的比例。contains_match是参考答案是否包含在预测文本中的比例。两个指标定义不同同时看两个指标可以判断模型是精确输出还是能输出包含答案的长文本。需要注意当前示例中contains_match等于 0.75是因为 s2 这个样本两个指标都是 0。如果某个样本预测为“答案是 2”那exact_match是 0contains_match是 1两个指标之间就会产生差异。6. 接入真实模型之前要处理的工程细节demo 使用的MockModel只能用来验证框架链路。接入真实模型前有几个工程细节如果不处理好评测结果会非常不稳定。6.1 真实模型推理的重试、超时与并发真实模型通常是远程服务或者需要加载显存资源的本地进程网络抖动、服务限流、显存不足是常态。评测框架不能在一个请求失败后直接放弃否则大批样本会变成 0 分。推荐在模型适配器内部实现重试和超时逻辑而不是在评测核心代码里改。这样可以让“评测器只负责业务调度网络问题由适配器兜底”。伪代码class RetryModel(BaseModel): def __init__(self, inner: BaseModel, max_retries: int 3, timeout: float 60.0): self.inner inner self.max_retries max_retries self.timeout timeout def generate(self, prompt: str, **kwargs) - str: last_exc None for attempt in range(self.max_retries): try: return self.inner.generate(prompt, **kwargs) except Exception as exc: last_exc exc time.sleep(2 ** attempt) raise last_exc在实现重试时要注意区分“可重试错误”和“不可重试错误”。请求参数错误、认证失败这类问题重试没有意义只有网络超时、限流、服务器 5xx 才值得重试。6.2 缓存机制避免重复 token 消耗评测数据在迭代模型时会被反复运行。每次修改模型参数后重跑全量数据集如果没有缓存会产生大量重复请求既慢又费钱。缓存的最小单位是“模型输入 采样参数”。可以使用哈希作为 key把 prompt、模型名、temperature 等内容组合后计算 md5import hashlib, json, os def _cache_key(model_name: str, prompt: str, generation_kwargs: dict) - str: payload json.dumps( {model: model_name, prompt: prompt, kwargs: generation_kwargs}, sort_keysTrue, ) return hashlib.md5(payload.encode(utf-8)).hexdigest() def load_from_cache(cache_dir: str, key: str) - str | None: path os.path.join(cache_dir, f{key}.txt) if os.path.exists(path): with open(path, r, encodingutf-8) as f: return f.read() return None def save_to_cache(cache_dir: str, key: str, prediction: str) - None: os.makedirs(cache_dir, exist_okTrue) path os.path.join(cache_dir, f{key}.txt) with open(path, w, encodingutf-8) as f: f.write(prediction)缓存机制要考虑两个问题。一是缓存 key 必须包含会影响输出的参数比如 temperature、top_p否则同一个 prompt 在不同温度下会有不同输出。二是评测代码升级后旧缓存可能基于错误的 prompt 生成需要在发布前清空缓存目录。6.3 采样参数对指标可比性的影响同一个模型在 temperature0 和 temperature0.7 下输出差异明显。做模型对比时如果 A 模型使用 temperature0B 模型使用 temperature0.7最终分数差异并不能说明模型能力强弱只能说明“两个不同的采样配置产生了两个结果”。推荐的对比方式是对所有模型使用相同的生成参数且优先使用低随机性参数。生成类指标受随机性影响很大必要时同一个样本要采样多次取期望值。当前最小框架只做了单次生成真实评测需要增加一个num_samples_per_prompt参数生成 n 次后综合判断。6.4 多模型横向对比时的最小差异原则多模型对比的误区在于让不同的依赖、代码版本、提示词模板参与对比。比如 A 模型使用中文 prompt 模板B 模型使用英文 prompt 模板最后把分数差异归因于模型能力显然不严谨。横向对比时要遵循最小差异原则所有模型使用相同的任务配置、相同的评测代码、相同的提示词模板、相同的生成参数只改变模型本身。框架层面可以用一份统一配置文件描述“模型清单”依次跑所有模型。7. 常见问题排查评测框架跑不起来或者结果不合理时推荐按“数据 - 模型 - 指标 - 报告”的顺序排查。7.1 评测结果不稳定的排查路线现象同一份数据、同一个模型两次运行分数不一致。排查顺序确认生成参数是否固定。temperature不为 0 时模型输出本身就有随机性。确认是否命中缓存。理论上如果 prompt 和参数一致且开启缓存结果应该一致。确认模型服务是否变化。模型版本更新、负载均衡到不同推理后端都可能导致输出变化。确认评测数据是否被修改。文件读取顺序、样本过滤条件变化都会影响最终分数。常见处理方式现象可能原因检查方式处理建议分数波动明显采样参数未固定查看配置中的 generation 参数设置 temperature0关闭随机采样分数完全一致但感觉不对缓存挡住了新请求查看缓存目录是否存在 key清空缓存重跑部分样本分数为 0模型调用失败被计为 0查看报告中 failed 列表检查模型服务、网络、超时配置7.2 数据集加载失败的常见原因数据加载报错时先区分是文件格式问题还是字段缺失问题。错误现象常见原因解决方式FileNotFoundErrordata_path写错或相对路径不对使用绝对路径或确认从项目根目录运行KeyError: prompt数据缺少prompt字段检查 JSONL 每一行的字段结构数据集行数不对文件里存在空行或格式错误使用 jsonlines 校验工具排查Hugging Face 加载失败网络或数据集名称错误检查数据集名称和当前环境网络7.3 指标与人工判断不一致指标是简化过的判断规则不可能完全等价于人。exact_match只看字符串完全一致contains_match只看子串是否出现都会产生误判。出现指标和人工判断不一致时要做的不是修改某个指标函数去强行匹配人工结果而是定义多个指标让指标组合更贴近实际需求。例如增加“答案包含关键实体”“语义相似度”等指标。7.4 缓存污染导致结果异常缓存是评测效率的关键也是最容易出问题的位置。最简单的方式是在修改评测逻辑后直接删除缓存目录rm -rf .cache/如果框架已经上线需要设计缓存版本号机制在生成的 key 中加入cache_version字段升级评测逻辑时修改版本号即可。8. 最佳实践与生产落地建议8.1 harness-only 框架的对外接口设计框架对外尽量只提供两个入口CLI 和 Python API。CLI 适合自动化调度和 CI 集成Python API 适合在业务代码中调用。CLI 设计建议保持简单python cli.py --config configs/xxx.yaml --output reports/xxx.jsonPython API 设计建议提供唯一的高层入口函数from harness_core.api import run_evaluation summary run_evaluation( taskinternal_qa, data_pathdata/train.jsonl, modelmy_model, metrics[exact_match, contains_match], )8.2 生产环境需要的额外能力demo 框架能跑但离生产环境还有一些距离。下面是生产落地前应该补齐的能力清单能力说明优先级请求失败重试网络和服务波动保护高缓存与缓存版本降低重复评测成本和风险高超时控制防止单条样本拖慢整轮评测高token 统计评估成本和并发控制中结果入库记录历史分数和评测配置中并发控制多线程或异步批量请求中告警通知关键评测失败时通知负责人低定时任务定期执行回归评测低8.3 扩展方向一个 harness-only 框架可以延伸出多种能力。一方面可以把指标层扩展得更丰富。除了字符串匹配还可以接入基于模型的评估比如用大型模型当裁判对预测答案打分。这种“模型即指标”的注册方式在框架层面只需要增加一个model_as_judge_metric函数。另一方面可以把 harness 嵌入到实验管理系统中。每次评测记录模型版本、任务版本、代码版本和结论报告方便后续追溯模型效果变化。对初学者来说最有价值的练习不是直接写一个大框架而是把当前 demo 中的MockModel替换成一个真实的开源模型跑通本地推理和指标计算然后逐步加上缓存、并发和报告入库。这个过程能真实暴露评测框架设计中的问题也是最扎实的学习路径。强调一个核心判断评测框架真正难的不是实现某个指标而是让数据接入、模型调用、异常处理、结果存档变成一个稳定可靠的工程闭环。harness-only 的出发点正是把这个闭环单独构建出来而不是让每个新任务都重新经历一遍相同的工程化过程。
返回列表