【AI】流式回答为什么会重复?一次实战修复

流式回答为什么会重复?一次实战修复

做流式输出(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 tokenStrlastText 开头,就只发送新增那一截: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 序列是增量还是累积? (累积全文 + 前端 += 是必炸组合)
  • 同一次请求是否触发了两个生成器循环?(生成器被迭代两次)
  • 前端是否可能重复启动请求?(多次点击/未禁用按钮/并发请求)
  • 最终渲染是否在 answerfinal 都触发了同一套 UI 更新?(UI 层二次渲染)

经验总结:流式系统要"协议化",别靠侥幸

流式输出不是"能跑就行",最好明确一套小协议:

  • token永远是增量
  • answer只发一次(最终答案)
  • final只做收尾状态与元数据(成功/失败、references、session_id 等)

一旦把这三条定死,前后端实现都会简单很多,排查成本也会直线下降。

最后一条金律 :当非流式正常、流式重复时,不要怀疑模型,先查传输层。这能帮你省掉大量无效的调参和换模型时间。


附:本次修复涉及的技术栈

  • 后端:Python Flask + LlamaIndex + Ollama
  • 前端:原生 JavaScript + fetch ReadableStream
  • 模型:Qwen2.5 系列(通过 Ollama 本地部署)
  • 传输协议:SSE (Server-Sent Events)
相关推荐
慢半拍iii1 小时前
从零搭建CNN:如何高效调用ops-nn算子库
人工智能·神经网络·ai·cnn·cann
空白诗3 小时前
CANN ops-nn 算子解读:AIGC 风格迁移中的 BatchNorm 与 InstanceNorm 实现
人工智能·ai
说实话起个名字真难啊4 小时前
用docker来安装openclaw
docker·ai·容器
金融RPA机器人丨实在智能5 小时前
Android Studio开发App项目进入AI深水区:实在智能Agent引领无代码交互革命
android·人工智能·ai·android studio
乂爻yiyao5 小时前
Vibe Coding 工程化实践
人工智能·ai
慢半拍iii6 小时前
ops-nn算子库深度解析:昇腾神经网络计算的基础
人工智能·深度学习·神经网络·ai·cann
Elastic 中国社区官方博客7 小时前
Elasticsearch:Workflows 介绍 - 9.3
大数据·数据库·人工智能·elasticsearch·ai·全文检索
组合缺一7 小时前
Solon AI (Java) v3.9 正式发布:全能 Skill 爆发,Agent 协作更专业!仍然支持 java8!
java·人工智能·ai·llm·agent·solon·mcp
Ekehlaft7 小时前
这款国产 AI,让 Python 小白也能玩转编程
开发语言·人工智能·python·ai·aipy
慢半拍iii7 小时前
对比分析:ops-nn与传统深度学习框架算子的差异
人工智能·深度学习·ai·cann