周报里的 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 成本归因:标签、分摊与单位经济 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。