数据截至 (上游 commit 10bba89b6792)
工具系统:定义、schema 生成、注册与安全执行
30 秒导读: 工具是 agent 的手脚——让 LLM 不只是「说」,还能「做」(读文件、查数据库、调 API)。本章讲一条完整链路:一段普通 Kotlin 函数怎样变成工具、怎样把它的参数契约翻译成 LLM 能读的 JSON schema、怎样 注册进表、以及被调用时怎样经过安全管线执行而不是被直接 call。
本章只讲工具本身。「LLM 在某个节点该选哪个工具」是图的 edge 谓词与 ToolSelectionStrategy 的事,见 01-graph-engine.md 与 02-dsl-and-strategies.md。「工具描述最终怎么塞进 prompt、发给哪个 Client」见 03-llm-layer.md。「工具执行时被 feature 拦截、观测」见 05-features-runtime-extensions.md。
1. 这是什么(零基础也能懂)
一句话定义: 工具(Tool)是一段带类型化输入/输出、并向 LLM 自我描述的可执行逻辑。LLM 读到它的名字和参数说明后,可以决定「调用它、传这些参数」;框架接住这个决定,真正把逻辑跑起来,再把结果喂回 LLM。
解决什么问题 / 给谁用: 纯 LLM 只会生成文本。你想让它「查一下今天订单数」「把这段文字情感打个分」「在数据库里建一条记录」——这些动作模型自己做不了,得有人替它做。工具就是那个「替它做」的东西。给谁用:写 agent 的 Kotlin/Java 工程师。
它能做什么:
- 把任意 Kotlin/Java 函数零样板变成工具(
@Tool注解 + 反射)。 - 从参数类型自动生成 LLM 能读的参数 schema。
- 用一张注册 表统一管理、按名字/类型查找工具。
- 执行时走一条受控管线:类型解码 → 执行 → 结果编码 → 事件通知 feature,任何一步出错都被转成结构化的失败结果而不是崩掉。
- 接入外部 MCP server 的工具,当成本地工具一样用。
用起来什么样: 最直接的一种——继承 SimpleTool,写一个返回字符串的工具:
// 示意,非源码:一个把文本情感打标签的工具
object ToneTool : SimpleTool<ToneTool.Args>(
argsType = typeToken<Args>(),
name = "analyze_tone",
description = "分析给定文本的情感倾向" // 这句会被 LLM 读到
) {
@Serializable
data class Args(val text: String) // 参数类型 → 自动变成 schema
override suspend fun doExecute(args: Args): String =
if ("糟糕" in args.text) "negative" else "positive"
}
val registry = ToolRegistry { tool(ToneTool) } // 注册,交给 agent
一句话直觉: 把工具想成招聘启事 + 岗位本人。招聘启事(ToolDescriptor)贴给 LLM 看,写清「岗位叫什么、需要哪些参数」;岗位本人(execute)在后台真正干活。LLM 只看启事做决定,永远不直接碰本人——中间隔着一层 HR(environment 管线)。
2. 顶层全景(它大概怎么转)
一个工具的一生分两大阶段:创作期(把它定义好、暴露给 LLM)和运行期(LLM 决定调用、框架执行)。
怎么读这张图: 从左到右是时间。上半行是创作期(编译/组装时做),下半行是运行期(每次调用时做)。竖线是「LLM」这道墙——墙左边是给模型看的描述,墙右边是真正的执行。
创作期(定义 + 暴露)
你的类型/函数 自动 schema 生成 注册
Args / KFunction ──▶ ToolDescriptor(中间表示) ──▶ ToolRegistry
@Tool @LLMDescription requiredParameters 等 (按名字唯一)
│
▼ 各 Client 二次翻译
Provider JSON schema
(OpenAI / Anthropic ...)
━━━━━━━━━━━━━━━━━━━━━━━━━ LLM 这道墙 ━━━━━━━━━━━━━━━━━━━━━━━━━
运行期(调用 + 执行)
LLM 回一个 tool call
{name, args(JSON)}
│
▼
SafeTool.execute(args) ← 你的代码这样发起
│ 编码 args→字符串,包成 Tool.Call
▼
ContextualAgentEnvironment ← 发 onToolCallStarting 等事件、合并 metadata
│
▼
GenericAgentEnvironment ← 注册表查工具 → decodeArgs → execute → encodeResult
│
▼
ReceivedToolResult ──▶ 转成 SafeTool.Result.Success/Failure ──▶ 回到 LLM
部件一句话职责:
| 部件 | 干什么 | 在哪(相对 koog/) |
|---|---|---|
ToolBase / Tool / SimpleTool | 工具的三层抽象基类,定义 execute 签名与编解码钩子 | agents/agents-tools/.../core/tools/ToolBase.kt、Tool.kt、SimpleTool.kt |
ToolCallMetadata | 每次调用的附加旁路上下文(如 trace id),不进 schema、不发给 LLM | .../core/tools/ToolCallMetadata.kt |
ToolDescriptor | 给 LLM 的「招聘启事」:名字、描述、参数列表(provider 无关的中间表示) | .../core/tools/ToolDescriptor.kt、ToolDescriptors.kt |
| schema 生成 | 从类型/函数生成 JSON schema,再折成 ToolDescriptor | .../core/tools/schema/SchemaGenerator*.kt |
| provider 翻译 | 把 ToolDescriptor 再翻成某家 LLM 的 JSON | prompt/.../openai/base/OpenAICompatibleToolDescriptorSchemaGenerator.kt |
@Tool / @LLMDescription / ToolSet / ToolFromCallable | 让普通函数零样板变工具 | .../core/tools/annotations/*、.../core/tools/reflect/* |
ToolRegistry | 工具的注册与查找 | .../core/tools/ToolRegistry.kt |
SafeTool + AIAgentEnvironment | 运行期安全执行路径 | agents/agents-core/.../environment/SafeTool.kt、AIAgentEnvironment.kt |
| MCP 接入 | 把外部 MCP server 的工具映射成本地工具 | agents/agents-mcp/.../McpTool*.kt |
3. 核心原理(逐个机制,由浅入深)
3.1 工具抽象:三层基类与 execute 签名
它要解决的小问题: 不同工具需求差别很大——有的只想返回一段文本、有的需要读运行时上下文、有的需要一个 trace id。既要让「简单工具写起来简单」,又要让「复杂工具能拿到全部信息」,还要让框架能用一个统一入口调度所有工具。
思路: 把「统一调度入口」和「用户写起来的便利形态」拆开。底座 ToolBase 定义唯一的调度入口;上面派生几种便利形态,各自消化掉自己不关心的东西。
ToolBase<TArgs,TResult> ← 唯一调度入口:execute(args, metadata)
├─ Tool<TArgs,TResult> ← 常规:只写 execute(args),metadata 被丢弃
│ └─ SimpleTool<TArgs> ← 更简单:结果就是 String,原样回给 LLM
└─ AgentContextAwareTool<...> ← 想要 AIAgentContext?从 metadata 里取
唯一入口是带 metadata 的那个。 ToolBase 只声明一个抽象方法,框架运行期永远调它(ToolBase.kt:97,execute(args: TArgs, metadata: ToolCallMetadata))。TArgs/TResult 是泛型,配一对 TypeToken(argsType/resultType)在运行期承载类型信息,用于编解码(ToolBase.kt:37-43)。
常规工具用 Tool,不必管 metadata。 Tool 把带 metadata 的重载 final override 掉,转发给只收参数的 execute(args),把 metadata 丢弃(Tool.kt:62-65)。所以 99% 的工具只需实现 execute(args): TResult。
// 真实源码 Tool.kt:64 —— 常规工具的 metadata 就是在这里被丢掉的
final override suspend fun execute(args: TArgs, metadata: ToolCallMetadata): TResult =
execute(args)
SimpleTool 再省一步: 它固定 TResult = String,并覆盖 encodeResultToString 为「原样返回」(SimpleTool.kt:22)——因为结果本身就是要给 LLM 的文本,不需要 JSON 序列化。
要运行时上下文的用 AgentContextAwareTool。 它住在 agents-core(不是 agents-tools),因为要依赖 AIAgentContext。它的 final override 从 metadata 里按保留键取出上下文,取不到就抛异常(说明这个工具被在 agent 运行之外错误地调用了),取到就转发给带 context 参数的重载(AgentContextAwareTool.kt:71-79)。这个「分层」是刻意的:agents-tools 不能反向依赖 agents-core,所以「要 context」这件事被下沉到 agents-core 里的子类(ToolBase.kt:22-27 的注释点明了这个设计动机)。
ToolCallMetadata 是什么: 一个 Map<String,Any?> 的只读包装(ToolCallMetadata.kt:18-20,用 Kotlin 的 by 委托直接实现 Map)。关键性质写在类注释里:它是严格附加的旁路——不属于参数 schema、不序列化给 LLM、不能用于路由或选工具(ToolCallMetadata.kt:3-9)。典型用途是 trace span id、correlation id。空实例是共享单例 EMPTY(ToolCallMetadata.kt:51),plus 做合并且后者覆盖前者(ToolCallMetadata.kt:26-30)。
ToolBase 上还挂了一整套编解码钩子,都是 open 可覆盖的:decodeArgs / encodeArgs(参数 JSON ↔ 对象)、encodeResult / encodeResultToString(结果 → JSON / 给 LLM 的文本)、encodeResultToParts(结果 → 多模态内容块,默认包一个文本)(ToolBase.kt:127-269)。运行期就是靠这几个钩子在 JSON 与强类型之间来回翻译。