AI工程师新核心技能:从零搭建可运行的Eval Harness

AI工程师新核心技能:从零搭建可运行的Eval Harness 近两年AI 工程师这个岗位的技能清单发生了一个明显变化过去大家比的是谁能更快调通模型、写出更长的 prompt现在讨论重点变成了“你怎么证明这个系统在真实输入下是可靠的”。在关于 AI 工程师能力结构的讨论里Elvis Saravia 的一个判断经常被引用evals评估仍然是 AI 工程师的第一位技能而 harness engineering评测台架工程常被直译为“测试鞍具工程”正快速成为仅次于 evals 的重要能力。很多团队现在已经能调用模型也积累了一批 prompt 和原始用例但真正缺的是一套能把评估跑成自动化流程的工程体系。这篇文章就从 harness engineering 的含义出发用一个最小可运行的 eval harness 示例讲清它解决什么问题、由哪几个部分组成、和普通测试脚本有什么区别以及 AI 工程师该按什么顺序建立这项能力。1. 先分清 evals 与 harness engineering评测定义和评测执行是两件事1.1 evals 解决“测什么”harness 解决“怎么稳定地测”evals 在 LLM 应用里的含义并不神秘。它指的是先定义一次评测要验证什么行为再准备一批输入样例和预期标准最后通过某种判定方式得出“好还是不好”的结论。比如“客服机器人在用户问订单状态时必须正确抽取订单号”“模型回答不能编造不存在的物流信息”这些都是 eval 的定义工作。问题在于有了定义不代表团队能持续执行。早期的 AI 项目里评估往往以个人脚本形式存在测试数据散落在 Markdown 文档或聊天记录里模型返回结果靠人肉眼判断某个 prompt 改动之后需要手动把几十条用例重新跑一遍。这种状态能给出“这次改得好不好”的模糊印象却无法回答三个工程问题上一次通过率是多少这次哪些用例从通过变成失败失败原因是模型问题、prompt 问题还是测试用例本身不准确harness engineering 解决的就是后半段。它把 evals 定义的指标、用例和判定逻辑组织成一份可运行、可重复、可版本管理的工程代码。说得直白一点evals 是考试大纲harness 是标准化考场。没有考场大纲只能停留在文档里没法规模化执行。1.2 评测脚本和 eval harness 的差别“我写了个脚本调模型算了一下正确率”和“我搭了一套 eval harness”之间差别不在于是否调用了模型而在于工程属性是否完整。可以用一张表来做基础对比维度一次性评测脚本可持续的 eval harness用例变化方式改 Python 代码改数据文件代码不动模型或 prompt 更新后手动重跑人工看结果一条命令回归全部用例判定逻辑在循环里随手写独立成评分器可复用结果记录终端 print跑完即丢结构化报告保留原始输出团队协作写脚本的人自己维护共享数据、配置和报告格式生产接入几乎不可能接 CI可以接入代码提交和定时任务单从代码量看eval harness 并不比评测脚本复杂多少。真正重要的是它把“数据、执行、判定、报告”拆开了。AI 工程师维护的是一个可演化系统而不是一坨只有自己能跑的临时脚本。1.3 为什么 LLM 应用里harness 比普通单元测试更关键传统后端里单元测试之所以有效是因为一个函数给定输入通常能产生确定输出。LLM 应用恰恰相反同一段 prompt、同一个模型在 temperature 不为 0 时可能给出不同答案prompt 模板里一个标点变化可能影响几十条用例上游模型版本升级也可能让原本通过的规则集体失效。不确定性让“单次人工验证”几乎没有意义。harness 的意义不是消除不确定性而是把不确定性变成可观测、可拦截的现象。通过固定 seed、temperature 等参数再配合用例级别的通过/失败记录团队才能在模型行为变化时拿到第一手证据而不是靠“感觉好像变差了”。2. 一套 eval harness 的标准骨架测试集、执行器、评分器与报告2.1 四个核心组件和各自职责一个能长期使用的 eval harness 至少包含四个部分。每个部分职责单一替换起来才容易。组件职责典型内容最容易犯的错测试集描述“用什么输入、期望什么行为”输入文本、结构化期望字段、禁用词、场景标签把测试集和 prompt 示例混用执行器负责把输入交给模型并取回输出模型 SDK 调用、超时、重试、解析输出只考虑成功路径不做异常捕获评分器判定“这条输出是否通过”规则断言、相似度计算、LLM-as-judge评分标准分散在 prompt 和代码多处报告器输出结果并保留现场通过率、失败清单、原始输出、配置快照只记录分数不保留可追溯信息这里最容易忽略的是执行器和评分器的边界。执行器只负责“调用模型、拿到返回文本”评分器只负责“判断文本是否满足预期”。如果把模型调用逻辑和判定逻辑写在同一个函数里后续换成另一个模型或换一种判定方式时整个脚本都要重写。2.2 用一张目录结构图理解 harness 怎么放进项目在一个实际 AI 应用仓库里eval harness 通常不放在业务代码目录中而是独立成 evaluation 或 eval_harness 目录。这样可以避免评测代码被打进生产包也方便数据集单独做权限管理。your-llm-app/ ├── app/ # 业务代码 │ ├── prompts/ │ └── services/ ├── eval_harness/ │ ├── config.json # 本次评测的模型与指标配置 │ ├── datasets/ │ │ ├── order_extract.json │ │ └── quality_review.json │ ├── runners/ │ │ ├── model_client.py │ │ └── run_harness.py │ ├── evaluators/ │ │ ├── rule_check.py │ │ └── llm_judge.py │ └── reports/ # 运行结果建议加入 .gitignore 或按需入库 └── ci/ └── run_eval.sh这个目录值得注意的点是 datasets 从代码中剥离。数据文件被单独管理后产品同学、算法同学和测试同学都能在不碰代码的前提下补充用例这正是 harness 能够团队化的基础。2.3 harness 也要有“输入输出契约”不能写成一次性胶水脚本harness 面向的不是一次运行而是长期回归。因此每个用例、每次运行都应该有稳定结构。用例数据建议统一成 JSON 对象至少包含 id、input 和期望行为运行报告也应该统一包含任务标识、模型名称、用例总数、通过率、verdict 和每条用例的原始输出。长期维护时这套“契约”比任何注释都重要。一旦数据结构稳定后面接入 CI、生成趋势图、做配置 diff 都只是顺水推舟的事。3. 最小可运行案例为“订单信息抽取”搭一套可重复的评测3.1 场景假设与测试集格式下面用一个常见场景说明整个 harness 的落地过程客服对话里的“订单信息抽取”。业务方希望模型能在一段用户话术里找到订单号并且只在用户确实询问订单相关问题时返回订单信息。评测配置和用例数据合并在一个 config.json 中对于最小 demo 是足够的生产环境建议把配置和用例拆开。{ task_id: order_extract_v1, model: demo_model, temperature: 0, pass_rate_threshold: 0.9, cases: [ { id: case_001, input: 我想查一下订单 20250301-AB12 里的商品发货了没有, expected_fields: { order_no: 20250301-AB12 }, must_contain: [], must_not_contain: [] }, { id: case_002, input: 帮我退款 300 元可以吗, expected_fields: {}, must_contain: [], must_not_contain: [20250301-AB12] }, { id: case_003, input: 订单 88888888 什么时候到, expected_fields: { order_no: 88888888 }, must_contain: [], must_not_contain: [] } ] }这里用 expected_fields 表示必须抽出的结构化字段用 must_contain 和 must_not_contain 表示输出文本层面的约束。case_002 的场景是“不是订单查询却包含订单号”用于防幻觉回归实际项目中这种反向用例越多系统越稳。3.2 执行器把用例交给模型并捕获异常执行器负责读取测试集、调用模型、把模型输出原样保存。真实项目中模型调用可能是 OpenAI 兼容接口、自研推理服务或内部网关这里用一个占位函数模拟并强调异常必须被捕获。import json import time from pathlib import Path def load_config(config_path: str) - dict: with open(config_path, encodingutf-8) as f: return json.load(f) def call_model(system_prompt: str, user_input: str, model: str, temperature: int 0) - str: 真实项目里换成模型 SDK 调用。 这个函数只负责拿到模型文本输出不负责解析。 生产环境需要在此处处理超时、限流和重试。 # 示例返回一段 JSON便于演示完整链路。 # 换成真实模型后这里要捕获网络异常、鉴权异常和限流异常。 if 88888888 in user_input: return {order_no: 88888888} if 退款 in user_input: return 这不是订单查询请求。 return {order_no: 20250301-AB12} def parse_json_answer(answer: str): 解析模型输出中的 JSON 部分。 try: return json.loads(answer) except json.JSONDecodeError: return None执行器关键点在于不做过多判断。它只负责两件事一是调用模型并返回文本二是捕获调用层的异常。如果异常发生在模型调用阶段那通常是执行器的问题如果发生在解析阶段那通常是模型输出格式的问题两者排查方向不一样。3.3 评分器从“模型答了什么”到“这个用例过没过”评分器把每条用例和模型输出放在一起判定。示例中包含三种最常见规则字段值匹配、关键词必须出现、关键词禁止出现。def check_case(case: dict, output_text: str) - tuple[bool, str]: parsed parse_json_answer(output_text) # 如果用例要求结构化字段输出必须是可解析 JSON if case.get(expected_fields): if parsed is None: return False, 模型输出不是合法 JSON for key, value in case[expected_fields].items(): actual parsed.get(key) if actual ! value: return False, f字段 {key} 期望 {value}实际 {actual} for token in case.get(must_contain, []): if token not in output_text: return False, f输出缺少关键词 {token} for token in case.get(must_not_contain, []): if token in output_text: return False, f输出包含禁用词 {token} return True, pass def run_harness(config_path: str) - dict: config load_config(config_path) results [] passed 0 for case in config[cases]: output_text call_model( system_prompt你只输出 JSON不要额外解释。, user_inputcase[input], modelconfig.get(model), temperatureconfig.get(temperature, 0), ) ok, reason check_case(case, output_text) results.append({ case_id: case[id], passed: ok, reason: reason, output: output_text, }) passed 1 if ok else 0 total len(results) pass_rate passed / total if total else 0 threshold config.get(pass_rate_threshold, 0.9) return { task_id: config[task_id], model: config.get(model), total: total, passed: passed, pass_rate: round(pass_rate, 4), threshold: threshold, verdict: PASS if pass_rate threshold else FAIL, cases: results, run_at: time.strftime(%Y-%m-%d %H:%M:%S), }评分器直接返回失败原因而不是只返回 True 或 False。这个设计非常重要。看到“模型输出不是合法 JSON”和看到“字段期望值不一致”后续处理方式完全不同。没有失败原因的通过率对调试几乎没有帮助。3.4 报告输出与阈值判定最后把结果写入 reports 目录并在终端打印一行摘要。运行结果需要保留原始输出这是后续排查质量问题的依据。if __name__ __main__: import sys config_path sys.argv[1] if len(sys.argv) 1 else config.json report run_harness(config_path) report_dir Path(reports) report_dir.mkdir(exist_okTrue) file_name f{report[task_id]}_{report[run_at].replace(:, ).replace( , _)}.json report_path report_dir / file_name report_path.write_text( json.dumps(report, ensure_asciiFalse, indent2), encodingutf-8, ) print(fpass_rate{report[pass_rate]}, verdict{report[verdict]}) print(freport{report_path})到这里一个不受业务框架约束的最小 eval harness 就能运行了。它没有引入复杂的测试框架核心代码只有几个函数但已经满足数据与代码分离、失败原因可查、结果可归档这三个基本要求。4. 三种评测方式和参数选择不要只会用“模型打分”4.1 规则断言格式稳定任务的第一选择规则断言是最可靠的评测方式因为它不依赖概率。判断模型输出是否包含某个订单号、是否等于某个枚举值、是否符合正则结果都是确定的。适合规则断言的场景包括信息抽取、结构化 JSON 输出、分类标签、关键词过滤、代码生成后执行测试。规则断言缺点是表达力有限。“回答是否耐心”“语气是否专业”这类问题很难写规则。所以实际 harness 通常把规则断言作为底座再用其他方式处理主观维度。4.2 参考答案相似度判断语义一致的折中方案当模型输出是自然语言且和参考答案不一定逐字相同时可以用文本相似度做近似判定。经典做法是把参考答案和模型输出分别用 embedding 模型编码再计算余弦相似度超过阈值就算通过。def cosine_similarity(a_vector, b_vector) - float: # 实际项目直接使用 embedding 服务返回的向量 dot sum(x * y for x, y in zip(a_vector, b_vector)) norm_a sum(x * x for x in a_vector) ** 0.5 norm_b sum(y * y for y in b_vector) ** 0.5 return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0这种方式的缺点是阈值选择比较主观而且相似度高不代表“事实正确”。一个模型可能用完全相似的语气说出错误结论。因此相似度评估只适合作为兜底不应替代字段级事实校验。4.3 LLM-as-judge用另一个模型给输出打分对于“回答是否简洁、是否包含必要信息、语气是否合适”这类复杂标准可以用一个更强的模型作为裁判。裁判模型拿到原始输入、业务要求和待评输出按评分量表输出分数或结论。JUDGE_PROMPT 你是质量评审员。请按以下维度评价模型输出 维度{criteria} 评分标准5 分表示完全满足1 分表示完全不满足。 只输出一个整数分数不要输出解释。 用户输入{input} 预期要求{expected} 模型输出{output} 评分 def llm_judge(case: dict, output_text: str, judge_model: str) - int: prompt JUDGE_PROMPT.format( criteriacase.get(criteria, 回答是否准确并符合事实), inputcase[input], expectedcase.get(expected_behavior, ), outputoutput_text, ) # temperature 必须设 0避免裁判模型自己随机波动 score_text call_model(, prompt, judge_model, temperature0) return int(score_text.strip())使用 LLM-as-judge 时要注意几个原则裁判 prompt 要包含明确评分维度和离散量表裁判模型 temperature 设为 0 或其他可复现值每次评分都要保存裁判输出的解释否则无法判断分数是否合理。算法岗和测试岗最容易争论的往往不是模型输出而是裁判 prompt 是否公允因此它也要纳入 harness 的项目版本管理。4.4 常见参数与阈值建议不同任务对参数敏感度不同。下面是经验值实际项目要根据数据集和模型版本自己校准。参数默认建议调大影响调小影响适用场景temperature0 或 0.1输出多样通过率波动大输出稳定偏向保守抽取、分类、检索判定相似度阈值0.8 左右需校准更严格误杀多更宽松漏检多语义相似判断judge 温度0分数不稳定难复现分数稳定但可能同质所有模型裁判场景单用例重试次数1 到 3掩盖偶发异常易受网络抖动影响线上服务配合参数本质上是“可复现性”和“真实行为覆盖度”之间的权衡。评测不是模拟生产环境的所有随机性而是先做到可复现再补充额外的随机采样测试。5. 从 demo 到可用回归验证、基线与结果分析5.1 用一条命令完成回归前面 demo 已经支持命令级运行cd eval_harness python run_harness.py config.json预期输出如下pass_rate1.0, verdictPASS reportreports/order_extract_v1_20250216_103000.json这个命令应该在每次 prompt 改动、模型升级、测试集增改后都执行一遍。如果接入 CI就是在代码合并前自动跑一次任何通过率下降都会直接中断发布流程。持续维护的核心不是这条命令而是命令背后的测试集覆盖度。5.2 验证 eval harness 本身有没有问题harness 本身也是一段需要验证的代码。评测系统如果出错它给出的“PASS”或“FAIL”会误导所有人的判断。因此一个新的 eval harness 上线前至少要做四个检查用例数据能完整加载没有编码、字段缺失问题。执行器能访问目标模型错误能进报告而不是直接抛到进程外。评分器对明显正确和明显错误的输出都能给出期望结论。同一份 config 连续跑两次结果差异只在允许的范围内。检查手段很朴素先用两三条用例跑通再手工制造一个明显错误输出确认评分器会失败。确认 harness 能识别好和坏才开始谈阈值。5.3 结果分析哪些失败需要改 prompt哪些需要改 harness评测跑完后失败用例会自动出现在报告的 cases 数组里。分析失败时先判断归属如果模型输出格式不符合要求优先改 prompt 格式约束或在执行器层做解析扩展。如果字段抽取错误优先关注是不是用例歧义、需要补充 few-shot 示例。如果评分规则写得不合理需要回头改测试集或评分器改 prompt 没有意义。如果同一用例在连续多次运行中有时通过有时失败这是随机性问题需要固定温度或引入多次投票。一个常见的误区是看到失败就立刻改 prompt。先看失败原因再看是哪一层出的问题否则会陷入“改 prompt、跑评测、又失败、再改 prompt”的循环。6. 实战中踩过的坑现象、原因与排查路径6.1 高频问题对照表下表整理的是 eval harness 在工程化过程中出现频率最高的问题。这些坑来自实际维护经验和具体模型关系不大。问题现象常见原因检查路径处理建议所有用例都失败且报错相同模型调用参数错误或输出解析失败看报告里的原始输出和 error 字段在执行器层捕获网络、鉴权、限流异常统一记录同一模型同一配置两次运行结果不同temperature 不为 0或模型服务做了随机采样检查 config 和日志里的温度参数评测统一设置 temperature0必要时多次运行取统计LLM-as-judge 分数忽高忽低裁判模型温度过高或裁判 prompt 没给量表查看裁判输出解释裁判 temperature 设为 0加入离散评分标准和示例数据集更新后基线无法对比报告没有记录配置哈希和数据版本查看上次报告里的 task_id 和文件内容报告写入数据集版本、模型版本、prompt 版本、配置哈希单条用例单独跑通过全量跑失败用例之间共享了全局变量或上下文检查执行器有没有正确的隔离逻辑所有用例独立构造输入和上下文禁止共享缓存harness 通过但线上效果差测试集分布和真实请求不一致对比语料来源和场景占比从生产日志采样构建代表性测试集6.2 三个最容易被忽略的错误第一个错误是把评测用例和 prompt 示例混用。如果 prompt 里已经放了两个订单抽取示例评测集又把相同文本作为测试输入模型会“背答案”而不是真正理解规则。评测用例应该从预留的生产样本中收集至少不能和 few-shot 示例逐字相同。第二个错误是评分标准出现两份。不少团队既在系统 prompt 里写了“遇到非订单查询不要返回订单号”又在评分器代码里写了一份 must_not_contain 断言。两边口径不一致时prompt 改了但代码没改评测结果自然失真。评分规则应该只有一个权威来源另一侧只是执行。第三个错误是不保留失败原始输出。只记录“case_003 失败”却没有原始文本和模型输出事后分析只能重新跑。对于不确定性问题重复运行很可能已经拿不到当时的异常输出。正确的做法是每次运行都保存 raw output 和 error reason宁多勿少。6.3 排查一个失败用例的推荐顺序面对一条失败用例按下面的顺序排查能避免在无关方向浪费时间确认用例本身没写错输入是否符合真实场景期望值是否正确。确认模型确实被调用到了而不是命中缓存或 mock。查看模型原始输出确认问题在输出内容、输出格式还是解析逻辑。检查评分器逻辑手动用同样输出走一遍判定确认评分器没误判。对比同场景其他用例判断是单点问题还是系统性退化。查看同一用例近几次历史结果判断是新回归还是本来就不稳定。这套顺序的核心是先排除自身问题再怀疑模型最后才讨论是改 prompt 还是改数据。很多调试时间浪费在跳过第 1、4 步直接改 prompt 上。7. 为什么 harness engineering 会排在 AI 工程师技能第二位7.1 从评估到工程化AI 工程师能力重心的迁移把 harness engineering 放到第二重要的位置本质上是 AI 工程成熟的表现。第一年做 LLM 应用团队需要的是能快速验证“模型能不能做这件事”的人所以 evals 成为第一技能。到了第二年问题变成了“模型行为如何在几十个版本迭代中保持稳定”这时光会定义指标已经不够还需要把指标变成自动化系统。第二个位置不是指它比 prompt engineering、RAG、微调更高深而是指它处于能力扩散的关键节点。一个 AI 工程师只要掌握了 harness engineering就能把定义、执行、数据分析三个环节串起来快速评估自己写的 prompt 是否有效也能在一个大项目里定位质量下降是发生在哪一层。这项能力的价值不依赖某一个具体模型即使底座模型换了harness 结构依然能够复用。7.2 不同岗位如何在 harness 上协作harness engineering 不只是“AI 测试工程师”的工作。算法工程师需要它验证 prompt 和模型迭代效果应用开发工程师需要它在 CI 里拦截回归运维和平台工程师需要它支撑定时评测和灰度对比。传统岗位边界在 LLM 应用里会变模糊。测试工程师可以提供用例设计和边界场景经验但不能只负责“点一下跑通”开发工程师擅长把执行器和报告器写得更工程化但也要理解测试集怎么构造AI 平台工程师会把评测服务化让多个团队共享一套评测基础设施。最终每个岗位都会承担 harness 的一部分只会在侧重上不同。这个趋势不等于某个岗位会消失而是要求工程师从“完成一次调用”走向“保障一套行为”。7.3 建立 harness 能力的六步练习清单想系统培养 harness engineering 能力不一定要从复杂框架开始。下面这条路径适合大多数正在做 LLM 应用的工程师从手头项目里挑一个最容易出问题的真实场景比如信息抽取、客服回复或代码生成。先从生产日志和人工记录里整理出二十条有代表性的输入明确哪些是正向、哪些是反向用例。给每条用例写出可判定的期望结构化字段、关键词、禁止词或者一句行为描述。仿照本文的最小示例写一个执行器加评分器先不追求美观只追求能产出失败原因。把运行报告保存下来给 prompt 或模型做一次改动对比改动前后的通过率。把 run_harness 命令接入仓库的 CI让每次改动都自动触发评估。完成这六步harness engineering 就不再是一个抽象概念而是写进仓库、每天都会被跑一次的真实工程资产。回到最初那个判断evals 决定了 AI 工程师能不能回答“模型做得好不好”harness engineering 决定了团队能不能在每次改动后都保持清醒。前者是方向感后者是刹车和仪表盘。对于刚接触 LLM 应用开发的工程师与其急着收集更多 prompt 技巧不如先把手里的场景变成一套最小可运行的评测闭环。这套闭环会在后续模型升级、prompt 调优和线上问题排查中反复提供价值也是 AI 工程师从“会调模型”走向“能交付可靠系统”最踏实的一条练习路径。