Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 7 章 —— 设计良好的 Operator

承诺: 读完本章后,你会知道如何判断哪些逻辑该放进 operator、如何声明它的 schema 和行为契约,以及如何避免最常见的设计错误——把每一段逻辑都做成节点。


学习目标

  1. 运用单一能力原则来判断一段逻辑是该做成 operator,还是应该留在编排层。
  2. 把 operator 归入三层复用金字塔: 基础设施 → 能力 → 领域
  3. 声明 输入/输出 schema,让编译器、工具链和下游节点能更安全地校验数据。
  4. 设置 幂等性副作用 契约,帮助框架做出更安全的重试与调度决策。
  5. 识别——并消除——那些本应是 transform 或 input binding 表达式的琐碎数据整形 operator。

前置条件

源示例

文件展示内容
Operator.java核心 @FunctionalInterface —— executeidempotencysideEffectType
OperatorContext.java引擎传给每个 operator 的只读 record
SchemaAware.java可选接口,用于显式声明输入/输出 schema
Idempotency.javaIDEMPOTENTNOT_IDEMPOTENTUNKNOWN
SideEffectType.javaREAD_ONLYWRITEEXTERNAL_CALLMIXED
OperatorMeta.java用于标注 layer、tags、version、owner 的注解
OperatorLayer.javaINFRASTRUCTURECAPABILITYDOMAIN
Operator Design Specification完整的规范文档(八个章节)

为什么这很重要

前几章教会了你如何构建 graph传递数据。但一个 BLOGE 系统的质量,最终取决于它的 operator 质量——它们是每个 graph 的基本构件。

如果 operator 的边界划错了,你会看到:

  • 膨胀的 graph:一半节点只是单行字段重命名,本来用 transform 就能搞定。
  • 巨石 operator:把三个互不相关的 I/O 调用捆在一起,既无法单独重试,也无法单独观测。
  • 不透明的契约:在读源码之前,谁也不知道一个 operator 的输出长什么样。

把边界划对了,你就能解锁:

  • 复用 —— 一个边界清晰的 operator 可以服务于数十个 graph。
  • 可观测性 —— 每个 operator 都有自己的延迟、错误率和重试指标。
  • 安全演进 —— 类型化 schema 让破坏性变更在编译时就能被发现,而不是凌晨两点。

心智模型

单一能力原则

一个 operator 应该封装一项可独立度量的业务能力。问自己五个问题:

Diagram: 07-designing-good-operators figure 1

数据转换的四个层次

并非每段逻辑都应该放进 operator。BLOGE 提供了四个层次,各有不同的成本:

层次工具成本何时使用
路由input { userId = fetchUser.output.id }传递或重命名字段
轻量 transforminput { name = concat(a.output.first, " ", a.output.last) }拼接、类型转换、空值填充
结构适配transform orderSummary { … }零(虚拟节点)多字段重组,且被多个下游节点复用
业务逻辑node … : Operator { … }完整成本(有调度、计时、可重试)领域规则、I/O、副作用

经验法则: 如果移除该逻辑只会破坏 数据格式兼容性——用 transform。如果移除它会改变 业务含义——它必须是 operator。

三层复用金字塔

Diagram: 07-designing-good-operators figure 2

每一层通过 @OperatorMeta(layer = …) 使用 OperatorLayer 枚举(INFRASTRUCTURECAPABILITYDOMAIN)来标注。用它在代码和文档中明确 operator 的复用范围。


第一个可运行示例

下面设计一个从外部服务获取用户的 operator。类型化 I/O、schema 和行为契约会明确它与框架之间的边界。

步骤 1 — 定义类型化的输入和输出 record

public record FetchUserInput(String userId) {
public FetchUserInput {
if (userId == null || userId.isBlank())
throw new IllegalArgumentException("userId must not be blank");
}
}

public record FetchUserOutput(String id, String name, String email, int vipLevel) {}

Record 为你提供了不可变性、紧凑构造函数中的校验,以及通过 SchemaIntrospector 实现的自动 schema 内省。

步骤 2 — 实现 operator

