Article

AI Harness 实战 01:从 0 到 1 搭建可重复、可对比、可回归的评测基线

当 AI 能力进入真实工程后,团队很快会遇到同一个瓶颈:

  • 这次 Prompt 改动到底是变好还是变差?
  • 为什么线上突然出现“看起来随机”的质量波动?
  • 两个模型版本怎么公平对比?
  • 失败案例怎么沉淀成下一次优化输入?

如果这些问题只能靠感觉回答,你就还没有真正进入“工程化 AI 开发”。

这篇作为 Harness 系列第一篇,只做一件事:搭出一个可跑、可复现、可对比的评测基线。

一、为什么必须先做 Harness

很多团队会先做路由、自治、Multi-Agent 编排,但没有 Harness,这些优化几乎都不可验证。

常见后果:

  1. 优化靠主观判断
  • “感觉快了”“感觉更聪明了”,但没有稳定数据。
  1. 回归无法提前发现
  • 某次小改动导致关键场景退化,只在生产后才暴露。
  1. 团队协作失真
  • A 说模型变好,B 说变差,最终无法达成工程共识。

Harness 的本质不是“多跑几条测试”,而是建立统一、稳定、可追溯的质量证据链。

二、Harness 最小架构(MVP)

先用最小结构跑起来,不要上来就做复杂平台:

  • Case:标准化样本输入
  • Runner:统一执行流程
  • Judge:统一打分逻辑
  • Report:统一输出结果
  • Baseline:可对比历史版本
testcases/ -> Runner -> Model/Agent -> Judge -> report.json/report.md
                               |
                               +-> baseline compare

只要这条链路稳定,你后续做任何优化都能被量化。

三、样本集设计:先覆盖“高价值失败点”

第一版不追求样本量大,追求样本结构合理。

建议三层:

  1. 黄金样本(Golden Cases)
  • 团队确认的标准答案场景,用于稳定基线。
  1. 历史失败样本(Failure Replay)
  • 来自线上事故或返工案例,优先覆盖。
  1. 对抗样本(Adversarial Cases)
  • 刻意构造歧义、越权、脏输入,验证安全边界。

样本 JSON 示例

{
  "id": "case_001",
  "workflow": "code_review",
  "input": "请审查这段并发代码是否有竞态风险",
  "context": {
    "language": "csharp",
    "policy": "review-v2"
  },
  "expected": {
    "must_include": ["共享状态", "锁粒度"],
    "must_not_include": ["无依据断言"]
  },
  "weight": 1.0,
  "risk_level": "high"
}

四、指标体系:先小而硬,不要大而虚

第一版建议只保留 5 个核心指标:

  1. PassRate:规则通过率
  2. ActionExecutability:输出可执行率
  3. PolicyViolationRate:策略违规率
  4. P95LatencyMs:尾延迟
  5. CostPerPassedCase:每个通过样本成本

这 5 个指标能同时覆盖:质量、稳定性、合规、成本。

五、C# 最小 Runner 骨架

先实现一个 CLI Runner,后续再接 CI/CD。

using System.Text.Json;

public sealed class HarnessCase
{
    public string Id { get; set; } = string.Empty;
    public string Workflow { get; set; } = string.Empty;
    public string Input { get; set; } = string.Empty;
    public Dictionary<string, string> Context { get; set; } = new();
    public ExpectedSpec Expected { get; set; } = new();
}

public sealed class ExpectedSpec
{
    public List<string> MustInclude { get; set; } = new();
    public List<string> MustNotInclude { get; set; } = new();
}

public sealed class HarnessResult
{
    public string CaseId { get; set; } = string.Empty;
    public bool Passed { get; set; }
    public double LatencyMs { get; set; }
    public double CostUsd { get; set; }
    public List<string> FailedChecks { get; set; } = new();
}

