RAG 回答只显示一半:从模型输出到 SSE 渲染的排障笔记
RAG 页面出现“回答只显示一半”时,很容易先怀疑检索没找到材料,或者模型不够强。但回答内容缺失与流式传输中断是两类问题:前者要检查证据,后者要确认已经生成的内容有没有完整到达页面。
在此前的 RAG 链路笔记中,我把解析、召回、上下文组装和生成分开检查。这篇进一步讨论生成之后的链路:模型输出如何经过服务端、网络和浏览器,最终变成用户看到的文字。
1. 先给“完整”一个明确含义
HTTP 返回 200,只能说明请求已进入响应阶段。即使连接正常关闭,也不能单独证明回答生成成功:模型可能达到输出长度限制,服务端可能主动终止,用户也可能点击了停止。
我倾向于在应用协议里区分这些状态:
| 状态 | 含义 | 页面应该如何处理 |
|---|---|---|
| streaming | 正在接收增量内容 | 持续追加文本并保留停止入口 |
| completed | 收到有效的业务完成事件 | 结束加载,展示最终引用与结果 |
| truncated | 上游因长度限制等原因停止 | 保留内容并说明未完整生成 |
| cancelled | 用户主动停止 | 保留已生成内容,显示已停止 |
| failed | 请求、生成或传输失败 | 保留可用内容,展示错误和重试入口 |
这是一套应用状态划分,不是 SSE 协议自带的枚举。具体状态还需要根据模型供应商的结束原因做映射。若连接结束但没有业务完成标记,应按“未确认完成”处理,而不是直接显示成功。
2. 沿着四层证据定位丢失位置
最有用的诊断信息,是同一次请求在各层的输出是否一致。
| 层级 | 要确认的证据 | 常见问题 |
|---|---|---|
| 模型生成 | 增量文本、结束原因、上游错误 | 达到输出上限、供应商超时、证据不足 |
| 服务端转发 | 发出的事件序列、结束事件、异常分支 | 最后一个缓冲区未发出、异常被吞掉 |
| 网络与代理 | 首段到达时间、持续到达情况、断开位置 | 响应缓冲、空闲超时、连接中断 |
| 浏览器渲染 | 收到的事件、累计文本、最终状态 | 分块解析错误、状态覆盖、组件提前卸载 |
如果服务端已经发出了最后一段,而浏览器没有收到,应继续看传输链路;如果浏览器累计文本完整,页面却缺少段落,才进入状态管理和 Markdown 渲染排查。
同样,“引用不完整”也要分清楚:可能是生成前没有组装正确证据,也可能是服务端在最后发送引用事件,而前端收到结束标记后过早取消读取。
3. 网络分块不是 SSE 事件边界
这是流式读取中容易忽略的细节。一次 reader.read() 可能只拿到半个事件,也可能拿到多个事件。UTF-8 字符也可能跨字节块,不能把每个网络块直接当成一条完整消息解析。
例如服务端发送:
event: deltadata: {"text":"检索证据"}
event: donedata: {"status":"completed"}浏览器可能先收到 data: {"te,下一次才收到剩余内容。若对每个块单独调用 JSON.parse,正常传输也会变成解析失败。
处理顺序应当是:流式解码字节,累计文本,按照 SSE 的空行分隔规则提取完整事件,再解析业务数据。还需要处理多行 data:、不同换行形式、注释心跳和流结束时的残留内容。这里适合使用经过验证的 SSE 解析器,避免只按一个 \n 拆分。
原生 EventSource 适合它支持的请求方式;需要 POST 请求体或自定义鉴权头时,常见方案是 fetch 加流式读取。无论采用哪一种,都应明确连接恢复和事件解析由谁负责。
4. 停止、重试与旧请求不能混在一起
用户停止一次生成后立即重新提问,旧请求的最后几个回调仍可能到达。如果它们继续写入当前消息,就会出现两次回答混合、加载状态来回切换等问题。
可以给每次生成分配独立的 request_id,让回调只更新所属消息。停止时使用取消信号终止请求,并在服务端支持的情况下向上游传播取消;前端仍需校验请求身份,不能只依赖网络连接已经断开。
重试也要分为两种:重新生成一份回答,或续传同一次生成的已有事件。二者需要不同的服务端能力。只有服务端保留了稳定事件 ID、可重放记录和恢复位置,才有条件实现可靠续传;不能简单把重试后的内容拼到旧文本后面。
5. 代理配置和日志要服务于定位
SSE 响应通常使用 text/event-stream。但设置正确的 Content-Type 并不能保证实时到达,还要核对反向代理是否缓冲响应、是否存在空闲超时,以及服务端有没有及时刷新输出。
如果需要心跳,可以发送 SSE 注释行来维持活动,但它只能解决部分空闲连接问题,无法突破代理的绝对时长限制。调整配置应限定到流式接口,并通过实际请求确认效果。
日志中可以记录请求 ID、事件序号、累计字节数、首段耗时、结束原因和各层时间戳。排查完整性时,注意各层统计口径一致;字节数、字符数和 token 数不能直接比较。需要保留正文样本时,应先考虑访问权限与脱敏,避免把用户文档复制进长期日志。
6. 用故障注入检查终态
流式接口的测试不能只覆盖“顺利输出一段文字”。我会把下面这些情况作为回归场景:
- 把一个中文字符的 UTF-8 字节拆到不同网络块,确认解码正确。
- 将一个事件拆成多个块,再把多个事件合并到一个块,确认结果一致。
- 在任意位置断开连接,确认页面不会把未完成回答标为成功。
- 模拟长度限制、上游错误和用户取消,确认三者有不同终态。
- 连续发送两个请求,确认旧请求不会覆盖新请求的文字和状态。
- 在文本结束后发送引用事件,确认引用不会因过早关闭读取而丢失。
这些测试的目标是验证数据和状态。页面出现了打字动画,只能说明收到了部分增量,不能说明整条链路已经正确完成。
参考与延伸
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












