《互联网产品的 AI 架构·平台化与治理》12.2 技术债与退出条件

兼容层留了两年、旧提示没人敢删、两个能力永远在合并路上,技术债的问题不是记不住,而是没有退出条件。本节给出五类债与五种退出动作,把调用方、流量、静默天数与证据写成可判定条件,并说明永远不还的债如何按年化成本单独记账,让还债与做新功能能在同一张预算表上比较。

一个兼容层活了 26 个月

「回声」的 content.summarize 从 v1 升到 v2 时,内容团队加了一层兼容层:v1 的调用方继续传旧字段,平台在内部转换。计划写的是「等调用方迁完就删」。26 个月后,兼容层还在,每月维持成本 8,000 微元,最后两个调用方早已没人记得是谁。

这不是懒惰。兼容层没有坏,删掉反而有风险;旧提示也能用,下线需要重跑评测;两个能力合并了一半,剩下的一半牵涉两个团队。每一项都「暂时不能动」,合起来就变成一笔永远不清的账。

技术债管理要解决的不是「提醒大家还债」,而是让每一笔债都有一个可判定的退出条件:什么信号出现时删兼容层、什么条件下线旧提示、什么证据齐了才合并能力。没有退出条件的债不是债,是一笔没有期限的订阅。

一条债至少写九个字段

字段作用缺失后的表现
id稳定标识,跨季度引用每次复盘都重新起名,无法追踪
kind债的类型,决定退出动作只能写「技术优化」,无法验收
capability受影响的能力不知道还债会动到谁
owner对退出日期负责的人到期没人推进
openedOn / reviewBy开债日与复核日债沉进 backlog,永不再看
permanence可还、已接受、永久把「不还」混进「待还」
carryMicrosPerMonth每月维持成本,整数微元不还的债看起来是免费的
exit可判定的退出条件只能靠感觉判断「差不多了」
acceptance接受不还的审批与年化成本永久债没有记账口径

九个字段里,exit 与 acceptance 互斥:可还的债必须有退出条件,接受不还的债必须有审批记录。两者都缺,这条记录就只是愿望。

五类债,五种退出动作

债的类型退出动作典型触发条件
compat-shimdelete-shim活跃调用方 0、活跃发布 0、流量 0、静默 30 天
legacy-promptretire-prompt没有发布引用旧提示、流量 0、回归通过
duplicate-capabilitymerge-capability调用方收敛到 1 个、迁移签字完成
deprecated-modeloffboard-model旧模型 0 调用、0 发布、迁移完成
manual-workarounddelete-shim 或接受人工步骤可被能力替代,或被批准长期保留

类型与动作必须对上。兼容层的退出动作不能写成「合并能力」,旧提示也不能用「下线模型」结账。类型错了,验收人会在错误的证据上签字。

退出条件由五个可测量组成:活跃调用方上限、活跃发布上限、流量占比上限、连续静默天数、必需证据。前四个是数量,最后一个是清单。数量达标但证据缺失,仍然不能还。

证据不是形式。删除兼容层要求 caller-scan 与 traffic-report,因为「调用方说迁完了」和「扫描结果为零」是两件事。合并能力要求 migration-signoff 与 eval-regression,因为流量归零不代表合并后的质量达标。下线模型要求调用方、发布、流量、回归四份证据,因为模型下线不可逆。

什么时候算「可以还」

debtVerdict 先做静态校验,再看动态条件。静态校验回答「这条记录是否完整」:有没有 owner、日期是否合法、月度成本是否与年化成本一致、退出动作是否匹配类型。动态条件回答「此刻能不能还」:调用方、发布、流量、静默天数与证据是否同时满足。

示例台账有 6 条债,在 2026-11-05 的判定如下。

