BLOGE
0.9.8-RC1在线版 · 事实校验 2026-09-15 · English
第 34 章 —— 实验
承诺: 读完本章后,你将完成基础、进阶、毕业三级 capstone。最终作品不只包含 graph,还包含技术测试、业务 Scenario/Policy、证据解读、架构图和“尚未证明事项”。
学习目标
- 在限定范围内从 starter 演进 graph,而不是从空文件猜语法。
- 分别交付 graph contract、business contract 和 release boundary 的证据。
- 对 effect、等待、恢复和 fallback 做单因素失败实验。
- 用一张架构图解释运行边界,用一张 receipt 解读图说明证据边界。
- 在完成声明中同时列出已证明、未证明和必须由人批准的事项。
前置条件
前面全部三十三章。这些实验假设你已经熟练掌握:
- Graph 结构、依赖和数据流(第 1 章–第 4 章)
- 分支和韧性(第 5 章–第 6 章)
- Operator 设计和 DSL 编写(第 7 章–第 8 章)
- 子图、迭代、等待和持久化(第 10 章–第 13 章)
- 编排模式 —— 多轮 session、状态机和嵌套生命周期所有权(第 14 章–第 16 章)
- 工具链与评审反馈(第 9 章)
- 测试、生产环境接线和规模化(第 18 章–第 22 章)
- 业务正确性、证据和发布声明(第 23 章–第 32 章)
- 迁移思维(第 33 章)
源示例
| 文件 | 展示内容 |
|---|---|
order-process.bloge | 基础 capstone 起点——扇出、汇合、分支、韧性 |
ticket-routing.bloge | 进阶 capstone 起点——情感分析与多路分支 |
loan-approval.bloge | 毕业 capstone 起点——贷款业务与验证资产 |
batch-order-parallel.bloge | 可选题——foreach 并行处理每一项 |
status-polling.bloge | 可选题——loop 加 until 和 loopIteration |
payment-wait.bloge | 可选题参考——await 事件关联、超时和分支 |
order-saga.bloge | 毕业题参考——saga 补偿模式 |
GraphTestRunner.java | 每个实验验证步骤使用的测试工具 |
MockOperator.java | 测试替身——returning、throwing、delaying、recording |
DslTestHelper.java | DSL 编译 + expectParseError / expectCompileError |
missing-timeout.bloge | 反模式参考——节点没有超时预算 |
over-broad-fallback.bloge | 反模式参考——降级范围过宽,掩盖了真实的故障模式 |
为什么这很重要
阅读 graph 的知识和亲手设计 graph 是两回事。前面每一章都只引入一个概念。而真实的工作流会同时组合所有概念:
- 一个订单管道需要扇出 加 韧性 加 分支。
- 一个工单路由器需要情感分析 加 三路分支 加 对不稳定 ML 服务的降级。
- 一个批处理器需要
foreach加 下游聚合 加 对每一项结果的测试断言。
这些实验用于检验前述概念能否在实践中组合。每个实验都从仓库中已有的一致性测试 fixture 开始,因此不需要凭空发明语法;需要完成的动作是阅读、扩展和测试。
心智模型
把三级 capstone 想象成一段逐层扩大的责任:
阅读 —— 打开一致性测试 fixture。追踪每一个依赖。识别哪些节点并行运行、哪些节点需要等待。
扩展 —— 添加实验要求的功能。将上游输出接入下游输入。有意识地选择韧性策略。
验证 —— 使用 GraphTestRunner 和 MockOperator 执行 graph,断言 NodeStatus 值,并检查执行顺序。
| 级别 | 预计投入 | 起始文件 | 工作合同 | 完成标准 |
|---|---|---|---|---|
| 基础 | 60—90 分钟 | order-process.bloge | 一份 DSL 副本 + 一组 graph tests;给出明确步骤 | 成功/拒绝两路状态和输出可复核 |
| 进阶 | 2—3 小时 | ticket-routing.bloge | DSL、mock、listener 测试;只给边界提示 | fallback 与观测事实可区分 |
| 毕业 | 1—2 天 | loan starter + 验证资产 | graph、tests、Scenario/Policy/Fixture、图与报告;只给验收合同 | 可运行、可解释、可复核,并写明未证明事项 |
每一级都先复制只读 starter 到自己的工作目录。不要修改 submodule/examples 或
submodule/bloge;它们分别是远端示例基线和 RC1 事实源。
第一个可运行示例
在开始实验之前,确保你能运行最简单的测试驱动 graph 循环。这是你的热身。
// 1. 编译 DSL
var helper = new DslTestHelper(registry);
Graph graph = helper.compile("""
graph warmup {
node greet : GreetOperator {
input { name = ctx.name }
}
}
""");
// 2. 接入 mock operator
var runner = new GraphTestRunner(Map.of(
"GreetOperator", MockOperator.returning(Map.of("message", "Hello"))
));
// 3. 执行
var ctx = new GraphContext(Map.of("name", "Lab Runner"));
GraphResult result = runner.execute(graph, ctx);
// 4. 断言
assert result.isSuccess();
runner.assertNodeExecuted("greet");
如果编译通过并且断言成功,你就准备好了。
拆开来看
每个实验都遵循相同的结构。以下是每个阶段练习的技能:
| 阶段 | 练习的技能 | 对应章节 |
|---|---|---|
| 阅读 fixture | 依赖追踪、并行识别 | 第 3 章 |
| 添加节点 | Operator 绑定、输入接线、depends_on | 第 2 章、第 4 章 |
| 添加韧性 | retry、timeout、fallback、compensate | 第 6 章 |
| 添加分支 | branch on、otherwise、跳过节点推理 | 第 5 章 |
| 添加迭代 | foreach、loop、carry、until | 第 11 章 |
| 添加挂起 | await、事件关联、on_timeout | 第 12 章 |
| 编写测试 | GraphTestRunner、MockOperator、DslTestHelper | 第 18 章 |
常见陷阱
❌ 给每个节点都添加韧性策略
一个常见的冲动是在 graph 中的每个节点上都加上 retry + timeout + fallback。别这样做。
看看
over-broad-fallback.bloge 中的反模式:在 authorizePayment 上使用全覆盖的 fallback,把校验错误和瞬时超时都藏在同一个"人工审核"结果后面。下游节点无法区分合理的降级和永久性 bug。
再看看 missing-timeout.bloge:一个调用外部服务却没有任何 timeout 的节点,可能让整个 graph 无限期停滞。
实验中的经验法则:
- 外部调用 → 必须加
timeout。 - 瞬时不稳定的调用 → 加
retry并且每次尝试带timeout。 - 只有在下游每个消费者都能真正接受部分数据时,才添加
fallback。 - 纯本地计算(转换、聚合)→ 不需要韧性策略。
引导式重写
在进入正式实验之前,先用一个小改动练习扩展-验证的循环。
打开 order-process.bloge。 当前 createOrder 仅依赖 calcPrice:
node createOrder : CreateOrderOperator {
depends_on = [calcPrice]
input {
user = fetchUser.output
price = calcPrice.output
}
}
改写任务: 业务现在要求 createOrder 也记录授信检查的结果以便审计——但只在订单被批准时。添加一个新的输入绑定:
node createOrder : CreateOrderOperator {
depends_on = [calcPrice, checkCredit]
input {
user = fetchUser.output
price = calcPrice.output
creditCheck = checkCredit.output
}
}
现在验证这个修改是安全的:
var runner = new GraphTestRunner(Map.of(
"FetchUserOperator", MockOperator.returning(Map.of("id", "u1")),
"FetchProductsOperator", MockOperator.returning(Map.of("items", List.of())),
"CalcPriceOperator", MockOperator.returning(Map.of("total", 99.0)),
"CreditCheckOperator", MockOperator.returning(Map.of("approved", true)),
"CreateOrderOperator", MockOperator.recording(),
"RejectOrderOperator", MockOperator.recording()
));
var ctx = new GraphContext(Map.of(
"userId", "u1",
"productIds", List.of("p1")
));
GraphResult result = runner.execute(graph, ctx);
assert result.isSuccess();
runner.assertNodeExecuted("createOrder");
runner.assertNodeSkipped("rejectOrder");
这个模式——扩展 graph,然后编写测试证明新接线正确——就是这些实验要培养的核心能力。
脑力检查
在开始实验之前,不看回顾地回答以下问题:
-
在
ticket-routing.bloge中, 哪些节点并行运行?(答案:fetchCustomer和fetchTicketHistory——两者之间没有依赖。) -
在
loan-approval.bloge中,fetchApplication完成后的最大并行度是多少? (答案:四——checkCredit、detectFraud、verifyIncome和checkBlacklist都只依赖fetchApplication。) -
在
batch-order-parallel.bloge中,summarize能否在所有 foreach 迭代完成之前启动? (答案:不能——summarize依赖processOrders,而processOrders只有在所有迭代完成后才算完成。) -
你会使用哪个
MockOperator工厂方法来模拟一个耗时 500 毫秒然后成功的节点? (答案:MockOperator.delaying(Duration.ofMillis(500), output)。) -
哪个
GraphTestRunner方法可以证明一个分支目标没有被选中? (答案:assertNodeSkipped("nodeId")。)
实验
基础 capstone —— 读懂并扩展订单 graph
预计投入: 60—90 分钟
起始文件:
order-process.bloge,复制后修改
允许范围: 一份 DSL 副本、一组 GraphTestRunner 测试;不得修改 operator 实现
提示层级: 明确步骤
需求:
- 添加一个
fetchInventory节点,使其与fetchUser和fetchProducts并行运行。输入:ctx.productIds。超时:2s。 - 将
fetchInventory.output作为新的inventory输入接入calcPrice。相应更新depends_on。 - 给
createOrder添加一个compensate块,调用ReleaseInventoryOperator——模式参考order-saga.bloge。 - 验证: 编写一个测试,断言
fetchInventory、fetchUser和fetchProducts全部完成;当授信通过时createOrder执行;rejectOrder被跳过。
完成标准: DSL 真实编译;批准与拒绝两路分别断言 EXECUTED/SKIPPED;
库存超时只改变预期节点;写出一句“本实验没有证明业务规则正确”。
进阶 capstone —— 让工单降级可观察
预计投入: 2—3 小时
起始文件:
ticket-routing.bloge,复制后修改
允许范围: DSL、mock operator、一个 listener 测试;不得接真实工单系统
提示层级: 只给边界,不给完整 DSL
需求:
analyzeSentiment已经有了 fallback。给fetchTicketHistory添加retry = { attempts: 2, backoff: 300ms, strategy: jitter },使其能够承受历史服务的瞬时故障。- 添加一个
escalateToManager节点,作为classifyPriority.output.priority == "critical"时的第四个分支目标。输入:来自fetchCustomer.output.id的customerId和来自analyzeSentiment.output的sentiment。 - 验证: 编写一个测试,其中
classifyPriority返回{ priority: "critical" },断言escalateToManager执行,而assignVipAgent、assignNormalAgent和autoResolve全部被跳过。
完成标准: critical、normal、fallback 三条路径可复核;同一
executionId 下能区分 metric、log 与 node status;不能从 fallback 成功推出业务成功。
毕业 capstone —— 交付可复核的贷款变更
预计投入: 1—2 天
起始文件:
loan-approval.bloge
与同模块 src/test 中的 Scenario/Policy/Fixture 测试资产,复制后修 改
允许范围: graph、受控 operator test doubles、Scenario、Policy、Fixture、证据解读与图;不得替业务 Owner 批准 GOLDEN,不得调用真实放款 effect
提示层级: 只给验收合同
需求:
- 在
aggregateRisk和makeDecision之间添加一个reserveFunds节点。reserveFunds依赖aggregateRisk,输入为application = fetchApplication.output和risk = aggregateRisk.output。 - 给
reserveFunds声明output { reservationId: String }并添加一个调用ReleaseFundsOperator的compensate块——与order-saga.bloge中的 saga 模式相同。 - 更新
makeDecision,使其也depends_onreserveFunds。 - 给
reserveFunds添加timeout = 3s和fallback = { reservationId: "NONE", status: "degraded" },使慢速的预留操作不会阻塞管道。 - 技术验证: 编写一个测试,让
ReserveFundsOperator使用MockOperator.throwing(...),证明 fallback 被触发且 graph 仍能到达makeDecision。 - 业务验证: 至少覆盖
auto-approve、manual-review、reject三个 Case;Policy 中禁止真实 effect,Fixture 固定授信、反欺诈和收入事实。 - 证据解读: 对报告写出 verdict、evidence trust/source binding、Requirement contribution 和 release claim 四层结论,不得互相替代。
- 架构表达: 提交一张 graph/runtime/effect 边界图和一张证据到 claim 的 解释图,标出业务 Owner 与发布负责人。
- 未证明事项: 至少列出生产容量、真实下游健康、GOLDEN 批准和发布授权。
完成标准: 六类资产齐全;单因素失败可以复现;报告中的每句话都能指向
Scenario、源码测试或人工职责之一;任何 PASS 都没有被扩写成发布批准。
可选扩展题库
以下三题不计入三级 capstone 完成标准。它们用于工作坊加练,答案和评分说明已 移到教师指南,避免与主任务并列造成“必须做完六题才毕业”的误读。
批量订单:添加逐项韧性和汇总分支
Fixture:
batch-order-parallel.bloge
需求:
- 给
foreach主体内的deductStock节点添加retry = { attempts: 1, backoff: 100ms, strategy: exponential }和timeout = 3s。 - 给
deductStock添加fallback = { deducted: false, reason: "stock service unavailable" },使单个库存扣减失败不会破坏整个批次。 - 在
summarize之后,添加一个branch on summarize.output.allSucceeded:true→notifySuccess(一个新的NotifySuccessOperator节点)false→notifyPartialFailure(一个新的NotifyPartialFailureOperator节点)
- 验证: 让一个 item 的
deductStock使用MockOperator.throwing(...),确认 fallback 被触发,然后断言notifyPartialFailure执行而notifySuccess被跳过。
练习的概念: foreach、逐项韧性、迭代内的 fallback、基于聚合输出的下游分支(第 5、6、10 章)。
进阶任务 —— 全局限流与逐项失败模式。 在上面需求的基础上,把 foreach
的 batch_size = 10,这样同时最多 10 个 item 在跑;再把
on_item_failure = continue,这样单个坏 item 不会让整批中止。用
MockOperator.throwing(...) 让 20 个 item 里有 3 个失败,然后断言:
deductStock被调用了正好 20 次(每个 item 都尝试过)。summarize.output.failedCount == 3,且allSucceeded == false。notifyPartialFailure跑一次;notifySuccess被 skip。- 在
batch_size = 10下,测试观察到的并发峰值不超过 10 (在MockOperator.of(...)外层用AtomicInteger active记录当前并发, 再用第二个peak计数器在每次进入时更新峰值)。
这套组合 —— batch_size + on_item_failure = continue + 基于聚合的下游
分支 —— 就是生产环境做韧性批处理的标准模式。
状态轮询到事件驱动:用 Await 替代 Loop
Fixture:
status-polling.bloge(起点)和
payment-wait.bloge(参考)
需求:
本实验分为两个部分。
Part A —— 扩展轮询循环:
- 在
fetchResult之后添加一个notifyReady节点,当结果可用时发送通知。输入:result = fetchResult.output。 - 给循环主体内的
checkStatus添加timeout = 5s。 - 验证: 断言
notifyReady在fetchResult之后执行。
Part B —— 用 await 替代循环:
- 改写 graph,使用
await代替轮询循环。在submitJob之后,声明:await awaitJobReady {event "job.ready" where jobId = submitJob.output.jobIdtimeout = 60son_timeout {status = "timeout"reason = "Job did not become ready within 60 seconds"}} - 添加一个
branch on awaitJobReady.output.status:"ready"→fetchResultotherwise→handleTimeout(一个新节点,记录失败信息)
- 验证: 编写两个测试——一个模拟事件到达(断言
fetchResult执行、handleTimeout被跳过),一个模拟超时(断言handleTimeout执行、fetchResult被跳过)。
练习的概念: loop、await、事件关联、on_timeout、挂起分支、轮询 vs 事件驱动设计的对比(第 10、11、12 章)。
信贷审批 decision_table
目标: 将一个基于分支的信用等级分类器重构为 decision_table 节点,然后探索多策略决策表和运行时错误路径。
第 1 步 —— 基线:分支堆栈。
打开(或创建)
ch22/credit-approval-baseline.bloge。
该图使用 branch on applicant.output.score,有三个显式 case 和一个 otherwise。确认它通过所有现有测试。
第 2 步 —— 重构为 hit=unique + 命名字段输出。
用以下代码替换分支:
decision_table credit_tier(
score = applicant.output.score
) hit=unique -> { tier: String, rate: Decimal } {
rule (score: score >= 750) -> { tier: "platinum", rate: 3.25 }
rule (score: 680 <= score < 750) -> { tier: "gold", rate: 4.50 }
otherwise -> { tier: "rejected", rate: 0.0 }
}
更新下游接线,使用 credit_tier.output.tier 和 credit_tier.output.rate。运行测试——所有测试均应通过。
第 3 步 —— 添加 hit=collect 进行折扣累积。
添加第二张表,收集所有适用的折扣标签:
decision_table applicable_discounts(
score = applicant.output.score,
amount = loan.output.amount
) hit=collect -> String {
rule (score: score >= 700) -> "loyalty"
rule (score: score >= 760) -> "premium"
rule (amount: amount <= 100000) -> "small-loan"
}
将 applicable_discounts.output.items 接线到通知节点。编写一个测试,断言 score=780, amount=80000 返回三个折扣标签。
第 4 步 —— 触发错误路径。 编写两个负面测试用例:
- 从
hit=unique表中删除otherwise,然后传入score=500。断言图抛出DecisionTableViolationException,code 为RUNTIME_DECISION_TABLE_NO_MATCH。 - 在
score >= 750规则旁边添加一条重叠规则score >= 700(两者在score=800时都会命中)。断言hit=unique时引擎抛出RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH。验证后恢复表内容。
第 5 步 —— bloge lint 演示。
从 hit=unique 表中删除 otherwise 子句,运行 bloge-lint(或观察编辑器警告)。确认 decision-table/missing-otherwise WARNING 出现。重新添加 otherwise 使 lint 通过。
退出标准:
hit=unique+ 命名输出:所有等级与利率断言通过。hit=collect:折扣累积测试通过(score=780, amount=80000返回 3 个标签)。- 两个错误路径测试均断言了正确的
code字符串。 - 恢复
otherwise后,bloge-lint报告零 WARNING。
练习的概念: decision_table、命中策略(unique、collect)、命名字段输出、RUNTIME_DECISION_TABLE_NO_MATCH、RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH、decision-table/missing-otherwise lint 规则、DecisionTableViolationException 断言(第 5 章,附录 F)。
毕业运行与证据检查
毕业 capstone 最后必须回到一个由业务负责人定义的故 事。在固定的 BLOGE
0.9.8-RC1 源码树运行基线测试:
mvn -pl bloge-starter-loan-approval -am \
-Dtest='LoanApprovalApplicationTest,LoanApprovalBusinessScenarioTest,LoanApprovalOperatorFlowTest' \
-Dsurefire.failIfNoSpecifiedTests=false test
预期范围是三个 test classes、11 个 tests:三个决策路径,以及放款成功、重试、 fallback 和 compensation 行为。只把它当作 focused starter evidence。再运行你的 毕业变体,并明确记录测试数、Scenario verdict、evidence trust 和未证明事项。 用第 24 章检查业务映射,再用第 23 章 写出 green run 仍不能证明什么。
完整参考解、常见错误和 20 分量表位于
solutions/ch34/teacher-guide.md。教师应在学员
提交六类资产后再开放;正文不紧邻泄露答案。
实验验收卡
- 预期与观察: 三级 capstone 从 parser 到贷款 Case 再到证据治理,交付六类资产。
- 失败与恢复: 让基线、Case 或证据绑定失败;只修该层,未绿不升级。
- 证明边界: 证明学员变体满足毕业合同,不证明生产发布获批。
- 练习合同: 前 33 章资产和固定 RC1;每级只增一类能力;交付六类资产;91 个基线测试及本级条件通过即停止。
回顾
- 基础 capstone 证明你能读懂、扩展并用 graph contract 验证订单 DAG。
- 进阶 capstone 证明你能让 fallback、分支与可观测事实保持可解释。
- 毕业 capstone 把 graph、测试、Scenario/Policy/Fixture、证据解读、架构图和 未证明事项组合成一个可复核作品。
- 三个可选扩展题覆盖 batch、await 与 decision table,但不改变毕业完成标准。
- 测试、业务验证和发布批准是三层责任;任何一层通过都不能代替后一层。
下一步
你已经完成了 Head First BLOGE。最后一次产出不是“又一份 DSL”,而是一套别人 能够运行、解释和质疑的毕业作品。从这里开始:
- 发布一个真实的 graph —— 在你的系统中挑选一个工作流,编写 DSL,接入 operator,并使用
GraphTestRunner测试它。 - 探索高级特性 —— 尝试在测试夹具中接入
DurableSessionManager和DurableStateMachineManager来练习可崩溃恢复的编排模式。如果你的场景涉及 LLM 工具调用,可以探索bloge-agent-ext中的agentDSL(在第 17 章中讲解)。 - 当你需要精确的语法或边界行为时,查阅参考文档层:
- 探索高级示例,位于
bloge-examples/—— 尤其是food-order.bloge、claim-processing.bloge和shipment-planning.bloge, 这些 graph 综合运用了你所学的一切。 - 配置工具 —— 安装 VS Code 扩展 或 IntelliJ 插件,在编写时获得实时诊断。
参考链接
- 项目 README —— 模块概览与快速开始
- 入门指南 —— 完整安装指南
- DSL 规范 —— 正式语法参考
- Operator 设计规范 —— operator 契约与生命周期
- 核心架构 —— 引擎内部结构
GraphTestRunner.java—— 测试工具源码MockOperator.java—— 测试替身源码DslTestHelper.java—— DSL 编译辅助源码order-process.bloge—— 基础起点ticket-routing.bloge—— 进阶起点loan-approval.bloge—— 毕业起点batch-order-parallel.bloge—— 可选 batch 题status-polling.bloge—— 可选 loop 题payment-wait.bloge—— 可选 await 参考order-saga.bloge—— saga 补偿参考
Coding Agent: Open the versioned task guide.