让两个模型一写一审

49 阅读10分钟

让两个模型一写一审

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

想法很直接:一个模型写代码,另一个模型审。两家不同的 API,互相不认识, 第二意见总比自己审自己强。

但写下来之后,代码量最大的部分不是"怎么让它审",而是**"审过了"到底意味着什么**。

先泼一盆冷水:这不是我 PRD 里写的那个东西

PRD §7.11 定义的是 ExternalCodingAgentConnectorProfile —— 接官方 Codex CLI / Claude Code 的非交互接口,要做 binary identity、capability probe、 ExternalCandidateWorkspace,外部 Agent 产出的 diff 还要经过 Diff → MutationPlan → CAS 才能落地。

我实现的是两个 ModelConnectionProfile —— 两个 HTTP API,一写一审。

PRD 第 437 行明确要求这三个概念不得互相冒充。所以 README 里我这么写:

  • 这里用的是两个 ModelConnectionProfile不是 PRD 里定义的 ExternalCodingAgentConnectorProfile。所以本项目没有接入 Codex CLI / Claude Code 这类自带 Agent Loop 的外部编码代理,只是"第二个模型 API 交叉审核"。

写这句话有点扫兴,但"接入了 Codex 和 Claude"和"调了两个模型 API"差着一整个量级的工程量。 含糊过去就是在骗人。

最重要的一行代码是一个状态

| 'CROSS_REVIEWING'
| 'AWAITING_PATCH_REVIEW'
| 'SUCCEEDED'

只加了一个非终态 CROSS_REVIEWING,一个新终态都没加。

因为整件事的语义边界就一句话:审核方说"通过",既不是验证通过,也不是任务成功。

这个产品从第一天起就有条不肯让步的规则:

有通过的验证 + 用户接受 → SUCCEEDED
只有用户接受            → ACCEPTED_UNVERIFIED

如果让审核方的 PASS 也能推动终态,这条规则就废了 —— 一个模型说"我觉得没问题" 不是机器验证。所以交叉审核跑完之后,无论结论是什么,都回到 AWAITING_PATCH_REVIEW(也就是 PRD 里的 HUMAN_REVIEW_REQUIRED):

- runCrossReview:补丁封存后进 CROSS_REVIEWING,跑一轮只读审核,
  产出 CrossReviewRecord 后**始终**回到 AWAITING_PATCH_REVIEW
  (= PRD 的 HUMAN_REVIEW_REQUIRED),绝不自动接受。

判定类型也刻意不叫 approved

/** 单次审核调用的判定 —— "通过"仅指"没发现阻断项",绝不是 Verification/SUCCEEDED */
export type CrossReviewVerdict = 'PASS' | 'CHANGES_REQUESTED' | 'INCONCLUSIVE';

INCONCLUSIVE 是特意留的第三种 —— 审核方看不懂、信息不足、或者不确定, 应该有地方说"我给不出结论",而不是被迫在通过和打回之间二选一。

只读不是提示词说了算

这个仓库上一轮刚栽在这上面:规划阶段的只读只是提示词约束dispatchTool 查的是全局工具表,模型凭记忆写出 workspace_mutate 就能在"只读阶段"真的改文件。

所以审核方的只读从一开始就做成平台强制 —— 加第三个 phase:

type Phase = 'PLANNING' | 'EXECUTION' | 'REVIEW';

// …
if ((phase === 'PLANNING' || phase === 'REVIEW') && def.risk !== 'R0') {
  host.endToolCall(toolCallId, 'DENIED', 'PHASE_READONLY', /* … */);
  return { ok: false, /* … */ };
}

给模型的工具表也只放只读子集:

const reviewTools = TOOLS.filter((t) => t.risk === 'R0'); // 只读子集

两道都上,因为它们防的是不同的事:过滤工具表是"别诱导它去试", phase 检查是"试了也没用"。

系统提示里把这件事明说了 —— 不是为了约束模型,是为了让它别浪费轮次去试:

硬性约束(由平台强制,不是自律):
- 你是**只读**的。不能改文件、不能运行命令、不能批准补丁。任何写操作都会被拒绝。
- 你的"通过"**不等于**验证通过,也不等于任务成功 —— 那需要机器验证和人工接受。你只提供第二意见。
- 只通过 submit_review 输出结构化发现,不要在自由文本里下最终结论。

指纹必须由平台算

ReviewFinding 里有个 fingerprint 字段:

/**
 * 去重指纹。同一指纹在多轮里重复出现是"没有进展"的信号之一,
 * 会触发提前转人工(PRD-XAGENT-004)。
 */
readonly fingerprint: string;

它是判定"多轮之间有没有进展"的依据。所以不能让模型自报

// 指纹由平台算,不用模型自报的 —— 它是"有没有进展"的判据
fingerprint: digestOf({
  severity: f.severity,
  file: f.file ?? null,
  range,
  evidence: f.evidence.trim().slice(0, 400),
}),

道理和补丁 digest 一样:任何用来判断"能不能继续"的量,都不能由被判断方提供。 如果模型每轮换一个指纹,收敛检测就永远认为"有新发现",循环停不下来。

