跳转至

Tool nodes cn

工具与技能节点是 Jianmu 行为树中负责执行具体能力的关键节点类型。与 LLM 节点(负责"思考")相对应,本章节覆盖的三个核心组件——ToolNode、ToolExecutor 和 SkillNode——分别承担了"直接执行工具"、"由 Agent 调度工具"和"通过 ReAct 宿主路由技能"的职责。三者共用一套约束联动机制,将 Guard 策略、执行沙箱配置和预算控制贯穿到每一次工具调用与技能运行中。

节点体系定位

在行为树中,ToolNode 和 ToolExecutor 位于不同的抽象层次,前者是显式工作流节点("我确定这里要调用某个工具"),后者是Agent 循环内的动态调度器("LLM 决定调用哪个工具")。SkillNode 则是一个更高阶的扁平化代理节点,它在首次 tick 时惰性编译运行时子树,并承载基于 ReAct 的技能路由。

flowchart TB
    subgraph Workflow["显式工作流"]
        TN[ToolNode<br/>单工具包装]
    end
    subgraph AgentLoop["Agent 循环"]
        LLM[AgentLLMNode] -->|写入 ToolCall| TE[ToolExecutor]
        TE -->|追加 Observation| LLM
    end
    subgraph SkillPipeline["技能流水线"]
        SN[SkillNode]
        SN --> REACT[ReAct 节点]
    end
    GC[GuardEnforcer] -.->|预检| TE
    GC -.->|预检| REACT
    TR[ToolRunner] -.->|沙箱执行| TN
    TR -.->|沙箱执行| TE

ToolNode:显式工具包装节点

ToolNode 继承自 AsyncNode,将一个 Tool 实例包装为可直接插入行为树的节点。当工作流设计者在编辑器中明确决定"在这一步调用文件读取工具"时,使用的就是 ToolNode。

构造与输入输出绑定

ToolNode 的核心参数包括:tool(底层工具实例)、input_key(从状态读取输入的键名)和 output_key(写入输出的键名)。它还接受一个 execute 布尔值,允许在不删除节点的情况下临时禁用执行——这在调试或条件性跳过场景下很有用。

# ToolNode 的核心构造参数
ToolNode(
    name="read_config",
    tool=FileReadTool(),
    input_key="file_path",    # 从状态中读取 file_path
    output_key="config_data", # 将结果写入 config_data
    execute=True,             # False 时跳过执行直接返回 SUCCESS
)

执行流程与沙箱感知

update_async() 方法是 ToolNode 的核心。它首先通过 _resolve_inputs() 从状态端口读取工具参数(支持非字典类型的输入自动规范化为空字典),然后调用 _run_tool() 执行工具。_run_tool() 的关键特性是沙箱签名缓存——它根据当前沙箱配置(mode、docker_image、network 等)计算一个稳定签名,仅在签名变化时才重建 ToolRunner,避免了重复创建执行器的开销。

执行结果通过 ToolResult.from_raw() 规范化,失败时返回 Status.FAILURE,成功时将输出写入状态端口并返回 Status.SUCCESS。

遥测与事件

ToolNode 在执行的每个阶段(开始、完成、失败)都会发出两种遥测信号:运行时事件(通过 emit_runtime_event 发送到事件总线)和 Span 追踪(通过 trace_emit 发送到遥测中心)。此外,当 ToolNode 在 BT Skill 上下文中运行时,还会发出 bt.skill.node.io 事件用于可视化调试。

事件名称 触发时机 关键字段
tool.call.started 工具执行开始 tool, mode:"workflow", args_preview
tool.call.completed 工具执行成功 tool, result_preview
tool.call.failed 工具执行失败 tool, error
bt.skill.node.io BT Skill 上下文中 stage, tool_name, ok, input/output_preview

ToolExecutor:Agent 驱动的工具调度器

ToolExecutor 是 Agent 循环中的关键环节——当 AgentLLMNode 解析 LLM 响应并写入 ToolCall 列表后,ToolExecutor 负责读取这些调用请求、执行预检、并行或串行运行工具、最后将观察结果追回到对话状态中。

与 ToolNode 的职责分工

ToolNode 和 ToolExecutor 虽然都执行工具,但它们的触发方式和用途截然不同:

维度 ToolNode ToolExecutor
触发方式 行为树 tick 到达时自动执行 读取状态中的 ToolCall 列表后执行
工具选择 编译期固定绑定一个 Tool 运行时按名称从 ToolSet 动态查找
调用来源 工作流设计者显式放置 LLM 通过 function calling 动态决定
执行模式 单工具串行 支持 auto/serial/parallel 三种模式
Guard 集成 无预检(信任工作流设计) 完整的 _preflight_actions 预检链
结果处理 写入指定 output_key 追加为 tool observation 消息

执行模式:串行、并行与自动

ToolExecutor 支持三种执行模式,通过 config.execution_mode 控制。默认为 "auto",此时会根据工具数量和并行安全性自动决策:

  • serial:逐个执行工具,适合有副作用的工具或调试场景
  • parallel:使用 asyncio.gather 并发执行所有工具,适合无依赖的独立查询
  • auto:单工具或非并行安全工具使用串行,多工具且全部标记 parallel_safe=True 时使用并行

