跳转至

第 06 步:编写自定义工具

这一节学什么

  • @tool 和继承 Tool 的区别
  • description 与 schema 为什么会直接影响 LLM 规划
  • 如何把自定义 tool 接进 Agent

你会运行什么

示例代码:

  • examples/getting_started/step_06_build_custom_tool.py

运行:

python examples/getting_started/step_06_build_custom_tool.py

核心概念

这一节重点不是“写个 Python 函数”这么简单,而是理解:

  • Tool 是暴露给模型的能力边界
  • Tool 的名字、描述、参数 schema 都会影响模型是否会选它
  • ToolSet 负责组织一组工具,Agent 再消费它们

Tool 类到底是什么

可以把 Tool 理解成一个“可被 Agent 调用的能力对象”。

它至少承担 4 件事:

  1. 提供稳定名字
  2. 通过 name 暴露给模型和运行时
  3. 这个名字会直接出现在 tool call 里
  4. 提供能力描述
  5. 通过 description 告诉模型“这个工具能做什么”
  6. 描述不清楚时,模型可能根本不会选它
  7. 提供输入 schema
  8. 通过 input_schema 告诉模型参数长什么样
  9. schema 不清楚时,模型更容易组错参数
  10. 提供执行逻辑
  11. 通过 run(...) 真正完成工具动作

所以 Tool 不是一个普通 Python 类,它同时连接了两端:

  • 对上,连接 prompt / function-calling schema
  • 对下,连接真实执行逻辑

一个 Tool 最小要关心什么

你写一个自定义 Tool 时,最常见的关注点其实就三项:

  • name
  • description
  • run(...)

如果工具参数不是纯字符串输入,再补:

  • input_schema

在这个意义上,Tool 更像“能力接口声明 + 执行实现”的组合体。

为什么 name、description、schema 这么重要

很多人第一次写 tool 时,会把注意力全放在 run(...) 里。 但对 LLM 来说,它在调用前根本看不到你的 Python 实现,先看到的是:

  • tool 名字
  • tool 描述
  • 参数 schema

也就是说,模型做规划时主要依据的不是代码,而是 metadata。

一个很实用的判断是:

  • run(...) 决定“调用后做得对不对”
  • name / description / schema 决定“调用前会不会被选中、参数会不会传对”

Tool 的职责边界

Tool 负责定义“能力本身”,但它不负责所有事情。

通常不应该让 Tool 自己承担这些职责:

  • 不负责决定是否允许调用
  • 不负责审批确认
  • 不负责整体上下文装配
  • 不负责多工具的集合管理

这些职责通常分别落在:

  • Guard / Approval
  • ContextBuilder
  • ToolSet

把边界分清楚后,设计会更稳定:

  • Tool 保持单一职责
  • 运行时治理交给 Guard / Execution
  • 组合和暴露交给 ToolSet

@tool 和继承 Tool 的区别

这两种方式的核心差别,不是“哪个更高级”,而是“你要封装到什么程度”。

用 @tool 的场景

适合:

  • 很小的函数型能力
  • 输入输出简单
  • 你只是想快速把一个函数暴露给 Agent

它的优点是:

  • 写起来快
  • 对简单工具很直接
  • 适合 demo、轻量 helper、纯函数式能力

继承 Tool 的场景

适合:

  • 你想显式声明类级别 metadata
  • 你需要更复杂的输入输出 schema
  • 你希望这个工具以后还能复用到别的节点、provider、tool set

它的优点是:

  • 结构更稳定
  • 更适合复杂能力
  • 更利于后续扩展 effect tags、schema、复用逻辑

所以可以粗略这样选:

  • 简单函数能力:先用 @tool
  • 稍微正式、可复用、后续会扩展:直接写 Tool 子类

ToolSet 类到底是什么

ToolSet 不是另一个工具,也不是执行器。

它更像一个“工具集合门面”,负责把一组工具整理成 Agent 更容易消费的形式。

它主要解决的是“当前有哪些工具、它们怎么被统一组织起来”。

最常见的几件事包括:

  • 从显式 tool 列表构造集合:ToolSet.from_tools([...])
  • 合并多组工具:merge(...)
  • 按名字查工具:get(name)
  • 导出 function-calling schema:schemas()
  • 导出 prompt 用描述:describe()
  • 直接执行集合中的某个工具:execute(name, args)

为什么需要 ToolSet

