ZeroClaw Eval Harness 实战指南:基于确定性回放的 Agent 循环回归测试体系
【免费下载链接】zeroclawFast, small, and fully autonomous AI personal assistant infrastructure, any OS, any platform — deploy anywhere, swap anything 🦀项目地址: https://gitcode.com/gh_mirrors/ze/zeroclaw
本文围绕 ZeroClaw 的评估工具链(zeroclaw eval run,实现于crates/zeroclaw-eval)展开:它通过把 JSON 轨迹夹具(LlmTrace)灌入真实 Agent 循环,用声明式期望(expects)逐项打分,为工具调度、多轮对话顺序、回复格式与拒绝行为提供免网络、零成本、完全确定的回归防线。读完本文,你将掌握 eval harness 的模式与套件分类、用例格式与全部 11 种期望的语义、fail-closed 加载校验与空运行可证伪性设计,并能在自己的改动合入前用它守护 Agent 行为不回退。
Eval harness 是什么:与 in-loop 评分的本质区别
ZeroClaw 的 eval harness 由zeroclaw eval run子命令驱动,核心 crate 为 crates/zeroclaw-eval。它的工作方式是:把每个评估用例(一个 JSON 轨迹夹具)通过真实 Agent 循环回放一遍,再针对运行产物与夹具中的声明式期望逐项比对,产出通过/失败报告。它的使命是守卫 Agent 循环的行为(工具分发、多轮排序、回复格式、拒绝应答)不因代码演进而回归。
一个容易混淆的点是:harness不等于[agent.eval]配置段。后者是 Agent 循环内部对回复质量的在线评分器;而 harness 是独立于循环之外的离线评估框架,配置在[eval]段下,通过 CLI 子命令调用。这一点在 crates/zeroclaw-config/src/scattered_types.rs 的EvalHarnessConfig注释中有明确区分。
两种运行模式:replay 与 live
| 模式 | 做什么 | 成本 | CI |
|---|---|---|---|
replay | 将夹具中脚本化的 LLM 回复经 Agent 循环回放,完全确定性、无网络 | 免费 | 门禁(默认) |
live | 对真实 provider 执行用例(规划中,落地后见 live 模式章节) | 真实 token | 默认绝不 |
Mode枚举定义在 crates/zeroclaw-eval/src/lib.rs:Replay与Live两个变体,FromStr同时接受小写形式。当前阶段(Phase 0)runner 遇到Mode::Live会直接返回明确错误"live mode is not implemented yet (Phase 0 supports --mode replay only)"(见 runner.rs),CLI 解析--mode live本身是允许的,只是执行会失败——这是为后续阶段预留的接口。
套件分类:谁必须 100% 通过
套件(suite)是存放*.json夹具的目录,规范见 evals/README.md:
evals/regression/:必须保持 100% 通过。由 crates/zeroclaw-eval/tests/regression_suite.rs 在 CI 中门禁,任一失败即阻塞合并。这也是[eval].suite_dir的默认值。evals/capability/(规划中):难度高的任务,通过率低;随时间跟踪,永不门禁。evals/live/(规划中):针对真实 provider 执行;默认绝不进 CI。
其中回归目录本身由一个测试硬性锁定:gated_suite_directory_matches_the_configured_default断言EvalHarnessConfig::default().suite_dir == "evals/regression",并检查该目录真实存在,防止 CI 门禁的目录与zeroclaw eval run默认跑的目录漂移。
运行方法与命令行参数
# 回放默认回归套件: zeroclaw eval run # 指向指定套件,输出机器可读 JSON: zeroclaw eval run --suite evals/regression --format json--suite覆盖[eval].suite_dir;--mode覆盖[eval].mode。- 套件加载是非递归的:只有套件目录的直接
*.json子文件才被视为用例(见 case.rs 的load_suite,路径按字典序排序保证输出稳定,非 json 文件被忽略,目录读取任一失败则整体中止,绝不允许套件“悄悄变小”)。 - 输出格式有两种:人类可读表格(
Table,默认)与机器可读 JSON(Json,适合 CI 产物),对应OutputFormat枚举与 src/commands/eval.rs 中的print_report。
CLI 子命令定义在 src/main.rs:zeroclaw eval run [--suite <dir>] [--format <table|json>],并通过 src/commands/eval.rs 转发到zeroclaw_eval::run_suite。
表格渲染效果(SuiteReport::render_table,report.rs)大致如下:
✓ single-tool-echo (single_tool_echo.json) 4/4 checks ✗ smoke-greeting (smoke_greeting.json) 1/3 checks ✗ response_not_contains("error"): unexpectedly present in response: ... ... 7/8 cases passed (1 failed)JSON 输出(to_json)则包含passed/failed/total/all_passed汇总,以及每个 case 的name、source、passed、error、grades数组,可直接作为 CI 工件归档。
退出码契约:CI 门禁的信号
zeroclaw eval run的进程退出码就是门禁信号:所有用例通过则退出0,否则(任一检查失败或运行出错)退出1。该决策被抽成纯函数SuiteReport::exit_code()(report.rs),使其能在真实边界被单元测试覆盖——report.rs 的测试直接验证了全通过返回 0、任一失败返回 1。regression_suite.rs的regression_suite_replays_green就是把这个契约接进#[tokio::test]的实例:加载并运行回归套件后断言report.all_passed()且exit_code() == 0。
用例格式:LlmTrace 轨迹夹具
每个夹具是一个LlmTrace:包含model_name(报告里展示的用例名)、turns对话轮列表(每轮有user_input与脚本化回复steps),以及声明式expects。用例分为正向(行为必须发生)与负向(行为必须不发生,例如tools_not_used、response_not_contains、max_tool_calls: 0)。
LlmTrace与TraceExpects的结构定义在 case.rs:顶层与嵌套结构都带#[serde(deny_unknown_fields)],拼写错误的键会直接导致解析失败而不是被静默丢弃——这是 fail-closed 的第一层。steps中每步response按type标签区分为text(含content,可带input_tokens/output_tokens)或tool_calls(含id/name/arguments的调用列表),token 字段缺省为 0。
下面是一个完整可运行的回归用例(与仓库 evals/regression/single_tool_echo.json 一致):
{ "model_name": "single-tool-echo", "turns": [ { "user_input": "Echo hello for me", "steps": [ { "response": { "type": "tool_calls", "tool_calls": [ { "id": "call_1", "name": "echo", "arguments": {"message": "hello"} } ], "input_tokens": 30, "output_tokens": 15 } }, { "response": { "type": "text", "content": "The echo tool said: hello", "input_tokens": 50, "output_tokens": 10 } } ] } ], "expects": { "response_contains": ["hello"], "tools_used": ["echo"], "max_tool_calls": 1, "all_tools_succeeded": true } }回放机制由TraceLlmProvider(replay.rs)实现:它是一个实现ModelProvider的 provider,把每轮脚本化 steps 放进各自的 FIFO 队列,chat()时按序弹出;ReplayHandle::finish_turn在每轮结束时检查该轮 steps 是否全部被消费,若轨迹为某轮“超规格”编写(脚本步数多于实际 LLM 往返次数)会直接报错,杜绝回复跨轮串扰。这一行为有对应测试over_specified_turn_is_an_error(runner.rs 测试)。
期望(Expectations)全表:11 种声明式断言
| 期望 | 语义 | 说明 |
|---|---|---|
response_contains | 最终回复必须包含的子串列表 | 针对脚本化最终文本 |
response_not_contains | 最终回复必须不包含的子串列表 | 负向断言 |
response_matches | 最终回复必须匹配的正则列表 | 无效正则记为该检查失败,不 panic,也不短路后续检查 |
tools_used | 必须被调用过的工具名列表 | 存在性断言 |
tools_not_used | 必须没被调用过的工具名列表 | 负向断言 |
max_tool_calls | 工具调用次数上限 | 上界,含等于 |
min_tool_calls | 工具调用次数下限 | 下界,含等于 |
exact_tool_calls | 工具调用次数精确值 | 比tools_used+max_tool_calls强得多 |
all_tools_succeeded | 是否所有工具调用都成功 | 布尔,可断言 true 或 false |
tool_arguments_contain | 工具实际收到的参数负载必须包含指定子串 | 边界级断言 |
tool_results_contain | 工具实际返回的结果负载必须包含指定子串 | 边界级断言 |
这些检查全部实现在 grader.rs 的evaluate_expects中:每个声明产生一个GradeResult { check, passed, detail },失败时detail会带上观察到的真实负载,让 CI 失败无需本地重跑即可诊断。
边界断言:tool_arguments_contain/tool_results_contain
response_contains只能评价回放 provider 为自己脚本化的文本——它无法证明某个值真的穿过 Agent 循环往返成功。为此,评估框架在分发边界记录了每一次真实调用:RecordingObserver(observer.rs)从ObserverEvent::ToolCall捕获(name, arguments, result, success)四元组存入RecordedCall;RunRecord.tool_calls因此成为唯一权威的分发事实(record.rs 注释明确:工具名、聚合成功状态均由该列表派生,而非另行存储一份可被改动的副本)。tool_arguments_contain/tool_results_contain就基于这份事实打分:
"expects": { "exact_tool_calls": 2, "tool_arguments_contain": [ { "tool": "echo", "needle": "alpha", "call_index": 0 }, { "tool": "echo", "needle": "beta", "call_index": 1 } ], "tool_results_contain": [ { "tool": "echo", "needle": "alpha", "call_index": 0 } ] }call_index可选、从 0 开始,按分发顺序在对指定工具的全部调用中计数;省略时只要该工具任意一次调用的负载含needle即通过。指定call_index后,若实际调用次数不足、或第i次负载不含needle,检查都会失败并给出观察到的负载内容——这是断言调用顺序的推荐方式(grader.rs 的grade_payload)。配套测试覆盖了交换顺序必失败(indexed_payload_expect_grades_per_call_ordering)与索引越界必失败(indexed_payload_expect_fails_when_index_out_of_range)。
关键规则:凡用例声称某个值经工具往返、或声称发生了 N 次分发,就必须用tool_arguments_contain/tool_results_contain/exact_tool_calls来打分——因为tools_used是存在性断言、max_tool_calls只是上界,两者组合在“某次分发悄悄丢失”时依然全绿;而仅对最终回复的期望在回放机制下永远只能验证脚本化的文本。
Fail-closed 加载校验:门禁不允许“无法失败的用例”
LlmTrace::from_file()(case.rs)在加载阶段就拒绝以下所有形态,且每条错误都点名出问题的夹具文件与字段:
- 零轮对话:回放会驱动 Agent 零次,随后把期望打在一个空运行上,
max_tool_calls: 0之类的期望会空口认证门禁; - 期望块缺失或为空:
TraceExpects::is_empty()判定无有效断言(零条 grade 时CaseReport::passed()空真成立); - 字符串型期望族(
response_contains、response_not_contains、response_matches、tools_used、tools_not_used)含空字符串条目:空子串是重言式、空正则匹配一切、空工具名永远不会被记录,正向族会产生永真通过,tools_not_used则是退化断言; - 未知顶层键或未知期望键(
deny_unknown_fields):拼写错误的键会静默丢弃期望,必须显式失败; - 边界断言族的空值:空的
tool或needle; - 空泛/自相矛盾的计数边界:
min_tool_calls: 0(空泛下界)、min > max、exact落在[min, max]之外。
其中“零长度条目”与“空 expects 块”的判定还有细节:is_empty()只检查向量非空与否,而empty_entry_family()专门抓向量非空但条目为空串的形态——因为前者会通过is_empty但断言真空。这些规则在 case.rs 的单元测试中逐条验证:省略 expects、空 expects 块、拼错response_contain、拼错顶层expect、五类空条目、四类非法边界、空 payload 字段、嵌套字段拼错(如nedle)全部被拒。
空运行可证伪性:admission 拦不住的最后一种空洞
加载校验无法拦截一种真空形态:一个“什么都不做就已满足”的断言,例如孤立的max_tool_calls: 0(零轮时会加载失败,但只要有一轮且仅此一个断言,空运行同样全绿)。因此门禁测试对每一个已提交夹具额外执行“空运行打分”:构造一个final_response为空、无任何历史与调用、token 为 0 的RunRecord,要求至少产生一个失败检查,否则该用例无法加入必需门禁。这就是regression_suite.rs中no_gated_fixture_passes_on_a_run_that_produced_nothing(回归套件测试)的职责。
配套的另一个测试missing_argument_fixture_fails_when_the_dispatch_is_silently_repaired则验证了“夹具名字与期望必须一致”:把missing_tool_argument_continues_loop的实际运行记录手工改写成“分发被静默修复”(wrong_key被改成工具能读的键、一次成功echo),断言边界期望必须变红——否则该用例无法检测它声称要防的回归。
编写规则:让每个用例都可证伪、可判定、可长期维护
evals/README.md 给出了明确的用例作者守则:
- 源自真实失败:从 bug 追踪、支持工单取材;从小而精开始——20~50 个好用例胜过 500 个含糊用例。
- 必须到达真实边界:若回归发生在某个 bug 处,用例必须到达该 bug 发生的边界;只用回放 provider 自供文本的夹具无法证明 provider 序列化、流式、策略、历史或 UI 行为。
- 命名与期望一致:夹具名与期望必须相符;声称的值往返用
tool_arguments_contain/tool_results_contain,声称的 N 次分发用exact_tool_calls: N,声称的顺序用call_index;做不到就改名。 - 标注用例类别:每个用例声明正向或负向;保持套件正负平衡,单向评测会催生单向优化。
- 双专家测试(two-experts test):两人仅凭用例文本必须独立得出相同通过/失败结论,否则用例有歧义,需要收紧。
- 脚本步即参考答案:回放用例的脚本化步骤同时证明任务可解。
- 最低要求:至少回放一轮、至少声明一个非空断言(加载阶段强制)。
- 空运行可证伪:每个用例必须能在“什么都没发生”的运行上失败(门禁测试强制)。
- 隐私:夹具会永久流传,只能使用占位身份(如
zeroclaw_user、example.com),见 docs/book/src/contributing/privacy.md;绝不粘贴真实对话、姓名、密钥或主机名。
仓库内的真实回归套件巡礼
当前 evals/regression 已落地 8 个用例,覆盖不同行为面:
| 用例 | 覆盖的行为 |
|---|---|
smoke_greeting.json | 纯文本问候,负向断言(不出现 error),零工具调用 |
no_tools_on_greeting.json | 问候不应触发工具,tools_not_used: ["echo"] |
single_tool_echo.json | 单轮单工具调用 + 脚本化最终回复 |
multi_tool_chain.json | 同一轮内三次连续工具分发后收尾 |
multi_turn_echo_tool_loop.json | 多轮工具循环,用call_index断言每轮参数与结果顺序 |
multi_turn_response_ordering.json | 多轮回复顺序:最终回复必须是第二轮内容(不含第一轮关键词) |
unicode_tool_arguments_roundtrip.json | Unicode 参数(naïve café 日本語 ✓)经分发边界字节级往返 |
missing_tool_argument_continues_loop.json | 参数键错误时工具仍被真实调用、返回(empty)兜底、循环继续 |
其中multi_turn_echo_tool_loop.json与unicode_tool_arguments_roundtrip.json是“边界期望”的教科书示例:两者都同时声明exact_tool_calls、tool_arguments_contain、tool_results_contain,并配合call_index断言顺序。missing_tool_argument_continues_loop.json的期望还包含response_matches: ["^The echo tool received no usable message\\b"]这样的正则锚定。
之所以回放中只有echo一个工具可用,是因为 Phase 0 注册的是无副作用的确定性内置工具集default_tools()(tools.rs)。EchoTool只读取message参数并原样返回;参数缺失时返回(empty)兜底(tools.rs),且execute永远报成功——这正是missing_tool_argument_continues_loop期望tool_results_contain: [{ "tool": "echo", "needle": "(empty)" }]能成立的依据。接线真实沙箱工具注册表留给 live 阶段的后续实现。
运行时隔离与整体架构
run_case(runner.rs)为每个用例构建完全隔离的 Agent:独立临时工作区(tempfile::tempdir)、后端为none的临时内存(用例之间无法互相观察)、独立的RecordingObserver与TraceLlmProvider,再通过引擎的ScopedToolRegistry装配口把default_tools()以“恒等装配”方式注入(所有扩展装配全关:无外设、无 MCP、无技能、无 memory-strip),确保评测 Agent 看到的工具集与预期完全一致。随后逐轮调用agent.turn(&user_input),每轮结束强制finish_turn校验步数消费,最后聚合出RunRecord。
crate 的整体模块划分(见 crates/zeroclaw-eval/README.md 的 Library shape 一节):
case——LlmTrace夹具格式 + 套件加载;replay::TraceLlmProvider—— 按 FIFO 顺序回放轨迹步的ModelProvider;tools—— 回放 Agent 可分发的确定性内置工具;observer::RecordingObserver—— 捕获每次分发的RecordedCall(名称、参数、结果、成功)与 token 用量;grader—— 不 panic 的GradeResult检查;Gradertrait 是后续阶段副作用/预算/LLM 裁判类 grader 的扩展点;runner—— 每个用例构建隔离 Agent、驱动并打分;report—— 通过/失败聚合,表格 + JSON 渲染。
配置项:[eval]
EvalHarnessConfig(crates/zeroclaw-config/src/scattered_types.rs)提供两个字段,均可在配置文件的[eval]段覆盖:
suite_dir:省略--suite时使用的夹具目录,默认evals/regression(即 CI 门禁套件);规划中的evals/capability/与evals/live/并列其旁;mode:省略--mode时的执行模式,默认replay。
该配置类型注册在Config的eval字段上(crates/zeroclaw-config/src/schema.rs),支持zeroclaw config体系的序列化与 schema 导出。
在 CI 中的落地方式
一句话总结门禁链路:zeroclaw eval run的进程退出码是信号(0 全过、1 有失败);regression_suite.rs把它包装成 Rust 集成测试,确保evals/regression全绿才允许合并;套件目录与配置默认值之间由gated_suite_directory_matches_the_configured_default锁定不漂移。对使用者而言,本地开发时随时可以zeroclaw eval run --suite evals/regression --format json验证改动,或编写新的回归夹具并遵守上文的作者守则——只要遵循“至少一轮、至少一个非空断言、空运行必失败、只放占位身份”这几条铁律,你的用例就能安全进入被门禁的套件,成为 Agent 循环长期不回退的又一重保障。
【免费下载链接】zeroclawFast, small, and fully autonomous AI personal assistant infrastructure, any OS, any platform — deploy anywhere, swap anything 🦀项目地址: https://gitcode.com/gh_mirrors/ze/zeroclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考