承上:上一篇我们让AI学会了流式输出,体验已经和ChatGPT一样丝滑了。但它依然是个"嘴强王者"------能说会道,但查不了数据库、调不了接口、做不了任何实事。今天,我们要给AI装上第一只手。
1. 问题场景:AI的"信息孤岛"
先看一个让人血压升高的对话:

翻译成人话就是:"这事我干不了,你找别人吧。"
任何后端老鸟看到这个回答都会想:我明明有一个天气接口,AI为什么不能自己调?
答案很简单:AI根本不知道你有这个接口。它的世界就是一个黑盒子,除非你主动告诉它。
这就引出了今天的主角------Function Calling(函数调用) ,Spring AI中对应的是 @Tool 注解。
2. 核心概念:用类比理解Function Calling
用你最熟悉的场景来类比:
你招了一个实习生,他很聪明,但对你公司的系统一无所知。
你想让他帮忙查订单,就必须先告诉他:
- 有这个能力:"我们有一个查订单的接口"
- 怎么用:"入参是订单号,出参是订单状态、金额、时间"
- 什么时候用:"用户提到'查订单'、'订单状态'时,就调这个接口"
Function Calling就是干这三件事的:
- 声明工具 :用
@Tool注解标记一个方法 - 描述Schema :用
@ToolParam描述入参 - AI决策:AI根据用户问题,自动判断要不要调工具、传什么参数
3. 第一只手:查天气
3.1. 模拟天气服务
typescript
public class WeatherTools {
@Tool(description = "查询指定城市在指定日期的天气情况")
public String getWeather(@ToolParam(description = "城市名称,例如:北京、上海、深圳") String city) {
// 模拟数据
Map<String, String> weatherMap = Map.of(
"北京", "晴,25°C,微风",
"上海", "多云转阴,28°C,东南风3级",
"深圳", "雷阵雨,30°C,湿度85%",
"成都", "阴天,22°C,空气质量优"
);
String weather = weatherMap.getOrDefault(city, "数据暂未覆盖该城市");
return city + " 天气:" + weather;
}
}
拆解这个注解三件套:
| 注解 | 作用 | 类比 |
|---|---|---|
@Tool |
声明这是一个AI可调用的工具 | 告诉实习生"有查天气这个能力" |
description |
描述工具的用途 | 告诉实习生"这个接口是干什么的" |
@ToolParam |
描述每个参数的含义 | 告诉实习生"city是什么,date是什么格式" |
这些描述不是给人看的,是给AI看的。 AI会根据这些描述来决定:要不要调这个工具、传什么参数。
3.2. 注册工具
arduino
package com.yunxi.ai.service;
import com.yunxi.ai.tools.WeatherTools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Slf4j
@Service
public class ChatService {
private final ChatClient chatClient;
private final WeatherTools weatherTools;
public ChatService(ChatClient.Builder builder, WeatherTools weatherTools) {
this.chatClient = builder
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();
this.weatherTools = weatherTools;
}
/**
* 流式调用
*/
public Flux<String> chatFlux(String message) {
return chatClient.prompt()
.system("你是一个专业的助手,可以回答用户的问题")
.user(message)
.tools(weatherTools)
.stream()
.content()
.doOnCancel(() -> {
// 用户关闭了连接,可以在这里记录日志或释放资源
log.info("用户中断了对话,停止Token消耗");
});
}
}
3.3. 测试效果

