面试知识库
高 进阶

流式输出与实时交互#

一句话答案#

AI 应用必须流式,因为 LLM 是逐 token 生成的,一条几百字的回答要跑好几秒,流式把用户感知延迟从总时长压到首 token 时间(TTFT)。端到端流式 = 模型 SSE → 服务端转发(WebFlux Flux / MVC SseEmitter,关掉 Nginx 缓冲、加心跳)→ 浏览器增量渲染;Agent 场景的难点不在传输,而在多阶段事件协议设计(思考/工具调用/答案)、中途出错的收尾、以及流式与输出审查、计费统计之间的矛盾。

核心要点

1. 为什么必须流式:TTFT vs 总延迟#

LLM 解码是自回归的,每个 token 都要过一遍模型,常见吞吐每秒几十个 token,一条 500 token 的回答总时长动辄 5–15 秒。用户体验的关键指标不是总时长而是 TTFT(Time To First Token):流式下 TTFT 通常几百毫秒,用户立刻看到内容开始出现,总时长没变但”感知延迟”大幅下降;非流式则是白屏等到底。附带收益:用户可中途取消、省下后半段 token。模型层 SSE 的帧格式(data:、delta、[DONE]、usage 在末帧)见 [LLM API调用与流式输出](/topics/llm-serving/LLM API调用与流式输出),本文聚焦应用层。

2. 传输选型:SSE vs WebSocket vs HTTP chunked#

维度SSEWebSocketHTTP chunked(NDJSON)
方向单向 服务端→客户端全双工单向
协议普通 HTTP,text/event-stream需 Upgrade 握手,独立协议普通 HTTP
代理/LB/CDN 兼容好(本质是长 HTTP 响应),只需关缓冲需代理支持 Upgrade;服务端状态放实例内存时 LB 要会话保持(状态放 Redis 广播则不需要)好
重连与事件语义EventSource 自动重连、Last-Event-ID 续传;内置 event:/id:/retry:自己做心跳、重连、定帧自己实现,按行切
典型用途聊天回答、Agent 事件流语音实时对话、协作编辑、需要客户端频繁打断/插话服务间或简单场景

默认选 SSE:对话场景数据流绝大多数是服务端→客户端,用户的”发送/取消”走普通 HTTP 即可。注意浏览器原生 EventSource 只能 GET、不能带自定义 header(如 Authorization)、HTTP/1.1 下每域名并发连接有限,所以生产前端通常用 fetch + ReadableStream + TextDecoder 手动按 \n\n 切帧解析 SSE(POST 体带 messages、header 带 token)。双向强交互(语音、打断)才上 WebSocket;服务内部流式用 gRPC streaming。

3. 服务端实现要点(Spring 为例)#

WebFlux:@GetMapping(produces = TEXT_EVENT_STREAM_VALUE) Flux<ServerSentEvent<String>>
         → chatClient.prompt(q).stream().content().map(t -> ServerSentEvent.builder(t).event("delta").build())
         → doOnCancel:客户端断开 → Reactor 取消订阅 → 上游 LLM 连接随之关闭
Spring MVC:new SseEmitter(0L)(0 = 不超时,否则默认几十秒就断)→ 独立线程里 emitter.send(...) / complete()
         → onTimeout / onError / onCompletion 里释放资源、取消上游;别占 Servlet 线程
plaintext
  • 背压与慢消费者:LLM 吐得快、客户端(弱网/卡顿)收得慢时,Flux 会在缓冲区堆积;用 onBackpressureBuffer(n) 限界或 onBackpressureLatest,并设置单连接最大存活时间。写出失败(IOException/ClientAbortException)要当作取消信号向上游传播,否则模型还在白白生成计费
  • Nginx/网关:必须 proxy_buffering off;(或响应头 X-Accel-Buffering: no 按接口关闭)、proxy_cache off、proxy_http_version 1.1、proxy_read_timeout 调大到几分钟、对 text/event-stream 关 gzip——任何一层缓冲都会把流式变回”攒满一起吐”
  • 心跳与超时分层:LB/网关通常有 60s 左右空闲超时,工具执行或长思考期间没有 token 输出就会被掐断,每 15–30s 发一条 SSE 注释帧 : ping 或 event: heartbeat;超时要分连接超时(短)、总超时(按最长任务)、“帧间空闲超时”(检测上游卡死)三层

