从evals到harness:LLM应用可重复测试工程化解析
2026/9/3 5:14:53 网站建设 项目流程

如果你正在把大模型接进一个真实业务系统,大概会遇到这样的场景:上个月还是“智能客服工单分类”的演示 demo,进入联调后,产品经理改了一句需求——“取消订单但已经发货的,要单独走售后流程”。你花了半小时调整 prompt,用例跑了一遍,看起来分类效果不错。可到了第二天,测试丢过来一条很普通的“我重复扣款了怎么退款”,它居然被分到了 account 而不是 billing。你查了半天也没找到原因:这版 prompt 相比上版只加了一句指令,为什么上版一直正常的场景突然就失败了?

真正的问题不是某个 prompt 写得不好,而是你的项目里没有一套能回答“这一版行为相对于上一版,到底改变了什么”的机制。

传统软件有编译报错、有单元测试、有回归集,代码行为是可重复验证的。LLM 应用则不然:哪怕输入完全相同,模型输出也可能抖动;改一个不相关的系统指令,可能影响十几个下游场景;跑一次真实模型评估,还要计算 token 成本和 API 延迟。于是大家开始意识到,模型能力已经不是瓶颈,真正重要的是给 LLM 应用建立一套可重复、可隔离、可观测的测试与评估工程体系。Elvis Saravia 把这件事的优先级说得很清楚:harness engineering 正在成为 AI 工程师仅次于 evals 的重要技能。

这篇文章不打算给你介绍一个新框架的名字,而是想拆解四件事:harness engineering 到底是什么;为什么它不是 evals 的重复,而是 evals 能落地的前提;它对 AI 工程师的角色和技能栈意味着什么;以及如何用不到 200 行代码,搭一个你自己项目里能用的最小 harness。读完你应该能判断:自己团队现在缺的是评测指标,还是让评测可重复运行的夹具层。

1. 先看问题:AI 应用“能跑”不等于“敢上线”

很多团队在引入大模型时会走过同一个阶段:demo 期一切顺利,生产期问题集中爆发。

原因是 demo 里你关注的是“模型会不会做这道题”,而生产环境关心的是另外三件事。

第一,可重复性。同样的 prompt、同样的参数,昨天输出合格,今天输出不合格。这不是玄学,而是采样温度、模型版本动态更新、上下文长度截断等共同作用的结果。如果一次失败无法复现,那你根本没法做根因分析。

第二,可归因性。模型输出质量下降到底是 prompt 改坏的,还是召回内容变了,还是模型供应商灰度了新版本?没有基线存档,任何问题讨论都会变成“我觉得它变笨了”这种不可验证的争论。

第三,可防护性。上线之后你不可能每次都人工看一遍输出。你需要一种机制在发布前发现高风险行为,或者在生产环境用低成本方式持续采样。

这三件事,传统软件工程其实早有答案:单元测试、集成测试、测试夹具、CI 流水线。但 LLM 应用把这三件事都变难了,因为它没有“确定性的预期输出”。你不能写一个assert response == "OK"就算完事,你需要回答的是:模型输出好还是坏、相似还是不同、是否包含了幻觉内容、是否遵守了输出格式。

这就是为什么现在“模型选型”已经不是 AI 应用的第一瓶颈。可以这样判断:只要团队还在靠“人工多测几条样例”来验收 AI 功能,那无论模型多强,工程质量都不会稳定。而 harness engineering,正是把“人工多测几条”变成“工程化可重复验证”的那一层能力。

2. 什么是 harness engineering:给 AI 应用夹上测试台架

Harness 这个词最早来自硬件测试场景,中文可以理解为“测试夹具”或“测试台架”。你去查汽车碰撞测试,车本身不是直接开上街去撞的,而是被固定在一套复杂的台架上,周围布置各种传感器、假人、高速相机,碰撞过程被反复录制、量化和对比。

这里有一个关键区分:台架不是被测对象,而是让被测对象可以被科学测试的整套辅助系统。

LLM 应用里的 harness engineering 也是如此。被测对象不是一行 prompt,也不是一个模型,而是由“系统提示词 + 用户输入 + 工具调用 + 上下文内容 + 模型参数”共同构成的动态系统。要稳定地测它,你需要至少四层能力。

