
1. 为什么 Java 后端接大模型 API卡点从来不在“调通”很多 Java 同学第一次接大模型 API五分钟就能发出一个请求然后觉得这事很简单。但真把它放进订单、客服、审核这类业务里问题会一个接一个冒出来模型返回的不是纯 JSON、流式响应读成了整段、超时之后不知道该不该重试、多轮对话把 Token 越堆越高、并发一上来 Tomcat 线程全被占满。这篇聚焦一个具体场景在 Java 后端项目里用 Function Calling 让模型调用你自己的方法。我会给你一套能直接复制的 Maven 依赖、HTTP 客户端骨架、JSON 解析配置以及一次完整的工具调用请求与响应验证。跑通之后你手里就有一个可以往业务里塞的最小可用版本。适合谁看写过 Spring Boot、知道HttpClient或 RestTemplate 怎么用、但还没把大模型 API 真正工程化落地的后端开发。如果你只是想先感受一下模型对话是什么样可以先去 模型对话 页面手动发几条消息观察一下返回结构再回来看代码会更有感觉。下面所有代码都基于 OpenAI 兼容的/v1/chat/completions格式换厂商只需要改baseUrl、apiKey、model三个配置不动业务代码。2. 前置准备TaoToken 的 Key、地址与依赖2.1 拿到可用的 API Key接入的第一步是有一个能用的 Key。打开 API Keys 页面创建一个新 Key复制出来先放到环境变量里别写进代码export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api/v1注意Key 一旦出现在 Git 提交、日志、异常堆栈里就等于泄露。后面第九节会讲怎么脱敏。2.2 Maven 依赖HTTP 客户端用 JDK 11 自带的java.net.http.HttpClient不额外引 HTTP 库JSON 用 Jackson。pom.xml里加这几项就够properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target jackson.version2.17.2/jackson.version /properties dependencies dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version${jackson.version}/version /dependency dependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jdk8/artifactId version${jackson.version}/version /dependency /dependenciesJackson 的jdk8模块是为了让Optional、LocalDateTime这类类型能正常序列化工具调用的参数里经常会用到。2.3 配置骨架用一个配置类把三个变量收口方便灰度切换厂商public record LlmConfig(String apiKey, String baseUrl, String model) { public static LlmConfig fromEnv() { return new LlmConfig( System.getenv(TAOTOKEN_API_KEY), System.getenv(TAOTOKEN_BASE_URL), System.getenv().getOrDefault(TAOTOKEN_MODEL, gpt-4o-mini) ); } }baseUrl指向https://taotoken.net/api/v1请求路径拼上/chat/completions就是完整地址。这样设计的好处是哪天要换模型或换厂商只改环境变量代码零改动。3. 可复制配置HttpClient 与 JSON 解析骨架3.1 复用 HttpClient别每次 new生产上每次请求新建HttpClient会浪费连接池。正确做法是把它做成单例配好连接超时public class LlmHttpClient { private final HttpClient http; private final ObjectMapper mapper; public LlmHttpClient() { this.http HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .version(HttpClient.Version.HTTP_1_1) .build(); this.mapper new ObjectMapper() .registerModule(new Jdk8Module()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } public HttpClient http() { return http; } public ObjectMapper mapper() { return mapper; } }FAIL_ON_UNKNOWN_PROPERTIES关掉很重要模型返回的字段经常比你声明的多不关掉会直接抛异常。3.2 请求体构造用Map拼请求体比字符串拼接安全也避免手写转义MapString, Object body new LinkedHashMap(); body.put(model, config.model()); body.put(messages, messages); body.put(temperature, 0.2); body.put(tools, tools); // Function Calling 时带上 body.put(tool_choice, auto); // 让模型自己决定是否调用 String json mapper.writeValueAsString(body);temperature在工具调用场景建议调低0.1~0.3因为你要的是稳定的参数抽取不是创意发挥。3.3 发送请求HttpRequest req HttpRequest.newBuilder() .uri(URI.create(config.baseUrl() /chat/completions)) .timeout(Duration.ofSeconds(60)) .header(Authorization, Bearer config.apiKey()) .header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) .build(); HttpResponseString resp http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (resp.statusCode() ! 200) { throw new LlmException(HTTP resp.statusCode() : resp.body()); }到这里一个可复用的请求骨架就搭好了。接下来是 Function Calling 的核心部分。4. Function Calling 完整代码声明工具、解析调用、回传结果4.1 工具声明工具用 JSON Schema 描述。假设你要让模型查订单声明一个query_order_by_idString toolsJson [ { type: function, function: { name: query_order_by_id, description: 根据订单号查询订单状态订单号格式为纯数字, parameters: { type: object, properties: { orderId: { type: string, description: 订单号例如 20240517001 } }, required: [orderId] } } } ] ; JsonNode tools mapper.readTree(toolsJson);description写得越具体模型抽参数越准。我试过把“订单号格式为纯数字”写进去之后模型不再把“我的订单”这种词当订单号传进来。4.2 解析 tool_calls模型决定调用工具时返回的message里会带tool_calls数组注意arguments是 JSON 字符串不是对象JsonNode root mapper.readTree(resp.body()); JsonNode message root.path(choices).path(0).path(message); JsonNode toolCalls message.path(tool_calls); if (toolCalls.isArray() !toolCalls.isEmpty()) { JsonNode call toolCalls.get(0); String callId call.path(id).asText(); String fnName call.path(function).path(name).asText(); String argsRaw call.path(function).path(arguments).asText(); // arguments 是字符串要再解析一层 JsonNode args mapper.readTree(argsRaw); String orderId args.path(orderId).asText(); String result queryOrderById(orderId); // 真正调你的内部服务 // 把执行结果作为 tool 角色消息回传 messages.add(Map.of( role, tool, tool_call_id, callId, content, result )); // 再发一次请求让模型组织最终答复 String finalAnswer chat(messages); }这里有两个容易踩的点一是arguments必须二次readTree直接当对象用会拿到空二是回传消息必须带tool_call_id否则模型不知道这是哪次调用的结果。4.3 完整方法串起来public String askWithTools(ListMapString, Object messages, JsonNode tools) throws Exception { MapString, Object body new LinkedHashMap(); body.put(model, config.model()); body.put(messages, messages); body.put(tools, tools); body.put(tool_choice, auto); String respBody post(/chat/completions, mapper.writeValueAsString(body)); JsonNode root mapper.readTree(respBody); JsonNode message root.path(choices).path(0).path(message); if (message.has(tool_calls) message.path(tool_calls).isArray()) { // 处理工具调用见 4.2处理完递归再问一次 return handleToolCalls(message, messages, tools); } return message.path(content).asText(); }handleToolCalls里执行完工具后把结果塞回messages再调一次askWithTools模型就会基于工具结果生成自然语言答复。5. 验证请求一次完整的工具调用与成功结果5.1 准备一个假的订单服务为了本地能跑通先用一个内存 Map 模拟private String queryOrderById(String orderId) { MapString, String fakeDb Map.of( 20240517001, 已发货预计明天送达, 20240517002, 待付款 ); return fakeDb.getOrDefault(orderId, 未找到该订单); }5.2 发起请求并观察ListMapString, Object messages new ArrayList(); messages.add(Map.of(role, system, content, 你是订单助手需要查订单时调用工具。)); messages.add(Map.of(role, user, content, 帮我看看订单 20240517001 到哪了)); String answer askWithTools(messages, tools); System.out.println(answer);5.3 预期结果第一次请求返回的message里没有content只有tool_calls形如{ role: assistant, tool_calls: [ { id: call_abc123, type: function, function: { name: query_order_by_id, arguments: {\orderId\:\20240517001\} } } ] }你的代码执行queryOrderById拿到“已发货预计明天送达”回传后再请求一次最终content会是类似“您的订单 20240517001 已发货预计明天送达”的自然语言。看到这个输出说明整条链路通了。提示如果第一次返回直接是content而没有tool_calls说明模型判断不需要调工具。可以换一句更明确的用户输入比如“调用工具查一下订单 20240517001”。6. 本篇常见错排查6.1 报 401 或 403先确认Authorization头是不是Bearer加 Key中间有空格。再确认 Key 没有多余换行——从网页复制时经常带一个尾部换行trim()一下。如果还不行去 API Keys 页面确认 Key 状态正常、额度没用完。6.2 报 400提示 tools 格式错误大概率是tools传成了字符串而不是 JSON 数组。用mapper.readTree(toolsJson)转成JsonNode再放进请求体别直接put(tools, toolsJson)。6.3 arguments 解析出来是空arguments是 JSON 字符串必须mapper.readTree(argsRaw)再取字段。直接args.path(orderId)在字符串节点上取不到东西。6.4 模型不调用工具直接回答检查tool_choice是不是auto以及工具description是否足够清楚。如果模型总是自己编答案把tool_choice临时设成{type:function,function:{name:query_order_by_id}}强制调用一次验证链路再改回auto。6.5 流式场景读不到内容如果你开了stream: true响应体必须用BodyHandlers.ofInputStream()逐行读不能用ofString()否则会等整个响应结束才拿到流式就失去意义了。逐行解析时只处理以data:开头的行遇到[DONE]结束。6.6 超时后不知道该不该重试超时重试要谨慎如果这次调用已经消耗了 Token 但响应超时重试就是再花一次钱。建议只对 429限流做指数退避重试超时直接降级到兜底逻辑返回“模型繁忙请稍后再试”。具体接入细节可以对照 接入文档 里的错误码说明处理。7. 工程化收尾并发、成本与安全7.1 别阻塞 Tomcat 线程大模型调用是慢 IO直接在 Controller 里同步调会把 Tomcat 线程占满。用自定义线程池 CompletableFutureprivate final ExecutorService pool new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadPoolExecutor.CallerRunsPolicy() ); public CompletableFutureString answerAsync(String userMsg) { return CompletableFuture.supplyAsync(() - { try { return askWithTools(buildMessages(userMsg), tools); } catch (Exception e) { return 抱歉暂时无法处理请稍后再试。; } }, pool); }有界队列 CallerRunsPolicy是背压的关键队列满了让调用方自己跑避免无限堆积。7.2 上下文裁剪多轮对话别无限累加messages超过一定轮数就裁掉中间的if (messages.size() 20) { int keep 8; messages.subList(1, messages.size() - keep).clear(); // 保留 system 最近 8 条 }7.3 密钥脱敏异常信息里如果出现 Key打日志前先替换String safe raw.replaceAll(sk-[A-Za-z0-9], sk-***);7.4 长期编码场景如果你是要把 Function Calling 做成 Agent 骨架、长期跑在编码或自动化任务里单次调用成本会累积建议看一下 Coding Plan 的额度方案比按次调用更适合高频场景。控制台里也能看到每次调用的 Token 消耗方便做成本核算。8. 下一步把工具调用做成可注册的骨架上面这套代码跑通之后你会发现每加一个工具就要改一次toolsJson和handleToolCalls里的 switch很别扭。下一步可以做一个工具注册表用注解标记方法启动时扫描生成 JSON Schema调用时按function.name反射分发。这样新增工具只需要写一个方法加一个注解不用动核心逻辑。如果你还没跑通基础对话建议先去 模型对话 手动发几条带工具的请求看看原始返回长什么样再回来对照代码会快很多。跑通之后把queryOrderById换成你真实的内部服务调用注意加超时和幂等就可以往业务里灰度了。