怎么让 Agent 没法假装自己成功了

怎么让 Agent 没法假装自己成功了

开发记录 · 2026-08-07 · RepoPilot

用过 coding agent 的都见过这一幕:它说「已修复,构建通过 ✅」,你一跑,还是那个错。

这不是模型撒谎,是系统没有独立的成功判定 ------ 「成功」这个结论是模型自己说的, 产品只是把它转述了一遍。

这篇讲怎么在架构上让这件事变成不可能。

第一层:命令结果是判别联合,不是布尔

最常见的写法:

ts 复制代码
const { stdout } = await exec('npm run build');
return { success: true, output: stdout };   // 💀

问题是 exit code 非零、被信号杀死、超时、spawn 失败,在这里全都变成了同一种东西 (或者抛异常,然后在某个 catch 里被吞掉)。

改成穷举:

ts 复制代码
export interface CommandOutcome {
  readonly commandId: string;
  readonly argv: readonly string[];
  /** exit-code / signal / timeout / cancel / spawn-error 判别联合 */
  readonly outcome:
    | 'EXIT_ZERO' | 'EXIT_NONZERO' | 'SIGNAL'
    | 'TIMEOUT' | 'CANCELLED' | 'SPAWN_ERROR';
  readonly exitCode: number | null;
  readonly signal: string | null;
  readonly durationMs: number;
  readonly stdoutPreview: string;
  readonly stderrPreview: string;
  readonly outputTruncated: boolean;
}

然后 passed 只有一种算法:

ts 复制代码
passed: outcomes.length > 0 && outcomes.every((o) => o.outcome === 'EXIT_ZERO'),

注意 outcomes.length > 0 ------ 一条命令都没跑,不能算通过 。这个空数组 默认为 true 的坑,Array.every() 会热情地帮你踩。

命令不存在时也不能悄悄跳过:

ts 复制代码
const def = profile.commands[id];
if (!def) {
  outcomes.push({
    commandId: id,
    outcome: 'SPAWN_ERROR',
    stderrPreview: `profile 中没有登记 command "${id}"`,
    // ...
  });
  continue;
}

第二层:先跑基线,否则你不知道是不是本来就坏的

「构建通过」本身没有信息量 ------ 万一它改之前就是通过的呢?万一它把别的东西改坏了呢?

所以必须先跑一次基线,再跑修改后的版本,然后做分类对比

ts 复制代码
export function compareVerification(
  baseline: VerificationRun,
  current: VerificationRun,
): VerificationComparison {
  const baseFailed = new Set(
    baseline.commands.filter((c) => c.outcome !== 'EXIT_ZERO').map((c) => c.commandId),
  );
  const nowFailed = new Set(
    current.commands.filter((c) => c.outcome !== 'EXIT_ZERO').map((c) => c.commandId),
  );

  const fixed: string[] = [];         // 原来坏、现在好 ------ 这才是修好了
  const stillFailing: string[] = [];  // 原来坏、现在还坏
  const newlyFailing: string[] = [];  // 原来好、现在坏 ------ 改出新问题了

  for (const id of baseFailed) {
    (nowFailed.has(id) ? stillFailing : fixed).push(id);
  }
  for (const id of nowFailed) {
    if (!baseFailed.has(id)) newlyFailing.push(id);
  }
  return { fixed, stillFailing, newlyFailing };
}

三个数组分开报,UI 上三种颜色。「修好了 build,但弄坏了 test」这种情况没法被 一句「构建通过」盖过去。

后来变成了四个数组。 这一版比较器只看「有没有出现在当前失败集合里」, 一条基线里失败、这次根本没跑 的命令自然不在里面,于是被算进了 fixed ------ 系统替模型说了一句没有证据的话。08-08 补 verify.ts 单测时才发现,加了第四个数组:

ts 复制代码
// 只在两次都跑过的命令上做 fixed/stillFailing 分类。
// 否则"这次压根没跑"会因为不在当前失败集合里而被算成 fixed ------ 一句没有证据的谎话。
const currentIds = new Set(current.commands.map((c) => c.commandId));

for (const id of baseFailed) {
  if (!currentIds.has(id)) notRerun.push(id);
  else if (nowFailed.has(id)) stillFailing.push(id);
  else fixed.push(id);
}

诚实地说:当时两个调用点给基线和终验传的是同一组 commandIds,所以这个洞在产品里 暂时走不到。但 fixed 会直接进补丁摘要,比较器不该对输入做这个假设。

顺带一个副作用:如果基线全绿,直接返回 NO_CHANGES 并提示