第一层:样本与期望管理。你得有一批有代表性、有标注、可持续追加的评测样本。每一条样本对应什么输入、期望什么行为、哪些语义必须出现、哪些内容不能出现,都要有结构化管理。

第二层:执行编排。跑评测不是简单地写个 for 循环调 API。你需要处理并发限制、超时、重试、模型切换、不同运行模式(本地 mock、测试环境、生产样本)之间的隔离。

第三层:依赖隔离与替身。AI 应用在生产环境中会调用数据库、搜索接口、外部工具。如果每一次评测都依赖真实上游,那么评测结果会因为上游抖动而抖动。Harness 需要能注入固定数据、mock 工具返回、模拟真实但可控的环境。

第四层:观测与归档。每次运行都应记录模型输入输出、token 消耗、延迟、prompt 版本、模型版本、样本命中情况。这些记录是未来做回归对比、badcase 分析、成本优化的重要基础。

值得注意的是,不要把 harness engineering 理解成一个新的测试框架。你可以用 pytest、可以用 LangSmith、可以用自己团队的工具,但关键是团队是否具有这种工程能力。没有夹具的评测是脆弱的,没有归档的评测是失忆的。

维度传统单元测试AI EvalHarness Engineering
被测对象确定性的函数或模块模型或应用的输出质量Prompt、模型、工具、上下文组成的动态系统
预期结果固定值或固定行为概率性正确,需要指标描述可重复执行、可对比基线的整套评测流程
主要目标防止代码逻辑回归衡量模型效果好坏让评测稳定、低成本、能进 CI
常见交付物测试用例和断言指标报告样本集、缓存、替换层、归档、幂等执行机制

3. 为什么“evals 第一,harness 第二”

Elvis Saravia 把 harness engineering 排在第二位,后面还有一层潜台词:AI 工程师技能栈中,最优先的事情永远是先想清楚你要评测什么,其次才是怎么稳定地把评测跑起来。

3.1 Evals 解决的是“目标”问题

Evals 的本质是定义“什么是好”。比如客服工单分类场景,我们不仅要看准确率,还要看账单类错误是否被误判为账号类、关键实体是否正确提取、风险场景是否被漏掉。

没有 evals,你连“这版 prompt 更好还是更差”都无法回答。这也是为什么很多大模型团队会反复强调“先做 eval set,再调 prompt”。目标是第一位的。

3.2 Harness 解决的是“可重复执行”问题

但只有 eval 还不够。场景会问:你凭什么相信这次 eval 的结果?

许多团队已经有 eval 脚本,但它们往往是 Jupyter Notebook 或临时 Python 脚本,存在作者电脑里。跑一次要人工看输出,参数靠手改,样本不统一,结果没有存档。换一个人跑,结果就可能不一样;换一个模型,没人知道整体影响。这是典型的“有 eval,但没 harness”。

Harness 补上的,是 eval 背后的工程质量:样本放进版本库、代码可复用、运行环境可重复、mock 与真实模型可切换、输出结果自动生成报告并能与历史基线对比。没有 harness 的 eval,只是实验结果;有了 harness 的 eval,才是工程验收。

3.3 两者构成一个闭环

实践中最顺的链路是这样的:

  1. 从真实业务日志或用户反馈中采集代表性样本;
  2. 定义分类标签、期望输出格式、边界规则,形成 eval set;
  3. 用 harness 统一执行批量评测,记录指标和样本明细;
  4. 将结果归档为基线;
  5. 修改 prompt 或调整模型后重新执行;
  6. 对比新结果与基线,输出差异报告,决定是否发布。

Evals 决定了闭环的“目标”,harness 决定了闭环能不能“重复转起来”。如果你想成为一个能对模型应用质量负责的 AI 工程师,这两项缺一不可。

4. 从岗位热词看 AI 工程师的技能栈变化

