跳到主内容
创建时间:最后更新:作者:白川

建立基线,用对照实验验证改动

固定协议、配对任务并重复运行,用完整观测解释分差与波动。

建立基线,用对照实验验证改动

改了一句提示词后任务成功了,只能说明这一次成功。要判断新版本是否更好,还需要知道旧版本在同一任务上会怎样、各自运行了多少次,以及有没有被漏掉的失败。

这篇建立 baseline/candidate 成对比较。先看队列是否完整,再看质量差异;不把缺失结果当成零分,也不把它悄悄删掉。

独立示例位于仓库 columns/coding-agent/examples/evaluation-optimization/,固定 Pi 相关依赖为 1.5.0。先在该目录执行 npm install,下文命令也从此处运行。离线样本不需要密钥;真实模型入口另行配置,不会由测试自动触发。

实验协议先于结果

协议记录任务、模型标识、两组配置、重复数、夹具内容和教学评分器版本,并计算 SHA-256。摘要帮助识别配置变化,不能证明运行产物可信,也不能消除远程模型服务更新与负载波动。

本例每个任务按重复序号交替执行两组:第一次 baseline 在前,第二次 candidate 在前。它降低某些顺序影响,不是严格随机化,更不能让两次模型生成共享同一个随机过程。

一个 arm 对应一条有效观测

完整实现位于 src/comparison.ts:

ts
export interface Arm {
  caseId: string
  variant: "baseline" | "candidate"
  repetition: number
}
export type Observation = Arm & { protocol: string } & (
    | {
        state: "scored"
        passed: boolean
        budgetExceeded?: boolean
        durationMs?: number
        estimatedCost?: number
      }
    | { state: "infra-error"; reason: string }
  )
const key = (arm: Arm) => JSON.stringify([arm.caseId, arm.repetition, arm.variant])

export function plan(caseIds: string[], repetitions: number): Arm[] {
  if (
    caseIds.length === 0 ||
    new Set(caseIds).size !== caseIds.length ||
    caseIds.some((id) => !id) ||
    !Number.isSafeInteger(repetitions) ||
    repetitions < 1 ||
    repetitions > 10
  ) {
    throw new Error("任务标识或重复次数无效;教学入口最多重复 10 次")
  }
  return caseIds.flatMap((caseId) =>
    Array.from({ length: repetitions }, (_, index) => {
      const variants: Arm["variant"][] =
        index % 2 === 0 ? ["baseline", "candidate"] : ["candidate", "baseline"]
      return variants.map((variant) => ({ caseId, variant, repetition: index + 1 }))
    }).flat(),
  )
}

export function compare(expected: Arm[], observed: Observation[], protocol: string) {
  if (!protocol || new Set(expected.map(key)).size !== expected.length) {
    throw new Error("协议或计划无效")
  }
  const expectedKeys = new Set(expected.map(key))
  const issues: string[] = []
  for (const row of observed) {
    if (row.protocol !== protocol || !expectedKeys.has(key(row))) {
      issues.push("出现协议不匹配或计划外 observation")
    }
  }
  const valid: Array<Extract<Observation, { state: "scored" }>> = []
  for (const arm of expected) {
    const rows = observed.filter((row) => key(row) === key(arm))
    const row = rows[0]
    if (rows.length !== 1 || row?.state !== "scored" || row.protocol !== protocol) {
      issues.push(`${key(arm)} 缺失、重复、未评分或基础设施异常`)
    } else if (
      typeof row.passed !== "boolean" ||
      [row.durationMs, row.estimatedCost].some(
        (value) => value !== undefined && (!Number.isFinite(value) || value < 0),
      )
    ) {
      issues.push(`${key(arm)} 指标无效`)
    } else {
      valid.push(row)
    }
  }
  const summaries = (["baseline", "candidate"] as const).map((variant) => {
    const rows = valid.filter((row) => row.variant === variant)
    const knownCosts = rows.flatMap((row) =>
      row.estimatedCost === undefined ? [] : [row.estimatedCost],
    )
    return {
      variant,
      scored: rows.length,
      passRate:
        issues.length > 0 || rows.length === 0
          ? null
          : rows.filter((row) => row.passed).length / rows.length,
      costKnownTotal:
        knownCosts.length === 0 ? null : knownCosts.reduce((sum, value) => sum + value, 0),
      costAvailable: knownCosts.length,
    }
  })
  const baseline = summaries[0].passRate
  const candidate = summaries[1].passRate
  const flakyCases = [...new Set(valid.map((row) => row.caseId))].filter((caseId) =>
    ["baseline", "candidate"].some(
      (variant) =>
        new Set(
          valid
            .filter((row) => row.caseId === caseId && row.variant === variant)
            .map((row) => row.passed),
        ).size > 1,
    ),
  )
  return {
    complete: issues.length === 0 && expected.length > 0,
    issues,
    summaries,
    flakyCases,
    liftPercentagePoints:
      baseline === null || candidate === null ? null : (candidate - baseline) * 100,
  }
}

缺失、重复、协议不匹配、基础设施异常和无效指标都会阻止 headline。预算超限若属于预先定义的任务约束,则记录为有评分的失败;请求断开导致无法判断,则保持运行异常。

官方报告按评测集合处理 blocked pair。本例为单套教学队列,任意不完整 arm 都会阻止整套 headline,两者范围不同。不要将这里的 JSON 文件当作官方 vitest-evals 报告。

分差是什么,什么还不知道

liftPercentagePoints 是候选成功率减去基线成功率后的百分点差。75% 到 100% 是增加 25 个百分点,不是“提升了 25%”的相对变化;也不意味着所有任务都变好。

应同时查看逐任务的得失,以及同一变体同一任务多次成功/失败的波动。flakyCases 只说明观察到结果变化,没有给出原因。一次重复既不能证明稳定,也不足以计算可靠的置信结论。

至少成功一次与每次都成功回答不同问题。报告更多尝试时,应说清关注的是搜索机会还是可靠完成,不用“多试几次总能成功”的数字替代单次成功率。

运行与产物

bash
npx tsx src/live.ts prompt 2 > live-report.json

这会实际运行十六个 arm。没有预算时先用 offline 入口检查报告逻辑;其预设分差只属于示意数据。JSON 重定向使用直接脚本入口,避免 npm 启动日志混入报告。

官方文档实验的 treatment 不只是文档文本:without_docs 还改变了提示中的文档路由。本系列的 prompt/tool/context 试验分别只修改一个配置维度,并由测试核对,但环境仍不可能完全无噪声。小样本正差应作为继续验证的线索,而非确定因果收益。

依据与版本

官方本地快照为 8efd314fa,evals 包标记 1.3.0;本系列示例另按发布包 1.5.0 核验。官方 evals 是 private 工作区包,教程使用自定义教学协议,不冒充稳定公共评测 SDK。