AI 生成到 90% 突然断了怎么办?我用 SSE、检查点和幂等把任务接着跑
最近我遇到一个很典型的 AI 全栈故障:模型已经生成到 90%,浏览器突然断网。用户刷新页面后,只能从头再来;前面消耗的时间和 Token 全部浪费,工具调用还可能重复执行。
问题并不只是"重连 SSE"。真正要解决的是三件事:浏览器如何知道自己读到了哪里,服务端如何保存任务进度,恢复以后如何保证同一个动作不会执行两次。

本文用 FastAPI、SSE、SQLite 检查点和幂等键搭一个最小可恢复任务。你可以主动断开连接、重启服务,再验证任务是否从上次事件继续。
先定义验收标准
一个可恢复长任务至少满足:
- 每个任务有唯一
task_id。 - 每条输出事件有递增
event_id。 - 服务端持续保存状态和检查点。
- 浏览器重连时携带
Last-Event-ID。 - 同一个写操作使用幂等键,重复请求不会重复生效。
- 任务状态能区分运行、暂停、恢复、完成和失败。
数据表只保存状态,不保存幻想
sql
CREATE TABLE tasks (
task_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
checkpoint INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE events (
task_id TEXT NOT NULL,
event_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (task_id, event_id)
);
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
result TEXT NOT NULL
);
checkpoint 表示业务已经完成到哪一步,events 保存浏览器可以重放的输出片段。两者不能混为一谈:事件已经发出,不代表业务事务已经提交。
FastAPI 输出可恢复的 SSE
python
import asyncio
import json
from fastapi import FastAPI, Header
from fastapi.responses import StreamingResponse
app = FastAPI()
EVENTS = {
"task-001": [
{"id": 1, "type": "progress", "data": {"percent": 20}},
{"id": 2, "type": "progress", "data": {"percent": 50}},
{"id": 3, "type": "progress", "data": {"percent": 90}},
{"id": 4, "type": "result", "data": {"percent": 100}},
]
}
def encode_sse(item: dict) -> str:
body = json.dumps(item["data"], ensure_ascii=False)
return (
f"id: {item['id']}\n"
f"event: {item['type']}\n"
f"data: {body}\n\n"
)
@app.get("/tasks/{task_id}/events")
async def task_events(
task_id: str,
last_event_id: int = Header(0, alias="Last-Event-ID"),
):
async def stream():
for item in EVENTS.get(task_id, []):
if item["id"] <= last_event_id:
continue
yield encode_sse(item)
await asyncio.sleep(1)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
关键不是 StreamingResponse,而是过滤 item["id"] <= last_event_id。客户端已经确认收到的事件不能再发一次。
浏览器记录最后一个事件
原生 EventSource 会在标准重连中自动处理事件编号,但页面刷新后仍应把最后位置持久化:
javascript
const taskId = "task-001";
const key = `last-event:${taskId}`;
const lastId = localStorage.getItem(key) || "0";
const source = new EventSource(
`/tasks/${taskId}/events?after=${lastId}`
);
source.onmessage = (event) => {
localStorage.setItem(key, event.lastEventId);
render(JSON.parse(event.data));
};
source.addEventListener("result", (event) => {
localStorage.setItem(key, event.lastEventId);
render(JSON.parse(event.data));
source.close();
});
实际项目可以同时支持 Last-Event-ID 请求头与 after 查询参数,因为页面刷新后用 EventSource 不方便手工设置请求头。
检查点应该保存什么
不要序列化整个 Python 进程。稳定的检查点应该是可重建的业务状态:
json
{
"task_id": "task-001",
"step": "summarize_documents",
"completed_document_ids": ["doc-1", "doc-2"],
"next_document_id": "doc-3",
"prompt_version": "summary-v4"
}
恢复时重新加载配置和代码,只读取必要业务字段。这样版本升级后仍有机会迁移旧检查点。
幂等键防止工具重复执行
断线发生时,客户端并不知道"付款动作失败了",还是"付款成功但响应丢了"。因此产生外部副作用的接口必须支持幂等:
python
@app.post("/tasks/{task_id}/charge")
def charge(task_id: str, idempotency_key: str):
old = find_result(idempotency_key)
if old is not None:
return old
result = payment_gateway.charge(task_id)
save_result(idempotency_key, result)
return result
幂等键应绑定用户、任务、动作和业务版本,不能简单使用一个全局固定字符串。
用状态机管理恢复

建议只允许明确的状态迁移:
text
running -> paused -> recovering -> completed
running -> failed
failed -> recovering
recovering -> failed
不要让前端直接把任务改成 completed。状态迁移必须由服务端验证当前状态、调用者权限和检查点完整性。
做三次故障注入
第一次:生成到 50% 时关闭浏览器,再打开页面。应从下一个事件继续,不重复显示。
第二次:生成到 90% 时终止 FastAPI 进程,再启动服务。应从数据库检查点恢复,而不是依赖内存列表。
第三次:连续点击两次恢复按钮。服务端应该只领取一次任务,第二次返回已有执行状态。
验收命令:
bash
curl -N \
-H "Last-Event-ID: 2" \
http://127.0.0.1:8000/tasks/task-001/events
输出必须从事件 3 开始。
常见错误
只有重连,没有重放
连接恢复了,但之前丢失的内容仍然缺失。必须有事件日志或可重建结果。
有检查点,没有幂等
任务可以恢复,但付款、发消息或写数据库被执行两次,后果更严重。
所有输出最后一次性保存
运行 20 分钟后崩溃,仍然等于没有检查点。应在可验证的业务边界保存。
把失败自动无限重试
权限错误、输入错误不会因为重试而消失。重试必须有次数、退避和可重试错误白名单。
总结
可恢复 AI 长任务的核心不是某一个框架,而是一条完整证据链:
- SSE 事件编号解决"浏览器读到哪里"。
- 业务检查点解决"服务端做到哪里"。
- 幂等键解决"恢复后能不能安全再执行"。
- 状态机解决"谁可以让任务进入下一状态"。
如果你的 AI 任务在 90% 时断开,你现在最缺的是事件重放、业务检查点,还是幂等保护?欢迎把具体故障贴出来。