一次「顺手加个检索」引发的重构
「回声」最早只有一个能力:chat.reply。控制器里写死一次模型调用,超时、重试、预算都堆在同一个函数里。产品要加检索,团队在旁边复制一段;要加摘要,再复制一段。三个月后,同一个 chat.reply 在三个文件里有三套重试策略,预算只在第一段生效,失败后从哪一步继续也没有人说得清。
团队决定重构成编排。第一版方案是一次性替换:定义工作流、迁移三条链路、删除旧代码,一周内完成。评审时被否掉了。理由不是方案不对,而是它把两件事同时改了:能力的行为,以及能力的组织方式。上线后如果成本从 4,240 涨到 4,500 微元,没人能判断是模型变了、提示变了,还是编排结构本身有问题。
本节给出另一条路径:把重构拆成三步,先抽节点契约,再抽执行器,最后拆链路。每一步都先定义验收证据与回退动作,再决定是否继续。文中的判定结果来自 ai-lab 的示例样本运行输出,不是生产实测;成本与延迟沿用前文的示例口径。
为什么不能一次换掉
一次性替换的问题不是风险高,而是不可归因。重构同时改变三样东西:调用点数量、失败传播方式、计量口径。任何一项变化都会影响成本与成功率,三者混在一起,观测到的差值就无法分配给具体动作。
三步路径的核心是把不可归因的大变更拆成三个可分别验收的小变更。每一步只回答一个问题:
| 步骤 | 只改变什么 | 验收问题 | 不通过时怎么办 |
|---|---|---|---|
| 抽节点契约 | 节点边界与输入输出校验 | 每个节点的契约是否覆盖真实字段 | 校验退回 shadow,只记录不拒绝 |
| 抽执行器 | 调用点、重试、超时与预算 | 是否只剩一个调用入口 | 按能力切回直连 |
| 拆链路 | 链路的组合与开关 | 每条链路能否独立上下线 | 切回旧链路并停止新版本接单 |
顺序不能交换。没有节点契约,执行器不知道要统一什么;没有执行器,链路拆分只是把复制粘贴换了个位置。更重要的是,回退动作依赖前一步的产物:链路开关要挂在执行器上,执行器又要按契约识别能力。
第一步:抽节点契约
节点契约不是给每个函数加类型标注,而是把「这个节点需要什么、产出什么、缺字段时怎么办」写成可校验的声明。至少要覆盖四件事:必填输入、输出字段、错误语义、契约版本。人工节点与外部动作也要有契约,否则执行器无法判断它们是否完成。
验收看三个数:契约覆盖率、行为一致率、旧路径是否保留。覆盖率的分母是全部节点,不是 AI 节点;行为一致率取同一批请求在新旧路径上的输出一致比例,样本必须包含失败与部分结果,不能只测成功路径。
覆盖率门槛设为 95% 而不是 100%,是给长尾节点留出迁移窗口。但未覆盖的节点必须登记,不能因为「只有两个」就默认它们不会出问题。行为一致率低于 99% 时,先查契约字段是否漏了上游真实使用的字段,而不是改判定阈值。
这一步的回退最轻:契约校验以 shadow 模式运行,只记录字段缺失与类型不符,不拒绝请求。发现误报就关闭校验,线上行为完全不变。
第二步:抽执行器
执行器把分散在各处的调用收拢成一个入口,统一处理超时、重试、预算、计量与幂等键。它不负责决定链路顺序,只负责把一次节点调用执行完,并返回可计量的结果。
验收看执行器接管率、行为一致率、回退演练次数与旧路径保留。接管率的分母是全部能力调用点,包括后台任务与定时任务;只统计在线请求会高估迁移进度。回退演练至少做一次,演练内容不是「把开关拨回去」,而是「拨回去之后,正在执行的任务能不能安全结束」。
执行器的回退按能力维度进行,而不是整层回退。某个能力的适配器有问题时,只把这个能力切回直连,其他能力继续走执行器。这样回退的影响面与故障面一致,不会因为一个适配器把全部能力打回旧路径。
第三步:拆链路
链路拆分把 A/B/C 三条链路分别迁移,每条链路一个独立开关。链路 A 是内容发布安全链,链路 B 是私信客服链,链路 C 是站内下单咨询链。三条链路的失败代价不同,迁移顺序也不同:先迁失败代价最低、流量最小的链路,再迁主流量链路。
验收看独立开关数、行为一致率、回退演练次数与旧路径保留。三条链路至少三个开关;演练至少两次,一次在低峰切回,一次在有一半任务在途时切回。只做低峰演练,等于没有验证在途任务的处理。
到这一步,前文那组数字才可以被引用:示例样本上,三能力独立调用成本 4,240 微元,编排后 3,786 微元;延迟从 1,200ms 变成 1,269ms。每次交接传 820 token、耗 35ms;共享上下文 3,200 token,在 90% 缓存折扣下,复用率要越过 28.47% 的回本线,编排才开始省 token。注意这组数字的前提是三步都完成。在第一步或第二步结束时引用它,等于把还没发生的结构收益提前记到账上。
实现
下面的模块把三步验收写成数据,而不是写成一段说明。它接收每一步的证据,逐项判定,并按顺序阻断。
// file: src/orch/evolution.ts
// tier: L2
// verify: node --test test/orch/evolution.test.ts
import { AiError } from "../core/errors.ts";
export type RefactorStepId = "node-contract" | "executor" | "chain-split";
export const STEP_ORDER: readonly RefactorStepId[] = ["node-contract", "executor", "chain-split"];
/** 一次重构步骤的可观测证据;比例用 0–1,计数用整数。 */
export interface RefactorEvidence {
step: RefactorStepId;
/** 已声明并校验输入输出的节点 / 全部节点。 */
contractCoverage: number;
/** 经统一执行器调用的能力占比;其余仍是业务代码直连。 */
executorShare: number;
/** 同一批请求在新旧路径上的行为一致率。 */
behaviorParity: number;
/** 旧路径是否保留,可一键切回。 */
legacyPathRetained: boolean;
/** 有独立开关、可单独回退的链路数。 */
chainToggles: number;
/** 回退演练通过次数。 */
rollbackDrills: number;
}
export interface GateResult {
id: string;
expected: string;
actual: string;
ok: boolean;
}
export interface StepAssessment {
step: RefactorStepId;
title: string;
passed: boolean;
checks: GateResult[];
failed: string[];
/** 回退到上一步的具体动作。 */
rollback: string;
/** 通过后允许进入的下一步。 */
next: string;
}
interface Gate {
id: string;
expected: string;
ok: (evidence: RefactorEvidence) => boolean;
actual: (evidence: RefactorEvidence) => string;
}
const pct = (value: number): string => `${(value * 100).toFixed(2)}%`;
/** 每一步只回答一个问题:现有证据是否足以支撑下一步。 */
const STEP_GATES: Record<RefactorStepId, readonly Gate[]> = {
"node-contract": [
{ id: "contract-coverage", expected: ">= 95.00%", ok: (e) => e.contractCoverage >= 0.95, actual: (e) => pct(e.contractCoverage) },
{ id: "behavior-parity", expected: ">= 99.00%", ok: (e) => e.behaviorParity >= 0.99, actual: (e) => pct(e.behaviorParity) },
{ id: "legacy-path", expected: "retained", ok: (e) => e.legacyPathRetained, actual: (e) => String(e.legacyPathRetained) },
],
executor: [
{ id: "executor-share", expected: ">= 90.00%", ok: (e) => e.executorShare >= 0.9, actual: (e) => pct(e.executorShare) },
{ id: "behavior-parity", expected: ">= 99.00%", ok: (e) => e.behaviorParity >= 0.99, actual: (e) => pct(e.behaviorParity) },
{ id: "rollback-drill", expected: ">= 1", ok: (e) => e.rollbackDrills >= 1, actual: (e) => String(e.rollbackDrills) },
{ id: "legacy-path", expected: "retained", ok: (e) => e.legacyPathRetained, actual: (e) => String(e.legacyPathRetained) },
],
"chain-split": [
{ id: "chain-toggles", expected: ">= 3", ok: (e) => e.chainToggles >= 3, actual: (e) => String(e.chainToggles) },
{ id: "behavior-parity", expected: ">= 99.00%", ok: (e) => e.behaviorParity >= 0.99, actual: (e) => pct(e.behaviorParity) },
{ id: "rollback-drill", expected: ">= 2", ok: (e) => e.rollbackDrills >= 2, actual: (e) => String(e.rollbackDrills) },
{ id: "legacy-path", expected: "retained", ok: (e) => e.legacyPathRetained, actual: (e) => String(e.legacyPathRetained) },
],
};
const STEP_META: Record<RefactorStepId, { title: string; rollback: string; next: string }> = {
"node-contract": {
title: "抽节点契约",
rollback: "契约校验先以 shadow 模式运行,只记录不拒绝;发现问题时关闭校验,不切流量。",
next: "进入执行器抽取:把重试、超时与预算集中到一个调用点。",
},
executor: {
title: "抽执行器",
rollback: "执行器按能力维度保留直连开关;异常时按能力切回直连,不影响其他能力。",
next: "进入链路拆分:把 A/B/C 三条链路分别开关,逐条迁移。",
},
"chain-split": {
title: "拆链路",
rollback: "每条链路独立开关;回退只切开关并停止新版本接单,不删除旧状态与旧缓存。",
next: "三条链路都通过验收后,才允许声明编排层成为默认路径。",
},
};
function fail(message: string): never {
throw new AiError("invalid-request", `evolution: ${message}`);
}
function ratio(value: number, field: string): number {
if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} 必须落在 [0, 1]`);
return value;
}
function integer(value: number, field: string): number {
if (!Number.isInteger(value) || value < 0) fail(`${field} 必须是 >= 0 的整数`);
return value;
}
function validate(evidence: RefactorEvidence): void {
if (!STEP_ORDER.includes(evidence.step)) fail(`未知步骤 ${evidence.step}`);
ratio(evidence.contractCoverage, "contractCoverage");
ratio(evidence.executorShare, "executorShare");
ratio(evidence.behaviorParity, "behaviorParity");
integer(evidence.chainToggles, "chainToggles");
integer(evidence.rollbackDrills, "rollbackDrills");
if (typeof evidence.legacyPathRetained !== "boolean") fail("legacyPathRetained 必须是布尔值");
}
/** 单步验收:返回逐项证据,而不是一个通过/不通过的布尔值。 */
export function assessRefactorStep(evidence: RefactorEvidence): StepAssessment {
validate(evidence);
const checks = STEP_GATES[evidence.step].map((gate) => ({
id: gate.id,
expected: gate.expected,
actual: gate.actual(evidence),
ok: gate.ok(evidence),
}));
const failed = checks.filter((check) => !check.ok).map((check) => check.id);
const meta = STEP_META[evidence.step];
return {
step: evidence.step,
title: meta.title,
passed: failed.length === 0,
checks,
failed,
rollback: meta.rollback,
next: meta.next,
};
}
export type PlanStopReason = "complete" | "failed-gate" | "missing-evidence";
export interface RefactorPlan {
completed: RefactorStepId[];
blockedAt: RefactorStepId | null;
reason: PlanStopReason;
canOrchestrate: boolean;
assessments: StepAssessment[];
}
/**
* 重构必须按顺序验收:后一步的证据不能抵消前一步的缺口。
* 某一步失败时立即停止,返回该步的回退动作,而不是继续评估后面的步骤。
*/
export function planRefactor(evidences: readonly RefactorEvidence[]): RefactorPlan {
const byStep = new Map<RefactorStepId, RefactorEvidence>();
for (const evidence of evidences) {
validate(evidence);
if (byStep.has(evidence.step)) fail(`重复的步骤证据 ${evidence.step}`);
byStep.set(evidence.step, evidence);
}
const completed: RefactorStepId[] = [];
const assessments: StepAssessment[] = [];
for (const step of STEP_ORDER) {
const evidence = byStep.get(step);
if (evidence === undefined) {
return { completed, blockedAt: step, reason: "missing-evidence", canOrchestrate: false, assessments };
}
const assessment = assessRefactorStep(evidence);
assessments.push(assessment);
if (!assessment.passed) {
return { completed, blockedAt: step, reason: "failed-gate", canOrchestrate: false, assessments };
}
completed.push(step);
}
return { completed, blockedAt: null, reason: "complete", canOrchestrate: true, assessments };
}
/** 下一步的步骤 id;已完成全部步骤时返回 null。 */
export function nextRefactorStep(completed: readonly RefactorStepId[]): RefactorStepId | null {
return STEP_ORDER.find((step) => !completed.includes(step)) ?? null;
}
assessRefactorStep 返回逐项证据,而不是一个布尔值。评审时可以看到「覆盖率 83.33%,低于 95%」这样的具体差距,而不是只看到「不通过」。planRefactor 的关键在顺序:它不会因为第三步证据好看就跳过第一步的缺口,缺少证据也会停在对应位置。
示例样本上的运行结果
用同一组三步证据跑首轮与补齐后的判定,结果如下。前两轮都故意留有缺口,用来观察阻断位置。
| 轮次 | 步骤 | 关键证据 | 判定 | 未通过项 |
|---|---|---|---|---|
| 首轮 | 抽节点契约 | 覆盖率 83.33%,一致率 99.62% | 不通过 | contract-coverage |
| 首轮 | 抽执行器 | 接管率 75.00%,演练 0 次 | 未评估 | 被第一步阻断 |
| 首轮 | 拆链路 | 3 个开关,演练 2 次 | 未评估 | 被第一步阻断 |
| 补齐 | 抽节点契约 | 覆盖率 100.00%,一致率 99.86% | 通过 | 无 |
| 补齐 | 抽执行器 | 接管率 100.00%,演练 1 次 | 通过 | 无 |
| 补齐 | 拆链路 | 3 个开关,演练 2 次 | 通过 | 无 |
首轮 planRefactor 返回 blockedAt: "node-contract"、completed: []、assessments 长度 1。也就是说,执行器与链路的证据虽然已经准备,却没有进入评估。这个行为是有意设计的:如果允许跳步,团队会用后两步的漂亮数字掩盖第一步的契约缺口。
补齐后返回 reason: "complete"、canOrchestrate: true,三步全部进入 completed。此时才允许把编排层设为默认路径,也才允许在周报里引用 4,240 到 3,786 微元的成本变化。
另一个样本只缺执行器的回退演练:接管率 92.00%、一致率 99.86%、演练 0 次。判定停在 executor,已完成为 ["node-contract"],未通过项是 rollback-drill。这比「接管率不够」更常见:团队把调用收拢了,却没有验证能不能切回去。
验收证据怎么取
三个证据的取法决定了验收是否可信。
契约覆盖率按节点计数,不按代码行数。一个节点即使只有三行,只要它调用模型或产生外部副作用,就必须有契约。人工节点也要算,否则执行器无法判断「等待人工」是成功、超时还是取消。
行为一致率要配对比较:同一条请求分别走新旧路径,比较结构化输出,而不是比较自然语言文本。文本可以不同但语义相同,结构化字段不同则一定有问题。样本里必须包含失败与部分结果,否则一致率只证明了成功路径没坏。
回退演练要记录三件事:切换耗时、在途任务数量、切换后失败率。只记录「演练成功」没有意义;一次在零流量下的切换,什么也证明不了。
回退不是把开关拨回去
每一步的回退动作不同,因为它们改变的东西不同。契约步骤只改校验,回退是关校验;执行器步骤改了调用路径,回退是按能力切直连;链路步骤改了组合,回退是切链路开关并停止新版本接单。
链路回退最容易被低估。切开关只影响新请求,已经进入新链路的任务还在跑。正确做法是先停止新版本接单,等在途任务排空或到达检查点,再切回旧链路。旧状态与旧缓存不能删,否则在途任务恢复时会读到空状态。
双路径并存有成本:旧代码还要修 bug、还要跟着模型升级。保留旧路径不是免费的安全网。每一步都要给旧路径写一个负责人和移除日期;没有移除日期的旧路径,最后会变成第二套生产系统。
与后两节的衔接
三步重构完成,只说明编排层可以成为默认路径,不说明它可以一次全量。下一节讨论编排版本怎么灰度:按租户、流量与任务类型分流,新旧版本并行时状态与缓存如何隔离。
再往后一节讨论边界:固定编排能表达的任务,是否还需要自主 Agent。本节的三步验收只覆盖「把已有路径搬进编排」,不覆盖「让模型自己决定路径」。
小结
从单能力到编排的重构可以拆成三步:抽节点契约、抽执行器、拆链路。每一步都有明确的验收证据与回退动作,且必须按顺序通过。示例样本中,首轮在契约覆盖率 83.33% 处阻断,执行器与链路证据不被采信;补齐后三步全部通过,才允许引用 4,240 到 3,786 微元的成本变化与 1,200 到 1,269ms 的延迟变化。
重构的难点不是写编排代码,而是让每一步都可验收、可回退、可归因。把这三件事写进门槛,比事后解释「为什么成本涨了」便宜得多。
阅读导航:上一节:11.3 多团队共享能力目录 · 下一节:12.2 编排版本与灰度 。
常见错误与边界
- 一次性替换全部调用点。行为与结构同时变化,成本差值无法归因。
- 先拆链路再抽契约。执行器不知道节点边界,拆分只是换位置的复制粘贴。
- 契约覆盖率只统计 AI 节点。人工与外部动作没有契约,执行器无法判断完成状态。
- 行为一致率只测成功路径。失败与部分结果才是编排最容易改变的行为。
- 用代码行数衡量契约覆盖。三行的外部动作同样需要输入输出声明。
- 覆盖率不到 95% 就调低门槛。应该登记长尾节点并给出迁移日期。
- 回退演练只在零流量下做。没有在途任务的切换证明不了恢复能力。
- 回退时删除旧状态或旧缓存。在途任务恢复会读到空状态。
- 旧路径没有负责人和移除日期。双路径会变成第二套长期维护的生产系统。
- 三步没完成就引用编排成本。4,240 到 3,786 的前提是链路已经拆分完成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。