让两个模型一写一审

让两个模型一写一审

开发记录 · 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.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:

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 不推动终态这些核心 不变式,不覆盖多轮收敛(因为多轮还没实现)。

可以带走的

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

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

相关推荐
程序员cxuan1 小时前
Claude Opus 5 的系统提示词被扒出来了
人工智能·后端·程序员
代码不加糖1 小时前
common.js和es6中模块引l入的区别?
前端·javascript·es6
董员外1 小时前
RAG 系统进化论(十):可运营 RAG,从原型到长期运行的知识系统
人工智能·后端·设计模式
二川bro1 小时前
从零跑通大模型全流程:基于Qwen3‑0.6B微调中医模型并Docker部署
人工智能
网易云信1 小时前
权威认可!网易智企帝王蟹入选信通院《2026 智能体创新实践汇编》
人工智能·agent
吾鳴1 小时前
用一句话需求,做完一张 9:16 的 Skill 发布海报:我把设计生图交给 Agent 试了一次
人工智能·aigc·agent
yuluo_YX1 小时前
什么是 OpenAI Responses API?
人工智能·chatgpt
qq83443101 小时前
一款纯前端的excel控件,XuY_Sheet 电子表格组件教程
前端·excel·教程·luckysheet·xuy_sheet控件·前端表格控件
云浪1 小时前
从 0 到实战:掌握向量数据库 Milvus,构建 AI 应用的核心能力
javascript·数据库·人工智能