Java + AI 应用入门

Java 调用大模型:从一次 HTTP 请求开始

用 Java 21 发送 Responses 请求,解析多条输出并处理超时;提供无需密钥的本地演示与验证边界。

Java 调用模型接口,可以先拆成三步:生成请求 JSON、发送 HTTP 请求、从返回 JSON 中取出回答。下面使用 Java 21 的 HttpClient 和 Responses 格式,先运行本地固定响应,再说明真实服务需要哪些配置。

准备与本地演示

下载完整示例工程,解压进入 core 目录,使用 JDK 21 和 Maven 3.9.9 运行:

mvn package dependency:copy-dependencies -DincludeScope=runtime
java -cp 'target/classes:target/dependency/*' site.MockWalkthrough

首次构建需要下载公开依赖。命令使用 macOS/Linux 类路径分隔符;Windows 未在本轮验证。 本轮容器运行输出:

[本地模拟响应,不是模型生成]
线程池通过复用工作线程来执行提交的任务。

这条输出由本地 HTTP 服务返回,没有调用模型,也不需要密钥。工程中 ModelClientTest 覆盖 HTTP 成功解析、多个输出条目、401 脱敏、429 不重试、503、超时、非法 JSON、未完成响应和无文本等路径。最终定稿代码与正文一起保存,依赖如下。

查看完整 Maven 配置
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>site.cxykk.examples</groupId><artifactId>phase8-core</artifactId><version>1.0.0</version>
  <properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>tools.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>3.1.7</version></dependency>
    <dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.3</version><scope>test</scope></dependency>
  </dependencies>
  <build><plugins>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.5.0</version></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.15.0</version></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.6</version></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.5.0</version></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version></plugin>
  </plugins></build>
</project>

请求和解析放在同一个客户端里

客户端使用 Jackson 生成 JSON,问题包含中文、引号或换行时也不需要手工转义。input 是问题,model 必须使用服务实际支持的模型 ID。

查看完整 ModelClient.java
package site;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

/** A single synchronous, non-streaming Responses request. No automatic retry. */
public final class ModelClient implements AutoCloseable {
  private final HttpClient http;
  private final URI endpoint;
  private final String apiKey;
  private final String model;
  private final Duration timeout;
  private final JsonMapper json = JsonMapper.builder().build();
  private final tools.jackson.databind.node.ObjectNode evidence = json.createObjectNode();

  public ModelClient(URI endpoint, String apiKey, String model, Duration timeout) {
    this(endpoint, apiKey, model, timeout, null);
  }

  public ModelClient(URI endpoint, String apiKey, String model, Duration timeout, URI approvedEndpoint) {
    boolean official = "https".equals(endpoint.getScheme())
        && "api.openai.com".equals(endpoint.getHost())
        && (endpoint.getPort() == -1 || endpoint.getPort() == 443);
    boolean localTest = "http".equals(endpoint.getScheme())
        && "127.0.0.1".equals(endpoint.getHost());
    boolean approvedRelay = endpoint.equals(approvedEndpoint)
        && "https".equals(endpoint.getScheme()) && endpoint.getHost() != null;
    if ((!official && !localTest && !approvedRelay) || endpoint.getUserInfo() != null
        || endpoint.getQuery() != null || endpoint.getFragment() != null
        || !(approvedRelay ? endpoint.getPath().endsWith("/responses") : "/v1/responses".equals(endpoint.getPath()))) {
      throw new IllegalArgumentException("仅接受官方、本地模拟或明确批准的 HTTPS Responses 地址");
    }
    if (apiKey == null || apiKey.isBlank() || model == null || model.isBlank()) {
      throw new IllegalArgumentException("请通过环境变量配置 API key 与模型名称");
    }
    if (timeout == null || timeout.isZero() || timeout.isNegative()) {
      throw new IllegalArgumentException("请求超时必须大于零");
    }
    this.endpoint = endpoint;
    this.apiKey = apiKey;
    this.model = model;
    this.timeout = timeout;
    this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NEVER).build();
  }

  public String ask(String input) throws IOException, InterruptedException {
    if (input == null || input.isBlank()) {
      throw new IllegalArgumentException("输入不能为空");
    }
    var payload = json.createObjectNode();
    payload.put("model", model);
    payload.put("input", input);
    payload.put("store", false);
    payload.put("max_output_tokens", 1024);
    var request = HttpRequest.newBuilder(endpoint).timeout(timeout)
        .header("Authorization", "Bearer " + apiKey)
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload), StandardCharsets.UTF_8))
        .build();
    HttpResponse<String> response;
    long start = System.nanoTime();
    try {
      response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
    } catch (HttpTimeoutException e) {
      throw new IOException("请求超时;本次未自动重试,服务端仍可能已处理请求");
    } catch (IOException e) {
      throw new IOException("网络请求失败;检查网络与服务状态");
    }
    evidence.put("httpStatus", response.statusCode());
    evidence.put("elapsedMillis", (System.nanoTime() - start) / 1_000_000);
    response.headers().firstValue("x-request-id").ifPresent(value -> evidence.put("requestId", redact(value)));
    if (response.statusCode() != 200) {
      // Do not expose arbitrary upstream error bodies, which may contain secrets or prompts.
      throw new IOException("模型接口 HTTP " + response.statusCode() + ";本次未自动重试");
    }
    JsonNode root;
    try {
      root = json.readTree(response.body());
    } catch (JacksonException e) {
      throw new IOException("响应不是有效 JSON");
    }
    if (root != null && root.isObject()) {
      for (String field : new String[]{"id", "model", "status"}) {
        if (root.path(field).isString()) evidence.put(field, redact(root.path(field).asString("")));
      }
      var usage = json.createObjectNode();
      for (String field : new String[]{"input_tokens", "output_tokens", "total_tokens"}) {
        if (root.path("usage").path(field).isNumber()) usage.set(field, root.path("usage").path(field));
      }
      evidence.set("usage", usage);
    }
    if (root == null || !"completed".equals(root.path("status").asString(""))) {
      throw new IOException("响应未完成;请核对响应状态与输出预算");
    }
    var result = new StringBuilder();
    for (var item : root.path("output")) {
      if (!"message".equals(item.path("type").asString(""))
          || !"assistant".equals(item.path("role").asString(""))) continue;
      for (var content : item.path("content")) {
        if ("output_text".equals(content.path("type").asString("")) && content.path("text").isString()) {
          if (!result.isEmpty()) result.append('\n');
          result.append(content.path("text").asString(""));
        }
      }
    }
    if (result.toString().isBlank()) {
      throw new IOException("响应已完成,但没有可显示的文本;可能是拒答或非文本输出");
    }
    return result.toString();
  }

  private String redact(String value) {
    return value.replace(apiKey, "[REDACTED]");
  }

  public JsonNode evidence() {
    return evidence.deepCopy();
  }

  @Override
  public void close() {
    http.close();
  }
}

