通义千问 SSE 流式:累计文本 vs 增量 Delta

通义千问 SSE 流式:累计文本 vs 增量 Delta

一个 bug 牵出的问题

前端流式对话,通义千问返回的内容重复:

复制代码
你好!你好!有什么有什么我可以帮助你的吗?

排查发现:通义千问每条 SSE 事件的 output.text完整文本,不是增量。

json 复制代码
// event 1
{"output":{"text":"你好"}}

// event 2
{"output":{"text":"你好!"}}

// event 3
{"output":{"text":"你好!有什么"}}

而 OpenAI 系列返回的是增量 delta:

json 复制代码
// event 1
{"choices":[{"delta":{"content":"你好"}}]}

// event 2
{"choices":[{"delta":{"content":"!"}}]}

// event 3
{"choices":[{"delta":{"content":"有什么"}}]}

如果后端把通义千问的每条 output.text 当作 delta 直接转发给前端,前端逐条拼接,自然重复。

为什么会有两种设计?

通义千问的 output.text 默认是累计的,但提供了 incremental_output=true 参数。

加了参数后,文档说是增量输出,但实际行为是:首条返回完整文本,后续返回增量

更坑的是,这个参数的行为在 SDK 和 HTTP API 之间不一致。为了可靠性,我们选择不依赖这个参数,而是在后端自行计算增量。

增量计算的核心逻辑

go 复制代码
var lastText string

for scanner.Scan() {
    line := scanner.Text()
    if !strings.HasPrefix(line, "data:") {
        continue
    }

    data := strings.TrimPrefix(line, "data:")
    var result tongyiStreamEvent
    json.Unmarshal([]byte(data), &result)

    // 核心计算:只发送新增部分
    if result.Output.Text != "" && result.Output.Text != lastText {
        increment := strings.TrimPrefix(result.Output.Text, lastText)
        if increment != "" {
            callback(increment)
        }
        lastText = result.Output.Text
    }
}

strings.TrimPrefix(result.Output.Text, lastText) ------ 用已发送长度截取新增部分。

这是 O(1) 的字符串操作,没有正则、没有分词,直接基于前缀匹配。

为什么不用下标切片?

直觉可能会写:

go 复制代码
increment := result.Output.Text[len(lastText):]

这更高效,但有风险:output.text 不保证严格递增。如果某条事件的文本被截断或修正(如敏感词过滤),下标切片会越界或产生乱码(UTF-8 多字节字符中间截断)。

strings.TrimPrefix 更安全:如果前缀不匹配,返回原字符串,不会 panic。代价是多一次字符串比较,在 SSE 场景下完全可忽略。

为什么不直接让前端去重?

假设前端收到累计文本,自己计算增量:

javascript 复制代码
let lastText = ''
event => {
    const delta = event.text.slice(lastText.length)
    lastText = event.text
    fullAnswer += delta
}

看起来也行,但问题在于:

  1. 网络不可靠:SSE 事件可能丢失或乱序,前端用下标切片会错位
  2. 前端无法感知错误 :如果某条事件的 text 被修正(过滤后变短了),前端不知道该回退多少
  3. 后端是单一责任点:增量计算放在后端,前端只管累加,职责清晰

后端计算增量、前端只做 fullAnswer += chunk,这是最不容易出问题的架构。

回到最初:为什么会踩这个坑?

后端最初按 OpenAI 格式解析通义千问的 SSE:

go 复制代码
// 错误的结构体定义
type tongyiStreamEvent struct {
    Choices []struct {
        Delta struct {
            Content string `json:"content"`
        } `json:"delta"`
    } `json:"choices"`
}

通义千问根本没有 choices 字段,解析后是零值,callback 永远不被调用,前端只收到 event:done

根因:想当然地认为所有 LLM API 都兼容 OpenAI 格式。实际上只有 DeepSeek、智谱等明确声明兼容,通义千问和 Ollama 各有各的格式。

格式差异一览

Provider 流式字段 增量/累计 Usage 位置
通义千问 output.text 累计 最后一条 usage
OpenAI choices[0].delta.content 增量 stream_options
DeepSeek choices[0].delta.content 增量 兼容 OpenAI
智谱 choices[0].delta.content 增量 兼容 OpenAI
Ollama message.content 增量 done:true

通义千问是唯一返回累计文本的。这意味着你的流式抽象层必须考虑这种差异,而不是假设所有 Provider 都是 delta。

抽象层设计

go 复制代码
type Provider interface {
    ChatStream(systemPrompt string, messages []Message, callback func(string)) (*ChatResponse, error)
}

callback(string) 的契约是:传入的字符串是增量 delta

无论 Provider 内部是累计还是增量,对外统一为增量。这个归一化在 Provider 实现内部完成,调用方无需关心。

通义千问在内部做 TrimPrefix 转换,OpenAI 直接透传,Ollama 直接透传。调用方只看到 delta。

总结

一个"流式输出重复"的 bug,根因是 LLM API 格式差异。修复路径:

  1. SSE 无内容 → 结构体字段对不上,改用 output.text
  2. 内容重复 → 累计 vs 增量,后端计算 delta
  3. 用 TrimPrefix 不用下标切片 → 防御性编程,避免乱序和截断风险
  4. 归一化在 Provider 内部 → 对外统一 delta 语义

核心教训:不要假设 LLM API 都兼容 OpenAI 格式。在 Provider 抽象层内部做好格式归一化,比在外部打补丁更可靠。

相关推荐
fox_lht1 小时前
第十一章 错误处理
开发语言·后端·rust
焗猪扒饭1 小时前
极简案列入门golang依赖注入工具wire
后端·go
M ? A2 小时前
Vue 转 React | VuReact 实时监听开发指南
前端·vue.js·后端·react.js·面试·开源·vureact
贺国亚2 小时前
Kafka系统设计与编码
后端·kafka
南方的耳朵2 小时前
谨慎使用git rebase --onto A B C
后端
何陋轩2 小时前
Spring AI Alibaba实战:通义千问与Java的完美融合
人工智能·后端·ai编程
Copy_Paste_Coder3 小时前
小程序失败后,换个方向,终于成功搞到收益
前端·javascript·后端
小杍随笔3 小时前
【在 Rust + Tauri 2 应用中实现语言切换功能:完整技术指南】
开发语言·后端·rust
卷毛的技术笔记3 小时前
双十一零点扛过10倍流量洪峰:Sentinel与Redis+Lua的分布式限流深度避坑指南
java·redis·分布式·后端·系统架构·sentinel·lua