第 03 篇:Java HttpClient 手写第一个 Chat 请求
系列:《Java 大模型应用开发入门》第 03 篇。
源码定位:
com.example.llm.client.SimpleHttpAiClient、com.example.llm.controller.DebugController。
一、背景
前两篇我们已经把链路看清了:
HTTP POST → Authorization 头 → JSON 请求体 → 200/4xx/5xx → JSON 响应体
现在把它翻译成 Java。
绝大多数人的做法是直接上 WebClient 或者某个厂商 SDK。这一篇我们刻意退一步,用 JDK 21 自带的 java.net.http.HttpClient 手写一遍。
这么做的理由有三个。
零依赖。不引入任何 jar,你能确认这确实是 JDK 能做到的事,而不是某个库的魔法。
看得见。URL、请求头、JSON 体、超时、状态码、响应体全在你眼前,没有一层封装挡着。
有对照组。第 04 篇会用 WebClient 重写同样的功能。放在一起,你才能说清「框架到底帮我做了什么」。
SimpleHttpAiClient在本工程里不是被淘汰的代码。它一直留着,通过/ai/debug/simple-chat可以直接调用,用来和WebClient版本做行为对比。
二、目标
- 用纯 JDK 写一个能跑通的最小大模型客户端;
- 搞清
HttpClient的三个组成部分:HttpClient/HttpRequest/HttpResponse; - 搞清连接超时和请求超时是两个不同的东西;
- 处理 UTF-8 编码、错误响应体、异常响应结构;
- 封装出一个最小可用的
chat(String prompt)方法。
三、前置
- 第 01 篇的 mock 服务(或真实 API Key)已就绪;
- 主工程已启动;
- 环境变量可选:
AI_BASE_URL/AI_API_KEY/AI_MODEL。
四、核心概念
JDK HttpClient 的三件套
Java 11 引入了 java.net.http,到 Java 21 已经相当成熟。它的 API 只有三个核心角色:
java
HttpClient httpClient = HttpClient.newBuilder()...build(); // 谁来发
HttpRequest request = HttpRequest.newBuilder(uri)...build(); // 发什么
HttpResponse<String> res = httpClient.send(request, BodyHandlers.ofString()); // 发出去,拿回来
HttpClient 是重量级对象,应该复用。它内部维护连接池,每次 new 一个都会丢掉连接复用,在高频调用场景下会直接体现为 P99 延迟飙升。所以本工程把它放在构造方法里创建一次。
两种超时,别搞混
java
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)) // ① 建连超时:TCP 握手多久算失败
.build();
HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(20)) // ② 请求超时:从发出到拿到响应的总时间
.build();
| 超时 | 覆盖阶段 | 不设会怎样 |
|---|---|---|
connectTimeout |
仅 TCP 建连 | DNS 解析慢或 IP 不通时,线程会等系统默认值,可能几十秒 |
HttpRequest.timeout |
整个请求(含建连、发送、等响应、读响应) | 上游挂住不返回时,线程永远不返回,这是最危险的一种 |
两个都必须设。只设 connectTimeout 是最常见的新手错误:连接建得很快,但服务端不吐数据,请求就永远挂着。
字符编码:三处都要 UTF-8
中文乱码是 Java 调 HTTP 接口的经典问题,涉及三个地方:
java
// ① 请求体用 UTF-8 编码
HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)
// ② 响应体用 UTF-8 解码
httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
// ③ 启动参数加 -Dfile.encoding=UTF-8(Windows 上尤其需要)
错误响应体必须读
这是本篇最重要的一条纪律:
java
// 错误写法:把最有价值的信息丢了
if (response.statusCode() != 200) {
throw new IOException("调用失败");
}
// 正确写法:状态码 + 响应体一起带出来
if (response.statusCode() != 200) {
throw new IOException("调用大模型失败,HTTP " + response.statusCode()
+ ",响应体:" + response.body());
}
回忆第 01 篇的 404 响应体:
json
{"error":{"message":"The model `gpt-5-ultra` does not exist. 可用模型:[mock-gpt-4o-mini, ...]"}}
上游已经把答案告诉你了,只读状态码等于把说明书扔了。
五、代码实操
第一步:定义 DTO
先定义最小 DTO。这里为了聚焦 HTTP 怎么发,刻意用 Map 拼请求体、用 JsonNode 读响应体,不引入一堆 DTO 类。正式的 DTO 设计留到第 04 篇。
java
public record ChatResult(String content, int promptTokens, int completionTokens, long latencyMs) {
}
ChatResult 是本篇自造的业务对象,跟上游协议无关。这一点很关键:不要让上游的 DTO 语义一路渗透到业务层。
第二步:完整实现
java
public class SimpleHttpAiClient {
private final String baseUrl;
private final String apiKey;
private final String model;
private final HttpClient httpClient;
private final ObjectMapper objectMapper = new ObjectMapper();
public SimpleHttpAiClient(String baseUrl, String apiKey, String model) {
// 统一去掉结尾斜杠,避免拼出 //chat/completions
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.apiKey = apiKey;
this.model = model;
// 连接超时只覆盖建连阶段,读超时由 HttpRequest.timeout 控制,两者都要设
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
public ChatResult chat(String prompt) throws IOException, InterruptedException {
Map<String, Object> payload = Map.of(
"model", model,
"temperature", 0.7,
"messages", List.of(Map.of("role", "user", "content", prompt)));
String json = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/chat/completions"))
.timeout(Duration.ofSeconds(20))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
long start = System.currentTimeMillis();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
long latency = System.currentTimeMillis() - start;
// 关键:不要只处理 200,错误响应体里往往有最有价值的排查信息
if (response.statusCode() != 200) {
throw new IOException("调用大模型失败,HTTP " + response.statusCode()
+ ",响应体:" + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode choices = root.path("choices");
if (!choices.isArray() || choices.isEmpty()) {
throw new IOException("响应结构不符合预期:" + response.body());
}
String content = choices.get(0).path("message").path("content").asText();
JsonNode usage = root.path("usage");
return new ChatResult(content,
usage.path("prompt_tokens").asInt(0),
usage.path("completion_tokens").asInt(0),
latency);
}
}
几个值得单独说的细节
为什么用 path() 而不是 get()?
java
root.path("choices").get(0).path("message").path("content").asText()
path() 在键不存在时返回 MissingNode,后续调用不会抛 NPE;get() 返回 null,链式调用立刻 NPE。处理不可信的外部 JSON 时,一律用 path()。
为什么加 Accept: application/json?
服务端可能同时支持 JSON 和 SSE。显式声明 Accept 是告诉服务端「我要非流式」。第 06 篇做流式时这里要改成 text/event-stream,这个头写错,流式就会静默退化成一次性返回。
为什么锁 HTTP_1_1?
部分网关和代理对 HTTP/2 的支持不完整,会偶发 EOF reached 之类的问题。教学和排查阶段显式锁 HTTP_1_1,可以排除一个变量。
异常为什么不吞?
java
public ChatResult chat(String prompt) throws IOException, InterruptedException
InterruptedException 绝对不能吞掉,正确做法是先 Thread.currentThread().interrupt() 再抛出。吞掉中断信号会让线程池无法正常关闭。
加个 main 方法,方便命令行验证
java
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("AI_BASE_URL", "http://localhost:8899/v1");
String apiKey = System.getenv().getOrDefault("AI_API_KEY", "mock-key-123456");
String model = System.getenv().getOrDefault("AI_MODEL", "mock-gpt-4o-mini");
String prompt = args.length > 0 ? args[0] : "用一句话说明什么是 Token";
SimpleHttpAiClient client = new SimpleHttpAiClient(baseUrl, apiKey, model);
System.out.println("baseUrl = " + baseUrl);
System.out.println("model = " + model);
System.out.println("prompt = " + prompt);
System.out.println("--------------------------------------------------");
ChatResult result = client.chat(prompt);
System.out.println("content = " + result.content());
System.out.println("usage = prompt=" + result.promptTokens()
+ ", completion=" + result.completionTokens()
+ ", total=" + (result.promptTokens() + result.completionTokens()));
System.out.println("latency = " + result.latencyMs() + " ms");
}
注意这个 main 没有把 Key 打印出来。日志里泄露密钥是低频但致命的事故,从第一篇代码开始就要养成习惯。
暴露一个 HTTP 入口,方便对比
SimpleHttpAiClient 是纯 JDK 实现,不参与 Spring 依赖注入。为了能通过浏览器或 curl 直接验证它,本工程提供了一个调试入口:
java
@RestController
@RequestMapping("/ai/debug")
public class DebugController {
private final AiProperties aiProperties;
@GetMapping("/simple-chat")
public ApiResponse<Map<String, Object>> simpleChat(@RequestParam String prompt) throws Exception {
SimpleHttpAiClient client = new SimpleHttpAiClient(
aiProperties.getBaseUrl(), aiProperties.getApiKey(), aiProperties.getModel());
SimpleHttpAiClient.ChatResult result = client.chat(prompt);
Map<String, Object> data = new LinkedHashMap<>();
data.put("impl", "java.net.http.HttpClient(第 03 篇)");
data.put("baseUrl", aiProperties.getBaseUrl());
data.put("model", aiProperties.getModel());
data.put("content", result.content());
data.put("promptTokens", result.promptTokens());
data.put("completionTokens", result.completionTokens());
data.put("latencyMs", result.latencyMs());
return ApiResponse.ok(data);
}
}
data.impl 字段显式标明了用的是哪条实现路径。第 04 篇我们会用同样的接口形状再返回一次 WebClient 的结果,放在一起对比。
六、验证
命令行直连
bash
# 编译
mvn -f llm-tutorial/pom.xml clean package -DskipTests
# 直接用 JDK 的 main 跑(注意 Windows 上路径分隔符是 ;)
java -cp "llm-tutorial/target/classes;$(ls ~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/*/jackson-databind-*.jar):..." \
com.example.llm.client.SimpleHttpAiClient "用一句话说明什么是 Token"
手工拼 classpath 嫌麻烦的话,直接跑下面的 HTTP 入口即可,效果一样。
通过主工程验证(推荐)
bash
curl 'http://127.0.0.1:8080/ai/debug/simple-chat?prompt=用一句话解释什么是Token'
实测输出:
json
{
"code": 0,
"message": "成功",
"traceId": "afc40c5e98cb4e76",
"data": {
"impl": "java.net.http.HttpClient(第 03 篇)",
"baseUrl": "http://localhost:8899/v1",
"model": "mock-gpt-4o-mini",
"content": "【本地模拟回复】我收到了你的问题:「用一句话解释什么是Token」。当前运行的是 mock-llm-server,用规则引擎代替真实模型,因此回复内容是确定性的、可复现的。把 ai.base-url 换成真实服务地址与 Key,即可获得真实模型的回答。",
"promptTokens": 15,
"completionTokens": 88,
"latencyMs": 67
}
}
| 检查项 | 期望 |
|---|---|
| HTTP 200 | 通过 |
data.content 有非空字符串 |
通过 |
data.promptTokens / completionTokens 有值 |
15 / 88 |
data.latencyMs 有耗时 |
67 ms |
data.impl 标明了实现路径 |
java.net.http.HttpClient(第 03 篇) |
留意这里的 promptTokens = 15,比第 02 篇 /ai/chat 的 62 小很多。因为本篇只发了一条 user 消息,没有 system。
一个 system 提示词就要占 30~50 Token,这就是第 02 篇说的「输入 Token 是大头」。
验证错误路径
由于 DebugController 用的是配置里的模型名,最直接的验证方式是把 application-local.yml 里的 ai.model 临时改成一个不存在的名字,重启后调用。日志里你会看到:
调用大模型失败,HTTP 404,响应体:{"error":{"message":"The model `mock-gpt-4o-mini-x` does not exist. 可用模型:[...]","type":"model_not_found","code":"model_not_found"}}
这行日志就是本篇的成果:状态码和响应体都保留了,排查时间从「猜」变成「看」。
七、常见坑
| 坑 | 表现 | 原因 / 解法 |
|---|---|---|
只设 connectTimeout,不设 HttpRequest.timeout |
上游挂住时线程永久阻塞 | 两个都必须设,请求超时才是真正的读超时 |
HttpClient 每次 new |
高并发下延迟飙升 | HttpClient 是重量级对象,要复用 |
| 响应体不定编码 | 中文乱码 | 三处 UTF-8:请求体、响应体、-Dfile.encoding |
用 get() 链式取树 |
偶发 NPE | 一律用 path(),返回 MissingNode 而不是 null |
| 不读错误响应体 | 排查全靠猜 | 把 statusCode 和 body 一起放进异常消息 |
吞掉 InterruptedException |
线程池关不掉、中断信号丢失 | 先 Thread.currentThread().interrupt() 再抛出 |
baseUrl 结尾带 / |
拼出 //chat/completions,部分网关 404 |
构造时统一去掉结尾斜杠 |
Accept 头写错 |
想要流式却拿到一次性 JSON | 非流式用 application/json,流式用 text/event-stream |
| 把 API Key 打进日志 | 密钥泄露 | 日志只打模型名、耗时、Token,Key 一律脱敏 |
Throwable 一把抓 |
把 OOM、栈溢出也当业务错误重试 | 只捕获明确可恢复的异常类型 |
八、小结与下一篇
这篇做完的事:
- 用 JDK 21 自带的
HttpClient从零写出了能跑通的大模型客户端; - 搞清了
HttpClient/HttpRequest/HttpResponse三件套的分工; - 分清了连接超时和请求超时,并明确两个都要设;
- 建立了「错误响应体必须读」这条纪律;
- 通过
/ai/debug/simple-chat拿到了真实可验证的结果。
但你现在再看这个类,应该能感觉到几处不舒服:
chat(String prompt)只支持一条消息,没法传system;- 请求体用
Map拼,字段名写错了编译器不报错; - 每次都要
throw IOException, InterruptedException,异常语义含糊; - 没有流式、没有向量化、没有统一超时配置;
- 错误处理散落在 if 里,没法复用。
这些都是「能跑」和「能上生产」之间的差距。
下一篇我们用 Spring Boot 3 加 WebClient 把它重写成一个可复用的生产级客户端:统一超时、统一请求头、统一错误归一、统一日志脱敏。
下一篇 → 第 04 篇:Spring Boot 3 + WebClient 封装 OpenAI 客户端
跟着敲的建议:本文的
SimpleHttpAiClient请不要删。第 04 篇和第 08 篇都会拿它当对照组,它是你理解「框架做了什么」的标尺。