《互联网产品的 AI 架构·平台化与治理》11.3 培训、文档与知识传递

文档不是写完就算,培训也不是听一次课。本节把文档分为入门、操作、参考、决策四层,按槽位而不是篇数计算覆盖度,参考文档必须钉住当前主版本,受监管能力的操作与参考文档 90 天复审;培训路径按读、跟岗、受控独立、独立四阶段单调推进;新人上手用「首次独立上线且复核通过」定义,示例样本上中位数 31 天、P90 44 天。

只有一个人能上线的那条能力

「回声」的 moderation.classify 由一位工程师维护。提示、阈值、评测集、发布脚本都在他的本地目录里,文档只有一页 README,写着「有问题找我」。他休假的第一周,审核阈值需要临时调整,三个人翻遍仓库也没找到发布入口;第二周,一条监管抽检要求提供最近一次评测记录,团队只能凭记忆复述。

这不是个人问题,而是知识传递没有可判定的载体。文档分层、培训路径与上手度量,本质上是在回答三个可验证的问题:每一层文档是否齐全、培训是否按顺序完成、新人是否能独立上线一条能力。三者都能写成字段与规则,而不是「加强文档建设」这类无法验收的口号。

文档分四层,各回答一个问题

文档不是按篇幅分类,而是按读者要解决的问题分类。四层缺任何一层,读者都会在某个时刻卡住。

层级回答的问题主要读者责任团队最长复审周期
入门 onboarding这个平台为什么存在、边界在哪、怎么拿到第一个结果新人与跨团队调用方平台365 天
操作 how-to发布、回滚、调阈值、处理常见故障的具体步骤值班人与能力维护者能力 owner180 天;受监管 90 天
参考 reference契约字段、参数、错误码、版本与 SLO 的准确清单调用方与集成方能力 owner365 天;受监管 90 天
决策 decision为什么选这个模型、这个阈值、这个所有权模型继任者、评审委员会、审计平台与 owner365 天

入门文档全平台只有一份,用 * 作为能力标识;其余三层按能力各一份。把入门文档复制到每个能力下,会产生五份互相矛盾的「快速开始」,新人反而不知道该信哪一份。

参考文档有一项额外要求:必须钉住当前主版本。content.summarize 已经到 v3,参考文档还写 v2 的字段,读者会以为字段仍然存在。版本钉住不是写一句「以最新版为准」,而是记录一个可比较的整数主版本号。

覆盖度按槽位算,不按篇数算

覆盖度的分母是必需文档槽位,不是仓库里的 Markdown 文件数。示例里有五个能力,槽位是 1 份入门文档加每个能力 3 层,共 16 个槽位。同一层写十篇补充材料,也不能补上缺失的那一层。

判定规则有四条:

  1. 每个槽位恰好一份文档。零份报 missing-doc,多份报 duplicate-doc;两份「操作手册」往往意味着一份已过期但没人敢删。
  2. 文档必须有 owner。没有责任人的文档不会有人主动复审,报 empty-owner。
  3. 超过最长复审周期报 stale-doc。受监管能力的操作与参考文档上限是 90 天,其余按层级计算。
  4. 参考文档必须钉住当前主版本。缺少版本报 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%。

培训路径:四阶段单调推进

培训不是课程清单,而是一条有方向的路径。阶段顺序固定为读、跟岗、受控独立、独立:先读入门与参考,再跟着值班处理真实请求,然后在他人复核下完成一次发布,最后独立上线。

阶段典型时长完成标志约束
读 read4 小时能复述能力边界与失败语义无前置
跟岗 shadow8 小时跟随处理至少 3 个真实请求必须完成读
受控独立 supervised16 小时在复核下完成一次发布与回滚必须完成跟岗
独立 solo8 小时独立上线并通过考核必须完成受控独立,且有考核方式

校验规则有四条:阶段不能倒退;前置步骤必须出现在当前步骤之前;独立阶段必须写考核方式;总时长在 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 天被平均值掩盖,少数能力长期无人能独立维护。
  • 待定记录只报第一个原因。团队修完一个条件,第二次统计才发现还有三个。
  • 文档齐全就认为知识传递完成。没有培训路径与权限绑定,文档仍然只是静态文件。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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