一、为什么需要 Tools
LLM 只会"说话",不会"做事",也不知道实时信息。Tools 让模型通过结构化输入直接对接外部系统(API、数据库、文件系统)。
两类典型用途:
| 类别 | 目标 | 例子 |
|---|---|---|
| 信息检索 | 增强模型知识,回答原本答不了的问题(RAG) | 查天气、查新闻、查数据库记录 |
| 执行操作 | 自动化原本需人工或硬编码的任务 | 发邮件、建订单、订机票、按 TDD 生成 Java 类 |
关键安全认知
Model 永远无法访问你提供的 API,它只能"请求"调用并给出参数;真正执行的是客户端应用程序。
这是工具调用最重要的安全边界 ------ 权限、鉴权、副作用控制全在你手里。
另注:OpenAI / Anthropic / Gemini 等有服务器端内置工具(Web 搜索、代码解释器),属厂商能力,与本节的客户端工具体系不同。
二、Tool Calling 的六步序列
① 聊天请求中携带 tool 定义(名称 + 描述 + 输入 schema)
② 模型决定调用 → 返回 tool 名 + 符合 schema 的参数 [AssistantMessage.toolCalls]
③ 应用按名称找到 ToolCallback 并用参数执行它 [ToolCallingManager]
④ 应用处理执行结果
⑤ 应用把结果作为 ToolResponseMessage 发回模型
⑥ 模型结合结果生成最终响应
核心接口:ToolCallback ;生命周期管理者:ToolCallingManager。
三、定义工具的四种方式
3.1 声明式:@Tool 注解(最推荐)
java
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(@ToolParam(description = "Time in ISO-8601 format") String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
System.out.println("Alarm set for " + alarmTime);
}
}
String response = ChatClient.create(chatModel)
.prompt("Can you set an alarm 10 minutes from now?")
.tools(new DateTimeTools()) // 传入实例
.call().content();
@Tool 四个属性
| 属性 | 说明 |
|---|---|
name |
默认取方法名;同一请求内必须唯一 |
description |
强烈建议必填,决定模型何时/如何调用;缺失会导致"该用不用"或"用错" |
returnDirect |
结果是否直接返回调用者而不回传模型 |
resultConverter |
自定义结果序列化器 |
规则 :方法可静态/实例、任意可见性;参数支持基本类型、POJO、枚举、List、数组、Map 等;返回值必须可序列化(可为 void)。
AOT:若类不是 Spring bean,需加
@RegisterReflection(memberCategories = MemberCategory.INVOKE_DECLARED_METHODS)。
@ToolParam :description(格式/取值说明)+ required(默认 true)。加 @Nullable 且未显式标 required 则视为可选。
3.2 编程式:MethodToolCallback
java
Method method = ReflectionUtils.findMethod(DateTimeTools.class, "getCurrentDateTime");
ToolCallback toolCallback = MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.builder(method)
.description("Get the current date and time in the user's timezone")
.build())
.toolMethod(method)
.toolObject(new DateTimeTools()) // 静态方法可省略此行
.build();
Builder 参数 :toolDefinition(必需)、toolMetadata、toolMethod(必需)、toolObject、toolCallResultConverter。
⚠️ 方法 Tool 不支持的类型 :Optional、异步类型(CompletableFuture/Future)、响应式类型(Flow/Mono/Flux)、函数类型。
3.3 函数式:FunctionToolCallback
java
public class WeatherService implements Function<WeatherRequest, WeatherResponse> {
public WeatherResponse apply(WeatherRequest request) { return new WeatherResponse(30.0, Unit.C); }
}
public record WeatherRequest(String location, Unit unit) {}
public record WeatherResponse(double temp, Unit unit) {}
ToolCallback toolCallback = FunctionToolCallback
.builder("currentWeather", new WeatherService())
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
// 挂到 ChatClient
ChatClient.create(chatModel).prompt("...").toolCallbacks(toolCallback).call().content();
// 挂到 ChatModel(走 ChatOptions)
ChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(toolCallback).build();
chatModel.call(new Prompt("What's the weather like in Copenhagen?", chatOptions));
支持 Function / Supplier / Consumer / BiFunction;输入输出必须是可序列化的 public POJO (也可 Void)。
⚠️ 函数 Tool 额外不支持:基本类型、集合类型(List/Map/Array/Set)→ 这些请改用「方法 Tool」。
3.4 动态式:@Bean + ToolCallbackResolver
java
@Configuration(proxyBeanMethods = false)
class WeatherTools {
public static final String CURRENT_WEATHER_TOOL = "currentWeather";
@Bean(CURRENT_WEATHER_TOOL)
@Description("Get the weather in location")
Function<WeatherRequest, WeatherResponse> currentWeather() { return new WeatherService(); }
}
// 使用时只给名字
ChatClient.create(chatModel).prompt("...").toolNames("currentWeather").call().content();
ChatClient.builder(chatModel).defaultToolNames("currentWeather").build();
- bean 名即工具名,建议用常量存名字(避免硬编码 + 运行时解析的类型不安全)。
- 解析器:
DelegatingToolCallbackResolver→SpringBeanToolCallbackResolver(找 Function 类 bean)+StaticToolCallbackResolver(找 ToolCallback 类 bean)。
四、Tool 规范三件套
ToolCallback
java
public interface ToolCallback {
ToolDefinition getToolDefinition(); // 名称+描述+输入schema
ToolMetadata getToolMetadata(); // returnDirect 等附加设置
String call(String toolInput);
String call(String toolInput, ToolContext toolContext);
}
ToolDefinition
java
public interface ToolDefinition {
String name();
String description();
String inputSchema();
}
可手工写 schema:
java
ToolDefinition toolDefinition = ToolDefinition.builder()
.name("currentWeather")
.description("Get the weather in location")
.inputSchema("""
{"type":"object","properties":{
"location":{"type":"string"},
"unit":{"type":"string","enum":["C","F"]}},
"required":["location","unit"]}
""")
.build();
JSON Schema 定制
| 目的 | 可用注解(按优先级) |
|---|---|
| 描述 | @ToolParam(description) / @JsonClassDescription / @JsonPropertyDescription / @Schema(description) |
| 可选性 | @ToolParam(required=false) / @JsonProperty(required=false) / @Schema(required=false) / @Nullable |
警告 :必需性设置错误会诱发幻觉。必填参数模型拿不到值时可能编造一个 → 无值真的可选就标
required = false。
五、结果转换与 returnDirect
结果转换
默认用 DefaultToolCallResultConverter(Jackson 转 JSON)把返回值序列化为 String 回传模型。
java
@Tool(description = "Retrieve customer information", resultConverter = CustomToolCallResultConverter.class)
Customer getCustomerInfo(Long id) { ... }
returnDirect(绕过模型)
默认结果回传模型继续推理;设为 true 则直接返回调用者,适合:RAG 检索结果不想再加工、某些工具应终止 Agent 循环。
java
@Tool(description = "Retrieve customer information", returnDirect = true)
Customer getCustomerInfo(Long id) { ... }
// 编程式
ToolMetadata.builder().returnDirect(true).build();
⚠️ 一次请求多个 tool call 时,必须全部 returnDirect=true 才直接返回,否则仍回传模型。
六、Tool 执行:框架控制 vs 用户控制
框架控制(默认)
internalToolExecutionEnabled = true(默认),DefaultToolCallingManager 在 ChatModel 内部透明完成:拦截 → 调用 → 回传 → 生成最终响应。
java
public interface ToolCallingManager {
List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions);
ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse);
}
⚠️ 内部往返消息不暴露给用户 ;想看到工具调用细节请改用用户控制模式。
自定义:@Bean ToolCallingManager toolCallingManager() { return ToolCallingManager.builder().build(); }
资格判定可换:ToolExecutionEligibilityPredicate。
用户控制(需要审计/人工确认时)
java
ChatOptions chatOptions = ToolCallingChatOptions.builder()
.toolCallbacks(new CustomerTools())
.internalToolExecutionEnabled(false) // 关键开关
.build();
Prompt prompt = new Prompt("Tell me more about the customer with ID 42", chatOptions);
ChatResponse chatResponse = chatModel.call(prompt);
while (chatResponse.hasToolCalls()) {
ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, chatResponse);
prompt = new Prompt(result.conversationHistory(), chatOptions);
chatResponse = chatModel.call(prompt);
}
System.out.println(chatResponse.getResult().getOutput().getText());
七、异常处理
工具抛异常 → 包装为 ToolExecutionException → 由 ToolExecutionExceptionProcessor 决定「转成错误消息回给模型」还是「抛给调用者」。
java
@FunctionalInterface
public interface ToolExecutionExceptionProcessor {
String process(ToolExecutionException exception);
}
默认策略(DefaultToolExecutionExceptionProcessor):
RuntimeException→ 错误消息回传模型(让模型自行重试/换策略)- 检查异常与 Error(
IOException、OutOfMemoryError)→ 总是抛出
全局开关:
| 属性 | 说明 | 默认 |
|---|---|---|
spring.ai.tools.throw-exception-on-error |
true=抛异常给调用者;false=转成消息回给模型 | false |
java
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(true);
}
自定义
ToolCallback实现时,务必在call()中抛ToolExecutionException。这与 ReactAgent 里的
ToolInterceptor错误兜底是同一思想:把失败变成模型可读的信息,而不是让整条链路崩掉。
八、可观测性与日志
- 观测点:
spring.ai.tool(耗时、trace 传播) - tool 参数与结果导出为 span 属性:默认关闭(敏感信息)
- 日志:
org.springframework.ai设为DEBUG即可看到全部 tool calling 关键操作
九、ToolContext:让工具"有记忆、知上下文"
工具签名里加 ToolContext 参数即可自动注入,且对 LLM 隐藏(模型只看到 input 的 schema)。
它提供五类信息:
| 能力 | 说明 |
|---|---|
| State | 执行中流动的可变数据(消息、计数器、自定义字段) |
| Context | 不可变配置:user_id、会话信息、应用配置 |
| Store | 跨对话的长期记忆 |
| Config | RunnableConfig |
| Tool Call ID | 当前调用 ID |
访问 State
java
public class ConversationSummaryTool implements BiFunction<String, ToolContext, String> {
@Override
public String apply(String input, ToolContext toolContext) {
OverAllState state = (OverAllState) toolContext.getContext().get("state");
RunnableConfig config = (RunnableConfig) toolContext.getContext().get("config");
Map<String, Object> extraState = (Map<String, Object>) toolContext.getContext().get("extraState");
List<Message> messages = (List<Message>) state.get("messages", new ArrayList<>());
// 统计 user/assistant/tool 消息数 ...
return "Conversation has %d user messages, %d AI responses, %d tool results".formatted(u, a, t);
}
}
访问 Context(拿 user_id)
java
public class AccountInfoTool implements BiFunction<String, ToolContext, String> {
@Override
public String apply(String query, ToolContext toolContext) {
RunnableConfig config = (RunnableConfig) toolContext.getContext().get("config");
String userId = (String) config.metadata("user_id").orElse(null);
...
}
}
// 调用侧注入
RunnableConfig config = RunnableConfig.builder().addMetadata("user_id", "1");
agent.call("question", config);
更新 State
通过 Hook 在 AFTER_MODEL 等位置返回 Map 来更新:
java
@Override
public CompletableFuture<Map<String, Object>> afterModel(OverAllState state, RunnableConfig config) {
return CompletableFuture.completedFuture(Map.of(
"user_name", "Alice", "last_updated", System.currentTimeMillis()));
}
长期记忆
java
RedisSaver redisSaver = new RedisSaver(redissonClient);
ReactAgent agent = ReactAgent.builder().name("my_agent").model(chatModel)
.tools(saveUserInfoTool, getUserInfoTool)
.saver(redisSaver).build();
agent.call("Save user: id=abc123, name=Foo...", RunnableConfig.builder().threadId("session_1").build());
agent.call("Get user info for 'abc123'", RunnableConfig.builder().threadId("session_2").build());
// 跨 session 仍能取到 → 这就是 Store 的价值
十、在 ReactAgent 中提供工具的 6 种方式
| 方式 | 用法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
tools() |
直接传 ToolCallback 实例 |
工具 < 5 个、编译期确定 | 简单、类型安全 | 多了代码冗长 |
methodTools() |
传带 @Tool 的对象 |
工具逻辑成组、需访问成员变量 | 组织清晰 | 要写工具类 |
toolCallbackProviders() |
实现 ToolCallbackProvider |
运行时动态决定工具集 | 灵活 | 需实现接口 |
toolNames() + resolver() |
只给名字 | 定义与使用解耦、配置化 | 解耦 | 必须配 resolver,否则抛异常 |
resolver() |
自定义解析逻辑 | 多来源统一管理 | 高度灵活 | 需实现解析器 |
| 组合使用 | 以上混用 | 复杂/渐进迁移 | 最大灵活 | 复杂度上升 |
java
ReactAgent agent = ReactAgent.builder()
.name("combined_tool_agent").model(chatModel)
.methodTools(calculatorTools) // @Tool 类
.toolCallbackProviders(toolProvider) // 动态提供者
.tools(searchTool) // 直接实例
.saver(new MemorySaver())
.build();
AssistantMessage response = agent.call("What's the weather like in San Francisco?");
十一、接入远程 MCP 工具
多数场景不必自己造工具,直接接 MCP Server(魔搭 ModelScope、百炼等平台)。
方式一:Spring Boot 自动发现(推荐)
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
yaml
spring:
ai:
mcp:
client:
enabled: true
name: saa-mcp-client
toolcallback:
enabled: true
type: async
streamable-http:
connections:
amap-maps:
url: ${MODEL_SCOPE_AMAP_BASE_URL}
endpoint: mcp
sse:
connections:
12306-mcp:
url: ${MODEL_SCOPE_12306_BASE_URL}
sse-endpoint: sse
java
@Service
public class RemoteMcpToolsExample {
private final ToolCallbackProvider toolCallbackProvider;
public void run() throws GraphRunnerException {
Builder builder = ReactAgent.builder()
.name("travel_planning_assistant").model(chatModel)
.instruction("You are a helpful assistant with travel route planning and train ticket search.")
.saver(new MemorySaver());
builder.toolCallbackProviders(toolCallbackProvider); // 一行接入全部远程工具
ReactAgent agent = builder.build();
...
}
}
方式二:MCP SDK 手工构建(脱离 Spring Boot)
关键四步:
- 创建传输层
HttpClientSseClientTransport/HttpClientStreamableHttpTransport - 构建并初始化
McpSyncClient listTools()拉取远程工具列表- 逐个转成
FunctionToolCallback
java
private ToolCallback createToolCallback(McpSchema.Tool mcpTool, McpSyncClient mcpClient, String serverName) {
return FunctionToolCallback.builder(mcpTool.name(),
(Map<String, Object> functionInput) -> { /* 真正发起 MCP 调用 */ })
.description(mcpTool.description())
.inputType(Map.class)
.build();
}
记得在
finally里逐个client.close()。
流式下区分模型/工具输出
java
stream.doOnNext(output -> {
if (output.node().equals("_AGENT_MODEL_")) {
answer.append(((StreamingOutput<?>) output).message().getText());
} else if (output.node().equals("_AGENT_TOOL_")) {
answer.append("Tool Call:").append(
((ToolResponseMessage) ((StreamingOutput<?>) output).message()).getResponses().get(0));
}
}).blockLast();
十二、速查卡
java
// 1) 声明式定义
@Tool(description = "一句话说清何时用、怎么用")
String xxx(@ToolParam(description = "格式说明") String p) { ... }
// 2) 挂到 ChatClient
ChatClient.create(chatModel).prompt(q).tools(new XxxTools()).call().content();
// 3) 挂到 ChatModel
new Prompt(q, ToolCallingChatOptions.builder().toolCallbacks(cb).build());
// 4) 用户控制执行
.internalToolExecutionEnabled(false) + while (resp.hasToolCalls()) { ... executeToolCalls ... }
// 5) 拿上下文(BiFunction 第二参)
BiFunction<String, ToolContext, String> → toolContext.getContext().get("config" / "state" / "extraState")
// 6) ReactAgent
ReactAgent.builder().tools(t1, t2).methodTools(obj).toolCallbackProviders(p).toolNames(n).resolver(r)
八条铁律
description决定一切 ------ 写得含糊,模型就不会用或乱用。- 工具名在同一请求内必须唯一。
- 函数式工具不支持基本类型与集合,用 POJO 或改方法式。
- 方法式工具不支持 Optional / 异步 / 响应式类型。
- 参数默认必填;确实可缺省的标
required=false,否则诱发幻觉。 - 多个 tool call 同批返回时,全部 returnDirect=true 才直接返回给调用者。
- 自定义
ToolCallback要在call()抛ToolExecutionException。 toolNames()必须配resolver()。
十三、对照本项目(xs-interview-agent)
当前项目完全没有使用工具调用,三次 LLM 调用都是纯 Prompt 驱动。最值得加的三个工具:
| 工具 | 类型 | 作用 | 实现要点 |
|---|---|---|---|
queryQuestionBank(category) |
信息检索 | 从本地题库/ES 召回真实高频题,再让模型结合简历改写 | 解决"纯生成题目可能不专业"的问题;入参 record:category、difficulty、count |
saveInterviewReport(...) |
执行操作 | 评估报告落 MySQL,替代 ConcurrentHashMap |
可设 returnDirect = true,无需回传模型二次加工 |
searchJD(keyword) |
信息检索 | 拉岗位 JD,让简历评分对照真实招聘需求 | 正好呼应 resume-analysis-system.st 里"招聘需求提到的加分项" |
落地最小改动 :出题环节在 MockInterviewService 里从 chatModel.call(prompt) 换成带工具的调用即可:
java
ChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(questionBankTool) // 题库工具
.build();
// 或迁到 ReactAgent:.tools(questionBankTool).outputType(InterviewQuestions.class)
若想借助平台现成能力(如联网搜索最新技术趋势来出题),按第十一节加 spring-ai-starter-mcp-client 后 builder.toolCallbackProviders(toolCallbackProvider) 一行接入即可,不必自己写工具。