《互联网产品的 AI 架构·平台化与治理》1.2 治理的四个对象:能力、调用方、租户、版本

治理不是给网关再加一层看板,而是先回答「治什么」。本节把能力、调用方、租户、版本四个对象各自的字段、关系与校验规则写清楚,用一段确定性代码检查对象存在性、引用一致性与语义合法性,并说明为什么只有网关加预算时,重复建设、版本漂移与责任不清仍然无法被发现。

治理先要回答「治什么」

1.1 把规模化的四类冲突摆了出来:能力重复、版本漂移、配额争抢、责任不清。四类冲突看起来分散,其实指向同一个缺口:平台没有一套稳定的对象模型。

没有对象模型,治理只能按事件做:有人重复建能力就发通知,有人用旧版本就拉群,月底额度超了再补规则。事件永远比规则多,团队会把治理理解成「平台又来找麻烦」。有对象模型,治理才能按状态做:能力有在役与退役,版本有当前与弃用,调用方有归属租户与分配额,租户有总额与保留策略。状态可查询,冲突才可判定,责任才落得下去。

本节定义四个对象:能力、调用方、租户、版本。每个对象只保留治理必须的字段,其余信息通过关系或外部系统引用。然后给出关系校验,说明为什么「网关 + 预算」这套执行面工具替代不了对象层。

为什么恰好是四个

治理对象不是越多越好。对象一多,目录会变成第二套业务数据库,维护成本超过收益。四个对象的选择标准是:每一个都必须是冲突的承载者,缺了它,某类冲突就没有地方挂。

能力承载「重复建设」与「质量责任」。调用方承载「谁在用、用多少、属于谁」。租户承载「总额、合规边界与结算主体」。版本承载「兼容性、弃用窗口与迁移计划」。

四个对象也对应四种生命周期:能力从立项到退役,调用方从接入到下线,租户从开通到注销,版本从发布到弃用。生命周期不同,治理动作就不同。模型、提示、评测集、事故也重要,但它们是对象上的属性或关联记录;先立四个对象,其他信息才有稳定的挂载点。

对象一:能力

能力是产品对外承诺的最小治理单位,例如 chat.reply、content.summarize。它不是模型,也不是接口地址;同一个能力可以由不同模型在不同区域实现。

字段类型作用缺失后的表现
idstring稳定标识,全局唯一调用方按文档猜名字
functionKeystring业务功能键,用于判重同一功能出现多个在役能力
ownerstring对质量、成本、退役负责的主体事故与退役无人承接
statusactive/deprecated/retired生命周期状态已退役能力仍被调用
currentVersionsemver推荐版本,漂移判断基准无法判断哪个主版本该保留
supersedesstring?声明替代关系,仅用于合并期正常合并被误判为重复建设

functionKey 是最容易被省略、也最不该省略的字段。chat.reply 与 reply.chat 的 id 不同,功能键都是 reply;平台只有靠功能键才能识别「这是同一件事的两种实现」。没有它,判重只能靠字符串相似度,既误报又漏报。

owner 必须是能做出发布与退役决定的主体,不能是抽象部门。写「平台组」而平台组只管网关,等于没有 owner。判定标准很直接:出事故时能找到人,模型退役时能列清单。status 用 active/deprecated/retired 三态,而不是布尔值;deprecated 表示仍可用但不推荐,retired 表示禁止新调用,弃用窗口才有地方表达。

对象二:调用方

调用方是「谁在用」的答案。它不是用户,也不是租户,而是租户内部的一个产品入口或服务,例如内容团队的发布链路、客服团队的站内信入口。

字段类型作用缺失后的表现
idstring调用方标识,全局唯一无法按入口归因与限流
teamstring负责团队账单与事故找不到责任人
tenantIdstring所属租户配额无法归集到租户
monthlyQuotaMicrosinteger租户额度内的月度份额只能看总用量,无法事前发现超分

调用方与团队不是一对一。内容团队可以同时有主站入口与批处理入口,两个调用方共享 4,000 万份额,但主站入口延迟敏感、批处理入口可以排队。只按团队建模,平台就无法对两个入口做不同的限流与降级。

调用方与租户是多对一:一个租户可以有多个调用方,一个调用方只能属于一个租户。跨租户复用必须新建调用方,不能改原调用方的 tenantId,否则历史账单会跟着迁移,审计线会断。

