《互联网产品的 AI 架构·多能力编排与任务闭环》12.2 编排版本与灰度

编排版本一换,受影响的不只是新请求,还有在途任务、共享状态与语义缓存。本节把灰度拆成租户、流量、任务类型三个维度,给出新旧版本并行的状态双向兼容检查与缓存隔离规则,并用示例样本跑出分流结果、四类兼容问题,以及可实际执行的回退顺序与放量门禁。

一次全量发布带来的三个故障

「回声」的编排层升级到 b2:链路 B 在检索与答复之间加了一个意图置信度节点,答复提示也换了版本。团队判断改动「只是多一步」,选了低峰时段全量发布。半小时后出现三个互不相关的故障。

第一,发布前进入链路 B 的任务还在跑,恢复时读到新状态结构,找不到自己需要的游标字段,直接失败。第二,语义缓存没有换命名空间,b2 的请求命中了 b1 缓存的摘要,输出里混着旧提示的措辞。第三,一个受合规约束的租户被流量分桶带进了 b2,而它的合同要求新版本先经过人工评审。

三个故障分别对应三件事:在途任务的状态兼容、缓存的版本隔离、租户维度的准入。它们都不在「新版本功能是否正常」的检查清单里,却决定了灰度能不能安全进行。

本节把编排版本灰度拆成三个维度与两类隔离,并给出可运行的分流与兼容判定。文中分流结果与兼容问题来自 ai-lab 的示例样本运行输出,不是生产实测。

灰度不是调一个百分比

单能力灰度只需要一个流量比例:新模型接 5% 请求,观察指标,再放量。编排灰度多了两层结构。

第一层是链路。一次发布可能只改 A 链路的摘要节点,B、C 不动。如果按全局流量放量,没改的链路也被卷进新版本,故障面被无谓放大。

第二层是状态。编排任务可能跑几十秒甚至几分钟,发布瞬间总有任务在半路。新版本写出的状态,旧版本要能读;旧版本写出的状态,新版本也要能读。只保证「新请求走新版本」不够。

所以编排灰度至少要有三个维度:

维度控制什么典型用法不控制的后果
租户谁能进入新版本内部租户先试,合规租户排除合同约束被流量分桶绕过
流量多少请求进入新版本5% → 25% → 50% → 100%一次放量,故障面等于全量
任务类型哪些任务进入新版本先 FAQ,后订单咨询高失败代价任务被提前卷入

三个维度的优先级必须写死:排除名单高于允许名单,租户名单高于任务类型,任务类型高于流量。否则同一请求会得到互相矛盾的路由结果。

流量档位与放量门禁

示例使用四档:5%、25%、50%、100%。档位不是随便定的,每一档都要有明确的观察窗口与停止条件。

档位观察重点进入下一档的条件停止条件
5%新版本是否报错、状态能否读写连续两个窗口无兼容错误出现状态字段缺失
25%成本与延迟是否在护栏内每次成功交付成本不高于 4,139 微元成本高出基线 10%
50%失败传播与部分结果是否符合预期成功率不低于 98.12%成功率跌破 98.12%
100%长尾任务与人工节点观察一个完整业务周期任一租户投诉

表中的 4,139 微元与 98.12% 沿用前文高失败变体的示例口径:成功率的分母是该变体下的全部请求,成本的分母是成功交付次数。换一套失败率假设,这两个门槛要重算,不能当成通用常数。

流量分桶必须稳定。同一个 requestId 在重试、重放与补偿时应该落进同一个版本,否则一次任务的两半会跑在不同编排上。分桶键至少包含版本标识与请求 ID,不能只用随机数。

状态兼容要双向检查

新旧版本并行时,状态兼容有两个方向,缺一不可。

旧读新:旧版本可能恢复新版本写出的检查点。新版本新增的字段可以忽略,但旧版本需要的字段不能消失。这意味着新版本必须继续写旧版本的必填字段,哪怕它自己已经不用了。

新读旧:新版本可能捡起发布前旧版本写出的任务。新版本需要的新字段,旧状态里没有,必须有默认值或迁移逻辑,不能直接判失败。

还有一个容易被忽略的约束:状态 TTL 必须不短于任务最长执行时间。任务上限 45 分钟、状态 TTL 30 分钟时,一个在途任务还没结束,状态已经过期,恢复时读到空值。这不是版本兼容问题,而是配置问题,但表现和兼容问题一样。

缓存必须按版本隔离

编排缓存通常有三类:节点结果缓存、语义缓存、共享上下文前缀缓存。三类的隔离方式不同。

节点结果缓存可以直接把版本写进键。语义缓存更危险:它按语义相似度命中,b2 改了提示或节点顺序,语义相同但输出契约可能已经变了。只改键不够,必须换命名空间,让 b1 与 b2 的语义缓存完全不相交。

