《后端架构设计·演进与治理》11.3 平台度量:采纳率、交付效率与反悔成本

从 arch-lab 平台周报里 66% 的采纳率被拆成 38% 的席位加权率这一具体场景切入,说明团队均值这个数字为什么会被小团队放大,如何把未采纳原因、迁移成本、退出成本与回收期放进同一张记分卡,并给出可运行的 Go 实现与本机真实测试输出。

周报里的 66% 和 38%

2026 年 10 月,arch-lab 的平台周报写着“部署黄金路径采纳率 66%,接近年度目标”。管理层准备据此减少平台投入,把人力转去业务项目。订单团队的架构师多问了一句:这个 66% 是怎么算的?

拆开看,五个团队的采纳率分别是 80%、100%、25%、100%、25%,直接平均正好 66%。但按席位加权只算 38%:两个 2 到 4 人的小团队全员使用,20 人的目录团队只有 5 人用,40 人的支付团队只有 10 人用。平台并不是“接近完成”,而是“小团队完成、大团队停滞”。

同一个数字,两种算法,结论相反。平台度量最先要解决的不是采集,而是定义:分母是谁、权重是什么、未采纳的原因由谁填写。没有这三项,采纳率只是一个容易被组织愿望拉高的数字。

平台工程 讨论过平台作为产品的交付方式,DORA 指标 讨论过交付效率的四个维度;本节只处理平台自身的三类度量:采纳、效率归因和反悔成本。

两种平均数回答不同问题

团队均值回答“典型团队用得怎么样”,席位加权率回答“有多少工程师真正在用”。前者适合看体验分布,后者适合看覆盖面和投入产出。两个都报,差距本身就是信号。

指标算法能回答会被什么欺骗
席位加权采纳率总采纳席位 / 总符合条件席位平台覆盖了多少工程师大团队少数人使用会拉低,但真实
团队均值各团队采纳率的算术平均典型团队的体验小团队全员使用会放大结果
均值减加权差距团队均值 − 加权率采纳是否集中在小团队团队规模分布极端时波动大
未采纳原因按原因聚合团队数与未用席位下一步该补文档、功能还是迁移原因留空会被归为 unspecified
交付效率变更前置时间、部署频率等团队交付是否变快无法单独归因给平台

交付效率要特别小心。平台上线后部署频率上升,可能同时受到组织调整、业务需求变化和 CI 改造影响;没有对照组时,不能把全部改善记在平台账上。可行的做法是把采用黄金路径的团队与未采用团队按服务类型配对,观察同口径的前置时间变化,并明确这是相关性而不是因果证明。

未采纳原因必须由未采纳团队填写,不能由平台代填。原因分布比总数更有用:如果多数未用席位集中在“缺少审计日志”,下一步是补能力;如果集中在“不知道有这条路径”,下一步是文档和入口;如果集中在“迁移成本太高”,那就要进入反悔成本的计算。

反悔成本要把退出算进去

平台投入不只看收益,还要看“如果判断错了,退出来要花多少”。本节把反悔成本拆成四项:迁移成本、持续维护成本、收益和退出成本,全部换算成金额或工程师小时,再算回收期与成本收益比。

量含义判据示例
迁移成本接入平台的一次性投入320 工程师小时
持续成本每月维护、适配、培训40 小时/月
月净收益每月节省时间减持续成本80 小时/月
回收期迁移成本 / 月净收益4 个月
反悔成本比(迁移 + 持续) / 总收益0.556
退出成本迁回旧路径的一次性投入50 小时

回收期只回答“多久回本”,不回答“值不值得冒险”。反悔成本比回答“为了这份收益,我们押进去多少”,退出成本回答“判断错了能不能走”。三个数一起看,才能避免“回收期很短,但退出代价极高”的平台锁定。

门禁也要一起看。只设采纳率下限,平台会去追容易覆盖的小团队;只设回收期上限,平台会倾向于做短期见效、长期难退出的能力。下面的 AssessPlatform 把四个阈值放在同一个判决里,任一不满足就给出原因,而不是合成一个可以互相抵消的总分。

