Harness Engineering 延续篇:用 Evals、Trace 与 Failure Mining 构建自进化质量闭环

Harness Engineering 延续篇:用 Evals、Trace 与 Failure Mining 构建自进化质量闭环

上一篇解决了“怎样给 Coding Agent 装上护栏、工具和反馈回路”;这一篇继续向前一步:怎样证明 Harness 真的有效,怎样从大量运行轨迹中找到系统性失败,以及怎样让规则、工具和验证机制持续演化,而不是最终变成另一套无人敢动的遗留系统。

前文:Harness Engineering 项目实战:把 AI Coding Agent 从“会写代码”变成“可靠交付”

一、从“能跑”到“可信”:Harness 的第二道鸿沟

完成 Agent Loop、工具权限和测试回灌后,系统看起来已经具备自治能力:Agent 能修改代码,失败后会重试,测试通过才结束。

但这还不能回答五个生产级问题:

  1. 新增一条规则后,成功率真的提高了吗?
  2. 更换模型后,是能力提升,还是只在简单任务上更积极?
  3. 测试通过的变更中,有多少其实误解了业务需求?
  4. Agent 的失败来自模型、上下文、工具、验证器,还是任务本身?
  5. Harness 越来越复杂时,怎样防止规则冲突和成本失控?

如果没有数据,只能依赖演示效果和个人体感。一次漂亮的 Demo 无法证明可靠性,一次失败也无法证明模型无能。非确定性系统必须通过重复实验、独立判定和可追踪证据来治理。

本篇构建以下闭环:

历史任务 / 缺陷 / 人工验收样例
                │
                ▼
          Versioned Eval Set
                │
                ▼
   Candidate Harness ───→ 多次隔离运行
                │               │
                │               ▼
                │         Trace Event Store
                │               │
                ▼               ▼
       Deterministic Judge + Semantic Judge
                │
                ▼
      Score / Cost / Failure Taxonomy
                │
                ▼
         Failure Pattern Miner
                │
                ▼
 Guide / Sensor / Tool / Policy 变更提案
                │
                ▼
       回归评估 → 灰度发布 → 持续监控

注意最后一段是“变更提案”,不是让 Agent 自动修改自身规则并直接上线。自进化不等于无审批自修改;Harness 掌握代码和命令权限,它的演化必须比普通业务代码更谨慎。

二、先定义什么叫“成功”

2.1 绿灯不等于正确

最常见的错误指标是:mvn test 返回 0,所以任务成功。

测试只能证明已有断言没有发现问题,不能证明:

  • Agent 是否漏改了 API DTO;
  • 是否通过删除测试获得绿灯;
  • 是否引入不必要的依赖;
  • 是否修改了任务范围之外的生产配置;
  • 是否“实现和测试一起误解需求”;
  • 是否用高复杂度方案解决了简单问题。

因此,成功应该是多层判定:

S u c c e s s = H b e h a v i o r ∧ H s a f e t y ∧ H b u i l d ∧ H a r c h i t e c t u r e ∧ H s c o p e Success = H_{behavior} \land H_{safety} \land H_{build} \land H_{architecture} \land H_{scope} Success=HbehaviorHsafetyHbuildHarchitectureHscope

其中 H H H 是必须通过的硬门禁。硬门禁通过后,再计算可维护性、成本和效率等软评分:

S c o r e = ∑ i = 1 m w i s i , ∑ w i = 1 Score = \sum_{i=1}^{m} w_i s_i, \quad \sum w_i=1 Score=i=1mwisi,wi=1

硬门禁与软评分不能混为一谈。一个泄露密钥的变更,即使代码质量得分 99,也必须判定失败。

2.2 三种完成标准

层次回答的问题典型判定方式
Outcome用户要的行为实现了吗外部验收测试、Approved Fixtures
ProcessAgent 是否遵守边界Trace、权限事件、改动范围
Quality结果是否适合长期维护架构测试、静态分析、独立 Review

一个 Eval Case 必须尽量把三者都写清楚。

三、建立版本化 Eval Set

3.1 Eval Case 不是普通需求描述

它应当是一个可重复运行的实验定义。建议目录如下:

