LLM 流式应用生产化:从首 Token 到可恢复会话
流式输出不是把上游的 chunk 原样转发给前端。一个能上线的大模型流式应用要处理连接生命周期、首 Token 延迟、客户端取消、断线恢复、工具调用事件、安全审核、计费与可观测性。它是大模型应用开发和系统设计面试里非常容易拉开差距的专题。
基础使用方式可先看 LLM 应用开发实战。本页关注生产语义:当用户网络抖动、上游限流、输出中途失败或工具执行耗时很长时,系统还能否给出一致、可解释、可恢复的结果。
一、先定义体验与系统目标
流式场景最关键的体验指标不是总耗时,而是 TTFT(Time To First Token):用户从发送到看到第一个可展示内容的时间。总生成时间由模型 decode、输出长度和网络共同决定;但如果 TTFT 很差,用户会先认为系统卡住。
设计前先澄清五个问题:
- 是纯文本聊天,还是包含引用、图片、工具调用和结构化字段?
- 是否需要浏览器断线后继续看同一答案?允许重放多少历史?
- 用户点击停止后,是否必须取消上游推理以节约成本?
- 内容审核是首 token 前阻断、流中检测,还是完成后标记?
- 是否需要把流中的 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。
三、不要只传文本:设计显式事件协议
不要让前端猜测某段字符串是正文、引用还是报错。建议服务端向前端发送带版本的事件:
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 就调用支付、发信或写库。
四、流式状态机:生命周期必须可追踪
created -> queued -> connecting -> generating -> tool_waiting
| |
+-----> completed
+-----> cancelled
+-----> failed每次状态转换写入 stream_id、时间、原因、上游 request id 与 trace id。状态机的价值是防止并发竞态:例如用户取消与模型恰好完成同时发生,最终状态只能按明确规则落在 cancelled 或 completed,不能既计费成功又向用户显示“已取消”。
推荐策略:一旦已经持久化 message.completed,完成状态优先;在此之前收到有效取消,取消优先,并记录已生成 token 数。对外错误码还要区分“用户主动停止”“客户端断连”“上游超时”“内容安全拦截”和“服务内部故障”。
五、取消不是关闭浏览器标签那么简单
用户断开 SSE 连接只说明下行消费者消失,不一定说明用户不再需要结果:网络重连时可能还要恢复。因此把“客户端断连”和“业务取消”分开。
显式取消应沿链路传播:前端取消请求 -> 应用层标记 cancel token -> 停止从模型 SDK 读取 -> 关闭/abort 上游 HTTP 请求 -> 停止尚未开始的工具任务 -> 释放队列和并发槽位。若上游 API 不支持强取消,仍应停止下游读取并记录“可能已产生上游费用”。
取消接口必须幂等。用户连点、浏览器重试、负载均衡重放都不应产生多次取消副作用;可用 stream_id + actor_id 做权限校验,用状态机 compare-and-set 保证只发生一次有效转换。
六、断线续传与幂等提交
浏览器网络抖动时,最差体验是重新发起同一提问,得到两份答案并消耗两次 token。应分别解决两种问题:
- 消息提交幂等:客户端为
POST /messages带idempotency_key。服务端以用户、会话和 key 去重,重复请求返回原stream_id。 - 流事件恢复:前端保存最后收到的
seq,重连带Last-Event-ID或after_seq;服务端从事件存储或短期 buffer 重放缺失片段,再继续 live stream。
事件存储不一定要永久保存每个 token。常见折中是:Redis Stream/内存 ring buffer 保留短窗口用于重连;最终完整消息和审计摘要落数据库。窗口过期时返回明确的“无法续传”,前端改为获取已持久化的最终/部分消息,而不是静默从头重复生成。
七、背压、排队与慢客户端
上游模型可能每秒产生 token,前端也可能因弱网或后台标签页消费很慢。如果应用无限制累积 delta,就会出现单个连接占满内存、拖垮 event loop 的问题。
背压策略从轻到重:
- 小窗口聚合:按 20 至 100ms 或若干 token 合并再发,降低 frame 和 DOM 更新次数。
- 有界缓冲:每个 stream 限制未发送字节数,超过阈值暂停上游读取或转为持久化。
- 并发与队列:按租户/用户限制 in-flight stream,排队请求提供排队位置或快速失败。
- 慢客户端策略:超过心跳/写超时则断开下行,保留任务供短期恢复,或在预算策略下显式取消。
不要把“流式”误解为“每个 token 都必须立即发一次网络包”。面试中说明微批聚合是体验、吞吐和成本之间的折中,会很加分。
八、工具调用与流中结构化内容
Agent 场景会混合文本、思考状态、tool call、工具结果和最终回答。协议层应区分“给用户展示的 text delta”和“供系统执行的 tool arguments”。
安全原则:模型产生的 tool call 只是建议。执行前必须完成 schema 校验、身份与权限校验、参数范围校验、幂等键校验和高风险操作的人类确认。对于写操作,应把“计划展示”和“真正执行”拆成两个事件,不能因为模型开始流式输出就不可逆地操作外部系统。
工具很慢时,不要让 UI 沉默。发出 tool.status=running,但不要暴露内部敏感参数、密钥或完整推理内容。工具失败应有结构化结果,让模型决定重试、换工具或向用户解释,不应把原始 stack trace 流到浏览器。
九、流式安全、审核与数据治理
流式输出使“最终完成后再审核”变得不够。可采用分层策略:
- 输入侧:认证、频率限制、敏感数据识别、Prompt 注入检测。
- 上游前:按租户、模型和数据级别做路由,避免敏感上下文走错模型通道。
- 流中:对累计片段或句子边界做风险检测,命中时停止继续展示并转换为安全回复。
- 落盘前:脱敏、审计字段最小化、按数据等级配置留存期限。
流中审核一定存在“已展示少量内容再截断”的窗口。高风险行业可选择先缓冲到句子/段落边界再展示,牺牲 TTFT 换取更高控制力。回答设计题时要把这项业务权衡讲清楚。
十、可观测性:从一次坏体验回到根因
每个请求至少关联 request_id、trace_id、conversation_id、stream_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 元数据、哈希、脱敏片段和受控采样为主,严格区分调试权限与业务访问权限。
十一、故障演练清单
- 上游在首 token 前返回 429:前端收到可重试的结构化错误,不能留下半条空消息。
- 上游生成到一半断开:持久化 partial 状态,事件发出
message.failed,不可伪装成完成。 - 用户取消与模型完成并发:状态机只能得到一个最终状态,计费和 UI 一致。
- 前端断网 10 秒后恢复:使用
after_seq补齐,不重复模型调用。 - 工具参数只生成半段 JSON:不执行,等
tool.call.completed后校验。 - 慢客户端长期不读:有界 buffer 生效,不让单连接拖垮服务。
这些演练应自动化到集成测试或压测脚本里。只有“正常链路能流出来”不叫生产可用。
十二、系统设计回答框架
被问“设计一个企业聊天助手的流式接口”时,可按以下顺序回答:
- 澄清并发、TTFT、是否支持续传、工具与合规等级。
- 对外采用
POST创建消息、SSE 拉取事件、独立取消接口;定义版本化事件协议。 - 用
stream_id状态机治理 queued/generating/tool/completed/cancelled/failed。 - 用 idempotency key 防重复提交,用
Last-Event-ID或after_seq重放短期事件。 - 向上游传播 timeout 与 cancellation;对慢客户端做有界缓冲、微批聚合和并发队列。
- 将文本展示、tool 计划、真实执行和审计事件分离;高风险操作必须经策略层确认。
- 以 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 形成可运营闭环。