两段检索,串行还是并行
B 链原本只有一段知识库检索。产品提出:私信里经常问到社区里的帖子,能不能同时查社区内容?于是链路多了一个 search.rerank 节点。最直接的写法是接在 support.assist 后面,串行执行;另一种写法是把两段检索放进同一个阶段并行,汇合后再交给 chat.reply 答复。
两种写法调用的节点完全相同,成本也完全相同,差别只在延迟与交接次数。这正是结构选择最容易被低估的地方:它不改模型、不改提示、不改价目表,只改节点的排列方式,却能改变用户等待时间与失败语义。
本节用 shape-variants.ts 对比两组结构:B 链的两段检索串行 vs 并行,A 链的人工复核每次都跑 vs 只在灰度区间跑。文中数字都是示例样本上的运行结果,不是生产实测。
三种结构的判定条件
串行、并行与条件分支不是风格偏好,它们各自有适用条件。
| 结构 | 适用条件 | 成本影响 | 主要风险 |
|---|---|---|---|
| 串行 | 下游依赖上游输出,或共享同一份预算 | 成本按节点相加,交接逐次累加 | 延迟线性增长,长链路容易撞超时 |
| 并行 | 分支输入相互独立,汇合成本低于省下的延迟 | 成本通常不变,延迟取最长分支 | 上下文复制、汇合冲突、部分结果语义 |
| 条件分支 | 判定条件可在调用前算出来,且判定成本远低于被跳过节点 | 成本与延迟按执行率折算 | 误判导致漏放或误放,两条路径都要测 |
判定顺序建议固定为三步:先问「下游是否真的需要上游输出」,有依赖就只能串行;再问「分支条件能否在调用前判定」,不能判定就不该做条件分支;最后问「并行省下的时间是否大于汇合与复制的成本」,不划算就退回串行。把这个顺序写进设计评审,可以避免「为了并行而并行」。
并行的三个隐藏成本
第一,上下文复制。并行分支各自携带上下文,如果每个分支都带完整的 820 token 交接内容,fan-out 会把一份上下文变成两份账单。示例里两个检索节点共用同一份交接上下文,成本才恰好不变;真实系统里如果两个分支需要不同的检索指令,成本会上升。
第二,汇合成本。示例把「汇合」当成零成本的阶段边界,实际往往需要一个合并节点:去重、排序、裁剪,甚至再调一次模型做融合。这部分成本没有进入模型,所以并行方案的 969ms 是下限,不是承诺。
第三,失败语义。示例按「两个分支都成功,阶段才算成功」计算,阶段成功率是两个分支成功率的乘积。如果允许「一个分支失败、用另一个分支的结果继续」,成功率会更高,但回答质量会下降,而且「部分可用」需要单独的验收标准。并行把「一个失败」变成「要不要等另一个」的决策,这个决策不能留给超时机制。
还有两个工程约束不在成本模型里:并行会把瞬时 QPS 翻倍,可能触发提供方的限流;两个分支的返回如果冲突,合并规则必须先定义,否则会把矛盾内容一起塞给答复节点。
条件分支的期望值
条件分支把「是否执行」变成一个概率。A 链的人工复核只在灰度区间触发,示例执行率是 18%。它不花 token,但单次 90 秒,所以对延迟的影响远大于对成本的影响。
| 指标 | 每次都人工复核 | 只在 18% 灰度区间复核 |
|---|---|---|
| 期望成本 | 2,313 | 2,313 |
| 全路径延迟 | 90,945 | 90,945 |
| 期望延迟 | 90,827 | 17,095 |
| 整链成功率 | 99.3703% | 99.7798% |
条件分支省下 73,732ms 期望延迟(-81.18%),成本一分没省,成功率反而高了 0.41 个百分点。成功率变高不是因为「少做人工更安全」,而是因为这个模型把人工节点自身的 0.5% 失败也算作链路失败;跳过人工自然跳过了这部分失败。人工真正拦住的风险内容不在这个模型里,不能拿这 0.41 个百分点去论证「人工可以少做」。
这就是条件分支的取舍:它优化的是期望延迟,不是质量。判定条件必须用另一套指标验收,例如灰度区间的漏放率与误放率,而不是链路成功率。
实现
shape-variants.ts 只做两件事:复用 workflow-shape.ts 的节点定义拼出结构变体,再用 compareRuns 给出成本、延迟与成功率的差值。它不重新实现计价,也不复制链路定义,避免两套口径漂移。
// file: src/orch/shape-variants.ts
// tier: L1
// verify: node --test test/orch/workflow-shape.test.ts
import { AiError } from "../core/errors.ts";
import {
CHAIN_A,
ECHO_PRICE,
evaluateChain,
type ChainRun,
type ChainSpec,
type FlowNode,
} from "./workflow-shape.ts";
const ASSIST: FlowNode = {
id: "retrieve",
capability: "support.assist",
kind: "ai",
inputTokens: 1_200,
outputTokens: 260,
latencyMs: 300,
failureRate: 0.025,
retries: 1,
};
const RERANK: FlowNode = {
id: "rerank",
capability: "search.rerank",
kind: "ai",
inputTokens: 700,
outputTokens: 80,
latencyMs: 160,
failureRate: 0.01,
retries: 1,
};
const INTENT: FlowNode = {
id: "intent",
capability: "chat.reply",
kind: "ai",
inputTokens: 700,
outputTokens: 40,
latencyMs: 180,
failureRate: 0.02,
retries: 1,
};
const ANSWER: FlowNode = {
id: "answer",
capability: "chat.reply",
kind: "ai",
inputTokens: 1_100,
outputTokens: 240,
latencyMs: 420,
failureRate: 0.02,
retries: 1,
};
/** 两段检索串行:知识库检索完成后再查社区内容。 */
export const B_SERIAL: ChainSpec = {
id: "B",
name: "私信客服链(两段检索串行)",
sharedContextTokens: 3_200,
handoffTokens: 820,
handoffLatencyMs: 35,
stages: [
{ id: "intent", mode: "serial", executionRate: 1, nodes: [INTENT] },
{ id: "assist", mode: "serial", executionRate: 1, nodes: [{ ...ASSIST, id: "assist" }] },
{ id: "rerank", mode: "serial", executionRate: 1, nodes: [RERANK] },
{ id: "answer", mode: "serial", executionRate: 1, nodes: [ANSWER] },
],
};
/** 两段检索并行:共享一次交接,汇合后一起进入答复。 */
export const B_PARALLEL: ChainSpec = {
id: "B",
name: "私信客服链(两段检索并行)",
sharedContextTokens: 3_200,
handoffTokens: 820,
handoffLatencyMs: 35,
stages: [
{ id: "intent", mode: "serial", executionRate: 1, nodes: [INTENT] },
{
id: "retrieve",
mode: "parallel",
executionRate: 1,
nodes: [{ ...ASSIST, id: "assist" }, { ...RERANK, id: "rerank" }],
},
{ id: "answer", mode: "serial", executionRate: 1, nodes: [ANSWER] },
],
};
/** 人工复核每次都跑:把条件分支关掉,作为对照。 */
export const A_ALWAYS_REVIEW: ChainSpec = {
...CHAIN_A,
name: "内容发布安全链(每次人工复核)",
stages: CHAIN_A.stages.map((stage) =>
stage.id === "human-review" ? { ...stage, executionRate: 1 } : stage
),
};
export interface ShapeDelta {
costMicros: number;
costPct: number;
latencyMs: number;
latencyPct: number;
/** 成功率差值,单位是百分点。 */
successPoints: number;
}
function pct(delta: number, base: number): number {
if (base === 0) fail("基准为 0,无法计算百分比");
return Number(((delta / base) * 100).toFixed(2));
}
function fail(message: string): never {
throw new AiError("invalid-request", `shape-variants: ${message}`);
}
/** 正数代表候选方案更贵、更慢或成功率更高。 */
export function compareRuns(base: ChainRun, candidate: ChainRun): ShapeDelta {
return {
costMicros: candidate.expectedCostMicros - base.expectedCostMicros,
costPct: pct(candidate.expectedCostMicros - base.expectedCostMicros, base.expectedCostMicros),
latencyMs: candidate.expectedLatencyMs - base.expectedLatencyMs,
latencyPct: pct(candidate.expectedLatencyMs - base.expectedLatencyMs, base.expectedLatencyMs),
successPoints: Number(((candidate.successProbability - base.successProbability) * 100).toFixed(2)),
};
}
export interface PatternComparison {
serial: ChainRun;
parallel: ChainRun;
serialToParallel: ShapeDelta;
/** 并行相对串行省下的交接次数。 */
handoffsSaved: number;
}
/** 同一组节点、只改阶段结构:成本应当相同,差异只来自延迟与交接。 */
export function compareSerialParallel(): PatternComparison {
const serial = evaluateChain(B_SERIAL, ECHO_PRICE);
const parallel = evaluateChain(B_PARALLEL, ECHO_PRICE);
return {
serial,
parallel,
serialToParallel: compareRuns(serial, parallel),
handoffsSaved: serial.handoffCount - parallel.handoffCount,
};
}
export interface ConditionalComparison {
always: ChainRun;
conditional: ChainRun;
alwaysToConditional: ShapeDelta;
}
/** 同一组节点、只改人工阶段的执行率,观察延迟与成功率的交换。 */
export function compareConditionalReview(): ConditionalComparison {
const always = evaluateChain(A_ALWAYS_REVIEW, ECHO_PRICE);
const conditional = evaluateChain(CHAIN_A, ECHO_PRICE);
return {
always,
conditional,
alwaysToConditional: compareRuns(always, conditional),
};
}
compareSerialParallel 保证两个变体使用同一组节点、同一份价目表,只改 mode 与阶段划分。compareConditionalReview 只改人工阶段的 executionRate,其余字段不动。对照实验的合法性来自「只改一个结构因子」,不是来自数字看起来合理。
compareRuns 的百分比以基准为分母。基准成本为 0 时直接报错,而不是返回 Infinity 或 0;这类静默值最容易在报表里被当成「没有变化」。
运行结果
两段检索的串行与并行对比如下。这里的 B 变体比 1.1 的三节点 B 多了一个 search.rerank 节点,所以成本是 2,681,不是 2,328;对照只在变体内部进行。
| 指标 | 串行 | 并行 | 差值 |
|---|---|---|---|
| 期望成本 | 2,681 | 2,681 | 0(0%) |
| 期望延迟 | 1,164 | 969 | -195ms(-16.75%) |
| 整链成功率 | 99.8476% | 99.8476% | 0 |
| 交接次数 | 3 | 2 | -1 |
195ms 由两部分组成:并行把 search.rerank 的 160ms 藏在 support.assist 的 300ms 后面,省下 160ms;少一次阶段交接,再省 35ms。成本不变,因为两个分支的节点成本没有变化,汇合成本被模型忽略了。
人工复核的对照结果已经在上面的条件分支表里。两组实验的共同结论是:结构改动可以只影响延迟,也可以只影响成本,还可以只影响成功率。评审时必须说清楚这次改的是哪一个指标,否则「优化了」无法被复核。
选择顺序与阈值
把结构选择写成可执行的顺序:
- 下游是否读取上游输出?是则串行,不进入后续判断。
- 分支条件能否在调用前用确定性规则或轻量分类器算出?不能则不做条件分支。
- 并行分支是否独立,且省下的延迟大于「交接 35ms + 汇合节点成本 + 复制上下文成本」?不大于则串行。
- 条件分支的误判成本是否低于省下的期望延迟?用「误判率 × 单次事故成本 ÷ 请求量」折算成每请求微元,再与延迟收益比较。
- 并行或条件分支上线前,是否两条路径都有回归样本?没有则先补样本。
这五步里,第 4 步最容易被跳过。条件分支的收益是延迟,成本是误判;两者单位不同,必须折算到同一口径才能比较。折算不出事故成本时,保守选择是「先串行,保留条件分支的开关」。
小结
同样的节点,串行改并行,成本不变,延迟从 1,164ms 降到 969ms(-16.75%),代价是汇合与部分结果的语义复杂度。人工复核从「每次都跑」改成「18% 触发」,成本不变,期望延迟从 90,827ms 降到 17,095ms(-81.18%),代价是判定条件需要独立验收。
结构选择没有通用最优解,只有顺序:先看依赖,再看条件可判定性,最后看并行的净收益。三步都过,才值得改结构;任何一步答不上来,串行是更安全的默认值。
阅读导航:上一节:1.1 一个请求要几个能力:回声的三条真实链路 · 下一节:1.3 什么该编排,什么该固定流程 。
常见错误与边界
- 为了并行而并行。下游依赖上游输出时,并行只会制造错误的结果。
- 把并行当成免费。上下文复制、汇合节点与限流都会把省下的延迟吃回去。
- 并行分支失败后无限等待。必须定义「等多久、拿不拿部分结果」。
- 条件分支只看平均延迟。误判导致的漏放与误放不会出现在延迟报表里。
- 用链路成功率验收人工节点。人工拦住的风险内容不在成功率模型里。
- 条件判定用昂贵模型。判定成本超过被跳过节点时,条件分支没有意义。
- 只测一条路径。串行与并行、执行与跳过,四条路径都要有回归样本。
- 把示例的 195ms 当成通用收益。分支延迟差、交接次数与汇合成本一变,收益就要重算。
- 忽略瞬时 QPS。并行 fan-out 会让峰值翻倍,触发限流后延迟反而更高。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。