流式输出成了默认:增量解析、中途出错与断流重连

发布时间:2026/10/14 11:20:44
流式输出成了默认:增量解析、中途出错与断流重连
说明本文讨论的是把流式输出接进产品之后增量解析、中途出错与断流重连该怎么处理属于流式链路的应用侧承接话题不涉及具体模型版本与价格。AI 领域版本迭代极快凡涉及版本号、价格、可用性请以你阅读时的官方页面为准。文中代码为结构示意请按自己的技术栈调整后再上生产。一、流式把一次调用拆成了一条链路从一次响应变成一串事件非流式调用里客户端发一次请求、拿到完整响应体这件事就结束了。流式把它拆成三部分一次请求、一串增量事件delta event、一个终止事件finish event。增量事件各带一小段新内容终止事件声明这次生成正常收尾。这个拆分不只是把响应切碎。响应体的完整性原本由 HTTP 层保证要么完整到达要么报错。换成流之后「内容是否完整」从协议层掉到了应用层得由你自己判断。载体常见两种服务端推送Server-Sent Events, SSE和基于 WebSocket 的自定义帧。前者单向、断线由浏览器自动重试后者双向、能在生成中途往上游发控制指令——要不要上行「停止生成」这类指令是选型的分水岭。⚠️ 代码待验证{id:evt_1001,type:delta,seq:1,content:流式}{id:evt_1002,type:delta,seq:2,content:把一次调用}{id:evt_1003,type:delta,seq:3,content:拆成了一条链路。}{id:evt_1004,type:finish,seq:4,reason:stop,usage:{output_events:3},resume_anchor:evt_1004}reason把「正常停止」「达到长度上限」「被内容策略拦截」分开。只发一个空的结束标记也能跑通但客户端就只剩「结束了」这一个信息。终止事件是必需项终止事件承担一个别的事件替代不了的职责告诉客户端「这次真的生成完了」。没有它客户端只能靠连接关闭来推断结束而连接关闭有两种含义——正常收尾或者中途断了。两者在传输层长得一样。不少流式接口的坑就在这里连接断开时服务端仍在继续生成把剩余内容写进一个没人读的 socket直到写失败才发现。终止事件要带三样才够用结束原因、本次生成的用量口径、可用于续传的锚点引用第四章展开。失败点从一处变成四处非流式只有一个失败点请求失败。流式把它摊成四段每段的失败形态和处置方式都不同。环节典型失败客户端看到的现象能否重试建连握手失败、鉴权过期、限流拒绝一个错误码没有任何内容可以且成本低首事件上游排队超时、模型迟迟不产出连接成功但长时间无数据通常可以但要设超时传输中网络抖动、网关空闲超时、切后台半截内容后停住分情况见第三章终止终止事件缺失、重复、乱序内容看着完整状态机卡在运行中不能靠重试解决最容易忽略的是最后一段它不产生错误提示只在界面上留下一个永远转圈的加载图标。二、增量解析任意切分片段不是一小段合法结构结构化输出在非流式下是一个完整对象校验一次就够。流式下输出的字节流会被任意切开切点跟结构边界毫无关系第一个片段可能是{na第二个是me: 张。很自然的反应是每个片段校验一次在片段层跑完整 schema 校验。结果是每一次都失败。跑一阵之后的常见处置是「校验太吵先关掉吧」——坑就在这一步为了让校验闭嘴把整条链路的校验一起关掉了。校验只有两个合法落点。两种做法累积校验与按结构事件推进第一种是先累积后校验。片段按顺序拼进缓冲区等终止事件到达再对完整文本做一次解析与校验。实现最简单、判定最确定代价是校验结果来得最晚终止事件前无法保证已展示内容合法。第二种是按结构事件增量推进incremental parsing。用一个流式解析器在语法层面推进遇到{进入对象、值闭合时抛出「字段已完整」的事件。每个字段一确定就能被下游消费非法输入也能尽早报错代价是你得自己维护一个状态机式的解析器。做法校验时机首屏可用时间代价累积后校验终止事件到达后最晚要等全文简单但出错发现得晚增量推进每个字段闭合时最早字段级可用要自己维护解析器状态机混合增量推进 终局全量校验边推进边用终局再验一遍早多一次校验成本换终局兜底落地里混合做法最常见增量推进为了「能早点用」终局全量校验为了「保证最终结果合法」。后者不能省——增量推进只保证结构闭合不保证语义约束比如字段值在枚举范围内也满足。⚠️ 代码待验证importjsonclassIncrementalParser:按结构事件推进每闭合一个字段就抛出事件不做全量校验。def__init__(self):self.bufself.depth0deffeed(self,chunk:str):只做结构闭合判断语义校验留给终局。self.bufchunk events[]self.depthchunk.count({)-chunk.count(})ifself.depth0andself.buf.strip().startswith({):events.append({type:object_closed,text:self.buf})elifself.buf.count()%20:events.append({type:field_maybe_closed,text:self.buf})returneventsdeffinal(self):终局兜底这里失败才算真失败。returnjson.loads(self.buf)object_closed与field_maybe_closed是设计要点前者确定后者不确定下游拿后者只做展示、不做决策。流式下不能做的事不能在中途对累计文本做全文替换。若每来一个片段就替换一次累计文本替换会跨片段边界失效手机和号分在两个片段里单看哪一个都不含敏感词。这类处理要么放到终局要么在解析器里按语义单位做。不能跑需要看到结尾才成立的判断。「这段 JSON 里数组有几个元素」在最后一个元素到达之前数不准。需要完整上下文的结论都挂到终止事件上。不能假设片段顺序跨连接成立。单条连接内多数实现保证顺序但重连之后第四章会出现补发片段与实时片段同时到达的情况。三、中途出错半截内容的三条处置路线已经吐了半屏才失败是流式链路里产品决策最重的一处。三条路线的差别不在实现难度在用户看到的到底是什么。路线用户看到实现代价适用场景保留半截 标注未完成半截内容端上标记「生成中断」低只需状态标记内容有阅读价值的场景丢弃重来内容清空重新开始中要能撤销已渲染内容结果必须是完整结构从断点续写内容接着往下长高要锚点 服务端可续长输出、重试成本高选哪条不能等出错时现想因为它同时决定三件事要不要保留已渲染内容、要不要让服务端继续跑、要不要给用户重试按钮。判断顺序是先看结果能不能被部分消费——解释性文字截一半仍可读只有半边的 JSON 对象只能重来再看重试成本一次生成要几十秒时续写才值得投入。错误事件怎么在流里表达失败有两种到达方式必须分开连接层失败客户端只拿到一个网络异常没有业务信息和流内错误事件服务端明确说明「这次生成没有完成」然后正常关闭连接。后者更好因为它能携带原因。错误事件至少带三样错误类别、错误发生在第几个事件之后用于确定已展示内容的边界、是否可重试。第三样最容易被省但它是客户端唯一能决定「弹重试还是弹报错」的依据。还要区分「错误」和「提前结束」——达到长度上限、被内容策略截断不是错误是非正常但合法的结束和网络错误混在一个状态里会让用户收到误导性报错。⚠️ 代码待验证错误分类决定客户端弹重试、弹报错还是什么都不弹。ERROR_TABLE{# code: (category, retryable, user_visible)conn_failed:(connect,True,True),auth_expired:(connect,True,True),rate_limited:(upstream,True,True),# 需退避upstream_timeout:(upstream,True,True),stream_broken:(transport,True,True),# 纯展示链路可续content_blocked:(policy,False,True),# 确定性拒绝internal_error:(server,False,True),}defclassify(code:str)-dict:category,retryable,visibleERROR_TABLE.get(code,(unknown,False,True))return{category:category,retryable:retryable,user_visible:visible,}这张表要落库让「重试按钮该不该出现」变成一个查表动作。哪些错误可以重来哪些不能判据只有一条重来一遍会不会产生副作用或者把已经发生的事重复一遍。错误可重来判据建连失败、鉴权过期可以可多次还没有任何内容生成无副作用上游限流、排队超时可以但要退避无副作用但立刻重试会继续被拒传输中断纯展示链路可以从锚点续内容只用于展示续写不改变任何状态传输中断链路里带了写操作不能直接重来写操作可能已经执行重来会重复执行内容策略拦截不能重来同输入同策略重来结果一致达到长度上限不能重来应改「继续」重发整个请求会从头开始用户要的是续写最后两行容易写错。内容策略拦截属于确定性拒绝重试多次结果一样只多消耗配额达到长度上限不是失败用户要的是「接着说」——把它实现成重试用户会看到内容从头再来一遍。带写操作的链路是另一类若生成结果会被落库、会被当作工具调用的参数去执行「重来」就不安全必须先确认上一轮执行到了哪一步。四、断流与重连断开和「生成结束」不是一回事客户端断开连接的原因至少三类网络抖动、网关空闲超时、用户把页面切到后台。前两类是暂时的用户还在等第三类用户可能已经走了。服务端要做一个明确选择客户端断开之后这次生成继续跑还是停掉。继续跑的理由是内容可能还有用要落库、要写进会话历史停掉的理由是省算力和配额。这个选择必须在服务端定断开这个信号本身不携带意图。若决定继续跑就得把结果存下来并给客户端一个「回来取」的入口也就是续传锚点resume anchor。续传锚点的三种记法常见的记法有三种记事件序号、记累计输出长度、记任务 ID。事件序号最直接它同时回答了「续传从哪开始」和「已经展示了什么」两个问题但要求服务端把事件按序号留一段留多久是要定的参数通常跟会话或任务的保留期对齐。累计长度对文本最自然。它的坑是多字节字符一个汉字在 UTF-8 里占三个字节一端按字节算、一端按字符算锚点就对不齐。锚点的单位必须在协议里写死。任务 ID 最灵活服务端可自行决定保留多久、保留多少客户端重连时带 ID 问「我该从哪接」。代价是多一次往返。⚠️ 代码待验证重连请求必须与初始请求不同带锚点服务端据此走续写分支。 POST /v1/stream { session_id: ..., prompt_hash: ..., // 校验是同一次生成避免改了输入还续写 resume_anchor: { kind: seq, value: 37 } } 响应头先声明这次走的是续写而不是重新生成 X-Stream-Mode: resume X-Resume-From: 37prompt_hash的作用是防止「输入已经变了还在续写」——用户改完问题再点重试必须走新的生成不能接在旧锚点后面。重连后的去重与插入位置重连最容易出的事是内容重复。原因通常两种客户端重发整个请求、服务端从头生成或者服务端补发时多发了几个事件客户端直接追加在已渲染内容后面。第一种的解法是重连请求不能等同于初始请求初始请求不带锚点重连必须带锚点服务端据此走续写分支。第二种靠幂等。可行做法是让每个事件带一个单调递增的序号客户端维护「已应用到第几号」的水位线watermark收到序号不大于水位线的事件就丢弃。水位线也要持久化否则页面刷新后归零内容又会重一遍。插入位置也要管。若重连期间服务端继续生成客户端可能拿到「中间缺了一截」的内容此时要判断缺的那一段是不需要了、还是必须先补上纯追加式文本能容忍小段缺失结构化输出不行必须从锚点完整重放。五、渲染与状态不完整结构的三种半截状态增量渲染体验好但它要在内容随时可能是半截的状态下渲染。半截状态症状处置未闭合的代码围栏后面所有内容被当成代码整页跑偏渲染前临时补闭合标记终局再撤掉未闭合的表格行表格结构错乱列数对不上只渲染已闭合的行未闭合的行内标记加粗、行内代码的标记符裸露在正文里边界处暂不渲染未闭合标记代码围栏最典型三个反引号开了头没闭合Markdown 解析器会把后面所有内容吸进代码块包括本该是正文的部分。可行做法是渲染前做一次补全——检测到未闭合围栏就临时补上闭合标记再交给解析器等真正的闭合标记到了再重渲染。补全必须只作用于渲染不作用于数据。存下来的必须是原始增量否则终局内容里会混进假的闭合标记。⚠️ 代码待验证defclose_open_fences(text:str)-str:渲染前的临时补全只影响这一帧不写回数据。iftext.count()%21:returntext\nreturntextdefrender_frame(raw_accumulated:str)-str:returnmarkdown_to_html(close_open_fences(raw_accumulated))defcommit(raw_accumulated:str)-str:落库一律用未补全的原始内容。returnraw_accumulatedrender_frame与commit分开是这套做法的关键约束补全只活在帧里。停止生成按钮背后的状态机「停止生成」不是纯前端动作它是一条要跨到服务端的指令既要让服务端停下也要让客户端把状态收干净。状态机大致有idle空闲、connecting、streaming、stopping已发停止、待确认、done、interrupted异常中断。关键在stopping这个中间态——用户点了停止之后不能立刻把界面置为结束因为服务端可能还有事件在路上这些事件到达时若状态机已在终态容易被丢掉导致最后一段内容丢失。正确顺序是进入 stopping → 继续接收直到收到终止事件 → 再进入终态。停止按钮要能承受连点进入 stopping 之后应立刻禁用。interrupted和done必须分开两者在界面上都可能表现为「停止输出」但一个要提示异常一个不需要——混成一个状态正常结束也会弹错误提示。首屏与稳定性的取舍「先累积后渲染」和「增量渲染」在 2026 年中的产品里并存。前者等结构完整再渲染页面稳定不闪代价是首屏慢后者边来边渲染体感快代价是要处理上面那一堆半截状态。按内容类型分开处理更常见代码块和表格累积后渲染普通段落增量渲染。这两类结构对半截状态的容忍度最低——半截代码块会让整页跑偏半截表格会让列对不上而普通文字少几个字没人看得出来。判断依据是「这个结构在没闭合时强行渲染会不会影响它之外的内容」。完整版资料清单本文用到的对照表与实测记录都整理在里面了扫码即可获取六、把流式纳入测试与验收怎么造「中途断开」的用例流式的测试难点在于失败不在函数返回值里而在时间轴上所以测试要能控制事件的到达节奏。三种做法假服务端本地起一个按脚本发事件的桩可在第 N 个事件后关连接、录制回放把真实事件流录下来回放时注入延迟与断点、代理注入在客户端与服务端之间放可编程代理随机切连接。假服务端最可控适合断言具体行为录制回放最适合回归代理注入最适合稳定性验证。要覆盖的断点位置至少四类首事件之前断客户端应显示「未开始」而非「已结束」、首事件之后第一个片段断看半截内容怎么处置、中间断且服务端仍在生成看锚点是否生效、终止事件之前断看会不会永远停在 loading。缺终止事件、乱序、重复的断言流式的验收断言跟普通接口不一样断的是状态而不是返回值。用例断言终止事件缺失连接正常关闭客户端进入 interrupted而不是 done终止事件重复到达只处理第一次状态机不重复迁移事件乱序到达按序号排序后拼接或明确拒绝并报错事件序号跳号触发缺口检测不静默拼接续传时重发已渲染事件水位线机制丢弃内容不重复停止指令发出后仍有事件到达继续接收直到终止事件才进终态头两条最容易漏。缺终止事件在很多实现里会表现成「看着像正常结束」因为连接关了重复终止事件则容易被忽略反正状态已经进了终态。只要状态机是幂等的这两条都该被显式断言。⚠️ 代码待验证deftest_missing_finish_event(stream_client):发完 delta 就关连接、不发 finish客户端必须是 interrupted。stream_client.replay(fixturenormal_stream.jsonl,drop_finishTrue)statestream_client.run_to_completion()assertstate.nameinterrupted# 不能是 doneassertstate.partial_text!# 半截内容保留assertstate.reasonisNone# 没有结束原因只能归为异常夹具回放要能单独关掉终止事件这一点是这套测试可用与否的关键。录制一段真实事件流做回归夹具最实用的做法是把一次真实生成的事件流原样录下来存成夹具fixture回放给客户端做回归。夹具要保留事件的原始形态——序号、类型、时间间隔以及每个事件原始的字段名与大小写不要在录制环节顺手「整理」一遍。录制要覆盖三种形态一次正常完成、一次中途失败、一次带续传的重连。前两种用于回归渲染与状态机第三种用于验证锚点逻辑。夹具是长期资产——事件形态里的坑只有在真实流里才会出现手搓的桩通常搓不出来。七、落地顺序与观测要看的四个数首字时间从发请求到第一个增量事件到达异常时先看建连耗时与上游排队。流中断率未收到终止事件的连接数 / 总连接数异常时先看网关空闲超时与客户端网络分布。重连成功率重连后能续上的次数 / 重连次数异常时先看锚点是否被识别、保留期是否够。中途失败占比已产生内容后才失败的次数 / 总失败数异常时先看上游稳定性与内容策略触发率。这四个数里流中断率最容易被误读。它高不一定说明服务端有问题——用户在生成过程中切后台、关页面也会算进中断。所以要按客户端行为拆开看正常结束、用户主动关闭、异常断开三个桶分开统计只看总数会得出错误结论。先在只读展示链路打开流式落地顺序建议先只读、后写。第一版把流式开在纯展示链路上——生成的内容只用于展示不落库、不触发工具调用、不改变业务状态。这条链路上中途出错和断流都只是「少看到一段内容」不会留下残留。在只读链路上跑稳两件事之后再放开写操作一是终止事件在正常路径上稳定到达二是续传锚点在真实网络下能对上。这两件事在只读链路上验证成本很低一旦链路上带了写操作验证成本会高一个量级。观测要能被状态机消费日志里至少要落每次连接的唯一标识、终止事件是否到达、结束原因、续传次数、停止指令的发送与确认。只记一个「请求成功 / 失败」不够——流式链路的失败大多以「没走到终点」的形式出现而不是以错误码的形式出现。把终止事件是否到达单独记成一个字段前面那些指标才算有可追溯的依据。完整版资料清单本文用到的对照表与实测记录都整理在里面了扫码即可获取附表 A关键取舍一览工程取舍原因依据章节终止事件必须发且要带结束原因连接关闭无法区分收尾与中断第一章片段层不做完整 schema 校验任意切分下片段单独看永不合法第二章增量推进 终局全量校验早点可用与最终合法是两件事第二章中途出错三条路线提前定出错再想会同时卡住三处决策第三章错误事件带「是否可重试」客户端据此决定弹重试还是弹报错第三章内容拦截不重试、长度上限走续写前者确定性拒绝后者是「接着说」第三章带写操作的链路不直接重来写操作可能已执行重来会重复第三章续传锚点单位写死在协议里字节与字符口径不一致会对不齐第四章重连请求必须带锚点不带锚点等于从头重新生成第四章用序号水位线做去重补发事件会重复到达第四章stopping 中间态要保留在途事件不能因进了终态被丢掉第五章缺终止事件断言为 interrupted连接正常关闭容易被误判成完成第六章先只读链路再放开写操作只读链路上失败不留残留第七章附表 B术语速查表术语含义增量事件流式生成中携带一小段新内容的事件终止事件声明本次生成正常收尾的事件缺失则无法区分完成与中断增量解析在内容边到达边推进的解析方式又称 incremental parsing结构事件解析器在语法结构闭合时抛出的事件如字段闭合累积校验把片段拼成完整文本后再做校验的做法水位线客户端记录已应用到第几号事件的标记用于去重续传锚点重连时用于指定从何处继续的标识即 resume anchor半截状态内容尚未闭合时的中间形态如未闭合的代码围栏渲染补全仅在渲染时临时补齐未闭合标记的做法假服务端按脚本发送事件、可指定断点的测试桩夹具保存下来的真实事件流样本用于回归写在最后这篇用到的资料写这篇文章时我把几个模型的官方文档、参数表和实测记录都对了一遍顺手整理成几份配套的东西大模型学习路线图从 LLM 基础到 Agent 开发各阶段该学什么、用什么资料大模型全套教程按主题分好的视频与文档清单大模型实战好书24 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。