《互联网产品的 AI 架构·平台化与治理》1.1 从一条链路到很多条链路:回声的规模化现场

回声从三条链路扩到多团队、多租户后,治理问题不再是单条链路的重试与预算。本节把能力重复、版本漂移、配额争抢、责任不清四类冲突写成可判定的检测条件,用示例样本跑出 4 条冲突记录,并说明为什么仅有网关加预算,无法回答所有权、版本与退役计划的问题。

三条链路跑稳之后,问题反而变多了

卷三结束时,回声的三条链路都有了自己的基线。链路 A 内容发布安全链成功率 96.25%,每次成功交付 3,496 微元,P95 3,523ms;链路 B 私信客服链 99.58%,3,804 微元,P95 1,738ms;链路 C 站内下单咨询链 95.00%,2,429 微元,P95 793ms。三条链路合计 698/720 = 96.94%,判定 conditional。

单团队看到这组数字,会自然地问「C 什么时候补到 97%」。平台团队看到的却是另一组问题:下个月内容团队要把摘要能力开放给两个新业务,客服团队想复用交易团队的下单动作,大客户要求独立配额,安全团队要求所有审核能力统一升级到新模型。三条链路会变成三十条,一个租户会变成很多个租户。

规模一变,原来藏在链路内部的决定就变成了跨团队的契约。谁维护 chat.reply、旧版本还能用多久、内容团队借走的额度怎么还、事故算谁的,这些问题在卷三的节点契约里没有位置。本节先把规模化现场压缩成四类可判定的冲突,再用一段确定性代码把四类冲突检测出来。

回声最初的三条链路

先把已知事实摆齐。三条链路共用五个能力:chat.reply、search.rerank、content.summarize、moderation.classify、support.assist。链路 A 是 content.summarize → moderation.classify → 人工复核 → 发布;链路 B 是 chat.reply → support.assist → chat.reply;链路 C 是 search.rerank → chat.reply → 下单动作。

链路编排成功率每次成功交付P95
A 内容发布安全链摘要 → 审核 → 人工 → 发布96.25%3,496 微元3,523ms
B 私信客服链回复 → 辅助 → 回复99.58%3,804 微元1,738ms
C 站内下单咨询链重排 → 回复 → 下单95.00%2,429 微元793ms

三能力编排的数字也已经固定:独立调用 4,240 微元,编排后 3,786 微元,下降 10.71%;延迟从 1,200ms 升到 1,269ms,上升 5.79%;每次交接 820 token、35ms,共享上下文 3,200 token,缓存折扣 90%,复用回本线 28.47%,单步失败预算 13.02%。

这些数字回答的是「一条链路跑得好不好」。它们回答不了「两条链路用了同一个能力的两个版本,谁的基线算数」。规模化不是把数字乘以十,而是让原本不存在的比较关系出现。

从三条到很多条:规模改变了什么

假设半年后回声有四个团队、六个调用方、两个租户。内容团队同时服务主站与创作者工具,客服团队接入站内信与外部渠道,交易团队维护下单与退款,安全团队维护审核与风控。每个团队都从三条链路里复制了一份自己熟悉的配置。

这时平台会收到四类请求,每一类都很合理,合在一起却互相矛盾。

第一类请求是「再建一个」。内容团队觉得 chat.reply 的默认提示不适合摘要场景,于是新建 reply.chat,自己维护一套。第二类请求是「先别升级」。客服团队的旧渠道还依赖 chat.reply 1.9.0,不愿意跟交易团队一起升到 2.1.0。第三类请求是「多给一点」。内容团队的大促批处理入口没有独立额度,直接从客服团队的份额里借。第四类请求是「这事谁管」。reply.chat 上线三个月,创建人转岗,没有人知道它是否还在被调用。

四类请求分别对应四类冲突:能力重复、版本漂移、配额争抢、责任不清。它们不是四个独立故障,而是同一个结构问题的四个侧面:治理对象没有被显式建模。

冲突一:能力重复

能力重复最容易被误判成「多写了一个服务」。真正的代价不在代码,而在基线。chat.reply 与 reply.chat 都声称处理回复,但默认档位、提示版本、失败语义、计费口径各自一套。平台看板上「回复能力」的成功率被拆成两条曲线,任何一条下降都无法判断是能力退化还是调用方变化。

更隐蔽的是重复建设会吞掉复用收益。卷三算出三能力编排把每次成功交付从 4,240 微元降到 3,786 微元,前提是三个能力被同一条链路共享。如果每个团队都复制一份「更适合自己」的回复能力,共享上下文与缓存折扣就不再发生,10.71% 的成本下降会被复制成本抵消。