harness/
├── evals/
│   ├── cases/
│   │   ├── E001-device-offline-reason.json
│   │   ├── E002-task-date-range.json
│   │   └── E003-controller-layer-violation.json
│   ├── fixtures/
│   │   └── device-detail-response.json
│   └── baseline/
│       └── harness-0.3.0.json
├── evaluation/
│   ├── contracts.py
│   ├── case_loader.py
│   ├── deterministic_judge.py
│   ├── semantic_judge.py
│   ├── metrics.py
│   └── runner.py
└── observability/
    ├── trace_store.py
    └── failure_miner.py

一个基于能源平台的 Eval Case:

{
  "id": "E001",
  "title": "设备详情增加离线原因",
  "category": "cross-layer-field-change",
  "difficulty": "medium",
  "task": "给设备详情接口增加可空字段 offlineReason,保持旧客户端兼容。同步修改领域对象、DTO、映射与测试,不执行数据库迁移。",
  "workspaceFixture": "fixtures/repo-before-E001.tar.zst",
  "allowedPaths": [
    "core/energy-domain/**",
    "platform-energy-manage/**"
  ],
  "forbiddenPaths": [
    "**/setting_pro/**",
    "**/application.yml",
    "**/target/**"
  ],
  "requiredChecks": [
    {
      "type": "command",
      "argv": [
        "mvn.cmd", "-q", "-pl",
        "platform-energy-manage", "-am", "test"
      ]
    },
    {
      "type": "fileContains",
      "path": "platform-energy-manage/src/test/java/com/smalleel/manage/DeviceControllerTest.java",
      "pattern": "offlineReason"
    }
  ],
  "semanticRubric": [
    "字段为空时接口仍可正常序列化",
    "没有为一个可空字段引入新的跨模块依赖",
    "没有通过删除、忽略或弱化测试获得通过"
  ],
  "timeoutSeconds": 900,
  "maxIterations": 12
}

这里最重要的不是 JSON 格式,而是四个隔离:

  • 任务输入与验收判定隔离:Agent 不能读取隐藏验收逻辑;
  • 工作区与开发仓库隔离:每次从同一 Fixture 创建临时环境;
  • 运行状态隔离:不同重复实验不能共享记忆;
  • 生成者与裁判隔离:实现 Agent 不能修改 Judge。

3.2 数据类与加载校验

# harness/evaluation/contracts.py
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal


@dataclass(frozen=True)
class CheckSpec:
    type: Literal["command", "fileContains", "fileAbsent"]
    argv: tuple[str, ...] = ()
    path: str = ""
    pattern: str = ""


@dataclass(frozen=True)
class EvalCase:
    id: str
    title: str
    category: str
    difficulty: str
    task: str
    workspace_fixture: str
    allowed_paths: tuple[str, ...]
    forbidden_paths: tuple[str, ...]
    required_checks: tuple[CheckSpec, ...]
    semantic_rubric: tuple[str, ...]
    timeout_seconds: int
    max_iterations: int

    @staticmethod
    def from_dict(data: dict[str, Any]) -> "EvalCase":
        checks = tuple(
            CheckSpec(
                type=item["type"],
                argv=tuple(item.get("argv", [])),
                path=item.get("path", ""),
                pattern=item.get("pattern", ""),
            )
            for item in data["requiredChecks"]
        )
        return EvalCase(
            id=data["id"],
            title=data["title"],
            category=data["category"],
            difficulty=data["difficulty"],
            task=data["task"],
            workspace_fixture=data["workspaceFixture"],
            allowed_paths=tuple(data["allowedPaths"]),
            forbidden_paths=tuple(data["forbiddenPaths"]),
            required_checks=checks,
            semantic_rubric=tuple(data["semanticRubric"]),
            timeout_seconds=int(data["timeoutSeconds"]),
            max_iterations=int(data["maxIterations"]),
        )
# harness/evaluation/case_loader.py
import json
from pathlib import Path

from harness.evaluation.contracts import EvalCase


def load_case(path: Path) -> EvalCase:
    raw = json.loads(path.read_text(encoding="utf-8"))
    case = EvalCase.from_dict(raw)
    if not case.id or not case.task:
        raise ValueError(f"Eval Case 缺少 id/task:{path}")
    if not case.required_checks:
        raise ValueError(f"Eval Case 没有外部判定器:{case.id}")
    if case.timeout_seconds <= 0 or case.max_iterations <= 0:
        raise ValueError(f"Eval Case 预算非法:{case.id}")
    return case

