流程从 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.67pp | worsening |
| 配额闲置率 | 18.00% | 24.00% | +6.00pp | worsening |
| 评审平均耗时 | 44.0 小时 | 31.5 小时 | -12.5 小时 | improving |
| 流程豁免率 | 10.00% | 16.67% | +6.67pp | out-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。一旦允许,所有团队都会开始优化流程数量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。