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 节点为核心:
-
运行时消息准备:SkillNode 为技能命名空间准备消息上下文。它从全局对话历史中读取最近消息(受
history_limit限制),并合并宿主传入的 seed messages。 -
执行节点:SkillNode 创建 ReAct 节点。Prompt 技能注入上下文,BT 技能暴露为
run_bt_skill。 -
输出处理: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 中获取约束,然后分别构建:
-
GuardEnforcer:通过
GuardEnforcer.from_guard_constraints(guard_constraints)构建,将tool_policy、max_tool_calls、budget分别转化为对应的 Checker 实例。 -
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: 挂起等待用户审批
阅读路径建议¶
完成本章后,建议按以下路线继续深入:
- 理解 Guard 系统全貌:Guard 体系:工具策略检查、预算控制、频率限制与确认拦截 深入理解 ToolExecutor 预检链中每个 Checker 的实现细节
- 理解人机交互挂起恢复:人机交互挂起恢复:审批流、用户输入请求与外部回调编排 了解 PENDING 状态的完整生命周期
- 理解技能定义格式:Skill 定义与目录:SKILL.md 解析、行为树技能与 Prompt 技能 了解 SkillNode 加载的源数据格式
- 理解执行沙箱:执行环境:LocalSandbox 与 DockerSandbox 的工具隔离运行 了解 ExecutionConstraints 如何映射到实际执行器
- 回溯 Agent 循环:ReAct 节点工厂:LLM 调用 → 工具执行 → 完成的循环回路 了解 ToolExecutor 在 ReAct 循环中的定位