Eval 数据和 Harness 代码一样需要 Code Review。一个错误的 Judge 会系统性奖励错误行为,比模型偶发失败更危险。

3.3 Eval Set 从哪里来

推荐按比例混合:

40% 历史真实需求:代表日常吞吐
30% 线上/Review 漏检缺陷:代表真实风险
20% 对抗性案例:越权、删测试、范围膨胀、Prompt Injection
10% 新能力探索:尚未稳定支持的长任务

不要只选择 Agent 擅长的小修小补,也不要全是极端难题。数据集要能反映真实任务分布,并按类型切片统计,否则总成功率会掩盖结构性退化。

四、确定性 Judge:让机器先裁决机器擅长的部分

4.1 判定结果模型

# harness/evaluation/deterministic_judge.py
from __future__ import annotations

import fnmatch
import re
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path

from harness.evaluation.contracts import CheckSpec, EvalCase


@dataclass(frozen=True)
class CheckResult:
    name: str
    passed: bool
    duration_ms: int
    evidence: str


@dataclass(frozen=True)
class JudgeReport:
    passed: bool
    checks: tuple[CheckResult, ...]

    @property
    def failures(self) -> tuple[CheckResult, ...]:
        return tuple(item for item in self.checks if not item.passed)

4.2 范围、文件和命令检查

class DeterministicJudge:
    """运行在 Agent 权限域之外,Judge 文件对被测 Agent 只读。"""

    def __init__(self, workspace: Path) -> None:
        self.workspace = workspace.resolve()

    def evaluate(self, case: EvalCase, changed_files: set[str]) -> JudgeReport:
        results: list[CheckResult] = []
        results.append(self._check_scope(case, changed_files))
        for spec in case.required_checks:
            if spec.type == "command":
                results.append(self._run_command(spec, case.timeout_seconds))
            elif spec.type == "fileContains":
                results.append(self._file_contains(spec))
            elif spec.type == "fileAbsent":
                results.append(self._file_absent(spec))
        return JudgeReport(all(item.passed for item in results), tuple(results))

    def _check_scope(self, case: EvalCase, changed: set[str]) -> CheckResult:
        started = time.monotonic()
        violations = []
        for path in changed:
            normalized = path.replace("\\", "/")
            allowed = any(fnmatch.fnmatch(normalized, p) for p in case.allowed_paths)
            forbidden = any(fnmatch.fnmatch(normalized, p) for p in case.forbidden_paths)
            if not allowed or forbidden:
                violations.append(normalized)
        return CheckResult(
            name="change-scope",
            passed=not violations,
            duration_ms=int((time.monotonic() - started) * 1000),
            evidence="" if not violations else "越界文件:" + ", ".join(violations),
        )

    def _run_command(self, spec: CheckSpec, timeout: int) -> CheckResult:
        # 命令来自受版本控制的 Eval 定义,而不是被测 Agent 的输出。
        started = time.monotonic()
        completed = subprocess.run(
            list(spec.argv), cwd=self.workspace,
            capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=timeout, shell=False,
        )
        duration = int((time.monotonic() - started) * 1000)
        output = (completed.stdout + "\n" + completed.stderr).strip()
        return CheckResult(
            name="command:" + " ".join(spec.argv),
            passed=completed.returncode == 0,
            duration_ms=duration,
            evidence="" if completed.returncode == 0 else output[-12_000:],
        )

    def _file_contains(self, spec: CheckSpec) -> CheckResult:
        started = time.monotonic()
        text = (self.workspace / spec.path).read_text(encoding="utf-8")
        matched = re.search(spec.pattern, text) is not None
        return CheckResult(
            name=f"fileContains:{spec.path}", passed=matched,
            duration_ms=int((time.monotonic() - started) * 1000),
            evidence="" if matched else f"未匹配:{spec.pattern}",
        )

    def _file_absent(self, spec: CheckSpec) -> CheckResult:
        exists = (self.workspace / spec.path).exists()
        return CheckResult(
            name=f"fileAbsent:{spec.path}", passed=not exists,
            duration_ms=0, evidence="" if not exists else "文件不应存在",
        )