收敛:硬上限,写死在常量里

/** 交叉审核的收敛硬上限 —— 不可放宽(PRD-XAGENT-004) */
export const CROSS_REVIEW_LIMITS = {
  maxReviewerInvocations: 2,
  maxRemediations: 1,
} as const;

停止原因是一个判别联合,八种:

export type CrossReviewStopReason =
  | 'REVIEWER_PASSED'      // 审核方无阻断发现
  | 'COUNTER_EXHAUSTED'    // 用满 2 次审核 + 1 次整改
  | 'NO_DELTA'             // 整改后补丁 digest 没变
  | 'NO_PROGRESS'          // 阻断项没减少或出现重复指纹
  | 'REVIEWER_UNAVAILABLE' // 只配了一家 key / 审核方 route 不可用
  | 'BUDGET_EXHAUSTED'
  | 'CANCELLED'
  | 'ERROR';

前两个是"正常跑完",后六个都是提前转人工。但终点都一样AWAITING_PATCH_REVIEW。区别只在于时间线上写的原因不同。

counter 是任务级聚合,注释里写死了:

/**
 * counter 是"任务级聚合":换窗口、换模型、恢复 session 都不能重置它
 * (PRD-XAGENT-004)。
 */

这条是防"换个方式再来一遍"绕过上限 —— 一个有界循环如果能靠新建会话重置计数器, 那它就不是有界的。

降级:绝不偷偷自审

用户只配了一家 API 的 key 怎么办?

最省事的做法是回落到 implementer 的 route —— 反正也能跑。这恰恰是最坏的做法: 同一个模型自己审自己,用户以为拿到了独立第二意见,实际什么都没有。

// 交叉审核方 route:每任务显式勾选,凭据缺失时降级为不审核,
// 绝不回落到 implementer 的 route(那就成了自审)。
let reviewer: { resolution: ModelRouteResolution; heterogeneous: boolean } | null = null;
let reviewerDegradeNote: string | null = null;
if (input.reviewerModelProfileId) {
  if (input.reviewerModelProfileId === input.modelProfileId) {
    // 同一个 profile 一写一审没有独立第二意见的价值,如实降级
    reviewerDegradeNote = '交叉审核方与实现方是同一个 profile —— 无法提供独立第二意见,已跳过交叉审核';
  } else {
    try {
      const reviewerResolution = this.gateway.freezeRoute(input.reviewerModelProfileId);
      reviewer = {
        resolution: reviewerResolution,
        heterogeneous: reviewerResolution.providerId !== resolution.providerId,
      };
    } catch (err) {
      // 审核方没配凭据:降级,不阻断任务创建,也不偷偷改用 implementer 的 key
      reviewerDegradeNote = `已请求交叉审核,但审核方 route 不可用(${(err as Error).message})—— 本次降级为不审核`;
    }
  }
}

三种情况分开处理:没勾选(不审)、勾了同一个(拒绝并说明)、勾了但 key 不可用(降级并说明)。 每种都有一条能读的说明进事件流。

还有 heterogeneous 这个字段:

/** 两条 route 是否异构(不同 provider)—— 同源审核价值有限,如实标注 */
readonly heterogeneous: boolean;

用 DeepSeek 写、用 DeepSeek 的另一个模型审,是允许的,但价值有限。 事件流里如实写:

`已启用交叉审核:审核方 ${reviewer.resolution.providerId}/${reviewer.resolution.modelId}${
  reviewer.heterogeneous ? '(与实现方异构)' : '(与实现方同源,第二意见价值有限)'
}`

审核失败不能拖垮补丁

补丁已经封存了、验证已经跑过了。审核只是附加的第二意见。 所以审核出错绝不能让 Run 变成失败:

} catch (err) {
  if (err instanceof AgentCancelled || record.abort.signal.aborted) {
    stopReason = 'CANCELLED';
  } else if (err instanceof EgressBlocked) {
    stopReason = 'REVIEWER_UNAVAILABLE';
    this.emit(record, 'NOTE', `交叉审核出站被阻断:${err.reason}`);
  } else {
    stopReason = 'ERROR';
    this.emit(record, 'NOTE', `交叉审核出错(不影响补丁本身):${(err as Error).message}`);
  }
}

所有异常都收敛成 stopReason,然后照常走到 AWAITING_PATCH_REVIEW。 用户拿到的还是那个补丁,只是旁边写着"这次没审成,原因是 X"。

schema v1 → v2 要能读旧数据

CrossReviewRecord 要进 state.json,于是 schema 从 v1 升到 v2。 关键是旧快照必须还能读

- PersistedRunState 增加可选 crossReview。v2 能读 v1(缺字段 = 没审过),
  仍拒绝高于当前版本的快照。4 条测试覆盖往返、v1 兼容、v3 fail-closed。

方向是不对称的:版本低可以读(缺字段有明确含义),版本高必须拒绝(我们不知道 新字段意味着什么,硬解就是编造)。这跟当初给 state.jsonschemaVersion 是同一个理由。

