流式回答为什么会重复?一次实战修复
做流式输出(SSE/WS)时,遇到的体验问题之一就是:回答里出现整段/整句重复 。这篇文章用一次真实项目(Flask SSE + 前端 fetch 读流)的排查与修复,讲清楚"重复"最常见的成因、定位方法,以及一套能稳定落地的修复方案。
现象:流式输出过程中出现重复段落
典型表现:
- 流式过程中,某些句子/段落完全重复出现,甚至越往后重复越多
- 或者最终完整答案展示时,同一段被渲染两次
先说结论:这次项目命中的两个"确定性重复源"
这次排查最终确认并修复了两类问题,都是"只要满足条件就一定会复现"的那种:
- 问题 A(后端重复发送) :后端在非流式路径里 发送了两次
answer,前端自然渲染两次 - 问题 B(累积全文 token 被当增量拼接) :后端流式 token 实际上是"累积全文 "(例如
你好→你好,世界→你好,世界!),但前端用的是currentAnswer += token,于是全文被不断重复追加,导致"越播越复读"
你的架构大概率是这样(这点很关键)
很多人以为自己在用 SSE,但前端实现其实是:
- 后端返回
text/event-stream(SSE 格式) - 前端不用
EventSource,而是用fetch + ReadableStream:
js
const response = await fetch('/api/search', { method: 'POST', body: ... });
const reader = response.body.getReader();
// 循环 reader.read(),按 \n 切行,解析 data: {...}
这类实现非常常见,也更灵活,但意味着"去重/增量协议"要你自己保证:前端通常只是"按顺序追加显示",不会替你识别重复 chunk。
排查方法:先分清是"网络层重复"还是"内容层重复"
1)网络层:同一类消息到底发了几次?
你可以快速在前端加日志(或抓 Network)确认:
type: "token"收到几次?type: "answer"收到几次?type: "final"收到几次?
如果你看到 answer 出现两次,那基本就是后端逻辑问题,不是模型"复读"。
2)内容层:token 是"增量"还是"累积"?
观察 token 序列:
- 增量 token :
"你"、"好"、","、"世"、"界"...... - 累积全文 token :
"你"、"你好"、"你好,世"、"你好,世界"......
如果是累积全文而你还在前端用 +=,重复几乎必现。
根因 A:后端在非流式模式里发送了两次 answer
为什么会发生?
非常常见的写法是:
- 先生成完整答案 → 发一次
answer - 后面又做"去重/写历史/补充字段" → 再发一次
answer(以为这是"最终版")
前端只管渲染,当然就重复。
修复原则
- 无论流式/非流式:
answer只发一次 final只做收尾 (把answer/results/references/session_id等最终状态一次性打包)
根因 B:后端 token 是累积全文,但前端按增量追加
为什么这是"高频坑"?
不同模型 SDK/封装层的 stream 语义不完全一致:
- 有的每次给你的是"新增 delta"
- 有的每次给你的是"截至当前的全文"
- 还有的混着来(某些分支是 delta、某些分支变全文)
而前端大多数实现都是:
js
currentAnswer += msg.token;
一旦后端给的是全文累计,前端每次都把全文再追加一次,重复就会指数增长。
修复方案(推荐:后端兜底)
最稳的做法是:后端统一把输出规范化成"只发送新增部分"。
思路:
- 记录上一轮"全文累计"
lastText - 如果当前 chunk
tokenStr以lastText开头,就只发送新增那一截:tokenStr.slice(lastText.length) - 然后更新
lastText = tokenStr
这样前端永远收到的是"增量",继续 += 就安全。
后端兜底代码(Python/Flask)
python
# 流式发送答案片段
last_cumulative_text = "" # 记录累积文本
for token in answer_stream:
if token is None:
continue
token_str = str(token) if not isinstance(token, str) else token
if not token_str:
continue
# 默认:直接发送(标准增量模式)
chunk_to_send = token_str
# 兼容累积全文模式:只发送新增部分
if last_cumulative_text and token_str.startswith(last_cumulative_text):
chunk_to_send = token_str[len(last_cumulative_text):]
last_cumulative_text = token_str
elif (not last_cumulative_text) and full_answer and token_str.startswith(full_answer):
# 首次遇到累积全文,用已有 full_answer 对齐
chunk_to_send = token_str[len(full_answer):]
last_cumulative_text = token_str
# 发送增量片段
if chunk_to_send:
full_answer += chunk_to_send
yield f"data: {json.dumps({'type': 'token', 'token': chunk_to_send})}\n\n"
修复后的效果
- 非流式:
answer不再重复发送 → 前端不会重复渲染整段答案 - 流式:即便底层 SDK 给的是"累积全文",后端也会切成"增量片段"发出去 → 前端不会越播越重复
常见误区(别踩这些坑)
误区1:以为是模型本身的问题
表现:流式重复后,第一反应是调 LLM 参数
- 调高
repeat_penalty(1.5 → 2.0) - 降低
temperature(0.7 → 0.3) - 甚至考虑换模型(qwen2.5:7b → mistral:7b)
为什么是误区 :
如果你发现 非流式回答完全正常、流式回答才重复 ,那基本可以排除模型问题。
模型生成的内容是一样的,区别只在于传输和渲染方式。
误区2:用"实时去重器"拦截输出
我们尝试过在流式生成过程中加一个 StreamDeduplicator,用滑动窗口检测重复并立即停止。
问题:
- 容易误判正常内容(比如列表项结构相似就被当成重复)
- 导致回答被截断不完整
- 增加了不必要的复杂度
正解:累积全文转增量的方案更稳定,不会误杀正常内容。
30秒快速定位法
对比测试:在同一个问题上分别测试流式和非流式
| 模式 | 结果 | 结论 |
|---|---|---|
| 非流式正常、流式重复 | 几乎确定是累积全文问题 | |
| 两者都重复 | 可能是后端发送了两次 answer | |
| 两者都正常 | 可能是前端并发请求导致 |
这个对比测试能省掉 80% 的无效排查时间。
排查决策树
流式输出出现重复
│
├─→ 非流式也重复?
│ │
│ ├─ 是 → 检查后端是否发送了两次 answer
│ │
│ └─ 否 → 【累积全文问题】后端兜底转增量
│
└─→ 只在特定问题重复?
│
├─ 是 → 可能是模型对该内容的生成特性,调 repeat_penalty
│
└─ 否 → 检查前端是否并发请求/重复渲染
一分钟自检清单(建议收藏)
- Network/日志里
answer是否收到两次?(后端重复发送) - token 序列是增量还是累积? (累积全文 + 前端
+=是必炸组合) - 同一次请求是否触发了两个生成器循环?(生成器被迭代两次)
- 前端是否可能重复启动请求?(多次点击/未禁用按钮/并发请求)
- 最终渲染是否在
answer和final都触发了同一套 UI 更新?(UI 层二次渲染)
经验总结:流式系统要"协议化",别靠侥幸
流式输出不是"能跑就行",最好明确一套小协议:
token:永远是增量answer:只发一次(最终答案)final:只做收尾状态与元数据(成功/失败、references、session_id 等)
一旦把这三条定死,前后端实现都会简单很多,排查成本也会直线下降。
最后一条金律 :当非流式正常、流式重复时,不要怀疑模型,先查传输层。这能帮你省掉大量无效的调参和换模型时间。
附:本次修复涉及的技术栈
- 后端:Python Flask + LlamaIndex + Ollama
- 前端:原生 JavaScript + fetch ReadableStream
- 模型:Qwen2.5 系列(通过 Ollama 本地部署)
- 传输协议:SSE (Server-Sent Events)