一次「复用」引发的归属争议
「回声」的客服团队要在私信里加一条会话总结,直接复用了内容团队的 content.summarize。两周后摘要质量下降,内容团队说自己只负责内容侧提示,客服团队说能力不是自己建的。事故复盘时,能力目录的 owner 一栏写着 team-content,但契约版本谁冻结、SLO 谁承诺、半夜谁值班,三个问题各有不同的答案。
问题不在于谁写代码,而在于所有权模型从未被定义。平台团队以为「能力在目录里登记过」就等于交接完成,业务团队以为「调用方就是使用方」不等于负责方。缺少一份明确的模型与判据,owner 字段就只是一行字符串。
三种所有权模型
所有权模型回答三件事:契约谁冻结、内容谁维护、发布谁能否决。三者可以落在同一个团队,也可以拆开,但不能没有答案。
| 模型 | 平台负责 | 业务负责 | 发布决定权 | 典型失效 |
|---|---|---|---|---|
平台全权 platform-owns | 契约、实现、默认提示、评测基线、值班 | 提需求、报障、在自己的调用方内做参数覆盖 | 平台;主版本仍需评审委员会 | 业务私自改提示绕过评测,质量口径分裂 |
业务全权 business-owns | 网关、观测、配额、契约格式校验 | 契约内容、提示、样本、阈值、值班 | 业务 | 跨租户复用后平台无法统一升级与审计 |
平台管契约业务管内容 platform-contract-business-content | 契约版本、SLO、审计口径、一线值班 | 提示、样本、阈值、领域知识、二线支持 | 契约变更平台评审,内容变更业务自主 | 双方都以为对方负责,事故时互相等待 |
模型三不是折中口号,它有明确的切分线:凡是调用方可见的输入输出、失败语义、版本号与 SLO,归平台;凡是影响单次输出质量但不改变契约的提示、样本与阈值,归业务。切分线一旦写进 owner 字段,事故复盘时就不需要重新谈判。
选型判据:按顺序判定,不按偏好投票
模型选择容易变成组织政治,所以判据必须是有限的现场事实,并且有固定顺序。判定只看七个输入:调用团队数、租户数、是否跨租户、内容变更频率、是否需要领域知识、输出是否受监管、是否平台原语。
判定顺序如下:
regulatedOutput = true:直接进入模型三。受监管输出必须由平台持有契约、版本与审计口径,业务可以维护内容,但不能单独决定行为边界。platformPrimitive = true且内容变更频率为低、且不需要领域知识:进入平台全权。平台原语加稳定内容,集中维护的成本低于多方协调。- 调用团队数大于等于 2,或租户数大于等于 2,或跨租户:进入模型三。复用范围一旦超出单团队,契约就必须被冻结。
- 其余情况:业务全权。单团队、单租户、非平台原语,平台只在网关层提供能力。
顺序本身就是优先级。一个受监管的平台原语先命中第一条,不会被第二条抢走;一个多团队使用但内容高度变化的摘要能力命中第三条,不会被「平台原语」误判为平台全权。
示例样本的五个能力得到三种归属:
| 能力 | 调用团队 | 租户 | 关键事实 | 判定 |
|---|---|---|---|---|
chat.reply | 3 | 2 | 跨租户、内容高频变化 | 模型三 |
search.rerank | 1 | 1 | 平台原语、内容稳定 | 平台全权 |
content.summarize | 2 | 1 | 多团队复用、需要领域知识 | 模型三 |
moderation.classify | 3 | 2 | 受监管输出 | 模型三 |
support.assist | 1 | 1 | 单团队、内容高频变化 | 业务全权 |
owner 字段不是自由文本
模型确定后,要落成能力目录里可校验的 owner 字段。最小结构包含六项:accountable、contractOwner、contentOwner、oncall、escalation、reviewCadenceDays。每一项都有对应的团队类型约束,不能只写一个团队名。
| 字段 | 含义 | 校验规则 |
|---|---|---|
accountable | 最终负责团队,事故与季度复盘都找它 | 必须是非空、已登记的团队 |
contractOwner | 契约、版本与 SLO 的所有者 | 平台全权与模型三必填;模型三必须等于 accountable |
contentOwner | 提示、样本与阈值的所有者 | 业务全权与模型三必填;模型三必须不同于 contractOwner |
oncall | 一线值班标识 | 非空;平台全权与模型三应是平台值班 |
escalation | 二线升级路径 | 非空;业务全权可指向业务负责人 |
reviewCadenceDays | 所有权复查周期 | 1 到 365 的整数;受监管能力不得超过 90 天 |
平台全权要求 contractOwner 等于 accountable,因为平台同时持有契约与实现;业务全权要求 contractOwner 与 contentOwner 都等于 accountable,即使显式记录也不能指向别的团队;模型三要求契约方是平台、内容方是业务,且两者不能合并。之所以把「等于」也写进校验,是因为所有权拆分一旦退化成同一个团队,模型三就名存实亡。
复查周期不是形式字段。受监管能力的内容与提示会直接影响对外输出,90 天是上限;平台原语通常 90 天;业务全权可以放宽到 180 天。周期超过上限时,校验直接报 cadence-too-slow,而不是等到季度审计才发现。
把模型与字段写成确定性校验
org.ts 提供三组能力:模型判定、owner 字段校验、整目录一致性校验。判定返回模型与判据;单条校验检查团队类型、字段完整性与周期;整目录校验再补三类跨条目规则:能力重复、owner 缺失、owner 指向不存在的能力。模型判定结果还会与 owner 声明的 model 比对,防止目录写一套、现场跑另一套。
// file: src/gov/org.ts
// tier: L2
// verify: node --test test/gov/org.test.ts
/** 三种所有权模型:平台全权、业务全权、平台管契约业务管内容。 */
export type OwnershipModel =
| "platform-owns"
| "business-owns"
| "platform-contract-business-content";
export type ContentVolatility = "low" | "medium" | "high";
/** 判定只看这七个事实,不看组织架构图上的汇报关系。 */
export interface CapabilityProfile {
capability: string;
/** 调用方按团队计数,同一团队的多个入口只算一个。 */
callingTeams: number;
tenants: number;
crossesTenant: boolean;
contentVolatility: ContentVolatility;
requiresDomainExpertise: boolean;
regulatedOutput: boolean;
platformPrimitive: boolean;
}
export type OwnershipBasis =
| "regulated-output"
| "platform-primitive"
| "multi-team-reuse"
| "cross-tenant"
| "single-team-local";
export interface OwnershipDecision {
capability: string;
model: OwnershipModel;
basis: readonly OwnershipBasis[];
rationale: string;
}
/** 判定顺序就是选型判据:先合规,再原语,再复用范围,最后才允许业务自治。 */
export function decideOwnership(profile: CapabilityProfile): OwnershipDecision {
const base = { capability: profile.capability };
if (profile.regulatedOutput) {
return { ...base, model: "platform-contract-business-content", basis: ["regulated-output"],
rationale: "受监管输出由平台持有契约与审计口径,业务只维护内容" };
}
if (profile.platformPrimitive && profile.contentVolatility === "low"
&& !profile.requiresDomainExpertise) {
return { ...base, model: "platform-owns", basis: ["platform-primitive"],
rationale: "平台原语且内容稳定,平台同时持有契约、实现与默认内容" };
}
if (profile.callingTeams >= 2 || profile.tenants >= 2 || profile.crossesTenant) {
const basis: OwnershipBasis[] = [];
if (profile.callingTeams >= 2) basis.push("multi-team-reuse");
if (profile.tenants >= 2 || profile.crossesTenant) basis.push("cross-tenant");
return { ...base, model: "platform-contract-business-content", basis,
rationale: "多团队或多租户复用,契约由平台冻结,业务保留内容调整权" };
}
return { ...base, model: "business-owns", basis: ["single-team-local"],
rationale: "单团队单租户且非平台原语,业务可以端到端持有" };
}
/** 批量判定;重复能力标识直接拒绝,避免后者覆盖前者。 */
export function decideAll(profiles: readonly CapabilityProfile[]): OwnershipDecision[] {
const seen = new Set<string>();
for (const profile of profiles) {
if (seen.has(profile.capability)) throw new Error(`duplicate capability: ${profile.capability}`);
seen.add(profile.capability);
}
return profiles.map(decideOwnership);
}
/** 写进能力目录 owner 字段的最小结构。 */
export interface CapabilityOwner {
capability: string;
model: OwnershipModel;
/** 最终负责团队,事故与季度复盘都找它。 */
accountable: string;
/** 契约所有者;模型一与模型三必填。 */
contractOwner?: string;
/** 内容所有者;模型二与模型三必填。 */
contentOwner?: string;
oncall: string;
escalation: string;
/** 所有权复查周期,天;受监管能力不得超过 90 天。 */
reviewCadenceDays: number;
}
export interface Team { id: string; kind: "platform" | "business" }
export type OwnerIssueCode =
| "duplicate-capability" | "duplicate-owner" | "unknown-capability" | "missing-owner"
| "empty-field" | "unknown-team" | "team-kind-mismatch" | "missing-contract-owner"
| "missing-content-owner" | "owner-same-party" | "owner-not-accountable"
| "model-mismatch" | "invalid-cadence" | "cadence-too-slow";
export interface OwnerIssue {
capability: string;
code: OwnerIssueCode;
field: string;
detail: string;
}
const MODELS: readonly OwnershipModel[] = [
"platform-owns", "business-owns", "platform-contract-business-content",
];
function bad(capability: string, code: OwnerIssueCode, field: string, detail: string): OwnerIssue {
return { capability, code, field, detail };
}
function teamIssue(
teams: ReadonlyMap<string, Team>, capability: string, field: string,
teamId: string | undefined, kind: "platform" | "business", issues: OwnerIssue[],
): void {
if (teamId === undefined || teamId.trim() === "") return;
const team = teams.get(teamId);
if (team === undefined) {
issues.push(bad(capability, "unknown-team", field, `${teamId} is not registered`));
} else if (team.kind !== kind) {
issues.push(bad(capability, "team-kind-mismatch", field, `${teamId} is ${team.kind}`));
}
}
/** 单条 owner 字段校验;模型与团队类型必须互证。 */
export function validateOwnerEntry(owner: CapabilityOwner, teams: readonly Team[]): OwnerIssue[] {
const issues: OwnerIssue[] = [];
const byId = new Map(teams.map((team) => [team.id, team]));
for (const field of ["capability", "accountable", "oncall", "escalation"] as const) {
if (owner[field].trim() === "") {
issues.push(bad(owner.capability, "empty-field", field, `${field} is empty`));
}
}
if (!MODELS.includes(owner.model)) {
issues.push(bad(owner.capability, "model-mismatch", "model", `unknown ${owner.model}`));
}
if (!Number.isInteger(owner.reviewCadenceDays)
|| owner.reviewCadenceDays < 1 || owner.reviewCadenceDays > 365) {
issues.push(bad(owner.capability, "invalid-cadence", "reviewCadenceDays", "not in 1..365"));
}
const add = (code: OwnerIssueCode, field: string, detail: string): void => {
issues.push(bad(owner.capability, code, field, detail));
};
if (owner.model === "platform-owns") {
if (owner.contractOwner === undefined) add("missing-contract-owner", "contractOwner", "required");
teamIssue(byId, owner.capability, "accountable", owner.accountable, "platform", issues);
teamIssue(byId, owner.capability, "contractOwner", owner.contractOwner, "platform", issues);
if (owner.contractOwner !== undefined && owner.contractOwner !== owner.accountable) {
add("owner-not-accountable", "contractOwner", "must equal accountable");
}
if (owner.contentOwner !== undefined && owner.contentOwner !== owner.accountable) {
add("owner-not-accountable", "contentOwner", "platform-owns keeps content in-house");
}
} else if (owner.model === "business-owns") {
if (owner.contentOwner === undefined) add("missing-content-owner", "contentOwner", "required");
teamIssue(byId, owner.capability, "accountable", owner.accountable, "business", issues);
teamIssue(byId, owner.capability, "contentOwner", owner.contentOwner, "business", issues);
teamIssue(byId, owner.capability, "contractOwner", owner.contractOwner, "business", issues);
for (const field of ["contentOwner", "contractOwner"] as const) {
const value = owner[field];
if (value !== undefined && value !== owner.accountable) {
add("owner-not-accountable", field, "business-owns keeps both with accountable");
}
}
} else if (owner.model === "platform-contract-business-content") {
if (owner.contractOwner === undefined) add("missing-contract-owner", "contractOwner", "required");
if (owner.contentOwner === undefined) add("missing-content-owner", "contentOwner", "required");
teamIssue(byId, owner.capability, "accountable", owner.accountable, "platform", issues);
teamIssue(byId, owner.capability, "contractOwner", owner.contractOwner, "platform", issues);
teamIssue(byId, owner.capability, "contentOwner", owner.contentOwner, "business", issues);
if (owner.contractOwner !== undefined && owner.contractOwner !== owner.accountable) {
add("owner-not-accountable", "contractOwner", "must equal accountable");
}
if (owner.contentOwner !== undefined && owner.contentOwner === owner.contractOwner) {
add("owner-same-party", "contentOwner", "contract and content must not collapse");
}
}
return issues;
}
/** 整目录校验:能力、owner、团队三方一一对上,模型还要与现场事实一致。 */
export function validateOwners(
profiles: readonly CapabilityProfile[],
owners: readonly CapabilityOwner[],
teams: readonly Team[],
): OwnerIssue[] {
const issues: OwnerIssue[] = [];
const profileIds = new Set<string>();
for (const profile of profiles) {
if (profileIds.has(profile.capability)) {
issues.push(bad(profile.capability, "duplicate-capability", "capability", "appears twice"));
}
profileIds.add(profile.capability);
}
const ownerIds = new Set<string>();
for (const owner of owners) {
if (ownerIds.has(owner.capability)) {
issues.push(bad(owner.capability, "duplicate-owner", "capability", "appears twice"));
}
ownerIds.add(owner.capability);
const profile = profiles.find((item) => item.capability === owner.capability);
if (profile === undefined) {
issues.push(bad(owner.capability, "unknown-capability", "capability", "no profile"));
} else {
const expected = decideOwnership(profile).model;
if (owner.model !== expected) {
issues.push(bad(owner.capability, "model-mismatch", "model", `expected ${expected}`));
}
if (profile.regulatedOutput && owner.reviewCadenceDays > 90) {
issues.push(bad(owner.capability, "cadence-too-slow", "reviewCadenceDays",
`${owner.reviewCadenceDays} days for a regulated capability`));
}
}
issues.push(...validateOwnerEntry(owner, teams));
}
for (const profile of profiles) {
if (!ownerIds.has(profile.capability)) {
issues.push(bad(profile.capability, "missing-owner", "capability", "no owner entry"));
}
}
return issues;
}
代码里有两个刻意的取舍。第一,判定函数不接收组织架构,只接收七个现场事实;组织调整不应该让所有权判定自动漂移。第二,business-owns 也要求显式写 contractOwner 与 contentOwner,而不是留空表示「都在业务」;留空会在所有权转移时丢失责任锚点。
整目录校验的顺序也重要:先查能力重复,再查 owner 重复与未知能力,最后逐条做字段校验。这样同一份提交里的错误会一次列全,团队不需要在「发布—报错—再发布」之间来回。
示例样本上的运行结果
示例样本包含五个能力画像、五个 owner 条目与六个团队。以下是示例样本上的运行结果。
| 操作 | 结果 |
|---|---|
| 五个画像判定 | 平台全权 1、业务全权 1、模型三 3 |
| 五条完整 owner | 0 个问题 |
| 三条问题 owner | 8 个问题:团队类型不匹配 4、契约与内容同队 1、复查超期 1、模型不符 1、owner 缺失 2 |
删除 support.assist 的 owner | 1 个 missing-owner |
| 重复提交同一能力画像 | 抛出 duplicate capability |
三条问题条目分别是:把 chat.reply 的 contentOwner 写成平台团队、把 moderation.classify 的复查周期改成 120 天、把 support.assist 的模型改成模型三。它们分别触发 team-kind-mismatch 与 owner-same-party、cadence-too-slow、model-mismatch 与两处团队类型错误。另有两条能力因为被从目录里删掉而报 missing-owner,合计八个问题。
这组数字说明校验的价值不是「找到一个错」,而是让一份错误的目录提交无法被合并。八个问题里任何一个单独存在,都足以在事故当晚让值班人找不到能改配置的团队。
所有权转移的三条规则
第一条,所有权绑定版本,不绑定能力名。把 content.summarize 从业务全权改为平台管契约,必须发一次主版本,旧版本保留原 owner 直到迁移窗口结束。原地改 owner 会让已经锁定旧版本的调用方失去责任主体。
第二条,模型三不能省掉任何一方。契约方是平台、内容方是业务,accountable 必须落在平台;如果内容方空缺,平台就会在事故时同时承担内容责任,模型三退化为平台全权。
第三条,复用范围变化要重新判定。一个业务全权能力从单团队扩展到两个租户,下一次发布前必须重新跑判定;判定结果变化时,先完成所有权迁移,再开放新租户调用。
三条规则合起来,把「谁拥有 AI 能力」从一次会议结论变成目录里可以查询、可以校验、可以随版本迁移的状态。
小结
能力所有权有三种模型:平台全权、业务全权、平台管契约业务管内容。选择不靠偏好,而靠七个现场事实与固定判定顺序:先合规,再平台原语,再复用范围,最后才允许业务自治。示例样本的五个能力得到 1 个平台全权、1 个业务全权、3 个模型三。
模型要落到 owner 字段才算完成。accountable、contractOwner、contentOwner、oncall、escalation、reviewCadenceDays 六项分别回答最终责任、契约责任、内容责任、值班、升级与复查节奏;团队类型、字段相等关系与周期上限都由代码校验。五条完整条目返回 0 个问题,三条问题条目一次返回 8 个问题。
阅读导航:上一节:10.3 平台自身的 SLO 与容量 · 下一节:11.2 评审委员会与决策记录 。
常见错误与边界
- 把 owner 写成一个人名。人会离职,团队才是可交接的责任主体。
- 用「能力在谁的系统里」推断所有权。系统归属与契约、内容、值班责任可以分离。
- 模型三只写一个 owner。契约与内容没有分开记录,事故时必然互相等待。
- 业务全权却不记录契约所有者。所有权转移时找不到旧契约的签字方。
- 受监管能力把复查周期设成一年。内容漂移在两次审计之间无人发现。
- 判定顺序随意调整。受监管输出可能被「平台原语」规则误判为平台全权。
- 所有权变更原地改字段。已锁定旧版本的调用方失去责任主体,回滚也无法复现。
- 复用范围扩大后不重判。单团队能力直接开放给第二个租户,契约却仍由业务单方修改。
- 把校验当成发布前的形式检查。owner 缺失应该在目录提交时失败,而不是在事故值班表上发现。
- 认为模型一旦确定就永久有效。组织、租户与监管范围变化都会触发重新判定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。