《互联网产品的 AI 架构·多能力编排与任务闭环》11.1 声明式编排与代码编排

声明式与代码编排的分界不在语言,而在谁维护、怎么测试、怎么灰度、能力上限在哪。本节用「回声」链路 A 的四节点定义给出四条判据与混合方案:结构声明化、节点代码化、边界固定化,说明如何同时保留可治理性、表达力与秒级回滚能力,以及哪些需求必须交回代码。

一条链路,两种写法

「回声」的链路 A 只有三步:search.rerank 重排候选,content.summarize 压缩长文,chat.reply 生成回复。第一版把它写成一个 TypeScript 服务,三个函数顺序调用,中间加日志和重试。第二版换成一份声明式定义:节点、依赖、重试次数、预算和条件写在配置里,引擎负责解析、排序和执行。

两条链路都能跑通,成本同为 4,240 微元,延迟同为 1,200ms。三条链路、五个能力、一个团队时,两种写法几乎没有差别。差别出现在第四季度:链路增加到十二条,四个团队开始提交节点,有人想在回复前插入自定义审核,有人想把摘要换成两个并行版本择优。

这时问题不再是「哪种写法更优雅」,而是「谁改、怎么验、怎么退、能表达多复杂的东西」。这四个问题才是声明式与代码编排的真正分界。

四个判据

判据声明式代码选择信号
谁维护平台团队维护引擎,业务团队改配置业务团队维护服务代码改动频繁且跨团队时,声明式降低发布门槛
如何测试规范化结果做黄金图对比单元测试加集成测试拓扑稳定时,黄金图比端到端测试便宜
如何灰度定义版本加摘要,按租户放量部署新版本,按流量切分需要秒级回滚时,声明式更直接
能力上限静态 DAG、有限条件、固定扇出任意控制流、动态规划拓扑依赖运行时结果时,代码更合适

四条判据里,最容易被忽略的是「谁维护」。声明式不是把复杂度消灭了,而是把复杂度从每个业务服务挪到了平台引擎。平台必须提供校验、版本、灰度、回滚和观测;否则配置只是另一种散落的代码,而且更难调试。

第二容易被忽略的是「能力上限」。声明式 DSL 每增加一个控制流关键字,就离「重新发明一门编程语言」更近一步。当业务提出「根据检索结果数量决定并行几个摘要节点」时,正确的回答不是给 DSL 加 forEach,而是承认这属于代码节点或代码编排。

声明式的边界在哪里

三类需求不应进入声明式 DSL。

第一,动态扇出。并行分支数量由上游结果决定,例如检索返回 3 条还是 30 条。声明式图在编译期就固定了节点数,运行期改图会让预算、权限和观测全部失效。

第二,质量驱动的循环。例如摘要质量低于阈值就重写,最多五轮。这是典型的 while 语义,用条件节点加回边表达,等价于在配置里写程序。

第三,跨请求调度。把长任务挂起、等待人工审批、再恢复执行,需要状态机与检查点,不是单次图执行能覆盖的。这些能力应当由引擎提供原语,业务用代码节点调用,而不是塞进 DSL。

本节模块的条件语法只有 field==value 与 field!=value,重试上限为 3,预算必须是非负整数微元。限制不是偷懒,而是让「可静态校验」成为声明式的核心收益。一个能被完整校验的定义,才能在合并前判断它是否会超预算、是否有环、是否引用了不存在的节点。

混合方案:声明结构 + 代码节点

「回声」最终采用的不是二选一,而是三层分工。

第一层,结构声明化。节点、依赖、重试、预算、超时、条件写在定义里。平台可以在提交时做静态校验,在发布时生成灰度键,在运行时按拓扑序调度。

第二层,节点内部代码化。capability 节点调用目录里的能力,code 节点执行自定义逻辑。链路 A 在重排后加了一个 custom.guard 代码节点,只在 risk==high 时执行,预算为 0,超时 80ms。它不调用模型,只做规则判断。

