第 03 篇:Java HttpClient 手写第一个 Chat 请求

第 03 篇:Java HttpClient 手写第一个 Chat 请求

系列:《Java 大模型应用开发入门》第 03 篇。

源码定位:com.example.llm.client.SimpleHttpAiClientcom.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 版本做行为对比。


二、目标

  1. 用纯 JDK 写一个能跑通的最小大模型客户端;
  2. 搞清 HttpClient 的三个组成部分:HttpClient / HttpRequest / HttpResponse
  3. 搞清连接超时和请求超时是两个不同的东西;
  4. 处理 UTF-8 编码、错误响应体、异常响应结构;
  5. 封装出一个最小可用的 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
不读错误响应体 排查全靠猜 statusCodebody 一起放进异常消息
吞掉 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 篇都会拿它当对照组,它是你理解「框架做了什么」的标尺。

相关推荐
世岩清上1 小时前
一次性完工的数字展厅,如何预留后期内容更新空间?
大数据·网络·人工智能·音视频·展厅改造
LearnYard1 小时前
支持本地私有化部署的企业网盘选型:信创适配与 Docker 部署实践
大数据·人工智能
揽秀亭长1 小时前
如何提取视频中的脚本内容?常见方法与工具对比
人工智能·音视频
西峰u1 小时前
Java多线程从入门到线程安全
java·开发语言·jvm
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】LLMManager类架构与智能指针选型
网络·c++·人工智能·学习·面试·架构
时空未宇1 小时前
Codex 桌面版通过 SSH 访问 Docker 编译环境
语言模型·openai·codex
明航咨询-陈老师1 小时前
CS 信息系统建设和服务能力评估 2026:五级体系、4 年有效期与 ITSS/CMMI 协同选择实务
人工智能·cs资质
lisw051 小时前
网络安全与人工智能:进展、挑战、机遇与威胁!
人工智能·网络安全