可运行实现:采纳率与反悔成本

代码只依赖标准库。ComputeAdoption 同时返回加权率和团队均值,NonAdopterReasons 把空原因归入 unspecified,ComputeRegret 在月净收益不为正时把回收期设为正无穷。

// file: internal/platform/idp/metrics.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestAdoption|TestRegret|TestAssessPlatform'
package idp

import (
	"errors"
	"fmt"
	"math"
	"sort"
)

// AdoptionSample is one team's usage of one capability in a fixed window.
type AdoptionSample struct {
	Team, CapabilityID, Reason string
	Eligible, Adopted          int
}

// Adoption separates the seat-weighted rate from the per-team mean. A large
// gap means adoption is concentrated in small teams.
type Adoption struct {
	Weighted, Mean, Gap float64
	Adopted, Eligible   int
	Teams               int
}

// ComputeAdoption validates samples and returns both rates.
func ComputeAdoption(samples []AdoptionSample) (Adoption, error) {
	if len(samples) == 0 {
		return Adoption{}, errors.New("metrics: no adoption samples")
	}
	var a Adoption
	var sumRate float64
	seen := make(map[string]bool, len(samples))
	for i, s := range samples {
		if s.Team == "" {
			return Adoption{}, fmt.Errorf("metrics: sample[%d] has no team", i)
		}
		if s.Eligible <= 0 || s.Adopted < 0 || s.Adopted > s.Eligible {
			return Adoption{}, fmt.Errorf("metrics: team %q has invalid seats %d/%d", s.Team, s.Adopted, s.Eligible)
		}
		key := s.Team + "\x00" + s.CapabilityID
		if seen[key] {
			return Adoption{}, fmt.Errorf("metrics: duplicate sample for %q", key)
		}
		seen[key] = true
		a.Adopted += s.Adopted
		a.Eligible += s.Eligible
		a.Teams++
		sumRate += float64(s.Adopted) / float64(s.Eligible)
	}
	a.Weighted = float64(a.Adopted) / float64(a.Eligible)
	a.Mean = sumRate / float64(a.Teams)
	a.Gap = a.Mean - a.Weighted
	return a, nil
}

// ReasonCount explains why a team did not fully adopt a capability.
type ReasonCount struct {
	Reason      string
	Teams       int
	UnusedSeats int
}

// NonAdopterReasons aggregates free-text reasons under "unspecified" when
// missing, so silence cannot look like success.
func NonAdopterReasons(samples []AdoptionSample) []ReasonCount {
	byReason := make(map[string]*ReasonCount)
	for _, s := range samples {
		if s.Adopted >= s.Eligible {
			continue
		}
		reason := s.Reason
		if reason == "" {
			reason = "unspecified"
		}
		rc, ok := byReason[reason]
		if !ok {
			rc = &ReasonCount{Reason: reason}
			byReason[reason] = rc
		}
		rc.Teams++
		rc.UnusedSeats += s.Eligible - s.Adopted
	}
	out := make([]ReasonCount, 0, len(byReason))
	for _, rc := range byReason {
		out = append(out, *rc)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Teams != out[j].Teams {
			return out[i].Teams > out[j].Teams
		}
		return out[i].Reason < out[j].Reason
	})
	return out
}

// RegretInput is the cost model for one platform bet, in engineer-hours.
type RegretInput struct {
	Teams                                       int
	MigrationHours, OngoingHoursPerMonth        float64
	BenefitHoursPerMonth, HourlyCost, ExitHours float64
	Months                                      int
}

// Regret is the cost of having adopted, including the cost of leaving.
type Regret struct {
	MigrationCost, OngoingCost, Benefit, Net float64
	PaybackMonths, RegretRatio, ExitCost     float64
}

