《互联网产品的 AI 架构·平台化与治理》12.3 治理的度量:不是流程数量

治理周报列了 47 个流程、12 个委员会,变更失败率却从 10% 涨到 16.67%。本节用五个结果度量替换流程数量:能力上线周期、变更失败率、配额闲置率、评审平均耗时、流程豁免率,给出分子分母、目标带与趋势判定,并说明为什么流程数量不能进入记分卡。

流程从 31 个增加到 47 个

「回声」的平台治理季度汇报有一页固定幻灯片:本季度新增 9 个流程、3 个评审委员会、5 张审批表,累计流程 47 个。数字逐年上涨,汇报的语气也逐年自信。同一页的右下角,变更失败率从 10% 涨到 16.67%,能力上线周期从 7 天涨到 9.5 天,配额闲置率从 18% 涨到 24%。

流程数量增加,结果指标同时变差。这不是巧合。流程是投入,不是产出;它只能证明组织做了动作,不能证明动作解决了问题。当流程数量成为治理的主要成绩,团队会优化「让流程看起来更多」,而不是「让变更更可靠」。

治理需要度量,但度量的对象必须是结果。本节给出五个结果度量,说明它们的分子分母、目标与趋势判定,并解释为什么流程数量不能进入记分卡。所有数字都来自示例样本上的运行结果。

流程数量为什么不是度量

第一,它没有分母,也没有方向。47 个流程比 31 个好还是坏?没人能回答。流程多可能代表覆盖更全,也可能代表审批更碎。没有目标方向的数量,只能用来做同比,不能用来做判断。

第二,它与结果没有稳定关系。示例里流程增加 16 个,变更失败率反而上升 6.67 个百分点。流程可能降低了某一类风险,同时增加了协调成本和等待时间,净效果必须由结果指标回答。

第三,它容易被优化。把一张审批表拆成三张,流程数量就增加了;把两个委员会合并再拆开,数量也能变化。结果指标没有这种自由度:变更失败率只认部署后是否失败。

代码里把这类指标显式列为 NON_METRICS:process-count、approval-count、meeting-count、document-count。lookupMetric 遇到它们返回 non-metric,而不是悄悄算出一个数字。治理看板不接受这类输入,是从源头阻止「用动作冒充结果」。

五个度量,每个都有分母

度量口径示例值目标
能力上线周期批准到上线的中位天数9.5 天(39 天 / 4 条)≤14 天
变更失败率失败变更 / 全部变更16.67%(2 / 12)≤10%
配额闲置率未使用配额 / 分配总额24%(2,400 万 / 1 亿微元)≤25%
评审平均耗时变更评审的中位小时数31.5 小时(415 / 12)≤48 小时
流程豁免率豁免变更 / 应走流程变更16.67%(2 / 12)5%–15%

五个度量里,前四个是「越低越好」,第五个是目标带。豁免率太低,说明流程僵化到团队只能硬扛;太高,说明流程被绕过。5%–15% 的带不是行业标准,是回声在示例样本上选定的管理区间,换组织要重新定。

中位数而不是平均数,是为了减少极端值影响。能力上线周期里有一条 15 天、一条 5 天,平均数是 9.75,中位数是 9.5;评审耗时里有一条 55 小时,平均数会被拉高,中位数仍能代表典型体验。度量写进周报时必须注明是中位数,否则读者会默认是平均数。

分母同样不能省。变更失败率写 16.67% 而不写 2/12,读者无法判断样本是 12 次还是 1,200 次;配额闲置率写 24% 而不写 2,400 万 / 1 亿微元,读者无法判断是哪个租户的额度。分母是度量的一部分,不是附注。

趋势只看方向和目标带

单期数字只能回答「现在多少」,治理复盘还要回答「比上期好了还是坏了」。示例的上一期是 2026-09,两期对比如下。