第三层,边界固定化。代码节点不能增删节点、不能改图、不能覆盖全局预算或授权,也不能读写其他节点的私有状态。它只能消费声明的输入、产出声明的输出。这条边界让平台仍然能做全局优化,也让代码节点可以被单独测试。

混合方案的关键不是「配置里嵌代码」,而是「结构可校验,节点可替换」。结构决定能不能跑、花多少钱、失败了怎么办;节点决定具体怎么做。两者用契约连接,而不是用共享内存连接。

把定义规范化成图

platform.ts 做三件事:校验定义、稳定拓扑排序、生成灰度摘要。校验一次返回全部问题,而不是遇到第一个错误就停;排序在入度相同的节点之间按声明顺序决胜,保证同一份定义每次得到同一张图;摘要只是 FNV-1a 的 32 位结果,用来做灰度分桶,不是安全哈希。

// file: src/orch/platform.ts
// tier: L1
// verify: node --test test/orch/platform.test.ts

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

export interface Semver { major: number; minor: number; patch: number }
export type ConditionOp = "eq" | "ne";
export type NodeKind = "capability" | "code" | "human" | "join";
export interface Condition { field: string; op: ConditionOp; value: string }

export interface DeclarativeNode {
  id: string; kind: NodeKind; capability?: string; handler?: string;
  needs?: readonly string[]; when?: string;
  retries?: number; budgetMicros?: number; timeoutMs?: number;
}

export interface DeclarativeWorkflow {
  name: string; version: string; maxParallel?: number; nodes: readonly DeclarativeNode[];
}

export interface ExecutableNode {
  id: string; kind: NodeKind; index: number; capability?: string; handler?: string;
  needs: string[]; dependents: string[]; condition?: Condition;
  retries: number; budgetMicros: number; timeoutMs: number;
}

export interface ExecutableGraph {
  name: string; version: string; maxParallel: number;
  order: string[]; entry: string[]; terminals: string[]; nodes: ExecutableNode[];
}

export type DefinitionIssueCode =
  | "invalid-name" | "invalid-version" | "empty-workflow" | "invalid-id"
  | "duplicate-node" | "unknown-kind" | "missing-capability" | "missing-handler"
  | "unexpected-binding" | "join-needs-two" | "unknown-dependency" | "self-dependency"
  | "cycle" | "invalid-condition" | "invalid-retries" | "invalid-budget"
  | "invalid-timeout" | "invalid-max-parallel";

export interface DefinitionIssue { path: string; code: DefinitionIssueCode; detail: string }
export type NormalizeResult =
  | { ok: true; graph: ExecutableGraph } | { ok: false; issues: DefinitionIssue[] };

const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
const KINDS: readonly NodeKind[] = ["capability", "code", "human", "join"];
const CONDITION = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(==|!=)\s*(\S(?:.*\S)?)$/;

export function parseSemver(value: string): Semver | null {
  const match = SEMVER.exec(value.trim());
  if (!match) return null;
  return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
}

export function compareSemver(left: string, right: string): number {
  const a = parseSemver(left);
  const b = parseSemver(right);
  if (!a || !b) throw new AiError("invalid-request", `platform: invalid semver ${!a ? left : right}`);
  if (a.major !== b.major) return a.major - b.major;
  return a.minor !== b.minor ? a.minor - b.minor : a.patch - b.patch;
}

export function isValidRange(range: string): boolean {
  const parts = range.trim().split(/\s+/).filter(Boolean);
  return parts.length > 0 && parts.every((part) => {
    if (part === "*") return true;
    const match = /^(>=|<=|>|<|=|\^|~)?(.+)$/.exec(part);
    return match !== null && parseSemver(match[2]) !== null;
  });
}