最近关于“AI 出来后前端工程师是不是没了”的讨论很多,背后其实折射出一个更真实的趋势:AI 正在把很多工程岗位从“写代码”推向“定义和验证智能行为”。算法工程师、AI 平台工程师、AI 测试工程师、AI 运维工程师、AI 应用工程师,这些新 title 的边界还很模糊,但有一个共同交集开始变得清晰——谁能让模型行为可解释、可评测、可回归,谁就掌握了话语权。

角色方向核心职责与 harness engineering 的关系
AI 算法工程师模型微调、能力优化需要评估集判断训练效果,harness 是实验基线
AI 应用工程师Prompt 编写、Agent 编排、工具接入需要为自己交付的应用写行为验收 harness
AI 测试工程师质量保障、风险发现需要把测试思维嵌入 eval set 和回归流程
AI 运维工程师模型上线、灰度、稳定性需要对线上输出做采样评测,形成轻量上线 harness
AI 平台工程师构建团队公共评测与可观测平台harness 常常是平台内最核心的模块之一

现在的现实是:AI 算法工程师往往懂 eval,但对 harness 里工程化、可重复、成本控制这些事不够敏感;AI 应用工程师擅长写 prompt,但对“建立行为回归护栏”没有概念;AI 测试工程师熟悉传统测试方法论,但需要补上 LLM 概率性输出的评估经验。

因此,harness engineering 不专属于某一个岗位,它是一种横向能力。就像 DevOps 不只是一个职位,而是一套协作文化。未来团队里最吃香的人,未必是调 prompt 最快的人,而是能把 LLM 应用质量变成可验证工程资产的人。当公司决定把模型从 A 换成 B,谁能快速给出“切换后行为差异报告”,谁做的事情就不容易被替代。

5. 最小可落地示例:写一个能跑通的 LLM Harness

为了把概念落到代码里,这里做一个非常小的演示:一个客服工单分类应用,目标是判断用户输入属于 billing、technical、account 还是 other。我们会给这个应用写一个最小 harness,包含样本文件、真实模型调用、mock 运行模式、结果报告生成。

先看一下目录结构:

llm_harness_demo/ ├── src/ │ └── llm_app/ │ └── classifier.py ├── tools/ │ └── harness/ │ └── run_harness.py ├── tests/ │ └── samples/ │ └── customer_service.json ├── reports/ └── requirements.txt

5.1 待测应用:一个简单的工单分类器

# src/llm_app/classifier.py import json from typing import Any from openai import OpenAI SYSTEM_PROMPT = """你是一个客服工单分类器。 请把用户问题划分为以下类别之一: - billing:账单、扣款、退款、支付相关 - technical:产品功能报错、接口异常、系统崩溃 - account:登录、账号、密码、权限相关 - other:其他或无法明确归类 只输出 JSON,不要输出多余文字,格式如下: {"category": "...", "reason": "简要判断原因"} """ def _extract_json(text: str) -> dict[str, Any]: try: start = text.index("{") end = text.rindex("}") return json.loads(text[start : end + 1]) except (ValueError, json.JSONDecodeError): return {} def classify_inquiry(client: OpenAI, inquiry: str, model: str) -> dict[str, Any]: """调用大模型对用户问题进行工单分类。 返回 dict 中至少包含 category、reason、tokens 三个字段, 方便 harness 统一做断言和统计。 """ response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": inquiry}, ], temperature=0, ) raw_text = response.choices[0].message.content or "" parsed = _extract_json(raw_text) usage = response.usage return { "category": parsed.get("category", "other"), "reason": parsed.get("reason", ""), "tokens": usage.total_tokens if usage else 0, "raw_response": raw_text[:200], }

这里的核心设计是:业务代码被封装成一个可调用的函数,不直接在 prompt 里做评测逻辑。Harness 只关心这个函数的输入和输出,不关心它内部是不是用了最新的大模型。这样后续才能在 harness 层自由切换真实模型和 mock。

5.2 定义一份最小评测样本