// ComputeRegret turns hours into money. Payback is infinite when the monthly
// net benefit is not positive.
func ComputeRegret(in RegretInput) (Regret, error) {
	if in.Teams <= 0 || in.Months <= 0 || in.HourlyCost <= 0 {
		return Regret{}, errors.New("metrics: teams, months and hourly cost must be positive")
	}
	if in.MigrationHours < 0 || in.OngoingHoursPerMonth < 0 || in.BenefitHoursPerMonth < 0 || in.ExitHours < 0 {
		return Regret{}, errors.New("metrics: hours must not be negative")
	}
	r := Regret{
		MigrationCost: in.MigrationHours * in.HourlyCost,
		OngoingCost:   in.OngoingHoursPerMonth * float64(in.Months) * in.HourlyCost,
		Benefit:       in.BenefitHoursPerMonth * float64(in.Months) * in.HourlyCost,
		ExitCost:      in.ExitHours * in.HourlyCost,
	}
	r.Net = r.Benefit - r.MigrationCost - r.OngoingCost
	monthlyNet := (in.BenefitHoursPerMonth - in.OngoingHoursPerMonth) * in.HourlyCost
	if monthlyNet <= 0 || r.MigrationCost == 0 {
		r.PaybackMonths = math.Inf(1)
	} else {
		r.PaybackMonths = r.MigrationCost / monthlyNet
	}
	if r.Benefit <= 0 {
		r.RegretRatio = math.Inf(1)
	} else {
		r.RegretRatio = (r.MigrationCost + r.OngoingCost) / r.Benefit
	}
	return r, nil
}

// MetricGate is the agreed threshold set for a platform review.
type MetricGate struct {
	MinAdoption, MaxAdoptionGap, MaxPaybackMonths, MaxRegretRatio float64
}

// Verdict is the review result. Reasons are empty only when Pass is true.
type Verdict struct {
	Pass    bool
	Reasons []string
}

// AssessPlatform checks adoption, concentration and regret together; a single
// good number must not hide a bad one.
func AssessPlatform(a Adoption, r Regret, g MetricGate) Verdict {
	var reasons []string
	if a.Weighted < g.MinAdoption {
		reasons = append(reasons, fmt.Sprintf("weighted adoption %.3f < %.3f", a.Weighted, g.MinAdoption))
	}
	if a.Gap > g.MaxAdoptionGap {
		reasons = append(reasons, fmt.Sprintf("mean-minus-weighted gap %.3f > %.3f", a.Gap, g.MaxAdoptionGap))
	}
	if r.PaybackMonths > g.MaxPaybackMonths {
		reasons = append(reasons, fmt.Sprintf("payback %.2f months > %.2f", r.PaybackMonths, g.MaxPaybackMonths))
	}
	if r.RegretRatio > g.MaxRegretRatio {
		reasons = append(reasons, fmt.Sprintf("regret ratio %.3f > %.3f", r.RegretRatio, g.MaxRegretRatio))
	}
	return Verdict{Pass: len(reasons) == 0, Reasons: reasons}
}

实现里有两个刻意选择。第一,ComputeAdoption 拒绝 Adopted > Eligible 和重复样本,避免分母口径错误悄悄产生一个好看的数字。第二,AssessPlatform 不做加权总分,因为采纳率和反悔成本不能互相补偿:一个高采纳、极高退出成本的平台仍然不应该通过评审。

测试:两种平均数与正无穷回收期

测试用五个团队复现周报里的分歧,并覆盖空样本、零分母、采纳数超分母和重复样本;反悔成本用 320 小时迁移、40 小时/月持续成本、120 小时/月收益计算。

// file: internal/platform/idp/metrics_test.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestAdoption|TestRegret|TestAssessPlatform'
package idp

import (
	"math"
	"testing"
)

func sampleSet() []AdoptionSample {
	return []AdoptionSample{
		{Team: "order", CapabilityID: "cap-deploy", Eligible: 10, Adopted: 8, Reason: "missing audit logs"},
		{Team: "search", CapabilityID: "cap-deploy", Eligible: 4, Adopted: 4},
		{Team: "catalog", CapabilityID: "cap-deploy", Eligible: 20, Adopted: 5, Reason: "no docs"},
		{Team: "risk", CapabilityID: "cap-deploy", Eligible: 2, Adopted: 2},
		{Team: "payments", CapabilityID: "cap-deploy", Eligible: 40, Adopted: 10, Reason: "no docs"},
	}
}

