流式输出:先递证据,再慢慢说话
约 2075 字大约 7 分钟
2026-10-02
第十三篇,聊一个「不做用户骂娘、做了没人夸」的功能:流式输出。技术上不复杂,但里面有几个细节,是「能跑」和「好用」的分界线。本篇对应 relax-admin 的「会话洞察 → 对话历史」页(trace 落库的展示位)和聊天前台的流式体验。今天一篇讲完。
为什么必须流式
先摆事实:生成一段两三百字的回答,主流模型要跑两到八秒。非流式的体验是——用户点发送,进度条转圈,转啊转,转啊转,然后「啪」一屏文字砸出来。流式的体验是——半秒之内开始逐字上屏,打字机效果。
内容完全一样,体感延迟差一个数量级。 人类对「等待中」和「进行中」的耐心完全是两个量级,这是交互设计的基本人性。聊天产品里流式不是加分项,是标配,今天主流 AI 产品没有一个用「转圈等全文」的。
另外对我们 RAG 场景还有个特殊福利,下面马上讲。
技术主线:SSE + Flux
后端传输用 SSE(Server-Sent Events),单向服务端推送,比 WebSocket 轻,聊天场景够用。Spring 侧链路:
@PostMapping(value = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestBody ChatRequest req) {
return chatService.streamChat(req.sessionId(), req.question());
}服务内部,Java AI 的 stream() 替换 call(),返回 Flux<ChatResponse>,每个元素是一小段增量文本:
Flux<ServerSentEvent<String>> streamChat(String sessionId, String question) {
var evidence = retrievalService.retrieve(rewrite(question)); // 检索照旧,同步
var sources = toSourceCards(evidence); // 组装来源卡片
return chatClient.prompt()
.system(systemPrompt).user(evidenceText + question)
.stream().content() // Flux<String> 增量
.map(delta -> ServerSentEvent.<String>builder()
.event("delta").data(delta).build())
.startWith(sourceEvent(sources)) // 关键:证据先行!
.concatWith(doneEvent()); // 收尾事件
}结构就这么点,事件序列是 sources → delta → delta → … → done。但真正值钱的细节在下面。
细节一:先推 sources,这是 RAG 流式的灵魂
注意到上面代码里 .startWith(sourceEvent(sources)) 没有——引用卡片事件在正文一个字都没生成之前,就已经推给前端了。
为什么这事儿值得专门讲:检索是毫秒级的,生成是秒级的。这个时间差里,前端闲着也是闲着——与其让用户盯白屏,不如先把来源卡片刷出来:「我查到了这三篇文章的相关段落,正在为你整理答案」。用户瞬间获得两个关键信息:
- 系统找到了东西(不是在敷衍)
- 答案基于什么(心里有底)
然后正文才开始逐字流出。
📷 图片位(待补充)
应放什么图:聊天前台「生成进行中」的状态截图——来源卡片已渲染在回答上方(三张卡片带文章标题),下方正文正在逐字输出(截一半即可)。这张图要传达的就是「证据比正文先到」。
RAG 流式最对的一件事
「先递证据,再慢慢说话」——我认为这是 RAG 产品在流式设计上最对的一件事,比打字机效果本身重要。而且它和第 12 篇的引用归因严丝合缝:核验入口在回答出现之前就摆在那了。
细节二:中断之后,钱可能还在烧
用户点了「停止生成」,前端断开连接,界面安安静静停住了——你以为停了,后端到模型的那条连接可能还活着,token 还在默默计费。这是流式功能里最阴的成本坑。
处理方式:客户端断开时,WebFlux 的取消信号会沿 Reactor 链向上传播,理论上会取消上游订阅——理论上。你的中间层有没有把这个取消真的传递到 LLM SDK 的 HTTP 连接上,得实际验证:点停止后观察模型服务那边的请求是不是真断了(日志里看 token 使用量是不是停止增长)。验证不了就加兜底:超时上限硬砍。
必须验证
取消级联这事「理论上支持」和「真的断了」是两回事。别省钱省出事故,实测一次,眼见为实。
顺带一提,doFinally 是收拾残局的好地方:流结束(无论正常、报错还是取消)时,把完整回答、token 用量、耗时、命中的切片 id 落库。这一条记录就是第 15 篇「会话洞察」的全部数据源,也是排查 badcase 的第一现场——「用户那次到底召回了什么」从此有据可查。
这些落库数据的前端展示位,就是「会话洞察 → 对话历史」页:每轮对话点开就是一条完整的 trace——问题、改写后的问题、召回的切片和分数、最终引用、token 和耗时。第 08 篇的「检索评估」是主动测试用的,这一页是真实用户流量留下的档案,两者互补:一个测系统,一个看现场。
📷 图片位(待补充)
应放什么图:「会话洞察 → 对话历史」页一条对话的 trace 详情截图,展开后能看到召回切片列表(含分数)、引用编号、耗时和 token 数——这张图是「每次回答都有据可查」的直观证明。
细节三:断线重连的取舍
用户网断了重连,错过的字怎么办?工业级方案是 SSE 的 Last-Event-ID 机制加服务端缓存补发;务实方案是「断线即丢,重连后拉历史接口补全」——反正每轮对话结束都完整落库了(细节二干的事),前端重连后拉一次就齐了。
博客助手选务实方案,一行 Last-Event-ID 的代码都不用写。又不是不能用(这话怎么这么耳熟)。
细节四:拒答分支也要体面
第 10 篇的拒答闸门(Rerank 分数不够直接说「资料没有」)在流式下别忘了:拒答是本地逻辑,根本不用调生成模型——直接一个 sources 空事件加一条完整的 delta 事件发完收工。别让「我们没有相关内容」这七个字也走一遍大模型,用户等两秒换来一句秒回就能说的话,挺冤的。
前端侧速记
后端工程师的博客,前端给个速记版:用浏览器原生 EventSource 或者 fetch 的流式读取都行(POST 请求得用后者),按事件类型分发:
sources→ 渲染卡片列表delta→ 往消息区追加done→ 收尾把滚动条锁到底
打字机效果天然自带,不需要任何动画库。
一个容易漏的点:done 事件里带上这轮的 traceId(对话记录的主键),用户反馈「这条答错了」的时候,拿 traceId 能直接定位到那次召回的全部细节。反馈闭环从体验层一直打通到数据层。
压测一下再收工
流式上线前做个简单压力测试,重点不是吞吐量,是看并发下首事件延迟(用户从点发送到看到 sources 卡片的耗时)。我的预期值:检索段 200-400ms,sources 事件应该在半秒内到达。如果并发一上来这个数飙到两三秒,先查两路检索是不是在串行等(第 09 篇说过该并行),再查数据库连接池够不够。
今天的落地清单
到这里,用户能感知的所有功能全部就位:多轮对话、可信回答、来源可查、流式体验。但还有一个问题从头到尾悬着:这系统到底好不好? 「感觉挺准」不是答案。下一篇讲评测——把我们从第 05 篇就开始攒的那 10 个问题升级成正经的评测体系,让「提升了多少」变成一个能写进复盘报告的数字。
下篇见。