← 返回资讯

Java 调用大模型 API 完整示例

2026-06-22

Java 调用大模型 API 主要有两条路:JDK 11+ 内置的 java.net.http.HttpClient,或生态更成熟的 OkHttp 库。两者都走 HTTP POST 到 /chat/completions,只需把 base_url 改成目标平台即可跨模型复用代码。

如果你是在一个老项目里加这个功能,大概率会遇到两个纠结点:一是团队里可能还锁着 JDK 8 或 JDK 11 早期版本,HttpClient 这类新 API 用不了,只能上 OkHttp;二是就算能用 JDK 11+,很多人写完 demo 之后直接把这套裸代码扔进生产环境,结果第一次遇到网络抖动就整个服务卡死——因为没设超时、没做重试、拿到 429 也不知道怎么处理。这篇不只给你两套能跑的代码,还会把这几个真实会踩的坑一并讲透:字符串硬解析 JSON 会在什么场景炸掉、SSE 流式输出到底怎么用 Java 接、429 限流之后要怎么退避、以及中文乱码这种看似低级但天天有人问的问题根源在哪。

环境准备

<!-- pom.xml:OkHttp + JSON 序列化(可选) -->
<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>okhttp</artifactId>
  <version>4.12.0</version>
</dependency>
<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.10.1</version>
</dependency>

API key 通过环境变量注入,绝不硬编码:

export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"

为什么坚持用环境变量而不是配置文件里直接写死?不是洁癖,是真出过事——把 key 写进 application.yml 再提交到 Git,哪怕后来删掉了,Git 历史里还留着,别人 git log -p 一翻就能扒出来。企业微信群里常年有人分享”某公司 key 在 GitHub 泄露被刷爆几千块账单”的截图,套路基本都是这个。稳妥的做法是:本地开发用 .env + dotenv 类库加载,生产环境交给容器编排平台或 CI/CD 的密钥管理(比如 Kubernetes Secret、阿里云 KMS),代码里永远只 System.getenv() 读取,仓库里不留一个字符的真实 key。

方式一:JDK 11 HttpClient(零依赖)

适合不想引入第三方库、或在受限环境部署的场景:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class LlmClient {

    private static final String API_KEY  = System.getenv("OPENAI_API_KEY");
    private static final String BASE_URL = System.getenv().getOrDefault(
            "OPENAI_BASE_URL", "https://api.openai.com/v1");

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    public String chat(String userMessage, String model) throws Exception {
        String body = String.format("""
            {"model":"%s","messages":[{"role":"user","content":"%s"}],"max_tokens":1024}
            """, model, userMessage.replace("\"", "\\\""));

        HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create(BASE_URL + "/chat/completions"))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .timeout(Duration.ofSeconds(60))
                .build();

        HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
        if (resp.statusCode() != 200) {
            throw new RuntimeException("API error " + resp.statusCode() + ": " + resp.body());
        }
        // 简单解析(生产建议用 Gson/Jackson)
        String content = resp.body();
        int start = content.indexOf("\"content\":\"") + 11;
        int end   = content.indexOf("\"", start);
        return content.substring(start, end);
    }

    public static void main(String[] args) throws Exception {
        LlmClient client = new LlmClient();
        System.out.println(client.chat("用 Java 实现二分查找", "gpt-4o-mini"));
    }
}

上面这段 chat 方法里有个地方你多半会略过不看,但恰恰是最容易在生产上出问题的一行——content.indexOf("\"content\":\"") 这种字符串硬解析。它能跑,是因为示例里的回复足够简单:一句话、没有转义字符、也没有换行。可一旦模型回复里带了代码块(里面全是引号)、带了换行符、或者内容本身超长导致 JSON 换行格式化,这种硬解析立刻就会拿到错的截断结果,甚至直接抛数组越界异常。我把这段留在示例里是为了让你看清楚 HTTP 层到底发生了什么——请求怎么拼、响应长什么样,但生产代码这里必须换成 Gson 或 Jackson 做正经的 JSON 反序列化,别心存侥幸。另外这里的 connectTimeout 只控制建立连接的等待时间,真正等模型吐字的是请求本身的 timeout(Duration.ofSeconds(60))——如果你调的是推理慢的大参数模型,60 秒可能不够,遇到超时异常先看看是不是这个值该往上调,而不是怀疑网络有问题。

方式二:OkHttp + Gson(推荐生产)