并行安全性由 ToolSet.is_parallel_safe() 方法判断,底层依赖每个 Tool 实例的 parallel_safe 属性。

预检机制与 Guard 联动

这是 ToolExecutor 最核心的安全机制。在每次执行工具批次前,_preflight_actions() 方法会为批次中的每个 ToolCall 构造审批上下文并依次通过 GuardEnforcer.check() 检查。检查器链由四个 Checker 组成:

flowchart LR
    TC[ToolCall 批次] --> TPC[ToolPolicyChecker<br/>允许/拒绝/确认策略]
    TPC -->|allow| RLC[RateLimitChecker<br/>工具调用次数限制]
    RLC -->|allow| BC[BudgetChecker<br/>Token 预算检查]
    BC -->|allow| CC[ConfirmChecker<br/>用户确认拦截]
    CC -->|allow| EXEC[执行工具]
    TPC -->|deny| DENY[返回 DENY]
    CC -->|pending| SUSPEND[挂起等待审批]

四个检查器按顺序执行,一旦遇到 DENY 立即短路返回。CONFIRM 不会短路,而是继续执行后续检查器(因为后续检查器可能升级为 DENY)。当 ConfirmChecker 返回 PENDING 时,ToolExecutor 会调用 interaction.suspend() 挂起整个运行时,等待外部审批回调。

工具观测与副作用聚合

ToolExecutor 会把工具返回值视为observation,先归一化成 ToolResult,再把对应的 tool message 追加到共享 messages。ReAct 循环是否终止仍由 agent 侧决定;工具本身不会直接给循环写入 done 或 final_answer。

同时,_collect_tool_effects() 和 _write_tool_effects() 方法会聚合所有成功执行工具的 effect_tags,将副作用计数持久化到状态中,供下游节点或遥测系统消费。

SkillNode:技能驱动的代理节点

SkillNode 继承自 FlattenedAgentNode(一个 py_trees Decorator 子类),是 Jianmu 技能系统与行为树之间的桥梁。它的核心设计在于延迟编译——构造时不构建子树,而是在首次 initialise() 时再执行 _compile_subtree()。这意味着 SkillNode 可以等到运行时依赖(state_manager、ctx、wake_up)全部注入后再决定执行策略。

FlattenedAgentNode 的延迟编译与命名空间隔离

FlattenedAgentNode 在 initialise() 中基于节点在树中的路径位置生成一个确定性命名空间(如 skill_runtime.MySkill_2_1_0),然后将该命名空间注入自身及所有子节点。这样确保了同一个 SkillNode 的多次实例化不会发生状态冲突。terminate() 方法在运行结束时清理该命名空间,并在失败时保存调试快照。

三阶段流水线

SkillNode 编译出的子树以 ReAct 节点为核心:

  1. 运行时消息准备:SkillNode 为技能命名空间准备消息上下文。它从全局对话历史中读取最近消息(受 history_limit 限制),并合并宿主传入的 seed messages。

  2. 执行节点:SkillNode 创建 ReAct 节点。Prompt 技能注入上下文,BT 技能暴露为 run_bt_skill。

  3. 输出处理:SkillNode 维持普通 ReAct 路径中的最终回答;BT 工具运行的隔离输出提取由 RunBTSkillTool 自己处理。

sequenceDiagram
    participant RT as ReactiveRunner
    participant SN as SkillNode
    participant PREP as PrepareContext
    participant EXEC as ReAct
    participant SM as StateManager

    RT->>SN: initialise()
    SN->>SN: _compile_subtree()
    SN->>SN: _load_skills()
    SN->>SN: _merge_constraints()
    SN->>SN: _resolve_tools() + create_react_node()
    SN->>SM: 创建命名空间隔离

    RT->>SN: tick
    SN->>PREP: tick
    PREP->>SM: 读取历史/写入种子消息
    PREP-->>SN: SUCCESS

    SN->>EXEC: tick
    EXEC->>EXEC: ReAct 循环 / BT 工具执行
    EXEC->>SM: 写入 final_answer, done
    EXEC-->>SN: SUCCESS

    RT->>SN: terminate()
    SN->>SM: 清理命名空间

Prompt 驱动 vs BT 驱动技能

SkillNode 通过 skill.execution 字段区分技能类型:

  • "prompt"(默认):技能包含一个 prompt 文本(SKILL.md 正文),SkillNode 将其注入 ReAct 节点的系统提示中,让 LLM 按照技能指示执行。这是最常见的模式,适合需要 LLM 灵活推理的场景。

  • "bt":技能包含一个 tree.py 行为树入口(skill.tree 字段)。在 SkillNode 中,BT 技能会以 run_bt_skill 的形式暴露给 ReAct;当调用方决定 direct execution 时,则通过宿主侧显式装配 SkillTreeLoader + BTSkillContext + Agent 来构造并运行。

