用 Java 接入大模型(一):把流式输出跑通

简介: 本文是「用 Java 接入大模型」系列首篇,聚焦最易卡壳的流式输出:详解 SSE 协议原理、手写 60 行无依赖流式客户端(兼容通义千问/OpenAI),并揭示新手必踩的三大坑——不按行解析、漏传 `stream:true`、忽略空 content。代码即学即用,拒绝玄学,只讲真实可跑的 Java 实现。

网上讲大模型原理的文章很多,但真正落到"用 Java 怎么把模型接进来"这个层面的,其实不多。这个系列打算把这件事从头讲清楚:不谈玄学,只讲代码怎么跑、参数怎么传、坑在哪。第一篇先解决一个最基础、也最容易让人卡住的问题——怎么让模型的回答像 ChatGPT 那样一个字一个字往外蹦。


一、为什么要在意"流式输出"

很多人的第一版代码是这样的:

String prompt = "把下面这段话翻译成英文:" + text;
String answer = callModel(prompt);
System.out.println(answer);

能跑通,但体验很差。差在哪?

一次翻译、摘要、问答请求,模型从开始生成到全部吐完,短则一两秒,长则十几秒甚至更久。如果走"一次性返回",用户就只能盯着一个转圈圈的白屏干等,等到最后"啪"一下整段文字蹦出来。

而 ChatGPT、通义千问这类产品,都是边生成边显示——第一个字很快出现,后面的字持续跟上。用户能立刻感知到"它在干活了",等待的焦虑感会小很多。这个"边生成边显示"的能力,就是流式输出(Streaming)。

流式输出不是花架子,它直接影响一个功能能不能上线。一个要等 15 秒才有反应的对话接口,和 1 秒内就出第一个字的接口,在用户心里是完全不同的两个东西。所以我们先从它开始。


二、流式输出在协议层是怎么实现的

一个常见的误解,是以为流式输出是"前端做了个打字机动画"。其实不是。打字机动画是把已经拿到手的完整文本,用定时器一点点显示出来,属于障眼法。真正的流式输出,是服务端一边生成、一边通过网络把中间结果推给客户端。

它靠的是 HTTP 协议里的 SSE(Server-Sent Events),也就是"服务器推送事件"。

SSE 的核心就一条:响应头里带上 Content-Type: text/event-stream,服务端就可以分多次往同一个连接里写数据,每次写一个 data: 开头的小块,直到最后写一个 data: [DONE] 表示结束。客户端侧的 EventSource 或者 fetch 流式读取,就能一段一段地拿到这些数据。

用大白话讲:普通接口是"问一句、答一句",一次请求对应一次完整响应;SSE 是"问一句、答很多句",一次请求里服务端可以源源不断地往同一个连接里塞数据。

大模型厂商(OpenAI、通义千问、百炼等)的接口,几乎都遵循同一套流式规范:

  • 请求里加一个 "stream": true 参数;
  • 响应不再是单个 JSON,而是一连串 SSE 事件;
  • 每个事件的 data 字段是一个 JSON 片段,带着当前这一步生成的文字增量;
  • 全部生成完,服务端再发一个 data: [DONE] 收尾。

理解了这一层,后面的代码就没什么神秘的了。


三、动手:写一个最小的流式客户端

这里用通义千问(阿里云百炼)做演示,理由很简单:国内访问稳定、有免费额度,而且它是阿里云自家的服务,和这个社区的读者也算同频。换成 OpenAI、DeepSeek 或其他任何一家,代码结构几乎一样,改个 URL 和模型名就行。

先准备两样东西:

  1. 在阿里云百炼控制台开通模型服务,拿到一个 API Key;
  2. 记下要用的模型名,比如 qwen-plus 或 qwen-turbo。

