今天看到个有点意思的问题,我把它发给朋友:

AI 生成到 90% 突然断了,你的解决方案是?

朋友秒回:“继续。”

我说:“没 Token 了呢,继续不了。”

他又回了两个字:“充钱。”

万能的氪金!

玩笑归玩笑,如果不是额度用完,而是 AI 在流式生成到 90% 时突然断开,“继续”真的有用吗?它究竟是从断点接着写,还是偷偷从头想了一遍?

这个看似简单的操作,背后可能是三件完全不同的事:补发你没收到的内容、根据已有文字重新续写,或者恢复模型中断前的计算状态。

先说结论:

AI 流式生成中的“恢复”,至少包含连接恢复、内容恢复和推理恢复三层。我们看到的“继续生成”,未必真的恢复了模型中断前的计算现场。

一、输出到 90% 断了,会发生什么

普通文字停在半句话,用户通常还能看懂前面的内容。但 AI 输出越来越多地被程序直接消费:JSON 用来渲染表单,HTML 用来生成页面,Markdown 用来展示文档,工具参数还可能触发发邮件、扣款等真实操作。

这些内容断在中间,问题就不只是“少了几个字”。

1. 普通文本:可能重复、漏字或风格突变

纯文本的容错能力最强。即使停在半句话,已经收到的部分通常仍然可读。

但如果原来的模型请求已经失败,系统只能重新请求模型续写,新内容就可能重复上一段,也可能突然改变语气、人称甚至结论。因为“重新续写”并不等于“恢复现场”,它只是基于已有文本发起了另一次推理。

2. JSON:少一个括号,整个对象都不能用

模型可能停在这里:

{
"title": "AI 中断恢复",
"items": [
{"name": "事件重放"},
{"name": "KV Cache"

此时缺少右引号、右花括号和数组结束符,直接调用 JSON.parse() 一定报错。如果恢复后的模型又从一个新的 { 开始输出,把前后两段简单拼接也无济于事。

更稳妥的做法是:

  • 流式阶段保存原始字符串,不把每个 chunk 当成完整 JSON;
  • 使用增量解析器,保留引号、转义符、数组和对象的解析状态;
  • 或者把结构化输出拆成 field_update、JSON Patch 等独立事件;
  • 收到 completed 后,再执行完整的 Schema 校验;
  • 重新生成时创建新的 revision,不要把两次生成结果直接拼接。

3. HTML:能显示,不代表结构正确

模型可能只生成到:

<section class="card">
<h2>恢复方案</h2>
<p>首先保存事件日志

浏览器通常会尝试补齐缺失标签,因此页面不一定立刻白屏,却可能出现布局跳动、节点嵌套错误,甚至把后面的内容全部塞进同一个元素。

如果把模型生成的 HTML 直接写入 innerHTML 或框架的 v-html,还会引入 XSS 风险。流式中断和恢复不能绕过原有的安全策略。

实际处理时应当:

  • 流式阶段优先按纯文本或代码预览;
  • 必须预览 HTML 时,先严格清洗,再放进受限的 sandbox iframe;
  • 不在半成品阶段执行脚本、事件属性或外部资源;
  • 收到完成事件后,再做一次完整解析、清洗和渲染。

4. Markdown:不报错,也可能显示全乱

Markdown 常见的中断位置包括:

  • 代码围栏没有闭合,后面的正文全部显示成代码;
  • 表格只生成了一半,列数不断变化;
  • 链接只生成了 [标题](,导致后续内容渲染异常;
  • 加粗、列表或引用块停在半截,页面持续抖动。

客户端可以使用支持增量更新的 Markdown 渲染器,也可以只在完整的自然段、代码块或事件边界上刷新。为了预览而临时补上的围栏或标签,只能留在显示层,不能写回真实内容。

5. Unicode:一个汉字也可能被切成两半

网络分片(chunk)按字节传输,并不保证刚好落在字符边界。UTF-8 汉字、Emoji 或组合字符都可能横跨两个分片。如果每个分片单独解码,客户端就可能出现 或乱码。

接收端需要使用支持流式状态的解码方式。例如 Web 客户端可以调用 TextDecoder.decode(chunk, { stream: true }),让解码器暂存尚未完整的字节序列。

6. 代码和 Agent:危险的不只是显示异常

代码中断时,函数、字符串和注释都可能没有闭合。此时自动执行测试、部署,或者直接覆盖原文件,都可能扩大故障。更安全的做法是先标记为 draft,完成语法检查后,再通过临时文件、Patch 或原子替换落盘。

Agent 工具调用的风险更高。工具参数经常以 JSON 增量形式到达,中断时可能只收到一半;恢复后如果直接重试,还可能重复发邮件、重复扣款或重复创建记录。

因此,工具调用至少需要:

  • 通过 tool_call_id 聚合同一次调用的参数;
  • 收到完成标记并通过 Schema 校验后才执行;
  • 为有副作用的操作设置幂等键;
  • 保存 pending / running / succeeded / failed 状态;
  • 恢复时先查询原操作是否已经成功,再决定是否重试。

这些看似不同的问题,其实可以归结为同一个原则:

半成品可以展示,但不能当作完成数据解析、执行或提交。原始增量、解析状态和业务提交状态必须分开保存。

二、页面断了,不代表生成任务结束了

要理解如何恢复,得先找到故障发生的位置。一次常见的 AI 流式生成,大致经过这条链路:

模型服务 → 流式请求 → 业务服务 → SSE / WebSocket / 流式 HTTP → 客户端

所谓“生成到 90% 突然断了”,可能发生在任何一段。

第一种:客户端连接断了,模型仍在生成

这是最常见、也是最好处理的情况。

用户刷新了 Web 页面、移动端切换网络、桌面应用进入休眠,或者流式连接被网关关闭,但后台任务和模型请求都没有停止。模型仍在继续生成,服务端也在持续保存结果。

客户端重新打开会话后,服务端只需要把它没收到的部分补发回来。这种情况可以做到精确恢复,因为缺失的内容其实已经生成,只是没有成功送达当前客户端。

第二种:业务服务与模型之间的连接断了

这时麻烦大了。

模型可能只生成到某个 Token,上游流式请求就失败了。如果模型接口无法继续原任务,业务服务通常也拿不到模型当时的内部计算现场。

系统只能保留已收到的文本,把它们重新放进 Prompt,要求模型“从这里继续,不要重复”,然后发起一次新的模型请求。

它属于语义续写,不是精确续传。

第三种:生成服务本身崩溃了

如果任务状态、已生成文本和进度都只存在进程内存里,那么进程一崩,基本只能重新开始。

更成熟的系统会把生成任务交给独立 Worker,并持续记录任务状态和输出事件。这样即使服务实例重启,也能知道任务执行到了哪里、哪些内容已经生成,以及是否需要重试。

所以在讨论恢复之前,需要先回答:

断掉的是客户端订阅连接、模型请求,还是整个生成任务?

三、“继续”,可能是三种不同操作

找到断点以后,才能决定怎么“继续”。产品界面上只有一个交互,内部却可能执行三种完全不同的操作。

1. 重放

服务端已经生成了内容,只是客户端没有收到。

系统只需重新发送缺失事件,不必再次调用模型,内容可以保持完全一致。这是最可靠、成本也最低的恢复方式。

2. 重新续写

原来的模型请求已经失败,系统把已有文本重新交给模型,请它继续完成。

它会产生一次新的推理,可能重复、漏写或改变风格。对于 JSON、HTML、代码和工具调用,还要处理结构断裂与重复副作用。

3. 推理状态恢复

系统保留或重新加载 KV Cache、Token 序列和采样状态,让模型从原来的计算现场附近继续生成。

它最接近真正的“从模型断点继续”,但实现复杂,通常需要自建推理服务。

操作 实际发生了什么 能否精确恢复
重放 补发已经生成的事件 可以
重新续写 发起一次新的模型推理 不一定
推理状态恢复 加载原任务的推理状态 取决于状态是否完整

四、真实项目通常怎么处理客户端断线?

真实项目里,一个常见的坑是:客户端连接一断,就立刻取消模型请求。

这会把页面刷新、网络切换、应用休眠和网关超时都误判成用户主动放弃。

更稳妥的做法,是把“生成任务”和“客户端连接”解耦:

客户端只是结果的订阅者,不是生成任务的所有者。

连接断开后,后台任务可以继续运行一段时间,并把新内容写入事件日志。客户端回来后,再从最后收到的位置继续订阅。

创建生成任务

模型持续输出

服务端记录事件

推送给客户端

客户端断线

任务继续运行或进入宽限期

客户端携带 last_seq 重连

不同任务可以采用不同策略:

  • 普通聊天:断线后继续生成并缓存;
  • 长报告或代码:设置 30~60 秒宽限期,超时后再决定取消;
  • 图片、视频等后台任务:继续执行,不依赖客户端连接;
  • 实时语音:断线后尽快停止,避免持续消耗资源。

系统还要区分“网络断开”和“用户主动停止”。用户点击停止时,客户端应该调用独立的取消接口,而不是简单关闭 SSE 或 WebSocket。

五、事件日志如何保证内容不重不漏?

每个输出片段都可以记录为一个带递增序号的事件:

{
"generation_id": "gen_123",
"revision": 1,
"seq": 58,
"type": "text_delta",
"text": "这里是新生成的内容"
}

其中,generation_id 标识生成任务,revision 区分重新生成后的版本,seq 负责排序、查漏和去重。如果客户端已经连续收到 seq=57,重连时就要求服务端从 58 开始补发。

客户端已有:1 ~ 57
服务端补发:58 ~ 93
后续新事件:94、95、96……

客户端按 generation_id + revision + seq 去重,即使服务端重复发送也不会重复拼接。近期事件可以放在 Redis Stream 等短期存储中,最终结果写入数据库;长任务则使用“文本快照 + 少量增量”,避免从第一个 Token 重放。

结构化内容还要额外保存解析状态:JSON 完成后再校验,HTML 清洗后再渲染,工具调用则在参数完整且幂等校验通过后执行。

六、KV Cache 能让模型“原地续上”吗?

事件日志解决的是内容补发问题。但如果模型请求真的停了,再次生成时还可能需要重新计算全部前文。

KV Cache 保存了历史 Token 在注意力计算中产生的 Key 和 Value,可以把它理解成模型的“计算草稿”:

文本:模型已经读过和生成了什么
KV Cache:模型处理这些 Token 时产生的中间计算结果

如果模型已经处理了 10,000 个 Token,没有 KV Cache 就要重新执行 prefill;如果推理服务保留了可复用的 KV Cache,就能跳过大部分前文计算,更快生成下一个 Token。

事件日志解决“用户不要丢内容”,KV Cache 解决“模型不要重算前文”。

不过,拥有 KV Cache 不等于自动获得“断点续传”。它不能替代事件日志,而且体积很大。跨节点恢复还可能经历:

GPU → CPU → 网络存储 → CPU → 另一块 GPU

恢复时还要保证模型版本、Tokenizer、缓存格式和采样状态兼容。第三方模型 API 通常不会把 KV Cache 交给应用管理,因此绝大多数产品仍以事件重放为主;只有原任务确实失败时,才降级为语义续写。

写在最后

可靠的 AI 产品,需要分别管理连接、内容和推理状态:事件日志保证内容可重放,任务系统让生成不依赖某一次客户端连接,KV Cache 则在值得的场景里减少重复计算。