UI 上那条免责

RunDetail 里加了 CrossReviewPanel,按 severity 分级展示发现。顶部固定一条:

既不是机器验证,也不代表补丁可以接受。

这条不是法务式的免责声明,是产品语义的一部分。一个界面上如果并排放着 "验证通过 ✓" 和 "审核通过 ✓",用户会把它们当成同一种东西 —— 而它们的证据强度 差着一个数量级。

反向验证

这篇声称做对了三件事:审核方只读由平台强制、fingerprint 由平台算、 PASS 推动终态。声称不算数,把守卫拆掉跑一遍才算。

agent.crossreview.test.ts 5 条用例:

审核方直接提交发现 → 映射成 CrossReviewRound,fingerprint 由平台计算
审核方 PASS 且无发现 → verdict PASS,findings 为空
审核方试图写工作区 → 被平台以 PHASE_READONLY 拒绝,然后才提交发现
审核方只读读取补丁文件是允许的(fs_read 走 R0)
用满轮次未提交 → INCONCLUSIVE,不编造发现

实验一:把 REVIEW 从只读判定里去掉,只留 PLANNING:

- if ((phase === 'PLANNING' || phase === 'REVIEW') && def.risk !== 'R0') {
+ if (phase === 'PLANNING' && def.risk !== 'R0') {
× 审核方试图写工作区 → 被平台以 PHASE_READONLY 拒绝,然后才提交发现
  → expected 'FAILED' to be 'DENIED'
Tests  1 failed | 4 passed

注意红的方式:不是"抛异常",是 resolution 从 DENIED 变成了 FAILED —— 工具真的被派发了,只是执行时失败了(审核方拿到的是只读工作区)。 「被平台拒绝」和「试了但没成功」是两件完全不同的事,断言卡在前者上才有意义。

实验二:改成信任模型自报的 fingerprint

- fingerprint: digestOf({ severity, file, range, evidence: … }),
+ fingerprint: f.fingerprint ?? 'model-said-so',
× 审核方直接提交发现 → 映射成 CrossReviewRound,fingerprint 由平台计算
  → expected 'model-said-so' to be 'sha256:d90f874f0af50cd34c47c3ab5edf17…'
Tests  1 failed | 4 passed

两条都真的会红。持久化那侧另有 4 条(v2 往返、v2 读 v1、v3 fail-closed), 在 persistence.test.ts 里。

诚实地说,这 5 条覆盖的是单轮:多轮收敛、NO_PROGRESS / NO_DELTA 的早停判定 一条都没测 —— 因为多轮本身还没实现(见下)。

还没做的

自动整改没接线。 PRD 允许"1 次初始实现 + 最多 2 次审核 + 1 次整改", 现在只跑 1 轮审核就交回人工。有阻断发现时的处理是这样的:

// 本切片没有自动整改:PASS/无阻断 → REVIEWER_PASSED;有阻断 → 用满可自动进行的轮次
stopReason = round.verdict === 'PASS' || blocking === 0 ? 'REVIEWER_PASSED' : 'COUNTER_EXHAUSTED';

remediations 保持 0,不虚报"已用满整改次数"。README 的「还没做的」也写清楚了。

这个取舍是:先把语义边界和只读通道做对,整改循环是在这个骨架上加东西; 反过来先做循环、语义边界含糊,改起来要动状态机。

Track A 也没清完。 今天开工那轮盘点一共报了 35 条,Track A 只修了其中 7 条 P0。 剩下的 28 条(账本诚实性、失败分类 failureClass、失败 Run 也要封存补丁、 保留策略的界面出口、网关重试与超时……)还在 backlog 里 —— 读完 08-09 这几篇 容易以为问题清完了,并没有。

测试只有 5 条。 覆盖只读强制、fingerprint 由平台算、verdict 不推动终态这些核心 不变式,不覆盖多轮收敛(因为多轮还没实现)。

可以带走的

  1. 实现的东西和设计文档里的东西不是一回事时,要在 README 上说清楚。 "接入了 Codex"和"调了第二个模型 API"差一个量级。
  2. 新能力优先加非终态,别急着加终态。 终态是承诺,加了就得兑现它的证据要求。 这是「加新状态别改旧语义」这条线的第五次应用, 也是第一次它给出的答案是「别加」—— 前四次都在加,这次的正确动作是只加一个 非终态、一个终态都不加。
  3. 只读要平台强制,不能只写在提示词里。 过滤工具表和 phase 检查都要有,它们防的是不同的事。
  4. 任何"能不能继续"的判据,都不能由被判断方提供。 指纹、digest、计数器,一律平台算。
  5. 降级要如实说,绝不静默替换成一个更差的东西。 自审冒充交叉审核,比不审更有害。
  6. 附加能力失败不能拖垮主产物。 把异常收敛成 stopReason,正常交付。
  7. schema 版本兼容是不对称的:低版本可读,高版本必须拒绝。

上一篇:一个永远不会兑现的 Promise 下一篇:两个 wire 的翻译层,以及它可以怎么骗你