让两个模型一写一审
开发记录 · 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"差着一整个量级的工程量。 含糊过去就是在骗人。
最重要的一行代码是一个状态
ts
| 'CROSS_REVIEWING'
| 'AWAITING_PATCH_REVIEW'
| 'SUCCEEDED'
只加了一个非终态 CROSS_REVIEWING,一个新终态都没加。
因为整件事的语义边界就一句话:审核方说"通过",既不是验证通过,也不是任务成功。
这个产品从第一天起就有条不肯让步的规则:
有通过的验证 + 用户接受 → SUCCEEDED
只有用户接受 → ACCEPTED_UNVERIFIED
如果让审核方的 PASS 也能推动终态,这条规则就废了 ------ 一个模型说"我觉得没问题" 不是机器验证。所以交叉审核跑完之后,无论结论是什么 ,都回到 AWAITING_PATCH_REVIEW(也就是 PRD 里的 HUMAN_REVIEW_REQUIRED):
markdown
- runCrossReview:补丁封存后进 CROSS_REVIEWING,跑一轮只读审核,
产出 CrossReviewRecord 后**始终**回到 AWAITING_PATCH_REVIEW
(= PRD 的 HUMAN_REVIEW_REQUIRED),绝不自动接受。
判定类型也刻意不叫 approved:
ts
/** 单次审核调用的判定 ------ "通过"仅指"没发现阻断项",绝不是 Verification/SUCCEEDED */
export type CrossReviewVerdict = 'PASS' | 'CHANGES_REQUESTED' | 'INCONCLUSIVE';
INCONCLUSIVE 是特意留的第三种 ------ 审核方看不懂、信息不足、或者不确定, 应该有地方说"我给不出结论",而不是被迫在通过和打回之间二选一。
只读不是提示词说了算
这个仓库上一轮刚栽在这上面:规划阶段的只读只是提示词约束 ,dispatchTool 查的是全局工具表,模型凭记忆写出 workspace_mutate 就能在"只读阶段"真的改文件。
所以审核方的只读从一开始就做成平台强制 ------ 加第三个 phase:
ts
type Phase = 'PLANNING' | 'EXECUTION' | 'REVIEW';
// ...
if ((phase === 'PLANNING' || phase === 'REVIEW') && def.risk !== 'R0') {
host.endToolCall(toolCallId, 'DENIED', 'PHASE_READONLY', /* ... */);
return { ok: false, /* ... */ };
}
给模型的工具表也只放只读子集:
ts
const reviewTools = TOOLS.filter((t) => t.risk === 'R0'); // 只读子集
两道都上,因为它们防的是不同的事:过滤工具表是"别诱导它去试", phase 检查是"试了也没用"。
系统提示里把这件事明说了 ------ 不是为了约束模型,是为了让它别浪费轮次去试:
markdown
硬性约束(由平台强制,不是自律):
- 你是**只读**的。不能改文件、不能运行命令、不能批准补丁。任何写操作都会被拒绝。
- 你的"通过"**不等于**验证通过,也不等于任务成功 ------ 那需要机器验证和人工接受。你只提供第二意见。
- 只通过 submit_review 输出结构化发现,不要在自由文本里下最终结论。
指纹必须由平台算
ReviewFinding 里有个 fingerprint 字段:
ts
/**
* 去重指纹。同一指纹在多轮里重复出现是"没有进展"的信号之一,
* 会触发提前转人工(PRD-XAGENT-004)。
*/
readonly fingerprint: string;
它是判定"多轮之间有没有进展"的依据。所以不能让模型自报:
ts
// 指纹由平台算,不用模型自报的 ------ 它是"有没有进展"的判据
fingerprint: digestOf({
severity: f.severity,
file: f.file ?? null,
range,
evidence: f.evidence.trim().slice(0, 400),
}),
道理和补丁 digest 一样:任何用来判断"能不能继续"的量,都不能由被判断方提供。 如果模型每轮换一个指纹,收敛检测就永远认为"有新发现",循环停不下来。
收敛:硬上限,写死在常量里
ts
/** 交叉审核的收敛硬上限 ------ 不可放宽(PRD-XAGENT-004) */
export const CROSS_REVIEW_LIMITS = {
maxReviewerInvocations: 2,
maxRemediations: 1,
} as const;
停止原因是一个判别联合,八种:
ts
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 是任务级聚合,注释里写死了:
ts
/**
* counter 是"任务级聚合":换窗口、换模型、恢复 session 都不能重置它
* (PRD-XAGENT-004)。
*/
这条是防"换个方式再来一遍"绕过上限 ------ 一个有界循环如果能靠新建会话重置计数器, 那它就不是有界的。
降级:绝不偷偷自审
用户只配了一家 API 的 key 怎么办?
最省事的做法是回落到 implementer 的 route ------ 反正也能跑。这恰恰是最坏的做法: 同一个模型自己审自己,用户以为拿到了独立第二意见,实际什么都没有。
ts
// 交叉审核方 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 这个字段:
ts
/** 两条 route 是否异构(不同 provider)------ 同源审核价值有限,如实标注 */
readonly heterogeneous: boolean;
用 DeepSeek 写、用 DeepSeek 的另一个模型审,是允许的,但价值有限。 事件流里如实写:
ts
`已启用交叉审核:审核方 ${reviewer.resolution.providerId}/${reviewer.resolution.modelId}${
reviewer.heterogeneous ? '(与实现方异构)' : '(与实现方同源,第二意见价值有限)'
}`
审核失败不能拖垮补丁
补丁已经封存了、验证已经跑过了。审核只是附加的第二意见。 所以审核出错绝不能让 Run 变成失败:
ts
} 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。 关键是旧快照必须还能读:
diff
- PersistedRunState 增加可选 crossReview。v2 能读 v1(缺字段 = 没审过),
仍拒绝高于当前版本的快照。4 条测试覆盖往返、v1 兼容、v3 fail-closed。
方向是不对称的:版本低可以读(缺字段有明确含义),版本高必须拒绝 (我们不知道 新字段意味着什么,硬解就是编造)。这跟当初给 state.json 加 schemaVersion 是同一个理由。
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:
ts
- if ((phase === 'PLANNING' || phase === 'REVIEW') && def.risk !== 'R0') {
+ if (phase === 'PLANNING' && def.risk !== 'R0') {
arduino
× 审核方试图写工作区 → 被平台以 PHASE_READONLY 拒绝,然后才提交发现
→ expected 'FAILED' to be 'DENIED'
Tests 1 failed | 4 passed
注意红的方式:不是"抛异常",是 resolution 从 DENIED 变成了 FAILED ------ 工具真的被派发了,只是执行时失败了(审核方拿到的是只读工作区)。 「被平台拒绝」和「试了但没成功」是两件完全不同的事,断言卡在前者上才有意义。
实验二:改成信任模型自报的 fingerprint:
ts
- fingerprint: digestOf({ severity, file, range, evidence: ... }),
+ fingerprint: f.fingerprint ?? 'model-said-so',
arduino
× 审核方直接提交发现 → 映射成 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 轮审核就交回人工。有阻断发现时的处理是这样的:
ts
// 本切片没有自动整改: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 不推动终态这些核心 不变式,不覆盖多轮收敛(因为多轮还没实现)。
可以带走的
- 实现的东西和设计文档里的东西不是一回事时,要在 README 上说清楚。 "接入了 Codex"和"调了第二个模型 API"差一个量级。
- 新能力优先加非终态,别急着加终态。 终态是承诺,加了就得兑现它的证据要求。 这是「加新状态别改旧语义」这条线的第五次应用, 也是第一次它给出的答案是「别加」------ 前四次都在加,这次的正确动作是只加一个 非终态、一个终态都不加。
- 只读要平台强制,不能只写在提示词里。 过滤工具表和 phase 检查都要有,它们防的是不同的事。
- 任何"能不能继续"的判据,都不能由被判断方提供。 指纹、digest、计数器,一律平台算。
- 降级要如实说,绝不静默替换成一个更差的东西。 自审冒充交叉审核,比不审更有害。
- 附加能力失败不能拖垮主产物。 把异常收敛成 stopReason,正常交付。
- schema 版本兼容是不对称的:低版本可读,高版本必须拒绝。