3643 字
约 12 分钟
4
第九讲. 防止 agent 提前宣告完成

第九讲. 防止 agent 提前宣告完成

Agent 有一个系统性倾向:过早宣称任务完成。举例来说,你让它实现"密码重置"功能,它改了数据库 schema、写了 API 端点、加了邮件模板,跑了单元测试全部通过,然后告诉你"做完了"。但实际一跑才发现:密码重置链接发不出去,邮件服务配置缺失;数据库迁移半途失败,schema 不一致;端到端流程根本没走过一遍。

Guo 等人 2017 年在 ICML 上的经典论文证明:现代神经网络系统性地过度自信,模型自报的置信度显著高于实际准确率。AI 编码 agent 也一样,它"觉得"做完了,但实际上差得远。harness 必须用外部化的、基于执行的验证来替代 agent 的"感觉"。

滑坡效应

过早完成声明几乎总是一样的套路:代码看着还行,语法正确、逻辑似乎合理,静态检查没有明显错误。但 harness 没有强制要求全面执行验证,agent 跳过了实际运行或只跑了部分测试。跑了单元测试但跳过集成测试,跑了测试但没检查覆盖率。最后"代码看起来没问题"就被当作了"功能已完成"的证据。

每一步都在丢失信息。从任务规范到代码实现,再到运行时行为,每次转换都可能引入偏差,每次跳过的验证都加剧了信息不对称。

三层终止检查

flowchart LR
    Claim["agent 说:做完了"] --> L1["先跑<br/>lint / typecheck"]
    L1 --> L2["再跑<br/>测试和启动检查"]
    L2 --> L3["最后跑<br/>完整用户流程"]
    L3 --> Done["三层都过才算完成"]
flowchart LR
    A["代码写完了<br/>单测也绿了"] --> B["但应用没真正启动<br/>完整流程也没跑"]
    B --> C["配置、数据库、外部服务问题<br/>还都藏着"]
    C --> D["于是 agent 太早宣告完成"]

核心概念

  • 过早完成声明:agent 断言任务完成,但实际上存在未满足的正确性规范。问题出在 agent 依据代码层面的局部信心做判断,而系统级正确性需要全局验证。
  • 置信度校准偏差:agent 自报的完成信心与实际完成质量之间存在系统性差距。复杂多文件任务中,这个偏差显著为正,agent 总是比实际做得更自信。
  • 终止标准:一组明确的、可执行的判定条件,定义在 harness 里。agent 必须满足所有条件才能声明完成,"完成"从主观判断变成了客观判定。
  • 验证-确认双闸门:第一层验证检查代码是否正确实现了指定行为,第二层确认检查系统级行为是否满足端到端需求,两层都通过才算完成。
  • 运行时反馈信号:来自程序执行的日志、进程状态、健康检查,这是 harness 判定完成质量的客观基础。
  • 完成优先级约束:先验证功能正确性,再处理性能,最后管风格。核心功能没验证通过之前,不许做重构。

单元测试通过 ≠ 任务完成

这是最常见的陷阱,也是最危险的一个。agent 写了代码,跑了单元测试,全部绿色,然后说"做完了"。但单元测试的设计哲学是隔离被测单元、模拟依赖,这恰好使其无法检测跨组件问题:

接口不匹配:渲染进程传给预加载脚本的文件路径是相对路径,但预加载脚本期望绝对路径。各自的单元测试都用了 mock,都通过了,只有端到端跑通时才发现问题。

状态传播错误:数据库迁移改了表结构,但 ORM 的缓存层还持有旧结构的缓存条目。单元测试每次都是全新的 mock 环境,不会暴露这种跨层状态不一致。

环境依赖性:代码在测试环境(一切 mock)行为正确,在真实环境因配置差异、网络延迟、服务不可用而失败。

"顺便重构"是完成判定的毒药

Claude Code 有一个常见行为模式:在核心功能还没验证通过时就开始重构代码、优化性能、改进风格。Knuth 说的"过早优化是万恶之源"在 agent 场景中有了新含义,重构会改变已完成验证和未完成验证之间的边界,可能破坏之前隐式正确的代码路径。

