流式输出错乱、重复或只吐半截,先别急着怀疑模型
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
**流式输出错乱这件事,大多数人第一反应是”模型抽了”,然后去换模型、调温度、加一句”不要重复”——这个方向基本白费。文字错乱、整段重复、末尾缺半句,绝大多数是从模型出来之后、到你眼睛看到之前那段路上出的问题:客户端怎么拼、中间怎么缓冲、有几个东西同时在写、上游有没有悄悄重放。**模型确实会陷入重复循环,但那有明显特征(同一个句式越来越长、越来越机械),而且和”重复的是完整一段、位置还在开头”完全不是一个样子。
站内有两篇邻近的排查文,分工先说清楚:输出被截断的排查讲的是”为什么会断”(触发了停止条件、被上游掐掉),请求超时与连接中断讲的是连接层彻底挂掉怎么办;本篇讲的是没断也没超时,但内容就是不对——顺序乱、内容重、末尾缺、字变成问号。这是完全不同的一组成因。
一、四类成因和它们的指纹
先把四层分开,别混着查。这四层的位置是串起来的:模型生成 → 上游网关/重试 → 网络与代理缓冲 → 你的客户端解析和渲染。任何一层都能单独制造”错乱”,但制造出来的样子不一样。
第一类:客户端拼接与渲染。 最常见,也最容易自证。典型触发点有几个:一是增量语义和全量语义混用——有的接口每个事件给的是”新增的一小段”,有的给的是”到目前为止的全文”,你要是按增量去 append 一个全量流,结果就是雪球一样越滚越长的重复;反过来按全量去覆盖一个增量流,就只剩最后几个字。二是前端状态更新被覆盖,比如在 React 里用闭包里的旧 state 去做 setText(text + chunk),快速到达的多个 chunk 互相踩,看起来就是丢字、跳字。三是 Markdown 流式渲染——半个代码块、半个表格喂给渲染器,渲染器会不断重新解析,视觉上就是文字跳动、顺序变化,其实底层字符串是对的。
第二类:传输与缓冲。 中间任何一跳把流攒起来再放,你就得到”卡很久 → 哗啦一大坨”的伪流式;更麻烦的是攒的时候切错了地方。多字节字符被切在两个数据块之间,客户端如果每个块单独 decode('utf-8'),接缝处就出现问号或方块——这类现象和中文乱码与编码问题是同一族,只是流式让它更容易触发。SSE 协议要求按空行分事件、按行解析,很多手写客户端图省事按固定长度切,一个事件被拆到两个块里就直接解析歪,表现为丢事件或者把 JSON 拼错。企业环境里还要加一层:反向代理和安全网关有可能对响应做缓冲或内容检查,走公司内网出口时更容易碰上。判断方法不用猜——同一条 curl 命令,一次走代理、一次绕开代理(换出口或换网段),如果只有走代理那次是”攒一坨”,责任就基本落在那一跳,具体行为取决于该设备的配置,以你们运维给出的口径为准。
第三类:并发写。 这一类的特征极其明确:单独跑不复现,一起跑必出问题。同一个界面区域、同一个日志文件、同一个 buffer 被两个以上的流同时写,字符就交错。Agent 场景里更隐蔽——多个工具并行执行、各自都有流式输出,如果没有按会话或按任务分开的输出通道,几路文字就会织在一起。多会话并行时还有共享上下文和共享缓存互踩的问题,多会话并发冲突讲的是那一侧的连带影响。
第四类:上游重放与重试。 你看到的重复不是你拼错了,是上游真的发了两遍。触发路径通常是:连接抖了一下,某一层(SDK 的重试、网关的重试、你自己的自动重连)重新发起了请求,而流式请求没有”从第几个字继续”的通用机制,重来就是从头来。于是用户看到前半段出现两次。这类的指纹是:重复的部分总是从开头开始,重复内容一字不差,而且时间上能对上一次网络波动。
二、判别表:照着这张表定位
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 内容越滚越长,前面的话反复出现,长度像雪球 | 全量流被当增量 append | 把原始事件逐条落盘,看每条是否包含前一条的全部内容 | 改成覆盖式赋值;或只取增量字段 |
| 只显示最后一两个字,前面全没了 | 增量流被当全量覆盖 | 同上,看每条事件是不是只有几个字符 | 改成累加式拼接 |
| 偶发丢字、跳字,快的时候明显 | 前端状态覆盖或竞态 | 把 chunk 先推进一个数组,最后 join 对比显示结果 | 用函数式更新或队列串行落库,UI 只读 |
| 文字位置跳动但复制出来是对的 | Markdown 流式重渲染 | 把渲染层换成纯文本追加再看 | 只在结构完整时重渲染,或按块提交 |
| 接缝处出现问号、方块、半个汉字 | 多字节字符被切在块边界 | 原始字节落盘后整体解码,看是否正常 | 用增量解码器,跨块保留未完成字节 |
| 卡好久后一大段一起出来 | 中间层缓冲 | 直连上游对比,同一请求换网络路径 | 关掉该跳的响应缓冲;不行就换出口 |
| 事件解析报错、JSON 拼不上 | 按长度切块而非按行/空行解析 | 打印每个原始块的边界 | 按协议缓冲到完整事件再解析 |
| 单跑正常,多任务一起跑就交错 | 并发写同一输出目标 | 并行度降到 1 重跑,看错乱是否消失 | 按任务隔离通道,写入加锁或走队列 |
| 前半段一字不差地出现两次 | 上游重放或客户端自动重连 | 查日志里同一逻辑请求的发起次数 | 流式请求关掉自动重试,重连改为整体重来并清屏 |
| 同一句式机械延长、越来越空 | 模型自身重复循环 | 换一次请求仍复现,且原始流本身就重复 | 这才是该动生成侧的时候 |
用表的顺序有讲究:从下往上查是浪费时间的。先证明”原始流是对的”,再往客户端里找,因为客户端类问题占比最高且验证成本最低。
三、动作:三步拿到干净的证据
第一步,拿原始流。用命令行直连一次,绕开你所有的客户端代码:
BASE_URL=https://你的接口地址 # 换成实际地址,别把尖括号留在命令里
MODEL=你的模型名 # OpenAI 兼容风格的接口一般要求 model 必填
curl -N -sS "$BASE_URL/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "$(printf '{"model":"%s","stream":true,"messages":[{"role":"user","content":"数到二十"}]}' "$MODEL")" \
| tee /tmp/raw-stream.txt
-N(长写法就是 --no-buffer,两者是同一个开关)是关键:输出被管道接走时 curl 默认会攒着,不加这个开关你分不清”慢”是上游造成的还是 curl 自己造成的。让它慢慢吐,观察两件事:是不是均匀地一点点出来(不是就说明中间有缓冲),以及 /tmp/raw-stream.txt 里每条事件是增量还是全量。字段名各家不完全一致,别照抄别处的解析代码,按你自己落盘的这份文件来看,具体字段以对应接口的官方最新说明为准。这一步就能砍掉一半的猜测。
第二步,验证解码和拼接。把原始字节和你的解码路径分开测:
import codecs
dec = codecs.getincrementaldecoder("utf-8")()
buf = ""
with open("/tmp/raw-stream.txt", "rb") as f:
while chunk := f.read(7): # 故意用奇数长度切,模拟坏边界
buf += dec.decode(chunk)
buf += dec.decode(b"", final=True) # 收尾:若还剩半个字符,这里才会报错
print(len(buf), buf[-40:])
增量解码器会把不完整的多字节序列留在内部,等下一块补齐。如果你自己的客户端在这个测试下会出问号,问题就锁定在解码,和模型无关。事件解析同理:缓冲一个字符串,只在遇到空行时切出一个完整事件来解析,永远不要假设”一个网络块等于一个事件”。
第三步,隔离并发。把并行度降到 1 跑一遍。如果单流干净、并发脏,就不用再看协议了,直接去找共享的写入目标。Agent 场景下最省事的做法是给每路输出打上任务标识,先落到各自的缓冲里,收尾时再合并展示;实时性要求高就用一个单消费者队列串行写。原则只有一句:每一路数据都必须有明确归属,不存在”公共缓冲区”。
然后是兜底:
- 客户端保留一份原始事件日志(原样落盘,不加工),出问题时能三分钟内定位是上游脏还是自己拼脏。这一条的收益远大于成本。
- 流式请求默认关掉自动重试。这里给准确的分界线,别记成”一律不重试”:首字节还没到、可以确认服务端一个字都没吐出来时,重试和普通请求没区别,该重就重;一旦已经收到过内容,重试就等于让用户看两遍开头,因为流式没有”从第几个字继续”的通用机制。所以重连策略要改成”丢弃已收内容、清空显示、整体重来”,用户看到的是重新生成,而不是错乱。重试本身的白名单黑名单怎么划,请求超时与连接中断那篇讲得更细。
- 给流加幂等与去重的兜底:同一逻辑请求带一个自生成的 ID,客户端看到同 ID 的新流就清空旧内容。这不能修根因,但能把用户可见的错乱压到零。
- 长回答场景准备一个非流式回退开关。缓冲层短期改不动的时候,一次性返回虽然体验差,但内容是对的;先保正确再谈体验。
四、什么情况下别再折腾
排查也要有止损点,否则你会在别人的基础设施里耗掉整天。
中间层不在你手上,且能定位到就是它,就别修了。 典型是公司网关、安全审计设备、云厂商的边缘节点对响应做缓冲或内容检查。你能做的只有提工单和换出口。判断依据很直接:同一份 curl 命令,换一条网络路径(换出口、走另一个网段、用不同的接入点)行为就变了。这不是代码问题,改代码只会越改越复杂。
改了三处以上还在复现,就回滚重来。 流式问题很容易越修越乱,因为每次”修”都在拼接逻辑里加分支。给自己定个规矩:动手前先把改动隔离出来,随时能扔掉。
git switch -c fix/stream-glue # 排查改动只在这个分支
git stash push -m "试探性改动" # 没验证的猜测先存起来,别堆在工作区
git stash list # 随时看还欠着几个没落地的猜测
git diff --stat # 超过三个文件就停下来重新想
回到干净状态,用第三节的三步重新走一遍,通常比继续叠补丁快。
现象只在特定客户端出现,就先换客户端验证。 如果官方 SDK 干净、你的手写客户端脏,那就用 SDK,别为了少一个依赖去重写协议解析。SSE 的坑(多行 data、注释行、重连字段、事件边界)已经被踩平了,重写一遍没有收益。
内容质量问题不要往这条线上塞。 如果错乱其实是”逻辑跳跃、前后矛盾”,那是生成质量或上下文问题,属于另一条排查线,走大模型幻觉那套核查方法,和拼接毫无关系。混着查是最常见的时间黑洞。
关于海外工具还要说一句现实约束:部分海外 AI 编程工具和模型服务的可用区域列表并不覆盖中国大陆,是否支持、以什么方式支持,以各家官方最新说明为准。这类服务在国内网络环境下的表现往往很古怪——间歇性缓冲、连接被中途重置、TLS 握手异常。市面上确实存在第三方中转,但稳定性和数据流向都不受你控制,我不为任何具体渠道背书。如果你的错乱只在这类服务上出现、在国内服务上从不出现,那大概率是链路问题而不是你的代码问题,继续调代码是白干。
五、避坑清单
坑一:假设一个网络块等于一个事件。 为什么会踩——本地测试时数据小、网络快,一个块刚好装一个事件,看起来完全正常,上线后包大了才暴露。怎么避:解析永远走”缓冲 + 按协议分隔符切”,本地测试时故意用很小的读取长度(比如上面那个 7 字节)压一遍。
坑二:用闭包里的旧状态拼字符串。 为什么会踩——前端框架的状态更新是异步的,chunk 到得比渲染快时多次更新互相覆盖,而慢速网络下根本看不出来。怎么避:拼接用函数式更新(拿到的是最新值),或者干脆把累积逻辑放到组件外的一个可变引用里,UI 只负责读。
坑三:给流式请求配了重试。 为什么会踩——重试是 HTTP 客户端的默认好习惯,很多 SDK 和网关默认就开,没人想到它会作用在流上。怎么避:显式为流式路径关闭重试,重连交给上层业务逻辑处理,并且重连必须先清空已显示内容。
坑四:把 Markdown 渲染器当纯文本追加器用。 为什么会踩——半个代码围栏、半个表格是非法结构,渲染器每次重新解析都会给出不同的树,视觉上就是抖动和顺序变化。怎么避:流式阶段用纯文本或轻量高亮,检测到结构闭合(围栏配对、表格行完整)再升级渲染;或者按段落提交。
坑五:日志和界面共用一个输出流。 为什么会踩——调试时顺手把流式内容打到标准输出,同时框架也在往那里写日志,两边交错,你就开始怀疑模型。怎么避:内容和日志用不同通道,内容落文件或独立缓冲,日志走 stderr。
坑六:多个并行任务共享一个累积变量。 为什么会踩——单任务开发时那个全局变量很方便,加并行是后来的事,没人回头改。怎么避:累积容器一律按任务 ID 建索引,从一开始就这么写,成本几乎为零;等到并行出问题再改,你得同时动累积、展示和落库三处。
坑七:只在快网络下测试。 为什么会踩——缓冲、竞态、边界问题都需要”慢”和”碎”才会暴露。怎么避:加一个人为延迟和随机切块的本地模拟服务,把流故意吐得又慢又碎,跑一遍你的客户端。这个小工具一次写好能用很久。
收束
这类问题的排查效率,几乎完全取决于你有没有拿到原始流。拿到了,四类成因半小时能分清;没拿到,就是在猜。所以顺序永远是:先证明上游给的是对的,再证明解码是对的,再证明拼接是对的,最后才看并发和渲染。
一份可以贴到墙上的自检清单:
curl -N直连过了吗?原始流是均匀吐还是一坨?- 每条事件是增量还是全量?你的代码按哪种处理的?
- 解码用的是增量解码器吗?跨块的半个汉字保住了没?
- 事件解析是按协议分隔符切的,还是按块长度切的?
- 流式请求的自动重试关了吗?重连会清屏吗?
- 并行度降到 1 还复现吗?
- 原始事件日志留了吗?下次能不能三分钟定位?
七条里有五条答不上来,就先别改代码,回去补证据。