这 43 行实现保留完整,让 metadata、外部调用、幂等性、effect 分类和两端 schema 能够作为一份 Operator 合同一起审阅。

@OperatorMeta(
layer = OperatorLayer.INFRASTRUCTURE,
tags = {"user", "query"},
description = "Fetch a user by ID from the user service",
owner = "user-platform",
since = "1.0.0"
)
public class FetchUserOperator
implements Operator<FetchUserInput, FetchUserOutput>, SchemaAware {

private final UserServiceClient client;

public FetchUserOperator(UserServiceClient client) {
this.client = client;
}

@Override
public FetchUserOutput execute(FetchUserInput input, OperatorContext ctx)
throws Exception {
// 业务数据通过 `input` 传入,而不是通过 `ctx`。
return client.fetchUser(input.userId());
}

@Override
public Idempotency idempotency() {
return Idempotency.IDEMPOTENT; // 可安全重试
}

@Override
public SideEffectType sideEffectType() {
return SideEffectType.EXTERNAL_CALL; // 跨越服务边界
}

@Override
public SchemaDescriptor inputSchema() {
return SchemaIntrospector.introspect(FetchUserInput.class);
}

@Override
public SchemaDescriptor outputSchema() {
return SchemaIntrospector.introspect(FetchUserOutput.class);
}
}

注意四点:

  1. @FunctionalInterface —— 核心契约只有一个方法: O execute(I input, OperatorContext ctx)。不需要继承抽象基类,也不需要先学习生命周期钩子。

  2. 类型化泛型 —— Operator<FetchUserInput, FetchUserOutput> 为编译器和引擎提供了一个具体的类型对。即使你没有实现 SchemaAware,框架也会自动内省泛型参数来派生 StructuredSchema

  3. 行为默认值 —— idempotency() 返回 IDEMPOTENT,因此框架可以认为重试是安全的。sideEffectType() 返回 EXTERNAL_CALL,表明该 operator 跨越了服务边界,应该在 graph 中配合显式的韧性设置。

  4. OperatorContext 是只读的元数据 —— 它携带 nodeIdgraphNameretryAttemptexecutionIdtimeSource。所有 业务 数据都必须通过 input 传入。


用四刀重构一个坏 Operator

识别好 Operator 最快的方法,是修一个坏的。先看 PlaceOrderOperator:它校验 字段、计算折扣、调用支付、写入订单、发送邮件,还负责翻译所有异常。这个 名字描述的是一段工作流,而不是一项能力。

图:Operator 四步重构

第 1、2 刀 —— 让纯决策回到普通代码

把校验与计价提取成输入输出显式的函数或领域对象。它们不需要 OperatorContext、retry,也不需要 node。此时可以直接用表格驱动的单元 测试覆盖折扣边界,不必启动 graph。

第 3、4 刀 —— 暴露 effect,再把 adapter 做薄

引入 PaymentPortOrderRepository 之类的窄接口。最终 Operator 只组装 类型化输入、调用一项业务能力,并返回可检查结果:

负责什么测试接缝
纯领域逻辑校验与计价决策输入 → 输出
effect port支付或持久化协议fake/受控 port 加调用证据
Operator adaptergraph 输入输出与能力调用GraphTestRunner node 结果
graph顺序、分支、retry、timeoutScenario 与状态断言

完成标准不是“类变小了”,而是失败边界变清楚:计价错误由纯测试发现;支付 协议错误由 port contract 发现;依赖错误由 graph Scenario 发现。 SchemaAware、元数据和 context API 用来描述这项能力,不能挽救一个仍然 混合多种责任的能力。


拆开来看

Operator 接口

来自 Operator.java