OkHttp 支持连接池、超时、拦截器,适合在 Spring Boot 等框架中集成:

import com.google.gson.*;
import okhttp3.*;
import java.io.IOException;

public class LlmOkHttpClient {

    private static final String API_KEY  = System.getenv("OPENAI_API_KEY");
    private static final String BASE_URL = System.getenv().getOrDefault(
            "OPENAI_BASE_URL", "https://api.openai.com/v1");
    private static final MediaType JSON   = MediaType.get("application/json");

    private final OkHttpClient http = new OkHttpClient.Builder()
            .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
            .readTimeout(60,    java.util.concurrent.TimeUnit.SECONDS)
            .build();
    private final Gson gson = new Gson();

    public String chat(String userMsg, String model) throws IOException {
        JsonObject payload = new JsonObject();
        payload.addProperty("model", model);
        payload.addProperty("max_tokens", 1024);

        JsonArray messages = new JsonArray();
        JsonObject msg = new JsonObject();
        msg.addProperty("role", "user");
        msg.addProperty("content", userMsg);
        messages.add(msg);
        payload.add("messages", messages);

        Request req = new Request.Builder()
                .url(BASE_URL + "/chat/completions")
                .header("Authorization", "Bearer " + API_KEY)
                .post(RequestBody.create(gson.toJson(payload), JSON))
                .build();

        try (Response resp = http.newCall(req).execute()) {
            if (!resp.isSuccessful()) throw new IOException("API error: " + resp.code());
            JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
            return json.getAsJsonArray("choices").get(0).getAsJsonObject()
                       .getAsJsonObject("message").get("content").getAsString();
        }
    }

    public static void main(String[] args) throws IOException {
        System.out.println(new LlmOkHttpClient().chat("解释什么是 token", "gpt-4o-mini"));
    }
}

OkHttp 这版代码为什么值得你多花十分钟接进项目:第一,OkHttpClient 本身是线程安全的,且内置连接池,官方建议整个应用共用同一个实例,而不是每次调用都 new 一个——如果你在 Spring Boot 里每次请求都新建 OkHttpClient,连接池形同虚设,高并发下会把服务器的文件描述符耗尽,报出 Too many open files。第二,Gson 反序列化比字符串硬解析稳得多,但要注意 resp.body().string() 只能调用一次,调完流就关闭了,如果你既想打日志又想解析,得先存成局部变量。第三,如果拿到的是 401,先别怀疑代码逻辑,先用 curl 单独测一下这个 key 和 BASE_URL 拼出来的完整地址——十次里有八次是 base_url 结尾多了或少了一个 /v1,导致请求打到了根本不存在的路径,返回的 401 其实是网关层的鉴权兜底,不是 key 真的失效。

两种方式对比

维度JDK HttpClientOkHttp + Gson
外部依赖okhttp + gson
连接池内置(较基础)高性能连接池
拦截器不支持支持(日志/重试)
流式 SSE需手动解析需手动解析
适用场景简单脚本 / 微服务内嵌Spring Boot 等生产项目

怎么选,给你一个更直接的判断依据:如果这段调用代码只是内部工具、跑批脚本,或者部署环境对第三方 jar 包有严格审计(比如金融行业的合规内网),选 JDK HttpClient,少一个依赖就少一层被审计卡住的风险。如果是要长期维护的业务系统,尤其未来大概率要接入连接池监控、请求日志拦截、多个大模型供应商切换这些需求,直接上 OkHttp——它的 Interceptor 机制可以让你把日志打印、Token 用量统计、失败重试这些逻辑抽成独立的拦截器类,不用侵入业务代码,这是 JDK 原生 HttpClient 目前还做不到的。

流式输出:用 OkHttp 接 SSE

大模型 API 的流式响应用的是 Server-Sent Events(SSE),本质是服务端不断往同一个 HTTP 连接里写 data: {...}\n\n 这样的文本块,直到写一行 data: [DONE] 表示结束。JDK 的 HttpClient 处理这种”边收边读”不太方便,OkHttp 配合 BufferedSource 逐行读就顺手很多:

public void chatStream(String userMsg, String model, Consumer<String> onToken) throws IOException {
    JsonObject payload = new JsonObject();
    payload.addProperty("model", model);
    payload.addProperty("stream", true); // 关键:打开流式开关

    JsonArray messages = new JsonArray();
    JsonObject msg = new JsonObject();
    msg.addProperty("role", "user");
    msg.addProperty("content", userMsg);
    messages.add(msg);
    payload.add("messages", messages);

    Request req = new Request.Builder()
            .url(BASE_URL + "/chat/completions")
            .header("Authorization", "Bearer " + API_KEY)
            .post(RequestBody.create(gson.toJson(payload), JSON))
            .build();

    try (Response resp = http.newCall(req).execute()) {
        if (!resp.isSuccessful()) throw new IOException("API error: " + resp.code());
        BufferedSource source = resp.body().source();
        while (!source.exhausted()) {
            String line = source.readUtf8Line();
            if (line == null || line.isEmpty()) continue;
            if (!line.startsWith("data: ")) continue;
            String data = line.substring(6).trim();
            if (data.equals("[DONE]")) break;
            JsonObject chunk = JsonParser.parseString(data).getAsJsonObject();
            JsonArray choices = chunk.getAsJsonArray("choices");
            if (choices.size() == 0) continue;
            JsonObject delta = choices.get(0).getAsJsonObject().getAsJsonObject("delta");
            if (delta.has("content")) {
                onToken.accept(delta.get("content").getAsString());
            }
        }
    }
}

调用方式是 client.chatStream("讲讲快排", "gpt-4o-mini", token -> System.out.print(token)),每收到一个字就往控制台打一个字,跟你在网页版看到的逐字输出是一回事。这里有两个容易踩的坑:一是 payload 里一定要加 stream: true,漏了这个参数,服务端还是会一次性把完整结果吐给你,只是你用流式的代码去解析非流式的响应,大概率直接解析失败;二是 readUtf8Line() 读到的空行不能直接跳过忽略业务逻辑,SSE 协议里空行本身就是分隔符,属于正常现象,不是异常。如果你发现流式输出中途卡住不动了,先看看是不是连接被反向代理(Nginx 默认 proxy_buffering 会缓冲响应)截断了,这也是线上最常见的”流式在本地好好的,上线就变卡顿”的根因。

429 限流下的重试退避实战

前面 FAQ 提到过重试思路,这里给你一版能直接抄的实现,用的是指数退避加随机抖动(jitter),抖动是为了避免多个实例同时重试又同时撞上限流:

public String chatWithRetry(String userMsg, String model) throws Exception {
    int maxRetries = 3;
    long baseDelayMs = 1000;
    for (int attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            return chat(userMsg, model);
        } catch (IOException e) {
            boolean isRateLimited = e.getMessage() != null && e.getMessage().contains("429");
            if (!isRateLimited || attempt == maxRetries) throw e;
            long jitter = (long) (Math.random() * 300);
            long delay = baseDelayMs * (1L << attempt) + jitter; // 1s, 2s, 4s + 随机抖动
            Thread.sleep(delay);
        }
    }
    throw new IllegalStateException("重试逻辑异常退出");
}

注意这里判断是否限流用的是异常信息里带不带 “429” 字符串,实际项目里更稳妥的做法是让 chat 方法直接把状态码往外抛(比如自定义一个 ApiException 携带 statusCode 字段),不要靠字符串匹配去猜错误类型,字符串匹配这种写法本身也是一种”能跑但不严谨”的技术债,前面已经提醒过一次了。1L << attempt 是位运算实现的 2 的幂次,写法比 Math.pow(2, attempt) 快且不会有浮点转换的精度问题,在重试这种高频路径上是个值得记住的小技巧。

多轮对话:维护 messages 列表

多轮对话需把每轮 user + assistant 消息累积到同一个 messages 数组传入:

List<JsonObject> history = new ArrayList<>();
// 添加 system 角色
JsonObject sys = new JsonObject();
sys.addProperty("role", "system");
sys.addProperty("content", "你是一个 Java 编程助手。");
history.add(sys);

// 每轮:先加 user,拿到 assistant 后再加进去
JsonObject userMsg = new JsonObject();
userMsg.addProperty("role", "user");
userMsg.addProperty("content", "如何使用 Stream API?");
history.add(userMsg);

// 调用 API,把 history 传入 messages 字段
// 拿到回复后:
JsonObject assistantMsg = new JsonObject();
assistantMsg.addProperty("role", "assistant");
assistantMsg.addProperty("content", replyContent);
history.add(assistantMsg);