id类型状态原因
d-shim-summarize-v1compat-shimblocked活跃调用方 2 > 0
d-prompt-reply-v12legacy-promptpayable五项条件与三份证据全部满足
d-merge-summarizeduplicate-capabilityblocked迁移尚未完成
d-model-legacydeprecated-modelpayable旧模型已无调用与发布
d-workaround-manual-reviewmanual-workaroundaccepted监管要求,已批准长期保留
d-shim-searchcompat-shimreview-overdue复核日 2026-10-01 已过

两条 blocked 的原因不同。第一条是还有 2 个调用方,属于迁移没做完;第三条是调用方已经收敛到 1 个、流量 3%,但合并签字没完成,属于证据没齐。台账把两者都记为 blocked,但原因字段让人知道下一步该找谁。

review-overdue 优先于条件判断。一条债即使所有条件都满足,只要过了复核日,仍然先标红。原因是「条件曾经满足」不等于「现在仍然满足」,复核日就是重新确认的时点。

永远不还的债怎么记账

有些债不该还。人工复核是监管要求,某些旧接口承载合同义务,某些兼容层服务的是无法升级的客户。把它们塞进「待还」列表,会让整个台账失去可信度;直接删掉,又会让成本消失。

正确做法是单列一类:accepted 或 permanent。这类债不写退出条件,但必须写四件事:批准人、不还的理由、下一次年度复核日、年化维持成本。月度成本必须等于年化成本除以 12,取整后一致,否则台账里的两条口径会打架。

示例中 d-workaround-manual-review 每月 15,000 微元,年化 180,000 微元,批准人是平台委员会,理由是人工作业承担监管复核,不能在当期自动化。它不计入可还债的 82,000 微元,而是单独累计为 180,000 微元/年。

这样记账有三个好处。第一,永久债不会被误当成「免费」,预算里看得见。第二,年度复核强制重新回答「今年是否仍然不该还」。第三,可还债的维持成本与永久债的成本分开,优化目标不会被永久项稀释。

台账与示例结果

示例台账的汇总如下,全部来自示例样本上的运行结果。

指标数值
总条数6
可立即还2
受阻2
复核逾期1
已接受1
可还债月度成本82,000 微元
可立即还的月度成本48,000 微元
接受不还的年化成本180,000 微元
最近复核日2026-10-01(d-shim-search,已逾期)

「可立即还的月度成本 48,000」是最容易被忽略的数字。它表示只要执行两条已经满足条件的退出动作,每月就能少花 48,000 微元。没有这个数字,还债永远排在「有空再说」;有了它,还债和做新功能可以在同一张预算表上比较。

实现

下面的模块做三件事:校验一条债的记录是否完整、判定它此刻能否退出、按状态汇总台账。它不读工单系统,也不自动删除任何东西。

// file: src/gov/tech-debt.ts
// tier: L2
// verify: node --test test/gov/evolution-gov.test.ts

/** 债的类型决定退出动作,不允许只写一句“以后清理”。 */
export type DebtKind =
  | "compat-shim" | "legacy-prompt" | "duplicate-capability"
  | "deprecated-model" | "manual-workaround";

export type ExitKind = "delete-shim" | "retire-prompt" | "merge-capability" | "offboard-model";

export type EvidenceId =
  | "caller-scan" | "release-scan" | "traffic-report"
  | "migration-signoff" | "eval-regression";

export interface ExitCondition {
  kind: ExitKind;
  activeCallersMax: number;
  activeReleasesMax: number;
  trafficShareMax: number;
  /** 连续多少天没有新增使用,才算“安静”。 */
  quietDays: number;
  requiredEvidence: readonly EvidenceId[];
}

/** 接受不还的债也要记账:谁批的、为什么、年化成本、下一次复核日。 */
export interface AcceptanceRecord {
  approver: string;
  rationale: string;
  annualReviewOn: string;
  annualCarryMicros: number;
}

export type Permanence = "repayable" | "accepted" | "permanent";

export interface DebtItem {
  id: string;
  kind: DebtKind;
  capability: string;
  owner: string;
  openedOn: string;
  reviewBy: string;
  permanence: Permanence;
  /** 每月维持成本,整数微元;永久的债也要折算成月度口径。 */
  carryMicrosPerMonth: number;
  exit?: ExitCondition;
  acceptance?: AcceptanceRecord;
}

