裸调LLM_API-Java的HTTP客户端就够

发布时间:2026/11/5 3:58:11
裸调LLM_API-Java的HTTP客户端就够

裸调 LLM API:Java 的 HTTP 客户端就够

框架三行代码就能跑通,报文却从没看清过。哪天框架出问题,日志里全是框架自己的词汇,你连「正确的报文长什么样」都不知道。这篇把框架摘掉,用 JDK 自带的 HttpClient 裸调一次模型接口,tool_use 的原始 JSON 全部见光,工单循环自己转一圈。做过一次裸调,所有框架的文档就都变成了「翻译稿」。

一次裸调的报文往返

调模型接口就是一次普通的 HTTP POST。请求体分三块:model 指定用哪个模型,messages 是对话历史,tools 是工具清单(相当于接口文档)。

{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"北京今天多少度?"}],"tools":[{"name":"get_weather","description":"查询指定城市的当前天气","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}

响应体里,content 是一个块数组;需要调用工具时,其中会有一块 tool_use,带着工具名和参数。

{"content":[{"type":"tool_use","id":"toolu_01","name":"get_weather","input":{"city":"北京"}}],"stop_reason":"tool_use"}

(字段名按主流 API 的公开文档口径整理,Anthropic 的工具定义用 input_schema,其他家叫 parameters,形状相同,核验 2026-10-25。)

模型 API你的 Java 代码模型 API你的 Java 代码你的代码执行 getWeather(北京)POST /messages(messages + tools)content 含 tool_use(要调 get_weather)POST /messages(追加 tool_result)content 是最终文字回答

自己转一圈工单循环

看懂报文之后,循环的四步全是你写过的代码。第一步,发请求。第二步,解析响应,看 stop_reason 是不是 tool_use,是就取出工具名和参数。第三步,执行工具,也就是调用你自己的普通方法。第四步,把结果包成 tool_result 塞进 messages 再发一次请求,直到 stop_reason 不再是 tool_use。
没有框架,循环只有四个 HTTP 动作的厚度。框架替你做的其实是三件事:报文的拼装、循环的托管、各家供应商差异的抹平。

为什么值得裸一次

排查的底牌。框架报错时,你能从框架日志反推「原始报文应该长什么样」,定位是配置错了还是供应商改了字段。

换供应商的基准。裸调一遍 A 家再裸调一遍 B 家,字段差异当场对比,选型不再靠评测软文。

性能的真相。一次工单循环实际是两次以上的 HTTP 往返,延迟的账单在裸调里看得最清楚,这是调优的起点。

亲手跑

下面用一段 Java 拼请求报文、发请求、解析响应。没有配置 AI_API_KEY 时走演示模式,打印报文并解析一份模拟响应,形状和真实响应一致。直接运行java BareLlmCall.java(JDK 11+)。

// 运行:java BareLlmCall.java// 裸调一次模型接口。无 AI_API_KEY 时演示模式,打印并解析报文。importjava.net.URI;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;publicclassBareLlmCall{staticfinalStringREQUEST_BODY=""" { "model": "claude-sonnet-4-5", "messages": [ {"role": "user", "content": "北京今天多少度?"} ], "tools": [ { "name": "get_weather", "description": "查询指定城市的当前天气", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } ] } """;staticfinalStringMOCK_RESPONSE=""" { "content": [ { "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "北京"} } ], "stop_reason": "tool_use" } """;publicstaticvoidmain(String[]args)throwsException{StringapiKey=System.getenv("AI_API_KEY");Stringresponse;if(apiKey==null){System.out.println("== 演示模式:这就是将要发出的请求体 ==");System.out.println(REQUEST_BODY);response=MOCK_RESPONSE;}else{HttpRequestrequest=HttpRequest.newBuilder().uri(URI.create("https://api.example.com/v1/messages")).header("x-api-key",apiKey).header("anthropic-version","2023-06-01").header("content-type","application/json").POST(HttpRequest.BodyPublishers.ofString(REQUEST_BODY)).build();response=HttpClient.newHttpClient().send(request,HttpResponse.BodyHandlers.ofString()).body();}System.out.println("== 响应体 ==");System.out.println(response);// 演示级解析,看懂形状为目的,生产请用 JSON 库if(response.contains("\"tool_use\"")){Stringtool=extract(response,"\"name\":",",");Stringcity=extract(response,"\"city\":","}");System.out.println("循环第二步:发现工单,工具="+tool.trim()+",参数 city="+city.replace("\"","").trim());System.out.println("循环第三步:执行 getWeather("+city.replace("\"","").trim()+")");System.out.println("循环第四步:把结果包成 tool_result,追加进 messages 再发一次。");}else{System.out.println("stop_reason 不是 tool_use,这就是最终回答。");}}staticStringextract(Stringbody,Stringkey,StringendMark){inti=body.indexOf(key);if(i<0){return"?";}ints=i+key.length();inte=body.indexOf(endMark,s);returnbody.substring(s,e>0?e:body.length());}}

记住一句话:框架是翻译稿,报文才是原文,原文只在裸调里见过一次就再也不会忘。

适用边界

这一篇管非流式的最小调用。流式响应的 SSE 报文、各家字段的完整对照表、以及鉴权头的差异(Bearer 与 x-api-key),是另外的题。演示的字符串解析只为看形状,生产代码请用 JSON 库。报文按 2026-10-25 的主流 API 文档口径整理,字段名以你用的供应商为准。跑完把演示模式的报文存下来,它就是你以后排查一切框架问题的对照基准。