裸调 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。)
自己转一圈工单循环
看懂报文之后,循环的四步全是你写过的代码。第一步,发请求。第二步,解析响应,看 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 时演示模式,打印并解析报文。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class BareLlmCall {
static final String REQUEST_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"]
}
}
]
}
""";
static final String MOCK_RESPONSE = """
{
"content": [
{
"type": "tool_use",
"id": "toolu_01",
"name": "get_weather",
"input": {"city": "北京"}
}
],
"stop_reason": "tool_use"
}
""";
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("AI_API_KEY");
String response;
if (apiKey == null) {
System.out.println("== 演示模式:这就是将要发出的请求体 ==");
System.out.println(REQUEST_BODY);
response = MOCK_RESPONSE;
} else {
HttpRequest request = 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\"")) {
String tool = extract(response, "\"name\":", ",");
String city = 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,这就是最终回答。");
}
}
static String extract(String body, String key, String endMark) {
int i = body.indexOf(key);
if (i < 0) {
return "?";
}
int s = i + key.length();
int e = body.indexOf(endMark, s);
return body.substring(s, e > 0 ? e : body.length());
}
}
记住一句话:框架是翻译稿,报文才是原文,原文只在裸调里见过一次就再也不会忘。
适用边界
这一篇管非流式的最小调用。流式响应的 SSE 报文、各家字段的完整对照表、以及鉴权头的差异(Bearer 与 x-api-key),是另外的题。演示的字符串解析只为看形状,生产代码请用 JSON 库。报文按 2026-10-25 的主流 API 文档口径整理,字段名以你用的供应商为准。跑完把演示模式的报文存下来,它就是你以后排查一切框架问题的对照基准。
242



被折叠的 条评论
为什么被折叠?