度量上期本期变化趋势
能力上线周期7.0 天9.5 天+2.5 天worsening
变更失败率10.00%16.67%+6.67ppworsening
配额闲置率18.00%24.00%+6.00ppworsening
评审平均耗时44.0 小时31.5 小时-12.5 小时improving
流程豁免率10.00%16.67%+6.67ppout-of-band

前四个度量用方向判定:变化超过容忍带才算动,否则记为 stable。能力上线周期的容忍带是 1 天,变更失败率是 1 个百分点,配额闲置率是 2 个百分点,评审耗时是 2 小时。容忍带不是掩盖波动,而是避免把噪声当成趋势。

豁免率用目标带判定,不看涨跌。上期 10% 在带内,本期 16.67% 超出上限,结论是 out-of-band,而不是简单的 worsening。它可能意味着流程太慢,也可能意味着某类变更确实需要快速通道;无论哪种,都要先回到带内再谈方向。

评审耗时是唯一改善的指标:从 44 小时降到 31.5 小时。这说明流程数量增加不一定让每个环节都变慢,也可能把评审资源集中到了更少的节点。但单看这一项会得出错误结论,必须和失败率一起看:评审更快,失败更多,可能是评审深度被牺牲了。

总体结论不是平均分

scorecard 把五个度量放在一张表里,总体结论只有三档。

结论条件示例是否命中
degraded变更失败率 >15% 或上线周期 >21 天命中:16.67% > 15%
watch任一度量偏离目标,或趋势 worsening / out-of-band命中:失败率、豁免率偏离,三项恶化
healthy全部在目标内且无恶化趋势未命中

degraded 优先于 watch。示例的失败率 16.67% 越过 15% 的降级线,所以总体结论是 degraded,而不是「五项里三项达标,平均一下算 watch」。治理结论不能用达标率平均,因为失败率与上线周期的量级和后果不同。

总体结论的作用是触发动作,不是打分。degraded 要求下一季度给出失败率的整改方案与责任人;watch 要求解释偏离原因;healthy 不意味着没有问题,只意味着当前没有需要升级的信号。

实现

下面的模块做三件事:计算五个度量的值与分母、比较两期趋势、生成带总体结论的记分卡。它不读数据仓库,也不生成汇报幻灯片。

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

/** 五个度量都描述“改变发生了什么”,不描述“开了几次会”。 */
export type MetricId =
  | "capability-lead-time" | "change-failure-rate" | "quota-idle-rate"
  | "review-turnaround" | "process-exemption-rate";

export type MetricUnit = "days" | "ratio" | "hours";

export interface MetricDefinition {
  id: MetricId;
  label: string;
  unit: MetricUnit;
  direction: "lower-better" | "target-band";
  /** lower-better 的上限;target-band 时用 band。 */
  target: number;
  band?: readonly [number, number];
  denominator: string;
}

export const METRIC_CATALOG: readonly MetricDefinition[] = [
  { id: "capability-lead-time", label: "能力上线周期", unit: "days", direction: "lower-better", target: 14, denominator: "批准到上线的能力条目数" },
  { id: "change-failure-rate", label: "变更失败率", unit: "ratio", direction: "lower-better", target: 0.1, denominator: "本周期部署的变更数" },
  { id: "quota-idle-rate", label: "配额闲置率", unit: "ratio", direction: "lower-better", target: 0.25, denominator: "本周期分配的配额总额(微元)" },
  { id: "review-turnaround", label: "评审平均耗时", unit: "hours", direction: "lower-better", target: 48, denominator: "本周期提交的变更评审数" },
  { id: "process-exemption-rate", label: "流程豁免率", unit: "ratio", direction: "target-band", target: 0.1, band: [0.05, 0.15], denominator: "本周期应走流程的变更数" },
];

export type NonMetric = "process-count" | "approval-count" | "meeting-count" | "document-count";

export const NON_METRICS: readonly NonMetric[] = [
  "process-count", "approval-count", "meeting-count", "document-count",
];