自我评价的系统性偏差

Anthropic 在 2026 年的研究中发现了一个更深层的失败模式:当 agent 被要求评估自己的工作时,它系统性地过度正面评价,即使人类观察者认为质量明显不达标。

这个问题在主观任务(如设计审美)上尤其严重,"布局是否精致"是一个判断题,agent 可靠地偏向正面。即使在有可验证结果的任务上,agent 也会因为判断失误而影响表现。

解决方案不是让 agent "更客观"。同一个模型既生成又评估,内在地倾向对自己慷慨。解决方案是把"干活的人"和"检查的人"分开。

一个独立的评估 agent,经过专门调校为"挑剔"之后,比让生成 agent 自我评估有效得多。Anthropic 的实验数据如下:

架构 运行时长 成本 核心功能是否可用
单 agent 裸跑 20 分钟 $9 否(游戏实体无法响应输入)
三 agent(planner + generator + evaluator) 6 小时 $200 是(游戏可以正常游玩)

这是同一个模型(Opus 4.5),同一段提示词("做一个 2D 复古游戏编辑器")。区别只在 harness:从"裸奔"到"planner 扩展需求 → generator 逐功能实现 → evaluator 用 Playwright 实际点击测试"。

来源:Anthropic: Harness design for long-running application development

预防过早完成的方法

1. 外部化终止判定

完成判定不应该由 agent 自己做。harness 独立执行终止校验,输入是运行时信号,不是 agent 的置信度。在 CLAUDE.md 里可以写清楚:

## 完成定义
- 功能完成 = 端到端验证通过,不是"代码写完了"
- 必须运行的验证层级:
  1. 单元测试通过
  2. 集成测试通过
  3. 端到端流程验证通过
- 在第 1 层没通过时,不许进入第 2 层
- 在第 2 层没通过时,不许进入第 3 层

2. 构建三层终止校验

  • 第一层:语法与静态分析。成本最低,信息量最小,但必须通过。这是最低限度的检查,字都没写错才能往下看。
  • 第二层:运行时行为验证。测试执行、应用启动检查、关键路径验证。这是核心完成证据,不仅要写了,还要能跑。
  • 第三层:系统级确认。端到端测试、集成验证、用户场景模拟。这是防止过早声明的最后一道防线,不仅要能跑,还要跑对。

3. 给 agent 提供可操作的错误反馈

OpenAI 在 Codex 实践中提出了一个特别有效的模式:给 agent 写的错误消息要包含修复指导。要明确指出哪里错了、应该怎么改,不要只说"错了"。 例如不要用 "Test failed",而用 "Test failed: POST /api/reset-password returned 500. Check that the email service config exists in environment variables. The template file should be at templates/reset-email.html." 这种具体的、可操作的反馈让 agent 能自我修正,不需要人类介入。

4. 捕获运行时信号

有效的运行时信号包括:

  • 应用是否成功启动并达到就绪状态?
  • 关键功能路径在运行时是否执行成功?
  • 数据库写入、文件操作等副作用是否正确?
  • 临时资源是否被清理?

实际案例

任务:实现用户密码重置功能。涉及数据库操作、邮件发送和 API 端点修改。

提前交卷路径:agent 修改数据库 schema、编写 API 端点、添加邮件模板、跑单元测试(通过)、声明完成。看起来做了很多,但关键环节都跳过了。

实际遗漏项:(1) 端到端流程未测试,重置链接的实际发送和验证未确认。(2) 数据库迁移在部分执行后失败,导致 schema 不一致。(3) 邮件服务配置在目标环境中缺失。

harness 介入:终止校验强制执行,(1) 启动完整应用验证重置端点可访问;(2) 执行完整重置流程;(3) 验证数据库状态一致性。所有缺陷在会话内被发现,节省了 5-10 倍的后续修复成本。

