先说一个反直觉的结论:"接入大模型 API"最难的不是"调通接口",而是"生产可用"。 你花 5 分钟能发出第一个请求,但真正上线时,流式响应、结构化输出、超时重试、限流、Token 成本、并发、安全——每一个都会咬你一口。这篇文章不教"Hello World",而是给你一套能直接落地的 Java 工程实践,含完整代码。

一、选型:先找一个 OpenAI 兼容的接口

我用 DeepSeek 做示例(它兼容 OpenAI 的 /v1/chat/completions 格式,换其他家换 base_url + key 即可)。你只需要三样:

String apiKey = "sk-xxx";                       // 别写死在代码里,用环境变量/配置中心
String baseUrl = "https://api.deepseek.com/v1";
String model   = "deepseek-chat";

兼容格式的好处:base_url + model + key 可配置,哪天换厂商(通义/智谱/GPT)只改配置,不动代码。你甚至可以抽象出一个接口,灰度切换多家。

二、最小可用:同步对话(HttpClient + Jackson)

用 Java 11 自带的 java.net.http.HttpClient,别引一堆 HTTP 依赖。

public static String chat(String apiKey, String baseUrl, String model, String userMsg) throws Exception {
    var client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    // 用 Java 文本块搭 JSON,注意转义
    String body = """
            {
              "model": "%s",
              "messages": [
                {"role":"system","content":"你是一个Java技术专家,回答要简洁准确。"},
                {"role":"user","content":"%s"}
              ],
              "temperature": 0.7
            }
            """.formatted(model, userMsg.replace("\\", "\\\\").replace("\"", "\\\""));

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

    var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
    if (resp.statusCode() != 200) {
        throw new RuntimeException("HTTP " + resp.statusCode() + ": " + resp.body());
    }
    JsonNode root = new ObjectMapper().readTree(resp.body());
    return root.path("choices").path(0).path("message").path("content").asText();
}

这段能跑,但问题不少:每次新建 HttpClient、每次一个用户消息、没有历史、不能流式、异常只抛不重试。接下来逐个解决。

三、把它做成"可复用、带历史"的客户端

生产上绝不能每次新建连接,也要能带多轮上下文:

public class LlmClient {
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
    private final ObjectMapper mapper = new ObjectMapper();
    private final List<Map<String, String>> messages = new ArrayList<>();   // 会话上下文
    private final String apiKey, baseUrl, model;

    public LlmClient(String apiKey, String baseUrl, String model) {
        this.apiKey = apiKey; this.baseUrl = baseUrl; this.model = model;
        this.messages.add(Map.of("role","system","content","你是Java技术专家,回答简洁准确。"));
    }

    public String ask(String userMsg) throws Exception {
        messages.add(Map.of("role", "user", "content", userMsg));
        String content = chat(messages);
        messages.add(Map.of("role", "assistant", "content", content));   // 关键:记住回答,形成多轮
        return content;
    }

    private String chat(List<Map<String,String>> messages) throws Exception {
        String body = mapper.writeValueAsString(Map.of(
                "model", model, "messages", messages, "temperature", 0.7));
        var req = HttpRequest.newBuilder()
                .uri(URI.create(baseUrl + "/chat/completions"))
                .timeout(Duration.ofSeconds(60))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body)).build();
        var resp = http.send(req, HttpResponse.BodyHandlers.ofString());
        return mapper.readTree(resp.body()).path("choices").path(0).path("message").path("content").asText();
    }
}

:会话越长,messages 越大,Token 成本越高、越容易超时。生产上要做"上下文裁剪"(见第八节)。

四、生产必备:流式(SSE)响应

用户对着聊天框等 30 秒才出全文是不可接受的,必须流式逐字输出stream:true 后,接口按 data: 行推送:

public void askStream(String userMsg, Consumer<String> onDelta) throws Exception {
    messages.add(Map.of("role","user","content",userMsg));
    String body = mapper.writeValueAsString(Map.of(
            "model", model, "messages", messages, "stream", true));
    var req = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/chat/completions"))
            .timeout(Duration.ofSeconds(120))
            .header("Authorization", "Bearer " + apiKey)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body)).build();

    // 用 InputStream 逐行读,边收边吐给前端(SSE)
    HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
    StringBuilder full = new StringBuilder();
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
        String line;
        while ((line = reader.readLine()) != null) {
            if (!line.startsWith("data: ")) continue;
            String data = line.substring(6).trim();
            if ("[DONE]".equals(data)) break;
            String delta = mapper.readTree(data).path("choices").path(0).path("delta").path("content").asText("");
            if (!delta.isEmpty()) { full.append(delta); onDelta.accept(delta); }
        }
    }
    messages.add(Map.of("role","assistant","content",full.toString()));
}

坑①:读流不能用 OfString,会等整个响应结束才拿到,流式的意义就没了。必须 OfInputStream()
坑②:流式请求超时时间必须比非流式长(模型边生成边推,耗时更大),而且千万要 catch 断流,客户端一关连接,这里会抛 IOException。

五、结构化输出:让模型返回 JSON,直接映射成对象

你要的不是一段文字,而是"能直接用的数据"(比如意图、参数、审核结论)。让模型输出 JSON,再用 Jackson 解析:

public record Intent(String intent, Map<String,Object> params, String confidence) {}

public Intent parseIntent(String userMsg) throws Exception {
    String prompt = """
            你是订单查询助手。把用户的自然语言转成结构化意图,只输出 JSON,不要任何解释。
            格式:{"intent":"query_order|query_user|cancel_order","params":{},"confidence":"high|medium|low"}
            用户说:%s
            """.formatted(userMsg);
    messages.add(Map.of("role","user","content",prompt));
    String raw = chat(messages);
    return mapper.readValue(raw, Intent.class);   // 直接反序列化成 Java 对象
}

:模型不一定老实输出纯 JSON(可能加个"好的,这是结果:")。稳妥做法是提示词里写死"只输出 JSON",再用正则提取 {...} 区间兜底;或让接口 response_format={"type":"json_object"}(很多 OpenAI 兼容接口支持)。

六、进阶:Function Calling,让模型"调用你的方法"

场景:模型需要查数据库/调内部服务。用 Function Calling 把"你的工具"暴露给模型,模型返回该调哪个、参数是什么,你再去执行。

// 1) 声明工具(schema)
String tools = """
    [{"type":"function","function":{
        "name":"query_order_by_id",
        "description":"根据订单号查询订单状态",
        "parameters":{"type":"object","properties":{
            "orderId":{"type":"string","description":"订单号"}
        },"required":["orderId"]}}}]
    """;

// 2) 请求带上 tools
String body = mapper.writeValueAsString(Map.of(
        "model", model, "messages", messages, "tools", mapper.readTree(tools)));

// 3) 解析返回的 tool_calls,去执行真实方法
JsonNode root = mapper.readTree(respBody);
JsonNode tc = root.path("choices").path(0).path("message").path("tool_calls");
if (!tc.isMissingNode()) {
    String name = tc.path(0).path("function").path("name").asText();
    String args = tc.path(0).path("function").path("arguments").asText();
    String orderId = mapper.readTree(args).path("orderId").asText();
    String result = queryOrderById(orderId);          // 真正调你的内部服务
    // 4) 把执行结果回传给模型,让模型组织最终答复
    messages.add(toolResultMessage(name, args, result));
    String finalAnswer = chat(messages);
    return finalAnswer;
}

:Function Calling 的 arguments 是 JSON 字符串(不是对象),要再 readTree 一层;而且你实现的方法必须幂等、要有超时,否则模型"以为调用成功了",其实你内部崩了。

七、错误处理与韧性:超时、重试、限流

