《互联网产品的 AI 架构·多能力编排与任务闭环》11.2 引擎扩展点与插件边界

引擎扩展点不是把内部函数暴露出去,而是用四类稳定接口换取可治理的多样性。本节定义节点类型、策略、存储、观测四类扩展点与插件不能触碰的六条负边界,给出基于引擎 API 版本与 engineRange 的兼容判定、可运行注册表,并说明主次版本升级与扩展点弃用的处理顺序。

三个团队同时要插件

编排引擎上线半年后,「回声」的三个团队同时提出扩展需求。检索团队想要一种新的节点类型,直接调用向量库;基础设施团队想替换检查点存储;安全团队想插入一条策略,在高风险请求上强制人工审核;观测团队想导出自己的 trace 格式。

平台的第一版做法是「开放钩子」:把执行器的前置、后置、错误处理都暴露给插件。两周后,一个插件在错误处理钩子里修改了执行图,另一个插件覆盖了全局预算上限,还有一个观测插件读取了完整提示词。链路 A 的成本从 3,786 微元涨到 5,100 微元,没人能从日志里看出是谁改的。

问题不在插件开发者,而在平台没有定义「扩展点」与「边界」。扩展点是插件被允许接入的稳定接口;边界是插件即使拿得到内部对象也不能做的事。只给接口不给边界,等于把引擎的内部实现变成了公共 API。

四类扩展点

「回声」最终只开放四类扩展点。每一类都明确「插件提供什么」与「引擎保留什么」。

扩展点插件提供引擎保留典型插件
node-type一种节点类型的调用实现图结构、调度、重试与节点预算http-call、vector-search
policy重试、降级、路由的决策函数决策上限、授权与最终执行budget-guard、risk-gate
store检查点与产物的读写实现键空间、事务边界与保留策略redis-store、s3-store
observabilityspan、指标与日志的导出器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插件 APIengineRange结果
1.4.01.4.0^1.0.0exact
1.4.01.2.0>=1.0.0 <2.0.0additive
1.4.02.0.0>=2.0.0 <3.0.0incompatible-major
1.4.01.5.0>=1.4.0 <2.0.0engine-too-old
1.4.01.2.0>=1.5.0 <2.0.0engine-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-callnode-type1.2.0接受,additive
redis-storestore1.4.0接受,exact
trace-otlpobservability1.4.0接受,exact
budget-guardpolicy1.4.0接受,exact
legacy-nodenode-type2.0.0拒绝,incompatible-major
future-storestore1.5.0拒绝,engine-too-old
rogue-policypolicy1.4.0拒绝,forbidden-hook
http-call(重复)node-type1.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。
  • 只定义正接口,不定义负边界。插件总能找到绕过路径,最终由平台承担后果。
  • 让插件修改图或预算。静态校验、灰度摘要和成本上限会同时失效。
  • 用补丁号判定兼容。补丁不应改变接口语义,兼容判定看主次版本即可。
  • 主版本升级时静默加载旧插件。行为不确定比加载失败更危险。
  • 策略插件直接执行外部动作。策略只返回决策,执行与授权留在引擎。
  • 观测插件默认读取原始载荷。字段白名单与摘要是默认边界。
  • 注册失败只返回第一个错误。批量返回问题,插件作者才能一次修完。
  • 插件没有所有者。升级通知无人接收,弃用窗口无法执行。
  • 把注册表顺序当成加载顺序。排序只为确定性,加载应显式声明依赖。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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