下面这段代码,用 JDK 自带的 HttpClient 就能跑,不引入任何第三方依赖。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class StreamDemo {
   

    // 换成你自己的 Key
    private static final String API_KEY = "sk-xxxxxxxxxxxxxxxx";

    public static void main(String[] args) throws Exception {
   
        String url = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";

        String body = """
            {
   
              "model": "qwen-plus",
              "stream": true,
              "messages": [
                {
   "role": "user", "content": "用三句话介绍杭州西湖"}
              ]
            }
            """;

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json")
                .header("Accept", "text/event-stream")
                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                .build();

        // 关键:用 BodyHandlers.ofLines() 拿到一个按行读取的流
        HttpResponse<java.util.stream.Stream<String>> response =
                client.send(request, HttpResponse.BodyHandlers.ofLines());

        response.body().forEach(line -> {
   
            // SSE 里每个事件以 "data:" 开头
            if (line.startsWith("data:")) {
   
                String data = line.substring(5).trim();
                if ("[DONE]".equals(data)) {
   
                    System.out.println();
                    System.out.println("--- 生成结束 ---");
                    return;
                }
                // 这里 data 是一个 JSON,正文在 choices[0].delta.content 里
                // 为了演示,先原样打印,下一节再解析
                System.out.print(data);
            }
        });
    }
}

跑起来之后,控制台会不断刷出一行行 data: {...} 的 JSON。这说明流式通道已经通了——服务端确实在持续推送,而不是一次性返回。

到这里,最玄乎的一步就跨过去了。剩下的都是"体力活":把这些 JSON 里的文字增量抠出来,拼成完整回答。


四、把 JSON 里的文字增量抠出来

上面的代码只是把原始数据打了出来,真正要的是每个片段里的文字。先看一个 data 片段长什么样(为了看清,这里做了格式化):

{
   
  "choices": [
    {
   
      "delta": {
   
        "content": "西湖"
      },
      "index": 0
    }
  ],
  "created": 1720000000,
  "model": "qwen-plus"
}

要的就是 choices[0].delta.content 这个字段。注意一个细节:每个片段的 content 只是"增量",不是"到目前为止的全部"。也就是说,第一个片段可能是"西湖",第二个是"位于",第三个是"杭州"……需要按顺序把这些增量拼起来,才是最终答案。

解析 JSON 推荐用 Jackson(阿里系项目里几乎都能见到),引入一个依赖:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.1</version>
</dependency>

然后把上面打印的那段,改成解析并拼接:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

// ... 前面的代码不变 ...

ObjectMapper mapper = new ObjectMapper();

response.body().forEach(line -> {
   
    if (!line.startsWith("data:")) {
   
        return;
    }
    String data = line.substring(5).trim();
    if ("[DONE]".equals(data)) {
   
        System.out.println();
        System.out.println("--- 生成结束 ---");
        return;
    }
    try {
   
        JsonNode node = mapper.readTree(data);
        String content = node.path("choices").get(0)
                .path("delta").path("content").asText("");
        System.out.print(content);  // 直接打印,不换行
    } catch (Exception e) {
   
        // 某些行可能是心跳或空数据,忽略即可
    }
});

再跑一次,控制台里就会像 ChatGPT 一样,一个字一个字地把"西湖位于浙江省杭州市……"打印出来。

到这里,一个能用的流式大模型客户端就写完了,核心代码不超过 60 行。


五、三个新手必踩的坑

坑一:把 SSE 当成普通 JSON 解析

最常见的错误,是拿 readTree(body) 去解析整个响应体。流式接口的响应体根本不是合法 JSON,它是一串 data: {...}\n\n data: {...}\n\n 拼起来的东西。正确做法永远是按行读、逐行处理,就像上面代码里那样。

坑二:忘了在请求里传 "stream": true

如果不传这个参数,接口会退回"一次性返回"模式,返回一个完整 JSON,而不是 SSE 流。这时候再按流式去解析,就会得到一堆乱码或者空结果。排查问题时,先确认这个参数真的发出去了。

坑三:content 可能是空字符串

在流式响应里,有些片段是"元信息"片段,比如第一个片段往往只有 role 字段、没有 content;结尾附近也可能出现空的 content。所以代码里用 asText("") 给了默认值,并且对空内容做了容忍。别一看到空就以为程序坏了。


六、这一篇先到这

把流式输出跑通,只是"把大模型用起来"的第一步。

如果你也在这条路上踩过别的坑,欢迎在评论区补充,后续文章里会尽量一并回应。


本文是「用 Java 接入大模型」系列第一篇。文中代码基于 JDK 17 和通义千问兼容接口,实际参数以你使用的模型文档为准。

