实时智能体 UI 背后的流式传输基础设施
大多数智能体流式实现都会在以下四个方面出错:代理静默地吞掉了流、用户关闭标签页而智能体却在后台运行导致持续消耗 token、页面刷新导致任务丢失,或者工具调用在流的中途失败而智能体陷入静默空闲。这些都不是模型问题。它们是基础设施问题,是团队在本地测试良好,但在生产环境中才会发现的问题。
这篇文章讨论的就是这种差距 —— 服务器端架构的决策决定了一个实时智能体 UI 是否真正可靠,而不仅仅是在演示环境中看起来令人惊叹。
SSE vs. WebSocket:为什么所有主流 LLM API 都选择了 SSE
当 OpenAI、Anthropic 和 Google 发布他们的流式 API 时,他们都做出了同样的选择:基于 HTTP 的服务器发送事件(Server-Sent Events,SSE),而非 WebSockets 。这并非惯性使然;它反映了一套特定的权衡,在核心的 token 交付用例中,SSE 更具优势。
SSE 在普通 HTTP/HTTPS 上运行,无需协议升级握手。这意味着它能透明地与现有的所有反向代理、CDN 和负载均衡器协同工作。水平扩展是无状态的 —— 无需粘性会话(sticky sessions),无需 socket 代理。浏览器的 EventSource API 会自动利用 Last-Event-ID 处理重连。随着 HTTP/2 的普及,以前关于“每个域名 SSE 限制 6 个并发连接”的反对意见也随之消失 —— Chrome 现在允许在单个 H2 连接上运行 100 个并发 SSE 流。
WebSockets 提供了一个 SSE 不具备的特性:在单个 TCP 连接上的真正双向性。在服务器主动回传流数据时,客户端也可以向服务器发送数据。对于大多数 LLM token 交付来说,你并不需要这个特性 —— 用户提交请求,模型响应,结束。双向性真正发挥作用的地方包括:
- 流式传输过程中的人机协同(Human-in-the-loop)工具审批
- 用户可以在生成过程中进行干预的实时协同 AI 编辑器
- 语音智能体(通常使用 WebRTC 处理音频通道,而非 WebSockets)
一个团队在 token 流原型中用 WebSockets 替换了 SSE,结果在三天内就回滚了。负载均衡器需要特殊配置,重连逻辑变得非常复杂,而且那些理解 HTTP 的可观测性工具也不再适用。教训是:WebSocket 的复杂性会在基础设施层叠加。只有当你真正需要双向性时,才去支付这个代价。
一个合理的默认方案是:使用 SSE 进行 token 交付和智能体进度事件通知,并为客户端发回服务器的信号(取消、审批)添加一个单独的 HTTP POST 接口。这既能保持流式路径的简单,又能处理需要客户端到服务器通信的场景。
没人提醒你的代理缓存问题
最常见的生产环境流式传输失败并非发生在你的应用程序代码中 —— 而是在基础设施层。Nginx、Cloudflare 和 AWS ALB 默认会对 HTTP 响应进行缓冲(buffering)。如果开启了缓冲,你的流式接口在本地测试时运行完美,但在生产环境中却会静默地将所有 token 作为一个批处理包在最后一次性返回。
对于 nginx,修复方法很明确:
proxy_buffering off;
add_header X-Accel-Buffering no;
Cloudflare 有一个特定的 bug:通过 Cloudflare Tunnels (cloudflared) 进行的 GET 请求会被缓冲直到连接关闭。解决方法是在通过 Cloudflare Tunnels 路由时,对你的 SSE 接口使用 POST 请求而非 GET。对于 Cloudflare 代理(非 tunnels),每 30 秒发送一个心跳注释,以防止 100 秒的无响应连接超时导致流断开:
: heartbeat\n\n
在写入第一个数据块之前,务必立即刷新 header。在 Node.js 中使用:res.flushHeaders()。在 FastAPI 中:返回带有异步生成器的 StreamingResponse —— 框架会处理每次 yield 时的刷新。如果没有显式的刷新,响应 header 和第一个数据块可能会被 TCP 栈打包在一起,导致 UI 在显示第一个 token 之前出现明显的延迟。
背压(Backpressure):当模型跑得比客户端快时
在快速推理接口上,当速度达到每秒 80-100 个 token 时,模型的生成速度可能会超过客户端网络所能吸收的速度。简单的实现要么在内存中缓存所有内容(最终导致崩溃),要么静默丢弃 token。这两种情况都不可接受。
在 Node.js 中,当内核写入缓冲区满时,res.write() 会返回 false。正确的模式是:
const canContinue = res.write(data);
if (!canContinue) {
await new Promise(resolve => res.once('drain', resolve));
}
等待 drain 事件会挂起 token 写入循环,直到内核缓冲区清空。这会通过你的应用程序层向推理请求本身施加协作式背压,自然地调节生成速度以匹配客户端的消费能力。
对于 WebSocket 连接,监控 ws.bufferedAmount。当它超过你的阈值(64KB 是一个常用的起点)时,暂停上游生成器直到缓冲区清空。
对于 Python 异步服务器(FastAPI, Starlette),异步生成器实现了隐式背压 —— 只有当框架的事件循环调用 __anext__() 时,生成器才会推进,而这只有在上一块数据发送完成后才会发生。基础设施会处理它;你只需要编写一个正确的异步生成器,而不是收集所有块并在最后 yield。
在高吞吐量部署中需要注意的失败模式:如果你的推理服务器(vLLM, TGI)同时处理多个并发流,且部分客户端施加了背压,推理服务器可能会为这些慢速客户端卡住 GPU 显存,导致快速客户端也在等待。监控每个连接的队列深度,并为落后太多的客户端实现连接级超时。
优雅取消:当用户点击停止时会发生什么
当用户放弃一个运行时间较长的 Agent 任务时,需要发生三件事:LLM 生成停止、服务端清理运行、且孤儿(orphaned)子进程不 再继续消耗 token。
在客户端,AbortController 发出取消信号:
const controller = new AbortController();
const response = await fetch('/api/agent', {
method: 'POST',
signal: controller.signal
});
// 用户点击停止:
controller.abort();
浏览器发送连接重置,这在服务器上表现为一个信号。在 Node.js 中:监听 req.on('close', ...)。在 Fastify 或 Koa 等框架中,ctx.req.signal 或 req.signal 会传播该中止信号。将其转发给 LLM 提供商的调用:
const result = await streamText({
model,
prompt,
abortSignal: req.signal
});
大多数 LLM 提供商的 SDK 都接受 abortSignal 参数,并会取消进行中的请求,释放任何预留的推理能力。
服务端清理是团队经常遗漏步骤的地方。当一个流式传输中止时:
- 在推理层释放 KV 缓存块。 如果你是自托管(如 vLLM),不取消生成请求会导致 KV 缓存块继续为已放弃的会话预留,从而降低其他用户的可用容量。
- 如果预期会恢复,则持久化部分结果。 如果用户可以重新启动任务,请保存中止前已交付的所有 token。
- 释放数据库连接和锁。 Agent 任务通常会在运行期间持有打开的事务或咨询锁(advisory locks)。
对于使用 Temporal 编排的 Agent,取消必须是显式的。将每个 LLM 调用放在 Temporal Activity 中,而不是 Workflow 代码中。设置一个根据预期的推理延迟加回放余量校准后的 HeartbeatTimeout。当父工作流被取消时,下一个心跳会将该信号传播到进行中的 Activity。如果没有这个机制,子工作流就会变成孤儿——它们会继续运行,累积成本和事件历史,直到完成或达到 Temporal 的 51,200 个事件的工作流限制。
重新连接:当浏览器在任务中途刷新时
这是流式传输基础架构中最难的问题,而且大多数 Agent 框架都没有提供解决方案。用户开始一个 10 分钟的研究任务,浏览器刷新(标签页崩溃、误触 F5、移动端后台杀进程),任务就消失了。从 Agent 的角度来看,流遇到了客户端断开连接——它可能会也可能不会继续运行,这取决于你的服务器架构,但客户端不知道从哪里重新连接。
生产环境的解决方案采用三层架构:
第一层(KV 存储):将 task_id → active_stream_id 与生命周期状态映射:pending → ongoing → complete。这是重新连接的查询索引。
第二层(流缓冲区):存储所有以 task_id + stream_id 为键的 Agent 事件块,独立于客户端连接状态。Redis Streams 在这里表现良好——有序、持久、可订阅。
第三层(SSE 中继):当客户端重新连接时(通过 GET 请求 /api/task/{id}/stream),中继从第一层查找 active_stream_id,订阅第二层的该流,并通过 SSE 从头开始转发缓冲的块。如果任务已经完成,则返回 HTTP 204,并让客户端渲染存储的结果。
关键的设计约束:第二层的缓冲区必须由服务端 Agent 进程写入,而不是由 SSE 接口写入。无论是否有人在听,Agent 都会向缓冲区发送事件。SSE 接口纯粹是一个中继——客户端连接、回放,并通过 Last-Event-ID 跟踪它们的位置。
生产环境中会出现几种竞态条件:
- 在任务标记为
complete之后,流缓冲区中可能仍有到达的块。在渲染完成之前,务必完全清空缓冲区。 - 如果你的聊天框架仅在生成结束时才分配消息 ID,则不能在生成期间将它们用作流 ID。请使用在任务启动时生成的独立 UUID。
- 在页面重新挂载时,先检查
active_stream_id再尝试重新连接。如果它为空,说明任务要么尚未开始,要么已经完成——不要发出会导致失败的重新连接请求。
原生的 SSE Last-Event-ID 重新连接机制(浏览器自动重新连接,服务器从该 ID 开始回放)可以很好地处理短生命周期的流,但对于长达数分钟的 Agent 任务来说是不够的。完整的页面导航会清除 EventSource 状态,从而中断重新连接链。对于运行时间可能超过典型浏览器会话的任务,请使用三层架构。
多步 Agent 输出的分层事件模型
对于单轮 token 流式传输,平坦的文本增量(text deltas)流就足够了。对于多步 Agent——UI 需要显示进行中的工具调用、中间推理、并行子任务和最终综合——你需要一个结构化的事件层次结构。
新兴的标准(在 AG-UI 协议中正式化,并由 OpenAI Agents SDK 和 LangGraph 独立实现)将事件组织为三层:
第一层(原始):Token 增量,原始 LLM 输出。供仅显示文本的聊天 UI 使用。
第二层(语义):tool_called、tool_result、message_complete、Agent 移交事件。供需要显示 Agent 行为的 Agent 仪表盘使用。
第三层(生命周期):run_started、run_finished、run_error、checkpoint_saved。供监控系统和编排协调器使用。
UI 订阅它们需要的层。添加第二层及以上的可见性,可以将“一个转圈图标然后是响应”变成一个显示 Agent 正在搜索三个数据库、读取五个文件并综合结果的 UI——这会显著改变用户是否信任该输出。
针对流式工具调用的一种特定失败模式:Claude(以及其他提供商)会以部分 JSON 的形式增量流式传输工具调用的输入。累 积契约是严格的——input_json = "",追加每个增量,仅在 content_block_stop 时进行解析。如果你的 max_tokens 限制在工具调用中途达到,则累积的 JSON 是不完整的,JSON.parse() 将抛出异常。在解析工具输入之前,务必检查 stop_reason。如果原因是 "max_tokens" 且工具调用正在进行中,则该工具调用是格式错误的,会话需要一个恢复路径,而不是解析尝试。
故障恢复架构
在多步 Agent 流中,有两种故障模式会悄无声息地终结会话:
截断的工具调用 (Truncated tool calls):网络故障导致流式响应被截断,使得最后一条消息的 stop_reason 为 "tool_use",但没有任何工具调用的内容块 (content blocks)。Agent 会将其视为一个有效的工具调用轮次,但因找不到可执行的内容而陷入死循环或进入闲置状态。防范措施:在继续执行之前,验证每个 stop_reason 为 "tool_use" 的消息是否至少包含一个完整的工具调用块。
孤立的工具结果 (Orphaned tool results):在发出工具调用和接收工具结果之间发生的崩溃,会导致对话历史中存在一个没有对应 tool_use 的 tool_result。大多数 LLM API 会将此状态视为严重错误(hard error)并拒绝处理 —— 除非手动编辑历史记录,否则会话将无法恢复。防范措施:原子化地持久化 Assistant 消息和工具结果。在 Agent 循环继续之前,将两者通过单个事务写入存储。
对于运行在持久化执行基础设施(如 Temporal、LangGraph 的 PostgreSQL checkpointer)上的 Agent,持久化和重放语义会自动处理大部分此类问题。对于基于原生 HTTP 和自定义状态管理构建的 Agent,这些是你必须手动强制执行的约束条件 (invariants)。
针对瞬时工具故障的恢复模式:将错误作为 is_error: true 的 tool_result 返回。LLM 会将其视为内容接收,并可以决定使用不同参数重试或向上升级。这比抛出导致会话中断的异常更好,因为 LLM 拥有异常处理器所不具备的上下文。
监控指标
生产级流式系统的标准监控指标集:
- TTFT (Time to First Token,首个 Token 响应时间):感知响应速度。大多数服务商的故障发生在首个 Token 返回之前,而非流的中途。这是回退 (fallback) 逻辑可行的窗口期。
- 流完成率 (Stream completion rate):分别追踪用户中止、服务器错误和正常完成的情况。中止率上升通常意味着延迟问题,而非用户行为发生了改变。
- 连接时长 P99:基础设施的超时时间应设在 P99 之上,而不是恰好等于它。常见的错误是:当 P99 任务时长为 55 秒时,却将 Nginx 的
proxy_read_timeout设置为 60 秒。 - 分块间隙时间 (Chunk gap time):连续分块之间的时间间隔。峰值通常预示着推理停顿,而非网络问题。
- 重连成功率:如果你构建了可恢复的流,请衡量重连是否真的能正确重放。此处的静默失败意味着用户会在不知情的情况下丢失任务。
能揭示最多问题的调 试模式:使用通用的 stream_id 记录每个流生命周期事件(开始、首个分块、最后一个分块、关闭、错误、中止)并进行关联。已启动但从未关闭的流预示着连接泄漏。启动后立即关闭的流则预示着代理缓存问题。最后一个分块与关闭之间的巨大时间差表明你的后处理逻辑(持久化、压缩)阻塞了流的关闭。
实时 Agent UI 与传统的 API-over-HTTP 应用有着本质区别。基础设施需求 —— 持久连接、顺序交付、可恢复性、背压 (backpressure)、优雅取消 —— 并没有开箱即用的默认配置。每一个环节都需要明确的设计决策,而一旦缺失,其故障模式往往是悄无声息的,而非显性的报错。
- https://compute.hivenet.com/post/llm-streaming-sse-websockets
- https://procedure.tech/blogs/the-streaming-backbone-of-llms-why-server-sent-events-(sse)-still-wins-in-2025
- https://docs.ag-ui.com/introduction
- https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming
- https://platform.claude.com/docs/en/build-with-claude/streaming
- https://openai.github.io/openai-agents-python/streaming/
- https://ai-sdk.dev/docs/advanced/stopping-streams
- https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams
- https://stardrift.ai/blog/streaming-resumptions
- https://upstash.com/blog/realtime-ai-sdk
- https://www.xgrid.co/resources/temporal-ai-agent-orchestration-failure-patterns/
- https://www.assembled.com/blog/your-llm-provider-will-go-down-but-you-dont-have-to
- https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
- https://vercel.com/changelog/node-js-vercel-functions-now-support-request-cancellation
- https://community.cloudflare.com/t/using-server-sent-events-sse-with-cloudflare-proxy/656279
- https://docs.vllm.ai/en/stable/design/metrics/