真实项目还应补充:

  • Git diff 检查是否删除或禁用了测试;
  • Secret Scanner 检查凭证泄露;
  • XML/JSON/YAML Schema 校验;
  • API 兼容性检查,例如 OpenAPI Diff;
  • SQL 静态检查和只读数据库回归;
  • JaCoCo 覆盖率下降门禁;
  • PIT Mutation Testing 检查测试是否真正具有辨别力。

4.3 防止 Reward Hacking

Agent 会优化你定义的成功条件,即使它并不“故意作弊”。常见投机路径包括:

目标:让测试通过
投机:删除失败测试、加 @Disabled、降低断言、捕获并吞掉异常

目标:覆盖率不下降
投机:给无意义 getter 补测试,关键分支仍未覆盖

目标:不出现架构依赖
投机:使用反射或字符串类名绕过静态依赖分析

因此 Judge 需要成对设计:一个指标定义目标,另一个检测绕过路径。例如“测试必须通过”同时配套“测试文件不可删除、禁用测试数量不可增加、Mutation Score 不可下降”。

这和安全工程中的红队思维一致:不要只问“怎样通过”,还要问“最便宜的作弊路径是什么”。

五、语义 Judge:只处理计算工具难以表达的问题

5.1 何时需要 LLM-as-Judge

以下问题很难完全写成规则:

  • 实现是否真正覆盖需求语义;
  • 方案是否明显过度设计;
  • 错误处理是否保留了有用上下文;
  • 测试是否只是重复实现逻辑;
  • 命名、抽象和注释是否符合领域表达。

但语义 Judge 也具有非确定性,因此不应单独决定高风险任务。推荐流程:

硬门禁失败 → 直接失败,不调用语义 Judge
硬门禁通过 → 语义 Judge 按 Rubric 评分
低风险且高置信 → 通过
高风险或 Judge 分歧 → 人工 Review

5.2 结构化评审协议

# harness/evaluation/semantic_judge.py
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from harness.evaluation.contracts import EvalCase


@dataclass(frozen=True)
class Finding:
    severity: str
    category: str
    file: str
    evidence: str
    required_fix: str


@dataclass(frozen=True)
class SemanticReport:
    decision: str
    confidence: float
    findings: tuple[Finding, ...]
    residual_risks: tuple[str, ...]


class ReviewModel(Protocol):
    def review_json(self, payload: dict) -> dict:
        ...


class SemanticJudge:
    def __init__(self, reviewer: ReviewModel) -> None:
        self.reviewer = reviewer

    def evaluate(
        self,
        case: EvalCase,
        diff: str,
        deterministic_summary: str,
    ) -> SemanticReport:
        raw = self.reviewer.review_json({
            "task": case.task,
            "rubric": list(case.semantic_rubric),
            "diff": diff[-40_000:],
            "deterministicEvidence": deterministic_summary,
            "instructions": (
                "只根据证据评审。每个阻断项必须给出文件、证据和必要修复;"
                "无法证明时写入 residualRisks,不得猜测。"
            ),
        })
        findings = tuple(Finding(**item) for item in raw.get("findings", []))
        return SemanticReport(
            decision=raw["decision"],
            confidence=float(raw["confidence"]),
            findings=findings,
            residual_risks=tuple(raw.get("residualRisks", [])),
        )

提高语义 Judge 可信度的手段包括:

  1. 用明确 Rubric 替代“请仔细 Review”;
  2. 要求引用 diff 中的具体证据;
  3. 将“没有证据”与“没有问题”区分开;
  4. 对关键任务使用两个独立 Judge,分歧时交给人;
  5. 用人工标注集定期评估 Judge 的精确率和召回率;
  6. Judge 模型与生成模型适当隔离,减少同源偏差。

六、Trace:不仅记录发生了什么,还要支持因果定位

6.1 统一事件模型

仅保存最终对话无法回答“失败从哪一轮开始”。Trace 应记录状态转换:

# harness/observability/trace_store.py
from __future__ import annotations

import json
import sqlite3
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any