{ "desc": "客服工单分类最小回归集", "cases": [ { "id": "case001", "inquiry": "我同一个订单被扣了两次款,怎么申请退款?", "expected_category": "billing", "reason_hint": "扣款" }, { "id": "case002", "inquiry": "每次登录都提示密码错误,但我肯定没改过密码", "expected_category": "account", "reason_hint": "登录" }, { "id": "case003", "inquiry": "调用你们的 API 一直返回 500,是不是接口崩了?", "expected_category": "technical", "reason_hint": "接口" }, { "id": "case004", "inquiry": "想改一下绑定的手机号,在设置里找不到入口", "expected_category": "account", "reason_hint": "账号" }, { "id": "case005", "inquiry": "你好,在吗?", "expected_category": "other", "reason_hint": "" } ] }

样本里面有两个东西值得注意:expected_category是硬性分类期望,reason_hint是对模型推理理由的弱约束。真实场景里,你可以增加更多的断言维度,比如“回复中不得出现某类词汇”“必须引用某个上下文片段”“禁止编造订单号”等。

5.3 编写最小 Harness Runner

# tools/harness/run_harness.py import argparse import json import os import sys import time from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from openai import OpenAI from src.llm_app.classifier import classify_inquiry def build_args(): parser = argparse.ArgumentParser(description="Minimal LLM harness runner") parser.add_argument("--samples", type=Path, required=True, help="评测样本 JSON 文件路径") parser.add_argument("--model", default="gpt-4o-mini", help="模型名称,以实际可用模型为准") parser.add_argument("--report", type=Path, default=Path("reports/harness_report.json"), help="结果报告输出路径") parser.add_argument("--mock", action="store_true", help="使用 mock 模式,不真实调用模型") parser.add_argument("--sleep", type=float, default=0.0, help="每次调用后的休眠秒数,用于限流") return parser.parse_args() def load_samples(path: Path) -> list[dict[str, Any]]: with open(path, encoding="utf-8") as f: data = json.load(f) return data.get("cases", []) def mock_classify(inquiry: str) -> dict[str, Any]: """本地 mock 版本,用于不调用模型时验证 harness 流程。""" text = inquiry.lower() if any(k in text for k in ["扣款", "退款", "账单", "charge", "billing"]): return {"category": "billing", "reason": "订单存在重复扣款,建议走退款流程", "tokens": 0} if any(k in text for k in ["登录", "密码", "账号", "login", "password"]): return {"category": "account", "reason": "用户登录或账号相关问题", "tokens": 0} if any(k in text for k in ["api", "接口", "报错", "error", "崩溃"]): return {"category": "technical", "reason": "系统接口或服务异常", "tokens": 0} return {"category": "other", "reason": "无法明确归类的用户输入", "tokens": 0} def run_one_case(case: dict[str, Any], client: OpenAI | None, model: str, mock: bool): start = time.time() if mock: prediction = mock_classify(case["inquiry"]) else: if client is None: raise RuntimeError("client must be initialized when mock is False") prediction = classify_inquiry(client, case["inquiry"], model) latency_ms = (time.time() - start) * 1000 category_ok = prediction.get("category") == case.get("expected_category") reason_hint = case.get("reason_hint", "") if reason_hint: reason_ok = reason_hint in (prediction.get("reason") or "") else: reason_ok = True passed = category_ok and reason_ok return { "id": case.get("id"), "inquiry": case["inquiry"], "expected_category": case.get("expected_category"), "actual_category": prediction.get("category"), "passed": passed, "latency_ms": round(latency_ms, 2), "tokens": prediction.get("tokens", 0), "reason": prediction.get("reason", ""), } def main(): args = build_args() cases = load_samples(args.samples) client = None if not args.mock: if not os.environ.get("OPENAI_API_KEY"): print("Missing OPENAI_API_KEY: 真实模型模式需要设置环境变量") sys.exit(2) client = OpenAI() results = [] for idx, case in enumerate(cases): if args.sleep and idx > 0: time.sleep(args.sleep) results.append(run_one_case(case, client, args.model, args.mock)) total = len(results) passed = sum(1 for r in results if r["passed"]) total_tokens = sum(r["tokens"] for r in results) avg_latency = sum(r["latency_ms"] for r in results) / total if total else 0 metrics = { "total_cases": total, "passed_cases": passed, "failed_cases": total - passed, "pass_rate": round(passed / total, 4) if total else 0, "total_tokens": total_tokens, "avg_latency_ms": round(avg_latency, 2), } report = { "mode": "mock" if args.mock else "live", "model": args.model if not args.mock else "mock-classifier", "metrics": metrics, "cases": results, } args.report.parent.mkdir(parents=True, exist_ok=True) with open(args.report, "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) print(json.dumps(report, ensure_ascii=False, indent=2)) if passed == total: print("[harness] all cases passed") sys.exit(0) else: print("[harness] some cases failed") sys.exit(1) if __name__ == "__main__": main()