判断能力是否重复,不能只看名字。chat.reply 与 reply.chat 名字不同,功能键相同;反过来,同一个名字下也可能藏着两个完全不同的实现。可判定的口径是「功能键 + 在役状态」:同一功能键下的在役能力超过一个,且没有声明替代关系,就记为重复。

冲突二:版本漂移

版本漂移不是「有人用了旧版本」这么简单。旧版本可能是有意保留的兼容窗口,也可能是没人负责升级的遗留。区别在于是否有人能回答三个问题:这个版本还被谁调用、计划何时迁出、迁移后由谁验收。

在回声的样本里,客服团队用 chat.reply 1.9.0,交易团队用 2.1.0。两个主版本同时在役,契约字段、失败语义、计费单位都可能不同。平台若只按「能力」聚合指标,会把两个版本的样本混在一起;若只按「版本」聚合,又看不到能力整体是否退化。正确做法是两级都要:能力级回答「这个能力有没有变差」,版本级回答「哪个版本该退役」。

版本漂移还会放大交接成本。每次交接 820 token、35ms,是卷三在统一契约下测出的。跨主版本时,字段名与截断规则可能不同,调用方需要额外转换,实际交接成本高于 820 token。这个增量不会自动出现在任何一张现有看板上。

可判定的口径是「同一能力的主版本集合」。集合大小为 1 时没有漂移;大于 1 时,每个额外主版本都要有一条迁移计划,否则记为冲突。注意是主版本,不是补丁版本:补丁与次版本可以在兼容范围内自动进入,主版本必须有人做决定。

冲突三:配额争抢

单租户时,预算是链路的上限;多租户时,预算变成三层:租户总额、团队份额、单次请求上限。示例口径是租户月度总额 1 亿微元,内容团队 4,000 万、客服团队 3,000 万、交易团队 3,000 万;单次上限分别是摘要 6,200、回复 4,200、下单 3,000 微元。

三层都写对,仍然会有争抢。原因是有第四种额度:未登记的借用。内容团队的大促批处理入口没有独立份额,直接在内容团队的 4,000 万里再切一块,账面看不出问题,实际总量已经超过 1 亿。平台在月底对账时才发现缺口,但那时请求已经发生。

配额争抢的判定不能只看「用量是否超过总额」。更早的信号是「分配额之和是否超过租户总额」。分配是事前动作,用量是事后结果;等到用量超标再处理,只能拒绝或欠费。样本里三个正式团队合计正好 1 亿,再加一个 500 万的批处理调用方,分配额变成 1.05 亿,冲突在请求发生前就应被发现。

还要区分「争抢」与「借用」。借用是显式规则:谁借、借多少、什么时候还、借用期间单次上限是否不变。没有这四项,借用就是争抢的委婉说法。

冲突四:责任不清

责任不清最典型的信号是「能力有名字,没有主人」。reply.chat 能被调用、能产生账单、能出现在事故里,但目录里的 owner 是空的。事故发生时,平台不知道该找谁;模型退役时,没人能列出依赖它的链路;季度复盘时,没人对它的质量负责。

责任不清还有第二种形态:有 owner,但 owner 不是能做出决定的人。比如 owner 写的是「平台组」,而平台组只负责网关,不负责提示与评测。这种条目在目录上看起来完整,实际决策仍然悬空。

可判定的口径要落到字段:在役能力的 owner 不能为空,也不能是 unassigned;版本同样要有 owner;owner 必须能对应到一个有发布与退役权限的主体。三条缺一条,就记为责任不清。

责任不清和前三类冲突会互相喂养。没有 owner,就没人阻止重复建设;没有 owner,版本漂移没人排期;没有 owner,配额借用没人归还。所以四类冲突里,责任不清应该最先修。

把现场写成可判定的样本

下面的模块把四类冲突写成检测函数。输入是四类治理对象与调用关系,输出是带严重度与证据的冲突列表。它不做推断,也不读外部系统,只对快照做确定性判断。

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

import { majorsInUse } from "./scope.ts";
import type { GovernanceSnapshot } from "./scope.ts";

export type ScaleConflictCode =
  | "capability-duplicate"
  | "version-drift"
  | "quota-contention"
  | "unclear-ownership";

export interface ScaleConflict {
  code: ScaleConflictCode;
  subject: string;
  severity: "high" | "medium" | "low";
  detail: string;
}