@FunctionalInterface
public interface Operator<I, O> {
O execute(I input, OperatorContext ctx) throws Exception;

default Idempotency idempotency() { return Idempotency.UNKNOWN; }
default SideEffectType sideEffectType() { return SideEffectType.MIXED; }
}
成员用途
execute(I, OperatorContext)你必须实现的唯一方法——接收组装好的输入并返回输出
idempotency()告知引擎重试是否安全(IDEMPOTENT)、是否禁止(NOT_IDEMPOTENT)、还是未指定(UNKNOWN
sideEffectType()告知框架该 operator 有何种副作用,以便调度和韧性决策保持显式

OperatorContext —— 引擎告诉你的信息

来自 OperatorContext.java

public record OperatorContext(
String nodeId,
String graphName,
GraphContext graphContext, // 请求作用域;每个节点的写入相互隔离
int retryAttempt, // 0 = 首次执行
String executionId,
TimeSource timeSource // 永不为 null;默认 SystemTimeSource.INSTANCE
) {}

graphContext 用于请求级元数据(trace ID、tenant ID)。它不是存放业务数据的地方——业务数据必须通过类型化的 I 输入传递。

timeSource —— operator 中的可测试时间

timeSource 字段让每个 operator 都可以使用引擎的时钟,而不需要硬编码 System.currentTimeMillis()Instant.now()

为什么重要: 如果 operator 直接读取墙钟,测试就无法控制它看到的时间。依赖真实时间的重试延迟断言既慢又脆弱。有了 timeSource,测试夹具注入 ManualTimeSource,operator 就能免费获得确定性时间。

经验法则: 只要 operator 需要当前时间,就调用 ctx.timeSource().now() 而不是 Instant.now()

@Override
public AuditOutput execute(AuditInput input, OperatorContext ctx) throws Exception {
Instant timestamp = ctx.timeSource().now(); // ✅ 可测试
// Instant timestamp = Instant.now(); // ❌ 不可测试
return auditService.record(input.action(), timestamp);
}

在生产环境中,引擎提供 SystemTimeSource.INSTANCE,它委托给 Instant.now()Thread.sleep()。在测试中你用 ManualTimeSource 替换:

var manualTime = new ManualTimeSource(Instant.parse("2025-01-15T10:00:00Z"));

var ctx = OperatorContext.builder()
.nodeId("audit")
.graphName("compliance")
.graphContext(new GraphContext())
.timeSource(manualTime) // 注入测试时钟
.build();

var result = new AuditOperator(auditService).execute(input, ctx);

// 断言 operator 使用了注入的时间,而不是墙钟
assertEquals(Instant.parse("2025-01-15T10:00:00Z"), result.recordedAt());

这与第 18 章TestGraphEngine 使用的 ManualTimeSource 相同。区别在于:这里你在 operator 层做单元测试, 而第 18 章在 graph 层做集成测试。

SchemaAware —— 显式 schema 声明

来自 SchemaAware.java

public interface SchemaAware {
default SchemaDescriptor inputSchema() { return OpaqueSchema.INSTANCE; }
default SchemaDescriptor outputSchema() { return OpaqueSchema.INSTANCE; }
}

实现 SchemaAware 是可选的。如果你不实现,框架会回退到对泛型类型参数 IO 的自动内省。但推荐显式声明,因为:

  • 当泛型被擦除为 Map<String, Object> 时,显式声明仍能保留 schema 信息。
  • 你可以给单个字段附加描述和约束注解。
  • 它能让下游 input binding 获得编译期路径校验。

在 Java operator 层,DefaultOperatorRegistry 会先检查 SchemaAware;如果你没有实现该接口,它会回退到通过 SchemaIntrospector 对 operator 泛型类型参数的内省。

@OperatorMeta —— 目录元数据

来自 OperatorMeta.java

public @interface OperatorMeta {
OperatorLayer layer() default OperatorLayer.DOMAIN;
String[] tags() default {};
String version() default "";
String description() default "";
String owner() default "";
String since() default "";

// LLM 工具元数据 —— 由 bloge-agent-ext(第 17 章)消费
String promptHint() default "";
String usageExample() default "";
String constraintsDescription() default "";
}

每个 operator 都应该 至少声明 layerdescriptionowner。即使运行时行为取决于 operator 契约本身,这些元数据也能让意图和归属对读者和工具链保持可见。

最后三个字段是 LLM 工具描述。当一个 operator 作为工具暴露给 agent 节点(第 17 章)时,bloge-agent-ext 读取它们来构造发给模型的 function spec:

  • promptHint —— LLM 在工具列表里看到的一行说明。
  • usageExample —— 给模型锚定行为的具体调用示例。
  • constraintsDescription —— 前置条件、副作用、速率限制。

它们也会进入 operator-metadata.json(第 9 章),Studio 的 operator 面板会展示同样的提示。


常见陷阱

❌ 为纯数据整形创建 operator

// 不要这样做——这个 operator 的工作不需要独立调度,可以由 transform 完成
public class FormatOrderSummaryOperator
implements Operator<Map<String, Object>, Map<String, Object>> {

@Override
public Map<String, Object> execute(Map<String, Object> input,
OperatorContext ctx) {
return Map.of(
"customerName", input.get("name"),
"total", input.get("price")
);
}
}

这个 operator "勉强"可以独立测试,但它没有有意义的指标、没有副作用、没有领域知识、也没有复用价值。它在单一能力原则的五项检查中挂了四项。

改用 transform:

transform orderSummary {
customerName = fetchUser.output.name
total = calcPrice.output.total
}

Transform 不增加 Operator 调度成本——不会触发 operator 调用、没有超时追踪、没有重试开销——但表达式求值本身仍有计算成本,而且整形逻辑直接可见于 graph 本身。

如何在现有 graph 中发现这个陷阱:

  • Operator 的 execute 方法里没有 if、没有 I/O、没有外部调用。
  • Operator 类没有构造函数依赖(没有 service client、没有 repository)。
  • 把 operator 替换成 return input; 的透传,也不会改变业务结果。

如果以上任何一条成立,删掉这个 operator,改用 transform 或 input binding 表达式

❌ 把决策表编码为普通算子

有时候,一个 operator 看起来像业务逻辑,但实现其实只是一张“条件映射到输出值”的表格——没有 I/O、没有重试、没有状态。这不是一项可独立度量的能力,而是一张藏在 Java 代码里的策略表。

以下情况请选择 decision_table

  • 逻辑是输入的纯函数(无副作用,无外部调用)。
  • 条件是可枚举的——你可以列出所有情况,编译器或 lint 可以在列表不完备时发出警告。
  • 审计线索很重要——你希望运行时有结构化错误码,并受 decision-table/missing-otherwise lint 规则保护。

以下情况继续使用 Operator

  • 节点需要执行 I/O(HTTP 调用、数据库查询、消息发布)或存在副作用。
  • 你需要框架管理的重试、超时或降级策略。
  • 逻辑涉及可变状态或无法用规则谓词表达的协调逻辑。

完整语法参考和所有错误码,请见附录 F —— 决策表


引导式重写

下面这个 graph 有两个设计问题——一个 operator 太琐碎,另一个太臃肿:

graph invoiceProcess {

node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}

// 问题 1:琐碎的数据整形——应该用 transform
node formatAddress : FormatAddressOperator {
input {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
}
}

// 问题 2:巨石——把支付和通知捆在一个 operator 里
node processAndNotify : ProcessAndNotifyOperator {
depends_on = [fetchOrder, formatAddress]
input {
order = fetchOrder.output
address = formatAddress.output
}
timeout = 10s
}
}

