ZeroClaw Eval Harness 实战指南:基于确定性回放的 Agent 循环回归测试体系
2026/9/19 1:46:18 网站建设 项目流程

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:ReplayLive两个变体,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 的namesourcepassederrorgrades数组,可直接作为 CI 工件归档。

退出码契约:CI 门禁的信号

zeroclaw eval run的进程退出码就是门禁信号:所有用例通过则退出0,否则(任一检查失败或运行出错)退出1。该决策被抽成纯函数SuiteReport::exit_code()(report.rs),使其能在真实边界被单元测试覆盖——report.rs 的测试直接验证了全通过返回 0、任一失败返回 1。regression_suite.rsregression_suite_replays_green就是把这个契约接进#[tokio::test]的实例:加载并运行回归套件后断言report.all_passed()exit_code() == 0

用例格式:LlmTrace 轨迹夹具

每个夹具是一个LlmTrace:包含model_name(报告里展示的用例名)、turns对话轮列表(每轮有user_input与脚本化回复steps),以及声明式expects。用例分为正向(行为必须发生)与负向(行为必须不发生,例如tools_not_usedresponse_not_containsmax_tool_calls: 0)。

LlmTraceTraceExpects的结构定义在 case.rs:顶层与嵌套结构都带#[serde(deny_unknown_fields)],拼写错误的键会直接导致解析失败而不是被静默丢弃——这是 fail-closed 的第一层。steps中每步responsetype标签区分为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)四元组存入RecordedCallRunRecord.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_containsresponse_not_containsresponse_matchestools_usedtools_not_used)含空字符串条目:空子串是重言式、空正则匹配一切、空工具名永远不会被记录,正向族会产生永真通过,tools_not_used则是退化断言;
  • 未知顶层键或未知期望键deny_unknown_fields):拼写错误的键会静默丢弃期望,必须显式失败;
  • 边界断言族的空值:空的toolneedle
  • 空泛/自相矛盾的计数边界min_tool_calls: 0(空泛下界)、min > maxexact落在[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.rsno_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_userexample.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.jsonUnicode 参数(naïve café 日本語 ✓)经分发边界字节级往返
missing_tool_argument_continues_loop.json参数键错误时工具仍被真实调用、返回(empty)兜底、循环继续

其中multi_turn_echo_tool_loop.jsonunicode_tool_arguments_roundtrip.json是“边界期望”的教科书示例:两者都同时声明exact_tool_callstool_arguments_containtool_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的临时内存(用例之间无法互相观察)、独立的RecordingObserverTraceLlmProvider,再通过引擎的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

该配置类型注册在Configeval字段上(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),仅供参考

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

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

立即咨询