4. Agent 场景的事件协议设计#

Agent 一次运行不只是”一段文字”,而是思考 → 工具调用(可能多次、并行)→ 最终回答的多阶段事件流,不能只往下游扔文本。常见做法是 event: <type> + JSON payload,每个事件带 run_id、seq(用于去重/续传);Anthropic 的 message_start / content_block_delta / message_stop、Vercel AI SDK 的 UI message stream(基于 SSE,text-start/text-delta/text-end、tool-input-delta、tool-output-available 等分型帧)、LangGraph 的 stream mode 思路一致——事件类型化 + 增量 + 明确的开始/结束帧:

event: run_start        data: {"run_id":"r1"}
event: reasoning_delta  data: {"text":"需要先查订单…"}                        // 可选,按产品决定是否外显
event: tool_call_start  data: {"call_id":"c1","name":"get_order"}
event: tool_call_delta  data: {"call_id":"c1","args_delta":"{\"id\":\"1"}      // 参数 JSON 碎片,前端只做预览
event: tool_result      data: {"call_id":"c1","ok":true,"summary":"…"}         // 结果摘要,完整结果不一定下发
event: text_delta       data: {"text":"您的订单"}   …   event: usage {…}   event: run_end {"finish_reason":"stop"}   // 出错则 event: error + run_end
plaintext
  • 工具参数增量拼接:模型层 delta.tool_calls[index].function.arguments 是字符串碎片,服务端按 index 累加、拼完整再 parse 再执行,绝不能对半截 JSON 动手;前端若要实时展示参数可用容错的 partial-JSON 解析,仅做展示。工具循环本身见 [Function Calling与工具编排](/topics/ai-agent/Function Calling与工具编排)
  • 中途出错怎么收尾:HTTP 200 和一半内容已经发出去了,不能再改状态码——错误必须带内(in-band):发 event: error(含可读信息与是否可重试)再正常 complete,前端保留已渲染的部分并标记失败;服务端要把部分输出与错误一并落日志。可续传的场景用 seq/Last-Event-ID 让客户端从断点重放,多数对话产品选择整轮重试

5. 流式与审查、计费、观测的矛盾#

  • 流式 vs 输出审查:Guardrails 的输出护栏希望”看完整再放行”,流式却已经把内容推给用户。折中方案:①按句/按固定窗口缓冲后审再放(延迟增加一拍);②边流边审,命中违规立刻截断并下发 event: replace/撤回指令清掉前端已显示内容;③高风险场景直接退化为非流式。已 flush 的内容不可回溯改写——生成后再做反思、重写的逻辑在流式模式下会变成空操作:改写结果推不出去,评测上表现为这一步零增益(见 Guardrails与输出安全护栏)。修复方向是把它前置为生成前的门控(如置信度判断),或只在非流式场景做改写
  • 缓存命中的体验一致:语义缓存命中时直接拿到整段答案,常见做法是按固定字符数分片、带小间隔回放,让前端表现和真实流式一致
  • 流式 vs 计费/观测:模型侧 usage 通常在末帧(OpenAI Chat Completions 要开 stream_options.include_usage,各家字段和开关不同,以官方文档为准),客户端中途取消时上游也被取消,usage 可能永远拿不到——要用 tokenizer 估算已产出内容来补记费用;观测指标要拆成 TTFT、tokens/s、流总时长、取消率,Span 在流结束/取消时才关闭(见 Agent可观测性与质量保障)

6. 前端渲染要点#

前端用 fetch + ReadableStream 流式解码、按 \n\n 切帧,Markdown 按块缓存只重渲染最后一块,停止用 AbortController 并把取消传到服务端和上游模型;读流、状态建模、增量 Markdown、自动滚动、停止与重新生成见 AI对话前端流式渲染。

面试回答(2分钟版)