重写后:

graph invoiceProcess {

node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}

// 修复 1:用 transform 替换琐碎的 operator
transform formattedAddress {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
full = concat(fetchOrder.output.address.street, ", ",
fetchOrder.output.address.city, " ",
fetchOrder.output.address.zip)
}

// 修复 2:把巨石拆成两个独立的 operator
node processPayment : ProcessPaymentOperator {
depends_on = [fetchOrder]
input {
order = fetchOrder.output
address = formattedAddress.full
}
timeout = 5s
retry = { attempts: 2, backoff: 500ms, strategy: exponential }
}

node sendNotification : SendNotificationOperator {
depends_on = [fetchOrder]
input {
email = fetchOrder.output.customerEmail
orderId = fetchOrder.output.id
}
timeout = 3s
fallback = { sent: false, reason: "notification service unavailable" }
}
}

改进之处:

之前之后为什么重要
FormatAddressOperator(完整节点)transform formattedAddress零调度成本;纯数据整形不需要 timeout/retry
ProcessAndNotifyOperator(一个臃肿节点)processPayment + sendNotification独立的 SLA、独立的重试策略,而且它们可以并行执行

脑力检查

  1. 说出单一能力原则中五项检查中的三项。 (可独立测试、可独立观测、可独立替换、可独立复用、有清晰的领域语义。)

  2. 一个 operator 的 execute 方法通过 ctx.graphContext().get("items") 获取业务数据。这有什么问题? (业务数据必须通过类型化输入 I 传入,而不是通过 graph context。Graph context 用于请求级元数据,如 trace ID。)

  3. 你有一个只做字符串拼接的 operator。它该存在吗? (不该——字符串拼接是轻量 transform。在 input binding 或 transform 中使用 concat(a.output.first, " ", a.output.last) 即可。)

  4. SideEffectType.EXTERNAL_CALL 告诉框架什么? (该 operator 跨越了外部服务边界,因此读者和集成方应将其视为有副作用的调用,并在 graph 中配合显式的 timeout/retry 策略。)

  5. 如果一个 operator 没有实现 SchemaAware,框架如何推导它的 schema? (通过 SchemaIntrospector 对泛型类型参数 IO 进行内省。)