export interface DebtObservation {
  activeCallers: number; activeReleases: number; trafficShare: number;
  quietDays: number; evidence: readonly EvidenceId[]; migrationComplete: boolean;
}

export type DebtState = "payable" | "blocked" | "review-overdue" | "accepted" | "invalid";

export interface DebtVerdict { id: string; state: DebtState; reasons: string[]; missingEvidence: EvidenceId[] }

export type DebtIssueCode =
  | "empty-field" | "invalid-date" | "date-order" | "invalid-carry"
  | "missing-exit" | "unexpected-exit" | "exit-kind-mismatch"
  | "missing-acceptance" | "acceptance-incomplete" | "carry-mismatch";

export interface DebtIssue { id: string; code: DebtIssueCode; detail: string }

const EXIT_FOR_KIND: Record<DebtKind, ExitKind> = {
  "compat-shim": "delete-shim",
  "legacy-prompt": "retire-prompt",
  "duplicate-capability": "merge-capability",
  "deprecated-model": "offboard-model",
  "manual-workaround": "delete-shim",
};

function isIsoDate(value: string): boolean {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (match === null) return false;
  const time = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
  return new Date(time).toISOString().slice(0, 10) === value;
}

function issue(id: string, code: DebtIssueCode, detail: string): DebtIssue {
  return { id, code, detail };
}

/** 一条债的静态校验;动态条件留给 debtVerdict。 */
export function validateDebtItem(item: DebtItem): DebtIssue[] {
  const issues: DebtIssue[] = [];
  for (const field of ["id", "capability", "owner"] as const) {
    if (item[field].trim() === "") issues.push(issue(item.id, "empty-field", `${field} is empty`));
  }
  if (!isIsoDate(item.openedOn)) issues.push(issue(item.id, "invalid-date", `openedOn ${item.openedOn}`));
  if (!isIsoDate(item.reviewBy)) issues.push(issue(item.id, "invalid-date", `reviewBy ${item.reviewBy}`));
  else if (item.reviewBy < item.openedOn) issues.push(issue(item.id, "date-order", "reviewBy precedes openedOn"));
  if (!Number.isInteger(item.carryMicrosPerMonth) || item.carryMicrosPerMonth < 0) {
    issues.push(issue(item.id, "invalid-carry", "carry must be a non-negative integer"));
  }
  if (item.permanence === "repayable") {
    if (item.exit === undefined) {
      issues.push(issue(item.id, "missing-exit", "repayable debt needs an exit condition"));
    } else {
      if (item.exit.kind !== EXIT_FOR_KIND[item.kind]) {
        issues.push(issue(item.id, "exit-kind-mismatch", `${item.kind} must exit with ${EXIT_FOR_KIND[item.kind]}`));
      }
    }
  } else {
    if (item.exit !== undefined) {
      issues.push(issue(item.id, "unexpected-exit", `${item.permanence} debt must not carry an exit date`));
    }
    const acceptance = item.acceptance;
    if (acceptance === undefined) {
      issues.push(issue(item.id, "missing-acceptance", "accepted debt needs an approval record"));
    } else {
      if (acceptance.approver.trim() === "" || acceptance.rationale.trim() === "") {
        issues.push(issue(item.id, "acceptance-incomplete", "approver and rationale are required"));
      }
      if (!isIsoDate(acceptance.annualReviewOn) || acceptance.annualReviewOn < item.openedOn) {
        issues.push(issue(item.id, "acceptance-incomplete", "annualReviewOn is invalid"));
      }
      if (!Number.isInteger(acceptance.annualCarryMicros) || acceptance.annualCarryMicros < 0) {
        issues.push(issue(item.id, "acceptance-incomplete", "annualCarryMicros is invalid"));
      } else if (Math.round(acceptance.annualCarryMicros / 12) !== item.carryMicrosPerMonth) {
        issues.push(issue(item.id, "carry-mismatch", `monthly ${item.carryMicrosPerMonth} != annual/12`));
      }
    }
  }
  return issues;
}