func near(got, want float64) bool { return math.Abs(got-want) < 1e-9 }

func TestAdoptionWeightedVsMean(t *testing.T) {
	a, err := ComputeAdoption(sampleSet())
	if err != nil {
		t.Fatalf("ComputeAdoption: %v", err)
	}
	if a.Adopted != 29 || a.Eligible != 76 || a.Teams != 5 ||
		!near(a.Weighted, 29.0/76.0) || !near(a.Mean, 0.66) || !near(a.Gap, 0.66-29.0/76.0) {
		t.Fatalf("adoption = %+v", a)
	}
	t.Logf("weighted=%.4f mean=%.4f gap=%.4f seats=%d/%d", a.Weighted, a.Mean, a.Gap, a.Adopted, a.Eligible)

	reasons := NonAdopterReasons(sampleSet())
	if len(reasons) != 2 || reasons[0].Reason != "no docs" || reasons[0].Teams != 2 || reasons[0].UnusedSeats != 45 {
		t.Fatalf("reasons = %+v", reasons)
	}
	t.Logf("reasons=%+v", reasons)

	bad := [][]AdoptionSample{nil, {{Team: "order", Eligible: 0}},
		{{Team: "order", Eligible: 2, Adopted: 3}},
		{{Team: "order", CapabilityID: "c", Eligible: 2, Adopted: 1}, {Team: "order", CapabilityID: "c", Eligible: 2, Adopted: 1}}}
	for i, samples := range bad {
		if _, err := ComputeAdoption(samples); err == nil {
			t.Fatalf("bad case %d accepted", i)
		}
	}
}

func testRegret() RegretInput {
	return RegretInput{Teams: 5, MigrationHours: 320, OngoingHoursPerMonth: 40,
		BenefitHoursPerMonth: 120, Months: 12, HourlyCost: 100, ExitHours: 50}
}

func TestRegret(t *testing.T) {
	r, err := ComputeRegret(testRegret())
	if err != nil {
		t.Fatalf("ComputeRegret: %v", err)
	}
	if r.MigrationCost != 32000 || r.OngoingCost != 48000 || r.Benefit != 144000 || r.Net != 64000 ||
		!near(r.PaybackMonths, 4) || !near(r.RegretRatio, 80000.0/144000.0) || r.ExitCost != 5000 {
		t.Fatalf("regret = %+v", r)
	}
	t.Logf("migration=%.0f ongoing=%.0f benefit=%.0f net=%.0f payback=%.1f ratio=%.4f exit=%.0f",
		r.MigrationCost, r.OngoingCost, r.Benefit, r.Net, r.PaybackMonths, r.RegretRatio, r.ExitCost)

	noBenefit := testRegret()
	noBenefit.BenefitHoursPerMonth = 0
	r, err = ComputeRegret(noBenefit)
	if err != nil || !math.IsInf(r.PaybackMonths, 1) || !math.IsInf(r.RegretRatio, 1) {
		t.Fatalf("no-benefit = %+v, %v", r, err)
	}
}