两种模式可以混合——多个技能同时加载时,BT 技能会被包装为 RunBTSkillTool 工具暴露给 prompt 技能使用。对于调用方已经明确选中某个 BT 技能的纯 BT 场景,应使用显式的 direct BT 装配,而不是继续通过 SkillNode 路由。

输入解析与输出规范化

对于 BT 执行,结构化 inputs 的解析顺序是:先看显式调用传入的 inputs,再看全局 state 中的同名字段,最后回退到 schema 默认值。Prompt skill 不会自动合成结构化 skill_result;它的正常对外结果是 final_answer。BT skill 则通过命名空间内的 skill_result 返回结构化数据,而 skill_result_key 是 BT context 里的桥接字段,用来把配置好的 state key 传进子树。随后 run_bt_skill 之类的调用方再把这个 skill_result 值暴露为 tool output。若 skill 声明了 outputs,运行时还可以基于派生出的 JSON schema 校验最终 payload。

约束联动机制

约束系统是 ToolExecutor、SkillNode 与 Guard/Execution 基础设施之间的一条贯穿线。它遵循就近覆盖、逐层合并的原则。

约束的三层结构

flowchart TB
    subgraph AppConfig["全局配置 jianmu.yaml"]
        GC[全局 Guard/Execution 默认值]
    end
    subgraph NodeConfig["节点配置"]
        NC[SkillNodeConfig.constraints]
        TC[ToolExecutorConfig]
    end
    subgraph SkillConfig["技能级配置 SKILL.md"]
        SC[Skill.constraints]
    end
    SC -->|逐字段覆盖| NC
    NC -->|逐字段覆盖| GC
    MERGED[合并后的 Constraints] --> GUARD[GuardEnforcer<br/>检查器链]
    MERGED --> EXEC[ToolRunner<br/>沙箱选择]

Constraints 数据类聚合了两个子约束域: - guard: GuardConstraints:控制工具策略(tool_policy)、最大工具调用次数(max_tool_calls)、Token 预算(budget) - execution: ExecutionConstraints:控制执行后端(mode: local/docker)、Docker 镜像、网络、超时等

SkillNode 中的约束合并

_merge_skill_constraints() 是 SkillNode 约束联动的核心函数。它接收节点级的基础约束和技能列表,逐字段合并:

约束字段 合并策略
max_iterations 取所有 Skill 中声明值的最小值
guard.max_tool_calls 首个非 None 值优先(节点 > 技能顺序)
guard.tool_policy 字典合并,后加载的技能覆盖前者的同名策略
guard.default_policy 首个非默认值优先
guard.budget.total_tokens 首个非 None 值优先
execution 首个非 None 值优先
timeout 首个非 None 值优先
max_messages 首个非 None 值优先

这种合并策略确保了安全约束只能收紧不能放松:max_iterations 取最小值,而其他字段一旦被设置就不会被后续技能覆盖。

ToolExecutor 中的约束生效

ToolExecutor 在 update_async() 中首次执行时,从自身的 constraints 属性或 ctx.constraints 中获取约束,然后分别构建:

  1. GuardEnforcer:通过 GuardEnforcer.from_guard_constraints(guard_constraints) 构建,将 tool_policy、max_tool_calls、budget 分别转化为对应的 Checker 实例。

  2. ToolRunner:通过 ToolRunner.from_execution_constraints(execution) 构建,将 mode、docker_image、timeout_s 等转化为沙箱执行器。

约束一旦构建就被缓存(self._enforcer、self._tool_runner),后续 tick 复用,除非显式调用 reset_for_run()。

工具策略的三态模型

Guard 系统中的 tool_policy 支持三种策略值,为每个工具定义独立的行为:

策略值 行为 典型场景
allow 直接放行,不触发审批 只读查询、内建计算器
deny 直接拒绝,工具不可用 高风险操作、禁用特定工具
confirm 触发确认流程,等待用户审批 文件写入、网络请求、代码执行

当 default_policy 设置为 "allow" 且未对某工具显式配置策略时,该工具将被放行。SkillNode 在解析工具列表后会通过 _apply_tool_policy() 方法过滤掉标记为 deny 的工具,使其完全不对 LLM 可见。

端到端约束流实例

以下是一个完整的约束传递示例——从一个 SKILL.md 文件到最终的工具执行:

SKILL.md:
  constraints:
    guard:
      max_tool_calls: 2
      tool_policy:
        file_write: confirm
    max_iterations: 5
     ↓ SkillNode._merge_constraints()
合并 Constraints:
  guard.max_tool_calls = 2
  guard.tool_policy = {"file_write": "confirm"}
  max_iterations = 5
     ↓ SkillNode._compile_subtree()
ReActConfig.max_iterations = 5  (覆盖默认值)
guard_constraints → GuardEnforcer
     ↓ ReAct 循环内 ToolExecutor
_preflight_actions() → GuardEnforcer.check()
     → ToolPolicyChecker: file_write → "confirm"
     → ConfirmChecker: 挂起等待用户审批

阅读路径建议

完成本章后,建议按以下路线继续深入: