AI 生成到 90% 突然断了怎么办?我用 SSE、检查点和幂等把任务接着跑

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 长任务的核心不是某一个框架,而是一条完整证据链:

  1. SSE 事件编号解决"浏览器读到哪里"。
  2. 业务检查点解决"服务端做到哪里"。
  3. 幂等键解决"恢复后能不能安全再执行"。
  4. 状态机解决"谁可以让任务进入下一状态"。

如果你的 AI 任务在 90% 时断开,你现在最缺的是事件重放、业务检查点,还是幂等保护?欢迎把具体故障贴出来。

相关推荐
李昊哲小课2 天前
FastAPI + Echarts 构建可交互数据仪表板
信息可视化·echarts·fastapi
未知违规用户2 天前
大模型项目: 学习FastAPI 服务器开发
服务器·人工智能·python·学习·fastapi
普通网友3 天前
Python FastAPI 异步数据库管理
数据库·fastapi
观察员3 天前
深入理解 Pydantic 的 BaseModel
fastapi
郝同学今天有进步吗3 天前
构建 LangGraph Code Review Agent(七):实现规则匹配、Finding Guardrails 与 Markdown 报告
python·ai·fastapi·code review
智购科技自动售货机工厂3 天前
即时零售的风吹到自动售货机行业,会带来什么变化?~YH
大数据·人工智能·django·fastapi·零售·tornado
初圣魔门首席弟子3 天前
FastAPI 框架完全指南
fastapi
Jacky-0084 天前
FastAPI+PostgreSQL+SQLAlchemy2.0+asyncpg异步+alembic数据迁移==项目开发
fastapi
郭老二4 天前
【Python】Web框架 FastAPI 详解
python·fastapi