共享上下文前缀缓存则要保留跨版本复用,否则每次发布都会把前缀缓存打冷,成本反而上升。判断标准是前缀内容是否变化:只改了后续节点,前缀可以复用;改了系统提示或策略文本,前缀必须换版本。

实现上建议双重隔离:命名空间带版本,键里也带版本。命名空间配错时,键还能兜底;键被截断时,命名空间还能兜底。

实现

下面的模块把三维分流、策略结构检查、状态双向兼容与缓存隔离放在一起。它不执行编排,只回答「这次请求走哪版」与「两版能不能并行」。

// file: src/orch/rollout.ts
// tier: L2
// verify: node --test test/orch/evolution.test.ts

import { AiError } from "../core/errors.ts";

export type WorkflowVersion = "old" | "new";

/** 灰度策略:三个维度(租户、流量、任务类型)共同决定一次请求走哪版。 */
export interface RolloutPolicy {
  /** 新版本标识,用于分桶与缓存键,不是语义版本。 */
  version: string;
  /** 流量档位,必须严格递增且最后一个是 1。 */
  trafficStages: readonly number[];
  /** 当前生效档位下标。 */
  activeStage: number;
  tenantAllowlist: readonly string[];
  tenantDenylist: readonly string[];
  /** 允许进入新版本的任务类型;其余任务留在旧版本。 */
  taskTypes: readonly string[];
  cacheNamespace: string;
  previousCacheNamespace: string;
  /** 新旧版本共享的状态字段。 */
  stateFields: readonly string[];
  /** 旧版本读取状态时必须存在的字段。 */
  legacyRequiredFields: readonly string[];
  /** 新版本额外需要的字段。 */
  newRequiredFields: readonly string[];
  /** 任务最长执行时间,用于判断状态 TTL 是否足够。 */
  maxTaskMinutes: number;
  stateTtlMinutes: number;
}

export interface PolicyIssue {
  code: string;
  message: string;
}

export interface RolloutRequest {
  requestId: string;
  tenantId: string;
  taskType: string;
}

export interface RoutingDecision {
  version: WorkflowVersion;
  reason:
    | "tenant-pinned-old"
    | "tenant-pinned-new"
    | "task-type-excluded"
    | "traffic-in-stage"
    | "traffic-below-stage";
  /** 请求落在 0–9999 的哪个桶;同一版本与 requestId 永远得到同一个值。 */
  bucket: number;
  activeShare: number;
}

export interface RolloutReadiness {
  ready: boolean;
  issues: PolicyIssue[];
  activeShare: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `rollout: ${message}`);
}

function ratio(value: number, field: string): number {
  if (!Number.isFinite(value) || value <= 0 || value > 1) fail(`${field} 必须落在 (0, 1]`);
  return value;
}

function nonNegative(value: number, field: string): number {
  if (!Number.isFinite(value) || value < 0) fail(`${field} 必须是 >= 0 的有限数`);
  return value;
}