核心要点

  • agent 系统性地过度自信,置信度校准偏差是客观存在的。代码写完了不代表做对了。
  • 完成判定必须外部化,harness 独立验证,不信任 agent 的"感觉"。
  • 三层校验缺一不可:语法通过、行为通过、系统通过,层层递进。
  • 错误消息要包含具体修复步骤,让 agent 能自我修正,只说"错了"不够。
  • 核心功能验证通过之前不许重构,完成优先级约束是防止过早优化的关键。

延伸阅读

练习

  1. 终止校验函数设计:为一个涉及数据库迁移和 API 修改的任务设计完整的终止校验。列出需要的运行时信号和每个信号的通过/失败标准。在一个实际任务上运行,记录它发现了哪些隐藏问题。
  2. 校准偏差测量:选 10 个不同类型的编码任务,记录 agent 的自报完成信心和实际完成质量。计算偏差值,分析它和任务复杂度的关系。
  3. 多层防御实验:对同一组任务跑三种配置:(a) 仅静态分析,(b) 加单元测试,(c) 完整三层校验。比较过早完成声明的比例和未捕获缺陷的数量。

代码示例

干净状态检查清单

干净状态检查清单

  • 应用无需手动修复即可启动
  • 当前进度已记录
  • 没有未记录的半完成导入或索引步骤
  • 下一个 Agent 可以立即运行标准的启动和验证路径

victory-detector.ts

/**
 * victory-detector.ts
 *
 * Simulates an agent that claims task completion, then checks the claimed
 * state against actual verification. Outputs: claimed vs actual, highlighting
 * gaps between what the agent said and what's really true.
 *
 * Run: npx tsx docs/lectures/lecture-09-why-agents-declare-victory-too-early/code/victory-detector.ts
 */

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface Task {
  name: string;
  claimedComplete: boolean;
  checks: VerificationCheck[];
}

interface VerificationCheck {
  description: string;
  claimed: boolean; // What the agent says
  actual: boolean;  // What is actually true
  severity: "critical" | "warning";
}

// ---------------------------------------------------------------------------
// Simulated tasks with claimed vs actual states
// ---------------------------------------------------------------------------

const tasks: Task[] = [
  {
    name: "Add search endpoint",
    claimedComplete: true,
    checks: [
      {
        description: "Route handler exists",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Authentication middleware applied",
        claimed: true,
        actual: false, // Agent forgot auth
        severity: "critical",
      },
      {
        description: "Input validation added",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Unit tests written",
        claimed: true,
        actual: false, // Agent skipped tests
        severity: "critical",
      },
      {
        description: "Integration tests pass",
        claimed: true,
        actual: false, // No tests to run
        severity: "critical",
      },
      {
        description: "Documentation updated",
        claimed: true,
        actual: false, // Agent skipped docs
        severity: "warning",
      },
    ],
  },
  {
    name: "Fix pagination bug",
    claimedComplete: true,
    checks: [
      {
        description: "Bug reproduction case confirmed",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Root cause identified",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Fix applied",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Edge cases tested",
        claimed: true,
        actual: false, // Only happy path tested
        severity: "warning",
      },
      {
        description: "Regression tests added",
        claimed: true,
        actual: false,
        severity: "critical",
      },
    ],
  },
  {
    name: "Refactor auth module",
    claimedComplete: true,
    checks: [
      {
        description: "Old code removed",
        claimed: true,
        actual: false, // Old code still present
        severity: "warning",
      },
      {
        description: "All existing tests still pass",
        claimed: true,
        actual: false, // Two tests broke
        severity: "critical",
      },
      {
        description: "New auth flow works",
        claimed: true,
        actual: true,
        severity: "critical",
      },
      {
        description: "Migration guide written",
        claimed: true,
        actual: false,
        severity: "warning",
      },
    ],
  },
];

// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------

interface TaskVerification {
  taskName: string;
  claimedComplete: boolean;
  actuallyComplete: boolean;
  totalChecks: number;
  claimedPassing: number;
  actualPassing: number;
  gaps: { description: string; severity: string }[];
}

