三个团队同时要插件
编排引擎上线半年后,「回声」的三个团队同时提出扩展需求。检索团队想要一种新的节点类型,直接调用向量库;基础设施团队想替换检查点存储;安全团队想插入一条策略,在高风险请求上强制人工审核;观测团队想导出自己的 trace 格式。
平台的第一版做法是「开放钩子」:把执行器的前置、后置、错误处理都暴露给插件。两周后,一个插件在错误处理钩子里修改了执行图,另一个插件覆盖了全局预算上限,还有一个观测插件读取了完整提示词。链路 A 的成本从 3,786 微元涨到 5,100 微元,没人能从日志里看出是谁改的。
问题不在插件开发者,而在平台没有定义「扩展点」与「边界」。扩展点是插件被允许接入的稳定接口;边界是插件即使拿得到内部对象也不能做的事。只给接口不给边界,等于把引擎的内部实现变成了公共 API。
四类扩展点
「回声」最终只开放四类扩展点。每一类都明确「插件提供什么」与「引擎保留什么」。
| 扩展点 | 插件提供 | 引擎保留 | 典型插件 |
|---|---|---|---|
| node-type | 一种节点类型的调用实现 | 图结构、调度、重试与节点预算 | http-call、vector-search |
| policy | 重试、降级、路由的决策函数 | 决策上限、授权与最终执行 | budget-guard、risk-gate |
| store | 检查点与产物的读写实现 | 键空间、事务边界与保留策略 | redis-store、s3-store |
| observability | span、指标与日志的导出器 | trace 上下文、采样上限与字段白名单 | trace-otlp |
节点类型扩展最容易越界。插件只负责「给定输入,返回输出或错误」,不负责决定节点在图中排第几、失败后重试几次、最多花多少微元。这些由引擎根据声明式定义执行,插件看不到也不应看到全局图。
策略扩展的边界更微妙。策略可以返回「重试」「降级」「转人工」的决策,但引擎保留决策上限:最多重试 3 次、单节点预算不得为负、高风险动作必须经过授权。策略不能直接调用外部动作,只能返回决策让引擎执行。
存储扩展必须满足两个不变量:写入是幂等的,读取能按 revision 取到同一份数据。引擎保留键空间与命名空间,插件不能跨租户读写。观测扩展只能看到引擎允许的字段,原始提示词与用户内容默认不可读。
插件边界:不能做什么
边界用负列表表达,而不是写在文档里靠自觉。extensions.ts 为每个扩展点维护一份禁止钩子表。
graph.rewrite:任何扩展点都不能增删节点或改依赖。它会让静态校验、预算分配和灰度摘要同时失效。budget.override:不能覆盖全局或节点预算。预算超支必须由引擎按裁剪顺序处理,而不是由插件放行。auth.bypass:不能绕过权限与授权检查。策略可以收紧,不能放宽。store.global-write:存储插件只能在自己的命名空间内写,不能跨租户或覆盖引擎元数据。policy.override:存储与观测插件不能替换策略决策,职责分离。payload.read:观测插件默认不能读取原始载荷,只能读字段白名单与摘要。
这六条不是「建议」,而是注册时会被拒绝的硬约束。注册表在 registerExtension 里检查 hooks 与扩展点的禁止表,命中即返回 forbidden-hook,不会加载插件。
负边界比正接口更重要。正接口告诉插件「你能做什么」,负边界保证「即使你拿到对象也做不了」。后者决定平台能否在多个团队并行开发时仍然守住成本、权限和可观测性。
版本兼容判定
扩展点一旦公开,就会有插件依赖它。兼容规则必须能用一个函数判定,而不是靠评审记忆。
规则有三条。第一,扩展 API 的主版本必须与引擎一致;主版本变化意味着破坏性变更,旧插件不能静默加载。第二,引擎的次版本不得低于插件要求的次版本;次版本只做增量,新增钩子对旧插件不可见,但旧钩子保持行为。第三,插件还要满足自己声明的 engineRange,例如 >=1.0.0 <2.0.0。补丁号不参与兼容判定。
| 引擎 API | 插件 API | engineRange | 结果 |
|---|---|---|---|
| 1.4.0 | 1.4.0 | ^1.0.0 | exact |
| 1.4.0 | 1.2.0 | >=1.0.0 <2.0.0 | additive |
| 1.4.0 | 2.0.0 | >=2.0.0 <3.0.0 | incompatible-major |
| 1.4.0 | 1.5.0 | >=1.4.0 <2.0.0 | engine-too-old |
| 1.4.0 | 1.2.0 | >=1.5.0 <2.0.0 | engine-range-mismatch |
engineRange 与 API 版本是两道独立的门。API 版本回答「接口形状是否兼容」,engineRange 回答「这个插件是否只在某些引擎版本上验证过」。两道门都过,注册才成功。
注册表实现
extensions.ts 把上述规则写成可测试的函数:checkExtensionCompatibility 只判定兼容性,registerExtension 再叠加命名、所有者、禁止钩子和重复注册检查。注册失败时返回全部问题,注册成功时返回新的不可变注册表,并按扩展点与名称稳定排序。
// file: src/orch/extensions.ts
// tier: L2
// verify: node --test test/orch/platform.test.ts
import { AiError } from "../core/errors.ts";
import { compareSemver, isValidRange, parseSemver, satisfiesRange } from "./platform.ts";
export type ExtensionPoint = "node-type" | "policy" | "store" | "observability";
export const EXTENSION_POINTS: readonly ExtensionPoint[] = [
"node-type",
"policy",
"store",
"observability",
];
export interface ExtensionDescriptor {
name: string;
point: ExtensionPoint;
apiVersion: string;
engineRange: string;
owner: string;
hooks: readonly string[];
}
export type ExtensionIssueCode =
| "invalid-name"
| "unknown-point"
| "missing-owner"
| "invalid-api-version"
| "invalid-engine-version"
| "invalid-engine-range"
| "incompatible-major"
| "engine-too-old"
| "engine-range-mismatch"
| "forbidden-hook"
| "duplicate-extension";
export interface ExtensionIssue {
extension: string;
code: ExtensionIssueCode;
detail: string;
}
export interface ExtensionRegistry {
engineApiVersion: string;
points: readonly ExtensionPoint[];
extensions: readonly ExtensionDescriptor[];
}
export type RegisterResult =
| { ok: true; registry: ExtensionRegistry }
| { ok: false; issues: ExtensionIssue[] };
const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
/**
* 每个扩展点能挂什么钩子、不能碰什么是平台契约的一部分。
* 引擎保留图结构、预算上限、授权与原始载荷的所有权。
*/
const FORBIDDEN_HOOKS: Record<ExtensionPoint, readonly string[]> = {
"node-type": ["graph.rewrite", "budget.override", "auth.bypass"],
policy: ["graph.rewrite", "auth.bypass", "store.global-write"],
store: ["graph.rewrite", "budget.override", "auth.bypass", "policy.override"],
observability: ["graph.rewrite", "budget.override", "auth.bypass", "payload.read"],
};
export function forbiddenHooks(point: ExtensionPoint): readonly string[] {
return FORBIDDEN_HOOKS[point] ?? [];
}
function issue(
extension: string,
code: ExtensionIssueCode,
detail: string,
): ExtensionIssue {
return { extension, code, detail };
}
export function createRegistry(
engineApiVersion: string,
points: readonly ExtensionPoint[] = EXTENSION_POINTS,
): ExtensionRegistry {
if (parseSemver(engineApiVersion) === null) {
throw new AiError("invalid-request", `extensions: invalid engine API ${engineApiVersion}`);
}
if (points.length === 0) {
throw new AiError("invalid-request", "extensions: at least one extension point is required");
}
return { engineApiVersion, points: [...points], extensions: [] };
}
/**
* 兼容规则:主版本必须相等;引擎次版本不得低于扩展要求的次版本;
* 另外还要满足扩展自己声明的 engineRange。补丁号不参与兼容判定。
*/
export function checkExtensionCompatibility(
engineApiVersion: string,
descriptor: ExtensionDescriptor,
): { ok: true; mode: "exact" | "additive" } | { ok: false; issues: ExtensionIssue[] } {
const issues: ExtensionIssue[] = [];
const engine = parseSemver(engineApiVersion);
const api = parseSemver(descriptor.apiVersion);
if (engine === null) {
issues.push(issue(descriptor.name, "invalid-engine-version", `bad engine API ${engineApiVersion}`));
}
if (api === null) {
issues.push(issue(descriptor.name, "invalid-api-version", `bad extension API ${descriptor.apiVersion}`));
}
const rangeValid = isValidRange(descriptor.engineRange);
if (!rangeValid) {
issues.push(issue(descriptor.name, "invalid-engine-range", `bad range ${descriptor.engineRange}`));
}
if (engine !== null && api !== null) {
if (engine.major !== api.major) {
issues.push(
issue(descriptor.name, "incompatible-major", `engine ${engine.major}.x vs extension ${api.major}.x`),
);
} else if (engine.minor < api.minor) {
issues.push(
issue(descriptor.name, "engine-too-old", `engine ${engineApiVersion} < extension ${descriptor.apiVersion}`),
);
}
}
if (engine !== null && rangeValid && !satisfiesRange(engineApiVersion, descriptor.engineRange)) {
issues.push(
issue(descriptor.name, "engine-range-mismatch", `engine ${engineApiVersion} outside ${descriptor.engineRange}`),
);
}
if (issues.length > 0) return { ok: false, issues };
return { ok: true, mode: compareSemver(engineApiVersion, descriptor.apiVersion) === 0 ? "exact" : "additive" };
}
export function registerExtension(
registry: ExtensionRegistry,
descriptor: ExtensionDescriptor,
): RegisterResult {
const issues: ExtensionIssue[] = [];
if (!NAME.test(descriptor.name)) {
issues.push(issue(descriptor.name, "invalid-name", "name must be kebab-case"));
}
if (!registry.points.includes(descriptor.point)) {
issues.push(issue(descriptor.name, "unknown-point", `point ${descriptor.point} is not open`));
}
if (descriptor.owner.trim() === "") {
issues.push(issue(descriptor.name, "missing-owner", "owner is empty"));
}
const compatibility = checkExtensionCompatibility(registry.engineApiVersion, descriptor);
if (!compatibility.ok) issues.push(...compatibility.issues);
const blocked = [...new Set(descriptor.hooks)].filter((hook) =>
forbiddenHooks(descriptor.point).includes(hook),
);
for (const hook of blocked.sort()) {
issues.push(issue(descriptor.name, "forbidden-hook", `hook ${hook} crosses the plugin boundary`));
}
if (registry.extensions.some((item) => item.name === descriptor.name && item.point === descriptor.point)) {
issues.push(issue(descriptor.name, "duplicate-extension", `already registered at ${descriptor.point}`));
}
if (issues.length > 0) return { ok: false, issues };
const extensions = [...registry.extensions, descriptor].sort((a, b) =>
a.point < b.point ? -1 : a.point > b.point ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
return { ok: true, registry: { ...registry, extensions } };
}
export function listExtensions(
registry: ExtensionRegistry,
point?: ExtensionPoint,
): readonly string[] {
return registry.extensions
.filter((item) => point === undefined || item.point === point)
.map((item) => `${item.point}:${item.name}`)
.sort();
}
注册表本身不执行插件,只保存描述符。真正的加载发生在引擎启动阶段:先校验引擎 API 版本,再逐个注册;任何一个插件失败,只影响该插件,不影响引擎启动。这个设计让插件故障可以被隔离,而不是把整个编排平台拖垮。
示例样本上的运行结果
以引擎 API 1.4.0 为例,示例样本上注册四个合法插件、拒绝四个非法插件,结果如下。
| 插件 | 扩展点 | 插件 API | 结果 |
|---|---|---|---|
| http-call | node-type | 1.2.0 | 接受,additive |
| redis-store | store | 1.4.0 | 接受,exact |
| trace-otlp | observability | 1.4.0 | 接受,exact |
| budget-guard | policy | 1.4.0 | 接受,exact |
| legacy-node | node-type | 2.0.0 | 拒绝,incompatible-major |
| future-store | store | 1.5.0 | 拒绝,engine-too-old |
| rogue-policy | policy | 1.4.0 | 拒绝,forbidden-hook |
| http-call(重复) | node-type | 1.2.0 | 拒绝,duplicate-extension |
四个合法插件在注册表中的顺序是 node-type:http-call、observability:trace-otlp、policy:budget-guard、store:redis-store。顺序稳定,测试可以直接断言,不依赖加载时序。
禁止钩子的数量按扩展点不同:node-type 与 policy 各 3 条,store 与 observability 各 4 条。观测插件多一条 payload.read,存储插件多一条 policy.override。这些差异不是随意设定,而是由每个扩展点能看到的状态决定。
升级与弃用
引擎升级分两种。次版本升级只增加钩子或字段,旧插件继续以 additive 模式加载;新钩子对旧插件不可见,插件通过 engineApiVersion 检测能力后再决定是否使用。主版本升级不提供静默适配层,旧插件必须显式迁移到新扩展点或新 API。
扩展点的弃用也需要流程。第一步停止接受新注册,已有插件继续运行;第二步给出迁移窗口和替代扩展点;第三步在窗口结束后拒绝加载旧插件。注册表可以增加 closed 状态,但已注册插件不受影响,避免升级当天集体失效。
插件自身也要有版本和所有者。版本用于定位问题,所有者用于升级通知。没有所有者的插件不应被允许注册;这与 11.3 的能力目录使用同一套责任口径。
小结
扩展点的价值不是「什么都能改」,而是「在固定边界内提供多样性」。四类扩展点分别覆盖节点类型、策略、存储与观测;六类禁止钩子守住图结构、预算、授权、命名空间与原始载荷。
示例样本上,引擎 API 1.4.0 接受 4 个插件、拒绝 4 个插件,拒绝原因分别是主版本不兼容、引擎过旧、越界钩子与重复注册。兼容判定由 API 版本与 engineRange 两道门组成,补丁号不参与。边界清晰后,插件才能在不改变链路 A 成本结构的前提下独立演进。
阅读导航:上一节:11.1 声明式编排与代码编排 · 下一节:11.3 多团队共享能力目录 。
常见错误与边界
- 把内部函数直接暴露成钩子。一旦被插件依赖,内部实现就变成了公共 API。
- 只定义正接口,不定义负边界。插件总能找到绕过路径,最终由平台承担后果。
- 让插件修改图或预算。静态校验、灰度摘要和成本上限会同时失效。
- 用补丁号判定兼容。补丁不应改变接口语义,兼容判定看主次版本即可。
- 主版本升级时静默加载旧插件。行为不确定比加载失败更危险。
- 策略插件直接执行外部动作。策略只返回决策,执行与授权留在引擎。
- 观测插件默认读取原始载荷。字段白名单与摘要是默认边界。
- 注册失败只返回第一个错误。批量返回问题,插件作者才能一次修完。
- 插件没有所有者。升级通知无人接收,弃用窗口无法执行。
- 把注册表顺序当成加载顺序。排序只为确定性,加载应显式声明依赖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。