
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. 实操一个可运行的最小工具调用Demo3.1 场景设计与准备工作今天我做了一个“工单查询助手”场景非常简单用户发一句“帮我查一下工单TC20250601到哪一步了”模型识别出需要查询于是触发我封装的queryTicketStatus方法再把结果组织成一句话返回。准备过程很简单就三个依赖JDK 17以上我用的17Jackson处理JSON以及任意一个兼容OpenAI协议的大模型服务接口。我不建议在这一步引入复杂框架先用HttpClient原生的方式把协议走通后面你上Spring AI还是LangChain4j都会轻松很多。先定义一个工具描述方法核心就是构造那一段JSON Schemaprivate static MapString, Object buildTicketTool() { MapString, Object parameters new LinkedHashMap(); parameters.put(type, object); MapString, Object props new LinkedHashMap(); MapString, 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)); MapString, Object function new LinkedHashMap(); function.put(name, query_ticket_status); function.put(description, 根据工单号查询当前处理状态); function.put(parameters, parameters); MapString, 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(ListMapString, Object messages) throws Exception { MapString, 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(); HttpResponseString response HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); }注意API_URL和API_KEY我从环境变量读取不要硬编码到代码里。模型名称要替换成你实际在用的服务商支持的名字。tool_choice设为auto表示让模型自己决定是否调用工具如果设为required那不管什么对话都强制调用有时候会得到很奇怪的回复。响应解析环节是今天的重点之一。模型如果决定调用工具返回的JSON里会有finish_reasontool_calls以及一个tool_calls数组。我先把响应转成JsonNodeJsonNode 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(); MapString, Object args objectMapper .readValue(argsJson, new TypeReference() {}); String result query_ticket_status.equals(functionName) ? queryTicketStatus((String) args.get(ticketNo)) : 未知工具; MapString, 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(ListMapString, 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类本质上就是ListMapString, 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、维护过消息顺序之后再去看框架的源代码基本上就是老朋友见面一眼就懂。我也是今天跑通之后才对框架内部的自动代理、工具注册机制有了更深的认同感。按照我自己的规划第五天会去碰向量数据库和检索增强生成把私域知识库挂进来让这个工单助手能翻旧文档、读历史记录。到时候如果有值得记的坑我再来写第五天的记录。