monthlyQuotaMicros 是分配额,不是用量。它让平台在请求发生前发现「份额之和超过租户总额」;用量由调用关系上的 monthlyCalls 与计量系统共同决定,两者不能混为一谈。

对象三:租户

租户是结算与合规的主体。它可能是外部客户,也可能是内部事业部;治理只关心它有没有独立的额度、数据边界与保留策略。

字段类型作用缺失后的表现
idstring租户标识,全局唯一配额与账单无法归集
namestring展示名对账与合同无法对应
monthlyQuotaMicrosinteger月度总额,整数微元无法判断分配额是否超分

示例口径是租户月度总额 1 亿微元,内容团队 4,000 万、客服团队 3,000 万、交易团队 3,000 万。三个份额相加正好 1 亿,是一个合法的初始分配;一旦新增调用方,就必须同时调整总额或某个份额,否则分配额之和会超过总额。

租户对象故意不存「已用额度」。用量是高频变化的计量结果,放在租户对象上会让目录变成时序数据库。租户只存额度与边界,用量由计量系统按调用关系聚合,两边通过租户 id 关联。

对象四:版本

版本是把契约钉在时间上的对象。能力回答「做什么」,版本回答「按哪份契约定」,两者不能合并。

字段类型作用缺失后的表现
idstring固定为 capabilityId@semver版本引用出现别名
capabilityIdstring所属能力版本成为孤儿
semverstring语义化版本无法判断兼容性
contractRefstring固定为 contract:capabilityId@semver同一版本被换契约
ownerstring该版本的维护主体弃用与迁移无人负责

版本 id 采用固定格式,不允许另起别名。chat.reply@2.1.0 既是标识,也是契约引用的一部分;如果允许 latest、stable 这类别名,同一个别名在不同时间指向不同契约,回放与审计就无法复现。contractRef 必须等于 contract:capabilityId@semver:契约与版本绑定之后,调用方锁定的版本才有意义;同一个版本号换一份契约,等于在调用方不知情的情况下改变语义。

版本 owner 与能力 owner 可以不同。能力 owner 对整体质量负责,版本 owner 对具体版本的发布、补丁与弃用负责。所有权转移通常通过一次主版本升级完成,旧版本仍由原 owner 在迁移窗口内维护。

关系:三条边把四个对象连起来

四个对象本身只是名词,治理能力来自它们之间的三条边。

第一条边是「调用方属于租户」。它决定配额归集与数据边界。一个调用方只能属于一个租户,跨租户复用要新建调用方。

第二条边是「版本属于能力」。它决定兼容性与退役范围。一个版本只能属于一个能力,能力退役时其所有版本一起进入退役流程。

第三条边是「调用关系」。它是一条五元组:调用方、租户、能力、版本、月度调用量。这条边是计费、限流、归因与影响面分析的共同基础。

tenant 1 ─── n caller
capability 1 ─── n version
caller n ─── n capability   (经由 call relation,带 tenant 与 version)

调用关系必须同时带租户与版本。只带调用方与能力,无法回答「这个租户用了哪个版本」;只带能力与版本,无法回答「谁该为这次调用付费」。五元组看起来冗余,实际是把三类口径钉在同一条记录上。

关系校验:存在、引用、语义

对象模型的价值一半在字段,一半在校验。没有校验,目录只是手工维护的表格,错误会在调用时才暴露。校验分三层:对象存在(引用的租户、能力、调用方、版本都必须存在)、引用一致(调用关系的租户等于调用方租户,版本的所属能力等于调用关系声明的能力)、语义合法(版本 id 与契约引用自洽,配额为非负整数,已退役能力不能被新调用)。

下面的模块把三层校验写成一个函数,一次返回全部问题,避免修一个报一个。

// file: src/gov/scope.ts
// tier: L2
// verify: node --test test/gov/scope.test.ts

/** 治理只认四类对象:能力、调用方、租户、版本。其余都是属性或关系。 */
export type GovernanceObjectKind = "capability" | "caller" | "tenant" | "version";