发生了什么?
- AI分析用户问题,判断需要调用
getWeather - AI自动提取参数:
city=北京,date=今天日期 - AI调用工具,拿到结果:"北京 2026-07-23 天气:晴,25°C,微风"
- AI把结果组织成自然语言回复给用户
这是整个交互最巧妙的地方,由一个叫
ToolCallingAdvisor的组件负责,其工作就像一个"智能调度器",它会自动处理模型和工具之间的多次来回交互:
- 模型决策:接收到你的问题和工具清单后,模型会判断是否需要调用工具。
- 发起调用请求:如果需要,模型会返回一个结构化的指令,指明要调用的工具名和参数。
- 框架执行 :
ToolCallingAdvisor拦截到这个指令,找到对应的本地 Java 方法,传参并执行。- 结果回传 :执行结果被返回给
ToolCallingAdvisor,它再将结果作为"新上下文"发回给模型。- 生成最终回答:模型拿到工具返回的真实数据后,才能生成最终的自然语言回复
这就是Function Calling的完整闭环。
4. 看AI到底调了啥
我们先看一下一次对话,客户端和大模型的交互日志:
perl
2026-07-24T11:32:24.507+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.servlet.DispatcherServlet : GET "/chat/call?message=%E5%8C%97%E4%BA%AC%E7%9A%84%E5%A4%A9%E6%B0%94", parameters={masked}
2026-07-24T11:32:24.508+08:00 DEBUG 12511 --- [nio-9999-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.yunxi.ai.controller.ChatController#chatCall(String)
2026-07-24T11:32:24.523+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.client.DefaultRestClient : Writing [ChatCompletionRequest[messages=[ChatCompletionMessage[rawContent=你是一个专业的助手,可以回答用户的问题, role=SYSTEM, name=null, toolCallId=null, toolCalls=null, refusal=null, audioOutput=null, annotations=null], ChatCompletionMessage[rawContent=北京的天气, role=USER, name=null, toolCallId=null, toolCalls=null, refusal=null, audioOutput=null, annotations=null]], model=deepseek-v4-pro, store=null, metadata=null, frequencyPenalty=null, logitBias=null, logprobs=null, topLogprobs=null, maxTokens=null, maxCompletionTokens=null, n=null, outputModalities=null, audioParameters=null, presencePenalty=null, responseFormat=null, seed=null, serviceTier=null, stop=null, stream=false, streamOptions=null, temperature=0.7, topP=null, tools=[org.springframework.ai.openai.api.OpenAiApi$FunctionTool@3f3d5fc3], toolChoice=null, parallelToolCalls=null, user=null, reasoningEffort=null, webSearchOptions=null]] as "application/json" with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
2026-07-24T11:32:25.776+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.client.DefaultRestClient : Reading to [org.springframework.ai.openai.api.OpenAiApi$ChatCompletion]
2026-07-24T11:32:25.777+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.a.m.tool.DefaultToolCallingManager : Executing tool call: getWeather
2026-07-24T11:32:25.778+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.ai.tool.method.MethodToolCallback : Starting execution of tool: getWeather
2026-07-24T11:32:25.778+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.ai.tool.method.MethodToolCallback : Successful execution of tool: getWeather
2026-07-24T11:32:25.778+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.a.t.e.DefaultToolCallResultConverter : Converting tool result to JSON.
2026-07-24T11:32:25.780+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.client.DefaultRestClient : Writing [ChatCompletionRequest[messages=[ChatCompletionMessage[rawContent=你是一个专业的助手,可以回答用户的问题, role=SYSTEM, name=null, toolCallId=null, toolCalls=null, refusal=null, audioOutput=null, annotations=null], ChatCompletionMessage[rawContent=北京的天气, role=USER, name=null, toolCallId=null, toolCalls=null, refusal=null, audioOutput=null, annotations=null], ChatCompletionMessage[rawContent=好的,我来帮您查询北京的天气。, role=ASSISTANT, name=null, toolCallId=null, toolCalls=[ToolCall[index=null, id=call_00_8gwC0nhjGCrlHMcUg1KF6313, type=function, function=ChatCompletionFunction[name=getWeather, arguments={"city": "北京"}]]], refusal=null, audioOutput=null, annotations=null], ChatCompletionMessage[rawContent="北京 天气:晴,25°C,微风", role=TOOL, name=getWeather, toolCallId=call_00_8gwC0nhjGCrlHMcUg1KF6313, toolCalls=null, refusal=null, audioOutput=null, annotations=null]], model=deepseek-v4-pro, store=null, metadata=null, frequencyPenalty=null, logitBias=null, logprobs=null, topLogprobs=null, maxTokens=null, maxCompletionTokens=null, n=null, outputModalities=null, audioParameters=null, presencePenalty=null, responseFormat=null, seed=null, serviceTier=null, stop=null, stream=false, streamOptions=null, temperature=0.7, topP=null, tools=[org.springframework.ai.openai.api.OpenAiApi$FunctionTool@4b8f7cbf], toolChoice=null, parallelToolCalls=null, user=null, reasoningEffort=null, webSearchOptions=null]] as "application/json" with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
2026-07-24T11:32:27.775+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.client.DefaultRestClient : Reading to [org.springframework.ai.openai.api.OpenAiApi$ChatCompletion]
2026-07-24T11:32:30.525+08:00 DEBUG 12511 --- [nio-9999-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain, */*, application/json, application/*+json]
2026-07-24T11:32:30.526+08:00 DEBUG 12511 --- [nio-9999-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Writing ["北京今天的天气情况如下:<EOL><EOL>- **天气**:晴 ☀️<EOL>- **温度**:25°C<EOL>- **风力**:微风<EOL><EOL>天气不错,非常适合出行!请问还有什么可以帮您的吗?"]
2026-07-24T11:32:30.528+08:00 DEBUG 12511 --- [nio-9999-exec-2] o.s.web.servlet.DispatcherServlet : Completed 200 OK
4.1. 🔍 日志分步解读
4.1.1. 接收请求和准备第一轮请求
sql
GET "/chat/call?message=北京的天气"
...
Writing [ChatCompletionRequest... messages=..., tools=[...]]
- 含义 :你发起了"北京的天气"这个请求,Spring AI 把它和工具说明(
tools=[...])一起打包,准备发给 DeepSeek 模型。 - 关键点 :此时请求体里包含了你的问题(
USER)和工具定义(tools),但还没有任何toolCalls。
4.1.2. 模型决策 & 工具执行 ✅
yaml
Executing tool call: getWeather
Starting execution of tool: getWeather
Successful execution of tool: getWeather
- 含义 :这是最核心的部分!DeepSeek 模型收到了工具清单后,决定调用
getWeather工具,Spring AI 自动执行了你本地的 Java 方法。 - 关键点 :这三行日志就是你本地工具被成功调用的铁证。说明模型决策、框架拦截、方法反射执行,整个链路是通的。
4.1.3. 第二轮请求(带工具结果)
css
Writing [ChatCompletionRequest... messages=[ ..., ChatCompletionMessage[rawContent=好的,我来帮您查询北京的天气。, role=ASSISTANT, toolCalls=[ToolCall...]],
ChatCompletionMessage[rawContent="北京 天气:晴,25°C,微风", role=TOOL, ...]
]]
- 含义:这是第二轮请求的日志。Spring AI 把以下内容再次发给 DeepSeek:
-
- 历史对话(系统提示 + 你的问题)
- 模型第一轮的决策 (
role=ASSISTANT,内含toolCalls) - 你本地工具的执行结果 (
role=TOOL,内容是"北京 天气:晴,25°C,微风")
- 关键点 :这就是你之前问"为什么
toolCalls是空的"的答案 ------toolCalls出现在第二轮请求的"历史消息"里 ,而不是最终响应的ChatResponse里。
4.1.4. 生成最终回答
css
Reading to [ChatCompletion...]
Writing ["北京今天的天气情况如下:..."]
Completed 200 OK
- 含义:DeepSeek 模型基于你工具返回的真实数据(晴,25°C),润色生成了最终的自然语言回答,返回给你。
4.2. 📊 Spring AI + DeepSeek 工具调用完整交互图
bash
┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户 │ │ Spring AI │ │ DeepSeek │ │ 本地工具 │
│ (Client) │ │ 框架层 │ │ 模型 │ │ (Java方法) │
└────┬────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ ① GET /chat? │ │ │
│ message=北京的天气│ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ ② 构建 Prompt │ │
│ │ - System Msg │ │
│ │ - User Msg │ │
│ │ - Tools (说明书) │ │
│ │ │ │
│ │ ③ POST /v1/chat/ │ │
│ │ completions │ │
│ │ (第一轮请求) │ │
│ │───────────────────>│ │
│ │ │ │
│ │ │ ④ 模型推理决策 │
│ │ │ "需要调用 │
│ │ │ getWeather" │
│ │ │ │
│ │ ⑤ 返回 tool_calls │ │
│ │ getWeather(北京) │ │
│ │<───────────────────│ │
│ │ │ │
│ │ ⑥ 解析 tool_calls │ │
│ │ "model 想调用 │ │
│ │ getWeather" │ │
│ │ │ │
│ │ ⑦ 反射调用本地方法 │ │
│ │─────────────────────────────────────────>│
│ │ │ │
│ │ ⑧ 执行结果返回 │ │
│ │ "晴, 25°C, 微风" │ │
│ │<─────────────────────────────────────────│
│ │ │ │
│ │ ⑨ 构建第二轮请求 │ │
│ │ - 历史消息 │ │
│ │ - tool_calls │ │
│ │ - tool 执行结果 │ │
│ │ │ │
│ │ ⑩ POST /v1/chat/ │ │
│ │ completions │ │
│ │ (第二轮请求) │ │
│ │───────────────────>│ │
│ │ │ │
│ │ │ ⑪ 模型生成最终回答 │
│ │ │ 基于工具结果 │
│ │ │ 润色成自然语言 │
│ │ │ │
│ │ ⑫ 返回最终回答 │ │
│ │ "北京天气晴朗..." │ │
│ │<───────────────────│ │
│ │ │ │
│ ⑬ 返回最终结果 │ │ │
│ "北京天气晴朗..."│ │ │
│<─────────────────│ │ │
│ │ │ │
| 步骤 | 时间戳 | 事件 | 说明 |
|---|---|---|---|
| ① | 11:32:24.507 | 用户发起请求 | GET /chat/call?message=北京的天气 |
| ③ | 11:32:24.523 | 第一轮请求发出 | 携带 tools工具说明 |
| ⑤ | 11:32:25.776 | 收到模型决策 | 模型返回 tool_calls |
| ⑥-⑧ | 11:32:25.777-778 | 执行本地工具 | DefaultToolCallingManager+ MethodToolCallback |
| ⑩ | 11:32:25.780 | 第二轮请求发出 | 携带工具执行结果 |
| ⑫ | 11:32:27.775 | 收到最终回答 | 模型基于工具结果生成 |
| ⑬ | 11:32:30.525 | 返回给用户 | 最终输出 |
5. 多个@Tool方法:一个类暴露多个工具
less
@Component
public class CommonTools {
@Tool(description = "查询指定城市的天气")
public String getWeather(
@ToolParam(description = "城市名称") String city,
@ToolParam(description = "日期") LocalDate date) {
// ...
}
@Tool(description = "查询指定城市的限行规则")
public String getTrafficRule(
@ToolParam(description = "城市名称") String city) {
return city + "今日限行尾号:3和8";
}
@Tool(description = "获取当前服务器时间")
public String getCurrentTime() {
return LocalDateTime.now().toString();
}
}
AI会根据用户问题自动选择:
- "今天天气怎么样" → 调
getWeather - "今天限行吗" → 调
getTrafficRule - "现在几点" → 调
getCurrentTime - "今天天气和限行都说一下" → 可能依次调用多个工具
6. 本篇避坑指南
6.1. 坑1:AI不调用工具,直接瞎编答案
AI有时候会"懒",明明有工具却不调用,直接编一个答案。
解决 :在 @Tool(description) 里写得足够明确,必要时在SystemMessage里强调"必须调用工具获取实时数据,不要编造"。
6.2. 坑2:AI编造参数
用户问"火星天气",AI也可能传 city=火星。如果你的工具不支持,返回错误提示即可。
6.3. 坑3:参数类型不匹配
@ToolParam 标注了 LocalDate date,但AI传了 "今天"。Spring AI会尽量做类型转换,但复杂的日期格式建议用 String 接收后手动解析。
6.4. 坑4:Tool方法执行时间过长
工具调用默认有超时限制。如果调第三方接口很慢,考虑异步处理或设置合理的超时时间。
6.5. 坑5:忘记注册Tools
scss
// 错误:忘了加 .tools()
chatClient.prompt().user(question).call().content();
// 正确
chatClient.prompt().user(question).tools(weatherTools).call().content();
忘了加 .tools(),AI不会调用任何工具,直接靠自己回答。这是新手最容易犯的错。
7. 本篇小结
这一篇我们让AI长出了第一只手:
| 关键点 | 说明 |
|---|---|
@Tool |
声明一个AI可调用的工具方法 |
@ToolParam |
描述参数的Schema,AI据此传参 |
.tools(bean) |
注册工具到ChatClient |
| 自动决策 | AI根据用户问题判断要不要调工具、传什么参数 |
从这一篇开始,AI不再是"嘴炮",它能真正干活了。
但一个工具还不够。下一篇,我们要给AI装上"瑞士军刀"------同时暴露查订单、查库存、查物流三个工具,让它自己决定该用哪个。
本文与DeepSeek协作完成