ICHIKI
Log

生成中途离开:从发现数据丢失到改成后台任务的完整改动过程

一次规划要跑 20~190 秒,用户中途离开是常事。这篇按时间顺序记录这件事的完整处理过程:怎么发现行程一条都没存下来、第一次提前落库改到什么程度、剩下的时间窗口为什么补不上、把生成搬进后台任务之后怎么验证、以及上线后进详情页看不到攻略又补了什么。

约 6 分钟 Agent架构可靠性SSE工程实践
关键词:SSE、asyncio 任务、中断恢复、落库时机、任务查询

需求是从一句提问开始的

验收的时候你问我:「现在我们的页面就算用户生成中途离开,页面回来内容还能正常吗,这个是需要的?」

我不知道答案,先去读生成链路。/api/chat 返回一个 StreamingResponse,生成过程写在生成器里:

async def gen():
    async for ev in eng.plan_stream(spec):
        yield ev.sse()
    store.save(it)

return StreamingResponse(gen(), media_type="text/event-stream")

store.save 在循环结束之后。Starlette 在客户端断开时会取消响应生成器,这一行执行不到。

先量一下损失

本地起服务,用 curl 的 -m 充当断开开关:

$ curl -sN -m 8 -X POST /api/chat -d '{"departure":"上海","arrivals":["黄山"],"days":2}'
curl: (28) Operation timed out
$ curl -s /api/itineraries
2

断开之前库里有 2 条,断开之后还是 2 条。那 8 秒里收到 9 个 thinking_step 事件,行程没有写进去。

第一次改动:把落库提前

改动不大:第一次 itinerary_delta 到达时就把行程存下来,同时把 itinerary_id 塞进同一事件,前端拿到之后把地址换成 /plan/{id}。存的时候把已经收到的 AI 消息一起写进 chat,否则用户回来看到的是一个没有回复的空对话。

测出来第 20 秒左右出现 delta,落库,随即掐断连接,详情页能打开,对话 2 条。

接着换了另一个时间点:第 55 秒断开。那次网关慢,delta 还没发出来,库里 0 条。

剩下的时间窗口为什么补不上

整条链路的顺序是这样:

  1. 编排模型产出行程骨架,20~60 秒
  2. 补全景点知识、生成攻略
  3. 落库

第 1 步跑的时候还没有行程对象,也就没有能提前存的东西。用户在这个窗口里离开,前面的改动帮不上忙。这一段占整条链路的时间最多,反而没有任何兜底。

搬成后台任务

你确认要做完整版之后,我把它拆成三层:

  • jobs.py:一个 Job 保存事件列表、状态和归属,存在进程内的字典里,保留 1 小时
  • 生成的逻辑从路由里搬出来,写成模块级协程 _run_plan_job(job, spec, user_prompt, owner_id),事件用 job.push(ev) 收进列表
  • 路由起任务,然后旁听
job = _jobs.create(owner_id, user_id, client_ip(request))
task = asyncio.create_task(_run_plan_job(job, spec, user_prompt, owner_id))
_jobs.track(task)

async def tail():
    i = 0
    while True:
        while i < len(job.events):
            yield job.events[i].sse()
            i += 1
        if job.done:
            return
        await asyncio.sleep(0.05)

_run_plan_job 不读连接,往列表里推事件就算完事。连接断了 tail() 被取消,任务继续跑到 finally: job.done = True。

track 里用 set 存任务的强引用,add_done_callback 再移出。asyncio 的文档提过,没有引用的 task 可能被回收。

Job 存了五个字段:id、owner、itinerary_id、events、done,外加 error 和结束时间。对外三个接口:

接口用途
GET /api/jobs/{id}按任务查状态,完成后返回 itinerary_id
GET /api/jobs当前身份还在跑的任务,刷新页面时用
GET /api/jobs/by-itinerary/{id}按行程反查任务,进详情页时用

响应头另外带一个 X-Job-Id,前端在流式响应里就能拿到任务号。

改完跑测试,一个既有用例挂了。它断言攻略消息的块里有 poi 类型,而攻略那块之前已经改成渲染卡片的 cards 块。断言跟着改掉,顺便把「不许再出现 day/poi/traffic 文字块」也写进去。后端总数 185。

验证的时候先被两件事误导了一下。

第一次轮询 /api/itineraries 一直没有数据,我以为任务没跑起来。实际是 curl 每次请求都拿新 cookie,而匿名行程按 guest 身份归属,用另一次请求当然查不到。换成 -c/-b 共用一个 cookie 文件之后,第 21 秒就查到了。

第二次直接用 curl 请求生成接口,返回的是配额已用完。前面几轮测试已经把匿名那 1 次耗掉了,清掉 usage_log 才继续。

走通之后的数字:5 秒断开,退出码 28,期间收到 9 个事件;15 秒后行程进库,2 天 6 个地点;再等 45 秒,攻略和「猜你想问」也写进去了,对话 3 条。

图 1 第 5 秒断开连接,后台继续跑:第 15 秒行程落库,第 60 秒攻略补齐
图 1 第 5 秒断开连接,后台继续跑:第 15 秒行程落库,第 60 秒攻略补齐
图 2 改动后的三层:请求先做配额与注入检查,任务跑完整条链路,连接负责旁听
图 2 改动后的三层:请求先做配额与注入检查,任务跑完整条链路,连接负责旁听

上线后补的第二处:进详情页看不到攻略

部署完你报了一个新问题:生成到一半点进 /plan/{id},攻略一直不出来,不刷新就一直不出现。

前半段写好了,后半段没接上。行程先落库,所以地址能切过去;详情页打开之后读一次历史就结束,而攻略是任务最后写进去的。中间那几十秒没有东西去问任务还在不在跑。

加了 GET /api/jobs/by-itinerary/{id},按行程 id 查对应的任务状态,任务结束之后仍保留一小时,用来覆盖「刚好在你进入页面时结束」的情况。Planner 里加了一个 effect:进入时查一次,任务没结束就每 2 秒查一次,结束后重新拉一遍历史。

重新拉之前加了两个条件,避免把正在输入的对话冲掉:确实观察到任务在跑,或者任务结束不到 60 秒;并且当前对话里没有攻略。

实测:进入时 2 条气泡、没有攻略,36 秒后自己变成 3 条,期间没有刷新也没有点任何东西。

现在的状态

事件放在内存,保留 1 小时。进程重启会丢队列,行程不受影响,因为落库发生在任务里。

多副本部署要改两处:事件列表换成共享存储,asyncio.sleep(0.05) 的轮询换成订阅。

连点两次会起两个任务,现在由配额挡着,没有做幂等键。