如果系统里只有一个 tool,看起来直接传 list 也行。 但一旦工具数量多起来,很快会出现几个问题:

  • 工具名冲突怎么处理
  • 多组工具怎么合并
  • prompt 里该怎么统一渲染工具描述
  • provider 返回的一批工具怎么接入

ToolSet 的价值就在这里:

  • 它把“工具集合管理”从业务代码里抽离出来
  • 让 Agent、node、provider 不必重复处理这套组织逻辑

ToolSet 和 Tool 的关系

可以把它们理解成:

  • Tool 是单个能力单元
  • ToolSet 是能力集合容器

一个类比:

  • Tool 像单个函数接口
  • ToolSet 像这一组函数的注册表

所以:

  • 没有 Tool,ToolSet 没有内容可组织
  • 没有 ToolSet,多工具场景会更容易散落在各处的 list / dict 里

ToolSet 不负责什么

ToolSet 虽然能 execute(...),但它不是完整的运行时编排层。

它通常不负责:

  • ReAct 规划
  • guard 审批
  • sandbox / Docker 隔离
  • 状态更新和消息回写

这些仍然分别属于:

  • Agent / node
  • Guard / Approval
  • Execution runtime

所以 ToolSet 更像“整理和暴露工具”,不是“接管整个工具运行生命周期”。

看这个示例时该带着什么问题

运行这一步时,建议重点观察这几个问题:

  1. 模型为什么会选择 echo
  2. 如果把 description 改得很模糊,会不会更难触发
  3. 如果把 input_schema 改错,tool call 参数会不会变差
  4. tool 执行成功后,结果是如何回到 final_answer 的

如果你能回答这几个问题,基本就已经不只是“会写一个 tool”,而是开始理解 tool 在 Agent 体系里的位置。

运行后观察什么

运行这个示例时,不要只看最后有没有输出。 更重要的是观察 tool 在 Agent 链路里经过了哪些阶段。

1. 模型有没有选中正确的工具

示例里用户明确要求使用 echo。

如果模型正确规划,它应该生成一个针对 echo 的 tool call。 这一步主要验证的是:

  • name 是否足够稳定
  • description 是否足够清楚
  • 当前 prompt 里是否真的暴露了这个 tool

如果模型没有选中 echo,优先检查 tool metadata,而不是先怀疑 run(...)。

2. 参数有没有按 schema 组装

EchoTool.input_schema 要求输入里有 text 字段。

所以你要观察模型生成的 tool call 参数是否类似:

{"text": "jianmu getting started"}

这一步主要验证的是:

  • input_schema 是否准确描述了参数结构
  • 字段名是否和 run(...) 的参数名一致
  • required 字段是否覆盖了真正必需的输入

如果参数传错,常见原因不是 tool 执行逻辑错,而是 schema 没把输入形状讲清楚。

3. tool observation 是否清晰

工具执行后,结果会作为 observation 回到消息流里。

你要观察的是:

  • observation 是否包含足够清楚的结果
  • 模型能否根据 observation 继续生成最终回答
  • 最终 final_answer 是否真的利用了工具结果

一个好的 tool 不只是“能返回值”,还应该返回对模型后续推理有用的值。

4. 区分 Tool 和 ToolSet

这个示例直接把 [EchoTool()] 传给 ReAct 节点。

在更复杂的场景里,这组工具通常会先被整理成 ToolSet。 你可以把两者分开记:

  • Tool 定义单个能力
  • ToolSet 管理一组能力

这一步的重点不是一定要手写 ToolSet,而是理解 Agent 最终消费的是一组工具能力,而不是散落的 Python 函数。

5. 记住模型看到的是 metadata

LLM 不会先读你的 run(...) 源码再决定怎么调用。

它主要依赖:

  • name
  • description
  • input_schema

所以调试 tool 选择问题时,优先看 metadata。 调试 tool 执行问题时,再看 run(...)。

6. 有副作用的工具应声明 Permission V2 语义

自定义工具可以设置 permission_action。如果资源提取依赖具体参数,还可以 覆盖无副作用的 permission_check_for_call(args, context=...)。两者都不写 也仍然兼容:Jianmu 会用工具名作为 action,并用精确规范化参数指纹作为 resource fallback。

class PublishTool(Tool):
    name = "publish"
    permission_action = "network.write"

该声明由 ToolExecutor 在真正执行前求值。它是策略 metadata,不是 sandbox, 也不负责渲染审批 UI。

下一步