三条链路跑稳之后,问题反而变多了
卷三结束时,回声的三条链路都有了自己的基线。链路 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 功能键 | high | chat.reply、reply.chat 同时在役 |
| 版本漂移 | chat.reply | high | 主版本 1/2 同时被客服与交易调用 |
| 配额争抢 | tenant-echo | high | 分配额 1.05 亿 > 总额 1 亿,超出 500 万 |
| 责任不清 | reply.chat | high | 在役能力 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 条。示例样本只覆盖四类各一个最小例子。
- 用本文数字直接做容量规划。所有数字都是示例样本上的运行结果,不是生产实测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。