/** 只支持比较符、^、~ 与 *;表达不了的需求应写进代码节点,而不是扩 DSL。 */
export function satisfiesRange(version: string, range: string): boolean {
  if (!isValidRange(range)) throw new AiError("invalid-request", `platform: invalid range ${range}`);
  if (parseSemver(version) === null) throw new AiError("invalid-request", `platform: invalid semver ${version}`);
  return range.trim().split(/\s+/).filter(Boolean).every((part) => {
    if (part === "*") return true;
    const match = /^(>=|<=|>|<|=|\^|~)?(.+)$/.exec(part)!;
    const op = match[1] ?? "=";
    const base = match[2];
    const cmp = compareSemver(version, base);
    if (op === ">=") return cmp >= 0;
    if (op === "<=") return cmp <= 0;
    if (op === ">") return cmp > 0;
    if (op === "<") return cmp < 0;
    if (op === "=") return cmp === 0;
    const v = parseSemver(version)!;
    const b = parseSemver(base)!;
    if (op === "^") {
      return b.major > 0
        ? v.major === b.major && cmp >= 0
        : v.major === 0 && v.minor === b.minor && cmp >= 0;
    }
    return v.major === b.major && v.minor === b.minor && cmp >= 0;
  });
}

function parseCondition(raw: string): Condition | null {
  const match = CONDITION.exec(raw.trim());
  if (!match) return null;
  return { field: match[1], op: match[2] === "==" ? "eq" : "ne", value: match[3] };
}

/**
 * 把声明式定义规范化为可执行图。校验只回答“能否执行”,不评价业务合理性;
 * 节点顺序按声明顺序稳定排序,保证同一份定义每次得到同一张图。
 */
