跳到主要内容

生产环境中的流式 AI 应用:没人警告过你的那些坑

· 阅读需 12 分钟
Tian Pan
Software Engineer

第一个出问题的迹象:你的测试环境流式传输完美,但在生产环境中,每个用户都会看到一个空白屏幕,接着整个响应一次性出现。你检查了 LLM 提供商 —— 没问题。你检查了后端 —— 没问题。服务器正在流式传输 Token,但它们就是没能到达浏览器。

90% 的情况下,罪魁祸首是:NGINX 正在缓冲你的响应。

这是最常见的流式传输故障模式,而且除非你知道要去寻找它,否则它完全是不可见的。它还反映了生产环境流式传输中更广泛的问题:问题通常不在 LLM 集成上,而在于模型和用户之间的所有基础设施中。

TTFT 是你用户唯一能真实感知到的指标

在深入探讨故障模式之前,值得明确你正在优化的目标。对于面向用户的应用,LLM 推理有两个关键的延迟指标:

TTFT (Time to First Token,首个 Token 响应时间):从用户提交提示词到 UI 中出现第一个 Token 所经过的时间。这包括请求排队、提示词预填充处理和网络往返。它是主要的感知延迟指标。

TPOT (Time Per Output Token,单个 Token 输出时间):相邻 Token 之间的平均时间 —— 即流的“阅读速度”。

用户感知到的流式响应速度比总延迟相同的缓冲响应速度快约 40%。这种效果几乎完全来自 TTFT。看到第一个 Token 在约 300ms 内到达,标志着系统的活跃性,并瓦解了等待的心理负担。此后,只要 Token 以稳定的速度到达(即使速度适中),感知的响应性就会保持在高水平。

一致性比纯粹的速度更重要。以每秒 20 个 Token 的稳定速度交付的流,其体验优于爆发 100 个 Token、停顿两秒后再爆发的流。当你看到尽管总延迟可以接受,但用户仍抱怨“AI 感觉很慢”时,请查看 TPOT 方差,而不是 TPOT 平均值。

不同用例的目标阈值:

  • 交互式聊天机器人:TTFT 低于 500ms
  • 代码补全 / IDE 工具:TTFT 低于 100ms
  • 长文本生成:如果 Token 流保持顺畅,高达约 3 秒的 TTFT 是可以忍受的
  • P95 告警阈值:面向用户的应用为 1-2 秒

重要的权衡:最小化 TTFT 需要在推理层使用更小的批大小(batch sizes),这会降低 GPU 吞吐量。对于面向用户的流量,优先优化 TTFT 并接受吞吐量损失。

NGINX 缓冲陷阱(以及完整的代理检查清单)

NGINX 的默认配置会缓冲代理响应。当你将 NGINX 反向代理放在流式 API 端点前面时,Token 会在服务器端累积并突发式刷新 —— 或者直到响应完成且连接关闭时才刷新。这一切都是静默发生的,没有错误信息。服务器日志显示响应正常。

针对任何 SSE 端点的必要 NGINX 配置:

location /api/stream {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}

proxy_buffering off 指令是关键。gzip off 也很重要 —— 压缩操作作用于完整的块并会重新缓冲流。增加 proxy_read_timeout 是必要的,因为默认的 60 秒超时会切断任何生成时间超过一分钟的 Token 响应。一个 4,096 输出 Token 的 Claude Opus 响应在 30 tokens/second 的速度下需要超过两分钟。

你的后端还应该设置 X-Accel-Buffering: no 响应头。这会指示任何兼容 Nginx 的上游代理禁用缓冲,无论其配置如何。将其视为深度防御。

CDN 使问题更加复杂。Cloudflare、AWS CloudFront 和大多数其他 CDN 提供商默认会缓冲 SSE。最简单的修复方法是让流式端点完全绕过 CDN。如果你出于其他原因需要 CDN 覆盖,请检查供应商文档中关于流式透传(streaming passthrough)的配置 —— 它是存在的,但不是默认设置。

验证你的配置是否真正生效。运行 nginx -T 来转储合并后的有效配置,而不仅仅是你编辑的文件。多个配置文件、include 指令以及 location 块的继承关系可能会静默覆盖你的设置。