function meetsExit(
  exit: ExitCondition, observation: DebtObservation,
): { missing: EvidenceId[]; reasons: string[] } {
  const reasons: string[] = [];
  if (observation.activeCallers > exit.activeCallersMax) {
    reasons.push(`活跃调用方 ${observation.activeCallers} > ${exit.activeCallersMax}`);
  }
  if (observation.activeReleases > exit.activeReleasesMax) {
    reasons.push(`活跃发布 ${observation.activeReleases} > ${exit.activeReleasesMax}`);
  }
  if (observation.trafficShare > exit.trafficShareMax) {
    reasons.push(`流量占比 ${(observation.trafficShare * 100).toFixed(2)}% > ${(exit.trafficShareMax * 100).toFixed(2)}%`);
  }
  if (observation.quietDays < exit.quietDays) {
    reasons.push(`静默 ${observation.quietDays} 天 < ${exit.quietDays}`);
  }
  if ((exit.kind === "merge-capability" || exit.kind === "offboard-model")
    && !observation.migrationComplete) {
    reasons.push("迁移尚未完成");
  }
  const missing = exit.requiredEvidence.filter((evidence) => !observation.evidence.includes(evidence));
  return { missing, reasons };
}

/** 判定一条债此刻能不能还;不能还时给出缺的证据与未满足的条件。 */
export function debtVerdict(
  item: DebtItem, observation: DebtObservation, today: string,
): DebtVerdict {
  const issues = validateDebtItem(item);
  if (issues.length > 0) {
    return { id: item.id, state: "invalid", reasons: issues.map((entry) => entry.code), missingEvidence: [] };
  }
  if (item.permanence !== "repayable") {
    const acceptance = item.acceptance;
    if (acceptance === undefined) {
      return { id: item.id, state: "invalid", reasons: ["missing-acceptance"], missingEvidence: [] };
    }
    return today > acceptance.annualReviewOn
      ? { id: item.id, state: "review-overdue", reasons: [`年度复核日 ${acceptance.annualReviewOn} 已过`], missingEvidence: [] }
      : { id: item.id, state: "accepted", reasons: [], missingEvidence: [] };
  }
  const exit = item.exit;
  if (exit === undefined) {
    return { id: item.id, state: "invalid", reasons: ["missing-exit"], missingEvidence: [] };
  }
  if (today > item.reviewBy) {
    return { id: item.id, state: "review-overdue", reasons: [`复核日 ${item.reviewBy} 已过`], missingEvidence: [] };
  }
  const { missing, reasons } = meetsExit(exit, observation);
  if (missing.length > 0) reasons.push(`缺证据 ${missing.join("/")}`);
  return reasons.length > 0
    ? { id: item.id, state: "blocked", reasons, missingEvidence: missing }
    : { id: item.id, state: "payable", reasons: [], missingEvidence: [] };
}

export interface DebtLedger {
  asOf: string;
  total: number;
  payable: number;
  blocked: number;
  reviewOverdue: number;
  accepted: number;
  invalid: number;
  /** 所有可还债的月度维持成本:还掉就能省下的钱。 */
  repayableCarryMicrosPerMonth: number;
  /** 条件已满足、可以立刻还的债的月度成本。 */
  payableCarryMicrosPerMonth: number;
  /** 接受不还的债的年化维持成本,必须单独入账。 */
  acceptedCarryMicrosPerYear: number;
  nextReview: { id: string; date: string } | null;
  rows: DebtVerdict[];
}