这段代码看着简单,真正容易被忽视的是上下文会一直膨胀这件事:history 这个列表你不做任何裁剪,聊到第 20 轮、第 50 轮,每次请求都要把从第一轮到现在的全部消息重新发一遍给模型——这不是 Java 特有的问题,是所有走 messages 数组的对话式 API 的通性,但很多人第一次上手时完全没意识到,直到某天收到一个 context_length_exceeded 的错误才反应过来。解决办法通常是两条路二选一,或者结合着用:一是设一个滑动窗口,只保留最近 N 轮(比如最近 10 轮)加上开头的 system 消息,超出的直接从 historyremove(1)(下标 0 留给 system);二是找模型定期把早期对话总结成一段摘要,替换掉那些原始消息,既省 token 又保留了话题连贯性。至于 N 取多少、要不要做摘要,取决于你的场景对”记住多久之前的内容”有多敏感——客服场景通常保留最近 5~8 轮就够用,写作辅助类场景可能需要更长的窗口来保持文风一致。

顺带说一下成本这件事:Java 里没有像 Python tiktoken 那样开箱即用的分词库,想精确估算 token 数得引入 jtokkit 这类第三方库,或者退而求其次用经验公式估算——中文场景大致按”1 个汉字 ≈ 1.5~2 个 token”来估(不同模型的分词器不完全一致,这个比例仅供预算参考,不是精确值),英文场景大致按”4 个字符 ≈ 1 个 token”估。如果你的 history 列表已经攒了几千字的历史消息,调用前用这个比例心算一下大概会花多少 token,能帮你在测试阶段就发现”这轮对话是不是已经该做摘要压缩了”,而不是等账单出来才后知后觉。

常见问题

出现 SSLHandshakeException 怎么办? 通常是 JDK 版本过低或企业代理拦截了 TLS。升级到 JDK 17+,或在代理环境配置 javax.net.ssl.trustStore

Spring Boot 中怎么注入 LlmClient?OkHttpClient 声明为 @BeanLlmOkHttpClient@Service,通过构造注入;API_KEYapplication.yml@Value 读取。

如何处理 429 限流? 捕获到 HTTP 429 后指数退避重试:第 1 次等 1 s,第 2 次等 2 s,最多重试 3 次,超出后抛出业务异常,具体实现见上面「429 限流下的重试退避实战」一节。

中文回复返回乱码怎么办? 这是个老问题但每隔一阵就有人在群里问。根因几乎都出在字符编码上:JVM 默认编码在某些老版本 Windows 环境下不是 UTF-8,而 OkHttp 的 RequestBody.create 和响应体解析默认按 UTF-8 处理,两边编码对不上就出现乱码或问号。排查顺序是——先确认启动参数里加了 -Dfile.encoding=UTF-8,再确认请求头 Content-Type 写的是 application/json(不带 charset 后缀时大部分网关默认按 UTF-8 处理,但如果你的网关不是这样,需要显式写成 application/json; charset=utf-8),最后确认你打印日志用的终端(比如 Windows 自带的 cmd)本身是不是 GBK 编码在作怪——这种情况下数据其实是对的,只是终端显示错了,换成支持 UTF-8 的终端(Windows Terminal、Git Bash)看一下就能确认。

响应体特别长,resp.body().string() 会不会有内存问题? 正常聊天场景的回复一般几百到几千字,string() 一次性读入内存完全没问题。但如果你在做的是长文档生成、返回几万字的场景,建议改用 resp.body().charStream() 配合 BufferedReader 边读边处理,或者干脆走上面讲的流式接口分块接收,别等一次性攒完整个响应体再处理,既省内存也能让用户更快看到第一行输出。

connectTimeoutreadTimeout 分不清楚怎么办? 简单记:connectTimeout 管的是”能不能连上对方服务器”这一步,网络不通、DNS 解析失败会卡在这里;readTimeout 管的是”连上之后对方多久没给你发数据”,大模型生成慢、排队中都会体现在这个超时上。日常调试时如果异常信息是 ConnectException,去查网络和地址配置;如果是 SocketTimeoutException 且发生在读取阶段,先把 readTimeout 调大,尤其是调用参数量大、推理慢的模型时,60 秒未必够用,我在实测超长回复(几千字)的场景时曾经把这个值提到 120 秒才稳定跑完。


更多接入方案见大模型 API 接入完全指南接入教程专题。Go 语言版本示例参考Go 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测