export function normalizeWorkflow(workflow: DeclarativeWorkflow): NormalizeResult {
  const issues: DefinitionIssue[] = [];
  const report = (path: string, code: DefinitionIssueCode, detail: string): void => {
    issues.push({ path, code, detail });
  };
  if (workflow.name.trim() === "") report("workflow.name", "invalid-name", "name is empty");
  if (parseSemver(workflow.version) === null) report("workflow.version", "invalid-version", `bad ${workflow.version}`);
  const maxParallel = workflow.maxParallel ?? 4;
  if (!Number.isInteger(maxParallel) || maxParallel < 1) {
    report("workflow.maxParallel", "invalid-max-parallel", `bad maxParallel ${maxParallel}`);
  }
  const nodes = workflow.nodes ?? [];
  if (nodes.length === 0) report("workflow.nodes", "empty-workflow", "no nodes");

  const index = new Map<string, number>();
  const ids: string[] = [];
  nodes.forEach((node, position) => {
    const path = `nodes[${position}]`;
    const id = node.id.trim();
    if (id === "") report(`${path}.id`, "invalid-id", "id is empty");
    if (index.has(id)) report(`${path}.id`, "duplicate-node", `duplicate id ${id}`);
    else { index.set(id, position); ids.push(id); }
    if (!KINDS.includes(node.kind)) report(`${path}.kind`, "unknown-kind", `bad kind ${node.kind}`);
    if (node.kind === "capability" && !node.capability?.trim()) {
      report(`${path}.capability`, "missing-capability", "capability node needs capability");
    }
    if (node.kind === "code" && !node.handler?.trim()) {
      report(`${path}.handler`, "missing-handler", "code node needs handler");
    }
    if ((node.kind === "human" || node.kind === "join") && (node.capability || node.handler)) {
      report(path, "unexpected-binding", `${node.kind} node cannot bind capability or handler`);
    }
    const needs = (node.needs ?? []).map((need) => need.trim());
    if (new Set(needs).size !== needs.length) report(`${path}.needs`, "unknown-dependency", "duplicate dependency");
    if (node.kind === "join" && needs.length < 2) {
      report(`${path}.needs`, "join-needs-two", "join needs at least two dependencies");
    }
    if (node.when !== undefined && parseCondition(node.when) === null) {
      report(`${path}.when`, "invalid-condition", `bad condition ${node.when}`);
    }
    const retries = node.retries ?? 0;
    if (!Number.isInteger(retries) || retries < 0 || retries > 3) {
      report(`${path}.retries`, "invalid-retries", `bad retries ${retries}`);
    }
    const budget = node.budgetMicros ?? 0;
    if (!Number.isInteger(budget) || budget < 0) report(`${path}.budgetMicros`, "invalid-budget", `bad ${budget}`);
    const timeout = node.timeoutMs ?? 0;
    if (!Number.isFinite(timeout) || timeout < 0) report(`${path}.timeoutMs`, "invalid-timeout", `bad ${timeout}`);
  });

  nodes.forEach((node, position) => {
    for (const need of (node.needs ?? []).map((item) => item.trim())) {
      if (need === node.id.trim()) {
        report(`nodes[${position}].needs`, "self-dependency", `node ${need} needs itself`);
      } else if (need !== "" && !index.has(need)) {
        report(`nodes[${position}].needs`, "unknown-dependency", `unknown node ${need}`);
      }
    }
  });

  const dependents = new Map<string, string[]>();
  const indegree = new Map<string, number>();
  for (const id of ids) { dependents.set(id, []); indegree.set(id, 0); }
  for (const node of nodes) {
    const id = node.id.trim();
    for (const need of (node.needs ?? []).map((item) => item.trim())) {
      if (!index.has(need) || need === id || !dependents.has(id)) continue;
      dependents.get(need)!.push(id);
      indegree.set(id, (indegree.get(id) ?? 0) + 1);
    }
  }
  const ready = ids.filter((id) => (indegree.get(id) ?? 0) === 0);
  const order: string[] = [];
  while (ready.length > 0) {
    ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0));
    const id = ready.shift()!;
    order.push(id);
    for (const next of dependents.get(id) ?? []) {
      const left = (indegree.get(next) ?? 0) - 1;
      indegree.set(next, left);
      if (left === 0) ready.push(next);
    }
  }
  if (order.length !== ids.length) {
    const cycle = ids.filter((id) => !order.includes(id));
    report("workflow.nodes", "cycle", `cycle among ${cycle.join(",")}`);
  }
  if (issues.length > 0) return { ok: false, issues };

  const executable: ExecutableNode[] = order.map((id) => {
    const source = nodes[index.get(id)!];
    return {
      id, kind: source.kind, index: index.get(id)!,
      capability: source.capability?.trim(), handler: source.handler?.trim(),
      needs: (source.needs ?? []).map((need) => need.trim()),
      dependents: [...(dependents.get(id) ?? [])],
      condition: source.when === undefined ? undefined : parseCondition(source.when)!,
      retries: source.retries ?? 0, budgetMicros: source.budgetMicros ?? 0, timeoutMs: source.timeoutMs ?? 0,
    };
  });

  return {
    ok: true,
    graph: {
      name: workflow.name.trim(), version: workflow.version, maxParallel, order,
      entry: order.filter((id) => (nodes[index.get(id)!].needs ?? []).length === 0),
      terminals: order.filter((id) => (dependents.get(id) ?? []).length === 0),
      nodes: executable,
    },
  };
}

