一行 `compress: false`,为什么让 SSE 首包恢复实时返回

在一个ai问到聊天页面中,我遇到过一个很典型的流式接口现象:同一个接口在 demo 项目中很快就能收到第一条 SSE 消息,在当前 Taro H5 项目里却会长时间 Pending,随后把积累的内容一次性显示出来。

最终,在本地开发服务器中配置下面这一行后,两个项目的表现一致了:

ts 复制代码
// config/dev.ts
export default {
  h5: {
    devServer: {
      compress: false,
      proxy: {
        "/api": {
          target: "http://xxx.xx.xx",
          changeOrigin: true,
        },
      },
    },
  },
};

这看起来像是一个前端小配置,实际暴露的是 SSE 与 HTTP 响应压缩之间的兼容性问题。

SSE 的关键不是"请求成功",而是"每个分片都能立刻到达浏览器"

普通 HTTP 接口通常会等待响应完整生成后再返回;SSE 则会在同一条 HTTP 连接上持续输出事件:

text 复制代码
event: progress
data: {"text":"正在分析您的症状信息"}

event: message.delta
data: {"text":"请问"}

event: message.delta
data: {"text":"症状持续多久了?"}

浏览器的 ReadableStream 只有在收到字节时,reader.read() 才会继续执行。因此,流式体验依赖整条链路都不缓存、不聚合:

text 复制代码
浏览器 → Taro/Webpack 开发服务器 → 代理 → SSE 服务

只要其中一层攒住了分片,前端就会表现为"Pending 很久,然后一次性返回"。前端的 SSE 解析器即使写得完全正确,也拿不到尚未抵达浏览器的数据。

compress: true 为什么可能破坏流式体验

Webpack Dev Server 的 devServer.compress 会为可压缩响应启用 gzip / Brotli 等响应压缩中间件。对 HTML、JS、JSON 这类完整响应而言,这能减少传输体积;但 SSE 的每个事件往往很短,例如几十个字节。

压缩器为了提高压缩效率,可能会在内部累计数据后再输出压缩块。于是服务端虽然已经 flush 了事件,中间的压缩层仍可能没有把足够的数据交给浏览器。浏览器只能在压缩数据真正输出、并解压后,才收到一批完整的 SSE 文本。

所以这里的根因并不是 fetchReadableStream 或 React 渲染慢,而是开发代理在 SSE 数据通路上增加了一个可能聚合输出的响应压缩层。

关闭该层后:

ts 复制代码
devServer: {
  compress: false,
}

上游返回的 SSE 分片会更直接地透传到浏览器,第一条事件就能及时触发前端回调。此次"关闭后行为与 demo 一致"也说明,demo 与当前项目的主要差异是在本地开发服务器 / 代理层,而不是后端接口本身。

这不是请求参数 compress

这里的 compress: falseWebpack Dev Server 的响应压缩配置 ,作用于本地 H5 开发服务器;它不是 SSE 接口请求体或 fetch 的参数。

text 复制代码
config/dev.ts
└── h5.devServer.compress = false

因此它只解决本地开发环境通过 Taro Webpack 代理访问流式接口时的缓冲问题,不会自动修复生产环境中的网关、Nginx、CDN 或后端响应缓冲。

生产环境如何正确处理 SSE

不建议为了一个 SSE 接口关闭全站压缩。更稳妥的做法是仅对 SSE 路径禁用压缩和缓冲,并保留其他静态资源、普通 API 的压缩能力。

以 Nginx 为例,SSE 路径通常需要类似的策略:

nginx 复制代码
location /ihis/pre-consult/stream {
  proxy_pass http://pre_consult_backend;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
  proxy_buffering off;
  gzip off;
  proxy_cache off;
}

服务端响应也应满足:

http 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

其中 X-Accel-Buffering: no 对经过 Nginx 的响应尤其有用;最终是否生效仍取决于网关和反向代理的实际配置。

排查"流式变成批量返回"的步骤

  1. 先直接访问后端 SSE 地址,使用 curl -N 或服务端日志确认首个事件的实际发送时机。
  2. 再通过本地代理访问,比较首个事件到达时间;如果只有代理路径变慢,问题在开发服务器或网关层。
  3. 查看响应头中的 Content-Encoding。出现 gzipbr 时,要重点检查压缩中间件是否在缓冲分片。
  4. 确认 Nginx / 网关的 proxy_buffering、缓存和压缩策略。
  5. 最后再检查前端:使用增量解析器按 SSE 空行(\n\n)切分事件,避免等到整个响应结束才处理。

总结

SSE 是端到端的流式协议。后端已经逐条 write,并不代表浏览器会逐条收到;任何压缩、缓存或代理缓冲层都可能把它重新变成批量响应。

当前项目在开发环境将 h5.devServer.compress 设为 false,是让预问诊流式接口恢复首包实时性的正确修复。上线前则应将相同原则落实到生产网关:SSE 路由禁用压缩与缓冲,普通业务接口仍保留性能优化。

相关推荐
子兮曰1 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰1 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万1 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝1 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋1 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁1 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95271 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大1 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师1 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学1 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端