资讯动态

AI SDK流式恢复机制:从断线重连到可验收终态的工程实践

发布时间:2026/9/11 7:22:24 来源:尧图企业网站定制
我做了几年AI应用踩过最冤枉的一个坑就是把“流式恢复”当成“自动重试”来用。最初接AI SDK 7.0.91的流式接口时只要生成中途断了我就让客户端把整条流重新拉一遍结果用户眼睁睁看着答案生成到一半忽然清空、从头开始蹦字体验差到离谱模型成本还翻了一倍。后来我把这套流式恢复机制真正啃下来才意识到它压根不是“重试按钮”而是一套能把失败收敛成“可验收终态”的状态机。这篇文章就把它从底层原理到工程落地从头拆一遍。先说这个东西是干什么的流式恢复stream recovery是AI SDK 7这一代引入的能力它解决的是“流式生成过程中连接断了怎么办”的问题。适合三类人看一是正在用AI SDK写流式接口的后端同学二是被前端“转圈圈、结果丢了”逼疯的全栈开发者三是想搞清楚LLM流式输出怎么做到高可用、怎么验收结果的人。读完你可以直接照着配置把断线重连从“重新生成”升级成“接续生成”。1. 流式恢复的本质不是“重新开始”而是“接着讲”1.1 重试是重新点菜恢复是续着涮火锅先做个思维转换。传统重试是什么是你在餐厅点了一份水煮鱼厨师做了一半厨房着火了饭菜没了你得重新点一份后厨从头开始做。HTTP请求里最常见的重试就是这个逻辑——请求失败客户端重新发一次完整请求服务端重新跑一遍完整逻辑。AI SDK 7.0.91里的流式恢复完全不是这个思路。它更接近吃火锅吃到一半去上了个厕所回来之后锅底还留着你夹过的那盘毛肚还摆在桌上你只需要坐下来继续涮而不是把整桌菜撤掉重新点一遍。放到LLM场景里就是模型已经生成了800个token第801个token生成时网络断了流式恢复要做的是从第800个token的上下文继续往后生成而不是把前800个token丢进垃圾桶重新让模型从头吐一遍。这个区别在成本上尤其明显。LLM生成是按token计费的重试意味着已经生成的内容全部作废下游要重新算一遍完整输出。如果你的业务是长文档生成、报告分析或者多轮Agent对话一次重试的浪费可能不是小数点级别而是翻倍的账单。“流式恢复不是重试按钮”这个判断在账单一出来的时候你就完全懂了。1.2 为什么流式恢复能成立三个前提条件流式恢复不是天上掉下来的功能它成立的前提是三个层面都做了设计配合。第一个前提是服务端必须保留生成现场。普通流式接口是“无状态”的——请求进来模型吐流吐完就扔连接一断服务端啥都不剩。AI SDK 7引入了状态快照机制服务端在生成过程中会持续把“当前已经生成的内容、模型调用参数、工具调用中间结果”持久化下来。这样当客户端回头要恢复时服务端还能找到“我们刚才讲到哪儿了”。第二个前提是协议里必须有恢复凭证。光有状态还不够客户端必须能证明“我是刚才那个会话的主人”。这就需要一个类似票据的东西比如streamId恢复凭证。客户端断线后拿着这张凭证去服务端认领状态。没有凭证的恢复就是耍流氓谁都能接着别人的对话往下生成。第三个前提是流式协议本身支持分段消费。AI SDK 7这一代把流式数据拆成了一个个可独立解析的数据分片我们叫chunk每个分片带有连续的版本信息。恢复时客户端会告诉服务端“我最后收到的分片是第N个”服务端从第N1个分片开始推。这就像看连载小说你告诉编辑“我读到第30章了”编辑从第31章开始给你送而不是把全书重新寄一遍。三个前提缺一个流式恢复都做不成立。这也是为什么老一代语言模型SDK里做不了这个功能——不是不想做是协议层和服务端运行时都没有为“中断后再续上”留位置。1.3 可验收终态一个比“成功”更实际的目标“把失败变成可验收终态”是标题里的后半句它其实比“恢复”本身更值得思考。做工程的人都有个执念接口要么成功要么失败这是个二元逻辑。但流式生成这种长任务中间状态太多了——可能是网络断了一秒钟又好了可能是被限流等了三十秒又可以了可能是函数调用中途报错但主流程还能继续。你要是一律按“失败”处理用户看到的就是一个大红叉。AI SDK 7.0.91的做法是引入“终态”概念。终态不是“成功”而是“这件事终于有了一个确定的、可验收的结局”。结局可能是finishReason为stop的完整生成也可能是finishReason为error但附带截止当前已生成内容的半程结果还可能是工具调用部分失败后返回的异常终态。每一种都有明确的元数据前端拿到后可以直接渲染不用再去猜“现在是好了还是挂了”。我举个具体例子用户问AI“帮我分析这份季报并写一份总结”模型已经生成了600字这时网络中断。重试模式下前端Loading转圈用户以为卡了恢复模式下客户端把已生成的600字先渲染给用户看然后继续拉后面的内容用户看到的是一篇完整文章从短到长逐步长全的过程。哪怕最终因为某种不可抗力只生成了850字前端也能明确展示“本次生成在850字处停止原因服务端超时”用户至少得到一个可以验收、可以判断的结果而不是永远悬在半空。2. 失败也是分类的先看清断点在哪里2.1 网络类失败DNS、连接超时、链路异常搞恢复之前先要有“失败分类”的意识。常见的网络类失败有这么几类你肯定在日志里见过它们的变体。第一是DNS解析失败。比如“ping 请求找不到主机 www.zhangchunxu.cn。请检查该名称,然后重试”这类报错通常发生在请求发出之前域名根本没解析出IP。这种失败重试基本没用因为问题大概率出在本地DNS缓存或者网络出口你要做的是换DNS、手动配hosts、等网络恢复而不是让应用层疯狂重试同一个域名。第二是连接超时。像“ne1启动操作超时,请检查与服务器的链接后重试”这类提示本质是TCP握手在超时阈值内没完成。这种情况要看你用的是短连接还是长连接。如果服务端有连接池超时往往是服务端负载太高你重试太频繁反而会加剧雪崩这时候退避算法比重试次数更重要。第三是链路中断。我遇到过“选路连接失败,可能当前连接网络异常,请稍后重试”这种是网络已经在传输中段断掉了TCP连接被重置客户端根本不知道服务端到底收到了多少数据。这恰恰是流式恢复最擅长处理的场景——因为每次流式分片都有独立编号客户端知道最后一个成功收到的分片是哪个恢复时从那个分片后面继续要就避免了“重复生成一大段”的浪费。2.2 服务端类失败限流、超时、内容安全拦截服务端返回的失败比网络失败更值得关注因为这里面有大量可以策略化处理的空间。限流是流式场景最典型的服务端失败。AI SDK调用底层模型时模型服务商有每分钟请求数限制、每分钟token数限制超了就会返回类似“您最近作出的请求太多了。请稍候,然后重试”的提示。这里有个关键认识限流状态下重试不但解决不了问题反而会让限流窗口更长。流式恢复的价值在于如果服务端已经在生成中途把状态快照保存好了那么等限流窗口过去后客户端只需要发一个极小的“恢复请求”而不是重新发一遍完整对话历史。恢复请求的请求头、请求体都非常轻量命中限流策略的概率远低于重新生成。超时也要细分。有些超时是“整个请求超时”有些是“流空闲超时”——即一段时间内没有新的token到达。AI SDK 7.0.91的恢复机制专门服务的是后者它允许客户端在流空闲超时后发起恢复让服务端检查底层生成任务是否还活着如果活着就继续推流如果已经死了就返回明确的终态。还有一类失败是内容安全拦截。现在很多生成服务都接入了内容安全策略模型吐出的某一段文本可能触发了服务端的内容审核策略被强制终止生成提示类似“检测到内容违反社区规范,请检查后重试 (983)”。这种失败千万不要设计成自动重试因为重试一百次结果都一样——该拦截的还是会拦截。正确的做法是走恢复或降级流程把已生成的安全部分交给用户验收同时给出明确的终止说明。2.3 不同失败该走恢复还是重建有了分类决策就清晰了。我总结了一个决策表你可以直接抄进项目里失败类型典型表现推荐策略原因DNS解析失败找不到主机不重试提示网络检查应用层重试无意义TCP连接超时连接超时有限次数退避重试服务端可能在恢复流中途断开已收若干chunk后断流流式恢复最匹配恢复场景服务端限流429请求过多等待后恢复/降级重试加剧限流生成超时流空闲超时流式恢复可触发trunk续接内容安全拦截语义违规终止不重试走终态验收重试结果相同工具调用失败函数执行报错局部重试/恢复保留上下文重跑子步骤这里面最怕的就是“一刀切重试”。我接手过一个线上事故一个Agent任务本来只是中间一次工具调用超时结果前端配置了三倍重试每次重试都重新发起整个Agent任务最后不仅浪费了几万token还因为多次并发调用触发了服务端严格限流。后来改成“工具调用级别重试整体流式恢复”同样的情况只用了一次小范围重试就顺利跑完了。3. AI SDK 7.0.91 的流式恢复链路与实践配置3.1 从请求到快照服务端怎么记住“讲到哪儿了”要正确配置流式恢复得先理解服务端的运行机制。AI SDK 7.0.91在处理流式生成任务时不再是“收到请求-流式返回-立刻遗忘”而是引入了请求标识状态版本恢复凭证三层记录。一个标准的服务端生成流程在AI SDK 7里大致是import { streamText } from ai; import { openai } from ai-sdk/openai; import { saveSnapshot, markCompleted } from ./state-store; const result streamText({ model: openai(gpt-4o-mini), messages: conversationHistory, // 关键配置这个回调会在每个数据分片产出时触发 onChunk: async ({ chunk }) { // 实时保存状态快照 await saveSnapshot(streamId, { chunkId: chunk.id, content: chunk.text, version: chunk.version, }); }, onFinish: async ({ response, usage, finishReason }) { // 生成完成把终态保存下来 await markCompleted(streamId, { response, usage, finishReason, }); }, });注意这段代码里的streamId它不是在streamText内部自动生成的而是你在上层请求里创建并传入的。实际开发中我发现一个最佳实践在API路由入口处用crypto.randomUUID()生成一个streamId作为本次生成任务的唯一标识。这个标识既会出现在响应头里也会写进状态快照里客户端恢复请求时带着它就能找到快照记录。服务端状态存储的选择上一开始我图省事用了内存Map结果服务一重启所有恢复凭证全部失效。后来换了Redis给每个streamId设置过期时间TTL既控制了内存占用又保证了恢复窗口可控。生产量大的场景我建议Redis量小用数据库表甚至是SQLite都行关键是别用进程内内存——一旦多实例部署恢复请求路由到另一台机器就找不到快照了。分布式场景还得注意把恢复请求做亲和路由比如用streamId做哈希一致性路由确保同一会话的请求打到同一实例。快照本身不需要无限保存。我实际测试下来一个中等复杂度的对话任务每秒产生的chunk快照数据在几KB到几十KB之间。一个5分钟长任务全量快照大概15MB不等。如果给每个streamId保留10分钟TTL单机同时在线100个任务存储压力也就几百MB到几个GB的Redis内存一般公司完全能扛住。3.2 客户端恢复的最小实现服务端准备好之后客户端侧的恢复API其实比很多人想象的简单。AI SDK 7.0.91在useChat或底层客户端里提供了恢复能力最小实现长这样import { useChat } from ai-sdk/react; import { useState } from react; export function useRecoverableChat() { const [isRecovering, setIsRecovering] useState(false); const { messages, sendMessage, error } useChat({ api: /api/chat, // 核心配置出现非终态错误时启用恢复 onError: async (error) { // 从错误对象里拿到上一轮的流状态信息 const lastStreamId error.cause?.streamId; const lastVersion error.cause?.lastVersion; if (lastStreamId lastVersion) { setIsRecovering(true); try { await resumeMessage(lastStreamId, lastVersion); } finally { setIsRecovering(false); } } }, }); return { messages, sendMessage, isRecovering, error }; } // 恢复请求本质上是一个轻量级的“续传”请求 async function resumeMessage(streamId: string, fromVersion: number) { const resp await fetch(/api/chat/resume, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ streamId, fromVersion }), }); return resp; }服务端响应的恢复路由核心逻辑是“找到快照-校验版本-继续生成”。为了让用法更清楚整理一下伪代码// /api/chat/resume 路由 const { streamId, fromVersion } await req.json(); const snapshot await getSnapshot(streamId); if (!snapshot) { return Response.json({ error: stream_not_found }, { status: 404 }); } // 恢复上下文并带着“已生成到第fromVersion个chunk”的信号 const result await streamText({ model: openai(gpt-4o-mini), // 关键从快照恢复的messages而不是请求方的完整历史 messages: snapshot.filledMessages, experimental_continue: { fromVersion, streamId, }, });我把experimental_continue当做一个“续传开关”它告诉SDK这不是一个全新请求而是从指定版本号继续。实际SDK里方法名可能略有出入各版本API会有调整核心思想是一致的让服务端知道“用户已经看到第N个chunk了别重复发”。我在配置这个功能时踩过一个大坑恢复请求里传的messages千万不要用客户端本地缓存的完整历史。因为客户端收到的历史可能已经因为网络问题漏了一部分一旦拿不完整的历史去续传模型会基于错误上下文继续生成输出的内容驴唇不对马嘴。正确做法是用服务端快照里的上下文那是可靠完整的来源。3.3 恢复窗口、状态过期与存储清理流式恢复做上线前我强烈建议先想清楚三个配置恢复窗口、状态过期、存储清理。恢复窗口指“断线后多久之内可以发起恢复”。窗口太短用户还没来得及点重试服务端就把快照清了窗口太长存储成本一直高企而且旧的快照对正在运行的任务毫无意义。我这边实践的推荐值是515分钟。普通的直播类对话随手就回来继续的5分钟足够如果是长文档生成、耗时任务建议10分钟以上。你可以在每次成功恢复后刷新TTL保证活跃任务不会被中途踢掉。状态过期必须和存储清理联动。用Redis的时候我会专门跑一个定时任务# 每5分钟清理一次过期状态 redis-cli --scan --pattern ai:snapshot:* | while read key; do ttl$(redis-cli ttl $key) if [ $ttl -eq -2 ]; then echo expired: $key # 这里记录日志并统计过期数量 fi done清理本身不难难的是监控。我给快照存储加了一套指标当前快照总数、过期快照数、恢复成功数、恢复失败缺快照数。有了这些指标你才能知道恢复窗口定得合不合理也能在快照堆积膨胀前发现容量问题。我的经验是恢复成功率长期低于70%基本不是网络问题而是快照存储或恢复凭证链路有缺陷需要排查而不是调参数。4. 从失败终态到可验收终态构建你的验收契约4.1 一份合格的终态长什么样可验收的终态必须长成一个结构化的、能被前端直接消费的对象。AI SDK 7.0.91返回的终态对象我通常会要求包含四块信息已经生成的内容本身包括所有已产生的text、tool调用输入输出结束原因finishReason到底是自然结束stop、长度限制length、内容拦截content-filter、工具调用中断tool-calls还是出错errortoken消耗统计usage包括promptTokens、completionTokens、totalTokens恢复过程信息本次是否发生过恢复、从哪个版本恢复的、一共恢复了几次有了这四块信息前端就不是在“渲染一段不知道完没完的文本”而是在“渲染一个知道从哪来到哪去的产出物”。比如用户看到的提示可以从“正在生成中”变成本次回答在生成873个字后因网络原因中断已自动续接最终输出1247个字共消耗12,348 tokens。这个提示比任何“网络错误请点击重试”都有用——用户知道发生了什么、结果是什么、成本是多少。4.2 前端状态机loading、recovering、done、failed有了终态定义前端不能再用一个简单的isLoading布尔值打天下了。我现在的项目里维护了一个五态状态机状态含义用户可见行为idle无任务正常输入框loading首次生成中显示流式打字机效果recovering断线恢复中展示已生成内容顶部提示“正在恢复”done已收敛到终态完整内容终态信息展示failed不可恢复错误错误码建议动作保留已生成内容还有一个很容易被忽略的状态done之后用户又发起新一轮提问如果恢复凭证还在要不要延续我的建议是新一轮提问走全新streamId除非明确是“继续上次的回答”。这两者混在一起上下文边界会非常混乱实践中已经有团队踩过坑。在recovering状态下我强烈建议把已到达的内容实时渲染出来而不是继续转圈。用户看到答案在变长心理体感是“生成还在继续”而不是“又出错了”。实测这个细节能把用户对系统稳定性的评价提升一个档次。4.3 自动化测试用模拟故障验证恢复链路流式恢复这种能力不做自动化测试等于没做。手动测最多测一次每次网络断的时机不同根本没法稳定复现。我在项目里用Playwright写了一套故障注入测试模拟三类场景第一类是请求过程中断流。做法是在流中间人为终止WebSocket连接然后过2秒重新建立连接并调用恢复接口。断言目标是恢复后的文本顺序与断开前连续没有重复也没有跳字。第二类是服务端超时限流。我用一个测试用的mock模型设定它生成到第N个chunk时返回429限流错误。让客户端等过限流窗口后发起恢复验证恢复后能从快照继续生成。第三类是状态存储故障。把Redis直接停掉再触发一次恢复请求看服务端是否能优雅返回stream_not_found而不是抛出一个裸的500错误。这一步很多人忽略但它在生产里绝对会发生。下面这段是Playwright里断流测试的核心片段test(流中断后恢复文本保持连续, async ({ page }) { // mock路由在第3个chunk后挂断连接 await page.route(**/api/chat, async (route) { const response await route.fetch(); const reader response.body().getReader(); let chunkCount 0; // 手动读取流在第3个chunk之后抛出错误 while (true) { const { done, value } await reader.read(); chunkCount; if (chunkCount 3) { throw new Error(simulated network break); } if (done) break; } }); await page.goto(/); await page.fill(textarea, 生成一篇一千字的产品文档); await page.click(button[typesubmit]); // 等恢复完成最终看到完整终态 await expect(page.getByText(最后更新时间)).toBeVisible(); const text await page.textContent(.message-content); // 断言关键词连续出现没有明显重复段 expect(text).toContain(产品文档); });不要小看这套测试。我把它们接进CI之后至少拦下了三个回归一次是服务端快照字段改了名但恢复逻辑没同步改一次是Redis key前缀跟清理任务不一致导致快照全被删还有一次是恢复时前端误传了本地history导致上下文错乱。没有自动化兜底这些坑上线后才被发现那就是线上事故了。5. 常见问题与避坑记录5.1 stream_not_found恢复凭证丢了怎么处理线上最常出现的错误就是恢复时报stream_not_found。原因不外乎三种快照过期被清理了、服务实例重启后内存快照丢失了、恢复请求带了错误的streamId。这三种原因的排查路径完全不同所以错误处理设计要区分对待。第一种快照过期是最常见的情况。用户断线超过了恢复窗口再点恢复就找不到快照了。我的处理策略是当后端返回stream_not_found时前端检查本地是否已经收到了部分内容如果有就把它作为“半程终态”展示并提示用户“生成中断已展示部分结果”而不是清空重来。第二种服务重启丢内存快照这个可以通过切换到Redis存储解决。第三种请求参数错误那就是代码bug了需要日志监控来发现。5.2 恢复后token重复去重与幂等恢复功能做得不够精细时会出现一个隐蔽问题恢复后重新生成的文本跟断线前已经生成的那几百个token高度相似用户看到的内容出现大段重复。问题根源在于服务端保存快照的粒度太粗恢复时没有精确记录到“最后一个已返回给客户端的chunk”而是把模型的本地缓存状态当作断点导致模型把最后一段内容又生成了一遍。解决方案有两个层面。协议层上恢复请求必须带fromVersion服务端只推该版本之后的chunk。业务层上前端在渲染层做一次幂等去重如果新接收的chunk文本前缀与已渲染文本的尾部重叠就裁掉重叠部分。我在生产里两层都做了双保险。5.3 限流风暴下的恢复策略取舍限流是最容易被忽略的场景。假设你同时在线100个用户模型服务商限流所有请求一起被429。如果你自动触发恢复那100个恢复请求会同时打过去再次触发限流形成恢复风暴。正确做法是给恢复请求加一个随机退避。恢复不是实时任务晚500毫秒、1秒用户基本无感但错峰请求能显著降低限流冲突概率。我实践下来的配置是恢复请求的基础退避300毫秒最大退避3秒加上0500毫秒的随机抖动。实测可以把限流场景下的恢复成功率从30%拉到85%以上。另外429响应头里通常会带Retry-After字段客户端一定要优先尊重这个字段。它告诉你服务器期望你等多久再试无视它等于跟服务端的限流策略对着干。5.4 性能开销状态快照的内存与存储任何持久化机制都有成本流式恢复也不例外。我做了个基准测试一个包含工具调用的中等复杂度任务每秒产生的快照数据大概在10KB30KB之间一次任务全流程产生1万到3万条chunk记录。如果不加清理一个月就能积累几十GB的数据。控制成本的思路是“快照降级”。不是所有chunk都值得保存完整内容我只保留最近N个chunk的完整文本更早的只保存哈希值和版本号用于校验连续性。这样恢复时如果断得不太远可以完整续接如果断得太远就退化为“从最近完整快照重新生成”至少比完全重来省钱。我还加了开关内容短的任务预计1000 token以内不启用快照恢复因为这种任务恢复省下的token太少反而增加存储成本。快照只在预计生成长度超过一定阈值的任务上开启用maxOutputTokens或者prompt长度来预估。判断逻辑不复杂但能省不少存储钱。5.5 恢复成功不代表生成正确上下文完整性校验这里想提醒一个容易理解偏差的点恢复了不等于内容正确。如果断流之前模型已经基于错误的中间状态生成了后面的内容恢复只是在错误的地基上继续盖楼。我遇到过的最典型的场景是工具调用模型先调用了一个查询函数函数返回了结果但返回结果在传输过程中丢了一部分恢复时只续了后面的生成前面那个不完整的函数结果就成了错误依据。解决办法是在快照里同时记录工具调用的完整入参和出参。恢复时校验同一个工具调用ID如果发现出参不完整就主动重放一次工具调用而不是直接拿残缺结果往下走。这个校验逻辑加在服务端恢复路由里客户端感知不到但生成质量明显提升。我的后台添加了“恢复后废弃率”指标——恢复过的任务中用户重新提问/重新生成的比例。如果这个指标偏高就要排查是不是某种上下文完整性校验没做到位。这里的经验凝结在多个项目里把流式恢复从“能跑”调到“好用的状态”之后我最大的体会是千万不要把一个高级机制当失败兜底来用。流式恢复的价值不是让你无脑保住一次请求而是让你在生成类任务里给用户一个确定的交代。一个没有恢复机制的系统遇到断流只能让用户“看天吃饭”一个把恢复做好的系统断流只是一个小插曲用户甚至感知不到。代码里没有完美方案但流式恢复至少让我在运维视角从“求着上游别出事”变成了“出事也能在确定性里兜住”。这也是我做AI应用这一年觉得最值得的一次重构。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价