如果你做过 AI 对话、流式生成、或者 AI Agent 这类"跑得比较久"的功能,大概率被这样一个问题折磨过:
用户正在生成一篇长文,手一抖刷新了页面;网络抖了一下;或者服务端滚动更新把连接踢掉了。等他再连上来时,要么结果从头来一遍(慢且烧钱),要么内容重复了一截(尴尬),要么最惨——上下文丢了,AI 开始胡言乱语。
本文想讲清楚一件事:一个健壮的 AI 应用,应该把"用户的连接"和"实际的任务"这两条生命周期解耦。连接是易碎的、短暂的;任务是有价值的、长期的。任务状态必须被持久化,以便在需要时精确地恢复——而不是简单地重试或从头开始。
一、为什么会断?
在动手"恢复"之前,第一步是准确定义什么叫"断"。很多系统把"断"当成灾难,是因为它们默认"连接没了 = 任务完了"。这个假设本身就是 bug。
我们建议把"断"严格定义为:客户端与服务器之间的传输通道(连接)暂时不可用,而不是任务本身消亡。
任务应该独立于连接存在。基于这个认知,来看看"断"通常来自哪三类场景:
1.1 用户主动中断
-
点击暂停按钮(这是设计内的、优雅的中断)。
-
刷新页面 / 关闭标签页 / 切到后台被系统回收。
-
用户主观觉得"答得不对",想重来。
场景示例:用户在 /editor 页面用 AI 生成一段 500 行的重构,流式输出到第 300 行时觉得方向不对,习惯性按了 Cmd + R 刷新。如果他只是关掉页面,服务端还在傻傻地往下生成,既浪费算力,又可能把"已完成"的脏数据写进了他的项目文件。
1.2 网络波动
-
弱网、地铁 / 电梯里丢包。
-
代理 / 网关重连,TCP 连接被悄悄重置。
-
移动端切换 WiFi ↔ 蜂窝,IP 变更。
场景示例:手机上跑一个多轮 Agent(先查数据库、再画图、最后汇总)。用户走进地下车库,WebSocket 断开 8 秒后又连上。此时 Agent 在后台其实已经走到"画图"这一步,但客户端还停留在"查数据库"的进度条——恢复时如果处理不好,就会出现"画图结果"与"查数据库进度"对不上号。
1.3 服务器扩容等,连接被重置
-
K8s / 容器滚动更新、扩容、Pod 被驱逐。
-
网关 / 负载均衡的 idle timeout(空闲超时)把长连接砍了。
-
限流、熔断触发。
场景示例:你的 AI 服务发新版本,滚动更新期间老 Pod 被终止,几十个正在生成的 SSE 连接同时断掉。如果没有任务与连接解耦,这几十个用户看到的就是"生成失败,请重试"——体验灾难。
一句话心智模型:连接是"水管",任务是"水"。水管裂了可以换一根,水不能洒一地重接。让水管和水解耦,是后面所有设计的前提。
二、恢复过程会出现的问题
很多人以为"断线自动重连"就万事大吉了,其实重连才是麻烦的开始。
2.1 重复的副作用(Double Side Effects)
这是最危险的一类。如果任务内部有"写库、发消息、调支付、发邮件"这类有副作用的操作,且没做幂等,重连后重复触发,后果可能是真金白银的损失。
场景示例(AI 客服 Agent):Agent 在对话结束时调用"创建工单"接口。用户刷新页面导致连接断开,可 Agent 流程其实已经执行到创建工单这一步、只是结果没发回客户端。客户端重连后,Agent 被"从头调度"了一遍,于是同一个会话里出现了两张重复工单。
// ❌ 危险写法:没有幂等保护,重连就重复执行
async function stepCreateTicket(task: Task) {
return crm.createTicket({ userId: task.userId, summary: task.summary });
}
// ✅ 正确写法:用 (任务ID + 步骤ID) 做幂等键,命中即返回
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function withIdempotency<T>(key: string, fn: () => Promise<T>): Promise<T> {
const cached = await redis.get(`idem:${key}`);
if (cached) return JSON.parse(cached) as T; // 命中:直接返回首次结果,绝不二次执行
const result = await fn();
await redis.set(`idem:${key}`, JSON.stringify(result), 'EX', 86400);
return result;
}
// 用法
const ticket = await withIdempotency(`${task.id}:create_ticket`, () =>
crm.createTicket({ userId: task.userId, summary: task.summary }),
);
2.2 输出内容重复或错乱
流式(SSE / WebSocket)场景下尤其常见:客户端重连后从"头"订阅了事件流,于是又把前面已经渲染过的 token 吐了一遍;或者两段输出在客户端并发到达、顺序错乱。
场景示例:用户刷新页面,新连接从 offset=0 开始接收 SSE 事件,而页面上已经渲染到 offset=120。结果用户看到"……本文将从……本文将从三个角度……"这种鬼畜重复。
// 服务端:每个 delta 事件带单调递增 offset(Next.js Route Handler)
export async function GET(req: Request, { params }: { params: { taskId: string } }) {
const { searchParams } = new URL(req.url);
const from = Number(searchParams.get('from') ?? 0); // 客户端告知缺失起点
const stream = new ReadableStream({
async start(controller) {
const events = await loadEventsFrom(params.taskId, from); // 只取缺失增量
for (const e of events) controller.enqueue(`data: ${JSON.stringify(e)}\n\n`);
controller.close();
},
});
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
}
// 客户端:用 lastRenderedOffset 做去重 + 缓冲排序
let lastRendered = 0;
const buffer: Delta[] = [];
function onEvent(e: Delta) {
if (e.offset <= lastRendered) return; // 丢弃已渲染的
buffer.push(e); // 乱序先入缓冲
buffer.sort((a, b) => a.offset - b.offset);
while (buffer[0]?.offset === lastRendered + 1) {
const d = buffer.shift()!;
render(d.text); // 只 flush 连续前缀
lastRendered = d.offset;
}
}
2.3 上下文丢失或"记忆"中断
多轮对话、Agent 的工具调用中间态、检索到的知识片段——这些都属于"上下文"。如果任务状态只活在内存里的连接对象上,连接一断,上下文就随风而去了。
场景示例(RAG Agent):Agent 先调用 searchDocs 拿到 3 篇参考文档,再基于它们作答。连接在第 2 篇文档分析完时断了。重连后如果上下文没持久化,Agent 会"忘了"自己读过什么,要么重新检索(慢、且可能结果不同导致自相矛盾),要么凭空作答(幻觉加剧)。
解法:把"记忆"写进检查点,恢复时从存储读回,而非重新检索。 核心原则是——凡是会被后续步骤依赖的上下文(对话历史、工具结果、检索片段),都在产生时一并存进 Checkpoint.context,断线后从 Redis 直接还原,绝不重新"想一遍"。
// context.ts —— 上下文随检查点一起持久化,恢复时直接读回
interface AgentCtx {
taskId: string;
messages: ChatMessage[]; // 完整对话上下文(含工具结果),即"记忆"
cursor: number; // 已完成的步骤游标,对应 3.3 的进度标记
}
// 唯一干活的函数:签名固定为 (ctx, step)
async function runStep(ctx: AgentCtx, step: Step) {
// 1) 执行步骤,并把"会产生后续依赖"的上下文写进检查点
const docs = await searchDocs(ctx.taskId, step.query);
ctx.messages.push({ role: 'tool', name: 'searchDocs', content: docs });
// 2) 每一步都把最新上下文 + 游标落盘,断线即可从这儿还原
await saveCheckpoint(ctx.taskId, { ...ctx, cursor: step.index + 1 });
// 3) 后续推理直接消费上下文,而不是重新检索
return llm.chat({ messages: ctx.messages });
}
// 恢复时:从检查点还原"记忆",零重算
export async function resumeContext(taskId: string) {
const ckpt = await loadCheckpoint(taskId);
if (!ckpt) throw new Error('no checkpoint, cannot resume memory');
// 用检查点里的 messages + cursor 重建执行上下文 ctx
const ctx: AgentCtx = { taskId, messages: ckpt.messages, cursor: ckpt.cursor };
// 第二个参数始终是"下一步 step",与 runStep 签名一致
const nextStep = STEPS[ctx.cursor];
return runStep(ctx, nextStep);
}
2.4 检查点损坏或不兼容
如果你把任务状态序列化存进 Redis / 数据库,而某天升级了代码,旧的序列化结构读不出来——重启后所有"进行中"的任务全部无法恢复,只能失败。
场景示例:v1 的检查点存的是 context: string[],v2 改成了 context: Chunk[](带引用来源)。线上灰度期间,老任务用新代码反序列化直接抛异常,恢复全军覆没。
解法:给检查点加 schemaVersion,读回时显式迁移 + 兜底隔离。 每次改结构就 +1 版本号并写迁移函数;读取时按版本转换,解析失败的任务进入"隔离区"而非拖垮整体恢复。
// checkpoint-version.ts —— 版本化 + 迁移 + 兜底隔离
const CURRENT_VERSION = 2;
function migrate(raw: any): Checkpoint {
let data = raw;
// 逐级迁移,保证能从任意老版本升到当前版本
if (data.schemaVersion === 1) {
data.context = data.context.map((text: string) => ({ text, source: 'unknown' })); // string[] → Chunk[]
data.schemaVersion = 2;
}
return data as Checkpoint;
}
export function deserializeCheckpoint(raw: string | null): Checkpoint | null {
if (!raw) return null;
try {
const data = JSON.parse(raw);
return migrate(data);
} catch (err) {
// 损坏 / 不兼容:隔离该任务,绝不抛异常拖垮其他恢复
console.error('[checkpoint] corrupted, quarantined:', err);
return null; // 上层据此标记任务 FAILED 并告警,而非整体崩溃
}
}
这四个问题共同的 root cause 是:任务状态没有成为"可持久化的一等公民"。它不是活在连接对象里的临时变量,而必须是一个能被存、能被读、能被版本兼容的独立实体。
三、状态持久化策略
把任务建模为持久化实体后,落地要靠三件套:状态机制、检查点、进度标记。
3.1 状态机制(State Machine)
先给任务一个明确的状态集合。注意:不要把"断开"作为一个任务状态——断开是连接的属性,不是任务的属性。任务只关心自己"做到哪了"。
// task-state.ts
export enum TaskStatus {
PENDING = 'PENDING', // 已创建,排队中
RUNNING = 'RUNNING', // 执行中
SUSPENDED = 'SUSPENDED', // 暂停
COMPLETED = 'COMPLETED', // 已完成
FAILED = 'FAILED', // 失败(可带原因,便于介入)
}
// 合法转换白名单,禁止"野路子"跳转
const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
[TaskStatus.PENDING]: [TaskStatus.RUNNING],
[TaskStatus.RUNNING]: [TaskStatus.SUSPENDED, TaskStatus.COMPLETED, TaskStatus.FAILED],
[TaskStatus.SUSPENDED]: [TaskStatus.RUNNING, TaskStatus.FAILED],
[TaskStatus.COMPLETED]: [],
[TaskStatus.FAILED]: [TaskStatus.PENDING],
};
export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
return TRANSITIONS[from].includes(to);
}
这样,连接断了的时候,任务状态根本不变(依然 RUNNING),只是"没有人在听它说话"。重连后根据当前状态决定怎么续,逻辑异常清晰。
3.2 检查点(Checkpoint):保存"完整现场"
检查点回答的问题是:如果此刻进程被 kill,我从哪里接着跑? 它在任务执行的关键节点(如完成一个工具调用、一次 LLM 推理步骤后)保存完整的执行状态快照。
恢复时,系统从最后一个成功的检查点重新进入,已完成步骤的结果直接从存储中返回,只执行未完成的步骤——这正是"精确恢复"而非"从头再来"的核心。
// checkpoint.ts
export interface Checkpoint {
taskId: string;
seq: number; // 检查点序号,单调递增
schemaVersion: number; // 结构版本!用于兼容(见 3.4)
prompt: string; // 原始输入(恢复起点)
context: ChatMessage[]; // 完整上下文窗口
streamOffset: number; // 已下发的 token 偏移
toolStack: ToolCall[]; // 工具调用栈与中间结果
completedSteps: string[]; // 已完成步骤 id(用于"直接返回结果")
results: Record<string, unknown>; // 已完成步骤的缓存结果
sideEffects: string[]; // 已提交副作用的幂等键(防重复)
updatedAt: number;
}
// 在"逻辑段落 / 工具调用 / 推理步骤"后触发,节流写入
export async function maybeCheckpoint(taskId: string, build: () => Checkpoint, force = false) {
if (!force && Date.now() - lastCkptAt(taskId) < THRESHOLD_MS) return;
const cp = build();
await redis.set(`ckpt:${taskId}`, JSON.stringify(cp), 'EX', 3600);
}
场景示例:Agent 跑了 5 个工具调用后进程崩溃。检查点里 completedSteps = ['search','calc','sql','http','summarize'],results 里存着这 5 步的真实结果。恢复时这 5 步的结果直接从 results 返回,系统只从第 6 步继续——而不是把前 5 步重算一遍。
3.3 进度标记(Progress Marker):保存"下一步指针"
进度标记是一个轻量级的"位置"指针,指明下一个要执行的步骤。它通常与检查点配合使用:
恢复时先读取进度标记,再根据检查点判断哪些工作已完成。
// progress.ts
export interface ProgressMarker {
taskId: string;
nextStep: string; // 下一个要执行的步骤 id("下一步指针")
seq: number; // 与检查点 seq 对齐
}
export async function resume(taskId: string) {
const marker = await loadProgress(taskId); // 1) 先读"下一步指针"
const ckpt = await loadCheckpoint(taskId); // 2) 再据检查点判断已完成
const pending = STEPS.filter(s => s.id >= marker.nextStep); // 只跑未完成的
for (const step of pending) await execute(step, ckpt);
}
场景示例:nextStep = 'draw',检查点显示 completedSteps 里已有 ['search','calc']。恢复时跳过前两步、直接从 draw 开始,且 search/calc 的结果直接从检查点读取,零重算。
3.4 写入顺序至关重要:先检查点,再进度标记
必须先写入检查点,再推进进度标记。
原因:即使进程在两次写入之间崩溃,恢复时也能通过检查点键的确定性(检查点已经把该步骤的结果写进去了),检测到已完成的操作,避免重复执行。
// commit.ts —— 注意:这是两次独立写入,靠"顺序"而非事务保证安全
export async function commitStep(taskId: string, step: Step, result: unknown) {
const ckpt = await loadCheckpoint(taskId);
// 顺序 1:先把"完整现场 + 本步结果"写进检查点
const nextCkpt: Checkpoint = {
...ckpt,
completedSteps: [...ckpt.completedSteps, step.id],
results: { ...ckpt.results, [step.id]: result },
seq: ckpt.seq + 1,
};
await redis.set(`ckpt:${taskId}`, JSON.stringify(nextCkpt), 'EX', 3600);
// 顺序 2:再推进"下一步指针"
await redis.set(
`progress:${taskId}`,
JSON.stringify({ taskId, nextStep: step.next, seq: nextCkpt.seq }),
);
}
// 恢复时的安全网:崩溃发生在"顺序1之后、顺序2之前"时
export async function safeResume(taskId: string) {
const marker = await loadProgress(taskId); // 指针还停在本步
const ckpt = await loadCheckpoint(taskId); // 但检查点已含本步结果
if (ckpt.completedSteps.includes(marker.nextStep)) {
// 检查点已确认完成 → 直接返回存储结果,跳过执行,杜绝重复副作用
return ckpt.results[marker.nextStep];
}
return execute(STEPS.find(s => s.id === marker.nextStep)!, ckpt);
}
一句话总结写入顺序的价值:进度标记是"乐观指针",检查点是"事实真相"。崩溃时以检查点为准,就不会因为指针超前而漏掉工作,也不会因为指针滞后而重复工作。
四、解决方案
4.1 用户层:断点续传(把已生成文本塞回 prompt)
最朴素也最常见的做法:把已经生成的文本作为 system / user prompt(伪造历史对话),明确告诉模型接着写。
// app/api/resume/route.ts (Next.js App Router)
export async function POST(req: Request) {
const { partialText } = await req.json();
const messages: ChatMessage[] = [
{ role: 'system', content: buildSystemPrompt() },
{
role: 'user',
content:
`这是刚才未完成的回答(已生成部分):\n"""${partialText}"""\n` +
`请从断点处接着往下写,保持上下文连贯、风格一致,直接输出剩余部分,不要重复已有内容。`,
},
];
const stream = await llm.chat({ messages, stream: true });
return new Response(stream.toReadableStream());
}
优点
- 实现极简单,纯 prompt 工程,无需服务端持久化复杂状态。
- 前端即可闭环,适合轻量场景 / MVP 快速验证。
- 对"纯文本续写"类任务(作文、文案)效果不错。
缺点
- 消耗额外 token:把已生成文本重新塞进 prompt,长文成本显著上升。
- 模型可能"假装续写"却跑偏、风格漂移、甚至重复开头。
- 只能解决文本层面,无法恢复"副作用"(工具调用、写库、发消息)。
- 检查点 / 检索文档等上下文若没一起塞回,会丢失"记忆"(呼应 2.3)。
- 长文本会撑爆 context window,得不偿失。
结论:用户层续写适合"低成本、纯文本、可容忍少量偏差"的场景;一旦涉及 Agent、工具调用、长文,必须上服务端持久化。
4.2 服务端:逻辑段落级快照 + Redis 热缓存 + DB 长期归档
这是生产级做法。每生成一个"逻辑段落",触发一次状态快照(检查点 + 进度标记),先写 Redis 做低延迟热缓存;任务完成再持久化到数据库做长期归档。
// snapshot.ts (Next.js Server Action / Worker 内)
export async function onParagraphGenerated(taskId: string, paragraph: string, state: StepState) {
const cp = buildCheckpoint(taskId, state, paragraph); // 含检查点 + 进度标记
// 热路径:写 Redis,毫秒级,支撑高频续传
await redis.set(`ckpt:${taskId}`, JSON.stringify(cp), 'EX', 3600);
}
// 任务完成时:落库长期归档,释放热缓存
export async function finalizeTask(taskId: string) {
const raw = await redis.get(`ckpt:${taskId}`);
await db.taskArchive.create({ data: { id: taskId, snapshot: raw, status: 'COMPLETED' } });
await redis.del(`ckpt:${taskId}`);
}
整体数据流:
客户端 ──WS/SSE──► 网关/连接层 ──► 任务 Worker(独立进程,不随连接生死)
│
┌─────────┴─────────┐
▼ ▼
Redis(热快照/秒级) Postgres(长期归档) ckpt:{id} task_archive 表
这样连接断了,Worker 还在跑、Redis 里还有最新现场;重连后从 Redis 秒级恢复,任务结束才落库,兼顾了"低延迟"与"可长期回溯"。
4.4 如何降低重试带来的延迟和成本
单任务总成本 ≈ 首次生成 token 成本 + 重试生成 token 成本
降低"重试成本"的四条实战策略:
-
增量续传,杜绝重发:服务端按
offset只补发缺失 delta(2.2 的 SSE 方案),已生成的 token 不重发 → 重试成本趋近 0。 -
已完成步骤直接返回:检查点的
results缓存让恢复时"读存储"而非"重算力",省下整段推理 token。 -
慎用用户层续写:4.1 的"塞回 partialText"会膨胀 prompt,长文重试成本最高,长任务优先用服务端快照。
-
重试预算 + 早失败:设上限(如 3 次),超出直接
FAILED并提示用户,避免无限重试把成本打爆。
优化后:重试 token 成本 ≈ 0(增量续传) + 0(缓存命中)
仅剩"首次生成"这一份成本 → 中断恢复接近零额外开销
五、联想:React Fiber 架构的中断恢复机制
写到这里,你会发现这套思路和 React Fiber 高度同构。React 16 引入 Fiber,核心动机之一正是:渲染(尤其是大型组件树)是一个可能很长的过程,必须能被中断、能被恢复、能在多个帧之间分片执行。
Fiber 的几个关键机制,和我们讲的 AI 任务恢复一一对应:
| React Fiber | AI 任务恢复 | 对应点 |
|---|---|---|
| 把渲染拆成可中断的工作单元(Fiber 节点) | 把任务拆成带进度标记的子步骤 | 都是"分片化" |
| 双缓存树(current / workInProgress):渲染中出岔子,直接丢弃 workInProgress,current 不受影响 | 检查点:进程崩了从最近检查点恢复,不污染已完成态 | 都是"可丢弃的中间态" |
| 时间切片 / 让出主线程:时间片用完就续跑 | 任务 Worker 独立运行,连接断开就让出"听众",下次续订 | 都是"可暂停 / 可恢复的执行" |
| 优先级调度:高优任务可打断低优 | 用户主动暂停(SUSPENDED)可打断 RUNNING | 都是"中断是设计内的,不是异常" |
看一段被简化过的 Fiber 工作循环,感受"可中断"的写法:
// 伪代码:React 风格的"可中断工作循环"
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 处理一个工作单元
}
if (nextUnitOfWork) {
requestIdleCallback(workLoop); // 时间片用尽 → 让出 → 下次续
} else {
commitRoot(); // 全部完成 → 一次性提交
}
}
对 AI 工程的启示:把"长任务"当成一棵需要分片的"树"来设计——每个可独立恢复的子步骤就是一个 Fiber 节点;任务的"current 树"是已提交的检查点,"workInProgress 树"是当前这轮执行;连接、网络、发布,都只是"时间片用尽"式的被动让出,而非灾难。
当你用"可中断 / 可恢复的工作单元"这个心智模型重构 AI 任务时,所谓的"断线恢复"就从一堆 if-else 补丁,变成了一个清晰的架构选择。
六、总结:一个可用的心智模型
把全文浓缩成三句话,方便你在设计评审 / 面试里直接讲:
-
解耦:用户的连接是易碎的水管,任务是珍贵的水。让任务独立于连接存在——连接断开 ≠ 任务失败。
-
持久化:检查点存"完整现场"、进度标记存"下一步指针",且先写检查点再推进指针;恢复 = 读状态 + 续跑,而非重试或重来。
-
防御与成本:幂等键兜副作用、offset 兜流式重复、schemaVersion 兜版本兼容;用增量续传 + 缓存命中把"重试成本"压到趋近于 0。
做到这三点,AI 应用就能在用户刷新、地铁断网、服务端滚动更新的各种"断"里,依然优雅地把活干完——而且干得和用户预期严丝合缝,不重、不乱、不丢。
如果说传统 Web 开发的心法是"无状态、可水平扩展",那么 AI 长任务开发的心法就是"有状态、但状态可被精确恢复"。这正是 AI Native 应用和传统 CRUD 应用在架构上的最大分野之一。