心跳检测:需要 90 秒才能显现的故障模式

在没有任何数据流动的情况下,中间代理、某些 CDN 和特定浏览器会在 30-120 秒的沉默后关闭 SSE 连接。这表现为与思考时间奇特相关的超时 —— 针对难题的缓慢响应被切断,而针对简单提示词的快速响应则运行良好。

解决方法是心跳检测:在任何停顿期间,每 15-30 秒发送一个 SSE 注释事件:

: heartbeat

SSE 注释行以 : 开头,并会被客户端忽略。空行用于终止该事件。这可以在工具执行等待、漫长的预填充处理以及任何没有 Token 流动的停顿期间保持连接活跃。只需两行代码,就能防止在生产环境中可能需要数周才能诊断出的超时故障。

大规模应用下你会遇到的 O(n²) JSON 解析问题

如果你应用程序在流式传输结构化输出 —— 比如函数调用参数、JSON 响应、类型化数据 —— 你会面临增量解析问题。每一个中间数据块(chunk)都是无效的 JSON。通常的直觉是将每个新数据块喂给 JSON 修复库,并尝试构建部分对象以便实时显示。

在处理响应较小的开发环境下,这行得通。但在响应较大的生产环境中,这会导致明显的卡顿甚至最终超时。原因在于:朴素的增量解析是 O(n²) 的。每次调用修复库都会从头重新解析整个累积的字符串。对于一个以小块交付的 12 KB 响应,这意味着需要处理大约 1500 万个字符,而不是 12,000 个。真实的基准测试显示了这种性能下降曲线:

  • 数据块 1-688:每个数据块处理时间少于 1ms
  • 数据块 689:3.2ms —— 首次出现明显延迟
  • 数据块 954:5.4ms —— 出现肉眼可见的卡顿
  • 数据块 1514:完成进度 63% 时,每个数据块耗时 16.2ms

正确的方法是有状态增量解析:在调用之间维护解析器状态(最后解析的索引、当前的嵌套上下文、不完整的 token 缓冲区)。这能将 12 KB 响应的总处理时间从约 30 秒减少到约 43ms —— 提升了 388 倍。如果你要自己构建这个功能,核心秘诀是:每个新数据块只需要处理实际新增的字符,而不是完整的累积字符串。

对于必须保证结构有效(而不仅仅是尽力显示)的情况,受限解码(constrained decoding)是另一种选择:像 Outlines 和 vLLM 的结构化输出功能,可以在生成过程中屏蔽无效的 token,使模型只能产生符合特定 schema 的输出。这彻底消除了部分解析问题,但需要推理侧的基础设施支持。

可恢复流:改变可能性的模式

大多数团队在生产环境中实施得太晚的一个关键流式传输模式是:将 LLM 生成过程与客户端连接分离。

客户端连接是脆弱的。笔记本计算机会合上,网络会掉线,用户会在响应中途刷新页面。在朴素架构中 —— 客户端发送请求,服务器流式传输响应 —— 任何连接中断都会丢失已生成的部分响应并被迫重新生成。在大规模应用下,这会浪费大量的 API 开支并让用户感到沮丧。

解耦架构如下:

  1. 客户端发送一个带有稳定会话 ID(UUID,存储在 localStorage 中)的生成请求
  2. 流生成器服务开始 LLM 推理,并使用 XADD 将每个数据块写入 Redis Stream
  3. 一个独立的流消费者 API 从 Redis 读取内容,并通过 SSE 将数据块转发给客户端
  4. 如果客户端断开连接,生成过程仍会不间断地继续
  5. 重新连接时,客户端发送其会话 ID;消费者使用 Redis 消费者组从客户端上次看到的进度开始读取

这开启了几个特性:

  • 刷新安全:页面刷新会重新连接到同一个流,从最后一个接收到的 token 开始恢复
  • 多设备:在第二个设备上打开同一个会话,可以从上次离开的地方继续
  • 成本高效:重新连接无需重新生成;你只需为每一次生成支付一次费用
  • 幂等性:会话 ID 消除了生成触发器的重复 —— 发送两次相同的 ID 会返回现有的流