这段代码虽短,但已经把 harness 的几个核心部件带出来了:

  • 样本加载与断言规则组合expected_category做严格断言,reason_hint做语义弱断言;
  • 运行模式切换--mock和真实模型调用可以自由切换,保证离线跑通和在线评估两不误;
  • 观测数据归档:每次运行都会把每一条样本的输入、输出、延迟、token 消耗记录成 JSON 报告;
  • 退出码设计:全部通过返回 0,有失败返回 1,方便接入 CI 流水线。

6. 运行与验证:如何判断 Harness 真正可用

先把 mock 模式跑通,它不消耗 token,也能验证整个链路没有写错。

python tools/harness/run_harness.py \ --samples tests/samples/customer_service.json \ --mock \ --report reports/harness_report.json

如果一切正常,你会看到终端输出末尾包含类似内容:

"total_cases": 5, "passed_cases": 5, "failed_cases": 0, "pass_rate": 1.0 [harness] all cases passed

mock 模式的意义不是证明模型能力,而是证明你的 harness 本身跑得通、样本格式正确、报告能生成。很多人一上来就跑真实模型,结果代码报错都没定位到是 harness 写错了还是模型真不行,这是很低效的调试方式。

mock 跑通后,再设置环境变量并运行真实模型模式:

export OPENAI_API_KEY="你的密钥" python tools/harness/run_harness.py \ --samples tests/samples/customer_service.json \ --model gpt-4o-mini \ --report reports/harness_report.json

注意这里模型名只是示例,具体名称以你实际申请到的模型为准。如果真实模型模式下发生失败,先做三件事:

  1. 打开reports/harness_report.json,看每一条 case 的actual_categoryreason,判断是分类错还是理由不充分;
  2. 把失败的用户输入单独提取出来,直接在对话界面里复测,排除 harness 代码本身的 bug;
  3. 再检查是不是模型输出格式没被正确解析,比如返回了 json 以外的额外说明文字,导致_extract_json解析结果为空值。

从工程经验看,早期 harness 最常见的问题不是模型能力不够,而是解析不健壮、样本期望标注错误、mock 与真实模型行为差距过大。先把这三类问题排除,再谈调 prompt 优化效果。

7. 把 Harness 接进工程流水线:从一次性脚本到团队资产

能手动跑通之后,下一步是把 harness 变成 AI 应用交付流程的一部分。否则它还是躺在你电脑里的脚本,不是团队资产。

7.1 用 Makefile 固化常用命令

# Makefile .PHONY: harness-mock harness-live harness-mock: python tools/harness/run_harness.py \ --samples tests/samples/customer_service.json \ --mock \ --report reports/harness_report.json harness-live: python tools/harness/run_harness.py \ --samples tests/samples/customer_service.json \ --model $(EVAL_MODEL) \ --report reports/harness_report.json
make harness-mock EVAL_MODEL=gpt-4o-mini make harness-live

7.2 最小 GitHub Actions 工作流

接入 CI 时要注意成本策略:不要每次 PR 都跑大而全的真实模型评测。更稳妥的做法是:

  • PR 阶段只跑 mock 链路,确认代码没写坏、样本能加载、报告能生成;
  • 主分支或定时任务再跑真实模型评测,并自动对比历史报告;
  • 重大 prompt 改动时,由触发人手动跑完整数据集。
# .github/workflows/harness-ci.yml name: llm-harness-ci on: pull_request: push: branches: [main] workflow_dispatch: jobs: harness: runs-on: ubuntu-latest env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询