第 7 章:编写可靠的确定性 Verifier
一个 Agent 写出了格式漂亮的 report.json,Verifier 返回 1。评审者随后发现,报告只猜中了“依赖故障”这个高频类别:根因事件错了,证据数组还是空的。另一个 Agent 得到 0,却正确找到了根因,只因测试把解释文本逐字与 Solution 比较。前者是假阳性,后者是假阴性;两者都会污染后续的 Agent 比较。
本章为第 4 章的“分析服务日志并生成结构化故障报告”Task 实现确定性 Verifier。我们不评价文风,也不规定 Agent 使用 Python、jq 还是其他工具;Verifier 只从可信日志推导公开契约规定的唯一终态,再比较 Agent 产物。随后用正确、错误和投机三类答案攻击它,并把攻击固化为回归测试。
读完本章,读者应当能够:
- 解释
tests/test.sh、pytest、Reward 文件和 Harbor 解析器之间的边界; - 编写验证结果而非固定实现步骤的确定性测试;
- 识别假阳性、假阴性与 Verifier gaming;
- 根据威胁模型选择 shared 或 separate Verifier,并理解两者都不自动信任 Agent 产物;
- 用日志和正反例回归测试验收 Verifier,而不把一次 Oracle 通过当成充分证据。
7.1 Verifier 是可执行的评分契约
确定性 Verifier 的输入不是 Agent 的自我陈述,而是 Trial 结束时的可观察状态;输出是由测试脚本写入的数值 Reward。它至少有三层职责:
- 任务契约层判断
/workspace/report.json是否符合公开 schema; - 语义层从可信输入独立推导根因、证据和影响集合,再与报告比较;
- Harbor 适配层把测试成败稳定地写成
/logs/verifier/reward.txt或/logs/verifier/reward.json。
不要把这三层揉成一句 grep dependency report.json。那既没有验证 JSON,也没有证明关键词来自正确根因。反过来,也不要比较整个文件的字节:JSON 的空白、对象键顺序以及不受评分的自然语言可以不同。第 4 章已经公开规定了证据数组和请求 ID 的规范排序,所以这些数组可以精确比较;incident_summary、explanation 与建议只检查公开的结构约束,不固定措辞。
可以把误判写成一个小矩阵:
| 实际结果 | Verifier 通过 | Verifier 拒绝 |
|---|---|---|
| 满足公开契约 | 真阳性 | 假阴性:测试了隐藏格式、固定措辞或特定命令 |
| 不满足公开契约 | 假阳性:只查文件存在、关键词或 Agent 自报成功 | 真阴性 |
可靠性目标不是“让 Oracle 得 1”,而是同时扩大真阳性和真阴性集合。Oracle 只覆盖一个合法答案;错误答案与投机答案才会暴露测试是否过宽。
还要区分候选答案不正确与测试根本没有观察到目标事实。例如,报告写到了错误目录,属于候选答案失败;Verifier 因相对路径从另一个工作目录读取了同名文件,则是测试缺陷。后者即使稳定返回 0,也不是“确定性”带来的可靠性。每条断言都应能回答三个问题:它读取的是哪一个信任域中的数据;该数据能否被 Agent 修改;失败究竟否定了 instruction 中的哪条公开要求。答不上来时,先画清输入边界,不要继续增加断言。
本任务采用全有或全无的 reward,因为第 4 章把结构、根因、证据并集和影响集合共同定义为完成状态。确定性 Verifier 也可以计算部分分数,但维度必须互不冒充:例如“JSON 可解析”不能被包装成“诊断正确”,证据覆盖率也不能掩盖错误根因。若以后增加分维度整数或浮点数,应为每个键写清分母、边界和聚合用途;本章不提前引入那套评分设计。
7.1.1 先写不变量,再写断言
本任务要验证以下结果不变量:
- 报告是单个 JSON 对象,各层键集合、类型、枚举和数量满足第 4 章公开契约;
- 根因事件、组件和类别等于从权威日志推导的结果;
evidence恰好是全部目标因果路径的规范化并集;affected_request_ids恰好是最终失败请求的排序去重集合;- Agent 可见输入与权威副本逐字节一致。
以下内容不应成为断言:Agent 调用了哪个命令、扫描了多少行、是否写出与 Solution 一样的解释、JSON 采用几格缩进。它们不是完成状态。高效的一条 jq 管道与多轮 Python 分析,只要产生同一合法结果,都应获得相同 Reward。
7.2 Harbor v0.18.0 如何运行测试并读取 Reward
单步 Linux Task 的入口是 tests/test.sh。在默认 Verifier 中,Harbor 先把测试目录上传到 /tests,为 Shell 入口增加执行权限,再从环境工作目录执行它;执行命令把 stdout 和 stderr 一起重定向到 /logs/verifier/test-stdout.txt。也就是说,TrialPaths 虽然定义了 test-stderr.txt,v0.18.0 的这条默认路径实际使用 2>&1 合并输出,不会自动生成一份独立 stderr 日志。1
注意:
test.sh的退出码不会自动变成 Reward。脚本必须在每条成功、失败路径上覆盖写入 Reward 文件。pytest 只负责给出进程退出状态,Shell 适配层负责把状态转换成分数。
v0.18.0 接受两种文件:reward.txt 是可转成浮点数的单个文本值,读取后成为 {"reward": value};reward.json 是键到整数或浮点数的 JSON 对象。两者同时存在时,Harbor 优先读取 reward.json,然后才回退到 reward.txt;文件缺失、为空或无法解析都会导致 Verifier 异常。2 因而不要本轮写 reward.txt、下一轮遗留旧 reward.json。更稳妥的做法是固定一种格式、启动时写失败默认值、结束时原子覆盖,并清理另一种格式。
Harbor v0.18.0 初始化器提供的 pytest 模板与固定提交的官方 Cookbook simple-task 都采用相同桥接思路:运行固定版本 pytest,再按退出码写 0 或 1。3 本章使用 reward.json,既演示准确文件名,也为以后增加独立维度保留格式;这里只产生一个确定性的 reward,不展开 Reward Kit 或模型裁判。
#!/bin/bash
# tests/test.sh
set -uo pipefail
umask 077
readonly LOG_DIR=/logs/verifier
readonly REWARD="$LOG_DIR/reward.json"
mkdir -p "$LOG_DIR"
# 覆盖 Agent 可能预写的值;即使后面的 pytest 意外中断,默认也是 0。
if ! printf '{"reward":0}\n' > "$REWARD"; then
echo "cannot initialize reward file" >&2
exit 1
fi
rm -f "$LOG_DIR/reward.txt"
score=0
if env -u VERIFIER_TEST_ROOT \
/usr/local/bin/python -m pytest -q -rA /tests/test_report.py; then
score=1
fi
tmp="$LOG_DIR/.reward.json.$$"
printf '{"reward":%s}\n' "$score" > "$tmp" && mv -f "$tmp" "$REWARD"
这里没有 set -e:pytest 返回非零正是预期分支,脚本仍需写 0。VERIFIER_TEST_ROOT 只是下面单元测试的路径接缝,正式执行显式清除它。脚本用绝对解释器路径,减少 PATH 遮蔽;但在 shared 模式中,Agent 若拥有足够权限,仍可能改写解释器或留下竞态进程,这正是隔离模式要处理的问题。
若只需一维分数,可以把最后一段替换为 printf '%s\n' "$score" > /logs/verifier/reward.txt。不要写成 rewards.json:v0.18.0 读取的是单数 reward.json。该版本通用 Shell 模板的注释曾出现复数拼写,而路径模型、文档和实现均使用单数,应以实现为准。2
7.3 为贯穿项目实现结果验证
本章选择 separate 模式作为发布配置。原因不是测试更“高级”,而是本任务的 Agent 会写工作区文件:让权威日志副本、pytest 代码和解释器位于独立镜像,能缩小共享状态攻击面。Task 根目录增加以下内容:
tests/
├── Dockerfile
├── test.sh
├── test_report.py
└── fixtures/
└── input/
├── api.log
└── worker.log
tests/fixtures/input/ 是 Agent 可见日志的权威副本。它不是一个手写的最终答案;Verifier 仍按 instruction 的公开算法推导 Gold。构建流水线应从同一生成源产出环境日志和该副本,并在 Task 发布前比较摘要,避免两份 fixture 漂移。
在 task.toml 顶层声明要转移的 Agent 产物,并设置独立环境:
artifacts = ["/workspace/report.json", "/workspace/input"]
[verifier]
timeout_sec = 120.0
environment_mode = "separate"
[verifier.environment]
network_mode = "no-network"
在 v0.18.0 中,environment_mode 省略且没有独立环境时默认为 shared;存在 [verifier.environment] 会推断为 separate;显式 shared 与独立环境同时出现是配置错误。separate 但未给独立环境时,会深拷贝顶层环境配置。4
独立 Verifier 镜像以 tests/ 为构建上下文,必须自行提供 /tests/test.sh;Harbor 不会在运行时再次上传测试。下面是可构建的教学版本:
FROM python:3.13.5-slim-bookworm
RUN python -m pip install --no-cache-dir pytest==8.4.1
COPY test.sh test_report.py /tests/
COPY fixtures/input/ /oracle-input/
RUN chmod 0555 /tests/test.sh /tests/test_report.py
注意:镜像标签与在线安装并非最终发布锁。生产 Dataset 应把基础镜像固定到经过验证的 digest,并从带哈希的内部 wheel 仓库或随构建上下文提供依赖;本章未执行 Docker 构建,因此不把这段写成镜像实测结果。
下面的 test_report.py 是核心。它不导入、不执行 Agent 文件;先比较输入副本,再对报告做拒绝重复键的 JSON 解析,最后独立推导目标失败、共同根因、证据并集与影响集合。
import json
import os
import re
from collections import defaultdict
from datetime import date
from decimal import Decimal
from pathlib import Path
ROOT = Path(os.environ.get("VERIFIER_TEST_ROOT", "/"))
VISIBLE = ROOT / "workspace/input"
TRUSTED = ROOT / "oracle-input"
REPORT = ROOT / "workspace/report.json"
CATEGORIES = {
"network": {"dns_failure", "route_failure", "connection_failure",
"connection_reset", "tls_transport_failure"},
"resource": {"cpu_exhausted", "memory_exhausted", "disk_exhausted",
"file_descriptor_exhausted", "thread_exhausted",
"connection_pool_exhausted", "quota_exhausted"},
"configuration": {"missing_setting", "invalid_setting",
"deployment_misconfiguration"},
"dependency": {"dependency_unavailable", "dependency_error",
"invalid_upstream_response"},
"application": {"unhandled_exception", "code_defect",
"data_processing_error"},
}
RFC3339_UTC = re.compile(
r"(?P<date>\d{4}-\d{2}-\d{2})T"
r"(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})"
r"(?:\.(?P<fraction>\d+))?Z"
)
OUTCOMES = {"success", "failure"}
FAILURE_CODES = set().union(*CATEGORIES.values())
def strict_object(pairs):
out = {}
for key, value in pairs:
if key in out:
raise ValueError(f"duplicate key: {key}")
out[key] = value
return out
def strict_loads(text):
return json.loads(
text,
object_pairs_hook=strict_object,
parse_constant=lambda value: (_ for _ in ()).throw(
ValueError(f"invalid JSON constant: {value}")
),
)
def load_json(path):
return strict_loads(path.read_text(encoding="utf-8"))
def timestamp_key(value):
assert type(value) is str, "timestamp must be a string"
assert value.endswith("Z"), "timestamp must end with Z"
match = RFC3339_UTC.fullmatch(value)
assert match is not None, f"invalid timestamp syntax: {value!r}"
try:
day = date.fromisoformat(match["date"])
except ValueError as exc:
raise AssertionError(f"invalid calendar date: {value!r}") from exc
hour = int(match["hour"])
minute = int(match["minute"])
second = int(match["second"])
assert 0 <= hour <= 23, f"invalid hour: {hour}"
assert 0 <= minute <= 59, f"invalid minute: {minute}"
assert 0 <= second <= 59, f"invalid second: {second}"
digits = match["fraction"]
fraction = Decimal(f"0.{digits}") if digits else Decimal(0)
return day.toordinal(), hour, minute, second, fraction
def event_order_key(event):
event_id = event.get("event_id")
assert type(event_id) is str, "event_id must be a string"
return timestamp_key(event.get("timestamp")), event_id
def load_events(directory):
events = {}
for path in sorted(directory.glob("*.log")):
for line in path.read_text(encoding="utf-8").splitlines():
event = strict_loads(line)
assert type(event) is dict, "each log line must be an object"
event_id = event.get("event_id")
assert type(event_id) is str and event_id, \
"event_id must be a non-empty string"
assert event_id not in events, f"duplicate event_id: {event_id}"
event["_file"] = path.name
events[event_id] = event
return events
def select_targets(events):
completed = defaultdict(list)
for event in events.values():
if event.get("event_type") == "request.completed":
completed[event["request_id"]].append(event)
targets = []
for request_events in completed.values():
final = max(request_events, key=event_order_key)
if final["outcome"] == "failure":
targets.append(final)
return targets
def causal_path(events, target):
path, current = [], target
while True:
path.append(current)
parent_id = current.get("caused_by_event_id")
if parent_id is None:
return path
current = events[parent_id]
def validate_fixture(events):
assert events, "trusted fixture has no events"
request_fields = {"request_id", "event_type", "outcome"}
for event_id, event in events.items():
assert type(event) is dict, f"event {event_id} must be an object"
for field in ("timestamp", "event_id", "component", "level", "message"):
assert type(event.get(field)) is str, \
f"{event_id}.{field} must be a string"
assert event["event_id"] == event_id, \
f"event_id index mismatch: {event_id}"
assert event_id and event["component"] and event["level"], \
f"{event_id}: event_id, component, and level must be non-empty"
assert type(event.get("_file")) is str and event["_file"], \
f"{event_id} has no source file"
event_order_key(event)
level = event["level"]
failure_code = event.get("failure_code")
if level in {"ERROR", "FATAL"} or failure_code is not None:
assert type(failure_code) is str \
and failure_code in FAILURE_CODES, \
f"invalid failure_code for {event_id}: {failure_code}"
present_request_fields = request_fields & event.keys()
if present_request_fields:
assert present_request_fields == request_fields, \
f"incomplete request fields for {event_id}"
assert type(event["request_id"]) is str and event["request_id"], \
f"invalid request_id for {event_id}"
assert type(event["event_type"]) is str and event["event_type"], \
f"invalid event_type for {event_id}"
assert event["outcome"] in OUTCOMES, \
f"invalid outcome for {event_id}: {event['outcome']}"
parent_id = event.get("caused_by_event_id")
if parent_id is not None:
assert type(parent_id) is str and parent_id, \
f"invalid parent for {event_id}"
assert parent_id in events, f"missing parent: {parent_id}"
state = {}
for start_id in events:
current_id, chain = start_id, []
while current_id is not None and state.get(current_id, 0) == 0:
state[current_id] = 1
chain.append(current_id)
current_id = events[current_id].get("caused_by_event_id")
if current_id is not None:
assert state.get(current_id) != 1, \
f"cycle in trusted fixture at {current_id}"
for event_id in chain:
state[event_id] = 2
targets = select_targets(events)
assert targets, "trusted fixture has no target failures"
target_ids = {event["event_id"] for event in targets}
paths = [causal_path(events, target) for target in targets]
for target, path in zip(targets, paths):
assert target.get("caused_by_event_id") is not None, \
"target without parent in trusted fixture"
ancestor_ids = {event["event_id"] for event in path[1:]}
assert not (ancestor_ids & target_ids), \
"one target is an ancestor of another"
root_ids = {path[-1]["event_id"] for path in paths}
assert len(root_ids) == 1, f"multiple roots: {root_ids}"
root = paths[0][-1]
assert root["level"] in {"ERROR", "FATAL"}
path_ids = {event["event_id"] for path in paths for event in path}
assert len({events[event_id]["_file"] for event_id in path_ids}) >= 2, \
"trusted paths do not cross files"
def derive_expected(events):
targets = select_targets(events)
paths = [causal_path(events, target) for target in targets]
target_ids = {event["event_id"] for event in targets}
path_ids = {event["event_id"] for path in paths for event in path}
root = paths[0][-1]
category = next(name for name, codes in CATEGORIES.items()
if root["failure_code"] in codes)
evidence = []
for event_id in sorted(
path_ids,
key=lambda event_id: event_order_key(events[event_id]),
):
event = events[event_id]
role = ("root" if event_id == root["event_id"]
else "target-failure" if event_id in target_ids
else "causal-link")
evidence.append({"file": event["_file"],
"event_id": event_id, "role": role})
return {
"root": {"event_id": root["event_id"],
"component": root["component"], "category": category},
"evidence": evidence,
"affected": sorted({event["request_id"] for event in targets}),
}
def assert_exact_keys(value, expected, label):
assert type(value) is dict, f"{label} must be an object"
assert set(value) == set(expected), f"{label} keys: {set(value)}"
def assert_nonempty_string(value, label):
assert type(value) is str and value.strip(), \
f"{label} must be a non-empty string"
def test_report_contract_and_result():
trusted_files = {p.name: p.read_bytes()
for p in sorted(TRUSTED.glob("*.log"))}
visible_files = {p.name: p.read_bytes()
for p in sorted(VISIBLE.glob("*.log"))}
assert visible_files == trusted_files, \
"input logs were added, removed, or changed"
report = load_json(REPORT)
assert_exact_keys(
report,
{"schema_version", "incident_summary", "root_cause",
"evidence", "impact", "recommended_actions"},
"report",
)
assert report["schema_version"] == "1.0"
assert_nonempty_string(report["incident_summary"], "incident_summary")
root = report["root_cause"]
assert_exact_keys(root,
{"event_id", "component", "category", "explanation"},
"root_cause")
assert_nonempty_string(root["explanation"], "root_cause.explanation")
events = load_events(TRUSTED)
validate_fixture(events)
expected = derive_expected(events)
observed_root = {key: root[key]
for key in ("event_id", "component", "category")}
assert observed_root == expected["root"]
assert type(report["evidence"]) is list
for index, item in enumerate(report["evidence"]):
assert_exact_keys(item, {"file", "event_id", "role"},
f"evidence[{index}]")
assert report["evidence"] == expected["evidence"]
impact = report["impact"]
assert_exact_keys(impact, {"affected_request_ids"}, "impact")
assert impact["affected_request_ids"] == expected["affected"]
actions = report["recommended_actions"]
assert type(actions) is list and 1 <= len(actions) <= 3
for index, action in enumerate(actions):
assert_nonempty_string(action, f"recommended_actions[{index}]")
这段代码精确比较规范化证据数组,却没有比较 JSON 对象键顺序、空白和自然语言内容。它也不把 Agent 可见的 /workspace/input 当作 Gold:报告与输入可以同时被篡改而保持“自洽”,所以期望值只从镜像内 /oracle-input 推导,同时验证可见输入未变。validate_fixture 在计算 Gold 前遍历完整事件集,检查公开必需字段、类型和枚举,验证每个父引用,并对包括无关干扰项在内的完整父指针图查环;derive_expected 只做确定性计算。这样,目标路径外的缺父、环或非法终态不会被静默忽略。
时间不能直接按原字符串比较。...00.5Z 在字典序中位于 ...00Z 之前,时间上却更晚;这会同时选错重试终态并排错证据。RFC 3339 的小数秒允许一位或多位数字,所以也不能把它无声压缩到微秒精度。5 timestamp_key 先用正则把本 Task 输入限定为第 4 章公开的 YYYY-MM-DDTHH:MM:SS[.fraction]Z 子集,再校验日历日期、时、分、秒;秒只允许 00~59,不接受 leap second。末尾 Z 已把偏移固定为 UTC,小数用任意精度 Decimal 保存。event_order_key 在终态 max 和证据排序两处复用同一个 (UTC 时间分量, event_id) 键,因此 .123456788Z 与 .123456789Z 仍能严格排序。basic date、逗号小数、非 Z、naive、second = 60 和非法值都会在读取权威 fixture 时被拒绝;这是 Task 的更窄子集,不是通用 RFC 3339 解析器。
7.4 用三类答案攻击 Verifier
只运行“正确答案”会制造虚假的安全感。最小攻击集至少包含:
| 类别 | 构造 | 期望 |
|---|---|---|
| 正确 | 根因、完整证据并集、最终失败请求都正确;摘要措辞与 Solution 不同 | 通过 |
| 错误 | event_id 与组件正确,但把 dependency_unavailable 错分为 network | 拒绝,并定位到根因三元组 |
| 投机(协议) | 在报告中加入 "reward": 1,并同时留空 evidence | 先因未知顶层键被拒绝 |
| 投机(语义) | 只使用全部合法 schema 键,但把 evidence 设为 [] | 在证据精确比较处被拒绝 |
作者在临时工作区放置同一组两文件 JSONL 日志,并对三份报告分别运行下面的定向命令。VERIFIER_TEST_ROOT 只把绝对路径映射到各临时 case;正式 test.sh 会清除它。
VERIFIER_TEST_ROOT=/private/tmp/harbor-ch7-verify/run-correct \
uvx --from pytest==8.4.1 pytest -q test_report.py
VERIFIER_TEST_ROOT=/private/tmp/harbor-ch7-verify/run-wrong \
uvx --from pytest==8.4.1 pytest -q test_report.py
VERIFIER_TEST_ROOT=/private/tmp/harbor-ch7-verify/run-gaming \
uvx --from pytest==8.4.1 pytest -q test_report.py
VERIFIER_TEST_ROOT=/private/tmp/harbor-ch7-verify/run-gaming-empty-evidence \
uvx --from pytest==8.4.1 pytest -q test_report.py
2026-07-16 的实际结果依次是 1 passed、1 failed、1 failed、1 failed。错误答案失败于 category: network != dependency;协议投机答案先失败于顶层多出 reward;独立的合法 schema 投机答案确实执行到证据精确比较,并因期望五项而观察到空数组而失败。这是定向测试,不是 Harbor Trial:本机没有 docker 命令,因此没有构建镜像,也没有伪造 Trial Reward。
另一个专用回归把同一请求的失败终态放在 10:00:00Z,成功重试放在 10:00:00.5Z,并保留第二个确实失败的请求;期望影响集合只含第二个请求。它还把根、因果链和目标分别放在 00Z、00.25Z、00.75Z,断言证据按这个时间顺序排列。修复前,该用例稳定把已成功请求误列为失败。时间与排序回归拒绝 basic date、逗号小数、非法日历与越界时分秒,并断言 .123456788Z < .123456789Z,共 13 项通过。另一个作者侧契约回归明确拒绝 ...:60Z、JSONL 中的 NaN/Infinity/-Infinity、目标路径外缺父和成环、非法 outcome,并接受一份有效完整图,共 8 项通过。
攻击还应扩展为回归表,而不是临时手改一个文件:缺失报告、非法 UTF-8、截断 JSON、重复键、未知键、空字符串、错误 schema 版本、证据遗漏/多报/重复/乱序、重试后成功仍计入影响、日志被增删改,以及预先写入 Reward。每发现一次真实误判,就增加一个最小 case;以后修改 Verifier,先跑这组回归。
7.4.1 把 gaming 当成威胁模型
Verifier gaming 指 Agent 获得高分的最便宜路径绕开了目标能力。攻击测试不需要猜测某个模型“会不会作弊”;只要存在比读取、关联日志更稳定的路径,Reward 就失去解释力。可按数据流逐段审计:
- 输入侧:Agent 是否能删除困难日志、补写一条自证根因的事件,再让 Verifier 从被改写的输入推导 Gold?本章用镜像内副本和逐字节比较封死这条自洽攻击。
- 产物侧:Verifier 是否接受报告中的
passed、score或reward字段?这些都是 Agent 自报信号,本章用精确键集合拒绝。 - 执行侧:评分脚本是否从工作区导入辅助模块、执行 Agent 生成的 Shell,或使用可被遮蔽的解释器?若是,Agent 控制的是评分程序,而不只是评分输入。
- 协议侧:脚本是否只在 Reward 不存在时写值,或者同时留下两个格式?预写文件与 JSON 优先级都可能旁路真实测试结果。
- 时间侧:shared 环境中的后台进程能否等到
/tests出现后再篡改文件?这类竞态不能靠“测试上传得晚”排除。
攻击用例应尽量小。若“额外 reward 键 + 空证据 + 关键词摘要”被拒绝,下一步要把三种手法拆开测试,否则第一次 schema 失败会遮住后面的空证据漏洞。回归集的目标是定位一道防线,而不是构造一份同时触发十条断言、却无法说明哪条真正有效的怪异报告。
7.4.2 失败反例:看似严格的整文件比较
下面的断言看似最确定:
assert Path("/workspace/report.json").read_bytes() == \
Path("/tests/expected.json").read_bytes()
它会拒绝对象键顺序不同、缩进不同、摘要措辞不同但语义正确的报告,是典型假阴性。换成 json.loads 后直接比较整个对象仍不够,因为不受确定性评分的解释文本依旧被固定。正确做法是把输出拆为:严格 schema、可独立推导的确定字段、只验证结构的开放文本。若某个字段无法可靠判断,就不要假装它是本章的确定性能力;可以删除评分,或留到后续独立维度。
另一个反例只检查 Path.exists() 与关键词。它让错误类别、空证据和错误影响集合逃逸,是假阳性。测试数量多也不能弥补断言对象错了:十个文件存在性检查仍然没有测因果诊断。
7.5 shared 与 separate 的可见性和生命周期
两种模式使用同一 Reward 协议,却有不同信任边界。v0.18.0 单步生命周期可概括为:
| 阶段 | shared | separate |
|---|---|---|
| Agent 结束后 | 上传日志;main 仍运行时收集 main Artifact;若有 sidecar,也在 main 仍运行时采集 | 上传日志;main 仍运行时收集 main Artifact;若有 sidecar,则停止 main 后采集 sidecar;随后停止剩余 Agent 环境 |
| 测试来源 | 随后把宿主 tests/ 上传到同一 Agent 环境 | 测试已构建在独立镜像,运行时不上传 |
| 可见状态 | 原工作目录、Agent 安装的工具和残留进程仍在 | 只恢复 /logs/artifacts/ 与显式声明的 Artifact 到原绝对路径 |
| 结束时 | Verifier 完成后才停止 Agent 环境 | 独立 Verifier 环境完成后停止 |
这些顺序由 SingleStepTrial、分阶段 Artifact 采集和独立环境执行链明确实现:main collect hook 与 main Artifact 下载发生在 main 仍运行时;只有存在 sidecar 且 separate 要求 stop_main_before_sidecars 时,才先停 main 再采集 sidecar。之后 separate 模式在评分前停止剩余 Agent 环境、清空独立环境的 verifier 日志目录、上传 Artifact,再执行镜像内测试;shared 模式则直接在 Agent 环境创建 Verifier。6
shared 的优势是启动快,测试能直接观察服务进程与工作目录;代价是 Agent 已经控制过同一环境。即使 Agent 主调用时看不到随后上传的 /tests,它仍可留下后台进程,预建路径或符号链接,遮蔽 PATH 中的解释器,修改可执行文件,或与测试竞争 Reward 写入。始终覆写 Reward 是必要条件,却不能消除这些竞态。
separate 的优势是权威测试、依赖和解释器不在 Agent 环境,持续进程在评分前随 Agent 环境停止;代价是工作目录不会自动出现,必须显式声明 Artifact。它不能使 main Artifact 快照脱离 Agent 影响:采集发生时 main 仍可能写入文件,collect hook 也运行在该环境。/logs/artifacts/ 和配置 Artifact 会以原绝对路径恢复,但它们仍是敌对输入:Verifier 应只解析数据,不执行脚本、不导入 Python 模块,也不相信 Artifact 自带的“校验和”。7
警告:若 separate Verifier 需要服务的内存状态或 sidecar 数据,应先用 collect/Artifact 固化证据。不要因为独立环境中看不到状态,就悄悄删掉相关断言;那会把隔离改进变成评分盲区。
本任务输出是静态 JSON 和日志,天然适合 separate。需要验证“Agent 修复后的 HTTP 服务仍在监听”时,shared 可能更直接;另一种设计是把可重放配置和证据转移到 separate 环境,再由可信测试启动服务。选择取决于要观察的结果,不是固定教条。
模式切换不是一个只改 TOML 的机械动作。把原来在 shared 环境中读取 /workspace/report.json 的测试迁到 separate 后,若忘记声明 Artifact,测试会因为文件缺失而把全部 Agent 判为失败;为了“修好”它而删除文件断言,又会造成假阳性。正确迁移顺序是先列出每项观察所需的文件或状态,确认它在 Agent 结束时已经固化,再声明最小 Artifact 集,并在独立环境中验证恢复路径。不要转移整个根文件系统:可见面越大,攻击面、传输成本和偶然依赖越大。
Artifact 成功上传只证明字节到达,不证明字节可信。本章允许报告和 Agent 可见输入进入独立环境,是因为两者都按敌对数据解析;权威副本、类别映射和测试代码不通过同一通道传入。这个不对称正是 separate 模式的价值:隔离的不是“正确文件”和“错误文件”,而是评分依据与待评分材料。
7.6 日志、单元测试与验收
pytest 的断言消息应同时回答“哪里错”和“观察到了什么”,但不要把隐藏 Gold 全部打印到公开日志。test-stdout.txt 是第一诊断入口;使用 -rA 可以保留各测试结果。v0.18.0 对非挂载环境会把 /logs/verifier/ 下载到 Trial 目录;即使配置 include/exclude 日志过滤,reward.txt 与 reward.json 仍被保护,不会因过滤规则漏掉。8
把失败分成两类:
- 候选答案失败:报告缺失、schema 不符、根因或证据不对,Reward 为 0;
- Verifier/fixture 失败:权威日志重复 ID、有环、缺父事件、类别映射缺失,说明 Task 作者的前提坏了。运行时同样可能得到 0,但日志会显示
trusted fixture或因果不变量错误;发布流水线必须在没有 Agent 的情况下先阻止这类 Task 进入 Dataset。
断言消息应稳定而克制。打印 observed category=network 有助于定位错误;把完整 Gold 报告、所有隐藏事件或测试源码复制进 Agent 可访问通道则会扩大泄露面。开发阶段可以在受控 CI 日志中输出更详细的差异,发布版只保留字段路径、观察值和错误类别。日志本身也要进入版本化审查:一条新调试输出可能不会改变 Reward,却可能让后续 Trial 获得答案。
Flaky Verifier 不一定来自随机数。网络安装、当前时间、区域设置、文件系统遍历顺序、未固定依赖和服务尚未就绪,都能让同一终态得到不同结果。本章通过离线运行、排序输入文件与事件、固定 pytest 版本和纯数据比较消除主要波动;基础镜像 digest 与离线 wheel 仍是发布前待完成项。对相同 fixture 和报告连续运行多次只能发现波动,不能证明没有系统性误判,所以还必须保留正反例集合。
因此需要两层测试。单元层直接测试 strict_object、timestamp_key、validate_fixture、derive_expected 与 schema 辅助函数,覆盖重复键、非标准 JSON 常量、混合小数精度、leap second、重试终态,以及完整图中目标路径外的环和缺父引用;端到端回归层把完整 fixture 与正确、错误及两个投机报告交给 test_report_contract_and_result。Harbor 自身的相关单元测试也应保留为升级门禁:本章在 v0.18.0 源码上定向运行 Verifier、配置校验和 separate 生命周期测试,共 23 项通过;这证明我们引用的框架行为与固定版本测试一致,但不替代 Task 自身攻击集。
最终验收按以下顺序进行:
- 检查 instruction 与断言逐项对应,不存在隐藏字段、排序或措辞;
- 在干净临时目录运行 Verifier 单元测试与正确、错误、协议投机、语义投机回归;
- 检查
test.sh在 pytest 成功、失败和意外中断路径都会覆盖写 Reward,并且只使用单数文件名; - 审计 shared/separate 全生命周期、Artifact 列表、解释器来源、后台进程和 Reward 写权限;
- Docker 可用时再分别运行 Oracle、错误 Solution 和投机脚本的真实 Trial,检查
test-stdout.txt、Reward 与 Artifact; - 把新发现的任何假阳性或假阴性缩减成永久回归 case。
Harbor v0.18.0 的 Adapter 评审清单同样要求:Agent 在整个 Trial 生命周期中既不能读取 Ground Truth,也不能直接或间接影响权威通过信号;test.sh 每条路径都要重写 Reward,而不能只在文件缺失时补写。9 这比“Agent 运行时没看见 tests”更接近真正的验收标准。
7.7 本章小结
- 确定性 Verifier 应从可信输入独立推导终态,验证结果而非命令序列。
test.sh把 pytest 状态稳定映射到 Reward;v0.18.0 使用单数reward.json,且它优先于reward.txt。- 默认 Verifier 把 stderr 合并进
test-stdout.txt,排错时不要等待自动生成的独立 stderr 文件。 - 正确、错误和投机答案必须同时进入回归;Oracle 通过只证明一个真阳性。
- shared 保留 Agent 环境状态,separate 缩小攻击面但仍须把转移 Artifact 当作敌对数据。
- 严格 schema 与开放实现并不冲突:严格检查公开结果,放行未纳入评分的序列化和措辞差异。
7.8 练习
- 为
derive_expected编写四个单元测试:重试后成功、因果环、缺失父事件和两个不同根。区分哪一项是 Agent 失败,哪一项必须阻止 Task 发布。 - 构造一个报告:根因正确,但证据多出一个无关
ERROR。确认现有 Verifier 拒绝它;再解释为何“至少包含正确证据”的断言会造成假阳性。 - 把
test.sh改为reward.txt,验证 Harbor 解析结果仍是{"reward": 0.0}或{"reward": 1.0};然后故意同时留下旧reward.json,观察优先级并修复脚本。 - 针对 shared 模式设计三个 gaming 脚本:预写 Reward、留下后台进程、遮蔽解释器。说明哪些只能靠脚本覆写缓解,哪些需要 separate 或更严格的权限边界。
- 为本章回归表增加一个“语义正确但 JSON 键顺序和解释措辞不同”的答案,证明 Verifier 不会误拒绝多种合法实现。