function verifyTask(task: Task): TaskVerification {
  const claimedPassing = task.checks.filter((c) => c.claimed).length;
  const actualPassing = task.checks.filter((c) => c.actual).length;
  const gaps = task.checks.filter((c) => c.claimed && !c.actual);

  return {
    taskName: task.name,
    claimedComplete: task.claimedComplete,
    actuallyComplete: gaps.length === 0,
    totalChecks: task.checks.length,
    claimedPassing,
    actualPassing,
    gaps: gaps.map((g) => ({ description: g.description, severity: g.severity })),
  };
}

// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------

function pad(s: string, len: number): string {
  return s.length >= len ? s : s + " ".repeat(len - s.length);
}

function run(): void {
  console.log("\n" + "=".repeat(90));
  console.log("  VICTORY DETECTOR -- Claimed vs Actual Verification");
  console.log("=".repeat(90));

  const verifications = tasks.map(verifyTask);

  for (const v of verifications) {
    console.log("\n  Task: " + v.taskName);
    console.log("  " + "-".repeat(70));
    console.log(`  Agent claimed:  ${v.claimedComplete ? "COMPLETE" : "NOT COMPLETE"}`);
    console.log(`  Actually is:    ${v.actuallyComplete ? "COMPLETE" : "INCOMPLETE"}`);

    if (!v.actuallyComplete) {
      console.log(`  MISMATCH: Agent declared victory but ${v.gaps.length} check(s) failed!`);
    }

    // Detailed checks for this task
    const task = tasks.find((t) => t.name === v.taskName)!;
    console.log("\n  Check detail:");
    const header = `  | ${pad("Check", 40)}| ${pad("Claimed", 9)}| ${pad("Actual", 9)}| ${pad("Gap?", 6)}|`;
    const sep = `  |${"-".repeat(42)}|${"-".repeat(11)}|${"-".repeat(11)}|${"-".repeat(8)}|`;
    console.log(header);
    console.log(sep);

    for (const check of task.checks) {
      const claimedLabel = check.claimed ? "PASS" : "FAIL";
      const actualLabel = check.actual ? "PASS" : "FAIL";
      const gapLabel = check.claimed && !check.actual ? "GAP" : "";
      const marker = gapLabel ? ">>" : "  ";
      console.log(`${marker}| ${pad(check.description, 40)}| ${pad(claimedLabel, 9)}| ${pad(actualLabel, 9)}| ${pad(gapLabel, 6)}|`);
    }
  }

  // Overall summary
  console.log("\n" + "=".repeat(90));
  console.log("  OVERALL SUMMARY");
  console.log("=".repeat(90) + "\n");

  const sHeader = `| ${pad("Task", 28)}| ${pad("Claimed", 10)}| ${pad("Actual", 10)}| ${pad("Gaps", 6)}| ${pad("Accuracy", 10)}|`;
  const sSep = `|${"-".repeat(30)}|${"-".repeat(12)}|${"-".repeat(12)}|${"-".repeat(8)}|${"-".repeat(12)}|`;
  console.log(sHeader);
  console.log(sSep);

  for (const v of verifications) {
    const accuracy = Math.round((v.actualPassing / v.totalChecks) * 100);
    const cLabel = v.claimedComplete ? "Done" : "Pending";
    const aLabel = v.actuallyComplete ? "Done" : "Incomplete";
    console.log(`| ${pad(v.taskName, 28)}| ${pad(cLabel, 10)}| ${pad(aLabel, 10)}| ${pad(String(v.gaps.length), 6)}| ${pad(accuracy + "%", 10)}|`);
  }

  const totalGaps = verifications.reduce((s, v) => s + v.gaps.length, 0);
  const falseVictories = verifications.filter((v) => v.claimedComplete && !v.actuallyComplete).length;

  console.log("\n  False victories: " + falseVictories + " of " + verifications.length + " tasks");
  console.log("  Total undetected gaps: " + totalGaps);
  console.log("\n  The agent declared 'done' on every task, but verification reveals " + totalGaps + " unmet criteria.");
  console.log("  Without explicit verification, premature victory declarations go unchecked.\n");
}

run();

第九讲. 防止 agent 提前宣告完成
http://clxhxhhr.top/posts/274/
作者
clxstart
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。