一条链路,两种写法
「回声」的链路 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,混合方案会退化成「配置里藏脚本」。
- 在运行中热改定义。灰度与回滚都依赖版本不可变。
- 用端到端测试代替黄金图测试。拓扑变化时,端到端测试定位不到是哪条边错了。
- 把声明预算合计当成实际成本。预算是上限,实际成本还取决于重试、交接与失败传播。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。