首屏导读 · 本教程配套付费专栏: 大模型工程师修炼手记
19.9 元(AI 编程 / Agent 实战 | 本文同主题系统课程)· AI时代程序员的自我提升49.9 元(AI 时代成长方法论)。单篇不过瘾?订阅解锁全量源码、实战与答疑;文末附资料包领取方式 ↓
引言:AI真的会“用工具”吗?
当你在聊天框里输入“北京今天天气怎么样?”时,你是否好奇过:AI是怎么知道实时天气的?它自己上网查的吗?其实,AI本身并不具备实时获取信息的能力——它只是通过训练学到了“如何调用工具”这一技能。
所谓“工具调用”,就是让大模型在需要时,输出一个结构化的指令(比如“查询天气,城市=北京”),然后由外部程序执行这个指令,再把结果返回给模型,最终生成自然语言回答。整个过程就像你指挥一个助手去办事:你动嘴,助手动手。
目前实现工具调用的主流方式有三种:
- 提示工程模拟:通过精心设计的prompt,让模型输出固定格式的JSON,外部解析执行。
- 原生Tool Calling:大模型原生支持的标准接口,输出结构化的
tool_calls请求。 - MCP协议:新兴的开放协议,将工具封装为独立服务,实现跨语言、动态扩展。
本篇我们将从最基础的提示工程模拟入手,手把手带你实现一个完整的工具调用流程。这种方式不依赖任何高级功能,适用于任何大模型,也是理解工具调用原理的最佳起点。
一、核心思想:让模型当“编剧”,程序当“演员”
在没有原生函数调用支持的情况下,我们需要通过提示词“教会”模型如何表达调用工具的意图。核心思路如下:
- 告诉模型有哪些工具可用(名称、功能、参数)。
- 要求模型在需要调用工具时,输出一个特定格式的JSON(包含工具名和参数)。
- 外部程序解析这个JSON,执行对应的工具函数。
- (可选)将工具执行结果再次交给模型,生成自然语言回答。
这个过程的巧妙之处在于:模型并不真正执行任何代码,它只是“纸上谈兵”——输出调用指令。真正的“动手”由我们的Java程序完成。这样既利用了模型的智能,又保证了安全性和可控性。
二、实战准备:Spring Boot + Spring AI 环境搭建
我们将在Spring Boot项目中集成Spring AI,以OpenAI为例(其他模型类似)。首先创建Spring Boot项目(3.2+),添加依赖:
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId><version>1.0.0.M6</version></dependency></dependencies>在application.yml中配置OpenAI API密钥:
spring:ai:openai:api-key:${OPENAI_API_KEY}chat:options:model:gpt-3.5-turbotemperature:0.0# 降低随机性,保证输出稳定三、定义可执行的工具函数
我们实现两个简单工具:天气查询和计算器。注意:这些是真实的Java方法,之后将由我们的程序调用。
@ComponentpublicclassWeatherToolExecutor{// 模拟天气查询(实际可调用真实API)publicStringgetWeather(Stringcity){Map<String,String>weatherMap=Map.of("北京","晴,10-22℃","上海","多云,15-25℃","广州","雨,20-28℃");Stringweather=weatherMap.get(city);returnweather!=null?weather:"未找到"+city+"的天气信息";}// 模拟计算器(使用JavaScript引擎,生产环境建议使用安全计算库)publicStringcalculate(Stringexpression){try{ScriptEngineengine=newScriptEngineManager().getEngineByName("JavaScript");Objectresult=engine.eval(expression);returnString.valueOf(result);}catch(Exceptione){return"表达式错误:"+e.getMessage();}}}这里我们使用@Component将工具类注册为Spring Bean,方便后续注入。
四、设计提示词:让模型学会输出JSON
提示词是整个流程的关键。我们需要:
- 清晰描述每个工具的功能和参数。
- 规定输出格式(必须是一个JSON对象)。
- 提供几个示例(few-shot),帮助模型理解。
下面是我们设计的系统提示词:
你是一个智能助手,可以根据用户问题调用以下工具: 1. get_weather:获取城市天气 参数:{"city": "城市名称"} 2. calculate:计算数学表达式 参数:{"expression": "数学表达式,如 2+2"} 如果用户问题需要调用工具,请只输出一个JSON对象,格式为: { "tool": "工具名称", "args": {"参数名": "参数值"} } 不要输出其他任何内容。 如果不需要调用工具,直接回答用户问题。 示例: 用户:北京今天热吗? 输出:{"tool": "get_weather", "args": {"city": "北京"}} 用户:计算 3.5 * 2 输出:{"tool": "calculate", "args": {"expression": "3.5 * 2"}} 用户:你好 输出:你好!有什么可以帮你的吗?注意:我们将这个提示作为system消息发送,这样模型会在每次对话中遵循这些规则。
五、编写服务类:解析JSON并执行工具
接下来我们创建一个服务类,整合以上逻辑:
@ServicepublicclassPromptToolService{privatefinalChatClientchatClient;privatefinalWeatherToolExecutortoolExecutor;privatefinalObjectMapperobjectMapper=newObjectMapper();publicPromptToolService(ChatClient.Builderbuilder,WeatherToolExecutortoolExecutor){this.chatClient=builder.build();this.toolExecutor=toolExecutor;}publicStringprocess(StringuserInput){// 1. 调用模型获取响应Stringresponse=chatClient.prompt().system(SYSTEM_PROMPT)// 上一步定义的提示词.user(userInput).call().content();// 2. 尝试解析为JSONtry{// 清理可能的多余字符(如Markdown代码块)Stringcleaned=response.replaceAll("```json|```","").trim();if(cleaned.startsWith("{")&&cleaned.endsWith("}")){JsonNodejson=objectMapper.readTree(cleaned);Stringtool=json.get("tool").asText();JsonNodeargs=json.get("args");// 3. 根据工具名执行对应方法Stringresult=switch(tool){case"get_weather"->toolExecutor.getWeather(args.get("city").asText());case"calculate"->toolExecutor.calculate(args.get("expression").asText());default->"未知工具:"+tool;};// 4. 返回执行结果(也可以再次调用模型生成自然语言回答)return"工具返回:"+result;}}catch(Exceptione){// 解析失败,记录日志System.err.println("JSON解析失败: "+e.getMessage());}// 不是JSON,直接返回模型原始回答returnresponse;}// 将SYSTEM_PROMPT定义为常量privatestaticfinalStringSYSTEM_PROMPT=""" 你是一个智能助手,可以根据用户问题调用以下工具: 1. get_weather:获取城市天气 参数:{"city": "城市名称"} 2. calculate:计算数学表达式 参数:{"expression": "数学表达式,如 2+2"} 如果用户问题需要调用工具,请只输出一个JSON对象,格式为: { "tool": "工具名称", "args": {"参数名": "参数值"} } 不要输出其他任何内容。 如果不需要调用工具,直接回答用户问题。 示例: 用户:北京今天热吗? 输出:{"tool": "get_weather", "args": {"city": "北京"}} 用户:计算 3.5 * 2 输出:{"tool": "calculate", "args": {"expression": "3.5 * 2"}} 用户:你好 输出:你好!有什么可以帮你的吗? """;}六、测试一下
我们写一个简单的Controller来测试:
@RestControllerpublicclassChatController{@AutowiredprivatePromptToolServicepromptToolService;@GetMapping("/chat")publicStringchat(@RequestParamStringmessage){returnpromptToolService.process(message);}}启动应用,用浏览器或curl测试:
GET /chat?message=北京今天天气怎么样? 响应:工具返回:晴,10-22℃GET /chat?message=计算 25*4+10 响应:工具返回:110GET /chat?message=你好 响应:你好!有什么可以帮你的吗?完美!模型成功输出了JSON,程序正确解析并执行了工具。
七、进阶:将工具结果再次交给模型生成自然语言
目前我们直接返回了工具的执行结果,虽然清晰,但不够自然。比如用户问“北京今天热吗?”,工具返回“晴,10-22℃”,如果直接回复这句,显得生硬。更好的做法是:把工具结果作为上下文,再次调用模型,让它生成一个完整的回答。
修改process方法,在执行工具后,将结果组装成新的用户消息,再次调用模型:
publicStringprocess(StringuserInput){// 第一步:获取模型响应(可能包含JSON)StringfirstResponse=chatClient.prompt().system(SYSTEM_PROMPT).user(userInput).call().content();// 尝试解析JSONtry{Stringcleaned=firstResponse.replaceAll("```json|```","").trim();if(cleaned.startsWith("{")&&cleaned.endsWith("}")){JsonNodejson=objectMapper.readTree(cleaned);Stringtool=json.get("tool").asText();JsonNodeargs=json.get("args");// 执行工具StringtoolResult=switch(tool){case"get_weather"->toolExecutor.getWeather(args.get("city").asText());case"calculate"->toolExecutor.calculate(args.get("expression").asText());default->"未知工具";};// 将工具结果作为上下文,再次调用模型生成自然语言回答StringfinalAnswer=chatClient.prompt().system("你是一个智能助手,请根据工具返回的结果,用自然的语言回答用户。").user("用户问题:"+userInput+"\n工具返回:"+toolResult).call().content();returnfinalAnswer;}}catch(Exceptione){// 解析失败,忽略}returnfirstResponse;}测试:
GET /chat?message=北京今天天气怎么样? 响应:北京今天天气晴朗,气温在10到22摄氏度之间,适合外出活动。这样回答更加自然。
八、处理多轮对话:简单的记忆机制
在上面的实现中,每次对话都是独立的,模型不记得之前说过什么。如果用户接着问“那上海呢?”,模型无法关联上下文。我们可以通过维护一个会话记忆来改善。
Spring AI提供了ChatMemory接口和MessageChatMemoryAdvisor,可以方便地添加记忆功能。但为了保持本篇的独立性,我们先用一个简单的手动实现:
@ServicepublicclassPromptToolWithMemoryService{privatefinalChatClientchatClient;privatefinalWeatherToolExecutortoolExecutor;privatefinalObjectMapperobjectMapper=newObjectMapper();// 简单内存存储,key为sessionId,value为历史消息列表privatefinalMap<String,List<Message>>sessionMemory=newConcurrentHashMap<>();// ... 构造函数等publicStringprocess(StringuserInput,StringsessionId){// 获取或创建历史记录List<Message>history=sessionMemory.computeIfAbsent(sessionId,k->newArrayList<>());// 将用户消息加入历史history.add(newUserMessage(userInput));// 构建包含历史的promptPromptprompt=newPrompt(history,ChatOptions.builder().model("gpt-3.5-turbo").temperature(0.0).build());// 调用模型Stringresponse=chatClient.prompt(prompt).system(SYSTEM_PROMPT).call().content();// 处理工具调用(省略,与之前类似)// ...// 将助手回答加入历史history.add(newAssistantMessage(finalAnswer));returnfinalAnswer;}}这样,同一sessionId的对话会累积记忆,模型就能理解上下文了。
九、优缺点与适用场景
优点
- 通用性强:任何大模型都可以使用,不依赖厂商特定功能。
- 完全可控:输出格式、工具列表、执行逻辑都由自己定义。
- 易于理解:适合教学和快速原型,帮助理解工具调用的本质。
缺点
- 格式不稳定:模型可能输出格式错误的JSON,或编造不存在的工具。
- 解析繁琐:需要编写额外的解析和异常处理代码。
- 无法并行调用:一次只能请求一个工具(除非设计数组格式)。
- 多轮记忆需要手动维护。
适用场景
- 使用不支持原生Tool Calling的模型(如某些开源模型)。
- 需要对工具调用进行高度定制(如特殊的安全校验)。
- 教学演示,帮助新人理解原理。
十、小结
本篇我们通过提示工程的方式,成功模拟了函数调用,实现了天气查询和计算器功能。虽然这种方法有一些局限性,但它让我们深入理解了工具调用的核心思想:模型负责“想”,程序负责“做”。
下一篇文章,我们将介绍Spring AI原生的Tool Calling功能,看看它是如何优雅地解决上述问题的,让代码简洁10倍!
href="https://csdnimg.cn/release/blogv2/dist/mdeditor/css/editerView/markdown_views-e1cc28b339.css" rel="stylesheet"> href="https://csdnimg.cn/release/blogv2/dist/mdeditor/css/style-d1e89cace4.css" rel="stylesheet">内容来源:csdn.net
作者昵称:Tom·Ge
原文链接:https://blog.csdn.net/gedonshen/article/details/158463020
作者主页:https://blog.csdn.net/gedonshen
延伸阅读 · 我的付费专栏
觉得这篇文章对你有帮助?我把同类主题的系统化内容沉淀成了付费专栏,欢迎订阅支持持续输出:
| 专栏 | 定价 | 内容 |
|---|---|---|
| 大模型工程师修炼手记 | 19.9 元 | AI 编程 / Agent 深度实战 |
| AI时代程序员的自我提升 | 49.9 元 | AI 时代成长方法论 |
本文配套代码 / 资料包:欢迎在评论区留言「求代码」,我会私信发送完整资源!