Skip to content

LLM 流式应用生产化:从首 Token 到可恢复会话

流式输出不是把上游的 chunk 原样转发给前端。一个能上线的大模型流式应用要处理连接生命周期、首 Token 延迟、客户端取消、断线恢复、工具调用事件、安全审核、计费与可观测性。它是大模型应用开发和系统设计面试里非常容易拉开差距的专题。

基础使用方式可先看 LLM 应用开发实战。本页关注生产语义:当用户网络抖动、上游限流、输出中途失败或工具执行耗时很长时,系统还能否给出一致、可解释、可恢复的结果。

一、先定义体验与系统目标

流式场景最关键的体验指标不是总耗时,而是 TTFT(Time To First Token):用户从发送到看到第一个可展示内容的时间。总生成时间由模型 decode、输出长度和网络共同决定;但如果 TTFT 很差,用户会先认为系统卡住。

设计前先澄清五个问题:

  1. 是纯文本聊天,还是包含引用、图片、工具调用和结构化字段?
  2. 是否需要浏览器断线后继续看同一答案?允许重放多少历史?
  3. 用户点击停止后,是否必须取消上游推理以节约成本?
  4. 内容审核是首 token 前阻断、流中检测,还是完成后标记?
  5. 是否需要把流中的 token、工具调用和最终消息写入会话审计?

不同答案决定协议和状态机。不要在需求未明确时就说“用 WebSocket”。

二、SSE、WebSocket 与 HTTP Chunked 如何选

协议适合优点边界
SSE服务端单向持续推送聊天文本浏览器原生支持、HTTP 语义简单、自动重连友好客户端上行仍走普通 HTTP;二进制不方便
WebSocket双向实时协作、语音、频繁中断控制全双工、事件统一网关、心跳、重连和状态治理更复杂
HTTP chunked服务间转发或简单下载式流实现直接浏览器事件语义、重连 cursor 需要自己补

典型聊天产品优先选 SSE + 独立取消接口:发送消息用 POST /messages,服务器返回 stream_id;前端用 GET /streams/{stream_id} 接收事件;停止时调用 POST /streams/{stream_id}/cancel。这样鉴权、审计和重试与普通 HTTP 更一致。真正需要客户端持续上行事件时再使用 WebSocket。

三、不要只传文本:设计显式事件协议

不要让前端猜测某段字符串是正文、引用还是报错。建议服务端向前端发送带版本的事件:

text
event: message.start
data: {"stream_id":"s_123","message_id":"m_456","model":"primary"}

event: message.delta
id: 17
data: {"text":"首先,","seq":17}

event: tool.status
data: {"tool":"search","status":"running"}

event: citation
data: {"items":[{"doc_id":"d_9","span":[3,18]}]}

event: message.completed
data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":265}}

stream_id 关联一次生成,message_id 表示持久化的会话消息,seq 或 SSE id 支撑断线续传。事件应是追加式:前端只需按序应用,而不是等待一次“最终 JSON”。对 tool call 参数 delta,必须先缓冲、完成 schema 校验后才真正执行,不能收到半截 JSON 就调用支付、发信或写库。

四、流式状态机:生命周期必须可追踪

text
created -> queued -> connecting -> generating -> tool_waiting
                                      |               |
                                      +-----> completed
                                      +-----> cancelled
                                      +-----> failed

每次状态转换写入 stream_id、时间、原因、上游 request id 与 trace id。状态机的价值是防止并发竞态:例如用户取消与模型恰好完成同时发生,最终状态只能按明确规则落在 cancelledcompleted,不能既计费成功又向用户显示“已取消”。

推荐策略:一旦已经持久化 message.completed,完成状态优先;在此之前收到有效取消,取消优先,并记录已生成 token 数。对外错误码还要区分“用户主动停止”“客户端断连”“上游超时”“内容安全拦截”和“服务内部故障”。

五、取消不是关闭浏览器标签那么简单

用户断开 SSE 连接只说明下行消费者消失,不一定说明用户不再需要结果:网络重连时可能还要恢复。因此把“客户端断连”和“业务取消”分开。