export type MetricLookup =
  | { ok: true; definition: MetricDefinition }
  | { ok: false; reason: "non-metric" | "unknown"; detail: string };

/** 流程数量不是度量:它只证明动作发生过,不证明结果变好。 */
export function lookupMetric(id: string): MetricLookup {
  const definition = METRIC_CATALOG.find((entry) => entry.id === id);
  if (definition !== undefined) return { ok: true, definition };
  if ((NON_METRICS as readonly string[]).includes(id)) {
    return { ok: false, reason: "non-metric", detail: `${id} counts activity, not outcome` };
  }
  return { ok: false, reason: "unknown", detail: `${id} is not registered` };
}

export interface LaunchRecord { id: string; capability: string; approvedOn: string; liveOn: string }

export interface ChangeRecord {
  id: string; capability: string; deployedOn: string;
  failed: boolean; reviewHours: number; exempt: boolean;
}

export interface QuotaRecord { tenant: string; allocatedMicros: number; usedMicros: number }

export interface GovernanceSample {
  period: string;
  launches: readonly LaunchRecord[];
  changes: readonly ChangeRecord[];
  quotas: readonly QuotaRecord[];
}

export interface MetricValue {
  id: MetricId;
  value: number;
  unit: MetricUnit;
  numerator: number;
  denominator: number;
}

export interface GovernanceSnapshot {
  period: string;
  metrics: MetricValue[];
  launchCount: number;
  changeCount: number;
}

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 daysBetween(from: string, to: string): number {
  return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000);
}

function median(values: readonly number[]): number {
  if (values.length === 0) throw new Error("gov-metrics: median of an empty set");
  const sorted = [...values].sort((left, right) => left - right);
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}

function validateSample(sample: GovernanceSample): void {
  if (sample.launches.length === 0) throw new Error("gov-metrics: at least one launch is required");
  if (sample.changes.length === 0) throw new Error("gov-metrics: at least one change is required");
  if (sample.quotas.length === 0) throw new Error("gov-metrics: at least one quota record is required");
  for (const launch of sample.launches) {
    if (!isIsoDate(launch.approvedOn) || !isIsoDate(launch.liveOn)) {
      throw new Error(`gov-metrics: launch ${launch.id} has an invalid date`);
    }
    if (launch.liveOn < launch.approvedOn) {
      throw new Error(`gov-metrics: launch ${launch.id} goes live before approval`);
    }
  }
  for (const change of sample.changes) {
    if (!isIsoDate(change.deployedOn)) throw new Error(`gov-metrics: change ${change.id} has an invalid date`);
    if (!(change.reviewHours >= 0) || Number.isNaN(change.reviewHours)) {
      throw new Error(`gov-metrics: change ${change.id} has invalid review hours`);
    }
  }
  for (const quota of sample.quotas) {
    if (!Number.isInteger(quota.allocatedMicros) || quota.allocatedMicros <= 0) {
      throw new Error(`gov-metrics: quota ${quota.tenant} must be a positive integer`);
    }
    if (!Number.isInteger(quota.usedMicros) || quota.usedMicros < 0
      || quota.usedMicros > quota.allocatedMicros) {
      throw new Error(`gov-metrics: quota ${quota.tenant} used outside 0..allocated`);
    }
  }
}