export interface CapabilityObject {
  kind: "capability";
  /** 稳定的能力标识,例如 "chat.reply"。 */
  id: string;
  /** 业务功能键;同一个功能键只应有一个在役能力。 */
  functionKey: string;
  owner: string;
  status: "active" | "deprecated" | "retired";
  /** 当前推荐版本,调用方漂移以它为主版本基准。 */
  currentVersion: string;
  /** 被哪个能力替代;仅用于合并期,不能用来掩盖重复建设。 */
  supersedes?: string;
}

export interface CallerObject {
  kind: "caller";
  /** 调用方标识,通常是团队内的一个产品入口或服务。 */
  id: string;
  team: string;
  tenantId: string;
  /** 该调用方在租户额度内分到的月度份额,整数微元。 */
  monthlyQuotaMicros: number;
}

export interface TenantObject {
  kind: "tenant";
  id: string;
  name: string;
  /** 租户月度总额,整数微元。 */
  monthlyQuotaMicros: number;
}

export interface VersionObject {
  kind: "version";
  /** 固定写成 "capabilityId@semver",不允许另起别名。 */
  id: string;
  capabilityId: string;
  semver: string;
  /** 必须等于 "contract:capabilityId@semver",把契约钉在版本上。 */
  contractRef: string;
  owner: string;
}

/** 一次「谁在什么租户下用哪个能力的哪个版本」的声明。 */
export interface CallRelation {
  callerId: string;
  tenantId: string;
  capabilityId: string;
  versionId: string;
  monthlyCalls: number;
}

export interface GovernanceSnapshot {
  capabilities: readonly CapabilityObject[];
  callers: readonly CallerObject[];
  tenants: readonly TenantObject[];
  versions: readonly VersionObject[];
  calls: readonly CallRelation[];
}

export type GovernanceIssueCode =
  | "duplicate-id"
  | "missing-owner"
  | "invalid-semver"
  | "invalid-version-id"
  | "contract-mismatch"
  | "unknown-tenant"
  | "unknown-caller"
  | "unknown-capability"
  | "unknown-version"
  | "call-tenant-mismatch"
  | "call-capability-mismatch"
  | "invalid-quota"
  | "retired-capability-in-use";

export interface GovernanceIssue {
  object: GovernanceObjectKind;
  id: string;
  code: GovernanceIssueCode;
  detail: string;
}

const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;

function issue(
  object: GovernanceObjectKind,
  id: string,
  code: GovernanceIssueCode,
  detail: string,
): GovernanceIssue {
  return { object, id, code, detail };
}

function isQuota(value: number): boolean {
  return Number.isInteger(value) && value >= 0;
}

function majorOf(semver: string): number {
  return Number.parseInt(semver.split(".")[0], 10);
}

/**
 * 关系校验按「对象存在 → 引用一致 → 语义合法」三层执行。
 * 一次返回全部问题,避免修一个报一个。
 */