max_output_tokens 是输出预算,可能包含推理和可见文本,不等于汉字数或金额上限。store=false 是响应存储选项,不是对服务商所有数据保留行为的承诺。具体字段应以使用的服务文档为准。

客户端在 HTTP 200 后仍检查 status=completed,随后遍历 output 中的助手消息及其 output_text。第一条输出可能不是回答,直接读取 output[0] 会漏掉其他条目。无文本、拒答或未完成响应都不能作为普通文本成功处理。

换成真实接口需要什么

ModelDemo 默认使用 https://api.openai.com/v1/responses,需要环境变量 OPENAI_API_KEY 和 OPENAI_MODEL。密钥从可信的本地配置载入,不写进源码、截图或终端历史。确认账户权限、模型 ID 和费用后,再执行下面这条命令;它会产生一次真实请求,本轮没有执行它。

java -cp 'target/classes:target/dependency/*' site.ModelDemo

这个入口接受官方地址;其他兼容服务需要自行核对协议和可信范围,不能只改域名便假设兼容。示例保留本地测试地址用于自动化验证。不要把自己的密钥发送给不明服务。

已有真实调用能证明什么

此前待审稿保存了两次分别获准的中转站成功请求,本轮复用其脱敏记录,没有再次发送请求。其中一次返回:

线程池是一种复用多个工作线程来执行任务的机制,通过避免频繁创建和销毁线程来降低开销,并控制并发数量。

该次 HTTP 为 200、生成状态为 completed,中转站报告总用量 348 tokens,未提供货币费用。中转站的模型名称不能独立证明下游模型身份,单次结果也不能代表延迟、可用性或回答质量。

另一次回答用了“预先创建”这个限定。对 Java ThreadPoolExecutor 而言,核心线程默认在任务到来时按需创建,也可显式预启动,不能笼统说创建线程池时全部线程已经启动。接口成功之后,仍要对照 JDK 文档审核技术结论。

失败时按哪一层排查

现象检查方向
配置无效完整地址、模型 ID 和环境变量是否正确
HTTP 401密钥和接口权限
HTTP 429服务限流或额度,按真实响应核对
HTTP 503服务或网关状态
超时请求可能已到服务端,先查记录再决定是否重发
HTTP 200 但未完成生成状态和输出预算
非 JSON 或没有文本协议是否匹配、是否拒答或返回其他输出类型

示例的连接超时为 5 秒,单次请求等待为 60 秒,且不做应用级自动重试。超时不证明服务端没有处理或计费。本轮重新运行的是本地模拟测试,没有验证真实失败响应、流式输出、工具调用或生产网关。

继续学习

返回 Java + AI 应用入门目录