/** 每个度量都写明分子与分母;比例一律是小数值,不乘 100。 */
export function computeMetrics(sample: GovernanceSample): GovernanceSnapshot {
  validateSample(sample);
  const leadTimes = sample.launches.map((launch) => daysBetween(launch.approvedOn, launch.liveOn));
  const failed = sample.changes.filter((change) => change.failed).length;
  const exempt = sample.changes.filter((change) => change.exempt).length;
  const allocated = sample.quotas.reduce((sum, quota) => sum + quota.allocatedMicros, 0);
  const used = sample.quotas.reduce((sum, quota) => sum + quota.usedMicros, 0);
  const reviews = sample.changes.map((change) => change.reviewHours);
  const metrics: MetricValue[] = [
    { id: "capability-lead-time", value: median(leadTimes), unit: "days",
      numerator: leadTimes.reduce((sum, days) => sum + days, 0), denominator: leadTimes.length },
    { id: "change-failure-rate", value: failed / sample.changes.length, unit: "ratio",
      numerator: failed, denominator: sample.changes.length },
    { id: "quota-idle-rate", value: (allocated - used) / allocated, unit: "ratio",
      numerator: allocated - used, denominator: allocated },
    { id: "review-turnaround", value: median(reviews), unit: "hours",
      numerator: reviews.reduce((sum, hours) => sum + hours, 0), denominator: reviews.length },
    { id: "process-exemption-rate", value: exempt / sample.changes.length, unit: "ratio",
      numerator: exempt, denominator: sample.changes.length },
  ];
  return { period: sample.period, metrics, launchCount: sample.launches.length, changeCount: sample.changes.length };
}

export function metricValue(snapshot: GovernanceSnapshot, id: MetricId): number {
  const metric = snapshot.metrics.find((entry) => entry.id === id);
  if (metric === undefined) throw new Error(`gov-metrics: ${id} missing from snapshot`);
  return metric.value;
}

export type MetricStatus = "on-target" | "off-target";

export function metricStatus(definition: MetricDefinition, value: number): MetricStatus {
  if (definition.direction === "target-band") {
    const band = definition.band;
    if (band === undefined) throw new Error(`gov-metrics: ${definition.id} has no band`);
    return value >= band[0] && value <= band[1] ? "on-target" : "off-target";
  }
  return value <= definition.target ? "on-target" : "off-target";
}

export type Trend = "improving" | "stable" | "worsening" | "out-of-band";

export interface TrendVerdict {
  id: MetricId;
  current: number;
  previous: number;
  delta: number;
  trend: Trend;
}

const DEFAULT_TOLERANCE: Record<MetricId, number> = {
  "capability-lead-time": 1,
  "change-failure-rate": 0.01,
  "quota-idle-rate": 0.02,
  "review-turnaround": 2,
  "process-exemption-rate": 0,
};

/** 趋势判定只比较两个周期;豁免率看是否落在目标带内,不看涨跌。 */
export function compareTrend(
  current: GovernanceSnapshot,
  previous: GovernanceSnapshot,
  tolerances: Partial<Record<MetricId, number>> = {},
): TrendVerdict[] {
  const verdicts: TrendVerdict[] = [];
  for (const definition of METRIC_CATALOG) {
    const now = metricValue(current, definition.id);
    const before = metricValue(previous, definition.id);
    const delta = now - before;
    if (definition.direction === "target-band") {
      const band = definition.band;
      if (band === undefined) throw new Error(`gov-metrics: ${definition.id} has no band`);
      const inside = now >= band[0] && now <= band[1];
      const wasInside = before >= band[0] && before <= band[1];
      verdicts.push({ id: definition.id, current: now, previous: before, delta,
        trend: !inside ? "out-of-band" : wasInside ? "stable" : "improving" });
      continue;
    }
    const tolerance = tolerances[definition.id] ?? DEFAULT_TOLERANCE[definition.id];
    const trend: Trend = Math.abs(delta) <= tolerance ? "stable" : delta < 0 ? "improving" : "worsening";
    verdicts.push({ id: definition.id, current: now, previous: before, delta, trend });
  }
  return verdicts;
}

export interface ScorecardRow {
  definition: MetricDefinition;
  value: number;
  status: MetricStatus;
  trend?: Trend;
}

export interface Scorecard {
  period: string;
  rows: ScorecardRow[];
  overall: "healthy" | "watch" | "degraded";
}