func TestAssessPlatform(t *testing.T) {
	a, _ := ComputeAdoption(sampleSet())
	r, _ := ComputeRegret(testRegret())
	pass := AssessPlatform(a, r, MetricGate{MinAdoption: 0.35, MaxAdoptionGap: 0.30, MaxPaybackMonths: 6, MaxRegretRatio: 0.70})
	if !pass.Pass || len(pass.Reasons) != 0 {
		t.Fatalf("pass verdict = %+v", pass)
	}
	fail := AssessPlatform(a, r, MetricGate{MinAdoption: 0.60, MaxAdoptionGap: 0.15, MaxPaybackMonths: 3, MaxRegretRatio: 0.50})
	if fail.Pass || len(fail.Reasons) != 4 {
		t.Fatalf("fail verdict = %+v", fail)
	}
	for _, reason := range fail.Reasons {
		t.Logf("blocked: %s", reason)
	}
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0。gofmt -l . 与 go vet ./... 无输出;原始测试输出如下,完整日志在 .local/reports/be-arch-gov/ch11-full.txt。

$ go test ./internal/platform/idp/ -count=1 -v -run 'TestAdoption|TestRegret|TestAssessPlatform'
=== RUN   TestAdoptionWeightedVsMean
    metrics_test.go:32: weighted=0.3816 mean=0.6600 gap=0.2784 seats=29/76
    metrics_test.go:38: reasons=[{Reason:no docs Teams:2 UnusedSeats:45} {Reason:missing audit logs Teams:1 UnusedSeats:2}]
--- PASS: TestAdoptionWeightedVsMean (0.00s)
=== RUN   TestRegret
    metrics_test.go:64: migration=32000 ongoing=48000 benefit=144000 net=64000 payback=4.0 ratio=0.5556 exit=5000
--- PASS: TestRegret (0.00s)
=== RUN   TestAssessPlatform
    metrics_test.go:87: blocked: weighted adoption 0.382 < 0.600
    metrics_test.go:87: blocked: mean-minus-weighted gap 0.278 > 0.150
    metrics_test.go:87: blocked: payback 4.00 months > 3.00
    metrics_test.go:87: blocked: regret ratio 0.556 > 0.500
--- PASS: TestAssessPlatform (0.00s)
PASS
ok  	archlab/internal/platform/idp	0.259s

读数分三层。采纳层:加权率 38.16%,团队均值 66.00%,差距 27.84 个百分点;29/76 个席位在用,说明“接近目标”只是均值造成的错觉。原因层:no docs 覆盖 2 个团队、45 个未用席位,missing audit logs 覆盖 1 个团队、2 个席位,前者应该优先补文档和入口。成本层:迁移 32000、持续 48000、收益 144000、净收益 64000,回收期 4 个月,反悔成本比 0.5556,退出成本 5000;月净收益为 0 时回收期与比值都是正无穷。

边界要交代:金额按统一的 100 元/小时示例单价计算,不是真实薪酬;收益来自团队自报的节省时间,存在高估动机。生产上应使用财务确认的小时成本,并用至少两个季度的实际数据校准收益,而不是把一次估算当成承诺。

常见错误与边界

  • 只报团队均值。小团队全员使用会把整体拉到接近目标,掩盖大团队的停滞;必须同时报席位加权率。
  • 未采纳原因让平台代填。平台会倾向于写“用户不知道”,而真实原因可能是能力缺口或迁移成本。
  • 把交付效率全部归因给平台。没有对照组和同口径,部署频率上升不能证明是平台的贡献。
  • 反悔成本只算迁移不算退出。退出成本高的平台会形成锁定,判断错误时无法低成本回退。
  • 用一个加权总分做门禁。采纳率和成本收益比不能互相抵消,任一越界都应单独列出。

小结

平台度量至少要同时回答三个问题:多少工程师真的在用(席位加权采纳率)、采纳是否只发生在小团队(均值与加权率的差距)、这项投入判断错了要花多少才能退出(反悔成本与退出成本)。三个问题对应三个不同的分母和口径,不能合成一个漂亮数字。

本节用五个团队复现了 66% 与 38% 的分歧,用 320 小时迁移和 120 小时/月收益算出 4 个月回收期与 0.5556 的反悔成本比。这些是示例输入下的确定性结果,不是线上实测;真实评审应替换成本、收益和样本窗口,并保留未采纳原因的原话。下一章转向成本治理,讨论标签归因、未分摊桶和单位经济。

阅读导航:上一节:11.2 黄金路径与自助服务:从工单到模板 · 下一节:12.1 成本归因:标签、分摊与单位经济 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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