显式取消应沿链路传播:前端取消请求 -> 应用层标记 cancel token -> 停止从模型 SDK 读取 -> 关闭/abort 上游 HTTP 请求 -> 停止尚未开始的工具任务 -> 释放队列和并发槽位。若上游 API 不支持强取消,仍应停止下游读取并记录“可能已产生上游费用”。

取消接口必须幂等。用户连点、浏览器重试、负载均衡重放都不应产生多次取消副作用;可用 stream_id + actor_id 做权限校验,用状态机 compare-and-set 保证只发生一次有效转换。

六、断线续传与幂等提交

浏览器网络抖动时,最差体验是重新发起同一提问,得到两份答案并消耗两次 token。应分别解决两种问题:

  • 消息提交幂等:客户端为 POST /messagesidempotency_key。服务端以用户、会话和 key 去重,重复请求返回原 stream_id
  • 流事件恢复:前端保存最后收到的 seq,重连带 Last-Event-IDafter_seq;服务端从事件存储或短期 buffer 重放缺失片段,再继续 live stream。

事件存储不一定要永久保存每个 token。常见折中是:Redis Stream/内存 ring buffer 保留短窗口用于重连;最终完整消息和审计摘要落数据库。窗口过期时返回明确的“无法续传”,前端改为获取已持久化的最终/部分消息,而不是静默从头重复生成。

七、背压、排队与慢客户端

上游模型可能每秒产生 token,前端也可能因弱网或后台标签页消费很慢。如果应用无限制累积 delta,就会出现单个连接占满内存、拖垮 event loop 的问题。

背压策略从轻到重:

  1. 小窗口聚合:按 20 至 100ms 或若干 token 合并再发,降低 frame 和 DOM 更新次数。
  2. 有界缓冲:每个 stream 限制未发送字节数,超过阈值暂停上游读取或转为持久化。
  3. 并发与队列:按租户/用户限制 in-flight stream,排队请求提供排队位置或快速失败。
  4. 慢客户端策略:超过心跳/写超时则断开下行,保留任务供短期恢复,或在预算策略下显式取消。

不要把“流式”误解为“每个 token 都必须立即发一次网络包”。面试中说明微批聚合是体验、吞吐和成本之间的折中,会很加分。

八、工具调用与流中结构化内容

Agent 场景会混合文本、思考状态、tool call、工具结果和最终回答。协议层应区分“给用户展示的 text delta”和“供系统执行的 tool arguments”。

安全原则:模型产生的 tool call 只是建议。执行前必须完成 schema 校验、身份与权限校验、参数范围校验、幂等键校验和高风险操作的人类确认。对于写操作,应把“计划展示”和“真正执行”拆成两个事件,不能因为模型开始流式输出就不可逆地操作外部系统。

工具很慢时,不要让 UI 沉默。发出 tool.status=running,但不要暴露内部敏感参数、密钥或完整推理内容。工具失败应有结构化结果,让模型决定重试、换工具或向用户解释,不应把原始 stack trace 流到浏览器。

九、流式安全、审核与数据治理

流式输出使“最终完成后再审核”变得不够。可采用分层策略:

  • 输入侧:认证、频率限制、敏感数据识别、Prompt 注入检测。
  • 上游前:按租户、模型和数据级别做路由,避免敏感上下文走错模型通道。
  • 流中:对累计片段或句子边界做风险检测,命中时停止继续展示并转换为安全回复。
  • 落盘前:脱敏、审计字段最小化、按数据等级配置留存期限。

流中审核一定存在“已展示少量内容再截断”的窗口。高风险行业可选择先缓冲到句子/段落边界再展示,牺牲 TTFT 换取更高控制力。回答设计题时要把这项业务权衡讲清楚。

十、可观测性:从一次坏体验回到根因

每个请求至少关联 request_idtrace_idconversation_idstream_id、用户/租户、模型版本、prompt 版本和上游 request id。建议关键指标:

指标用途
P50/P95 TTFT定位排队、网关、prefill 或上游首包变慢
tokens/s 与总时长区分 decode 慢、输出长和网络慢
cancel rate / disconnect rate识别体验差、前端异常或预算策略问题
resume success rate验证断线恢复设计是否真的可用
finish reason 分布发现 length 截断、内容拦截、工具失败
每 stream token/成本防循环调用、做租户预算和计费对账

