只有一个人能上线的那条能力
「回声」的 moderation.classify 由一位工程师维护。提示、阈值、评测集、发布脚本都在他的本地目录里,文档只有一页 README,写着「有问题找我」。他休假的第一周,审核阈值需要临时调整,三个人翻遍仓库也没找到发布入口;第二周,一条监管抽检要求提供最近一次评测记录,团队只能凭记忆复述。
这不是个人问题,而是知识传递没有可判定的载体。文档分层、培训路径与上手度量,本质上是在回答三个可验证的问题:每一层文档是否齐全、培训是否按顺序完成、新人是否能独立上线一条能力。三者都能写成字段与规则,而不是「加强文档建设」这类无法验收的口号。
文档分四层,各回答一个问题
文档不是按篇幅分类,而是按读者要解决的问题分类。四层缺任何一层,读者都会在某个时刻卡住。
| 层级 | 回答的问题 | 主要读者 | 责任团队 | 最长复审周期 |
|---|---|---|---|---|
入门 onboarding | 这个平台为什么存在、边界在哪、怎么拿到第一个结果 | 新人与跨团队调用方 | 平台 | 365 天 |
操作 how-to | 发布、回滚、调阈值、处理常见故障的具体步骤 | 值班人与能力维护者 | 能力 owner | 180 天;受监管 90 天 |
参考 reference | 契约字段、参数、错误码、版本与 SLO 的准确清单 | 调用方与集成方 | 能力 owner | 365 天;受监管 90 天 |
决策 decision | 为什么选这个模型、这个阈值、这个所有权模型 | 继任者、评审委员会、审计 | 平台与 owner | 365 天 |
入门文档全平台只有一份,用 * 作为能力标识;其余三层按能力各一份。把入门文档复制到每个能力下,会产生五份互相矛盾的「快速开始」,新人反而不知道该信哪一份。
参考文档有一项额外要求:必须钉住当前主版本。content.summarize 已经到 v3,参考文档还写 v2 的字段,读者会以为字段仍然存在。版本钉住不是写一句「以最新版为准」,而是记录一个可比较的整数主版本号。
覆盖度按槽位算,不按篇数算
覆盖度的分母是必需文档槽位,不是仓库里的 Markdown 文件数。示例里有五个能力,槽位是 1 份入门文档加每个能力 3 层,共 16 个槽位。同一层写十篇补充材料,也不能补上缺失的那一层。
判定规则有四条:
- 每个槽位恰好一份文档。零份报
missing-doc,多份报duplicate-doc;两份「操作手册」往往意味着一份已过期但没人敢删。 - 文档必须有 owner。没有责任人的文档不会有人主动复审,报
empty-owner。 - 超过最长复审周期报
stale-doc。受监管能力的操作与参考文档上限是 90 天,其余按层级计算。 - 参考文档必须钉住当前主版本。缺少版本报
missing-version-pin,版本落后报stale-version-pin。
覆盖度向下取整。13 个有效槽位除以 16 得 81.25%,报告写 81%,不写「约八成」;整数百分比可以直接进季度目标,模糊描述不能。
把覆盖度写成确定性检查
docs.ts 提供三组能力:文档覆盖度评估、培训路径校验、新人上手度量。覆盖度按槽位逐一检查缺失、重复、owner、时效与版本钉住;培训路径检查阶段单调、前置关系与考核;上手度量把「独立上线」拆成四个可判定的条件。
// file: src/gov/docs.ts
// tier: L2
// verify: node --test test/gov/org.test.ts
import { daysBetween, isIsoDate } from "./review.ts";
/** 四层文档:入门讲为什么,操作讲怎么做,参考讲是什么,决策讲为什么这样定。 */
export type DocLayer = "onboarding" | "how-to" | "reference" | "decision";
const MAX_AGE_DAYS: Record<DocLayer, number> = {
onboarding: 365, "how-to": 180, reference: 365, decision: 365,
};
const REGULATED_MAX_AGE_DAYS: Partial<Record<DocLayer, number>> = { "how-to": 90, reference: 90 };
/** 平台级入门文档用 "*" 作为能力标识,全站只有一份。 */
export const PLATFORM_SCOPE = "*";
export interface DocProfile { capability: string; currentMajor: number; regulated: boolean }
export interface CapabilityDoc {
capability: string;
layer: DocLayer;
path: string;
owner: string;
reviewedAt: string;
/** 参考文档必须钉住当前主版本;其余层可留空。 */
pinnedMajor?: number;
}
export type DocGapCode =
| "missing-doc" | "duplicate-doc" | "empty-owner" | "invalid-date"
| "stale-doc" | "missing-version-pin" | "stale-version-pin";
export interface DocGap { capability: string; layer: DocLayer; code: DocGapCode; detail: string }
export interface DocsCoverage {
totalSlots: number;
coveredSlots: number;
/** 向下取整的百分比,分母是必需文档槽位总数。 */
coveragePercent: number;
gaps: readonly DocGap[];
}
function maxAge(profile: DocProfile | undefined, layer: DocLayer): number {
return profile?.regulated === true
? REGULATED_MAX_AGE_DAYS[layer] ?? MAX_AGE_DAYS[layer]
: MAX_AGE_DAYS[layer];
}
/** 覆盖度按槽位算,不按文档篇数算:同一层写十篇也补不上缺的那一层。 */
export function assessDocs(
profiles: readonly DocProfile[],
docs: readonly CapabilityDoc[],
asOf: string,
): DocsCoverage {
const gaps: DocGap[] = [];
const slots: { capability: string; layer: DocLayer; profile?: DocProfile }[] = [
{ capability: PLATFORM_SCOPE, layer: "onboarding" },
];
for (const profile of profiles) {
for (const layer of ["how-to", "reference", "decision"] as const) {
slots.push({ capability: profile.capability, layer, profile });
}
}
const gap = (capability: string, layer: DocLayer, code: DocGapCode, detail: string): void => {
gaps.push({ capability, layer, code, detail });
};
const known = new Set(profiles.map((profile) => profile.capability));
for (const doc of docs) {
if (doc.capability !== PLATFORM_SCOPE && !known.has(doc.capability)) {
gap(doc.capability, doc.layer, "missing-doc", "unknown capability");
}
}
let covered = 0;
for (const slot of slots) {
const matches = docs.filter(
(doc) => doc.capability === slot.capability && doc.layer === slot.layer,
);
if (matches.length !== 1) {
gap(slot.capability, slot.layer, matches.length === 0 ? "missing-doc" : "duplicate-doc",
`${matches.length} documents`);
continue;
}
const doc = matches[0];
let ok = true;
if (doc.owner.trim() === "") { gap(slot.capability, slot.layer, "empty-owner", doc.path); ok = false; }
if (!isIsoDate(doc.reviewedAt)) {
gap(slot.capability, slot.layer, "invalid-date", `${doc.path} ${doc.reviewedAt}`);
ok = false;
} else {
const age = daysBetween(doc.reviewedAt, asOf);
const limit = maxAge(slot.profile, slot.layer);
if (age > limit) {
gap(slot.capability, slot.layer, "stale-doc", `${doc.path} is ${age} days, limit ${limit}`);
ok = false;
}
}
if (slot.layer === "reference") {
const expected = slot.profile?.currentMajor;
if (doc.pinnedMajor === undefined) {
gap(slot.capability, slot.layer, "missing-version-pin", doc.path);
ok = false;
} else if (doc.pinnedMajor !== expected) {
gap(slot.capability, slot.layer, "stale-version-pin",
`${doc.path} pins v${doc.pinnedMajor}, current is v${expected}`);
ok = false;
}
}
if (ok) covered += 1;
}
return {
totalSlots: slots.length,
coveredSlots: covered,
coveragePercent: Math.floor((covered / slots.length) * 100),
gaps,
};
}
export type TrainingStage = "read" | "shadow" | "supervised" | "solo";
const STAGE_RANK: Record<TrainingStage, number> = { read: 0, shadow: 1, supervised: 2, solo: 3 };
export interface TrainingStep {
id: string;
stage: TrainingStage;
hours: number;
/** 前置步骤 id,必须出现在当前步骤之前。 */
requires?: string;
/** 独立上线前的考核方式,例如 "盲评 20 条 + 回滚演练"。 */
assessment?: string;
}
export type TrainingIssueCode =
| "duplicate-step" | "empty-id" | "invalid-hours" | "stage-regression"
| "unknown-prerequisite" | "forward-prerequisite" | "solo-missing-assessment"
| "path-too-short" | "path-too-long";
export interface TrainingIssue { stepId: string; code: TrainingIssueCode; detail: string }
export interface TrainingPathResult { totalHours: number; steps: number; issues: readonly TrainingIssue[] }
/** 培训路径必须单调推进:读完再跟岗,跟岗后再受控独立,最后才独立。 */
export function validateTrainingPath(steps: readonly TrainingStep[]): TrainingPathResult {
const issues: TrainingIssue[] = [];
const seen = new Set<string>();
let totalHours = 0;
let previousRank = -1;
for (const [index, step] of steps.entries()) {
const push = (code: TrainingIssueCode, detail: string): void => {
issues.push({ stepId: step.id, code, detail });
};
if (step.id.trim() === "") push("empty-id", "step id is empty");
if (seen.has(step.id)) push("duplicate-step", "duplicate step id");
seen.add(step.id);
if (!Number.isInteger(step.hours) || step.hours <= 0) push("invalid-hours", `${step.hours}`);
else totalHours += step.hours;
const rank = STAGE_RANK[step.stage];
if (rank < previousRank) push("stage-regression", `${step.stage} after a later stage`);
previousRank = Math.max(previousRank, rank);
if (step.stage === "solo" && (step.assessment ?? "").trim() === "") {
push("solo-missing-assessment", "solo step needs an assessment");
}
if (step.requires !== undefined) {
const prerequisite = steps.findIndex((item) => item.id === step.requires);
if (prerequisite === -1) push("unknown-prerequisite", step.requires);
else if (prerequisite >= index) push("forward-prerequisite", step.requires);
}
}
if (totalHours < 8) issues.push({ stepId: "", code: "path-too-short", detail: `${totalHours}h` });
if (totalHours > 60) issues.push({ stepId: "", code: "path-too-long", detail: `${totalHours}h` });
return { totalHours, steps: steps.length, issues };
}
export interface OnboardingRecord {
engineer: string;
capability: string;
startedAt: string;
/** 第一次独立上线并通过复核的日期;未独立则留空。 */
firstIndependentAt?: string;
reviewPassed: boolean;
/** 上线过程中需要他人接手的次数;超过 1 次不算独立。 */
interventions: number;
docsUpdated: boolean;
}
export interface LiveReadiness {
engineer: string;
ready: boolean;
reasons: readonly string[];
days?: number;
}
/** 独立上线的判定是四个条件的合取,不是主管的一句「差不多了」。 */
export function liveReadiness(record: OnboardingRecord): LiveReadiness {
const reasons: string[] = [];
let days: number | undefined;
if (record.firstIndependentAt === undefined) {
reasons.push("no independent change");
} else if (!isIsoDate(record.startedAt) || !isIsoDate(record.firstIndependentAt)) {
reasons.push("invalid dates");
} else {
days = daysBetween(record.startedAt, record.firstIndependentAt);
if (days < 0) reasons.push("independent date precedes start");
}
if (!record.reviewPassed) reasons.push("review not passed");
if (record.interventions > 1) reasons.push(`interventions ${record.interventions} > 1`);
if (!record.docsUpdated) reasons.push("docs not updated");
return {
engineer: record.engineer, ready: reasons.length === 0, reasons,
...(days !== undefined && days >= 0 ? { days } : {}),
};
}
export interface OnboardingStats {
cohort: number;
independent: number;
pending: number;
/** 偶数样本取中间两天的平均值,因此可能是 .5 天。 */
medianDays: number | null;
p90Days: number | null;
targetDays: number;
verdict: "on-target" | "over-target" | "insufficient-sample";
}
export function onboardingStats(
records: readonly OnboardingRecord[],
targetDays = 30,
): OnboardingStats {
const days = records.map(liveReadiness)
.filter((item) => item.ready && item.days !== undefined)
.map((item) => item.days as number)
.sort((a, b) => a - b);
const independent = days.length;
const pending = records.length - independent;
if (independent === 0) {
return { cohort: records.length, independent, pending, medianDays: null, p90Days: null,
targetDays, verdict: records.length < 3 ? "insufficient-sample" : "over-target" };
}
const mid = Math.floor(independent / 2);
const medianDays = independent % 2 === 1 ? days[mid] : (days[mid - 1] + days[mid]) / 2;
const p90Days = days[Math.min(independent - 1, Math.ceil(independent * 0.9) - 1)];
return {
cohort: records.length, independent, pending, medianDays, p90Days, targetDays,
verdict: records.length < 3 ? "insufficient-sample"
: medianDays <= targetDays ? "on-target" : "over-target",
};
}
代码里有两个刻意的取舍。第一,覆盖度不把「文档总篇数」作为分子,避免团队用补充材料刷高指标;槽位是有限的,缺一层就是缺一层。第二,受监管能力的 90 天限制只加在操作与参考两层,入门与决策仍按 365 天,因为监管关心的是操作步骤与字段口径是否最新,而不是平台的起源故事。
示例样本上的运行结果
示例样本包含五个能力、一份平台入门文档与十四份能力文档,共 16 个槽位。以下是示例样本上的运行结果。
| 检查项 | 结果 |
|---|---|
| 必需槽位 | 16 |
| 有效槽位 | 13 |
| 覆盖率 | 81% |
| 缺口 | 3 个 |
chat.reply 参考文档 | 钉住 v3,当前 v4,stale-version-pin |
moderation.classify 操作文档 | 169 天未复审,受监管上限 90 天,stale-doc |
support.assist 决策文档 | 缺失,missing-doc |
三个缺口分别代表三种失效:版本漂移、时效过期、层次缺失。它们不会在同一次检查里互相掩盖,因为每个槽位独立判定。若把 support.assist 的决策文档补上,覆盖率升到 87%;再把摘要参考文档更新到 v4,升到 93%;最后复审审核操作文档,才到 100%。
培训路径:四阶段单调推进
培训不是课程清单,而是一条有方向的路径。阶段顺序固定为读、跟岗、受控独立、独立:先读入门与参考,再跟着值班处理真实请求,然后在他人复核下完成一次发布,最后独立上线。
| 阶段 | 典型时长 | 完成标志 | 约束 |
|---|---|---|---|
读 read | 4 小时 | 能复述能力边界与失败语义 | 无前置 |
跟岗 shadow | 8 小时 | 跟随处理至少 3 个真实请求 | 必须完成读 |
受控独立 supervised | 16 小时 | 在复核下完成一次发布与回滚 | 必须完成跟岗 |
独立 solo | 8 小时 | 独立上线并通过考核 | 必须完成受控独立,且有考核方式 |
校验规则有四条:阶段不能倒退;前置步骤必须出现在当前步骤之前;独立阶段必须写考核方式;总时长在 8 到 60 小时之间。示例路径共 36 小时,四个阶段,0 个问题。总时长下限防止「看两页文档就上线」,上限防止培训变成长期脱产。
把独立阶段放在受控独立之前,会触发 stage-regression;前置步骤指向后面的步骤,会触发 forward-prerequisite;独立阶段没写考核,会触发 solo-missing-assessment。这些都不是风格问题,而是路径无法执行。
新人多久能独立上线一条能力
「多久能上手」不能靠主管印象。先定义什么叫独立上线,再谈天数。独立上线是四个条件的合取:
- 有第一次独立上线的日期;
- 上线后复核通过;
- 过程中需要他人接手的次数不超过 1 次;
- 本次上线同时更新了对应文档。
四条缺一不可。少一条,记录就进入 pending,不参与天数统计。示例样本有 5 位新人,4 位满足条件,1 位仍在待定;待定记录的失败原因是「没有独立上线」「复核未通过」「接手 3 次」「文档未更新」,四类原因会同时列出,而不是只报第一个。
四位独立新人的天数分别是 21、27、35、44 天。中位数取中间两天的平均值,为 31 天;P90 为 44 天。目标是 30 天,因此示例样本的结论是 over-target:中位数只差 1 天,但尾部拖到 44 天,说明问题不在平均带教质量,而在少数能力缺少可复用的操作文档。
天数统计只使用满足四条件的记录,不用「入职到第一次提交」代替。后者会把读文档、配环境的时间算进去,也会把没有真正上线的记录算成成功,指标会虚高。
知识传递的三条落地规则
第一条,文档槽位进发布门禁。能力发布前检查四层文档是否齐全、参考文档是否钉住新版本;缺层不允许发布,而不是发布后补文档。
第二条,培训路径与能力绑定。每条能力声明自己的培训路径与考核方式,新人完成路径才能获得该能力的发布权限;权限来自路径完成记录,不来自主管口头授权。
第三条,上手天数按季度统计。报告同时给出中位数、P90 与待定人数;只看中位数会掩盖尾部,只看平均数会被一个极端值带偏。连续两个季度 P90 超过目标,优先修操作文档与考核方式,而不是加长培训课时。
三条规则合起来,把「知识传递」从个人美德变成平台状态:文档有槽位、培训有路径、上手有度量。
小结
文档分四层:入门回答为什么,操作回答怎么做,参考回答是什么,决策回答为什么这样定。覆盖度按槽位计算,示例样本 16 个槽位覆盖 13 个,覆盖率 81%,三个缺口分别是版本漂移、时效过期与层次缺失。
培训路径按读、跟岗、受控独立、独立四阶段单调推进,示例路径 36 小时、0 个问题。新人上手用四个条件定义独立上线,示例样本 4 人独立、1 人待定,中位数 31 天、P90 44 天,相对 30 天目标判定为 over-target。知识传递只有落到槽位、路径与天数,才能被验收。
阅读导航:上一节:11.2 评审委员会与决策记录 · 下一节:12.1 模型换代与迁移计划 。
常见错误与边界
- 用文档篇数衡量覆盖度。补充材料越多,指标越好看,缺的那一层仍然缺。
- 把入门文档复制到每个能力下。多份「快速开始」互相矛盾,新人不知道以哪份为准。
- 参考文档不钉版本。字段已经删除,文档还在教调用方使用。
- 受监管能力按普通周期复审。90 天与 180 天的差距,足以让一次监管抽检落在过期文档上。
- 培训路径允许阶段倒退。没跟过岗就直接独立上线,考核变成走过场。
- 独立阶段不写考核方式。「能独立」由主管主观判断,无法复核。
- 用入职天数代替上手天数。读文档与配环境的时间被算成能力建设成果。
- 只报中位数。尾部 44 天被平均值掩盖,少数能力长期无人能独立维护。
- 待定记录只报第一个原因。团队修完一个条件,第二次统计才发现还有三个。
- 文档齐全就认为知识传递完成。没有培训路径与权限绑定,文档仍然只是静态文件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。