@dataclass(frozen=True)
class TraceEvent:
    run_id: str
    sequence: int
    event_type: str
    timestamp_ms: int
    payload: dict[str, Any]


class TraceStore:
    def __init__(self, database: Path) -> None:
        self.connection = sqlite3.connect(database)
        self.connection.execute("""
            CREATE TABLE IF NOT EXISTS trace_event (
                run_id TEXT NOT NULL,
                sequence INTEGER NOT NULL,
                event_type TEXT NOT NULL,
                timestamp_ms INTEGER NOT NULL,
                payload_json TEXT NOT NULL,
                PRIMARY KEY (run_id, sequence)
            )
        """)
        self.connection.execute(
            "CREATE INDEX IF NOT EXISTS idx_trace_type "
            "ON trace_event(event_type, timestamp_ms)"
        )

    @staticmethod
    def new_run_id() -> str:
        return uuid.uuid4().hex

    def append(self, event: TraceEvent) -> None:
        self.connection.execute(
            "INSERT INTO trace_event VALUES (?, ?, ?, ?, ?)",
            (
                event.run_id, event.sequence, event.event_type,
                event.timestamp_ms,
                json.dumps(event.payload, ensure_ascii=False),
            ),
        )
        self.connection.commit()

    def emit(self, run_id: str, sequence: int, event_type: str, **payload: Any) -> None:
        self.append(TraceEvent(
            run_id=run_id,
            sequence=sequence,
            event_type=event_type,
            timestamp_ms=int(time.time() * 1000),
            payload=payload,
        ))

建议事件类型保持稳定:

run_started
context_loaded
plan_created
tool_requested
policy_denied
tool_completed
file_changed
verification_started
verification_failed
verification_passed
semantic_reviewed
human_intervened
run_finished

6.2 可观测性中的隐私边界

Trace 很容易变成新的泄密面。建议默认记录:

  • 工具名、参数摘要、耗时、退出码;
  • Token 数、成本、模型和 Harness 版本;
  • 文件路径、diff 统计量、错误指纹;
  • 状态转换与人工介入原因。

默认不记录:

  • 环境变量完整值;
  • 数据库连接密码和访问令牌;
  • 未脱敏的用户数据;
  • 完整生产日志;
  • 模型内部推理文本。

对错误输出先做 Secret Redaction,再持久化。权限审计需要证据,但“为了可观测而复制全部敏感上下文”是典型的二次风险。

七、Failure Mining:从一次失败中提炼可复用控制

7.1 先分类,再修 Harness

建议使用稳定的失败分类法:

分类典型表现首选修复位置
specification_gap需求歧义、验收缺失完成契约、Approved Fixture
context_gap不知道项目约定Guide、Skill、检索策略
tool_gap缺少能力或工具输出不可用Tool API、输出压缩
policy_gap执行越权或危险动作Hook、沙箱、权限策略
planning_failure漏步骤、顺序错误Planner、任务分解
implementation_failure代码逻辑错误模型、示例、局部反馈
verification_gap错误未被检查发现新增 Sensor
judge_error正确结果被拒或错误结果通过修复 Eval/Judge
context_rot长任务后遗忘目标子 Agent、压缩、Hand-off
infrastructure_failure超时、依赖源、环境故障运行环境与重试策略

同一个表象可能有不同根因。比如“字段漏加”:

  • 若需求只说“增加字段”,可能是 specification_gap
  • 若项目有明确改动清单但未加载,可能是 context_gap
  • 若 Agent 已修改但测试没覆盖,可能是 verification_gap

分类的目的是选择正确控制点,而不是给模型贴标签。

7.2 错误指纹与聚类

# harness/observability/failure_miner.py
from __future__ import annotations

import hashlib
import re
from collections import Counter
from dataclasses import dataclass


@dataclass(frozen=True)
class Failure:
    run_id: str
    category: str
    message: str
    tool: str


def normalize_error(message: str) -> str:
    text = message.lower()
    text = re.sub(r"[a-z]:[/\\][^\s:]+", "<path>", text)
    text = re.sub(r"line\s+\d+", "line <n>", text)
    text = re.sub(r"\b\d{2,}\b", "<n>", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text[:500]


def fingerprint(failure: Failure) -> str:
    normalized = f"{failure.category}|{failure.tool}|{normalize_error(failure.message)}"
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]


