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 秒,且不做应用级自动重试。超时不证明服务端没有处理或计费。本轮重新运行的是本地模拟测试,没有验证真实失败响应、流式输出、工具调用或生产网关。