/** FNV-1a 32 位;只用于稳定分桶,不用于安全场景。 */
function hash32(input: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < input.length; index += 1) {
    hash ^= input.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

/** 同一 version 与 requestId 的分桶结果稳定,重放不会换版本。 */
export function trafficBucket(version: string, requestId: string): number {
  if (version.trim() === "" || requestId.trim() === "") fail("version 与 requestId 不能为空");
  return hash32(`${version}:${requestId}`) % 10_000;
}

/** 结构性问题:档位、名单与任务类型。它们不涉及状态兼容,但会让灰度失真。 */
export function policyIssues(policy: RolloutPolicy): PolicyIssue[] {
  const issues: PolicyIssue[] = [];
  if (policy.trafficStages.length < 3) {
    issues.push({ code: "stage-count", message: "至少需要 3 个流量档位,否则无法观察爬坡过程" });
  }
  policy.trafficStages.forEach((stage, index) => {
    if (!Number.isFinite(stage) || stage <= 0 || stage > 1) {
      issues.push({ code: "stage-range", message: `第 ${index} 档 ${stage} 必须落在 (0, 1]` });
    }
    if (index > 0 && stage <= policy.trafficStages[index - 1]) {
      issues.push({ code: "stage-order", message: `第 ${index} 档 ${stage} 必须大于前一档 ${policy.trafficStages[index - 1]}` });
    }
  });
  if (policy.trafficStages.at(-1) !== 1) {
    issues.push({ code: "stage-final", message: "最后一档必须是 1,否则新版本永远无法全量" });
  }
  if (!Number.isInteger(policy.activeStage) || policy.activeStage < 0 || policy.activeStage >= policy.trafficStages.length) {
    issues.push({ code: "active-stage", message: `activeStage ${policy.activeStage} 越界` });
  }
  const allowed = new Set(policy.tenantAllowlist);
  for (const tenant of policy.tenantDenylist) {
    if (allowed.has(tenant)) issues.push({ code: "tenant-overlap", message: `租户 ${tenant} 同时在允许与排除名单` });
  }
  if (policy.taskTypes.length === 0) {
    issues.push({ code: "no-task-types", message: "至少指定一种允许进入新版本的任务类型" });
  }
  if (new Set(policy.taskTypes).size !== policy.taskTypes.length) {
    issues.push({ code: "duplicate-task-type", message: "任务类型不能重复" });
  }
  if (policy.cacheNamespace.trim() === "" || policy.previousCacheNamespace.trim() === "") {
    issues.push({ code: "cache-namespace-empty", message: "新旧版本都必须有缓存命名空间" });
  }
  return issues;
}

/**
 * 兼容性只回答一件事:新旧版本并行时,状态与缓存会不会互相读错。
 * 两个方向都要检查:旧版本读新状态,新版本读旧状态。
 */
export function compatibilityIssues(policy: RolloutPolicy): PolicyIssue[] {
  const issues: PolicyIssue[] = [];
  const state = new Set(policy.stateFields);
  const duplicates = policy.stateFields.filter((field, index) => policy.stateFields.indexOf(field) !== index);
  for (const field of new Set(duplicates)) {
    issues.push({ code: "duplicate-state-field", message: `状态字段 ${field} 重复声明` });
  }
  for (const field of policy.newRequiredFields) {
    if (!state.has(field)) {
      issues.push({ code: "new-state-missing", message: `新版本需要 ${field},但共享状态没有该字段` });
    }
  }
  for (const field of policy.legacyRequiredFields) {
    if (!state.has(field)) {
      issues.push({ code: "legacy-state-missing", message: `旧版本需要 ${field},新版本不能停止写入` });
    }
  }
  if (policy.cacheNamespace === policy.previousCacheNamespace) {
    issues.push({ code: "cache-collision", message: "新旧版本共用缓存命名空间,语义变化会命中旧结果" });
  }
  nonNegative(policy.maxTaskMinutes, "maxTaskMinutes");
  if (policy.stateTtlMinutes < policy.maxTaskMinutes) {
    issues.push({ code: "state-ttl-short", message: `状态 TTL ${policy.stateTtlMinutes} 分钟短于任务上限 ${policy.maxTaskMinutes} 分钟` });
  }
  return issues;
}

export function rolloutReadiness(policy: RolloutPolicy): RolloutReadiness {
  const issues = [...policyIssues(policy), ...compatibilityIssues(policy)];
  const activeShare = policy.trafficStages[policy.activeStage] ?? 0;
  return { ready: issues.length === 0, issues, activeShare };
}

function assertActiveStage(policy: RolloutPolicy): number {
  ratio(policy.trafficStages[policy.activeStage] ?? Number.NaN, "activeShare");
  if (policy.tenantAllowlist.some((tenant) => policy.tenantDenylist.includes(tenant))) {
    fail("同一租户不能同时在允许与排除名单");
  }
  return policy.trafficStages[policy.activeStage];
}

/**
 * 三维分流:租户名单优先于任务类型,任务类型优先于流量。
 * 排除名单永远赢:被钉在旧版本的租户不会因为分桶落进新版本。
 */
export function decideVersion(policy: RolloutPolicy, request: RolloutRequest): RoutingDecision {
  const activeShare = assertActiveStage(policy);
  const bucket = trafficBucket(policy.version, request.requestId);
  const base = { bucket, activeShare };
  if (policy.tenantDenylist.includes(request.tenantId)) {
    return { ...base, version: "old", reason: "tenant-pinned-old" };
  }
  if (policy.tenantAllowlist.includes(request.tenantId)) {
    return { ...base, version: "new", reason: "tenant-pinned-new" };
  }
  if (!policy.taskTypes.includes(request.taskType)) {
    return { ...base, version: "old", reason: "task-type-excluded" };
  }
  const threshold = Math.round(activeShare * 10_000);
  return bucket < threshold
    ? { ...base, version: "new", reason: "traffic-in-stage" }
    : { ...base, version: "old", reason: "traffic-below-stage" };
}

/** 缓存键把版本写进命名空间与键本身,双重隔离,避免语义变化后命中旧结果。 */
export function cacheKeyFor(policy: RolloutPolicy, tenantId: string, taskType: string): string {
  if (tenantId.trim() === "" || taskType.trim() === "") fail("tenantId 与 taskType 不能为空");
  return `${policy.cacheNamespace}:v${policy.version}:${tenantId}:${taskType}`;
}

/** 下一档流量比例;已经全量时返回 null。 */
export function nextTrafficStage(policy: RolloutPolicy): number | null {
  const next = policy.activeStage + 1;
  return next < policy.trafficStages.length ? policy.trafficStages[next] : null;
}

decideVersion 的优先级是写死的:排除名单、允许名单、任务类型、流量。这个顺序不能交给配置,否则两个团队会给出相反的路由。compatibilityIssues 同时检查新读旧与旧读新,缺失字段各自报自己的错误码。cacheKeyFor 把版本写两次,是故意的冗余。

示例样本上的运行结果

用 b2 策略、25% 档位与一组示例请求跑分流,结果如下。分桶值由版本标识与请求 ID 决定,换版本或换 ID 都会变化。

请求租户任务类型桶值版本原因
req-001tenant-afaq5,433oldtraffic-below-stage
req-003tenant-afaq195newtraffic-in-stage
req-001tenant-internalorder5,433newtenant-pinned-new
req-001tenant-regulatedfaq5,433oldtenant-pinned-old
req-001tenant-aorder5,433oldtask-type-excluded

25% 档位的阈值是 2,500。req-003 的桶值 195 落在阈值内,进入新版本;req-001 的桶值 5,433 在阈值外,留在旧版本。同样的 req-001,只因为租户在允许名单里就进入新版本;只因为任务类型是 order 就留在旧版本。这说明三个维度不是相加关系,而是有优先级的短路判断。

兼容检查用一个故意配错的策略跑,得到四类问题:

错误码具体问题
cache-collision新旧版本共用缓存命名空间,语义变化会命中旧结果
legacy-state-missing旧版本需要 legacyScore,新版本不能停止写入
new-state-missing新版本需要 planHash,但共享状态没有该字段
state-ttl-short状态 TTL 30 分钟短于任务上限 45 分钟

修正后,rolloutReadiness 返回 ready: true、issues: []、activeShare: 0.25。缓存键的示例值是 orch.b2:vb2:tenant-a:faq;b1 的同租户同任务键是 orch.b1:vb1:tenant-a:faq,两者不可能命中同一份缓存。

回退:钉租户,不翻全量

灰度出问题时,最危险的动作是「全局切回旧版本」。它会把已经在新版本上成功执行的任务也当成故障,并在切换瞬间制造一批状态不连续的任务。

正确的回退顺序是:先停止新版本接单,把允许名单清空或把流量档位降到 0;再等待在途任务排空或到达检查点;最后把仍然失败的任务按幂等键重放到旧版本。排除名单里的租户始终留在旧版本,不需要参与回退。

旧状态与旧缓存不能删。旧版本恢复任务时要读它们;删掉之后,回退就从「切换」变成「数据恢复」。缓存也一样:b1 的命名空间要保留到最后一个在途任务结束,不能因为「已经全量」就清理。

与前后两节的衔接

上一节的三步重构让编排层成为默认路径,本节讨论这条路径怎么换版本。两节共享同一个前提:旧路径与旧状态必须保留到迁移真正结束。

下一节讨论更靠外的一层边界:什么时候固定编排已经不够,需要让模型自己决定路径。版本灰度回答「同一结构的新旧版本怎么并行」,Agent 边界回答「要不要换一种结构」。

小结

编排版本灰度要同时管三个维度:租户、流量、任务类型。优先级必须写死,排除名单永远赢。新旧版本并行时,状态要双向兼容,缓存要按版本隔离,状态 TTL 不能短于任务上限。

示例样本中,25% 档位下 req-003 进入新版本、req-001 留在旧版本;一个配错的策略同时报出缓存冲突、旧字段缺失、新字段缺失与 TTL 过短四类问题。灰度不是调一个百分比,而是让每一次放量都有可判定的准入与可执行的回退。

阅读导航:上一节:12.1 从单能力到编排的重构 · 下一节:12.3 从固定编排到自主 Agent 。

常见错误与边界

  • 只按全局流量放量。没改的链路也被卷进新版本,故障面被放大。
  • 租户名单与任务类型没有优先级。同一请求会得到互相矛盾的路由结果。
  • 排除名单被流量分桶覆盖。合规租户会因为桶值落进新版本。
  • 分桶键只用随机数。重试与重放会落到不同版本,任务两半跑在不同编排上。
  • 只检查旧读新,不检查新读旧。新版本捡起旧任务时缺少字段,直接失败。
  • 新版本停止写旧字段。旧版本恢复任务时读到空值。
  • 状态 TTL 短于任务上限。在途任务还没结束,状态已经过期。
  • 语义缓存只改键不换命名空间。提示一改,旧输出仍会被命中。
  • 把前缀缓存也按版本全量隔离。每次发布都打冷缓存,成本反而上升。
  • 回退时全局切回并删除旧状态。在途任务失去恢复依据,切换变成数据恢复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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