在客户端配合 AbortController 来处理用户主动发起的取消操作。将 abort 信号同时关联到 fetch 请求和后端取消 API。这会在推理层停止 token 生成,而不只是断掉连接 —— 这对于大规模成本控制至关重要,因为你停止了为那些没人会看到的 token 付费。

长连接的负载均衡

标准的 HTTP 负载均衡假设请求是短周期的。LLM 流式连接是长周期的 —— 复杂的智能体任务可能需要数分钟。这打破了两个常见的默认设置。

轮询负载均衡 (Round-robin) 在连接建立时均匀分配请求。在运行一小时的生产流量后,根据请求的时机,一个后端可能持有 800 个活跃流,而另一个只有 100 个。应改用 least_conn:将每个新连接路由到活跃连接数最少的后端。

ip_hash 粘性会话 在 CDN 后端会失效。所有的 CDN 流量都来自少量的 CDN 边缘 IP,因此 ip_hash 会将所有流量路由到一两个后端。应改用基于 cookie 的亲和性(affinity)。

部署时的连接排空 (Connection draining):在发布新版本时,后端会在响应中途终止活跃流。配置优雅终止期,其长度至少应与你预期的最长流持续时间一致。在 AWS ALB 上,这是 deregistration_delay。在 GCP 上,相应地配置后端服务超时。

特别是在 GCP 上:HTTP(S) 负载均衡器默认有 30 秒的后端超时,并会缓冲流式响应。使用具有客户端 IP 亲和性的 TCP 负载均衡,或者将所有请求状态外部化到 Redis,使后端实现无状态,从而完全避免粘性会话需求。

生产环境中的内存与连接管理

几个广泛使用的 LLM 代理库在持续负载下存在已知的内存泄漏问题:

  • LiteLLM:异步流处理器随每次调用累积内存,在持续负载数小时后导致 OOM 崩溃。在 500 RPS 的持续流量下,预计会有 8% 的超时率,需要每 6-8 小时重启进程。
  • vLLM:在生产流量下内存缓慢线性增长(约 400 MB/分钟),追踪到是异步生成器清理中的循环引用所致。
  • LangChain:对话历史累积和未清理的向量嵌入导致内存增长;在长时间运行的进程中,数百次 LLM 调用后容器就会 OOM。

缓解措施:始终将流消费者包裹在 try/finally 中以保证清理。为生成器对象使用上下文管理器。在发布前,在预发布环境中模拟生产流量模式监控长期内存变化。在 Python 中,GC 停顿、锁竞争和 asyncio 事件循环饥饿在 10 个并发连接(开发环境)和 1,000 个并发连接(生产环境)之间都会呈现非线性恶化。请在真实的并发量下进行测试。

基础设施检查清单

对于任何要上线生产环境的流式传输端点:

  • NGINXproxy_buffering offgzip offproxy_read_timeout 86400sproxy_http_version 1.1,以及 X-Accel-Buffering: no 响应头
  • 负载均衡器least_conn 策略、基于 cookie 的粘性会话、部署时的优雅连接排空
  • CDN:将流式传输端点绕过 CDN,或验证其流式透传配置
  • 心跳检测:在生成停顿期间,每 15-30 秒发送一次 SSE 注释事件
  • 超时设置:检查并增加请求路径中的每一个超时时间——包括负载均衡器、代理和后端服务——确保它们超过你预期的最大响应时间
  • 内存:在生产级别的并发量(而非开发级别)下进行压力测试;设置内存增长报警
  • 中断处理:将 AbortController 与客户端 fetch 和后端取消逻辑关联;衡量提前终止所节省的成本
  • 可恢复性:对于部分响应会产生实际成本或影响用户体验的应用,考虑采用基于 Redis 的解耦架构

流式传输层很少是 AI 工作中最有趣的部分,但它往往是生产环境可靠性出问题的地方。实现上述模式并不需要深厚的基础设施专业知识——你只需要在凌晨两点开始排查故障之前,意识到它们的存在即可。

References:Let's stay in touch and Follow me for more thoughts and updates