三条链路都塞进编排引擎之后
团队上线了编排引擎,第一版很成功:B 链的意图路由、检索与答复被拆成三个可配置节点,改提示不用发版。于是有人提议,把 A 和 C 也迁进来,「统一技术栈」。
两个月后问题显现。C 链只有固定三步:检索、说明、创建订单,但订单字段每加一个,就要改编排 DSL、跑一遍引擎回归、再发一次配置。一次字段变更从半天变成两天。A 链的人工复核在引擎里变成一个「等待外部回调」的节点,超时、重试、升级都要用引擎的状态机表达,比原来的工单系统更绕。
这不是引擎的问题,而是选型问题:三条链路的结构信号不同,不应该用同一种执行形态。本节给出一组可判定的结构信号与评分函数,回答「什么该编排,什么该固定流程」。
三个词,三种成本
先把三个容易混用的词分开。
| 形态 | 谁决定下一步 | 单请求开销 | 变更成本 | 适合什么 |
|---|---|---|---|---|
| 固定流程 | 代码写死 | 最低,只有节点调用 | 改代码、走发布 | 顺序固定、路径可枚举 |
| 声明式编排 | 配置里的条件与阶段 | 引擎解析、状态存储、追踪 | 改配置、跑回归 | 有运行时分支、变更频繁 |
| Agent 循环 | 模型在运行时决定 | 规划 token、工具调用、重试 | 改提示与工具,难回归 | 路径无法提前枚举 |
三者的成本不在同一个位置。固定流程的单请求开销最低,但每次变更都要动代码;声明式编排把变更成本降下来,代价是引擎开销与配置回归;Agent 循环把「下一步做什么」交给模型,灵活度最高,但成本、延迟与失败模式都变得难以预测。
选型不是选「更先进」的形态,而是选「总成本最低」的形态。总成本 = 单请求开销 × 请求量 + 变更成本 × 变更次数 + 故障成本 × 故障概率。请求量小、变更少时,固定流程几乎总是赢;请求量大、变更频繁时,声明式编排才可能回本。
判据不是「能力强就编排」
一个常见误区是「能力多、步骤多就该编排」。能力数量只说明链路的长度,不说明路径是否需要在运行时决定。真正的判据是六个结构信号:
| 信号 | 含义 | 为什么影响选型 |
|---|---|---|
| capabilities | 涉及的不同能力数 | 单能力不需要跨能力调度 |
| runtimeDecisions | 只能在请求到达后确定的分支数 | 决定是否需要运行时路由 |
| pathVariants | 预期会出现的不同路径数 | 路径越多,固定流程的测试组合越爆炸 |
| humanSteps | 人工节点数 | 人工需要超时、升级与默认动作 |
| sideEffects | 不可回滚的外部动作数 | 副作用需要幂等与检查点 |
| changeFrequencyPerMonth | 每月结构变更次数 | 高频变更需要配置化 |
评分函数把前五项折算成一个结构分:
score = runtimeDecisions × 2
+ max(0, pathVariants - 1)
+ humanSteps
+ sideEffects
+ (changeFrequencyPerMonth ≥ 2 ? 1 : 0)
+ (capabilities ≥ 3 ? 1 : 0)
判定分三段:score ≤ 3 用固定流程;3 < score ≤ 10 用声明式编排;score > 10 进入 Agent 循环,并且必须另加步数、花费与时限上限。两条硬规则优先于分数:单能力直接固定流程;路径完全可枚举(runtimeDecisions = 0 且 pathVariants ≤ 1)也直接固定流程。人工节点会把结论抬到至少声明式编排,因为人工的超时与升级不适合写死在代码里。
三条链路的判定
把三条链路的信号代入,结果如下。
| 链路 | capabilities | runtimeDecisions | pathVariants | humanSteps | sideEffects | 变更/月 | score | 判定 |
|---|---|---|---|---|---|---|---|---|
| A 发布安全链 | 2 | 2 | 4 | 1 | 1 | 2 | 10 | 声明式编排 |
| B 私信客服链 | 2 | 2 | 5 | 0 | 0 | 3 | 9 | 声明式编排 |
| C 下单咨询链 | 2 | 0 | 1 | 0 | 1 | 1 | 0 | 固定流程 |
A 的分数正好压在上限 10,说明它处在边界:再增加一个运行时分支或一条路径,就会进入 Agent 区间。B 的 9 分主要来自 5 条路径与每月 3 次变更,这两项都指向「配置化」。C 的 0 分不是因为它简单,而是因为它的执行顺序在请求到达前完全确定;外部副作用只影响控制项,不改变形态。
C 的固定流程不等于「裸调三个接口」。它仍然需要幂等键、补偿动作与检查点,只是这些控制写在代码里,而不是写在编排配置里。
为什么 C 加一个分支就会翻盘
C 现在是「检索 → 说明 → 下单」的直线。假设产品加一个会员价分支:会员走会员价说明,非会员走原价说明。此时 runtimeDecisions = 1、pathVariants = 2,其余信号不变,分数变成:
1 × 2 + (2 - 1) + 0 + 1 + 0 + 0 = 4
4 分超过固定流程上限 3,判定变成声明式编排。这不是评分函数的敏感过头,而是事实:一旦出现运行时分支,固定流程就要为两条路径各写一套测试、各留一个回滚点,变更成本开始上升,配置化的收益开始出现。
反过来,A 如果稳定到每月最多改一次、灰度路径从 4 条降到 2 条、运行时决策降到 1 个,分数会变成 2 + 1 + 1 + 1 = 5,仍然是声明式编排,但已经远离 Agent 区间。分数的作用不是贴标签,而是提示「离下一个形态有多远」。
实现
shape-choice.ts 只做三件事:校验信号、计算分数、给出判定与控制项。它不读取链路定义,也不调用模型;输入是结构信号,输出是可写进 ADR 的结论。
// file: src/orch/shape-choice.ts
// tier: L1
// verify: node --test test/orch/workflow-shape.test.ts
import { AiError } from "../core/errors.ts";
import type { ChainId } from "./workflow-shape.ts";
export type WorkflowDecision = "fixed-flow" | "declarative-orchestration" | "agent-loop";
/**
* 判据只吃结构信号,不吃“感觉”。运行时才知道的决策越多、路径越散,
* 越需要编排;路径完全可枚举时,固定流程更容易测试与回滚。
*/
export interface ShapeSignals {
capabilities: number;
/** 只能在请求到达后才能确定的分支数。 */
runtimeDecisions: number;
/** 预期会出现的不同执行路径数。 */
pathVariants: number;
humanSteps: number;
/** 不可回滚或回滚代价高的外部动作数。 */
sideEffects: number;
changeFrequencyPerMonth: number;
}
export interface ShapeDecision {
decision: WorkflowDecision;
score: number;
reasons: string[];
controls: string[];
}
const DECLARATIVE_MAX = 10;
const FIXED_MAX = 3;
function fail(message: string): never {
throw new AiError("invalid-request", `shape-choice: ${message}`);
}
function count(value: number, field: string): number {
if (!Number.isInteger(value) || value < 0) fail(`${field} 必须是 >= 0 的整数`);
return value;
}
export function validateSignals(signals: ShapeSignals): void {
count(signals.capabilities, "capabilities");
count(signals.runtimeDecisions, "runtimeDecisions");
count(signals.pathVariants, "pathVariants");
count(signals.humanSteps, "humanSteps");
count(signals.sideEffects, "sideEffects");
count(signals.changeFrequencyPerMonth, "changeFrequencyPerMonth");
if (signals.runtimeDecisions > 0 && signals.pathVariants < 2) {
fail("存在运行时决策,但 pathVariants 小于 2,信号自相矛盾");
}
}
/**
* 三段判定:
* 1) 单能力或路径完全可枚举 → 固定流程;
* 2) 有运行时分支但结构可声明 → 声明式编排;
* 3) 分支与路径超出可枚举范围 → Agent 循环,必须另加硬上限。
*/
export function decideShape(signals: ShapeSignals): ShapeDecision {
validateSignals(signals);
const reasons: string[] = [];
const controls: string[] = [];
if (signals.capabilities <= 1) {
reasons.push("只涉及一个能力,不需要跨能力调度");
return { decision: "fixed-flow", score: 0, reasons, controls };
}
if (signals.runtimeDecisions === 0 && signals.pathVariants <= 1) {
reasons.push("执行顺序在请求到达前完全确定,固定流程即可覆盖");
if (signals.sideEffects > 0) {
controls.push("外部动作使用幂等键,失败走补偿而不是盲目重放");
controls.push("动作前保留可回滚检查点");
}
return { decision: "fixed-flow", score: 0, reasons, controls };
}
const score = signals.runtimeDecisions * 2 +
Math.max(0, signals.pathVariants - 1) +
signals.humanSteps +
signals.sideEffects +
(signals.changeFrequencyPerMonth >= 2 ? 1 : 0) +
(signals.capabilities >= 3 ? 1 : 0);
reasons.push(`运行时决策 ${signals.runtimeDecisions} 个`);
reasons.push(`预期路径 ${signals.pathVariants} 条`);
if (signals.humanSteps > 0) reasons.push(`人工节点 ${signals.humanSteps} 个`);
if (signals.sideEffects > 0) reasons.push(`外部副作用 ${signals.sideEffects} 个`);
if (signals.changeFrequencyPerMonth >= 2) {
reasons.push(`每月变更 ${signals.changeFrequencyPerMonth} 次,需要配置化`);
}
let decision: WorkflowDecision;
if (score > DECLARATIVE_MAX) {
decision = "agent-loop";
reasons.push(`结构分 ${score} 超过声明式上限 ${DECLARATIVE_MAX}`);
controls.push("设置步数、花费与时限三重上限,并定义终止条件");
controls.push("每一轮循环都必须可观测,禁止无界自省");
} else if (score > FIXED_MAX) {
decision = "declarative-orchestration";
reasons.push(`结构分 ${score} 落在 (${FIXED_MAX}, ${DECLARATIVE_MAX}],路径可声明`);
controls.push("把阶段、条件与重试写成可版本化的声明");
controls.push("每次路径变更跑一组固定回归样本");
} else {
decision = "fixed-flow";
reasons.push(`结构分 ${score} 不超过 ${FIXED_MAX},固定流程更易测试`);
}
if (signals.humanSteps > 0) {
if (decision === "fixed-flow") decision = "declarative-orchestration";
controls.push("人工节点设超时、升级路径与默认动作");
}
if (signals.sideEffects > 0) {
controls.push("写动作前保留人工确认或可回滚检查点");
}
if (signals.changeFrequencyPerMonth >= 3) {
controls.push("高频变更走配置开关,不直接改编排代码");
}
return { decision, score, reasons, controls };
}
/** 三条链的结构信号。数字来自链路定义,不是事后拟合的评分。 */
export const CHAIN_SIGNALS: Record<ChainId, ShapeSignals> = {
A: {
capabilities: 2,
runtimeDecisions: 2,
pathVariants: 4,
humanSteps: 1,
sideEffects: 1,
changeFrequencyPerMonth: 2,
},
B: {
capabilities: 2,
runtimeDecisions: 2,
pathVariants: 5,
humanSteps: 0,
sideEffects: 0,
changeFrequencyPerMonth: 3,
},
C: {
capabilities: 2,
runtimeDecisions: 0,
pathVariants: 1,
humanSteps: 0,
sideEffects: 1,
changeFrequencyPerMonth: 1,
},
};
validateSignals 会拦一个自相矛盾的输入:有运行时决策,但路径数小于 2。这类输入通常来自「把分支写进了文档,却没算进路径数」,放过去会让评分偏低。
decideShape 的硬规则顺序很重要:先处理单能力与完全可枚举路径,再算分。否则一个「2 能力、0 决策、1 路径」的链路会因为 sideEffects 得 1 分,被误判成需要编排。控制项与判定分开返回,是因为同一个判定在不同链路下的控制强度不同:C 是固定流程,但它的外部动作控制比 B 多。
判定之后做什么
判定只回答形态,控制项回答「怎么保证这个形态不出事」。
| 形态 | 必须有的控制 |
|---|---|
| 固定流程 | 契约测试、幂等键、失败补偿、发布前回归 |
| 声明式编排 | 配置版本化、路径覆盖样本、引擎级追踪、灰度开关 |
| Agent 循环 | 步数/花费/时限上限、终止条件、每轮可观测、人工兜底 |
| 含人工节点 | 超时、升级路径、默认动作、人工决策留痕 |
| 含外部副作用 | 动作前检查点、幂等键、补偿或人工确认 |
这张表可以直接粘进设计评审模板。评审时先看形态判定,再看对应控制项是否齐全;缺哪一项,就在发布前补齐,而不是等事故后补。
决策记录模板
把判定写成一条 ADR,至少包含五列:
| 字段 | C 链示例 |
|---|---|
| 决策 | 采用固定流程,不接入编排引擎 |
| 依据信号 | 0 个运行时决策、1 条路径、1 个外部副作用、每月 1 次变更 |
| 放弃的方案 | 声明式编排:引擎开销与配置回归换不来路径灵活性 |
| 控制项 | 创建订单使用幂等键;失败走补偿;动作前写检查点 |
| 复核触发 | 新增运行时分支、路径数 ≥ 2、月变更 ≥ 2 时重新评估 |
最后一列最重要。形态不是一次性决定,信号变化就要重新判定;没有复核触发的 ADR,半年后就会变成「历史遗留」。
小结
三条链路里,A 与 B 有运行时分支、人工节点或高频变更,结构分是 10 与 9,适合声明式编排;C 顺序固定、路径唯一,结构分是 0,适合固定流程。C 一旦加入会员价分支,分数变成 4,立刻跨过固定流程上限,这说明形态判定对结构变化是敏感的,不是拍脑袋的标签。
判据的价值不在给出一个标准答案,而在把「为什么这样选」写成可复核的信号、分数与控制项。形态选错,通常不是因为技术能力不够,而是因为没有人把变更成本与故障成本算进同一张表。
阅读导航:上一节:1.2 串行、并行与条件分支 · 下一节:2.1 能力之间的数据契约 。
常见错误与边界
- 把所有链路迁进编排引擎。顺序固定的链路会为不存在的灵活性支付引擎开销。
- 用能力数量判断是否编排。能力多只说明链路长,不说明路径需要运行时决定。
- 忽略变更频率。每月改三次的链路,固定流程的发布成本会超过引擎开销。
- 把人工节点写进代码。超时、升级与默认动作会让代码迅速失控。
- 给外部副作用配自动重试。订单可能已创建,重试会制造重复副作用。
- 只看单请求开销。固定流程最便宜,但变更成本可能吃掉全部节省。
- 判定后不写复核触发。信号变了,形态却没人重新评估。
- 把分数当绝对真理。它只是把结构信号折算成一个可讨论的起点。
- 认为 Agent 循环更先进。路径可枚举时,Agent 只会增加成本与不确定性。
- 用示例分数替代真实信号。路径数与变更频率必须来自自己的链路记录。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。