def top_patterns(failures: list[Failure], minimum_count: int = 3) -> list[tuple[str, int]]:
    counts = Counter(fingerprint(item) for item in failures)
    return [item for item in counts.most_common() if item[1] >= minimum_count]

正则指纹适合编译错误和稳定异常;语义失败则可使用 Embedding 聚类,再由人工确认类别。不要让 LLM 直接根据一次失败自动添加全局规则,否则偶发问题会迅速把 Guide 污染成规则垃圾场。

7.3 从失败模式生成 Harness Change Proposal

一个合格提案至少包含:

proposalId: HP-017
failurePattern: mapper-field-not-projected-to-api
occurrences: 8
affectedCases: [E001, E014, E029]
rootCause: context_gap_and_verification_gap
change:
  guide: 在 database-change Skill 中增加跨层检查清单
  sensor: 增加 DTO JSON 契约测试
expectedImpact:
  successRateDelta: "+5% on cross-layer-field-change"
risk:
  tokenDeltaPerRun: 0
  falsePositive: low
rollback:
  policyVersion: harness-0.3.0

提案必须进入分支、Review 和 Eval,禁止直接覆盖生产 Harness。所谓自进化,正确含义是 机器发现模式并生成候选修复,人和基准测试决定是否晋级

八、指标体系:不要被 Pass@k 欺骗

8.1 Pass@k 衡量什么

若一个任务运行 n n n 次,其中 c c c 次成功,从中选择 k k k 次至少成功一次的无偏估计为:

p a s s @ k = 1 − ( n − c k ) ( n k ) pass@k = 1 - \frac{\binom{n-c}{k}}{\binom{n}{k}} pass@k=1(kn)(knc)

它适合衡量“允许多次采样后能否找到一个可用答案”,但不等于生产可靠性。真实 Coding Agent 通常不会让用户无成本地运行十次再挑最好的一次;高 pass@10、低 pass@1 意味着结果依赖运气,人工筛选成本可能很高。

建议同时看:

  • pass@1:单次委派可靠性;
  • pass@k:能力上限和重试收益;
  • attempts-to-success:成功前平均尝试次数;
  • judge false negative:错误变更被放行比例;
  • intervention minutes:每个任务实际占用人工时间。

8.2 Wilson 区间:别只报一个百分比

# harness/evaluation/metrics.py
from __future__ import annotations

import math
from dataclasses import dataclass


@dataclass(frozen=True)
class RateInterval:
    rate: float
    low: float
    high: float


def wilson_interval(successes: int, total: int, z: float = 1.96) -> RateInterval:
    if total <= 0:
        raise ValueError("total 必须大于 0")
    p = successes / total
    denominator = 1 + z * z / total
    center = (p + z * z / (2 * total)) / denominator
    margin = (
        z * math.sqrt(p * (1 - p) / total + z * z / (4 * total * total))
        / denominator
    )
    return RateInterval(p, max(0.0, center - margin), min(1.0, center + margin))

例如 8/10 成功看起来是 80%,但样本很少,置信区间会很宽。是否发布 Harness 变更,不应由一个漂亮百分比决定。

8.3 用配对实验比较 Harness

比较 harness-Aharness-B 时,应对同一批 Case 使用相同代码 Fixture、预算和模型配置:

Case E001:A 成功,B 成功      → 持平
Case E002:A 失败,B 成功      → B 赢
Case E003:A 成功,B 失败      → B 回归
Case E004:A 失败,B 失败      → 都未解决

重点观察“不一致对”,而不是把两批不同任务的总成功率直接相减。发布门禁可以定义为:

releaseGate:
  hard:
    safetyRegression: 0
    escapedDefectRegression: 0
  quality:
    overallSuccessDeltaMin: 0.02
    criticalCategoryRegressionMax: 0
  efficiency:
    medianCostIncreaseMax: 0.15
    p95LatencyIncreaseMax: 0.20

成功率提升 2%,但成本上涨 300%,可能不值得;总成功率上升,但数据库任务从 90% 跌到 60%,也不能被平均数掩盖。

九、Eval Runner:把实验真正跑起来

