上一篇把流式输出跑通了。但跑通不等于该用。真实项目里,比"怎么写流式"更靠前的一个决定是:这个接口到底该不该流式。选错了,要么白白多写一堆解析代码,要么上线后用户等到怀疑人生。这一篇把两者的差别摊开讲,最后给一张能直接照着用的判断清单。
一、先看两者的响应到底差在哪
非流式的响应,是一整个 JSON:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "西湖位于浙江省杭州市……"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 18,
"completion_tokens": 63,
"total_tokens": 81
}
}
流式的响应,是一连串 data: 片段,最后以 data: [DONE] 收尾:
data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"choices":[{"delta":{"content":"西湖"},"index":0}]}
data: {"choices":[{"delta":{"content":"位于"},"index":0}]}
data: [DONE]
看起来只是"一个 JSON"和"很多小 JSON"的区别,但落到代码里,有四个地方会实实在在影响到你的实现,而且很多人是踩了才知道。
二、四个必须提前知道的差别
差别一:取内容的字段不一样
非流式读的是 choices[0].message.content,流式读的是 choices[0].delta.content。
message 和 delta 这两个字段名,是同一个接口里两套完全不同的结构。用错的结果不是报错,而是 content 取出来是 null,然后你盯着日志怀疑是不是模型没返回内容。这一类问题排查起来最浪费时间,因为程序不会崩,只是安静地什么都不给你。
所以客户端代码里,这两种响应的解析最好是两条独立的路径,不要图省事写成一个"能兼容两种"的方法。字段名不同、结构语义不同,硬兼容出来的代码后面一定会在别的地方咬你。
差别二:流式默认不给你 usage
上面两个响应一比就看得出来:非流式带了 usage,流式没有。
usage 是 token 用量,算成本、做配额、排查"为什么这个请求这么贵",全靠它。流式模式下默认不返回,这是很多人做完流式之后才发现自己把成本统计弄丢了的原因。
如果用的是 OpenAI 兼容接口,可以显式要求带上:
String body = """
{
"model": "qwen-plus",
"stream": true,
"stream_options": {
"include_usage": true },
"messages": [
{
"role": "user", "content": "用三句话介绍杭州西湖"}
]
}
""";
开了之后,usage 会在最后一个数据块里给你(具体字段位置以你用的模型文档为准)。需要注意的是,这个参数不是所有兼容实现都支持,接之前先拿一个真实请求试一次,确认 usage 真的回来了,别默认它有。
差别三:报错出现的位置不一样
非流式的错误很好处理:HTTP 状态码是 4xx 或 5xx,响应体里带一个 error 字段,你按常规的接口异常处理就行——判断状态码、读错误信息、抛业务异常。
流式麻烦得多。请求发出去之后,服务端会先返回 HTTP 200 把 SSE 连接建立起来,然后才开始推送内容。这意味着错误可能出现在连接建立之后的任何时刻:
- 参数写错、Key 无效,有些实现会返回 200,然后在第一个数据块里给你一个
error对象; - 生成到一半上游超时或被限流,流会中途断掉,你收到的是"一半的内容 + 一个结束";
- 网络抖动导致连接直接断开,客户端拿到的是异常,但此时已经打印出去半句话了。
所以流式客户端必须多处理一件事:"半个响应"是什么状态。非流式天然没有这个问题,要么拿到完整的,要么什么都没有。流式是可能拿到半句话的。
这一点直接决定了你后面要不要落库、要不要给用户提示重试。
差别四:超时的含义完全不同
非流式的超时,指的是"从发出请求到收完整段回答"的总时长。这个时间很不确定——同一个接口,短问题 1 秒,长问题可能 20 秒。你把读超时设成 30 秒吧,真出问题时一个 hung 住的连接要挂 30 秒才释放;设成 5 秒吧,长回答直接被你自己掐断。
流式的超时,拆成了两个更有意义的指标:
- 首字时间(TTFB):从发出请求到收到第一个数据块。这个值很稳定,一般也就几百毫秒到两三秒。它超了,说明是上游排队或者网络有问题。
- 块间隔:两个相邻数据块之间隔了多久。这个值同样稳定,通常几十毫秒。它超了,说明流卡住了。
用一个"块间隔超时"(比如设成 20 秒)来判断卡死,比一个笼统的总超时要准确得多,也能更快把异常暴露出来。这是选流式之后能顺手拿到的一个好处,值得利用。
三、这些场景,流式基本是唯一选择
判断标准其实就一条:输出内容是给人看的,而且用户需要等。
- 对话式交互。一问一答,用户就坐在屏幕前等着。首字 1 秒和首字 8 秒,是完全两种产品体验。
- 长文本生成。写方案、写报告、写代码,输出动辄几千字。不流式的话,用户要等到全部生成完才能看到第一个字,中间那十几秒只能干瞪眼。
- 需要中途判断的任务。比如生成的内容方向错了,用户想立刻点"停止"。这要求内容必须是一边生成一边可见的,否则"停止"按钮无从谈起。
- 生成时间长且无法预估。同一句提问,模型可能答三行,也可能答三十行。这种不确定性交给流式最稳妥。
一句话概括:只要用户会盯着屏幕等,就上流式。
四、这些场景,用流式纯属给自己找麻烦
反过来说,"输出不给人看,或者过程不需要被看见"的场景,流式带来的只有成本。
批处理、离线任务。比如半夜跑一批任务,把十万条商品描述翻译成英文。这个过程没有任何人在等,流式不但没有收益,还多出两个问题:连接要长时间占着,解析逻辑也更复杂。老老实实走非流式,代码少一半。
结构化输出。如果你要的是 {"title": "...", "tags": [...]} 这样一个 JSON,拿去入库或者给下游校验,那就别流式。流式返回的 JSON 是碎成一地的,你得先拼完才能解析。既然最终都要拼完才用,"边拼边给用户看"这件事就没有意义,反而是白白增加一个出错环节。
内部服务之间的调用。上游服务要的是最终结果,它不关心中间过程。这种调用关系里,流式只会让链路超时设置变得更难对齐。
单次输出很短的请求。文本分类、情感打分、是否命中规则,这类请求本身就在一两百毫秒内返回,整个回答可能就十几个 token。为它上流式,省下的那点感知延迟还不够抵消代码复杂度。
需要严格重试的场景。非流式失败了可以干净地重试一次,因为还没对外输出任何东西。流式一旦已经吐出去半句话,重试就意味着用户会看到重复或矛盾的内容。如果你的业务对"结果必须完整且唯一"有要求,非流式省心得多。
五、一个绕不开的组合:既要流式,又要完整结果
真实业务里最常见的情况,其实是"两个都要":用户希望边生成边看,同时系统需要把完整回答存下来(做审计、做后续处理、做数据回流)。
这不矛盾,做法是在推送的同时,把增量自己攒一份:
StringBuilder full = new StringBuilder();
response.body().forEach(line -> {
if (!line.startsWith("data:")) {
return;
}
String data = line.substring(5).trim();
if ("[DONE]".equals(data)) {
finished = true;
return;
}
try {
JsonNode node = mapper.readTree(data);
String content = node.path("choices").get(0)
.path("delta").path("content").asText("");
if (!content.isEmpty()) {
full.append(content); // 攒一份完整的
pushToClient(content); // 同时推给前端
}
} catch (Exception e) {
// 心跳或空数据,忽略
}
});
// 流结束后再落库
if (finished) {
saveToDb(full.toString());
}
这里有个细节必须处理:流中断时,full 里是半句话。直接落库的话,你的数据库里就会留下一堆"西湖位于浙江省杭"这种残缺记录,而且很难分辨哪条是完整的。
所以上面的代码里加了 finished 标志,只有真的收到 [DONE] 才落库。中断的情况下,要么丢弃这一段,要么单独标记成"未完成"状态。这个判断不能省,它是流式和存储这两个世界接缝上最容易漏掉的一道缝。
六、如果决定用流式,上线前还差这几件事
本地跑通和线上可用之间,还隔着几道和代码无关的坎,全部来自中间的网络设施。
反向代理别做缓冲。如果前面挂了 Nginx,默认配置会把响应攒起来一起发,你辛苦实现的流式到了用户那边又变回"一次性蹦出来"。要在对应的 location 上关掉缓冲:
location /api/chat {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 300s;
}
留意各层网关的空闲超时。流式请求在生成期间是持续有数据的,空闲超时一般不会误杀;但如果模型首字慢,连接建立到首个数据块之间会有一段静默期,某些网关会在这段时间里把连接掐掉。压测时专门试一下"最慢的首字"这种极端情况。
用户断开时要能取消上游。用户关掉页面或点了停止,如果服务端还傻傻地读着上游的流,token 照样在烧,只是没人看了。这件事在做成本控制的时候会显得特别刺眼,最好在设计阶段就留好取消的钩子。
别假设一个数据块就是一行。模型接口在实践里基本是"一个事件一行 data:",但 SSE 规范本身允许一个事件的数据跨多行。真遇到奇怪的厂商实现时,正确做法是攒到空行再当成一个完整事件处理。这里提一句,是因为它属于那种"平时都对、偶尔翻车"的类型。
七、一张能直接照着用的判断清单
把上面的内容收成几条,做技术选型时从上往下过一遍:
- 输出面向人,且首字延迟会超过 1 秒 → 流式
- 输出要落库或交给下游服务 → 非流式;如果同时还要给用户看,就流式 + 增量累积
- 需要返回结构化 JSON → 非流式
- 批处理、离线、无人等待 → 非流式
- 单次输出很短(百毫秒级) → 非流式,图个简单
- 失败后必须干净重试 → 非流式
- 需要中途中断生成 → 流式,且要处理取消
还有一条经验:不确定的时候,先写非流式。把业务逻辑、错误处理、存储都跑通,确认这个功能真的有人用、真的嫌慢,再把这条路径换成流式。反过来先把流式写复杂了,一旦发现延迟不是瓶颈,那些解析和拼接的代码就全是负债。
下一篇
把调用方式定下来之后,下一个绕不过去的问题就是参数:temperature、top_p、max_tokens 这几个值到底填多少。这三个参数看着简单,但填错导致的"答非所问""回答被腰斩""同一个问题每次答案都不一样",是新手最容易困惑的地方。下一篇把它们一个一个掰开讲。
本文是「用 Java 接入大模型」系列第二篇。上一篇讲了把流式输出跑通,建议按顺序看。文中代码基于 JDK 17 和通义千问兼容接口,实际参数以你使用的模型文档为准。