LLM 是不可靠的外部依赖,必须当"第三方"防。核心三件套:超时、指数退避重试、限流兜底

public String askWithRetry(String userMsg) throws Exception {
    int max = 3;
    for (int i = 0; i < max; i++) {
        try {
            return ask(userMsg);
        } catch (HttpTimeoutException e) {
            // 超时:直接重试,但要小心"重试可能双倍扣 token"
        } catch (Exception e) {
            if (e.getMessage() != null && e.getMessage().contains("429")) {
                // 限流:等一会儿再试(指数退避)
                Thread.sleep((long) (Math.pow(2, i) * 1000));
                continue;
            }
            if (i == max - 1) throw e;   // 最后一次直接抛
        }
    }
    throw new IllegalStateException("LLM 调用失败");
}

坑(重要)重试要配合"幂等 + 成本考量"。如果这次调用已经消耗了 token 但响应超时,重试就是再花一次 token。所以业务上要设置兜底:超时就返回"模型繁忙"或降级到规则引擎,而不是无限重试。

八、成本与上下文裁剪

Token 成本常被忽略。两点:

// 拿到本次消耗的 token(接口返回 usage)
long usage = root.path("usage").path("total_tokens").asLong();

// 多轮上下文别无限累加:超长时只保留最近 N 轮 + 系统提示
if (messages.size() > 20) {
    int keep = 8;
    messages.subList(1, messages.size() - keep).clear();   // 保留系统消息 + 最近 keep 轮
}

别把整本源码/整个日志喂给它——token 烧钱,而且上下文过长会稀释注意力;只给"相关的类 + 方法签名 + 报错"就够了。

九、接到业务:一个完整案例(意图识别 → 订单查询)

把上面串起来,做成 order-service 里的一个"自然语言查订单"能力(正好接上我们系列的并发主线):

@Service
public class OrderAssistant {
    private final LlmClient llm;                 // 第三步封装好的客户端
    private final OrderService orderService;     // 你的内部服务
    private final ExecutorService pool;          // 第六步:自定义线程池,别用默认池

    public OrderAssistant(LlmClient llm, OrderService orderService) {
        this.llm = llm;
        this.orderService = orderService;
        this.pool = new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(200),
                new ThreadPoolExecutor.CallerRunsPolicy());   // 有界队列 + 背压
    }

    public CompletableFuture<String> answer(String userMsg) {
        // 用自定义线程池跑,避免阻塞 Tomcat 线程,也避免默认池无限建线程
        return CompletableFuture.supplyAsync(() -> {
            try {
                Intent it = llm.parseIntent(userMsg);
                String result = switch (it.intent()) {
                    case "query_order" -> orderService.query(it.params().get("orderId").toString());
                    default -> "暂不支持";
                };
                return it.intent() + " => " + result;
            } catch (Exception e) {
                return "抱歉,我暂时无法处理(已降级到兜底逻辑)。";
            }
        }, pool);
    }
}

十、安全与合规(别踩)

  • 密钥永不进代码仓、不打日志:用环境变量/配置中心;异常信息里出现 key 要脱敏。
  • 别把用户 PII / 生产数据裸喂给外部 LLM:要么脱敏(手机号打码),要么自建/私有模型,要么在合规边界内使用。
  • 加审计:记录调用方、耗时、token 数、是否命中兜底——为成本和问题排查留证据。

十一、总结

一句话:接入大模型 API,真正的工程在"接口之外"——流式、结构化、超时重试、成本裁剪、并发、安全、兜底降级。 把 LLM 当成你身边"能力很强但可能胡说、会超时、要花钱"的同事来对待,给它上下文、设定边界、做好兜底,它才能稳定地帮你干活。


本篇属于《Java 后端稳定性实战》AI 工程落地系列。下一篇预告:《AI Agent 与 Function Calling:Java 侧实现》——把"让模型自己调你方法"这件事做成真正能上生产的 Agent 骨架。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