网上讲大模型原理的文章很多,但真正落到"用 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 和模型名就行。
先准备两样东西:
- 在阿里云百炼控制台开通模型服务,拿到一个 API Key;
- 记下要用的模型名,比如
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 和通义千问兼容接口,实际参数以你使用的模型文档为准。