/**
 * 从 3 条链路扩到多团队、多租户后,冲突会集中在四类:
 * 同一功能重复建能力、主版本漂移、租户额度被超分、责任主体缺失。
 */
export function detectScaleConflicts(snapshot: GovernanceSnapshot): ScaleConflict[] {
  const conflicts: ScaleConflict[] = [];

  const byFunction = new Map<string, string[]>();
  for (const capability of snapshot.capabilities) {
    if (capability.status !== "active") continue;
    const group = byFunction.get(capability.functionKey);
    if (group === undefined) byFunction.set(capability.functionKey, [capability.id]);
    else group.push(capability.id);
  }
  for (const [functionKey, ids] of byFunction) {
    if (ids.length < 2) continue;
    const superseded = ids.filter((id) => {
      const target = snapshot.capabilities.find((item) => item.id === id);
      return target?.supersedes !== undefined && ids.includes(target.supersedes);
    });
    if (superseded.length >= ids.length - 1) continue;
    conflicts.push({
      code: "capability-duplicate",
      subject: functionKey,
      severity: "high",
      detail: `active capabilities: ${ids.slice().sort().join(", ")}`,
    });
  }

  for (const capability of snapshot.capabilities) {
    if (capability.status === "retired") continue;
    const majors = majorsInUse(snapshot, capability.id);
    if (majors.length > 1) {
      const callers = snapshot.calls
        .filter((call) => call.capabilityId === capability.id)
        .map((call) => call.callerId);
      conflicts.push({
        code: "version-drift",
        subject: capability.id,
        severity: "high",
        detail: `majors ${majors.join("/")} used by ${[...new Set(callers)].sort().join(", ")}`,
      });
    }
  }

  for (const tenant of snapshot.tenants) {
    const allocated = snapshot.callers
      .filter((caller) => caller.tenantId === tenant.id)
      .reduce((sum, caller) => sum + caller.monthlyQuotaMicros, 0);
    const overage = allocated - tenant.monthlyQuotaMicros;
    if (overage <= 0) continue;
    const ratio = overage / tenant.monthlyQuotaMicros;
    conflicts.push({
      code: "quota-contention",
      subject: tenant.id,
      severity: ratio >= 0.1 ? "high" : "medium",
      detail: `allocated ${allocated} > quota ${tenant.monthlyQuotaMicros} by ${overage} micros`,
    });
  }

  for (const capability of snapshot.capabilities) {
    if (capability.status === "retired") continue;
    if (capability.owner.trim() === "" || capability.owner === "unassigned") {
      conflicts.push({
        code: "unclear-ownership",
        subject: capability.id,
        severity: "high",
        detail: "active capability has no accountable owner",
      });
    }
  }
  for (const version of snapshot.versions) {
    if (version.owner.trim() === "" || version.owner === "unassigned") {
      conflicts.push({
        code: "unclear-ownership",
        subject: version.id,
        severity: "medium",
        detail: "version has no accountable owner",
      });
    }
  }

  return conflicts.sort((left, right) => {
    if (left.code !== right.code) return left.code < right.code ? -1 : 1;
    return left.subject.localeCompare(right.subject);
  });
}

export function countConflicts(conflicts: readonly ScaleConflict[]): Record<ScaleConflictCode, number> {
  const counts: Record<ScaleConflictCode, number> = {
    "capability-duplicate": 0,
    "version-drift": 0,
    "quota-contention": 0,
    "unclear-ownership": 0,
  };
  for (const conflict of conflicts) counts[conflict.code] += 1;
  return counts;
}

/**
 * 示例样本:在卷三三条链路之外,再加入一个影子调用方与一个重复能力。
 * 额度沿用租户 1 亿、内容 4,000 万、客服 3,000 万、交易 3,000 万的口径,
 * 额外 500 万是内容团队批处理入口未登记的借用份额。
 */
