用 Java 接入大模型(二):流式还是非流式,到底怎么选

简介: 本文深入剖析流式与非流式API的核心差异,从响应结构、字段解析、错误处理、超时机制四大维度揭示选型陷阱;明确“用户是否等待”为关键判断标准,并提供可直接落地的7条决策清单,助你避免过早复杂化或体验降级。

上一篇把流式输出跑通了。但跑通不等于该用。真实项目里,比"怎么写流式"更靠前的一个决定是:这个接口到底该不该流式。选错了,要么白白多写一堆解析代码,要么上线后用户等到怀疑人生。这一篇把两者的差别摊开讲,最后给一张能直接照着用的判断清单。


一、先看两者的响应到底差在哪

非流式的响应,是一整个 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

messagedelta 这两个字段名,是同一个接口里两套完全不同的结构。用错的结果不是报错,而是 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 → 非流式
  • 批处理、离线、无人等待 → 非流式
  • 单次输出很短(百毫秒级) → 非流式,图个简单
  • 失败后必须干净重试 → 非流式
  • 需要中途中断生成 → 流式,且要处理取消

还有一条经验:不确定的时候,先写非流式。把业务逻辑、错误处理、存储都跑通,确认这个功能真的有人用、真的嫌慢,再把这条路径换成流式。反过来先把流式写复杂了,一旦发现延迟不是瓶颈,那些解析和拼接的代码就全是负债。


下一篇

把调用方式定下来之后,下一个绕不过去的问题就是参数:temperaturetop_pmax_tokens 这几个值到底填多少。这三个参数看着简单,但填错导致的"答非所问""回答被腰斩""同一个问题每次答案都不一样",是新手最容易困惑的地方。下一篇把它们一个一个掰开讲。


本文是「用 Java 接入大模型」系列第二篇。上一篇讲了把流式输出跑通,建议按顺序看。文中代码基于 JDK 17 和通义千问兼容接口,实际参数以你使用的模型文档为准。

目录
相关文章
|
4天前
|
机器学习/深度学习 JSON 前端开发
用 Java 接入大模型(一):把流式输出跑通
本文是「用 Java 接入大模型」系列首篇,聚焦最易卡壳的流式输出:详解 SSE 协议原理、手写 60 行无依赖流式客户端(兼容通义千问/OpenAI),并揭示新手必踩的三大坑——不按行解析、漏传 `stream:true`、忽略空 content。代码即学即用,拒绝玄学,只讲真实可跑的 Java 实现。
66 1
|
云栖大会 开发者
收到阿里云【乘风者计划】博主证书和奖励
收到阿里云【乘风者计划】博主证书和奖励 2023年2月对我来说是一个很好的开端,因为我在1号就收到了阿里云寄给我的【乘风者计划】博主证书和奖励。好兆头啊! 我收到的是我获得的【技术博主】【星级博主】【专家博主】三个的奖品和证书,一快给我寄过来哒!
3424 2
收到阿里云【乘风者计划】博主证书和奖励
|
11天前
|
机器学习/深度学习 人工智能 自然语言处理
轻量化小模型MiniMind从训练到落地指南
本文基于Ubuntu 22.04与RTX显卡,手把手教你用原生PyTorch从零训练MiniMind(26M–200M)轻量大模型:涵盖环境配置、预训练、SFT微调、LoRA垂域适配、DPO对齐、Web服务搭建及GGUF量化全流程。3小时可跑通基础对话,低成本、可复现、适合新手入门与私有化部署。(239字)
169 2
|
10月前
|
机器学习/深度学习 人工智能 自然语言处理
无需人工奖励!Meta FAIR华人团队提出「早期经验学习范式」,AI智能体像人类一样“从错误中成长”
Lab4AI.cn提供免费的AI翻译和AI导读工具辅助论文阅读;支持投稿复现,动手复现感兴趣的论文;论文复现完成后,您可基于您的思路和想法,开启论文创新。
363 4
无需人工奖励!Meta FAIR华人团队提出「早期经验学习范式」,AI智能体像人类一样“从错误中成长”
|
3月前
|
数据可视化 BI 文件存储
2026 实测分享:好用的个人 Todo 管理工具有这些
2026年,轻量化Todo工具成主流:聚焦待办本质,界面清爽、操作极简、视图直观。支持卡片拖拽、状态分区、多端同步,兼顾个人规划与小团队协作,真正实现“零学习成本、高执行效率”。
505 0
|
8月前
|
存储 弹性计算 安全
阿里云无影云电脑解读:优势、功能、收费价格及使用场景全解析
阿里云无影云电脑提供安全、灵活、高效的云上办公解决方案,支持多端接入、弹性配置与数据防泄密,适用于企业协作、专业设计及家庭共享等场景,助力数字化转型。
|
SQL JSON 安全
如何开发工程项目部管理系统中的安全管理板块(附架构图+流程图+代码参考)
本文详解工程项目部安全管理系统的构建,涵盖重大危险源管理、安全检查、隐患整改闭环流程及管理层看板。内容包括功能模块、业务流程、技术架构、开发技巧与实用代码,助你快速落地可追踪、能量化的安全管理体系。
银行转账p图软件,对公转账截图生成器,java版开发银行模拟器【仅供学习参考】
这是一套简单的银行账户管理系统代码,包含`BankAccount`和`BankSystem`两个核心类。`BankAccount`负责单个账户的管理
|
存储 SQL Apache
网易云信 x Doris:降本70%、提速11倍, 统一 ES/InfluxDB/Hive 多技术栈的落地实践
网易云信引入 Apache Doris 统一了原有 Elasticsearch、InfluxDB 和 Hive 多技术栈系统。凭借其高性能和易扩展的特点,提供一站式的数据存储和分析服务。实现机器成本降低 70%、实时场景查询提速 11 倍、离线任务耗时缩短 80% 的显著收益。
983 0
|
Kubernetes API Go
在Kubernetes client-go库中如何有效构建CRD的informer
构建并运行Informer之后,你的应用现在能够实时地响应Kubernetes中的CRD资源变化事件。这是一个强大的模式,它可以使得你的应用更加智能地与你的Kubernetes集群互动。通过上述步骤,你可以创建一个强大、可扩展且与Kubernetes紧密集成的系统。
269 0

热门文章

最新文章