public static class HarnessRunner
{
    public static async Task<int> RunAsync(string caseDir)
    {
        var files = Directory.GetFiles(caseDir, "*.json");
        var results = new List<HarnessResult>();

        foreach (var file in files)
        {
            var json = await File.ReadAllTextAsync(file);
            var testCase = JsonSerializer.Deserialize<HarnessCase>(json)!;

            var started = DateTime.UtcNow;
            var output = await CallModelAsync(testCase); // 你的模型/Agent 调用入口
            var latency = (DateTime.UtcNow - started).TotalMilliseconds;

            var result = Judge(testCase, output, latency, costUsd: 0.0);
            results.Add(result);
        }

        await WriteReportAsync(results);
        return 0;
    }

    private static Task<string> CallModelAsync(HarnessCase c)
    {
        // MVP 阶段可先用 mock,确保框架先跑通
        return Task.FromResult($"mock output for {c.Id}");
    }

    private static HarnessResult Judge(HarnessCase c, string output, double latencyMs, double costUsd)
    {
        var failed = new List<string>();

        foreach (var token in c.Expected.MustInclude)
        {
            if (!output.Contains(token, StringComparison.OrdinalIgnoreCase))
            {
                failed.Add($"missing:{token}");
            }
        }

        foreach (var token in c.Expected.MustNotInclude)
        {
            if (output.Contains(token, StringComparison.OrdinalIgnoreCase))
            {
                failed.Add($"forbidden:{token}");
            }
        }

        return new HarnessResult
        {
            CaseId = c.Id,
            Passed = failed.Count == 0,
            LatencyMs = latencyMs,
            CostUsd = costUsd,
            FailedChecks = failed
        };
    }

    private static async Task WriteReportAsync(List<HarnessResult> results)
    {
        var passRate = results.Count == 0 ? 0 : results.Count(r => r.Passed) * 1.0 / results.Count;
        var report = new
        {
            total = results.Count,
            passRate,
            p95LatencyMs = Percentile(results.Select(r => r.LatencyMs).ToList(), 95),
            costPerPassed = results.Count(r => r.Passed) == 0
                ? 0
                : results.Sum(r => r.CostUsd) / results.Count(r => r.Passed),
            failures = results.Where(r => !r.Passed).Take(20).ToList()
        };

        var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
        await File.WriteAllTextAsync("report.json", json);
    }

    private static double Percentile(List<double> values, int p)
    {
        if (values.Count == 0) return 0;
        values.Sort();
        var rank = (int)Math.Ceiling((p / 100.0) * values.Count) - 1;
        rank = Math.Clamp(rank, 0, values.Count - 1);
        return values[rank];
    }
}

这段骨架先解决“跑得通 + 有结果 + 可复盘”,后续再替换真实模型调用与更强评分器。

六、报告输出:先保证“可比较”

建议每次执行产出两类报告:

  • report.json:给机器读(CI 门禁、趋势分析)
  • report.md:给人读(失败 TopN、回归变化)

report.md 示例

# Harness Report

- total: 120
- passRate: 0.9167
- p95LatencyMs: 1820
- costPerPassed: 0.14

## Failed Top 5
1. case_019 missing:锁粒度
2. case_044 forbidden:无依据断言
3. case_078 missing:回滚策略

关键要求:每次报告都要带 baselineVersionrunId,否则无法长期对比。

七、一周落地清单(可直接执行)

Day 1-2

  • 定义首批 50 条样本(黄金 20 + 失败回放 20 + 对抗 10)

Day 3

  • 接入 MVP Runner,稳定产出 report.json

Day 4

  • 建立基线版本 baseline-v1

Day 5

  • 将 Harness 纳入 PR/发布前门禁(先告警、后阻断)

Day 6-7

  • 做首轮复盘,补齐高频失败样本与策略规则

八、第一篇的结论

Harness 不是锦上添花,而是 AI 工程化的起点。

只要你把“样本标准化、执行可重复、结果可对比、失败可追溯”这四件事做扎实,后续所有优化(模型路由、Agent 编排、Auto-Run)才有真实增益。

下一篇我们进入《Harness 实战 02:评测样本工程(从手工案例到可持续数据飞轮)》,解决样本老化、覆盖不足与标注成本失控问题。