第 06 步:编写自定义工具¶
这一节学什么¶
@tool和继承Tool的区别- description 与 schema 为什么会直接影响 LLM 规划
- 如何把自定义 tool 接进 Agent
你会运行什么¶
示例代码:
examples/getting_started/step_06_build_custom_tool.py
运行:
核心概念¶
这一节重点不是“写个 Python 函数”这么简单,而是理解:
- Tool 是暴露给模型的能力边界
- Tool 的名字、描述、参数 schema 都会影响模型是否会选它
- ToolSet 负责组织一组工具,Agent 再消费它们
Tool 类到底是什么¶
可以把 Tool 理解成一个“可被 Agent 调用的能力对象”。
它至少承担 4 件事:
- 提供稳定名字
- 通过
name暴露给模型和运行时 - 这个名字会直接出现在 tool call 里
- 提供能力描述
- 通过
description告诉模型“这个工具能做什么” - 描述不清楚时,模型可能根本不会选它
- 提供输入 schema
- 通过
input_schema告诉模型参数长什么样 - schema 不清楚时,模型更容易组错参数
- 提供执行逻辑
- 通过
run(...)真正完成工具动作
所以 Tool 不是一个普通 Python 类,它同时连接了两端:
- 对上,连接 prompt / function-calling schema
- 对下,连接真实执行逻辑
一个 Tool 最小要关心什么¶
你写一个自定义 Tool 时,最常见的关注点其实就三项:
namedescriptionrun(...)
如果工具参数不是纯字符串输入,再补:
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 更像“整理和暴露工具”,不是“接管整个工具运行生命周期”。
看这个示例时该带着什么问题¶
运行这一步时,建议重点观察这几个问题:
- 模型为什么会选择
echo - 如果把
description改得很模糊,会不会更难触发 - 如果把
input_schema改错,tool call 参数会不会变差 - 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 参数是否类似:
这一步主要验证的是:
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(...) 源码再决定怎么调用。
它主要依赖:
namedescriptioninput_schema
所以调试 tool 选择问题时,优先看 metadata。
调试 tool 执行问题时,再看 run(...)。
6. 有副作用的工具应声明 Permission V2 语义¶
自定义工具可以设置 permission_action。如果资源提取依赖具体参数,还可以
覆盖无副作用的 permission_check_for_call(args, context=...)。两者都不写
也仍然兼容:Jianmu 会用工具名作为 action,并用精确规范化参数指纹作为
resource fallback。
该声明由 ToolExecutor 在真正执行前求值。它是策略 metadata,不是 sandbox,
也不负责渲染审批 UI。