export function validateGovernance(snapshot: GovernanceSnapshot): GovernanceIssue[] {
  const issues: GovernanceIssue[] = [];

  const tenantIds = new Set<string>();
  for (const tenant of snapshot.tenants) {
    if (tenantIds.has(tenant.id)) {
      issues.push(issue("tenant", tenant.id, "duplicate-id", "tenant id appears twice"));
    }
    tenantIds.add(tenant.id);
    if (tenant.name.trim() === "") issues.push(issue("tenant", tenant.id, "missing-owner", "tenant name is empty"));
    if (!isQuota(tenant.monthlyQuotaMicros)) {
      issues.push(issue("tenant", tenant.id, "invalid-quota", `quota ${tenant.monthlyQuotaMicros}`));
    }
  }

  const callerIds = new Set<string>();
  for (const caller of snapshot.callers) {
    if (callerIds.has(caller.id)) {
      issues.push(issue("caller", caller.id, "duplicate-id", "caller id appears twice"));
    }
    callerIds.add(caller.id);
    if (caller.team.trim() === "") issues.push(issue("caller", caller.id, "missing-owner", "team is empty"));
    if (!tenantIds.has(caller.tenantId)) {
      issues.push(issue("caller", caller.id, "unknown-tenant", caller.tenantId));
    }
    if (!isQuota(caller.monthlyQuotaMicros)) {
      issues.push(issue("caller", caller.id, "invalid-quota", `quota ${caller.monthlyQuotaMicros}`));
    }
  }

  const capabilityIds = new Set<string>();
  for (const capability of snapshot.capabilities) {
    if (capabilityIds.has(capability.id)) {
      issues.push(issue("capability", capability.id, "duplicate-id", "capability id appears twice"));
    }
    capabilityIds.add(capability.id);
    if (capability.owner.trim() === "") issues.push(issue("capability", capability.id, "missing-owner", "owner is empty"));
    if (!SEMVER.test(capability.currentVersion)) {
      issues.push(issue("capability", capability.id, "invalid-semver", capability.currentVersion));
    }
  }

  const versionIds = new Set<string>();
  const versions = new Map<string, VersionObject>();
  for (const version of snapshot.versions) {
    if (versionIds.has(version.id)) {
      issues.push(issue("version", version.id, "duplicate-id", "version id appears twice"));
    }
    versionIds.add(version.id);
    versions.set(version.id, version);
    if (!capabilityIds.has(version.capabilityId)) {
      issues.push(issue("version", version.id, "unknown-capability", version.capabilityId));
    }
    if (!SEMVER.test(version.semver)) {
      issues.push(issue("version", version.id, "invalid-semver", version.semver));
    }
    const expectedId = `${version.capabilityId}@${version.semver}`;
    if (version.id !== expectedId) {
      issues.push(issue("version", version.id, "invalid-version-id", `expected ${expectedId}`));
    }
    const expectedContract = `contract:${expectedId}`;
    if (version.contractRef !== expectedContract) {
      issues.push(issue("version", version.id, "contract-mismatch", `expected ${expectedContract}`));
    }
    if (version.owner.trim() === "") issues.push(issue("version", version.id, "missing-owner", "owner is empty"));
  }

  const capabilities = new Map(snapshot.capabilities.map((item) => [item.id, item]));
  for (const call of snapshot.calls) {
    const caller = snapshot.callers.find((item) => item.id === call.callerId);
    const version = versions.get(call.versionId);
    if (caller === undefined) {
      issues.push(issue("caller", call.callerId, "unknown-caller", "call references an unknown caller"));
      continue;
    }
    if (!capabilityIds.has(call.capabilityId)) {
      issues.push(issue("capability", call.capabilityId, "unknown-capability", "call references an unknown capability"));
    }
    if (version === undefined) {
      issues.push(issue("version", call.versionId, "unknown-version", "call references an unknown version"));
      continue;
    }
    if (caller.tenantId !== call.tenantId) {
      issues.push(issue("caller", caller.id, "call-tenant-mismatch", `${caller.tenantId} != ${call.tenantId}`));
    }
    if (version.capabilityId !== call.capabilityId) {
      issues.push(issue("version", version.id, "call-capability-mismatch", `${version.capabilityId} != ${call.capabilityId}`));
    }
    const capability = capabilities.get(call.capabilityId);
    if (capability?.status === "retired") {
      issues.push(issue("capability", capability.id, "retired-capability-in-use", caller.id));
    }
    if (!Number.isInteger(call.monthlyCalls) || call.monthlyCalls < 0) {
      issues.push(issue("caller", caller.id, "invalid-quota", `monthlyCalls ${call.monthlyCalls}`));
    }
  }

  return issues;
}

/** 同一能力被多个主版本同时调用时,返回这些主版本号,供规模化冲突统计使用。 */
export function majorsInUse(snapshot: GovernanceSnapshot, capabilityId: string): number[] {
  const vers = new Map(snapshot.versions.map((item) => [item.id, item]));
  const majors = new Set<number>();
  for (const call of snapshot.calls) {
    if (call.capabilityId !== capabilityId) continue;
    const version = vers.get(call.versionId);
    if (version !== undefined && SEMVER.test(version.semver)) majors.add(majorOf(version.semver));
  }
  return [...majors].sort((left, right) => left - right);
}

majorsInUse 之所以也放在这个文件,是因为版本漂移的判断需要同时读调用关系与版本;让 scale.ts 自己再拼一遍,容易把口径写偏。对象与关系的读取只保留一份实现,是治理模块避免口径分裂的基本要求。

示例样本上的运行结果

用 1.1 的 ECHO_SCALE_SAMPLE 跑关系校验,返回 1 条问题:reply.chat 的 owner 为空。其余关系都合法:四个调用方都挂在 tenant-echo,七条调用关系声明的租户与能力都与对象一致,七个版本 id 与契约引用全部自洽。