/** 32 位 FNV-1a,仅用于给定义生成稳定的灰度键,不作为安全摘要。 */
export function graphDigest(graph: ExecutableGraph): string {
  const canonical = JSON.stringify({
    name: graph.name,
    version: graph.version,
    maxParallel: graph.maxParallel,
    order: graph.order,
    nodes: graph.nodes.map((node) => [
      node.id, node.kind, node.capability ?? "", node.handler ?? "", node.needs.join(","),
      node.condition ? `${node.condition.field}${node.condition.op}${node.condition.value}` : "",
      node.retries, node.budgetMicros, node.timeoutMs,
    ]),
  });
  let hash = 0x811c9dc5;
  for (let i = 0; i < canonical.length; i += 1) {
    hash ^= canonical.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

normalizeWorkflow 的错误码是稳定的,测试可以直接断言 duplicate-node 或 cycle,而不是匹配错误文案。graphDigest 只覆盖影响执行的字段:节点顺序、依赖、条件、重试、预算和超时。改描述或改注释不会改变摘要,改执行语义一定会。

示例样本上的运行结果

把链路 A 的四节点定义输入模块,示例样本上的运行结果如下。这里的数字来自 ai-lab 的确定性测试,不是生产统计。

指标结果
节点数4(3 个能力节点 + 1 个代码节点)
拓扑序rerank → guard → summarize → reply
入口 / 出口rerank / reply
定义摘要bf6d5336
声明预算合计4,240 微元
非法定义问题数13 条,覆盖 13 种错误码

13 条问题不是 13 次失败,而是一次校验返回的完整清单。它同时报出重复节点、缺失能力、未知依赖、自依赖、非法条件、越界重试和负预算。对提交者来说,一次看到全部问题比修一个跑一次更省时间;对平台来说,稳定的错误码让门禁可以按类别分级,例如环和未知依赖是阻断,描述为空只是警告。

声明预算合计 4,240 微元,与三能力独立调用成本一致。规范化后的图不改变成本,只改变「谁来执行、能否校验、能否灰度」。真正的编排成本仍然是 3,786 微元,延迟从 1,200ms 变成 1,269ms,其中每次交接固定增加 35ms;这些数字属于执行阶段,不属于定义阶段。

测试与灰度

声明式的测试重点不是端到端,而是「定义到图」的确定性。三类测试足够:黄金图测试断言拓扑序与入口出口;摘要测试断言同一输入得到同一 graphDigest;错误码测试覆盖每一类非法定义。代码节点则单独做契约测试,输入输出必须落在声明的 schema 内。

灰度以定义摘要为单位,而不是以文件为单位。发布时同时登记版本、摘要和变更说明;流量按租户 ID 哈希分桶,依次经过 1%、10%、50%、100%。每一档比较节点级 SLO:search.rerank 的 p95 是 160ms,content.summarize 是 620ms,chat.reply 是 420ms。任何一档出现预算超支或失败率上升,回滚动作是切回上一个定义版本,而不是改代码再部署。

灰度期间不要原地修改正在运行的定义。定义不可变、版本可切换,才能保证同一摘要对应的执行语义不变。摘要变了就是新版本,必须重新走灰度;这也是为什么摘要只覆盖执行字段,避免注释改动触发无意义的重新放量。

小结

声明式与代码编排的分界不在语言,而在四个判据:谁维护、如何测试、如何灰度、能力上限。拓扑稳定、跨团队提交、需要静态校验的场景适合声明式;动态扇出、质量循环、跨请求调度适合代码。

「回声」采用混合方案:结构声明化,节点代码化,边界固定化。链路 A 的四节点定义在示例样本上得到固定拓扑序和摘要 bf6d5336,声明预算合计 4,240 微元;非法定义一次返回 13 条问题。执行阶段仍是 3,786 微元与 1,269ms,定义层不改变这些数字,只决定它们能否被治理。

阅读导航:上一节:10.3 编排事故的止血与复盘 · 下一节:11.2 引擎扩展点与插件边界 。

常见错误与边界

  • 把声明式当成「不写代码」。复杂度只是转移到了平台引擎,引擎能力不足时配置会更难维护。
  • 给 DSL 不断增加控制流关键字。每加一个,静态校验就弱一分,最后得到一门难用的编程语言。
  • 用文件路径当灰度单位。应使用定义摘要或版本号,确保同一份配置的语义不可变。
  • 只校验第一个错误。批量返回问题才能减少提交者的往返次数。
  • 让代码节点修改图或预算。节点一旦能改全局状态,平台的拓扑排序和成本控制都失效。
  • 把摘要当安全哈希。32 位 FNV-1a 只适合分桶,不能用于完整性或防篡改。
  • 忽略代码节点的契约。没有输入输出 schema,混合方案会退化成「配置里藏脚本」。
  • 在运行中热改定义。灰度与回滚都依赖版本不可变。
  • 用端到端测试代替黄金图测试。拓扑变化时,端到端测试定位不到是哪条边错了。
  • 把声明预算合计当成实际成本。预算是上限,实际成本还取决于重试、交接与失败传播。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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