调试两天,败给一个换行符:SSE 在浏览器里全灭但 curl 正常
给博客的 AI 助手做上线验收时,我遇到了整个项目里最诡异的一个 bug:
- 服务器上 curl 接口,逐字流式输出完全正常
- 浏览器 Network 面板里,响应状态 200,数据一帧不落全部到达
- 但聊天窗界面上,永远只显示一句「AI 助手暂时休息中,请稍后再试」
网络层明明收到了完整答案,界面却断言服务挂了。从怀疑后端、怀疑限流、怀疑模型,到最后定位真相,中间绕了两天。这篇文章按时间线复盘整个排查过程,它给我的教训比 bug 本身值钱。
先交代背景
架构很简单:FastAPI 用 sse-starlette 把大模型的输出逐 token 推给浏览器。因为 EventSource 不支持 POST,前端是自己用 fetch + ReadableStream 手写的 SSE 解析器。核心代码长这样:
buf += decoder.decode(value, { stream: true });
const frames = buf.split("\n\n"); // ← 罪魁祸首就是这行
buf = frames.pop() ?? "";
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data:"));
// 解析 JSON,累计文本,渲染……
}
另外前端有个兜底:发出请求后 15 秒没收到第一个字,就判定失败,显示降级文案。这个兜底后面会再次出场——它既是保护,也是烟雾弹。
排查第一阶段:所有「显而易见」的方向都是错的
第一个怀疑对象:后端根本没产数据。 在服务器上 curl,回答逐字流出,非常健康。排除。
第二个怀疑对象:大模型超时。 当时 DeepSeek 默认开思考模式,首字延迟确实超过 15 秒,触发降级。修掉之后 curl 更快了,但浏览器依旧显示降级文案。真修了,但修的不是这个 bug。
第三个怀疑对象:限流误伤。 Nginx 反代后 request.client.host 恒为 127.0.0.1,所有访客共享一个「10 次/分钟」的限流桶,我自己测试太勤快把桶刷爆了。这也是个真 bug,也修了。但修完重启、等窗口滑过去,浏览器还是不行。
三个猜测,修出两个真 bug,症状却纹丝不动。现在回头看,这一阶段的错误在于:我一直在服务器侧找原因,而证据早就指向了浏览器侧——curl 正常这件事本身就说明服务端没问题,我却默认了「curl 正常 = 数据能被消费」。
转折点:Network 面板里的完整数据
真正改变战局的是一次抓包。F12 打开 Network,发一个问题,点开 chat 请求——Response 里是完整的、一字不差的流式回答,最后的 done 帧和 sources 都在。Console 没有任何报错。
这一下把问题域收窄了:数据到达了浏览器,但前端的解析器一个 token 都没认出来。界面的降级文案是 15 秒兜底触发的——解析器没认出一帧,gotFirst 永远是 false,定时器到点,拉闸。
真相:十六进制里的 0d 0a 0d 0a
带着「解析器为什么不认」的问题,我把响应原始字节拉下来看:
$ curl -sN -X POST http://<服务器>/api/chat -d '{...}' | xxd | head
00000000: 6461 7461 3a20 7b22 ... data: {"...
00000030: 7d 0d 0a 0d 0a }....
0d 0a 0d 0a,也就是 \r\n\r\n。
sse-starlette 按 SSE 规范用 CRLF 作为行尾,帧与帧之间是 \r\n\r\n。而我的解析器写的是 buf.split("\n\n")——在 \r\n\r\n 这个字节序列里,根本不存在连续两个 \n(中间隔着 \r)。于是:
split("\n\n")永远切不开,所有数据堆积在缓冲区里- 一帧都没被解析,界面永远停在等待态
- 15 秒兜底触发,显示「AI 助手暂时休息中」
- 而 curl 不做解析、原样打印,所以看起来「一切正常」
修复只有一行:
const frames = buf.split(/\r?\n\r?\n/); // 兼容 LF 与 CRLF 两种分帧
重新构建部署,浏览器里逐字流出回答,两天的心病结束。
复盘:这个 bug 教我的三件事
1. 「curl 能通」和「浏览器能通」是两种通。 curl 验证的是网络层和字节流;浏览器里的 JavaScript 还要再过一道解析层。这次两边结论矛盾时,我花了太久才意识到矛盾的根源是「测的根本不是同一层」。以后遇到「服务端正常、界面异常」,第一反应应该是对比「网络层收到的字节」和「JS 解析出的结果」。
2. 协议解析永远要对分隔符做兼容。 SSE 规范里 CRLF、LF、CR 三种行尾都合法。写解析器时按最宽松的方式切帧(/\r?\n\r?\n/),是零成本的防御。手写协议解析的地方,值得专门留一条测试用例:换一种行尾,还认不认。
3. 兜底机制会掩盖真因。 15 秒超时降级在产品上是正确的——用户不该对着转圈等一辈子。但它把所有失败都折叠成同一句文案,排查时「首字超时」「HTTP 报错」「解析失败」看起来一模一样。事后我给前端补了一条规则:不同的失败原因要在 Console 里留下不同的日志,降级文案可以一样,调试信息必须分开。
写在最后
这个 bug 的修复成本是一行代码,定位成本是两天。差距在于方向:服务端被 curl 反复证明无辜之后,我才把目光挪回浏览器。
如果你也在手写 SSE 解析器,记住两件事:分帧用 /\r?\n\r?\n/,别用 "\n\n";以及,curl 说正常的时候,先别急着信。