AI应用开发学习(java方向)走到第四天,我终于感觉摸到了一点“应用”的边。前三天如果说是围着模型转,今天开始就是让模型围着我的业务转。我把这个阶段的重点放在了工具调用(Function Calling)上,这是Java后端开发者理解“大模型怎么接入真实系统”非常关键的一步。今天这篇记录,我会尽量说清楚三个问题:为什么要学工具调用、一个最小可用实现怎么写、以及我踩过的几个坑,希望给同样在走Java方向AI应用开发道路的朋友一点参考。
1. 先把前三天捋一遍,第四天到底在学什么
1.1 从“能对话”到“能干活”的转折点
如果你也在按比较常规的路线学习,前三天大致是这样的:第一天搭环境,JDK、Maven、Spring Boot跑起来,然后拿一个兼容OpenAI协议的服务接口调通了一次文本生成;第二天深入学习消息结构,搞清楚system、user、assistant三种角色怎么配合,尝试用提示词控制输出风格;第三天开始碰结构化输出,让模型返回的JSON能直接映射成Java对象,基本上算是把“对话”这件事玩明白了。
但学到这里,我心里一直有个疑问:光会对话,离“AI应用”还很远。我做后端,手上有的是业务接口、数据库查询、内部服务,一个小助手如果能直接调用这些接口,那才真正有落地价值。第四天学的工具调用,解决的正是这个核心问题:让模型自己决定要不要调用我准备好的Java方法,然后我替它执行,再把结果喂回去。简单说,就是从“聊天机器人”往“智能助理”迈进的转折点。
1.2 工具调用在Java技术栈里处于什么位置
对Java方向来说,工具调用不是什么花哨的东西,它就是一次HTTP请求的规范协议。模型服务商会暴露一个兼容接口,你可以在请求体里附带一份“工具清单”,每个工具就是一段JSON Schema描述,写清楚这个工具叫什么、是干嘛的、参数是什么类型。模型看完你的描述后,如果觉得需要查数据,就会返回一个结构化的“调用请求”,而不是直接回复你文字。
这跟Java后端日常打交道的JSON Schema很接近,所以Java开发者学这一块有天然优势。后面不管你是用Spring AI、LangChain4j,还是直接拿OpenAI SDK,本质都是在这个协议上包了一层。第四天我的做法是刻意不引入任何AI框架,直接用JDK自带的HttpClient加Jackson完成整个链路,因为框架会帮你隐藏太多细节,反而搞不清楚工具调用是怎么发生的。把这个协议亲手摸一遍以后,再用什么框架都不会心里没底。
2. Function Calling的原理,用一句话和一张流程图讲透
2.1 一个生活化的类比
理解函数调用,可以想象你带了一个完全没有系统权限的实习生。你问他“现在订单到哪一步了”,他没有系统账号,查不了数据库,但他知道有一个“查订单状态的接口”存在。于是他不直接回答你,而是写了一张便条:“请调用query_order_status接口,传入订单号参数:ORD12345”,然后把便条递给你。你去系统里查完,把“订单已发货,物流为顺丰”写在小纸条上递给他。他看了一眼,再整理成完整的话回复你。
这个便条一来一回的过程,就是一次完整的工具调用。模型不是真的调用了你的方法,它只负责输出“我希望你调用这个函数、这些参数”的意图,真正的执行永远发生在你的应用里。这样设计有一个非常实际的好处:模型拿不到你的数据库密码,也碰不到你的核心系统,所有风险控制、权限校验、审计日志,都还是由你原来的Java代码来做。
2.2 一次工具调用的完整数据流
我们把一次完整的调用链路拆开看,可以分为六步:
- 应用组装请求,把用户消息以及所有工具的描述信息一起发给模型接口。
- 模型根据用户提问判断是否需要工具。如果不需要,正常返回文本;如果需要,返回一个finish_reason为tool_calls的响应。
- 应用解析响应里的tool_calls数组,拿到工具名和参数JSON字符串。
- 把你自己的Java方法真正执行一遍,获得结果字符串。
- 应用把执行结果包装成一条role为tool的消息,同时把模型之前返回的assistant消息也原样放进消息列表里,再次发给模型。
- 模型结合工具结果,生成面向用户的最终回答。
这里最关键也是最容易出错的是第五步。很多人第一次写的时候只把工具结果追加到messages里,却忘记把上一条assistant消息一起放进去,结果模型根本不知道你是在回复哪一次调用,轻则回答混乱,重则报错。
2.3 为什么Java后端特别适合玩这个
我自己的体会是,Java生态在后端领域积累的东西实在太多了。你搞过Spring Boot,就知道把一个内部服务包装成HTTP接口有多顺手;你做过参数校验,就知道JSON Schema和Bean Validation本质上在描述同一件事。工具调用等于把这些后端基本功直接接到了大模型前面,模型成了“大脑”,你的Java方法成了“手脚”。
而且大多数Java团队都有现成的接口文档、幂等机制、监控链路,这些恰恰是AI应用落地最需要的配套能力。很多团队做大模型应用,卡住的地方不是模型不会回答,而是没人敢把业务接口交给一个新生系统调用。但工具调用这个模式允许你精确控制哪些方法暴露、暴露成什么描述、执行前做什么拦截,安全边界是代码可控的,这让Java程序员在做技术方案评审的时候非常有底气。
3. 实操:一个可运行的最小工具调用Demo
3.1 场景设计与准备工作
今天我做了一个“工单查询助手”,场景非常简单:用户发一句“帮我查一下工单TC20250601到哪一步了”,模型识别出需要查询,于是触发我封装的queryTicketStatus方法,再把结果组织成一句话返回。
准备过程很简单,就三个依赖:JDK 17以上(我用的17),Jackson处理JSON,以及任意一个兼容OpenAI协议的大模型服务接口。我不建议在这一步引入复杂框架,先用HttpClient原生的方式把协议走通,后面你上Spring AI还是LangChain4j都会轻松很多。
先定义一个工具描述方法,核心就是构造那一段JSON Schema:
private static Map<String, Object> buildTicketTool() { Map<String, Object> parameters = new LinkedHashMap<>(); parameters.put("type", "object"); Map<String, Object> props = new LinkedHashMap<>(); Map<String, Object> ticketNo = new LinkedHashMap<>(); ticketNo.put("type", "string"); ticketNo.put("description", "工单号,例如 TC20250601"); props.put("ticketNo", ticketNo); parameters.put("properties", props); parameters.put("required", List.of("ticketNo")); Map<String, Object> function = new LinkedHashMap<>(); function.put("name", "query_ticket_status"); function.put("description", "根据工单号查询当前处理状态"); function.put("parameters", parameters); Map<String, Object> tool = new LinkedHashMap<>(); tool.put("type", "function"); tool.put("function", function); return tool; }这一步没啥魔法,就是老老实实把数据结构搭出来。我见过有人直接拼JSON字符串,也可以,但用Map组织代码更清晰,后续要加参数校验也方便。
3.2 请求发送与响应解析
接下来是发送请求的核心逻辑。我用JDK的HttpClient,把请求体构造好,然后发出POST请求:
private static String callModel(List<Map<String, Object>> messages) throws Exception { Map<String, Object> body = new LinkedHashMap<>(); body.put("model", "your-model-name"); body.put("messages", messages); body.put("tools", List.of(buildTicketTool())); body.put("tool_choice", "auto"); String json = objectMapper.writeValueAsString(body); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(API_URL)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + API_KEY) .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); }注意API_URL和API_KEY我从环境变量读取,不要硬编码到代码里。模型名称要替换成你实际在用的服务商支持的名字。tool_choice设为auto表示让模型自己决定是否调用工具,如果设为required,那不管什么对话都强制调用,有时候会得到很奇怪的回复。
响应解析环节是今天的重点之一。模型如果决定调用工具,返回的JSON里会有finish_reason:"tool_calls",以及一个tool_calls数组。我先把响应转成JsonNode:
JsonNode root = objectMapper.readTree(resp); JsonNode message = root.path("choices").get(0).path("message"); String finishReason = root.path("choices").get(0) .path("finish_reason").asText(); if ("tool_calls".equals(finishReason)) { JsonNode toolCalls = message.path("tool_calls"); for (JsonNode call : toolCalls) { String callId = call.path("id").asText(); String functionName = call.path("function") .path("name").asText(); String argsJson = call.path("function") .path("arguments").asText(); Map<String, Object> args = objectMapper .readValue(argsJson, new TypeReference<>() {}); String result = "query_ticket_status".equals(functionName) ? queryTicketStatus((String) args.get("ticketNo")) : "未知工具"; Map<String, Object> toolMsg = new LinkedHashMap<>(); toolMsg.put("role", "tool"); toolMsg.put("tool_call_id", callId); toolMsg.put("content", result); MessageThread.add(toolMsg); } }这里有几件事值得展开说一下。arguments部分模型返回的是一个JSON字符串,比如"{"ticketNo":"TC20250601"}",你必须要先解析成Java对象才能取到参数值。另外tool消息里的tool_call_id必须和模型给你的一致,否则接口会报错,这个错还不怎么友好,只说“invalid tool_call_id”。
3.3 把工具执行结果写进消息上下文
执行完工具方法后,我拿到的是这样一个字符串:“状态:处理中,处理人:张三,最近更新:2025-06-01 14:30”。这个字符串作为content,封装成role为tool的消息,放进messages列表。
但千万别忘了,还要把assistant刚才那条带着tool_calls的消息也放回去,顺序必须是:……user消息、assistant消息(含tool_calls)、tool消息、再发给模型。我第一次写的时候漏了这个assistant消息,结果模型接口直接报错,提示消息顺序不对。补上之后第二次请求就正常了,模型会根据工具返回值组织出一段口语化的回答:“您查询的工单TC20250601目前正在处理中,由张三负责跟进,最近更新时间是6月1日下午2点30分。”
我把这个最小Demo完整跑通之后,对工具调用的理解一下子具体了很多,不再只是文档里的概念了。
4. 进阶:从单工具到循环执行器
4.1 为什么需要循环执行工具调用
单个工具的场景比较理想,实际业务里一个对话往往要调好几个工具。比如用户问“工单TC20250601是什么问题?处理人是谁?”你可能需要先查工单基本信息,再查人员信息,模型可能会连续发起两次工具调用,也可能第一次调用后根据结果决定要不要再调第二次。
这时候就需要一个循环执行器:反复把消息发送给模型,只要返回的finish_reason是tool_calls,就继续执行工具、追加tool消息、再次发送,直到模型返回正常文本总结。
核心结构大概是这样的:
public static String runWithTools(List<Map<String, Object>> messages) throws Exception { for (int i = 0; i < MAX_ITERATIONS; i++) { String resp = callModel(messages); // 解析响应,拿到 finishReason if ("tool_calls".equals(finishReason)) { // 将assistant消息加入messages // 循环执行所有tool_calls并加入tool消息 continue; } // 此时正常返回文本 return finalContent; } throw new IllegalStateException("达到最大迭代次数仍未结束"); }这个循环有两个细节很关键。第一是必须有最大迭代次数,我一般设5次。如果模型和工具描述写得不好,可能在两个工具之间来回打转,没有上限的话一个请求能把你的token烧穿。第二是每次循环都要把assistant消息和tool消息保持正确顺序,这个追加逻辑可以封装成一个方法,避免在循环里写乱。
4.2 多工具串行与上下文的处理
当你拥有多个工具的时候,工具的命名和描述就变得特别重要。我遇到过这样一个情况:我有两个工具,一个叫query_ticket_status,一个叫query_order_status,描述也差不多,模型就老选错,死活不调另一个。后来我调整了描述措辞,给每个工具加了使用场景提示,比如“当用户提到工单时使用”,调用准确率一下就上来了。
另外,在多轮对话场景里,你要维护一个session级别的消息列表。我写了一个简单的MessageThread类,本质上就是List<Map<String, Object>>。用户每次发新消息时,只把user消息追加进去,前面所有历史记录都保留。如果不保留历史,模型会丢失上下文,第二次问“那处理人呢”它根本不知道“那”指的是什么。
4.3 从Demo到服务化的一点思路
Demo跑通之后,我很自然地把它套进Spring Boot里做成一个接口。Controller层接收用户输入,Service层维护会话,工具执行层保持独立。这样既方便测试,也为后面接入更多工具留了接口。
服务化时我额外做两件事:一是给每个用户建一个会话ID,用Map长期保存聊天记录;二是给工具调用加了审计日志,每次调用都记录入参出参。说实话这部分比AI本身更贴近Java后端日常,等于把以前做接口的老功夫又用上了。这也是我觉得Java方向做AI应用的一个优势:不是从头学一套新东西,而是用熟悉的后端工程化能力去驾驭AI能力。
5. 实操中遇到的常见问题与排查实录
5.1 参数不过关导致工具触发不了
今天遇到最耗时间的问题是:模型说什么都不触发工具调用,不管用户怎么问“帮我查工单”,它都直接回复“暂时无法查询”。排查到最后发现是工具描述写得太模糊了,只写了“查询工单状态”,没有说明“当用户询问工单进度、处理情况、当前节点时都应该调用此工具”。
工具调用的触发逻辑更像是一种模糊匹配,模型的判断完全依赖你给它看的描述文本。所以描述要尽量贴近用户的真实口语表达,把触发场景写清楚。我后来把描述改成“根据工单号查询工单当前处理状态,当用户询问工单进度、处理到哪一步、还有多久完成时,必须调用此工具”,效果立竿见影。
5.2 流式响应解析工具调用容易踩坑
我一开始图省事直接开了流式输出,结果发现如果模型返回tool_calls,流式场景下解析要比普通模式麻烦很多。流式返回会把arguments分片吐出来,你需要自己按片拼接,尤其处理转义字符的时候容易拼错。对于第四天这个阶段,我强烈建议先用非流式把工具调用跑通,后面再做流式优化。踩过几次坑之后,我总结出一个原则:工具调用的可靠性优先于流式输出的实时性,真要上线再单独设计流式方案。
5.3 工具结果太长,把上下文撑爆
有次我给工具返回了一个特别完整的工单对象,十几个字段全塞进去,结果第二次请求的时候token直接超限。后来我把工具返回内容做了精简,只返回用户可能关心的字段,像工单状态、处理人、最近更新时间,其他内部字段一概不带。这其实也是建模思维,不是所有数据都适合喂给模型,喂之前你要想清楚这一步调用的目标是什么。
我把今天踩过的典型坑整理成一张速查表,方便后面复习:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 模型不调用工具 | 工具描述不清晰 | 补充触发场景和口语化示例 |
| 参数类型错误 | JSON Schema未限定类型 | 每个字段显式写type和description |
| 消息顺序报错 | 漏加assistant原始消息 | 保持user、assistant、tool顺序完整 |
| 工具结果太长 | 返回了过大的对象 | 精简为面向摘要的字段 |
| 模型在两个工具间打转 | 工具职责重叠、描述混淆 | 拆分职责,明确各自触发条件 |
| 循环请求不结束 | 缺少迭代上限 | 设置最大调用次数,及时抛出异常 |
6. 写到最后:今天最重要的三点体会
第四天的实践给我最大的收获不是学会了某个函数,而是真正理解了“模型只输出意图,执行永远在应用侧”这句话。Java开发者不用去畏惧模型不可控,你完全可以把所有业务逻辑都放在自己熟悉的Java方法里,模型只是一个理解自然语言的调度器。
还有一点关于学习路线的个人看法:我建议你如果刚开始接触Java方向的AI应用开发,一定先用手写HTTP的方式走一遍工具调用全链路,别急着上Spring AI这类封装框架。当你亲手构造过请求体、解析过tool_calls、维护过消息顺序之后,再去看框架的源代码,基本上就是老朋友见面,一眼就懂。我也是今天跑通之后才对框架内部的自动代理、工具注册机制有了更深的认同感。
按照我自己的规划,第五天会去碰向量数据库和检索增强生成,把私域知识库挂进来,让这个工单助手能翻旧文档、读历史记录。到时候如果有值得记的坑,我再来写第五天的记录。