基线验证已全部通过 ------ 没有需要修复的失败。请确认任务描述或验证命令是否正确。

比让 Agent 跑一圈然后交一个空补丁强。

第三层:终态在类型层面锁死

这是最关键的一层。SUCCEEDED 状态需要一个凭据对象:

ts 复制代码
export interface RunTerminalFacts {
  /** 未验证模式下为 null ------ "有没有验证"在类型上就是可区分的 */
  readonly verificationRunId: string | null;
  readonly patchAcceptanceId: string;
}

然后在状态转换处强制:

ts 复制代码
private setStatus(record: RunRecord, status: RunStatus, reason: string | null): void {
  if (isTerminal(record.view.status)) return;   // 终态不可逆

  const facts = record.view.terminalFacts;
  if (status === 'SUCCEEDED' && !(facts?.verificationRunId && facts.patchAcceptanceId)) {
    throw new Error('内部不变式违规:SUCCEEDED 必须同时绑定通过的 verification 与 patch acceptance');
  }
  if (status === 'ACCEPTED_UNVERIFIED' && !facts?.patchAcceptanceId) {
    throw new Error('内部不变式违规:ACCEPTED_UNVERIFIED 必须绑定 patch acceptance');
  }
  // ...
}

模型不管说什么,都构造不出 RunTerminalFacts ------ 它没有权限创建 verificationRunId(只有 verifier 能创建),也没有权限创建 patchAcceptanceId (只有用户在 UI 上点接受才会生成)。

后来加的第四种终态

这一层最初做得太绝对:没有验证就不让创建任务。逻辑上没错, 但用起来很烦 ------ 有些仓库就是解析不出 build 命令。

(这个决策改了三次,详见 门禁设计的三次反转。)

最后的解法不是放宽 SUCCEEDED,而是增加一个诚实的终态

ts 复制代码
export type RunStatus =
  | 'CREATED' | 'PLANNING' | 'AWAITING_PLAN_APPROVAL'
  | 'EXECUTING' | 'VERIFYING' | 'AWAITING_PATCH_REVIEW'
  | 'SUCCEEDED'
  /** 用户接受了补丁,但没有任何机器验证支撑 ------ 与 SUCCEEDED 严格区分 */
  | 'ACCEPTED_UNVERIFIED'
  | 'FAILED' | 'BLOCKED' | 'CANCELLED' | 'TIMED_OUT';

接受补丁时判断:

ts 复制代码
const verified =
  record.patch.verificationRunId !== null &&
  record.verifications.some(
    (v) => v.verificationRunId === record.patch!.verificationRunId && v.passed,
  );

this.setStatus(
  record,
  verified ? 'SUCCEEDED' : 'ACCEPTED_UNVERIFIED',
  verified
    ? '验证通过且用户已接受补丁'
    : '用户已接受补丁,但没有通过的机器验证支撑 ------ 正确性仅由人工判断',
);

UI 上 SUCCEEDED 是绿的,ACCEPTED_UNVERIFIED 是黄的。

门禁全部放开之后,正是这条区分让 SUCCEEDED 还剩下意义。

第五层:把没验证的东西列出来

即使验证通过,也有大量东西没被覆盖。补丁里带一份清单:

ts 复制代码
function buildUnverifiedItems(task, profile, comparison): string[] {
  const items: string[] = [];

  if (task.verificationCommandIds.length === 0) {
    items.push('⚠ 本次运行没有执行任何验证命令 ------ 全部改动都未经机器验证');
  }
  for (const id of Object.keys(profile.commands)) {
    if (!task.verificationCommandIds.includes(id)) {
      items.push(`命令 "${id}" 未被本次任务纳入验证范围`);
    }
  }
  for (const a of task.acceptance) {
    items.push(`验收条件「${a}」由人工判断,没有对应的自动断言`);
  }
  if (comparison && comparison.stillFailing.length > 0) {
    items.push(`以下命令在基线和修复后都失败,未被本次修复覆盖:${comparison.stillFailing.join(', ')}`);
  }
  items.push('运行时行为、视觉表现和未被测试覆盖的分支均未验证');
  return items;
}

最后那句写死的兜底不是废话 ------ 它是在提醒用户:通过的是这几条命令,不是这次改动。

一个连带发现:构建产物混进了补丁

端到端测试第一次跑通时,补丁里有 3 个文件而不是 1 个。多出来的是 dist/ ------ vite build 在工作区里生成的。

