第 11 章:使用 Reward Kit 构建多维评分
一个 Agent 生成了结构完整的 report.json:根因类别正确,受影响请求也正确,但证据数组是空的。若 Verifier 只给一个 0,开发者知道任务失败,却不知道问题出在输出契约、诊断还是证据;若把“根因正确”折算成 0.8,又可能让缺少证据的报告跨过通过门槛。单一数字既难排错,也容易把不同能力互相补偿。
本章继续使用“系统配置与故障诊断 Agent Benchmark”的日志诊断 Task。第 7 章已经建立严格的确定性判断,本章不放松契约,而是把它拆成 contract、diagnosis 和 evidence 三个可观察维度,再定义一个严格主 Reward 和一个只用于诊断的软分数。示例只使用确定性 criterion;模型 criterion 留到第 12 章。
读完本章,读者应当能够:
- 用内置 criterion 和
@criterion编写可复用的确定性检查; - 解释目录、criterion、维度、主 Reward 和 Dataset Metric 的边界;
- 为权重与聚合写出可审计的语义,而不是凭偏好调参;
- 区分 Harbor 的 separate Verifier 与 Reward Kit 的 criterion 隔离;
- 使用
reward-details.json定位失败,并把异常与得 0 分开处理。
11.1 多维 Reward 解决什么问题
Harbor v0.18.0 的固定提交中随附独立 Python 包 harbor-rewardkit 0.1.7;本章所说的 Reward Kit API 都指这个组合,不是 PyPI 上未来版本,也不是 Harbor main。1 它没有替代 Harbor Verifier。Harbor 仍负责把 tests/ 放进评分环境、执行 test.sh、保存日志并解析 reward.json;Reward Kit 是 test.sh 内部用来发现和执行 criterion、生成 Reward 文件的评分程序。2
这两层解决的问题不同:
| 层次 | 输入 | 输出 | 本章职责 |
|---|---|---|---|
| criterion | 工作区中的一个可观察事实 | bool、int 或 float | 判断 schema、根因、证据等单项事实 |
| Reward 维度 | 同一目录中的若干 criterion | 一个维度分数 | 把相关检查组合为 contract、diagnosis、evidence |
reward.toml 聚合 | 多个维度 | reward、soft_score 等附加键 | 定义通过门槛和诊断分数 |
| Harbor Verifier | /logs/verifier/reward.json | VerifierResult.rewards | 将完整 Reward 字典写入 Trial 结果 |
| Dataset Metric | 多个 Trial 的 Reward 字典 | Job/Dataset 聚合指标 | 跨 Trial 汇总,不反向改变单个 Trial |
多维评分的价值首先是可诊断性,不是“给差答案多一点分”。例如 evidence=0.5 明确表示证据维度只通过一部分检查;主 reward=0 仍能坚持“证据完整才算完成”。这也延续第 10 章的实验纪律:各维度先分别报告,权重不能在看到系统排名后再修改。
是否值得拆维度,可以用一个简单问题判断:低分出现时,团队会不会采取不同修复动作?contract 低说明输出协议或解析失败,应先修 Prompt、schema 或写入路径;diagnosis 低说明 Agent 没有正确建立因果关系,应查看输入理解与推理轨迹;evidence 低说明结论缺少可复核依据,应检查证据收集和覆盖。如果两个所谓维度总是由同一断言决定、失败后也采取同一动作,拆分只会制造更多数字。反过来,若一个维度混合了三种独立故障,先拆 criterion,再考虑是否需要新的维度。
Reward Kit 也不等于“宽松 Verifier”。第 7 章的 pytest 可以一次性拒绝错误报告;本章把相同的不变量组织成可复用函数和结构化明细。适合 Reward Kit 的场景是检查模式跨 Task 重复、需要多维诊断或需要并行执行;若 Task 只有一个短小且已经过攻击测试的确定性断言,保留普通 Verifier 往往更易审计。迁移的验收标准不是代码行变少,而是正确、错误和投机答案的判定与旧 Verifier 一致,并且失败信息更清楚。
11.2 从目录到分数的执行模型
Reward Kit 扫描 tests/ 时,若存在非隐藏子目录,每个子目录名成为一个 Reward 维度;根目录 .py 文件先导入,以便用 shared=True 注册跨目录的自定义 criterion。子目录里的 .py 文件注册程序化 criterion,包含 [judge] 与 [[criterion]] 的 TOML 则建立 Judge Reward。没有子目录的扁平布局使用默认维度名 reward。3
程序化 criterion 的第一个参数必须是 workspace: Path。无额外参数且非 shared 的函数会自动注册;带参数的函数必须经 rewardkit 模块调用,调用时可以设置 weight、name 与 isolated。运行时 bool 归一为 0 或 1,数值原样转为 float;其他返回类型会引发异常。4
同一程序化 Reward 内默认采用加权均值:
[ D=\frac{\sum_i w_i s_i}{\sum_i w_i} ]
其中 s_i 是 criterion 分数,w_i 是调用时的 weight。总权重为 0 时结果为 0。程序化 criterion 并不能在目录级改成 all_pass;[scoring] 的聚合设置属于 Judge TOML。根级 reward.toml 可以再添加 weighted_mean、all_pass、any_pass 或 threshold 聚合键。实现对 all_pass 和 any_pass 使用 value > 0 判断“通过”,threshold 则先求加权均值,再与门槛作 >= 比较。5
这里有一个容易误读的权重边界。锁定提交的文档写着“维度按其 criterion 权重之和加权”,但实际 _collapse_rewards() 使用的是同名 Reward 的 reward_weight 之和;程序化目录创建的 Reward 其 reward_weight 固定为 1,Judge 的 [judge].weight 才会改变这一层权重。6 因此,把某目录内一个 criterion 从权重 1 改为 100,只会改变该维度内部组成,不会让该维度在根级 soft_score 中自动变成其他维度的 100 倍。本书以固定提交实现为准,并把三个程序化维度设计为等权。
注意:
all_pass不是“所有分数都等于 1”。[1.0, 0.01]会得到 1。若维度含部分分数,却要求完全满足才能通过,应使用经过边界测试的threshold,或把门禁拆成明确的二元 criterion。
四种根级聚合并非可以互换的显示选项:
| 模式 | 固定提交的判定 | 适合的契约 | 主要风险 |
|---|---|---|---|
weighted_mean | 返回维度加权均值 | 诊断性连续分数 | 高分维度会补偿失败维度 |
all_pass | 每个维度都 > 0 | 所有输入严格二元时的合取 | 部分正分也被视为通过 |
any_pass | 至少一个维度 > 0 | 多种互斥合法完成路径 | 常被误用为“完成任一小项即可” |
threshold | 加权均值 >= threshold | 有明确、已测试门槛的决策 | 权重、舍入和越界值会改变边界 |
先写逻辑契约,再选模式。若“根因、证据、结构缺一不可”,最直接的表达是三个二元门禁的合取;由于本例的维度内部会产生部分分数,我们用 threshold=1.0,并额外约束所有输入都在 0..1。若业务允许多个互斥方案完成任务,any_pass 应作用于“完整方案”而不是方案内的零散步骤。weighted_mean 则更适合排序和诊断,不应在没有失败成本模型时自动变成通过标准。
11.3 构建三维确定性评分目录
本章 Task 的 Agent 工作目录是 /workspace,产物是 /workspace/report.json。评分目录如下:
tests/
├── Dockerfile
├── test.sh
├── criteria.py
├── expected-report.json
├── reward.toml
├── contract/
│ └── check.py
├── diagnosis/
│ └── check.py
└── evidence/
└── check.py
expected-report.json 不是作者另写的一份 Gold 报告,而是作者侧缓存产物:发布流水线对第 7 章的可信日志运行同一套 load_events()、validate_fixture() 与 derive_expected(),再把确定性投影序列化到这个文件。下面为便于独立复现使用缩小 fixture;开放文本根本不进入参考文件:
{
"root_cause": {
"event_id": "evt-worker-17",
"component": "worker",
"category": "dependency"
},
"evidence": [
{"file": "worker.log", "event_id": "evt-worker-17", "role": "root"},
{"file": "api.log", "event_id": "evt-api-42", "role": "target-failure"}
],
"impact": {"affected_request_ids": ["req-42"]}
}
流水线每次发布都必须按固定 UTF-8、键顺序和换行格式从可信 fixture 重建到临时路径,再与版本库中的 tests/expected-report.json 逐字节比较;不一致就阻止发布。评分镜像只携带比较通过的产物,不携带生成器和可信日志。这样运行期无需重新推导,却不会让静态副本演化成可手改的第二真相。若推导算法或 fixture 合法变更,应在同一评审中重建文件并重跑全部正反例。
根级 criteria.py 定义一组 shared criterion。候选文件缺失、非法 JSON、路径缺失或类型错误是预期的 Agent 失败,返回 False;参考文件在模块导入时读取,若它损坏则直接中止评分,以免把作者错误伪装成候选答案得 0。
自定义 criterion 的异常边界需要刻意收窄。下面的 except 只覆盖候选输入可以触发的读取、解码、路径和类型问题;它没有笼统捕获 Exception。如果作者拼错参考文件名、依赖导入失败,或实现中出现未预料的程序错误,评分应停止并留下基础设施证据。否则一次作者回归会让所有 Agent 同时得到 0,看起来像模型整体退化。criterion 也应保持单向数据流:读取候选数据,计算值,返回结果;不要在函数中修复报告、更新参考产物或把隐藏参考完整打印到日志。
粒度同样要服务于排错。把 schema、根因和证据全压进一个 check_everything() 会失去三个维度的意义;把公开协议的每个字段变成可补偿的正分,又会让非法报告得到正的 contract。本例以“失败后采取同一修复动作”为边界:完整协议是一项二元门禁,字段级检查只写明细;根因三元组作为原子诊断契约,证据与影响集合分别观察。每个计分 criterion 都应能写出正例、最小反例和投机反例;做不到时,先修公开任务契约,而不是增加模糊分数。
import json
from pathlib import Path
from typing import Any
from rewardkit import criterion
CANDIDATE_ERRORS = (
OSError, UnicodeError, json.JSONDecodeError,
KeyError, IndexError, TypeError, ValueError,
)
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate key: {key}")
result[key] = value
return result
def _reject_constant(value: str) -> None:
raise ValueError(f"invalid JSON constant: {value}")
def strict_loads(text: str) -> Any:
return json.loads(
text,
object_pairs_hook=_strict_object,
parse_constant=_reject_constant,
)
def _at(value: Any, json_path: str) -> Any:
for segment in filter(None, json_path.split(".")):
value = value[int(segment)] if isinstance(value, list) else value[segment]
return value
def _candidate(workspace: Path, path: str) -> Any:
return strict_loads((workspace / path).read_text(encoding="utf-8"))
def _has_exact_keys(value: Any, expected_keys: set[str]) -> bool:
return type(value) is dict and set(value) == expected_keys
def _is_nonempty_string(value: Any) -> bool:
return type(value) is str and bool(value.strip())
def _is_nonempty_string_list(
value: Any, min_items: int = 1, max_items: int | None = None
) -> bool:
return (
type(value) is list
and len(value) >= min_items
and (max_items is None or len(value) <= max_items)
and all(_is_nonempty_string(item) for item in value)
)
def _validate_reference(value: Any) -> None:
if not _has_exact_keys(value, {"root_cause", "evidence", "impact"}):
raise ValueError("invalid expected-report.json keys")
root = value["root_cause"]
if not _has_exact_keys(root, {"event_id", "component", "category"}) \
or not all(_is_nonempty_string(item) for item in root.values()):
raise ValueError("invalid expected root_cause")
if type(value["evidence"]) is not list or not value["evidence"]:
raise ValueError("invalid expected evidence")
for item in value["evidence"]:
if not _has_exact_keys(item, {"file", "event_id", "role"}) \
or not all(_is_nonempty_string(field) for field in item.values()):
raise ValueError("invalid expected evidence item")
impact = value["impact"]
if not _has_exact_keys(impact, {"affected_request_ids"}) \
or not _is_nonempty_string_list(impact["affected_request_ids"]):
raise ValueError("invalid expected impact")
_REFERENCE = strict_loads(
(Path(__file__).parent / "expected-report.json").read_text(encoding="utf-8")
)
_validate_reference(_REFERENCE)
def _has_public_contract(report: Any) -> bool:
if not _has_exact_keys(report, {
"schema_version", "incident_summary", "root_cause",
"evidence", "impact", "recommended_actions",
}):
return False
root = report["root_cause"]
impact = report["impact"]
evidence = report["evidence"]
return (
report["schema_version"] == "1.0"
and _is_nonempty_string(report["incident_summary"])
and _has_exact_keys(
root, {"event_id", "component", "category", "explanation"}
)
and all(_is_nonempty_string(item) for item in root.values())
and type(evidence) is list
and all(
_has_exact_keys(item, {"file", "event_id", "role"})
and all(_is_nonempty_string(field) for field in item.values())
for item in evidence
)
and _has_exact_keys(impact, {"affected_request_ids"})
and _is_nonempty_string_list(impact["affected_request_ids"])
and _is_nonempty_string_list(
report["recommended_actions"], min_items=1, max_items=3
)
)
@criterion(shared=True, description="{path}[{json_path}] has exactly the declared keys")
def json_object_has_exact_keys(
workspace: Path, path: str, json_path: str, expected_keys: list[str]
) -> bool:
try:
value = _at(_candidate(workspace, path), json_path)
return _has_exact_keys(value, set(expected_keys))
except CANDIDATE_ERRORS:
return False
@criterion(shared=True, description="{path}[{json_path}] is a non-empty string")
def json_path_is_nonempty_string(
workspace: Path, path: str, json_path: str
) -> bool:
try:
return _is_nonempty_string(_at(_candidate(workspace, path), json_path))
except CANDIDATE_ERRORS:
return False
@criterion(shared=True, description="{path}[{json_path}] is a bounded non-empty string list")
def json_path_is_nonempty_string_list(
workspace: Path, path: str, json_path: str,
min_items: int = 1, max_items: int | None = None,
) -> bool:
try:
value = _at(_candidate(workspace, path), json_path)
return _is_nonempty_string_list(value, min_items, max_items)
except CANDIDATE_ERRORS:
return False
@criterion(shared=True, description="{path} satisfies the complete public report contract")
def report_satisfies_public_contract(workspace: Path, path: str) -> bool:
try:
return _has_public_contract(_candidate(workspace, path))
except CANDIDATE_ERRORS:
return False
@criterion(shared=True, description="{path}[{json_path}] matches deterministic fields")
def json_fields_match_reference(
workspace: Path, path: str, json_path: str, fields: list[str]
) -> bool:
expected = _at(_REFERENCE, json_path)
if type(expected) is not dict:
raise TypeError(f"reference[{json_path}] must be an object")
selected = {key: expected[key] for key in fields}
try:
observed = _at(_candidate(workspace, path), json_path)
return type(observed) is dict and all(
observed.get(key) == value for key, value in selected.items()
)
except CANDIDATE_ERRORS:
return False
@criterion(shared=True, description="{path}[{json_path}] exactly matches the reference")
def json_section_matches_reference(
workspace: Path, path: str, json_path: str
) -> bool:
expected = _at(_REFERENCE, json_path)
try:
return _at(_candidate(workspace, path), json_path) == expected
except CANDIDATE_ERRORS:
return False
strict_loads() 与第 7 章一样拒绝重复键和 NaN、Infinity、-Infinity;候选解析失败由每个 criterion 转成 False。参考则在模块导入时完成严格解析和结构验证,异常不会落入候选的 except。json_fields_match_reference() 还在进入候选错误边界前解析作者给定的参考路径与字段,所以作者写错 path 同样会中止评分。
contract/check.py 同时使用内置与自定义 criterion。零权重项只在 reward-details.json 提供字段级诊断;唯一有权重的 public_contract 对完整公开结构作二元判定,避免“七项通过、两项失败”仍把非法协议表示成正分。每个名字描述一个可定位的失败,而不是 check_1 之类无意义编号:
import rewardkit as rk
rk.file_exists("report.json", name="report_exists", weight=0.0)
rk.json_object_has_exact_keys(
"report.json", "",
["schema_version", "incident_summary", "root_cause", "evidence",
"impact", "recommended_actions"],
name="top_level_keys", weight=0.0,
)
rk.json_object_has_exact_keys(
"report.json", "root_cause",
["event_id", "component", "category", "explanation"],
name="root_cause_keys", weight=0.0,
)
rk.json_object_has_exact_keys(
"report.json", "impact", ["affected_request_ids"],
name="impact_keys", weight=0.0,
)
rk.json_path_is_nonempty_string(
"report.json", "incident_summary",
name="incident_summary_nonempty", weight=0.0,
)
rk.json_path_is_nonempty_string(
"report.json", "root_cause.explanation",
name="root_explanation_nonempty", weight=0.0,
)
rk.json_path_is_nonempty_string_list(
"report.json", "recommended_actions", 1, 3,
name="recommended_actions_valid", weight=0.0,
)
rk.report_satisfies_public_contract(
"report.json", name="public_contract", weight=1.0,
)
diagnosis/check.py 只比较可确定的根因三元组,不固定自然语言解释:
import rewardkit as rk
rk.json_fields_match_reference(
"report.json", "root_cause", ["event_id", "component", "category"],
name="root_diagnosis", weight=1.0,
)
evidence/check.py 等权观察完整证据与影响集合:
import rewardkit as rk
rk.json_section_matches_reference(
"report.json", "evidence", name="evidence_exact", weight=1.0,
)
rk.json_section_matches_reference(
"report.json", "impact.affected_request_ids",
name="affected_requests_exact", weight=1.0,
)
contract 的唯一计分项权重为 1,字段级诊断项为 0;diagnosis 与 evidence 内的计分项仍等权为 1。零权重不是“要求可选”,而是避免字段诊断参与补偿;分母始终由 public_contract 保证大于 0。三个维度在根级等权。reward.toml 添加两个键:reward 是严格门禁;soft_score 只用于观察失败距离。
[[reward]]
name = "reward"
aggregation = "threshold"
threshold = 1.0
[[reward]]
name = "soft_score"
aggregation = "weighted_mean"
Reward Kit 保留三个维度键,并把聚合键并列加入 reward.json;聚合键不会伪装成新维度进入 reward-details.json。每个维度先四舍五入到四位小数,根级聚合使用这些已舍入的值,最终聚合键也保留四位。7 本例 criterion 都是二元值,所以 threshold=1.0 没有临界舍入歧义;使用连续分数时,应为门槛两侧增加回归用例。
11.4 接入 Harbor,并在本地先过门禁
发布配置使用 separate Verifier,只转移待评分报告:
artifacts = ["/workspace/report.json"]
[verifier]
timeout_sec = 120.0
environment_mode = "separate"
[verifier.environment]
network_mode = "no-network"
独立 Verifier 镜像必须预装固定包,不能指望运行时在无网络环境下载。下面只是可审查的构建定义;基础镜像标签在正式发布前还要替换成团队验证过的 digest。
FROM python:3.13.5-slim-bookworm
RUN python -m pip install --no-cache-dir harbor-rewardkit==0.1.7
COPY . /tests/
test.sh 先删除旧输出。若 Reward Kit 本身异常,它以非零状态退出且不留下 reward.json,Harbor 会把它记录为 Verifier 失败;候选报告不满足 criterion 时,Reward Kit 正常写出 0 分及明细。两者不能混成同一种“失败答案”。
#!/bin/bash
set -euo pipefail
umask 077
rm -f /logs/verifier/reward.json \
/logs/verifier/reward-details.json
rewardkit /tests \
--workspace /workspace \
--output /logs/verifier/reward.json
在 Harbor 源码树中,可以用锁定提交的本地包做无需 Docker、模型和网络的作者侧验证。基础回归仍是正确报告、把 root_cause.category 改为 network 的错误报告,以及把 evidence 改成空数组的投机报告。另建三个彼此独立的协议投机 fixture:重复写两次 root_cause.category、只把开放解释写成 NaN、以及保留合法顶层键但把摘要与建议改成错误类型、清空解释并给 impact 增加键。每份都用新进程运行,避免注册状态和输出文件串扰:
cd /private/tmp/harbor-framework-v0.18.0
uv run --frozen --package harbor-rewardkit rewardkit /path/to/tests \
--workspace /path/to/workspace \
--output /path/to/logs/reward.json
python3 -m json.tool /path/to/logs/reward.json
python3 -m json.tool /path/to/logs/reward-details.json
本章写作时实际得到:
| 输入 | contract | diagnosis | evidence | reward | soft_score |
|---|---|---|---|---|---|
| 正确 | 1 | 1 | 1 | 1 | 1 |
| 错误类别 | 1 | 0 | 1 | 0 | 0.6667 |
| 空证据 | 1 | 1 | 0.5 | 0 | 0.8333 |
| 协议投机:重复键 | 0 | 0 | 0 | 0 | 0 |
协议投机:NaN | 0 | 0 | 0 | 0 | 0 |
协议投机:结构类型/impact 多键 | 0 | 1 | 1 | 0 | 0.6667 |
这些是 2026-07-16 本地确定性 criterion 的实跑结果,不是 Harbor Trial、Docker 或模型评测结果。空证据 case 的 reward-details.json 明确记录 evidence_exact 的 raw=false、value=0,同时 affected_requests_exact 为 1;两项等权,所以 evidence=(0+1)/2=0.5。重复键与 NaN 令所有严格解析 criterion 正常返回 False;结构投机的确定性根因、证据和影响列表仍正确,但完整公开契约失败,因此主 Reward 仍为 0。另把参考文件改成包含重复 impact 键后运行正确报告,进程以 1 退出且两份输出都未生成,日志显示 ValueError: duplicate key: impact;恢复参考后正确 case 再次通过。
提交 Dataset 前,至少执行以下门禁:
- 正确、错误类别、空证据、重复键、非有限常量、合法顶层但内部结构错误、缺失文件和截断 JSON 均有独立 fixture;
public_contract对公开结构作二元判定,零权重诊断项能定位到字段;所有候选解析异常都返回False;- 所有程序化分数都验证为有限的
0..1,权重为有限非负数,且每个维度的正权重分母大于 0; - 主
reward的门槛在看结果前冻结,soft_score不参与通过判断; - 发布流水线从第 7 章可信 fixture 重建并比较
expected-report.json;删除、重复键、非有限常量或结构破坏必须使评分异常,而不是生成全 0; - 每次改解析器或契约后重跑四组行为:正确、错误类别、空证据和协议投机;
- Docker 可用后再验证 separate Artifact 恢复路径、无网络执行和真实 Trial 日志。
门禁还要区分“评分契约不成立”和“某份答案没通过”。例如所有正反例都得到 contract=0,优先怀疑工作区路径、Artifact 恢复或 shared criterion 未注册;只有错误类别 case 得到 diagnosis=0,才是预期的候选失败。若 reward.json 存在而明细缺失,应查输出复制和日志过滤;两者都缺失则先看 test-stdout.txt、进程退出与依赖。排查顺序从文件存在性、执行异常、维度分数到具体 criterion,避免看到主 Reward 为 0 就猜测 Agent 推理错误。
正式发布时还应建立一个小型判定矩阵。行是典型答案:正确、协议错误、语义错误、投机和作者 fixture 损坏;列是三个维度、主 Reward、是否应产生明细。每次修改 criterion、权重或依赖后重跑矩阵,并保存差异。这样一项“改善部分分数”的改动若让投机答案从 evidence=0 升到正值,会在进入正式 Agent 实验前暴露,而不是等排名异常后再回看。
11.5 权重、聚合与三条危险失败路径
权重表达的是同一契约内的相对贡献,不是 criterion 的可信度,也不是团队对某个指标的喜爱程度。本例的计分项与三个维度均等权;contract 的字段诊断项设为 0,是因为它们只解释同一个二元门禁,并非降低要求。当前没有证据证明“证据数组”应是“影响集合”的三倍。若无法说明“权重改变会对应哪种可观察错误成本”,就先等权报告,不要拍脑袋合成总分。
需要非等权时,可以按四步工作。第一,列出失败类型与下游损失,例如错误根因会触发错误变更,而证据缺一项只会触发人工复核;不要直接从“重要”二字跳到数字。第二,用人工双盲标注或已知正反例确认各 criterion 确实区分这些失败。第三,事前提出少量候选权重,做敏感性分析:在合理范围内系统排序、通过集合和失败归因是否稳定。第四,把权重、理由、标注集版本和批准日期一起冻结。若结论只在一个狭窄权重组合下成立,应分别报告维度,不应发布单一排名。
主门禁和诊断分数还应分工。主 reward 回答“是否满足交付契约”,通常适合硬条件;soft_score 回答“哪些部分已完成”,可以支持开发期排序,但不应被包装成成功率。若产品确实允许质量与成本交换,应把决策规则放在 Trial 之后的报告层,与成本、延迟并列,而不是把运行成本偷偷写进 Task Reward。这样修改资源预算不会迫使重新解释任务正确性。
还要用探针覆盖实现边界。本章在固定提交上实际运行了三类负例:
all_pass的正值边界:单项分数 0.01 得到all_pass=1.0,any_pass也为 1.0。把部分分数交给all_pass会产生与名称直觉不一致的通过。- 越界数值不钳制:程序化 criterion 返回 1.5 时,Reward Kit 打印告警,但单项值和默认聚合都保持 1.5。源码也对负值采取同样的“告警但保留”策略。8 因此自定义 criterion 必须自己保证有限且位于
0..1;不要指望框架修正。 - 程序化异常导致整体缺失:一个 criterion 抛出
RuntimeError("probe-boom")时,run()抛异常,reward.json与reward-details.json都没有生成。与之相对,返回False是正常评分路径,会产生可解释的 0。
这三项决定了本章采用 threshold=1.0、二元确定性 criterion,以及“候选错误返回 False、作者/基础设施错误抛异常”的分界。它们也说明主 Reward 不能仅靠一个好听的聚合模式命名来验收,必须用数值边界和异常路径测试其真实行为。
11.6 两种隔离,以及可解释性的边界
Harbor separate Verifier 与 Reward Kit 的 isolated=True 是正交的。前者把评分代码、参考数据和解释器放进独立 Environment,只把显式 Artifact 恢复到原绝对路径;后者在同一个评分环境内部为单个 criterion 建立 overlayfs 视图,原工作区作为 lower layer,写入进入临时 upper layer,criterion 结束后丢弃。910
本章 criterion 都只读,不需要 overlay。若使用 command_succeeds("python migrate.py", isolated=True),隔离可以防止迁移脚本影响并行 criterion;但它不是安全沙箱:进程仍可能访问网络、环境变量和 overlay 之外的路径。实现会先尝试内核 overlay,失败后尝试 fuse-overlayfs,缺失时甚至会尝试通过 apt-get 安装。无网络、非特权或不含 apt 的镜像不能假定隔离可用,必须在镜像门禁中实际挂载并验证清理。10
Reward Kit 默认并行执行程序化 criterion,CLI 默认并发上限为 8。11 两个未隔离的可变检查可能互相污染,两个都只读也仍可能争用同一服务或数据库。选择顺序应是:优先把 criterion 写成纯读取;确需写入时启用并验证 overlay;确需共享顺序时把步骤合并为一个边界清晰的 criterion,而不是依赖碰巧的调度顺序。
reward-details.json 是解释层,不是第二份权威 Reward。程序化明细包含名称、原始值、归一值、权重、描述和维度分数;Judge 明细还可包含 reasoning、错误和原始输出。Harbor v0.18.0 的 Viewer 能解析并渲染这个文件,但 VerifierResult 只保存 reward.json 的数值字典。12 因而明细缺失不会改变已有 Reward,却会破坏审计能力。若配置 Verifier 日志 include/exclude,v0.18.0 只强制保护 reward.txt 与 reward.json,不会自动保护 reward-details.json;应把它显式纳入日志策略。2
分数名称本身也属于数据契约。把 diagnosis 改名为 correctness,不会改变某次 Trial 的计算,却会让历史 Metric、仪表盘和分析脚本出现两个不连续序列;删除一个 criterion 则会改变维度分母,即使目录名不变。发布时应把维度名、criterion 名、描述、权重和聚合配置随 Task digest 一起评审。需要改语义时发布新 Task 版本,并在迁移说明中给出旧键到新键的映射,不要静默复用同名分数。
reward.json 中各维度和聚合键都保留在 Trial 结果里;命名为 reward 的键承担主分数约定,v0.18.0 的上传逻辑优先取它,标量 min_reward 也以它为门禁。多键却没有 reward 时,上传逻辑不会任选一个主分数。13 同时,v0.18.0 的内置 Mean 已能逐键聚合多维 Reward,缺失的维度按 0 计。14 Cookbook 的 multi-reward 固定提交仍写着“默认 Mean 只支持单键,必须自定义 Metric”;该说明可作为多维目录的历史参考,但不适用于本书的 v0.18.0 基线。15
最后,不要因为 reward-details.json 能容纳 reasoning,就过早引入模型 criterion。schema、根因 ID、证据集合和影响请求都能确定判断;用模型重判只会增加非确定性、成本和校准负担。第 12 章只把无法可靠编码的开放质量维度交给 Judge,并继续与本章确定性门禁分开报告。
11.7 本章小结
- Reward Kit 0.1.7 是 Harbor v0.18.0 固定提交中的独立评分包,运行在 Harbor Verifier 内部。
- 子目录定义 Reward 维度,criterion 权重只决定维度内部组成;根级维度权重以固定提交实现为准。
- 主
reward应表达事前冻结的通过契约;soft_score和分维度 Reward 用于诊断,不能掩盖失败维度。 - 候选 JSON 应拒绝重复键与非有限常量;完整公开结构是二元门禁,参考产物损坏则属于作者错误。
all_pass使用value > 0,程序化越界值不钳制,异常可能让两份 Reward 输出都缺失;三条路径都必须回归。- separate Environment 隔离 Agent 与评分依据,
isolated=True隔离 criterion 的工作区写入,两者都不是完整安全沙箱。 reward-details.json提供 criterion 级证据,但只有reward.json进入VerifierResult;日志策略必须显式保留明细。
11.8 练习
- 按本章目录复现表中的六个 fixture,并为“缺失
report.json”和“截断 JSON”各增加一项回归;解释它们为何应正常生成 0,而不是抛出作者错误。 - 把
evidence_exact改为返回 0.01,并将主聚合改成all_pass。观察主 Reward,再改回能表达“完整通过”的设计,记录两个边界结果。 - 新增一个会改写
report.json的 command criterion,分别在isolated=False与isolated=True下运行;验证原工作区、其他 criterion 和临时层的差异。若 overlay 不可用,应把它记录为环境门禁失败。 - 构造一个程序化目录,唯一通过 criterion 的权重为 100;另一个目录唯一 criterion 失败且权重为 1。用根级
weighted_mean验证结果仍为 0.5,并解释内部权重与维度权重的区别。 - 设计一个真正需要模型 Judge 的“解释质量”维度:先列出无法确定编码的事实、人工标注与校准方案,再说明为何它不能替代
diagnosis和evidence的确定性门禁。