检查场景结果
最小合法快照(1 租户、1 调用方、1 能力、1 版本)0 条问题
回声规模化样本1 条:reply.chat missing-owner
复制一份相同能力对象1 条:duplicate-id
租户配额设为 1.5、调用方配额设为 -12 条:invalid-quota
调用关系指向不存在的版本,同时原调用使用已退役能力2 条:unknown-version、retired-capability-in-use
调用关系租户与调用方租户不一致1 条:call-tenant-mismatch

这些数字都是示例样本上的运行结果,不是生产数据。它们说明校验器的行为是确定的:同一份输入永远得到同一组问题,问题的对象、id、代码与细节都可以直接进入工单。

为什么「网关 + 预算」不够

网关是执行面:它在请求发生时决定路由、限流、降级与超时。预算是财务面:它约束一段时间内的总花费。两者都很有用,但它们看到的是「调用」,不是「对象」。

网关能看到 caller-support 调用了 chat.reply@1.9.0,但不知道 chat.reply 是否还有一个同功能键的 reply.chat 在役,也不知道 1.9.0 是否已经过了弃用窗口。预算是总额约束,能看到本月花了多少,但不知道 1.05 亿的分配额是谁批的,也不知道某个调用方的份额是否重复计算。

具体地说,网关加预算缺四类信息。一是功能键:平台无法判断两个能力是否在做同一件事,重复建设只能靠人工发现。二是 owner:事故、退役、质量复盘都找不到责任主体。三是生命周期:网关只知道版本存在,不知道它处于 active、deprecated 还是 retired,弃用窗口无法执行。四是分配关系:预算知道总额与用量,不知道租户总额如何拆到团队,也不知道分配额是否超过总额。

这四类信息恰好对应四个对象的字段。换句话说,网关与预算是对象模型的消费者,不是替代品。先把对象建起来,网关才能按功能键做合并路由,预算才能按分配额做事前校验。

四个对象的最小闭环

一个可用的治理对象层,不需要一开始就接齐所有系统。最小闭环是四件事:建对象、连关系、跑校验、出问题单。建对象是把四个对象从现有文档与配置里抽出来,补上 owner、状态与额度;连关系是把调用方挂到租户、版本挂到能力、调用记录补上租户与版本;跑校验是每次变更都执行三层检查;出问题单是把每条问题落到对象、代码与责任人,而不是只在群里说一句「有空看看」。

四件事里最难的是补 owner:技术字段可以从配置里读,owner 必须由组织确认。也正因如此它最值得先做,后续的合并、迁移与配额调整都要有承接人。

小结

治理的第一层不是平台功能,而是四个对象:能力、调用方、租户、版本。能力用功能键判重,调用方用团队与租户定归属,租户用总额定边界,版本用 id 与契约引用钉住兼容性。三条关系把它们连起来:调用方属于租户、版本属于能力、调用关系同时带租户与版本。

关系校验分存在、引用、语义三层,一次返回全部问题。示例样本上,1.1 的规模化快照只报出 1 条 missing-owner;其余场景分别报出 duplicate-id、invalid-quota、unknown-version 与 call-tenant-mismatch。网关与预算管理调用,对象层管理身份与生命周期;缺了对象层,重复建设、版本漂移与责任不清仍然不可见。下一节讨论平台与业务各自该管哪一段。

阅读导航:上一节:1.1 从一条链路到很多条链路:回声的规模化现场 · 下一节:1.3 平台与业务的职责边界 。

常见错误与边界

  • 把能力等同于模型,或省略功能键。模型是版本的一种实现选择;没有功能键,重复建设只能靠名字判断,必然误报漏报。
  • 把调用方当成用户。调用方是租户内部的产品入口,用户维度属于业务数据,不进入治理对象层。
  • 让调用方跨租户漂移,或把「已用额度」写进租户对象。前者会迁移历史账单,后者会把目录变成时序库。
  • 允许版本别名,或让版本与契约分离。latest 在不同时间指向不同契约,同一版本换契约会改写调用方锁定的语义。
  • 只做对象、不做关系,或校验只报第一个错。前者让调用关系指错租户,后者让团队陷入修一个报一个的循环。
  • 把示例样本的 1 条问题当成生产状态。样本只覆盖最小结构,生产问题需要接入真实目录后再统计。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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