流式是 AI 应用的必选项:LLM 逐 token 生成,几百字回答要好几秒,流式把感知延迟从总时长降到首 token 时间 TTFT,几百毫秒就开始出字,还能中途取消省 token。传输默认选 SSE:本质是长 HTTP 响应,单向服务端推,代理兼容好,自带 event、id 和重连;WebSocket 全双工,语音对话、频繁打断才用,代价是代理要支持 Upgrade、自己做心跳重连。原生 EventSource 不能 POST、不能带自定义 header,前端一般用 fetch 加 ReadableStream 手动解析。服务端 WebFlux 返回 Flux 接 ChatClient 的 stream,MVC 用 SseEmitter。三个坑:Nginx 要 proxy_buffering off 或 X-Accel-Buffering: no,否则攒满一起吐;工具执行期间没输出要发心跳防网关断连;客户端断开要把取消传到上游别让模型白跑。Agent 场景一次运行是思考、工具调用、答案多阶段,要设计事件协议——event type 加 JSON payload,如 tool_call_start/delta、tool_result、text_delta、error、run_end,带 run_id 和 seq;工具参数碎片拼完再 parse 再执行;中途出错 200 已发,只能带内发 error 再正常结束。最后两个矛盾:流式和输出审查冲突,已 flush 的内容改不了,只能按句缓冲审或边流边审违规截断撤回,生成后再反思改写的逻辑在流式下会失效;流式和计费冲突,usage 在末帧,中途取消拿不到,要用 tokenizer 估算补记,观测拆成 TTFT、tokens/s、取消率。

追问与易错

追问方向:

  • SSE 和 WebSocket 怎么选?为什么聊天不用 WebSocket? → 聊天数据流是单向服务端推,用户的发送/取消走普通 HTTP 就够,SSE 是长 HTTP 响应,代理/LB/鉴权全复用现有体系,还自带重连;WebSocket 双向但要代理支持 Upgrade、自己做心跳,服务端状态放实例内存时还要 LB 会话保持(多实例转发见 实时推送断线续传与多实例事件转发),只有语音实时对话、需要打断插话这类真正双向的场景才值得
  • 工具执行要 30 秒没有 token 输出,连接会断吗? → 会,LB/网关普遍 60s 左右空闲超时,有的更短;用 SSE 注释帧 : ping 或 heartbeat 事件每 15–30s 保活,同时把 proxy_read_timeout 调到最长任务时长
  • 流式中途 LLM 报错/工具失败怎么处理? → 状态码已经 200 发出去,只能带内:发 event: error(含可重试标记)再正常结束流,前端保留已渲染部分并标失败;服务端日志要把部分输出与错误一起落;可续传场景用 seq / Last-Event-ID 重放
  • 流式怎么和 Guardrails 兼容? → 要么按句/窗口缓冲后审再放(多一拍延迟)、要么边流边审命中即截断并发撤回事件清屏、要么高风险接口退化非流式;核心认知是已 flush 内容不可改写,所以事后”改答案”类逻辑(反思/重写)要前置为生成前门控
  • 用户点”停止”后成本还在涨? → 取消要三层贯通:前端 AbortController → 服务端感知写失败/Flux cancel → 取消上游 LLM HTTP 连接;少一环模型就还在后台生成计费

易错点:

  • ❌ “SSE 就是 WebSocket 的简化版,能互换” → SSE 是单向 HTTP 长响应,WebSocket 是独立双工协议,代理兼容、鉴权、重连机制完全不同
  • ❌ “后端 stream 了前端就一定是流式” → 中间任一层(Nginx 缓冲、gzip、框架未 flush)都能把流式变回整段返回,排查要逐层看 chunk 到达时间
  • ❌ “工具调用参数一到就能 JSON.parse” → 参数是字符串碎片,必须按 index 拼完整(收到该 tool_call 的结束信号/finish_reason)再 parse
  • ❌ “流式出错改返回 500 就行” → 头已发出,只能带内 error 事件 + 正常 complete

结合项目时可以讲: TTFT 和总时长各是多少、瓶颈在检索还是生成、流式和审查/反思冲突时在哪一层做了取舍,以及用什么数据(分段耗时、评测增益)验证。