1. 本章目标
第一章我们搭建了一个能聊天的 Agent 控制台,但它只会"动嘴",不会"动手"。这一章我们要给它装上"手"——让它能调用外部工具:
- 查询当前时间:
getCurrentTime(timezone) - 查询天气:
getWeather(city)、getForecast(city, days) - 数学计算:
calculator(expression)
最终效果:用户在对话框中输入"武汉的天气怎么样?",Agent 会自动调用天气工具查询武汉天气,并将结构化数据转化为自然语言回复。
2.1 本章目标
第一章我们搭建了一个能聊天的 Agent 控制台,但它只会"动嘴",不会"动手"。这一章我们要给它装上"手"——让它能调用外部工具:
- 查询当前时间:
getCurrentTime() - 查询天气:
getWeather(city) - 数学计算:
calculator(expression)
最终效果:用户在对话框中输入"武汉明天适合跑步吗",Agent 会自动调用天气工具查询武汉天气,结合天气预报判断是否适合户外运动。
2.2 什么是 Function Calling?
2.2.1 直观理解
没有 Function Calling 的 AI:
用户:武汉今天多少度? AI:抱歉,我的训练数据截止到2025年,无法获取实时天气。有 Function Calling 的 AI:
用户:武汉今天多少度? AI:(内心:我需要查天气工具) AI:(调用 getWeather("武汉") → 返回 "28°C,晴") AI:武汉今天28°C,天气晴朗,非常适合外出!2.2.2 工作原理
┌─────────────────────────────────────────────────────────┐ │ Function Calling 流程 │ ├─────────────────────────────────────────────────────────┤ │ │ │ 用户:"武汉天气怎么样?" │ │ │ │ │ ▼ │ │ 1. 发送消息 + 工具描述给 AI │ │ ┌─────────────────────────────────────┐ │ │ │ messages: [{"role":"user", │ │ │ │ "content":"武汉天气?"}] │ │ │ │ tools: [{"name":"getWeather", │ │ │ │ "description":"查询天气", │ │ │ │ "parameters":{...}}] │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 2. AI 决定调用工具,返回工具调用请求 │ │ ┌─────────────────────────────────────┐ │ │ │ finish_reason: "tool_calls" │ │ │ │ tool_calls: [{ │ │ │ │ "function": { │ │ │ │ "name": "getWeather", │ │ │ │ "arguments": {"city": "武汉"} │ │ │ │ } │ │ │ │ }] │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 3. 应用程序执行工具函数 │ │ getWeather("武汉") → "28°C,晴" │ │ │ │ │ ▼ │ │ 4. 将工具结果发送回 AI │ │ ┌─────────────────────────────────────┐ │ │ │ tool_call_id: "call_xxx" │ │ │ │ content: "28°C,晴" │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 5. AI 基于工具结果生成自然语言回复 │ │ "武汉今天28°C,天气晴朗..." │ │ │ └─────────────────────────────────────────────────────────┘2.2.3 关键概念
概念 | 说明 | 类比 |
|---|---|---|
Tool Definition | 工具的 JSON Schema 描述 | 函数的"说明书" |
Tool Call | AI 决定调用哪个工具 | 函数调用指令 |
Arguments | 调用工具时的参数 | 函数实参 |
Tool Result | 工具执行的返回结果 | 函数返回值 |
2.3 新增文件结构
agent-console/ ├── pom.xml (不变) └── src/main/ ├── java/com/example/agent/ │ ├── AgentApplication.java (不变) │ ├── config/ │ │ ├── AgentConfig.java (修改:注册工具) │ │ └── ToolConfig.java ★ 新增:工具配置 │ ├── controller/ │ │ └── ChatController.java (修改:支持工具调用) │ ├── service/ │ │ └── ConversationService.java (不变) │ ├── model/ │ │ ├── ChatMessage.java (不变) │ │ ├── Conversation.java (不变) │ │ ├── ChatRequest.java (不变) │ │ └── CreateConversationRequest.java (不变) │ └── tool/ ★ 新增:工具包 │ ├── WeatherTool.java ★ 新增:天气工具 │ ├── TimeTool.java ★ 新增:时间工具 │ └── CalculatorTool.java ★ 新增:计算器工具 └── resources/ ├── application.yml (不变) └── static/ ├── index.html (修改:显示工具调用) └── style.css ★ 新增:工具调用样式2.4 完整源码
2.4.1 TimeTool.java —— 时间工具
package com.example.agent.tool; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Set; /** * TimeTool:时间相关的工具集合 * * @Component 让 Spring 管理这个 Bean * Spring AI 会扫描所有带有 @Tool 注解的方法,自动注册为 AI 可调用的工具 * * 注意:@Tool 注解是 Spring AI 2.0 新增的 * 1.x 使用的是 @ToolCallback 或 ToolCallbackProvider * 2.0 简化了工具注册流程 */ @Component public class TimeTool { /** 支持的时区集合,限制常用时区避免无效输入 */ private static final Set<String> SUPPORTED_ZONES = Set.of( "Asia/Shanghai", // 中国标准时间 (UTC+8) "America/New_York", // 美国东部时间 "Europe/London", // 英国伦敦时间 "Asia/Tokyo", // 日本东京时间 "Asia/Singapore" // 新加坡时间 ); /** * getCurrentTime:获取指定时区的当前日期和时间 * * @Tool 注解声明这是一个 AI 可调用的工具 * name:工具名称,AI 通过这个名字来调用 * description:工具描述,AI 根据描述判断何时使用 * * @param timezone 时区名称,例如 "Asia/Shanghai",不传则默认上海时区 * @return 格式化后的时间字符串 */ @Tool(name = "getCurrentTime", description = """ 获取指定时区的当前日期和时间。 支持的中国时区:Asia/Shanghai 如果不指定时区,默认返回北京时间。 返回格式:YYYY-MM-DD HH:mm:ss """) public String getCurrentTime( @ToolParam(description = """ 时区名称,例如: - Asia/Shanghai(北京时间) - America/New_York(纽约时间) - Europe/London(伦敦时间) 如果不传,默认为 Asia/Shanghai """) String timezone) { // 处理默认时区 if (timezone == null || timezone.isBlank()) { timezone = "Asia/Shanghai"; } // 验证时区是否在支持列表中 if (!SUPPORTED_ZONES.contains(timezone)) { return String.format("不支持的时区:%s。支持的时区:%s", timezone, String.join(", ", SUPPORTED_ZONES)); } try { // 获取指定时区的当前时间并格式化输出 ZoneId zone = ZoneId.of(timezone); LocalDate date = LocalDate.now(zone); LocalTime time = LocalTime.now(zone); DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); return String.format("当前时间(%s):%s %s", timezone, date.format(dateFormatter), time.format(timeFormatter)); } catch (Exception e) { return "获取时间失败:" + e.getMessage(); } } }2.4.2 WeatherTool.java —— 天气工具
package com.example.agent.tool; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; /** * WeatherTool:天气查询工具 * * 这是一个模拟实现,聚焦于演示 Function Calling 的核心机制 * 真实项目中应接入天气 API(如和风天气、OpenWeatherMap) */ @Component public class WeatherTool { /** 模拟城市天气数据:温度、天气状况、湿度、风力 */ private static final Map<String, Map<String, Object>> WEATHER_DATA = Map.ofEntries( Map.entry("Beijing", Map.of("temp", 22, "condition", "Sunny", "humidity", 45, "wind", 3)), Map.entry("Shanghai", Map.of("temp", 26, "condition", "Cloudy", "humidity", 65, "wind", 2)), Map.entry("Guangzhou", Map.of("temp", 30, "condition", "Rainy", "humidity", 80, "wind", 4)), Map.entry("Shenzhen", Map.of("temp", 29, "condition", "Sunny", "humidity", 75, "wind", 3)), Map.entry("Wuhan", Map.of("temp", 28, "condition", "Sunny", "humidity", 60, "wind", 2)), Map.entry("Chengdu", Map.of("temp", 25, "condition", "Overcast", "humidity", 55, "wind", 1)), Map.entry("Hangzhou", Map.of("temp", 27, "condition", "Light Rain", "humidity", 72, "wind", 3)), Map.entry("Nanjing", Map.of("temp", 23, "condition", "Cloudy", "humidity", 58, "wind", 2)) ); /** * getWeather:查询指定城市的当前天气 * * @ToolParam 描述参数的格式和含义,帮助 AI 生成正确的参数值 * required = true 表示该参数为必填 * * @param city 城市英文名称,如 "Beijing"、"Wuhan" * @return 天气信息的字符串描述 */ @Tool(name = "getWeather", description = """ Query the current weather for a specific city. Returns temperature, weather condition, humidity and wind level. Supported cities: Beijing, Shanghai, Guangzhou, Shenzhen, Wuhan, Chengdu, Hangzhou, Nanjing """) public String getWeather( @ToolParam(description = """ City name in English, e.g.: - Beijing - Shanghai - Wuhan - Guangzhou Only major Chinese cities are supported. Must use English name, NOT Chinese characters. """, required = true) String city) { // 查找城市天气数据 Map<String, Object> weather = WEATHER_DATA.get(city); // 城市不存在则返回友好错误提示 if (weather == null) { return String.format("City '%s' not found. Supported cities: %s", city, String.join(", ", WEATHER_DATA.keySet())); } // 组装返回信息 int temp = (int) weather.get("temp"); String condition = (String) weather.get("condition"); int humidity = (int) weather.get("humidity"); int wind = (int) weather.get("wind"); return String.format(""" [Weather for %s] Temperature: %d°C Condition: %s Humidity: %d%% Wind Level: %d """, city, temp, condition, humidity, wind); } /** * getForecast:查询未来几天的天气预报 * * @param city 城市英文名称 * @param days 未来天数(1-7),默认为3天 * @return 预报信息 */ @Tool(name = "getForecast", description = """ Get weather forecast for the next few days. Supports 1 to 7 days forecast. Returns daily weather summary. """) public String getForecast( @ToolParam(description = "City name in English, e.g.: Beijing, Shanghai, Wuhan", required = true) String city, @ToolParam(description = "Number of days to forecast, range 1-7, default is 3") Integer days) { // 默认查询3天 if (days == null || days < 1 || days > 7) { days = 3; } // 检查城市是否存在 if (!WEATHER_DATA.containsKey(city)) { return String.format("City '%s' not found.", city); } // 模拟生成预报数据 StringBuilder forecast = new StringBuilder(); forecast.append(String.format("[%d-Day Forecast for %s]\n", days, city)); String[] conditions = {"Sunny", "Cloudy", "Overcast", "Light Rain", "Sunny", "Cloudy"}; for (int i = 0; i < days; i++) { int temp = ThreadLocalRandom.current().nextInt(20, 35); String cond = conditions[ThreadLocalRandom.current().nextInt(conditions.length)]; forecast.append(String.format("Day %d: %d°C, %s\n", i + 1, temp, cond)); } return forecast.toString(); } }2.4.3 CalculatorTool.java —— 计算器工具
package com.example.agent.tool; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; import javax.script.ScriptException; /** * CalculatorTool:数学计算工具 * * 大模型在数学计算方面表现不稳定,复杂运算应交由专业工具处理 * 使用 Java ScriptEngine 执行 JavaScript 表达式,比手写解析器更强大 */ @Component public class CalculatorTool { /** JavaScript 脚本引擎,用于执行数学表达式 */ private final ScriptEngine engine; /** 构造器:初始化脚本引擎 */ public CalculatorTool() { ScriptEngineManager manager = new ScriptEngineManager(); this.engine = manager.getEngineByName("JavaScript"); } /** * calculator:执行数学计算 * * @param expression 数学表达式,如 "1 + 2 * 3" * @return 计算结果 */ @Tool(name = "calculator", description = """ Perform mathematical calculations. Supported operations: addition(+), subtraction(-), multiplication(*), division(/), power(^), parentheses Supported functions: sin, cos, tan, sqrt, abs, round Examples: 1 + 2 * 3, (10 + 5) / 3, sqrt(16), 2^10 """) public String calculator( @ToolParam(description = """ Mathematical expression to evaluate, e.g.: - Simple: 1 + 2 * 3 - With parentheses: (10 + 5) / 3 - Functions: sqrt(16), abs(-5) - Mixed: 2 * PI * 5 Note: only numbers, operators, parentheses and math functions allowed """, required = true) String expression) { // 参数校验 if (expression == null || expression.isBlank()) { return "Expression cannot be empty"; } // 安全检查:只允许数学表达式,过滤危险字符 String sanitized = expression.replaceAll("\\s+", ""); if (!sanitized.matches("[0-9+\\-*/.^()a-zA-Z,\\s]+")) { return "Expression contains invalid characters. Only numbers, operators and math functions allowed."; } try { // 执行计算 Object result = engine.eval(expression); // 格式化结果:整数去掉小数点,浮点数保留两位 if (result instanceof Double) { double d = (Double) result; if (d == Math.floor(d) && !Double.isInfinite(d)) { return String.format("%s = %d", expression, (long) d); } return String.format("%s = %.2f", expression, d); } return String.format("%s = %s", expression, result.toString()); } catch (ScriptException e) { return String.format("Calculation error: %s. Please check the expression format.", e.getMessage()); } catch (Exception e) { return "Unknown calculation error, please try again"; } } }2.4.4 ToolConfig.java —— 工具注册配置
package com.example.agent.config; import com.example.agent.tool.CalculatorTool; import com.example.agent.tool.TimeTool; import com.example.agent.tool.WeatherTool; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbacks; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * ToolConfig:工具注册配置类 * * 负责将 @Tool 注解的工具方法注册为 Spring AI 可识别的 ToolCallback * ToolCallbacks.from() 会扫描传入对象的所有方法, * 找出带有 @Tool 注解的方法,解析注解元数据,生成对应的 ToolCallback 对象 * * 注意:@Tool 注解只在 Spring AI 2.0 中有效 */ @Configuration public class ToolConfig { /** 注册天气相关的工具 */ @Bean public ToolCallback[] weatherTools(WeatherTool weatherTool) { return ToolCallbacks.from(weatherTool); } /** 注册时间相关的工具 */ @Bean public ToolCallback[] timeTools(TimeTool timeTool) { return ToolCallbacks.from(timeTool); } /** 注册计算器工具 */ @Bean public ToolCallback[] calculatorTools(CalculatorTool calculatorTool) { return ToolCallbacks.from(calculatorTool); } }2.4.5 AgentConfig.java —— 修改版
package com.example.agent.config; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.tool.ToolCallback; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.List; /** * AgentConfig:AI 客户端配置(修改版) * * 修改内容: * 1. 注入 ToolCallback 列表 * 2. 在 ChatClient 中注册工具 * 3. 系统提示词改为英文以提高 DeepSeek 的遵循度 */ @Configuration public class AgentConfig { /** * chatClient:创建 ChatClient Bean * * Spring 会自动收集所有 ToolCallback[] 类型的 Bean,合并为一个列表注入 * * @param builder ChatClient.Builder * @param toolCallbacks 所有注册的工具回调 * @return 配置好的 ChatClient 实例 */ @Bean public ChatClient chatClient( ChatClient.Builder builder, List<ToolCallback[]> toolCallbacks) { // 将所有工具回调展平为一个数组 ToolCallback[] allTools = toolCallbacks.stream() .flatMap(java.util.Arrays::stream) .toArray(ToolCallback[]::new); return builder .defaultSystem(""" You are an AI Agent assistant built with Java and Spring AI. You have the following capabilities: 1. Time query: Use getCurrentTime tool 2. Weather query: Use getWeather tool 3. Weather forecast: Use getForecast tool 4. Math calculation: Use calculator tool Rules: - When users ask about time, weather, or calculations, prefer using tools - Present tool results in natural language - If a tool returns an error, inform the user honestly - When combining multiple tool results, provide reasonable analysis and suggestions """) .defaultTools(allTools) .build(); } }2.4.6 ChatController.java —— 修改版
package com.example.agent.controller; import com.example.agent.model.*; import com.example.agent.service.ConversationService; import org.springframework.ai.chat.client.ChatClient; import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; import java.util.List; import java.util.Map; @RestController @RequestMapping("/ai") public class ChatController { private final ChatClient chatClient; private final ConversationService conversationService; public ChatController( ChatClient.Builder builder, ConversationService conversationService) { this.chatClient = builder.build(); this.conversationService = conversationService; } // ==================== 会话管理接口(不变) ==================== @PostMapping("/conversation") public Conversation createConversation(@RequestBody(required = false) CreateConversationRequest request) { String title = request == null ? null : request.title(); return conversationService.createConversation(title); } @GetMapping("/conversations") public List<Conversation> listConversations() { return conversationService.listConversations(); } @GetMapping("/conversation/{conversationId}/messages") public List<ChatMessage> getMessages(@PathVariable String conversationId) { return conversationService.getHistory(conversationId); } // ==================== 消息收发接口(修改) ==================== /** * 发送消息(非流式,支持工具调用) * * ChatClient 内部会自动处理工具调用的完整流程: * 1. 发送消息给 AI * 2. AI 返回工具调用请求 * 3. ChatClient 自动执行工具 * 4. 将工具结果发回给 AI * 5. AI 生成最终回复 */ @PostMapping("/chat") public Map<String, Object> chat(@RequestBody ChatRequest request) { // 保存用户消息 conversationService.addMessage(request.conversationId(), ChatMessage.user(request.message())); // 调用 AI 模型(自动管理工具调用) var response = chatClient.prompt() .user(request.message()) .call() .chatResponse(); // 提取回复内容并保存 String content = response.getResult().getOutput().getContent(); conversationService.addMessage(request.conversationId(), ChatMessage.assistant(content)); return Map.of( "reply", content, "finishReason", response.getResult().getMetadata().getFinishReason() ); } /** * 发送消息(流式,支持工具调用) * * 流式工具调用的特点: * - 工具调用本身是同步的(执行工具方法) * - 只有最终的文本回复是流式的 * - 前端看不到工具调用的中间过程 */ @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamChat(@RequestBody ChatRequest request) { String conversationId = request.conversationId(); String message = request.message(); conversationService.addMessage(conversationId, ChatMessage.user(message)); StringBuilder fullResponse = new StringBuilder(); return chatClient.prompt() .user(message) .stream() .content() .map(chunk -> { fullResponse.append(chunk); return ServerSentEvent.<String>builder(chunk) .event("text") .build(); }) .concatWith(Flux.just( ServerSentEvent.<String>builder("done") .event("end") .build() )) .doOnTerminate(() -> { String completeResponse = fullResponse.toString(); if (!completeResponse.isEmpty()) { conversationService.addMessage( conversationId, ChatMessage.assistant(completeResponse) ); } }); } }2.4.7 前端 index.html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Agent 控制台 - 带工具调用</title> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-gray-100"> <div class="flex h-screen"> <!-- 左侧会话列表 --> <div class="w-64 bg-gray-900 text-white p-4 flex flex-col"> <h2 class="text-xl font-bold mb-4">Agent 控制台</h2> <button class="bg-blue-600 hover:bg-blue-700 rounded px-4 py-2 mb-4" onclick="newConversation()"> + 新建会话 </button> <div id="conversation-list" class="flex-1 overflow-y-auto space-y-2"></div> </div> <!-- 右侧聊天区域 --> <div class="flex-1 flex flex-col"> <div id="messages" class="flex-1 overflow-y-auto p-6 space-y-4"></div> <div class="p-4 border-t bg-white"> <div class="flex"> <input id="message-input" type="text" placeholder="试试问:What's the weather like in Wuhan?" class="flex-1 border rounded-l px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> <button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-700 text-white rounded-r px-6 py-2"> Send </button> </div> </div> </div> </div> <script> let currentConversationId = null; window.onload = () => { loadConversations(); newConversation(); }; async function newConversation() { const res = await fetch('/ai/conversation', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title: 'New Session'}) }); const conv = await res.json(); currentConversationId = conv.id; document.getElementById('messages').innerHTML = ''; loadConversations(); } async function loadConversations() { const res = await fetch('/ai/conversations'); const convs = await res.json(); const list = document.getElementById('conversation-list'); list.innerHTML = convs.map(c => ` <div class="p-2 hover:bg-gray-800 cursor-pointer rounded ${c.id === currentConversationId ? 'bg-gray-700' : ''}" onclick="switchConversation('${c.id}')"> <div class="font-medium">${c.title}</div> <div class="text-xs text-gray-500">${new Date(c.createdAt).toLocaleTimeString()}</div> </div> `).join(''); } async function switchConversation(id) { currentConversationId = id; const res = await fetch(`/ai/conversation/${id}/messages`); const messages = await res.json(); const container = document.getElementById('messages'); container.innerHTML = ''; messages.forEach(m => addBubble(m.role, m.content)); loadConversations(); } async function sendMessage() { const input = document.getElementById('message-input'); const message = input.value.trim(); if (!message || !currentConversationId) return; input.value = ''; addBubble('user', message); const thinkingDiv = showThinking(); try { const res = await fetch('/ai/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversationId: currentConversationId, message: message }) }); const data = await res.json(); thinkingDiv.remove(); addBubble('assistant', data.reply); } catch (error) { thinkingDiv.remove(); addBubble('assistant', 'Sorry, an error occurred: ' + error.message); } } function showThinking() { const container = document.getElementById('messages'); const div = document.createElement('div'); div.className = 'flex justify-start'; div.innerHTML = ` <div class="bg-white border rounded-lg px-4 py-2"> <div class="flex items-center space-x-2 text-gray-500"> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-500"></div> <span>Thinking...</span> </div> </div> `; container.appendChild(div); container.scrollTop = container.scrollHeight; return div; } function addBubble(role, content) { const container = document.getElementById('messages'); const div = document.createElement('div'); div.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`; div.innerHTML = ` <div class="max-w-[70%] rounded-lg px-4 py-2 ${role === 'user' ? 'bg-blue-600 text-white' : 'bg-white text-gray-800 border'}"> <div class="content whitespace-pre-wrap">${content}</div> </div> `; container.appendChild(div); container.scrollTop = container.scrollHeight; return div; } document.getElementById('message-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendMessage(); }); </script> </body> </html>2.5 坑点警示
坑点 1:工具名称必须符合 OpenAI API 命名规范
OpenAI API 要求函数名必须匹配正则表达式:^[a-zA-Z0-9_-]+$
❌ 错误示例: name = "查询天气" → 包含中文,报错 name = "get weather" → 包含空格,报错 name = "get.weather" → 包含点号,报错 ✅ 正确示例: name = "getWeather" → 驼峰命名,合法 name = "get_current_time" → 下划线命名,合法 name = "get-weather" → 短横线命名,合法错误现象:
Invalid parameter: tools[0].function.name must match ^[a-zA-Z0-9_-]+$解读报错中的索引:
tools[0]表示第一个注册的工具(按 ToolConfig 中 @Bean 方法的定义顺序)tools[1]表示第二个注册的工具- 以此类推。如果你的第一个工具是 WeatherTool,报错就指向
tools[0]
坑点 2:参数描述要清晰明确
❌ 错误:@ToolParam(description = "城市名称") → AI 可能传入中文、拼音、缩写等各种格式 ✅ 正确:@ToolParam(description = "City name in English, e.g.: Beijing, Shanghai, Wuhan. Must use English name, NOT Chinese characters.") → AI 明确知道要传英文名参数描述越模糊,AI 传入错误值的概率越高。好的描述应包含:格式说明、示例值、取值范围、是否为必填。
2.6 运行验证
启动命令
export DEEPSEEK_API_KEY=sk-你的key mvn spring-boot:run测试用例
测试场景 | 输入示例 | 预期行为 |
|---|---|---|
查询时间 | "What time is it?" | 调用 getCurrentTime,返回当前时间 |
查询天气 | "What's the weather in Wuhan?" | 调用 getWeather,返回武汉天气 |
天气预报 | "How will be the weather in Beijing tomorrow?" | 调用 getForecast,返回预报 |
数学计算 | "What is 1024 * 768?" | 调用 calculator,返回计算结果 |
复合查询 | "Is it good for running in Wuhan tomorrow?" | 先调 getForecast,再综合分析 |
调试技巧
# application.yml 中开启调试日志 logging: level: org.springframework.ai: DEBUG开启后可以看到实际发送给 DeepSeek 的请求体,包括 tools 数组的内容,便于排查工具注册和调用的问题。