export const ECHO_SCALE_SAMPLE: GovernanceSnapshot = {
  tenants: [
    { kind: "tenant", id: "tenant-echo", name: "回声主租户", monthlyQuotaMicros: 100_000_000 },
  ],
  callers: [
    { kind: "caller", id: "caller-content", team: "team-content", tenantId: "tenant-echo", monthlyQuotaMicros: 40_000_000 },
    { kind: "caller", id: "caller-support", team: "team-support", tenantId: "tenant-echo", monthlyQuotaMicros: 30_000_000 },
    { kind: "caller", id: "caller-trade", team: "team-trade", tenantId: "tenant-echo", monthlyQuotaMicros: 30_000_000 },
    { kind: "caller", id: "caller-content-batch", team: "team-content", tenantId: "tenant-echo", monthlyQuotaMicros: 5_000_000 },
  ],
  capabilities: [
    { kind: "capability", id: "chat.reply", functionKey: "reply", owner: "team-support", status: "active", currentVersion: "2.1.0" },
    { kind: "capability", id: "reply.chat", functionKey: "reply", owner: "", status: "active", currentVersion: "1.9.0" },
    { kind: "capability", id: "content.summarize", functionKey: "summarize", owner: "team-content", status: "active", currentVersion: "3.1.1" },
    { kind: "capability", id: "search.rerank", functionKey: "rerank", owner: "team-search", status: "active", currentVersion: "1.7.0" },
    { kind: "capability", id: "moderation.classify", functionKey: "moderation", owner: "team-safety", status: "active", currentVersion: "1.4.2" },
    { kind: "capability", id: "support.assist", functionKey: "assist", owner: "team-support", status: "active", currentVersion: "1.2.0" },
  ],
  versions: [
    { kind: "version", id: "chat.reply@2.1.0", capabilityId: "chat.reply", semver: "2.1.0", contractRef: "contract:chat.reply@2.1.0", owner: "team-support" },
    { kind: "version", id: "chat.reply@1.9.0", capabilityId: "chat.reply", semver: "1.9.0", contractRef: "contract:chat.reply@1.9.0", owner: "team-support" },
    { kind: "version", id: "reply.chat@1.9.0", capabilityId: "reply.chat", semver: "1.9.0", contractRef: "contract:reply.chat@1.9.0", owner: "team-support-legacy" },
    { kind: "version", id: "content.summarize@3.1.1", capabilityId: "content.summarize", semver: "3.1.1", contractRef: "contract:content.summarize@3.1.1", owner: "team-content" },
    { kind: "version", id: "search.rerank@1.7.0", capabilityId: "search.rerank", semver: "1.7.0", contractRef: "contract:search.rerank@1.7.0", owner: "team-search" },
    { kind: "version", id: "moderation.classify@1.4.2", capabilityId: "moderation.classify", semver: "1.4.2", contractRef: "contract:moderation.classify@1.4.2", owner: "team-safety" },
    { kind: "version", id: "support.assist@1.2.0", capabilityId: "support.assist", semver: "1.2.0", contractRef: "contract:support.assist@1.2.0", owner: "team-support" },
  ],
  calls: [
    { callerId: "caller-support", tenantId: "tenant-echo", capabilityId: "chat.reply", versionId: "chat.reply@1.9.0", monthlyCalls: 120_000 },
    { callerId: "caller-trade", tenantId: "tenant-echo", capabilityId: "chat.reply", versionId: "chat.reply@2.1.0", monthlyCalls: 80_000 },
    { callerId: "caller-content", tenantId: "tenant-echo", capabilityId: "content.summarize", versionId: "content.summarize@3.1.1", monthlyCalls: 90_000 },
    { callerId: "caller-content", tenantId: "tenant-echo", capabilityId: "moderation.classify", versionId: "moderation.classify@1.4.2", monthlyCalls: 90_000 },
    { callerId: "caller-content-batch", tenantId: "tenant-echo", capabilityId: "content.summarize", versionId: "content.summarize@3.1.1", monthlyCalls: 20_000 },
    { callerId: "caller-trade", tenantId: "tenant-echo", capabilityId: "search.rerank", versionId: "search.rerank@1.7.0", monthlyCalls: 60_000 },
    { callerId: "caller-support", tenantId: "tenant-echo", capabilityId: "support.assist", versionId: "support.assist@1.2.0", monthlyCalls: 50_000 },
  ],
};

代码只依赖 majorsInUse 一个函数:它按能力返回当前被调用的主版本集合。检测顺序与冲突的修复顺序一致:先看重复建设,再看版本漂移,再看额度分配,最后看责任人。顺序不影响结果,但影响阅读者先处理哪一类。

示例样本上的运行结果

在示例样本上运行 detectScaleConflicts,得到 4 条记录,四类各 1 条。

冲突主体严重度证据
能力重复reply 功能键highchat.reply、reply.chat 同时在役
版本漂移chat.replyhigh主版本 1/2 同时被客服与交易调用
配额争抢tenant-echohigh分配额 1.05 亿 > 总额 1 亿,超出 500 万
责任不清reply.chathigh在役能力 owner 为空