日志不能默认保存全部 prompt 和输出。生产系统应以 trace 元数据、哈希、脱敏片段和受控采样为主,严格区分调试权限与业务访问权限。

十一、故障演练清单

  1. 上游在首 token 前返回 429:前端收到可重试的结构化错误,不能留下半条空消息。
  2. 上游生成到一半断开:持久化 partial 状态,事件发出 message.failed,不可伪装成完成。
  3. 用户取消与模型完成并发:状态机只能得到一个最终状态,计费和 UI 一致。
  4. 前端断网 10 秒后恢复:使用 after_seq 补齐,不重复模型调用。
  5. 工具参数只生成半段 JSON:不执行,等 tool.call.completed 后校验。
  6. 慢客户端长期不读:有界 buffer 生效,不让单连接拖垮服务。

这些演练应自动化到集成测试或压测脚本里。只有“正常链路能流出来”不叫生产可用。

十二、系统设计回答框架

被问“设计一个企业聊天助手的流式接口”时,可按以下顺序回答:

  1. 澄清并发、TTFT、是否支持续传、工具与合规等级。
  2. 对外采用 POST 创建消息、SSE 拉取事件、独立取消接口;定义版本化事件协议。
  3. stream_id 状态机治理 queued/generating/tool/completed/cancelled/failed。
  4. 用 idempotency key 防重复提交,用 Last-Event-IDafter_seq 重放短期事件。
  5. 向上游传播 timeout 与 cancellation;对慢客户端做有界缓冲、微批聚合和并发队列。
  6. 将文本展示、tool 计划、真实执行和审计事件分离;高风险操作必须经策略层确认。
  7. 以 TTFT、tokens/s、完成原因、恢复率、取消率和成本建立 trace 与告警。

十三、面试高频问答

Q1:SSE 断开时要不要立刻取消模型?

不应默认取消。断连可能只是网络抖动,系统可保留短期事件以支持恢复;用户点击停止或达到预算/超时才是业务取消。资源紧张时可配置断连宽限期,超期后再取消。

Q2:为什么需要事件序号,只有 stream_id 不够吗?

stream_id 标识哪一条流,序号标识已经消费到哪里。没有序号无法准确重放、去重和处理乱序/重复投递,也无法把“重连后继续”做成可验证的协议。

Q3:客户端重试 POST 如何避免重复扣费?

使用幂等键,将同一用户/会话/键映射到唯一创建结果;服务端在事务或原子存储中先占位再创建 stream,重复请求返回原结果。不能依赖前端“保证只点一次”。

Q4:TTFT 高怎么排查?

按 trace 分解排队时间、网关路由、RAG/工具预处理、上游连接、prefill 和首个下行事件。不同阶段对应不同解法:排队看并发和配额,prefill 看 prompt 长度与 prefix cache,上游看模型路由和网络。

Q5:流式内容审核会不会伤害体验?

会存在权衡。逐 token 审核延迟低但可能有少量内容先显示;句子/段落缓冲更安全但增加 TTFT。按风险等级分层,明确哪些场景宁可慢也不能早泄露,才是工程化答案。

Q6:如何保证工具调用在流式下安全?

将模型输出当作不可信意图;工具参数收齐后做 schema、权限、范围、幂等和策略校验,写操作走确认或事务边界。文本流和执行流分离,绝不因为半截参数就调用外部系统。

十四、可直接复述的收束答案

我会把流式聊天设计成事件驱动的可恢复状态机,而不是模型 chunk 的透传。外部用幂等 POST 创建消息、SSE 接收带序号事件、独立接口取消;内部用 stream_id 追踪排队、生成、工具、完成和失败。断线和取消分开处理,短期 buffer 支持按序重放,慢客户端使用有界缓冲和微批聚合。工具调用先缓冲并校验再执行,流中审核按业务风险选择 token 或句子级策略。最后通过 TTFT、tokens/s、恢复率、finish reason 和成本 trace 形成可运营闭环。

基于 MIT 许可发布