/** 结论只看结果指标;流程数量永远不进入这张表。 */
export function scorecard(
  current: GovernanceSnapshot,
  previous?: GovernanceSnapshot,
): Scorecard {
  const trends = previous === undefined
    ? new Map<MetricId, Trend>()
    : new Map(compareTrend(current, previous).map((entry) => [entry.id, entry.trend]));
  const rows = METRIC_CATALOG.map((definition): ScorecardRow => {
    const trend = trends.get(definition.id);
    return {
      definition,
      value: metricValue(current, definition.id),
      status: metricStatus(definition, metricValue(current, definition.id)),
      ...(trend === undefined ? {} : { trend }),
    };
  });
  const failure = metricValue(current, "change-failure-rate");
  const leadTime = metricValue(current, "capability-lead-time");
  const degraded = failure > 0.15 || leadTime > 21;
  const watching = rows.some((row) => row.status === "off-target"
    || row.trend === "worsening" || row.trend === "out-of-band");
  return { period: current.period, rows, overall: degraded ? "degraded" : watching ? "watch" : "healthy" };
}

示例样本上的运行结果

把示例样本输入 computeMetrics,得到五个值与分母:上线周期 9.5 天(39/4)、失败率 16.67%(2/12)、闲置率 24%(2,400 万/1 亿)、评审 31.5 小时(415/12)、豁免率 16.67%(2/12)。

compareTrend 对五个度量逐一判定:三项 worsening、一项 improving、一项 out-of-band。scorecard 的总体结论是 degraded,触发条件是失败率 16.67% 超过 15%。

这些数字是示例样本上的运行结果,不是生产数据。它们的价值不在于具体数值,而在于每一个都能从原始记录复算:4 条上线记录、12 条变更记录、3 条配额记录。换一组记录,结论会变,但口径不变。

度量怎么用

第一问:哪个度量变差,差了多少。不要只说「治理有待加强」,要说「失败率从 10% 涨到 16.67%,多了 6.67 个百分点,分母是 12 次变更」。

第二问:这个度量对应哪根杠杆。上线周期对应审批路径与评测排队;失败率对应变更分级与回滚能力;闲置率对应配额分配与借用规则;评审耗时对应评审人负载;豁免率对应流程适用范围。没有杠杆的度量,下一季度还会以同样的数字出现。

第三问:下一期准备动哪一根杠杆,预期哪个数字变化多少。预期可以错,但必须写下来,否则复盘无法区分「判断错了」与「没做」。

小结

治理度量要测结果,不测动作。流程数量没有分母、没有方向、容易被优化,不能进入记分卡;代码用 NON_METRICS 显式拒绝它。

五个度量是能力上线周期、变更失败率、配额闲置率、评审平均耗时、流程豁免率,每个都写明分子、分母与目标。示例中前三项恶化、评审改善、豁免率超出目标带,总体结论 degraded,触发点是失败率 16.67% 超过 15%。趋势判定要区分方向型指标与目标带指标,总体结论不能按达标率平均。

阅读导航:上一节:12.2 技术债与退出条件 · 下一节:13.1 治理基线:回声的平台化清单 。

常见错误与边界

  • 用流程数量证明治理成效。数量只能证明动作发生,不能证明结果变好。
  • 度量不写分母。16.67% 是 2/12 还是 200/1,200,结论完全不同。
  • 用平均数代替中位数。一条极端值就能把典型体验描述错。
  • 把豁免率当成越低越好。太低说明流程僵化,团队只能硬扛或绕过。
  • 趋势不看容忍带。正常的周波动被写成 worsening,团队会疲于解释。
  • 总体结论按达标率平均。失败率与上线周期的后果不同,不能同权。
  • 只看单期数字。没有上期对比,无法判断治理是在改善还是在恶化。
  • 度量没有责任人。数字变差时找不到对应的杠杆,复盘只能停在描述。
  • 把示例目标当成行业标准。5%–15% 的豁免带是回声的管理选择,换组织要重新定。
  • 记分卡混入 process-count。一旦允许,所有团队都会开始优化流程数量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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