配额超出的 500 万,正好是内容团队批处理入口的未登记份额。把它写进样本不是为了制造一个漂亮数字,而是为了说明:配额冲突可以在请求发生前被检出,条件是平台知道每个调用方的分配额,而不只是知道总用量。

版本漂移这一条要特别看证据粒度。检测结果记录了主版本 1/2 和调用方 caller-support, caller-trade,但不会替团队判断哪个版本应该保留。平台给事实,团队做决定;如果把决定也写进检测器,规则就会随组织变化而频繁失效。

能力重复的判定使用了 supersedes 字段。如果 reply.chat 明确声明替代 chat.reply,并且旧能力进入弃用流程,那么两条记录就不算重复建设,而是一次正常的合并。治理不是禁止新建,而是要求新建时说明与既有能力的关系。

为什么「网关 + 预算」先失效

前三卷已经有一套很实用的工具:网关统一入口、预算约束单次成本、降级保证可用、观测记录调用。很多团队会认为把这套工具复制到多团队就够了。样本说明它不够。

网关能回答「这次调用花了多少」,回答不了「同一个功能键下为什么有两个能力」。预算能回答「这个月有没有超支」,回答不了「1.05 亿的分配额是谁批的」。降级能回答「模型不可用时怎么办」,回答不了「旧主版本还能不能继续被调用」。观测能回答「哪条链路失败了」,回答不了「这个能力归谁」。

四个缺口对应四个治理对象:能力、调用方、租户、版本。网关与预算只覆盖了「调用」这一层,没有覆盖「对象」这一层。对象不存在,就没有地方挂 owner、版本、配额与退役计划。

这也是为什么规模化治理的第一步不是买一个平台,而是把对象建模。对象建模之后,网关与预算仍然是执行面,只是上面多了一层可查询、可校验、可追责的目录。

四类冲突的修复顺序

四类冲突不应该同时修。责任不清是根,先补 owner,才能有人对重复建设、版本漂移与配额借用负责。能力重复是次优,因为合并能力会改变调用方,必须在 owner 明确之后做。版本漂移需要迁移窗口,通常跨季度。配额争抢最急,但它可以在事前用分配额校验挡住,不必等治理结构全部到位。

顺序冲突第一步判定完成的标准
1责任不清给每个在役能力与版本补 owner目录中空 owner 为 0
2能力重复声明功能键与替代关系每个功能键只剩一个在役能力
3版本漂移列出主版本与调用方,排迁移窗口每个能力只保留一个主版本
4配额争抢校验分配额之和不超过租户总额借用有上限、有归还时间

这张表不是项目计划,而是判定顺序。顺序错了,后面三步都会变成没有负责人的会议。

小结

回声的三条链路基线是 A 96.25%、B 99.58%、C 95.00%,合计 698/720 = 96.94%,判定 conditional。规模化之后,真正先出问题的不是成功率,而是能力重复、版本漂移、配额争抢、责任不清四类结构冲突。

本节把四类冲突写成确定性检测:同一功能键下的在役能力、同一能力的主版本集合、租户分配额之和、在役能力与版本的 owner。示例样本上跑出 4 条记录,四类各 1 条,其中配额超出 500 万、chat.reply 同时存在主版本 1 与 2、reply.chat 没有 owner。

网关与预算管理的是调用,治理管理的是对象。对象建不起来,规模化就只能靠会议与口头约定。下一节把四类冲突收敛成四个治理对象,并给出它们的字段与关系。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 治理的四个对象:能力、调用方、租户、版本 。

常见错误与边界

  • 把规模化理解成流量乘以十。对象之间的比较关系才是新问题,流量只是放大器。
  • 用能力名判断重复。名字不同但功能键相同的两个能力,仍然是重复建设。
  • 把版本漂移当成「有人偷懒」。没有迁移窗口与验收人,旧版本就会被无限期保留。
  • 只看租户总用量,不看分配额。等到用量超标,只能拒绝或欠费,无法事前协商。
  • 把借用当成争抢的反面。没有上限与归还时间的借用,本质仍是争抢。
  • 认为 owner 写「平台组」就算清楚。平台组若没有提示与评测权限,决策仍然悬空。
  • 试图一次修完四类冲突。没有 owner 的合并与迁移,最后都会回到原点。
  • 把检测器当决策器。检测器给事实与证据,保留哪个版本、合并哪个能力仍要团队决定。
  • 样本里有 4 条冲突就以为生产只有 4 条。示例样本只覆盖四类各一个最小例子。
  • 用本文数字直接做容量规划。所有数字都是示例样本上的运行结果,不是生产实测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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