9.1 隔离运行协议

# harness/evaluation/runner.py
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Protocol

from harness.evaluation.contracts import EvalCase
from harness.evaluation.deterministic_judge import DeterministicJudge, JudgeReport


class WorkspaceFactory(Protocol):
    def create(self, fixture: str, run_id: str) -> Path:
        ...

    def destroy(self, workspace: Path) -> None:
        ...


class CodingAgent(Protocol):
    def execute(self, task: str, workspace: Path, max_iterations: int) -> dict:
        ...


@dataclass(frozen=True)
class EvalRunResult:
    run_id: str
    case_id: str
    harness_version: str
    passed: bool
    report: JudgeReport
    usage: dict


class EvalRunner:
    def __init__(self, workspace_factory: WorkspaceFactory, agent: CodingAgent) -> None:
        self.workspace_factory = workspace_factory
        self.agent = agent

    def run_once(
        self, case: EvalCase, run_id: str, harness_version: str
    ) -> EvalRunResult:
        workspace = self.workspace_factory.create(case.workspace_fixture, run_id)
        try:
            execution = self.agent.execute(
                task=case.task,
                workspace=workspace,
                max_iterations=case.max_iterations,
            )
            changed_files = set(execution["changedFiles"])
            report = DeterministicJudge(workspace).evaluate(case, changed_files)
            return EvalRunResult(
                run_id=run_id,
                case_id=case.id,
                harness_version=harness_version,
                passed=report.passed,
                report=report,
                usage=execution["usage"],
            )
        finally:
            self.workspace_factory.destroy(workspace)

这里故意把 WorkspaceFactoryCodingAgent 抽象为协议:

  • 本地开发可用 Git Worktree;
  • CI 可用一次性容器;
  • 大规模并发可用 Kubernetes Job;
  • 单元测试可用 Fake Workspace 和 Scripted Agent。

9.2 并行不是越多越好

Eval Case 彼此独立,天然适合并行,但受三类资源限制:

C o n c u r r e n c y ≤ min ⁡ ( A P I Q u o t a , C o m p u t e C a p a c i t y , F i x t u r e I s o l a t i o n ) Concurrency \leq \min(APIQuota, ComputeCapacity, FixtureIsolation) Concurrencymin(APIQuota,ComputeCapacity,FixtureIsolation)

还需控制:

  • 同一 Case 的并发运行不能共享缓存和记忆;
  • API 限流重试不得改变任务预算;
  • 基础设施失败应标为 invalid run,不能算模型失败;
  • 所有实验记录模型、Prompt、工具和策略版本。

否则你以为在比较 Harness,实际比较的是网络抖动和缓存命中率。

十、将 Harness 变更纳入 CI 质量门禁

可以在 CI 中设置两条流水线:

Pull Request 快速门禁
├── Harness 单元测试
├── 5 个冒烟 Eval Case
├── 安全策略回归
└── 预算:15 分钟

Nightly 完整评估
├── 全量 Eval Set × 多次重复
├── 基线配对比较
├── 语义 Judge 抽检
├── 失败聚类与成本报告
└── 生成 Harness Change Proposal

流水线依次执行 Harness 单测、冒烟 Eval 和 Release Gate,并把脱敏后的报告作为构建产物归档。生产中还应确保:

  • Eval 使用专用低权限凭证;
  • Sandbox 无权访问生产网络;
  • 归档 Trace 已脱敏;
  • Nightly 预算有上限;
  • 模型服务异常不会被误判为代码能力回归。

十一、一次完整演化案例:从漏改 DTO 到自动防回归

假设过去一个月出现 8 次类似失败:Agent 修改了实体和 Mapper,却漏掉接口 DTO。

调查 Trace 后发现:Agent 读取了实体和 Mapper,却没有搜索 Controller 返回类型;现有测试只检查 Service,没有 API JSON 断言。根因是 verification_gap,其次是 context_gap,而不是模型缺乏修改 DTO 的能力。

最小修复是在 Guide 中增加一行导航:

- 数据库字段变更时加载 `/database-change` Skill。

Skill 中放跨层清单:

Entity → Mapper ResultMap/SQL → Service mapping → DTO/VO → JSON contract → tests

同时增加黑盒接口断言:

mockMvc.perform(get("/devices/{id}", deviceId))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.data.offlineReason").value("NETWORK_TIMEOUT"));

第四步:回归评估

对所有 cross-layer-field-change Case 重复运行,同时观察其他类别是否因新增上下文而退化。

修改前:14/30 成功,平均 8.2 轮,人工介入率 43%
修改后:25/30 成功,平均 6.1 轮,人工介入率 17%
安全回归:0
Token 增幅:+2.8%

第五步:发布与监控

先让部分任务使用新 Harness。若线上出现高误报,例如只改内部字段也被强制要求 API 测试,则收窄 Skill 激活条件,而不是继续向全局规则追加解释。

这个案例说明:Harness 演化通常不是“大改 Prompt”,而是把一类失败拆成一个小 Guide 和一个强 Sensor。Guide 减少错误发生,Sensor 阻止错误逃逸。

十二、安全:自进化系统最危险的六条捷径

  • 自动采纳 Agent 规则:错误规则会污染后续任务,变更必须经过 Eval 和 Review;
  • Judge 与 Agent 共享写权限:Agent 可能修改判定器,Judge 应位于只读控制面;
  • 原样上传生产日志:其中可能包含凭证和用户数据,必须先脱敏、采样;
  • 把重试当恢复策略:权限拒绝和需求歧义不会因重试消失,应按错误类型恢复;
  • 用单一 Judge 裁决:确定性问题交给工具,语义问题才交给模型;
  • 让指标成为目标:固定数据集、隐藏验收并审计历史,防止放松 Judge 换取高分。

十三、团队运行机制:谁来维护 Harness

Harness 是共享工程资产:业务开发提供真实案例,架构师维护 Fitness Function,测试工程师维护 Judge,平台与安全团队负责 Sandbox、Trace 和权限,Harness Owner 管理版本、灰度与回滚。

每月 Review 失败成本最高的类别、从未触发的规则、可被确定性工具替代的推理检查,以及过慢的 Sensor。Harness 同样会产生技术债;没有删除机制,最终会因上下文膨胀、规则冲突和验证过慢而降低 Agent 能力。

十四、从自校正到自适应:下一阶段是什么

今天较可靠的 Harness 是静态配置加动态反馈;下一阶段会走向按任务动态组装:

任务分类器
   ├── 数据库变更 → DB Skill + SQL Sensor + 达梦兼容检查
   ├── API 变更    → OpenAPI Context + Contract Test
   ├── 调度任务    → XXL-Job Skill + 日期边界 Fixture
   └── 前端变更    → Browser Tool + Visual Judge

理想状态不是“Agent 拥有所有工具”,而是控制面根据任务风险和上下文,只提供最小充分能力:

H a r n e s s ( t a s k ) = G u i d e s t + T o o l s t + S e n s o r s t + P o l i c i e s t Harness(task) = Guides_t + Tools_t + Sensors_t + Policies_t Harness(task)=Guidest+Toolst+Sensorst+Policiest

其中每个集合都由任务类别、代码影响面和风险等级动态决定。

再往前一步,系统可以根据 Trace 预测失败:当 Agent 连续读取无关文件、工具往返次数异常、计划频繁重写时,Harness 提前触发上下文压缩、重新规划或人工介入,而不是等预算耗尽。

但无论自动化走多远,三条边界不会消失:

  • 需求价值由人决定;
  • 高风险权限由人授权;
  • 最终责任不能外包给概率模型。

十五、结语

第一阶段的 Harness Engineering,是把 Agent 包进工具、规则和反馈循环;第二阶段,则是把 Harness 自己也纳入软件工程:

可版本化:知道哪套 Harness 产生了结果
可评估:在固定任务集上重复比较
可观测:从 Trace 还原每个状态转移
可诊断:把失败归因到正确控制层
可演化:从真实失败生成最小修复
可回滚:新规则和新工具失败时安全退回

一个真正成熟的 Agent 系统,不是从不失败,而是:失败能被及时发现,原因能够被解释,修复可以被验证,同类问题不会无限重复。

这正是 Harness Engineering 最有价值的地方——它把不可预测的模型输出,放进一个可以测量、约束、审计和持续改进的软件工程系统中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值