/** 台账按状态分桶,并把“永远不还”的债单独累计,避免它沉进 backlog。 */
export function debtLedger(
  items: readonly DebtItem[],
  observations: ReadonlyMap<string, DebtObservation>,
  today: string,
): DebtLedger {
  const rows: DebtVerdict[] = [];
  let repayableCarry = 0;
  let payableCarry = 0;
  let acceptedCarry = 0;
  let overdue = 0;
  let nextReview: { id: string; date: string } | null = null;

  for (const item of items) {
    const observation = observations.get(item.id);
    if (observation === undefined) {
      rows.push({ id: item.id, state: "invalid", reasons: ["missing-observation"], missingEvidence: [] });
      continue;
    }
    const verdict = debtVerdict(item, observation, today);
    rows.push(verdict);
    if (item.permanence === "repayable") {
      repayableCarry += item.carryMicrosPerMonth;
      if (verdict.state === "payable") payableCarry += item.carryMicrosPerMonth;
    } else if (item.acceptance !== undefined) {
      acceptedCarry += item.acceptance.annualCarryMicros;
    }
    if (verdict.state === "review-overdue") overdue += 1;
    const reviewDate = item.permanence === "repayable" ? item.reviewBy : item.acceptance?.annualReviewOn;
    if (reviewDate !== undefined && (nextReview === null || reviewDate < nextReview.date)) {
      nextReview = { id: item.id, date: reviewDate };
    }
  }

  const count = (state: DebtState): number => rows.filter((row) => row.state === state).length;
  return {
    asOf: today, total: items.length,
    payable: count("payable"), blocked: count("blocked"), reviewOverdue: overdue,
    accepted: count("accepted"), invalid: count("invalid"),
    repayableCarryMicrosPerMonth: repayableCarry,
    payableCarryMicrosPerMonth: payableCarry,
    acceptedCarryMicrosPerYear: acceptedCarry,
    nextReview, rows,
  };
}

与 12.1 的衔接

12.1 模型换代与迁移计划 的灰度阶段会产生两类临时债:兼容新旧契约的适配层,以及同时保留的旧提示。迁移计划负责让流量走到 0,技术债台账负责确认「流量为 0 之后,兼容层什么时候删、旧提示什么时候下线」。

两份记录用同一组事实对齐:能力名、旧模型或旧提示、迁移完成日期、残余流量。迁移计划给出 retired,台账给出 payable;只有两边都完成,一笔换代债才算真正结清。

小结

技术债不是清单,是带退出条件的记录。一条债至少写九个字段,其中 exit 与 acceptance 互斥:可还的债要有可判定的退出条件,接受不还的债要有审批、年化成本与年度复核。

五类债对应五种退出动作,条件由调用方、发布、流量、静默天数与证据共同组成。示例 6 条债中,2 条可立即还、2 条受阻、1 条复核逾期、1 条已接受;可立即还的月度成本 48,000 微元,接受不还的年化成本 180,000 微元。把永久债单独记账,台账才不会变成一张永远清不完的待办。

阅读导航:上一节:12.1 模型换代与迁移计划 · 下一节:12.3 治理的度量:不是流程数量 。

常见错误与边界

  • 只记债名,不记退出条件。清单越长,越没人相信它会被还。
  • 把「暂时不能动」当成永久结论。没有审批与年化成本的「暂不」只是拖延。
  • 兼容层按调用方口头承诺删除。没有扫描证据,删完才发现还有流量。
  • 合并能力只看流量归零。迁移签字与回归评测缺一份,合并后的质量没有保证。
  • 旧提示下线不跑回归。提示与模型绑定,旧提示可能仍在某个回滚包里。
  • 月度成本与年化成本各写一套。两条口径不一致,预算与台账无法对账。
  • 永久债不记账。它不会消失,只会在下一次预算讨论时突然出现。
  • 复核日只写不查。逾期债要优先于条件判断,否则「曾经满足」会被当成「现在满足」。
  • 把 blocked 都当成同一种原因。调用方未迁完与证据未齐,责任人完全不同。
  • 台账只服务盘点,不服务预算。没有「可立即还的月度成本」,还债永远排在最后。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查