一个兼容层活了 26 个月
「回声」的 content.summarize 从 v1 升到 v2 时,内容团队加了一层兼容层:v1 的调用方继续传旧字段,平台在内部转换。计划写的是「等调用方迁完就删」。26 个月后,兼容层还在,每月维持成本 8,000 微元,最后两个调用方早已没人记得是谁。
这不是懒惰。兼容层没有坏,删掉反而有风险;旧提示也能用,下线需要重跑评测;两个能力合并了一半,剩下的一半牵涉两个团队。每一项都「暂时不能动」,合起来就变成一笔永远不清的账。
技术债管理要解决的不是「提醒大家还债」,而是让每一笔债都有一个可判定的退出条件:什么信号出现时删兼容层、什么条件下线旧提示、什么证据齐了才合并能力。没有退出条件的债不是债,是一笔没有期限的订阅。
一条债至少写九个字段
| 字段 | 作用 | 缺失后的表现 |
|---|---|---|
| id | 稳定标识,跨季度引用 | 每次复盘都重新起名,无法追踪 |
| kind | 债的类型,决定退出动作 | 只能写「技术优化」,无法验收 |
| capability | 受影响的能力 | 不知道还债会动到谁 |
| owner | 对退出日期负责的人 | 到期没人推进 |
| openedOn / reviewBy | 开债日与复核日 | 债沉进 backlog,永不再看 |
| permanence | 可还、已接受、永久 | 把「不还」混进「待还」 |
| carryMicrosPerMonth | 每月维持成本,整数微元 | 不还的债看起来是免费的 |
| exit | 可判定的退出条件 | 只能靠感觉判断「差不多了」 |
| acceptance | 接受不还的审批与年化成本 | 永久债没有记账口径 |
九个字段里,exit 与 acceptance 互斥:可还的债必须有退出条件,接受不还的债必须有审批记录。两者都缺,这条记录就只是愿望。
五类债,五种退出动作
| 债的类型 | 退出动作 | 典型触发条件 |
|---|---|---|
| compat-shim | delete-shim | 活跃调用方 0、活跃发布 0、流量 0、静默 30 天 |
| legacy-prompt | retire-prompt | 没有发布引用旧提示、流量 0、回归通过 |
| duplicate-capability | merge-capability | 调用方收敛到 1 个、迁移签字完成 |
| deprecated-model | offboard-model | 旧模型 0 调用、0 发布、迁移完成 |
| manual-workaround | delete-shim 或接受 | 人工步骤可被能力替代,或被批准长期保留 |
类型与动作必须对上。兼容层的退出动作不能写成「合并能力」,旧提示也不能用「下线模型」结账。类型错了,验收人会在错误的证据上签字。
退出条件由五个可测量组成:活跃调用方上限、活跃发布上限、流量占比上限、连续静默天数、必需证据。前四个是数量,最后一个是清单。数量达标但证据缺失,仍然不能还。
证据不是形式。删除兼容层要求 caller-scan 与 traffic-report,因为「调用方说迁完了」和「扫描结果为零」是两件事。合并能力要求 migration-signoff 与 eval-regression,因为流量归零不代表合并后的质量达标。下线模型要求调用方、发布、流量、回归四份证据,因为模型下线不可逆。
什么时候算「可以还」
debtVerdict 先做静态校验,再看动态条件。静态校验回答「这条记录是否完整」:有没有 owner、日期是否合法、月度成本是否与年化成本一致、退出动作是否匹配类型。动态条件回答「此刻能不能还」:调用方、发布、流量、静默天数与证据是否同时满足。
示例台账有 6 条债,在 2026-11-05 的判定如下。
| id | 类型 | 状态 | 原因 |
|---|---|---|---|
| d-shim-summarize-v1 | compat-shim | blocked | 活跃调用方 2 > 0 |
| d-prompt-reply-v12 | legacy-prompt | payable | 五项条件与三份证据全部满足 |
| d-merge-summarize | duplicate-capability | blocked | 迁移尚未完成 |
| d-model-legacy | deprecated-model | payable | 旧模型已无调用与发布 |
| d-workaround-manual-review | manual-workaround | accepted | 监管要求,已批准长期保留 |
| d-shim-search | compat-shim | review-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 都当成同一种原因。调用方未迁完与证据未齐,责任人完全不同。
- 台账只服务盘点,不服务预算。没有「可立即还的月度成本」,还债永远排在最后。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。