第 28 章:把 Harbor 评测接入工程流水线
周一上午,团队合并了一个看似无害的 Verifier 重构:把一段重复的 JSON 校验提取成公共函数。普通单元测试全部通过,周三的模型评测却突然提高了十几个百分点。模型没有变强;新函数把缺失字段当成了空字符串,而空字符串恰好绕过了一条失败分支。团队保存了评测报表,却没有保存 Task 摘要、Harbor 提交和逐 Trial 结果,因而无法证明这次“提升”来自代码、数据还是执行环境。
Benchmark 不是一份静态测试集,而是一套会变化的软件:Task 是输入和环境,Solution 是可解性证据,Verifier 是可执行规格,Dataset 是发布单元,Harbor 是运行时。把它接入 CI 的目的不是“每个 PR 都调用一次昂贵模型”,而是让不同风险在正确的信任边界、成本层级和时间尺度上失败。
本章继续使用“系统配置与故障诊断 Agent Benchmark”,并锁定 Harbor v0.18.0、提交 527d50deb63a5d279e8c20593c18a2cbc7f61f9e、Python >=3.12。下面的本机验证只检查配置、Python 门禁和 YAML 语法;只有在装有 Docker 的 GitHub-hosted runner 上执行 Oracle、Nop 和错误答案 Agent,才算真正的 Task 冒烟。定时模型评测还需要受保护的凭据和实际模型调用,不能由本章的无秘密探针代替。
读完本章,你应该能够:
- 把 Task、Verifier、Dataset、依赖和基线组织成可审查的评测仓库;
- 将 PR 的确定性冒烟与定时的付费 Agent 回归分开;
- 用 Oracle、Nop 和构造错误答案建立正负向门禁;
- 限制凭据、并发和预算,归档可追溯而不泄密的结果;
- 制定发布阈值、Benchmark 变更审查、Harbor 升级矩阵和回滚流程。
28.1 先分层,而不是先写 Workflow
一次流水线需要回答三个不同问题。
第一,仓库仍然自洽吗? 这包括配置可解析、脚本有语法、Task 目录完整、Verifier 的纯函数回归通过、锁文件没有漂移。它们应当快速、确定、不需要 Docker 和秘密。
第二,Task 在真实容器里仍然可解且不可投机吗? 这要求实际构建 Environment,运行 Oracle,再运行什么也不做的 Nop 和至少一种已知错误答案。Harbor 自身也把 oracle 与 nop 声明为内置 Agent;其端到端测试用同一个 Task 验证 Oracle 得到 1.0、Nop 得到 0.0。12 这一层需要 Docker,但不需要模型 API Key,适合在 GitHub-hosted PR runner 上运行。
第三,真实 Agent 的质量是否退化? 这通常是非确定性的,会消耗模型、云沙箱和网络预算。它不应该在来自 fork 的任意 PR 上运行,而应由默认分支上的 schedule 或人工 workflow_dispatch 触发,使用受保护 Environment 中的最小权限凭据。
| 层级 | 触发 | 可信输入 | 真实依赖 | 典型门禁 |
|---|---|---|---|---|
| L0 静态契约 | 每个 PR | PR 代码不可信 | Python,无 Docker、无秘密 | 锁、schema、单元测试、脚本语法 |
| L1 容器冒烟 | 每个 PR | PR 代码仍不可信 | GitHub-hosted runner、Docker | build、Oracle=1、Nop=0、错误答案=0 |
| L2 付费回归 | 定时/人工 | 默认分支和审批后的配置 | 模型、可选云沙箱、秘密 | 完整率、异常率、质量、成本、趋势 |
| L3 发布审计 | 候选发布 | 已审查提交 | 固定发布矩阵 | 基线比较、清单、签名/证明、回滚演练 |
这张表的关键不是名字,而是禁止“向上借信用”:harbor run --print-config 能证明 JobConfig 可解析,却不能证明 Dockerfile 可构建;Oracle 成功能证明参考路径可运行,却不能证明真实 Agent 的成功率;一次模型运行超过阈值也不能证明长期回归已经消失。
28.2 把 Benchmark 当成版本化产品
推荐把运行配置、质量门禁和结果策略与 Task 一起评审:
agent-benchmark/
├── pyproject.toml
├── uv.lock
├── BENCHMARK_VERSION
├── benchmarks/system-diagnosis/
│ ├── dataset.toml
│ └── tasks/
│ ├── analyze-service-log/
│ ├── repair-python-service/
│ └── repair-nginx-route/
├── configs/ci/
│ ├── smoke.yaml
│ └── release.yaml
├── ci/
│ ├── __init__.py
│ ├── gate.py
│ └── wrong_answer.py
├── tests/
│ ├── ci/
│ └── verifier_contract/
├── baselines/
│ └── system-diagnosis-1.3.0.json
└── .github/
├── CODEOWNERS
└── workflows/
├── benchmark-pr.yml
└── benchmark-scheduled.yml
BENCHMARK_VERSION 是团队自己的评测产品版本,不是 Harbor 的 Dataset 版本字段。二者应分别记录:前者表示能力范围、Task 与评分语义的发布状态,后者遵循实际 Dataset manifest。不要把一个 Git tag 同时解释成 Harbor、Benchmark、模型和镜像四种版本。
锁定四类输入
第一类是运行时。项目依赖直接固定到本书提交,并把解析结果提交到 uv.lock:
[project]
name = "system-diagnosis-benchmark"
version = "1.3.0"
requires-python = ">=3.12"
dependencies = [
"harbor @ git+https://github.com/harbor-framework/harbor.git@527d50deb63a5d279e8c20593c18a2cbc7f61f9e",
]
[dependency-groups]
dev = ["pytest>=8.4.2"]
CI 使用 uv sync --locked,让 pyproject.toml 与 uv.lock 不一致时立即失败;uv 官方将 --locked 定义为锁文件缺失或需要更新时退出。3 Harbor v0.18.0 自身的包元数据声明版本 0.18.0、Python >=3.12,并注册 harbor CLI。4
第二类是 Task/Dataset。发布套件应显式列出 Task,而不是让一个不断增长的目录悄悄改变样本量。Harbor v0.18.0 的 JobConfig 同时支持 tasks 和 datasets,并以 n_attempts、n_concurrent_trials 控制重复与并发。5 创建 Job 时,lock.json 会为解析后的 Task 记录内容 digest;Git Task 还会记录解析后的提交。6
第三类是容器。Docker 明确指出镜像 tag 可变,而 digest 固定同一镜像内容;生产 Benchmark 的 FROM 应使用经审查的 tag@sha256:...,更新 digest 要单独走依赖审查。7 Task 自身的 digest 不能替代镜像来源证明:Dockerfile 文本不变,浮动 tag 仍可能改变构建结果。
第四类是流水线依赖。GitHub 说明,完整 commit SHA 是引用不可变 Action 发布的方式。8 本章 Workflow 因而把 checkout、setup-uv、upload-artifact 固定到实际 SHA,而不是只写 @v4 或 @main。
注意:缓存不是版本锁。缓存未命中时流水线必须仍能重新下载或重建;命中也不能绕过 digest、锁文件和结果门禁。GitHub 还提醒不要把 token、凭据等敏感信息放进 cache,因为 fork PR 可以读取基线分支可访问的 cache。9
28.3 用正向和负向证据测试 Task
一个只运行 Oracle 的 CI 会鼓励危险的 Verifier:只要标准脚本恰好通过,任何答案是否也能通过无人知晓。对每个 smoke Task,至少维护下面四类证据。
- 构建证据:Environment 在干净 runner 上能够构建和启动。
- 可解性证据:Oracle 执行
solution/solve.sh后主 Reward 精确为1。Oracle 的真实实现会上传 solution 目录并在 Environment 中执行 OS 对应脚本。10 - 空操作反例:Nop 不修改环境,主 Reward 必须为
0;其实现的setup()与run()都不执行动作。11 - 已知攻击反例:错误诊断、伪造输出、只改表面文本或利用缺失字段的答案必须为
0,必要时另查分维 Reward。
贯穿项目的 smoke 配置显式选择三项任务:
job_name: local-smoke
jobs_dir: jobs
n_attempts: 1
n_concurrent_trials: 2
environment:
type: docker
force_build: true
delete: true
retry:
max_retries: 0
agents:
- name: oracle
tasks:
- path: benchmarks/system-diagnosis/tasks/analyze-service-log
- path: benchmarks/system-diagnosis/tasks/repair-python-service
- path: benchmarks/system-diagnosis/tasks/repair-nginx-route
这组字段对应 v0.18.0 的 JobConfig 与 EnvironmentConfig;--print-config 会输出解析后的配置并在创建 Job 前退出。1213 先在仓库根目录执行:
uv sync --locked
uv run harbor run -c configs/ci/smoke.yaml --print-config > /tmp/smoke.json
uv run pytest tests/ci tests/verifier_contract -q
这两条命令属于 L0。真正的 L1 是:
uv run harbor run -c configs/ci/smoke.yaml -a oracle \
--job-name pr-oracle --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-oracle --expect-reward 1
uv run harbor run -c configs/ci/smoke.yaml -a nop \
--job-name pr-nop --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-nop --expect-reward 0
uv run harbor run -c configs/ci/smoke.yaml \
-a ci.wrong_answer:WrongAnswerAgent \
--job-name pr-wrong --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-wrong --expect-reward 0
Harbor CLI 接受 module.path:ClassName 形式的自定义 Agent。14 教学用错误答案 Agent 可以固定写入一份结构合法、结论错误的报告:
from typing import override
from harbor.agents.base import BaseAgent
from harbor.environments.base import BaseEnvironment
from harbor.models.agent.context import AgentContext
class WrongAnswerAgent(BaseAgent):
@staticmethod
@override
def name() -> str:
return "ci-wrong-answer"
@override
def version(self) -> str:
return "1.0.0"
@override
async def setup(self, environment: BaseEnvironment) -> None:
return None
@override
async def run(
self,
instruction: str,
environment: BaseEnvironment,
context: AgentContext,
) -> None:
await environment.exec(
command=(
"mkdir -p /app/output && "
"printf '%s\\n' "
"'{\"service\":\"api\",\"cause\":\"disk_full\",\"fixed\":true}' "
"> /app/output/report.json"
)
)
这个 Agent 不是通用错误生成器。它只把一个已经发生过的假阳性固化成回归样本。每修复一个 Verifier 漏洞,就应把触发它的最小答案加入负向语料,而不是不断增加与历史问题无关的随机错误。
警告:不要把
tests/test.sh的退出码直接当成 Harbor Reward。v0.18.0 的模板要求 Verifier 写/logs/verifier/reward.txt或rewards.json;没有 Reward 文件会导致验证失败。1516
28.4 写一个拒绝不完整结果的发布门禁
Job 执行结束后,Harbor 把聚合统计写到 Job 的 result.json,但持久化时明确排除了 trial_results;每个 Trial 的详细结果保存在自己的目录中。1718 因此,不能只看到 Job 根文件存在就宣布成功。下面的 ci/gate.py 同时检查版本锁、完成状态、逐 Trial 异常、Reward 和付费成本:
from __future__ import annotations
import argparse
import json
import math
from collections import Counter
from pathlib import Path
from typing import Any
HARBOR_VERSION = "0.18.0"
HARBOR_COMMIT = "527d50deb63a5d279e8c20593c18a2cbc7f61f9e"
def load(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text())
if not isinstance(value, dict):
raise ValueError(f"{path}: expected JSON object")
return value
def primary_reward(trial: dict[str, Any], path: Path) -> float:
verifier = trial.get("verifier_result")
rewards = verifier.get("rewards") if isinstance(verifier, dict) else None
if not isinstance(rewards, dict) or not rewards:
raise ValueError(f"{path}: missing verifier rewards")
value = rewards.get("reward")
if value is None and len(rewards) == 1:
value = next(iter(rewards.values()))
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{path}: primary reward is not numeric")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{path}: primary reward is not finite")
return number
def reported_trial_cost(trial: dict[str, Any], path: Path) -> float:
contexts: list[tuple[str, dict[str, Any]]] = []
agent_result = trial.get("agent_result")
steps = trial.get("step_results")
if isinstance(agent_result, dict):
contexts.append(("agent_result", agent_result))
elif isinstance(steps, list) and steps:
for index, step in enumerate(steps):
if not isinstance(step, dict):
raise ValueError(f"{path}: step {index} is not an object")
if step.get("exception_info") is not None:
raise ValueError(f"{path}: step {index} contains exception_info")
context = step.get("agent_result")
if not isinstance(context, dict):
raise ValueError(f"{path}: step {index} has no agent cost context")
contexts.append((f"step_results[{index}].agent_result", context))
else:
raise ValueError(f"{path}: paid gate requires an agent cost context")
total = 0.0
for label, context in contexts:
value = context.get("cost_usd")
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{path}: {label}.cost_usd is not numeric")
number = float(value)
if not math.isfinite(number) or number < 0:
raise ValueError(f"{path}: {label}.cost_usd is invalid")
total += number
return total
def lock_fingerprint(value: dict[str, Any]) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def validate(
job_dir: Path,
expect_reward: float | None,
min_mean_reward: float | None,
max_cost_usd: float | None,
) -> dict[str, Any]:
for label, value in (
("expect_reward", expect_reward),
("min_mean_reward", min_mean_reward),
("max_cost_usd", max_cost_usd),
):
if value is not None and not math.isfinite(value):
raise ValueError(f"{label} must be finite")
if max_cost_usd is not None and max_cost_usd < 0:
raise ValueError("max_cost_usd must be non-negative")
result = load(job_dir / "result.json")
lock = load(job_dir / "lock.json")
harbor = lock.get("harbor")
if not isinstance(harbor, dict):
raise ValueError("lock.json: missing harbor metadata")
if harbor.get("version") != HARBOR_VERSION:
raise ValueError(f"unexpected Harbor version: {harbor.get('version')!r}")
if harbor.get("git_commit_hash") != HARBOR_COMMIT:
raise ValueError(
f"unexpected Harbor commit: {harbor.get('git_commit_hash')!r}"
)
total = result.get("n_total_trials")
stats = result.get("stats")
if not isinstance(total, int) or total < 1 or not isinstance(stats, dict):
raise ValueError("result.json: invalid total or stats")
locked_trials = lock.get("trials")
if not isinstance(locked_trials, list) or len(locked_trials) != total:
raise ValueError("lock.json: trial count does not match result.json")
expected_counts = {
"n_completed_trials": total,
"n_errored_trials": 0,
"n_running_trials": 0,
"n_pending_trials": 0,
"n_cancelled_trials": 0,
}
for key, expected in expected_counts.items():
if stats.get(key) != expected:
raise ValueError(f"result.json: {key}={stats.get(key)!r}, want {expected}")
trial_paths = sorted(
path / "result.json"
for path in job_dir.iterdir()
if path.is_dir() and (path / "result.json").is_file()
)
if len(trial_paths) != total:
raise ValueError(f"found {len(trial_paths)} trial results, want {total}")
rewards: list[float] = []
observed_cost = 0.0
observed_locks: list[dict[str, Any]] = []
trial_ids: set[str] = set()
for path in trial_paths:
trial = load(path)
if trial.get("exception_info") is not None:
raise ValueError(f"{path}: trial contains exception_info")
steps = trial.get("step_results")
if steps is not None:
if not isinstance(steps, list):
raise ValueError(f"{path}: step_results is not a list")
for index, step in enumerate(steps):
if not isinstance(step, dict):
raise ValueError(f"{path}: step {index} is not an object")
if step.get("exception_info") is not None:
raise ValueError(f"{path}: step {index} contains exception_info")
trial_id = trial.get("id")
trial_name = trial.get("trial_name")
if not isinstance(trial_id, str) or trial_id in trial_ids:
raise ValueError(f"{path}: missing or duplicate trial id")
if trial_name != path.parent.name:
raise ValueError(f"{path}: trial_name does not match directory")
trial_ids.add(trial_id)
observed_locks.append(load(path.parent / "lock.json"))
rewards.append(primary_reward(trial, path))
if max_cost_usd is not None:
observed_cost += reported_trial_cost(trial, path)
if Counter(map(lock_fingerprint, observed_locks)) != Counter(
map(lock_fingerprint, locked_trials)
):
raise ValueError("trial lock set does not match job lock")
if expect_reward is not None:
wrong = [r for r in rewards if r != expect_reward]
if wrong:
raise ValueError(f"rewards {wrong!r} do not equal {expect_reward}")
mean_reward = sum(rewards) / len(rewards)
if min_mean_reward is not None and mean_reward < min_mean_reward:
raise ValueError(f"mean reward {mean_reward:.6f} < {min_mean_reward:.6f}")
cost = stats.get("cost_usd")
if max_cost_usd is not None:
if isinstance(cost, bool) or not isinstance(cost, (int, float)):
raise ValueError("paid gate requires numeric stats.cost_usd")
if not math.isfinite(float(cost)) or float(cost) < 0:
raise ValueError(f"invalid aggregate cost {cost!r}")
if not math.isclose(float(cost), observed_cost, rel_tol=1e-9, abs_tol=1e-9):
raise ValueError(
f"aggregate cost {cost!r} != observed trial cost {observed_cost!r}"
)
if observed_cost > max_cost_usd:
raise ValueError(f"cost {cost!r} exceeds {max_cost_usd}")
return {
"trials": total,
"mean_reward": mean_reward,
"cost_usd": cost,
"harbor_commit": HARBOR_COMMIT,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("job_dir", type=Path)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--expect-reward", type=float)
mode.add_argument("--min-mean-reward", type=float)
parser.add_argument("--max-cost-usd", type=float)
args = parser.parse_args()
summary = validate(
args.job_dir,
args.expect_reward,
args.min_mean_reward,
args.max_cost_usd,
)
print(json.dumps(summary, sort_keys=True))
if __name__ == "__main__":
main()
JobStats 在 v0.18.0 中确实包含完成、错误、运行、待处理、取消、token 和 cost_usd 字段;成本来自各 Trial 的 AgentContext 聚合。1920 门禁拒绝 NaN、缺失成本和缺失 Reward,是因为“无法测量”不能在发布时被解释为零成本或零误差。
在没有 Docker 和秘密的开发机上,可以用合成 Job/Trial JSON 单测这个门禁的成功与失败分支;合成数据必须标为测试夹具。它证明解析逻辑,而不证明真实 Task。验收至少包含:错误 Harbor SHA 被拒绝、缺少一个 Trial 被拒绝、异常 Trial 被拒绝、Oracle 中出现 0 被拒绝、Nop 中出现 1 被拒绝、非有限 Reward 被拒绝、付费结果缺少成本被拒绝。
28.5 PR Workflow:无秘密地运行真实 Docker 冒烟
下面的 Workflow 针对 fork PR 仍按“不可信代码”设计:只读 GITHUB_TOKEN、无模型秘密、GitHub-hosted runner、不用 pull_request_target、不上传原始 trajectory。GitHub 明确指出,fork 触发的 pull_request 不会收到除只读 GITHUB_TOKEN 外的秘密;而在 pull_request_target 等特权上下文中检出并执行不可信 PR 代码会带来仓库接管风险。2122
name: benchmark-pr
on:
pull_request:
paths:
- "benchmarks/**"
- "configs/ci/**"
- "ci/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/benchmark-pr.yml"
permissions:
contents: read
concurrency:
group: benchmark-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout untrusted PR merge commit
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install pinned uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.9.7"
enable-cache: false
- name: Resolve locked dependencies and run deterministic contracts
run: |
uv sync --locked
uv run harbor run -c configs/ci/smoke.yaml --print-config > /tmp/smoke.json
uv run pytest tests/ci tests/verifier_contract -q
- name: Oracle must solve every smoke task
run: |
uv run harbor run -c configs/ci/smoke.yaml -a oracle \
--job-name pr-oracle --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-oracle --expect-reward 1
- name: Empty and known-wrong answers must fail
run: |
uv run harbor run -c configs/ci/smoke.yaml -a nop \
--job-name pr-nop --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-nop --expect-reward 0
uv run harbor run -c configs/ci/smoke.yaml \
-a ci.wrong_answer:WrongAnswerAgent \
--job-name pr-wrong --jobs-dir jobs --yes
uv run python ci/gate.py jobs/pr-wrong --expect-reward 0
- name: Preserve only non-secret result metadata
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: benchmark-pr-${{ github.run_id }}
if-no-files-found: warn
retention-days: 7
path: |
jobs/*/result.json
jobs/*/lock.json
jobs/*/config.json
jobs/*/*/result.json
jobs/*/*/lock.json
这不是“无 Docker 的 CI 样例”:harbor run 默认使用 Docker Environment,配置又设置了 force_build: true。本章没有替你在 GitHub runner 上执行它,因此采用该文件前仍应在测试仓库跑一次真实 Workflow,并记录 runner 镜像、Docker 版本和耗时。
两个 Workflow 都把 persist-credentials 设为 false。actions/checkout 默认会保留凭据,使后续步骤能够执行已认证的 Git 命令;本例不需要 push,也不应让 PR 中的任意脚本取得这份能力。23
Harbor 自己的 v0.18.0 流水线也是分层的:先 uv sync --locked,Linux 上分别运行非 runtime 与 runtime 测试,runtime 使用 Docker,并在工作流级把 contents 权限限制为只读。24 本章没有逐字复制它,因为 Benchmark 仓库的风险对象是 Task/Verifier,而不是 Harbor 全部源码。
为什么 PR 不共享写缓存
PR 中关闭 setup-uv 持久 cache,是为了让不可信分支不能向后续可信运行投毒。定时受信任 Workflow 可以开启以 uv.lock 为键的依赖缓存;镜像则优先从只读 registry 按 digest 拉取。不要缓存 jobs/、.env、Agent 配置目录或包含模型响应的日志。即使启用 cache,任务仍须在 cache miss 时完整成功。
28.6 Scheduled Workflow:把秘密和预算关进边界
定时评测使用默认分支上的 Workflow。GitHub 的 schedule 只运行默认分支版本,高负载时可能延迟,因此它适合周期回归,不适合作为精确到分钟的生产告警器。25 把模型 Key 放进名为 paid-eval 的 Environment,并设置只允许默认分支、必需 reviewer、禁止自审批。Environment 的保护规则通过前,Job 不能访问其中的 secrets。26
name: benchmark-scheduled
on:
schedule:
- cron: "17 2 * * 2"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: benchmark-paid-eval
cancel-in-progress: false
jobs:
paid-eval:
runs-on: ubuntu-latest
timeout-minutes: 120
environment: paid-eval
env:
EVAL_AGENT: ${{ vars.HARBOR_EVAL_AGENT }}
EVAL_MODEL: ${{ vars.HARBOR_EVAL_MODEL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MAX_COST_USD: "20"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.9.7"
enable-cache: true
cache-dependency-glob: uv.lock
- name: Verify protected inputs
run: |
test -n "$EVAL_AGENT"
test -n "$EVAL_MODEL"
test -n "$OPENAI_API_KEY"
uv sync --locked
- name: Run the committed release cohort
run: |
uv run harbor run -c configs/ci/release.yaml \
-a "$EVAL_AGENT" -m "$EVAL_MODEL" \
--job-name "scheduled-${GITHUB_RUN_ID}" \
--jobs-dir jobs --n-attempts 3 --n-concurrent 4 \
--max-retries 0 --yes
- name: Enforce result and post-run cost gates
run: |
uv run python ci/gate.py "jobs/scheduled-${GITHUB_RUN_ID}" \
--min-mean-reward 0.75 \
--max-cost-usd "$MAX_COST_USD" \
| tee trend-point.json
- name: Archive sanitized evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: benchmark-scheduled-${{ github.run_id }}
if-no-files-found: warn
retention-days: 30
path: |
trend-point.json
jobs/scheduled-*/result.json
jobs/scheduled-*/lock.json
jobs/scheduled-*/config.json
jobs/scheduled-*/*/result.json
jobs/scheduled-*/*/lock.json
这里的 $20 和 0.75 是教学性门槛,不是本项目的实测结论或普适建议。正式值必须来自冻结 cohort 的批准基线、成本模型与风险容忍度。
--max-cost-usd 是事后门禁,无法阻止一次已经发出的昂贵请求。真正的预算保护至少有四层:release 配置显式列出 Task;n_attempts 限制重复;n_concurrent_trials 限制同时运行的 Trial;模型供应方使用专用项目/Key 和服务端额度。Harbor 实际按 attempt、Task 和 AgentConfig 的组合展开 Trial;若每个 AgentConfig 对应一个 Agent/Model 组合,计划数就是“Task × Agent/Model 组合 × attempt”。27 重试会增加实际执行次数,因此样例关闭自动重试。一次 Trial 内的模型调用数仍由 Agent 行为决定,不能从 Trial 数推导出硬成本上限。
如果 Agent 或供应方没有上报 cost_usd,付费门禁应失败并标记“成本不可观测”,而不是填零。Harbor 的 AgentContext.cost_usd 本来就是可空字段。28 团队可以另用供应方账单核对,但必须记录取数窗口、币种、缓存 token 口径和延迟入账边界。
警告:GitHub 日志脱敏不是数据治理替代品。模型可能在 trajectory、命令输出或 Artifact 中复述秘密。样例只上传结果、锁和配置元数据;若要保存完整轨迹,先做结构化脱敏,再写入访问受限且有删除策略的存储。
28.7 归档、趋势与发布阈值
Cache 为下一次运行提速,Artifact 为本次运行留证,baseline 为跨运行比较提供批准参照,三者不可互换。GitHub 的 Workflow Artifact 用于保存测试结果和日志,retention-days 可单独设置;依赖缓存则应当可以丢弃并重建。29
一次可比较的趋势点至少关联:
- 仓库 commit 与 Workflow run ID;
BENCHMARK_VERSION;- Harbor 版本和 commit;
- Job/Trial
lock.json中的 Task digest; - Agent、Agent 版本、模型与必要配置;
- Task cohort、attempt 数、成功/异常/取消计数;
- 主 Reward 和分维 Reward;
- token、成本、开始/结束时间;
- 原始结果 Artifact 的哈希和保留位置。
SLSA 的核心思想之一是同时保存产物、构建输入与可验证来源;只有生成 provenance 而不按预期验证,并不能降低风险。30 对发布报告同样如此:一个哈希只说明字节未变,不说明 Benchmark 设计合理。候选发布若生成容器或可下载结果包,可以进一步使用 GitHub Artifact Attestation;GitHub 也明确提醒,attestation 连接来源与构建方式,但不保证产物本身安全。31
阈值要按指标性质区分:
- 确定性合同:配置必须解析;所有 smoke Oracle 必须为
1;Nop 与已知错误答案必须为0;异常、缺失结果和非有限值一票否决。 - 基础设施健康:完成数必须等于计划数;错误、取消、待处理和运行中必须为零。基础设施失败不能混成 Reward
0后继续算平均值。 - 非确定性质量:对冻结 cohort 和固定 attempt 比较分布与区间;单次均值低于门槛可阻止自动发布,但是否宣告模型回归需要重复与失败归因。
- 成本和速度:成本缺失即不可观测;超预算阻止发布。延迟阈值要区分 Agent 时间、Environment 构建和排队,避免用总时长误判模型。
- 安全与泄露:发现答案泄露、Verifier gaming 或凭据进入 Artifact 时,停止发布并撤销受影响基线,而不是调低阈值。
趋势图只比较可比的点。Task digest、Harbor commit、Agent/模型、attempt 或评分语义任一改变,都应开启新序列或显式标注断点。否则所谓“月度提升”很可能只是样本或评分尺变化。
28.8 Benchmark 变更必须由人审查语义
普通代码审查常问“实现是否正确”;Benchmark 审查还必须问“测量对象是否改变”。建议让 .github/CODEOWNERS 覆盖以下路径:
/benchmarks/ @eval-owners
/configs/ci/ @eval-owners @platform-owners
/ci/gate.py @eval-owners @security-owners
/.github/workflows/ @platform-owners @security-owners
/.github/CODEOWNERS @security-owners
GitHub 可以在相关路径变化时自动请求 code owner,并可配合分支保护要求其批准;保护 CODEOWNERS 自身同样重要。32 但 CODEOWNERS 只是路由机制。评审模板还要强制回答:
- 能力目标、instruction 或允许解法是否变化?
- Environment、基础镜像、依赖或网络是否变化?
- Solution 是否仍代表合法而非唯一实现?
- Verifier 的假阳性/假阴性边界如何变化?新增了哪个负向夹具?
- Reward 名称、范围、权重或主指标是否变化?
- Task digest、Benchmark 版本和基线是否应更新?
- Oracle、Nop、错误答案与真实 Agent 的证据在哪里?
- 成本、秘密、许可和外部数据来源是否变化?
只改错别字且不影响 instruction 语义,也会改变 Task digest;这不一定要求大版本,但必须让趋势系统识别新输入。改变测试期望、Reward 聚合或 Task cohort 则属于测量语义变更,应发布新的 Benchmark 版本,并禁止把新结果直接拼接到旧基线。
一个实用的合并规则
将 L0/L1 状态检查设为必需;Task、Verifier、release cohort、Workflow 或依赖锁变化时要求评测 owner;任何影响秘密或权限的 Workflow 变化再要求安全 owner。模型 L2 不必阻塞每个 PR,但发布分支必须引用最近一次与候选 commit、cohort 和运行时完全匹配的通过记录。不要拿默认分支一周前的绿色定时任务批准今天的候选。
28.9 Harbor 升级:兼容矩阵先行,回滚路径后置
升级不是把 0.18.0 改成新版本后重跑一次。先在独立分支生成候选 uv.lock,保留当前锁作为控制组,对同一仓库 commit 和同一 Task cohort 执行矩阵:
| 契约 | v0.18.0 控制组 | 候选版本 | 通过条件 | 失败后的动作 |
|---|---|---|---|---|
| Python/API | 导入 gate 依赖类型 | 相同探针 | 无意外导入/字段变化 | 修改适配层,不改 Task 语义 |
| 配置 | --print-config | 相同配置 | 解析结果差异已审查 | 保持旧锁 |
| Environment | 三个 smoke build | 同一镜像 digest | 构建和启动均通过 | 查 Provider/Compose 差异 |
| 可解性 | Oracle 全 1 | Oracle 全 1 | Task 逐项一致 | 阻止升级 |
| 负向性 | Nop/错误答案全 0 | 同样反例 | 无假阳性 | 阻止升级并审计 Verifier |
| 结果格式 | v0.18 gate | 候选解析器 | 字段映射明确、缺失拒绝 | 版本化结果 Adapter |
| 真实 Agent | 冻结小 cohort | 同一 Agent/模型 | 差异有重复与归因 | 不合并或扩大实验 |
| 云 Provider | 批准的最小任务 | 相同区域/镜像 | 生命周期与 Artifact 完整 | 回退本地/旧 Provider |
v0.18.0 的 Job lock 为 Harbor 版本和 Git commit 提供可空字段,并记录并发、重试与各 Trial 输入;本章采用 Git commit 直连安装,因此门禁进一步要求两个运行时字段都存在且精确匹配。恢复时的相等性比较关注可重放字段,而不是创建时间。33 这使它适合作为兼容证据,但不能证明不同 Harbor 版本产生相同语义。候选版本必须用自己的 schema 解析器,不能偷偷放宽本章 gate 以“兼容”缺失字段。
回滚步骤应在升级前写好:恢复 pyproject.toml 与 uv.lock;恢复已批准的镜像 digest 和 Workflow Action SHA;清除候选版本专用 cache key;用旧运行时重跑同一 release manifest;把候选结果归档为不可比较序列;若 Task 或 Verifier 同时变化,先拆分提交,避免一次回滚两种变量。
注意:回滚运行时不等于回滚结果。已经用候选版本生成的 Job/Trial 文件应保留原始版本标签,不能重写元数据后混入旧趋势。
28.10 失败模式与排查顺序
“PR 通过,定时任务没有 Key”
这是正确的默认边界,不应通过把 secrets 开放给 fork 修复。确认定时 Workflow 位于默认分支、Job 引用了 paid-eval Environment、保护规则已通过、秘密名与 Agent/模型供应方匹配。若 PR 必须触发付费实验,让 maintainer 在合并候选提交后手动运行可信 Workflow,而不是在 pull_request_target 中检出 PR head 并执行。
“Oracle 通过,Nop 也通过”
先停止模型评测。这通常意味着初始环境已经满足完成条件、Verifier 只查文件存在、Reward 默认值错误,或测试路径没有落在 Agent 能修改的同一状态上。保存 Nop Trial 的 result、Verifier stdout/stderr 和 Task digest,添加最小负向夹具,修复后同时要求 Oracle=1、Nop=0。
“平均 Reward 达标,但有 Trial 异常”
不要用成功 Trial 的平均值掩盖缺失。先检查 n_completed_trials、n_errored_trials、n_cancelled_trials 和逐 Trial exception_info;本章 gate 会先拒绝异常,再计算均值。Harbor 的 JobStats 会把含异常的 Trial 计入错误统计,取消也作为特定异常类型计数。19
“同一 commit 的趋势跳变”
按顺序核对 Harbor commit、Task digest、镜像 digest、Agent/模型版本、release cohort、attempt、模型参数和外部服务。若只有 cache 命中状态不同,执行一次完全无 cache 的对照;若差异消失,说明构建输入尚未完全锁定。不要先调整发布阈值。
“结果 Artifact 上传了,但无法复现”
检查是否同时保存 Job/Trial lock、config、逐 Trial result 和仓库 commit。只有聚合表没有失败样本,只有日志没有解析配置,只有 Task digest 没有容器 digest,都不足以重放。原始 trajectory 若因敏感性不能上传,至少保存脱敏规则版本和受限存储引用。
28.11 验收清单
在把流水线设为必需检查前,完成一次有意破坏演练:
- 删除一个
solution/solve.sh,Oracle job 必须失败; - 让 Verifier 对缺失输出默认给
1,Nop 或错误答案门禁必须失败; - 把 Harbor 依赖改成另一 commit 但不更新 gate,版本锁必须失败;
- 删除一个 Trial 结果,完整性门禁必须失败;
- 构造
NaNReward,非有限值门禁必须失败; - 在付费夹具中删除
cost_usd,预算门禁必须失败; - 从 fork 发 PR,确认没有模型秘密,且只使用 GitHub-hosted runner;
- 让定时 Job 进入 Environment 审批前,确认秘密尚不可用;
- 修改一个 Task,确认 CODEOWNERS、必需状态和基线更新规则被触发;
- 在升级分支恢复旧
uv.lock,确认回滚矩阵能够重新变绿。
这些都是负向验收。绿色流水线的可信度来自它是否会在错误发生时变红,而不来自 Workflow 文件有多长。
28.12 本章小结
- Harbor 评测流水线应分为无秘密静态契约、真实 Docker 冒烟、受保护付费回归和发布审计。
- Oracle=1 只是正向证据;Nop=0 和历史错误答案=0 才能约束 Verifier 假阳性。
uv.lock、Harbor commit、Task digest、镜像 digest 和 Action SHA 分别锁定不同输入,缓存不能替代其中任何一个。- Job 根
result.json不含持久化的 Trial 列表,门禁必须读取逐 Trial 结果,并拒绝异常、缺失、非有限 Reward 和不可观测成本。 - fork PR 不获得模型秘密;不要在特权事件中检出并执行不可信代码。付费评测应使用默认分支、受保护 Environment、专用 Key 和服务端额度。
- 趋势只能比较输入和语义一致的结果;Benchmark 或 Harbor 升级必须开新序列、跑兼容矩阵并预先准备回滚。
28.13 练习
- 为你的一项 Task 写出 Oracle、Nop 和两个历史错误答案的期望 Reward,并解释每个反例防住哪一种假阳性。
- 扩展
ci/gate.py,要求指定的分维 Reward 键全部存在、有限且处于批准范围;为缺键、布尔值、NaN和越界值写负向测试。 - 在测试仓库部署 PR Workflow,故意破坏 Dockerfile、Solution 和 Verifier,记录三个失败分别出现在哪一步;不要提供任何模型秘密。
- 根据团队的 Task 数、attempt、Agent 最大模型调用次数和供应方单价上界,设计 pre-run 预算;再说明哪些变量仍使它只是估算。
- 选择一个 Harbor 候选版本,建立本章兼容矩阵。只运行无模型探针和 Oracle/Nop smoke,写出是否可以进入付费比较阶段的证据结论。