跳转至

第 09 步:编写自定义节点

这一节学什么

  • 什么时候应该写自定义 Node,而不是 Tool 或 Skill
  • 自定义同步 / 异步节点的最小实现
  • .bind(inputs=..., outputs=...) 在节点编排中的作用

你会运行什么

示例代码:

  • examples/getting_started/step_09_write_custom_node.py

运行:

python examples/getting_started/step_09_write_custom_node.py

核心概念

这一节的重点是边界感:

  • Tool 适合暴露给模型主动调用
  • Skill 适合打包一组能力或子流程
  • Node 适合显式编排运行时控制流

如果你开始需要稳定的树结构、明确的状态端口和可预测的调度语义,通常就该写 Node,而不是继续把逻辑塞进 prompt。

为什么这里继承 AsyncBehaviour

这个示例里的两个节点都继承了:

class NormalizeTextNode(AsyncBehaviour):
    ...

这里选 AsyncBehaviour,是因为它很适合作为 Getting Started 里的统一节点基类。

你可以先这样理解:

  • AsyncBehaviour
  • 适合通过 update_async() 写节点逻辑
  • 即使当前逻辑只是本地字符串处理
  • 也可以先用异步节点写法
  • 这样以后接文件、网络、数据库、外部服务时不用再整体改风格

所以这一步不是在强调“这个例子必须异步”,而是在建立一个更稳的节点编写心智。

一个自定义节点的最小契约

一个自定义节点最少需要做三件事:

  1. 读取输入
  2. 产生输出
  3. 返回 Status

也就是说,update_async() 不是普通 helper 函数。

它是行为树节点真正被 tick 时执行的入口。

最小结构通常像这样:

async def update_async(self) -> Status:
    value = self.read_port("input_name", default="")
    result = ...
    self.write_port("output_name", result)
    return Status.SUCCESS

这里的 Status 很重要:

  • Status.SUCCESS
  • 这一步完成了,而且结果满足预期
  • Status.FAILURE
  • 这一步完成了,但条件不满足,或者这条路径不成立
  • Status.RUNNING
  • 这一步还没做完,后续 tick 还要继续推进

Getting Started 里的这个例子都是同步可完成的小逻辑,所以直接返回 SUCCESS。

read_state/write_state 和 read_port/write_port 的区别

你前一节已经见过直接读写 state。

这一节开始进入更推荐的节点写法:

  • read_state/write_state
  • 直接依赖具体 state key
  • 节点内部知道外部字段名
  • read_port/write_port
  • 先面向逻辑端口读写
  • 再通过 .bind(...) 映射到真实 state key

可以先把区别理解成:

  • state
  • 更像共享存储本身
  • port
  • 更像节点对外暴露的输入输出接口

如果一个节点只是一次性 demo,直接写 state 也能跑。 但如果你希望节点能复用,port 通常是更好的边界。

.bind(...) 到底解决了什么

这一步最关键的其实不是 read_port() 或 write_port() 本身,而是:

NormalizeTextNode(name="NormalizeText").bind(
    inputs={"source_text": "text"},
    outputs={"normalized_text": "normalized_text"},
)

.bind(...) 解决的是“节点内部名字”和“外部 state 字段名”之间的解耦。

也就是说:

  • 节点内部只知道自己需要一个 source_text 输入端口
  • 但它不需要知道外部 state 里这个字段是不是叫 text

同理:

  • 节点内部只知道自己输出一个 normalized_text
  • 但这个输出最终也可以映射到别的 state key

这带来的好处是:

  • 同一个节点可以复用到不同 state schema
  • 节点实现不必跟某个具体业务字段名绑死
  • 组合节点时,胶水逻辑集中在树装配处,而不是塞进节点内部

这就是为什么:

  • Step 08 先讲“怎么组一棵树”
  • Step 09 再讲“节点怎么通过 port 接进这棵树”

为什么这个例子拆成两个节点

这个例子没有把逻辑塞进一个大节点,而是拆成:

  • NormalizeTextNode
  • SummarizeNode

这是故意的。

它想表达的是节点设计里的一个很实用原则:

  • 一个节点尽量只负责一步清晰的工作

这里两步分别是:

  • 先规范化输入文本
  • 再基于规范化结果构造摘要

拆开之后更容易得到这些收益:

  • 每个节点职责更单一
  • 中间结果可以被观察、复用或替换
  • 后续更容易把其中一步替换成别的实现

什么时候不该写 Node

这一节也要反过来提醒一次:

  • 如果你只是想给模型暴露一个能力
  • 更像 Tool
  • 如果你只是想打包一段技能说明或子流程
  • 更像 Skill
  • 只有当你需要显式控制流、状态推进和调度语义时
  • 才更适合写 Node

Checkpoint 与恢复

对普通自定义节点来说,恢复机制通常应该是无感的:

  • 纯计算不需要恢复代码。
  • 需要跨 resume 保留的业务进度,应通过端口或 state helper 写入 Jianmu state。
  • Tool / LLM / Agent 相关外部任务优先使用内置 API,它们会记录已完成任务,供 checkpoint replay。
  • 如果自定义节点直接执行外部副作用,需要通过 self.recovery.task(...) / self.recovery.tasks 登记,或者让该操作显式具备幂等和 reconcile 策略。

普通应用节点不应该实现 dump_resume_state() 或 restore_resume_state()。这些 hook 是内置节点迁移旧 node-local 恢复路径时使用的内部兼容机制。

运行后观察什么

  • 节点如何从输入端口读取数据
  • 节点如何把结果写回输出端口
  • .bind(...) 如何减少状态胶水代码
  • 一个较大的流程为什么值得拆成多个小节点

下一步