目录
相关文章
|
20天前
|
存储 缓存 人工智能
Agent运行机制拆解:上下文、检查点、任务恢复以及循环执行、资源管控完整流程23.3
本文系统解析大模型Agent的核心运行机制,涵盖执行循环、上下文与状态管理、工具反馈、检查点、停止条件、资源管控及人工接管等十大模块,揭示其“自主迭代、容错可溯、可控可兜底”的本质,助力开发者构建稳定智能体应用。
159 2
|
2月前
|
人工智能 缓存 安全
模型这么多,请求该交给谁?阿里云 AI 网关智能路由,正式上线!
模型越来越多,每次请求该交给谁?AI 网关智能路由已上线:能力够是前提,更省、更快还是更适合由你选,Agent 干活中途不换脑。
|
24天前
|
消息中间件 人工智能 自然语言处理
从钉群提问到任务交付:团队级 Agent 基础设施全链路实践
依托钉群无人值守、跨群会话记忆、组织知识检索及前端专业 Skill,云原生消息前端团队自研的内部研发协作平台 Domino 正经历关键演进——从单一的代码执行工具,升级为能够理解团队上下文、持续沉淀演进并闭环真实交付的研发 Agent 系统。
|
9天前
|
存储 NoSQL 安全
用 Java 接入大模型(五):多轮对话的上下文怎么管
本文深入剖析多轮对话的核心误区:大模型本身无记忆,所谓“遗忘”实为上下文管理失当。从messages结构化封装、token精准截断策略,到Redis会话存储与三步排错法,系统讲解如何可靠构建长程对话能力。
106 7
|
9天前
|
人工智能 自然语言处理 机器人
Qwen3 API 次元门:文本生成体验第二期
本文介绍阿里云Qwen3.8-27b模型的两大核心能力:结构化输出(支持JSON等格式,确保机器可解析)与前缀续写(Partial Mode),适用于AI智能体、聊天机器人及文档处理场景。提供OpenAI兼容API调用方式、密钥获取及免费额度信息。(240字)
143 3
|
14天前
|
人工智能 自然语言处理 Cloud Native
Qwen3 API 次元门:文本生成体验
本文介绍阿里云Qwen3.8-27b大模型的多轮对话实践,涵盖普通对话、多模态理解与深度思考模式,支持流式输出与结构化响应;提供OpenAI兼容API调用方式及免费额度、密钥获取等实用信息。(239字)
152 2
|
20天前
|
机器学习/深度学习 运维 安全
用 Java 接入大模型(三):temperature、top_p、max_tokens 到底怎么调
本文详解大模型调用核心参数:厘清temperature、top_p等作用边界,强调“参数只影响选词,不改变理解”;提供分场景配置清单与故障排查指南,助开发者高效调试、避免无效调参。(239字)
180 1
|
19天前
|
人工智能 分布式计算 NoSQL
美股数据 API 分层接入完整指南:在阿里云上打通实时行情、财报日历与 AI 工作流
本文剖析美股数据在阿里云落地的五大分层结构(行情、事件、公司行动、基本面、AI接入),指出常见误区——问题不在接口不通,而在未理解数据分层。以TickDB为统一数据源,提供阿里云组件映射方案与实测代码,助开发者精准补全数据链路。(239字)
|
24天前
|
存储 JSON 自然语言处理
用 Java 接入大模型(二):流式还是非流式,到底怎么选
本文深入剖析流式与非流式API的核心差异,从响应结构、字段解析、错误处理、超时机制四大维度揭示选型陷阱;明确“用户是否等待”为关键判断标准,并提供可直接落地的7条决策清单,助你避免过早复杂化或体验降级。
126 1
|
16天前
|
网络协议 安全 Java
用 Java 接入大模型(四):把调用封装成可复用的客户端
本文是Java接入大模型系列第四篇,聚焦将零散调用封装为健壮客户端。核心不在于“如何发送请求”,而在于系统性应对各类失败:分层超时(连接/首字/块间隔/总时长)、精准重试(区分可重试与不可重试错误)、异常分类(统一LlmException)、流中断识别、连接复用及配置外置。真正区分“能跑”与“能用”的,正是对出错场景的周全设计。
97 0

热门文章

最新文章