第一反应是加个过滤。但转念一想,静默过滤和静默通过是同一类问题: 用户看到 1 个文件,不知道系统丢掉了 2 个。

所以拆成两份,并且都报出来:

ts 复制代码
changedVsBaseline(): { authored: string[]; generated: string[] } {
  // ...
  for (const f of now) {
    if (baseMap.get(f.path) === f.digest) continue;
    (isGeneratedPath(f.path) ? generated : authored).push(f.path);
  }
  return { authored: authored.sort(), generated: generated.sort() };
}

补一句后话:这一版只遍历「当前存在」的文件,被删掉 的文件既不在 authored 也不在 generated ------ 又是一次静默省略,而且正好是本文反对的那种。08-08 补测试时 抓到,加了第三个数组 deletedworkspace.ts:179,见 补 322 个测试,挖出 19 个 bug)。

PatchArtifact 上多一个字段:

ts 复制代码
/**
 * 被验证命令生成、因而未纳入补丁的文件(dist/ 等)。
 * 列出来是为了让"补丁里为什么没有它们"可解释,而不是静默省略。
 */
readonly excludedGeneratedFiles: readonly string[];

UI 上是一个可展开的「已排除 N 个由验证命令生成的文件」。

端到端怎么证明

用一个确定性的模型替身跑完整链路,然后断言失败必须是真失败

ts 复制代码
// 基线必须是"真的构建失败",不能是 spawn 失败伪装成的失败
const baselineBuild = result.baseline!.commands.find((c) => c.commandId === 'build')!;
expect(baselineBuild.outcome).toBe('EXIT_NONZERO');
expect(baselineBuild.exitCode).toBeGreaterThan(0);
expect(`${baselineBuild.stdoutPreview}${baselineBuild.stderrPreview}`).toContain('TS2345');

// 修复后必须是真的 exit 0
const fixedBuild = result.finalVerification!.commands.find((c) => c.commandId === 'build')!;
expect(fixedBuild.outcome).toBe('EXIT_ZERO');

toContain('TS2345') 这一句是重点。只断言 passed === false 的话, npm 没装、命令拼错、超时,全都能让它通过 ------ 那就变成了另一种形式的假绿。

还有一条:

ts 复制代码
// 宿主仓库必须一个字节都没被动过
const hostStatus = execFileSync('git', ['status', '--porcelain'], { cwd: FIXTURE, encoding: 'utf8' });
expect(hostStatus.trim()).toBe('');

可以带走的

  1. 布尔值是信息损失。 命令结果、导入结果、验证结果,全都该是判别联合。
  2. Array.every() 对空数组返回 true。 判定「全部通过」时永远先查长度。
  3. 没有基线的「通过」没有意义。 必须能区分「修好了 / 还坏着 / 改坏了 / 压根没跑」------ 第四类最容易漏,因为它天然会掉进第一类。
  4. 不变式写在状态转换处,不写在文档里。 让违反它的代码根本编译/运行不过去。
  5. 需要放宽标准时,加一个新状态,别改旧状态的含义。 ACCEPTED_UNVERIFIED 比「SUCCEEDED 但有个 flag」诚实得多。
  6. 静默过滤 = 静默通过。 任何省略都要报数量和原因。

上一篇:让 LLM 改代码,但不让它模糊匹配 下一篇:一个门禁设计,我改了三次

相关推荐
Marvin_Harrison1 小时前
今天来聊聊 LLM 的 Temperature:从 logits、softmax 到模型如何选出下一个 Token
人工智能
不一样的少年_1 小时前
不用LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
人工智能·agent·ai编程
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-08-09
人工智能·深度学习·神经网络·搜索引擎·百度
狂师1 小时前
推荐一款开源 Skill:让 AI Agent 给你做一份"能改"的 PPT,支持 上千套模板!
人工智能·agent
阿图灵1 小时前
基于 LSTM 的中文电商评论情感分类:从数据处理到 91% 准确率实战
人工智能·深度学习·分类·nlp·lstm·情感分类
用户298698530141 小时前
HTML 转 Word 指南:新手入门教程
人工智能·后端·python
冬哥聊AI1 小时前
淘天一面:Prefix Caching 原理是什么?Agent 框架怎么保证不破坏缓存?
人工智能
agent8971 小时前
实战升级|SpringBoot WebSocket实现多轮对话AI流式问答(上下文记忆+自动重连+会话隔离)
人工智能·spring boot·websocket
染指11101 小时前
80.高级RAG-LLamaIndex实际应用-金融助手
人工智能·rag·llama_index·llamaindex