练习

  1. 审计一个现有 graph。 打开 order-process.bloge。 对每个节点应用五项检查。有没有节点应该改成 transform?写下你的推理过程。

  2. 从零设计一个新 operator。 假设你要实现一个 CreditCheckOperator,它调用外部信用评分 API。

    • 定义 CreditCheckInputCreditCheckOutput 为 Java record。
    • 实现 Operator<CreditCheckInput, CreditCheckOutput>
    • 实现 SchemaAware 并提供显式 schema。
    • 覆写 idempotency()IDEMPOTENT(只读查询)。
    • 覆写 sideEffectType()EXTERNAL_CALL
    • @OperatorMeta(layer = CAPABILITY, ...) 标注。
  3. Schema 校验检查。 写好 record 后,在单元测试中使用 SchemaIntrospector.introspect(CreditCheckOutput.class),验证返回的 StructuredSchema 包含你期望的字段。

  4. 重构一个不好的 operator。 写一个 FormatCurrencyOperator,接收 Number 返回 String。然后删掉它,用 transform 或绑定表达式替代。哪种方式更简洁?


实验验收卡

  • 预期与观察: Operator 拆成纯逻辑、port、adapter 和 graph policy 后,失败接缝独立。
  • 失败与恢复: 让 adapter 返回传输错误;只替换 adapter 或 fallback。
  • 证明边界: 只证明责任边界可测试,不证明外部系统可靠。
  • 练习合同: 坏 service;每次只切一层;交付责任图和失败测试;失败归到唯一 owner 即停止。

回顾

  • 一个 operator 应该封装一项可独立度量的业务能力——单一能力原则。
  • 数据转换有四个层次:路由(input binding)、轻量 transform(表达式)、结构适配(transform 块)和业务逻辑(operator 节点)。使用成本最低的那个。
  • 三层复用金字塔(基础设施 → 能力 → 领域)按作用范围对 operator 进行分类;用 @OperatorMeta(layer = …) 标注它们。
  • 类型化 I/O record + SchemaAware 带来编译期校验、schema 感知的工具链和更安全的 schema 演进。
  • 行为契约 —— idempotency()sideEffectType() —— 为框架和你的团队在重试、调度和韧性决策上提供更明确的指引。
  • OperatorContext 用于元数据,而非业务数据。 所有领域值通过类型化输入传递。使用 ctx.timeSource().now() 替代 Instant.now() 以获得可测试的时间。
  • 最常见的错误是为纯数据整形创建 operator。如果没有 I/O、没有副作用、也没有领域规则——用 transform。

下一步

第 8 章——把个人 DSL 草稿变成团队资产中,你将把这些想法带回 .bloge 编写层——将 graph 级 schema、文档注释、transform、分支和 operator 契约组合成既易读又易演进的文件。


参考链接

Coding Agent: Open the versioned task guide.