生成中途离开:从发现数据丢失到改成后台任务的完整改动过程
一次规划要跑 20~190 秒,用户中途离开是常事。这篇按时间顺序记录这件事的完整处理过程:怎么发现行程一条都没存下来、第一次提前落库改到什么程度、剩下的时间窗口为什么补不上、把生成搬进后台任务之后怎么验证、以及上线后进详情页看不到攻略又补了什么。
关键词: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 条。
剩下的时间窗口为什么补不上
整条链路的顺序是这样:
- 编排模型产出行程骨架,20~60 秒
- 补全景点知识、生成攻略
- 落库
第 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 条。


上线后补的第二处:进详情页看不到攻略
部署完你报了一个新问题:生成到一半点进 /plan/{id},攻略一直不出来,不刷新就一直不出现。
前半段写好了,后半段没接上。行程先落库,所以地址能切过去;详情页打开之后读一次历史就结束,而攻略是任务最后写进去的。中间那几十秒没有东西去问任务还在不在跑。
加了 GET /api/jobs/by-itinerary/{id},按行程 id 查对应的任务状态,任务结束之后仍保留一小时,用来覆盖「刚好在你进入页面时结束」的情况。Planner 里加了一个 effect:进入时查一次,任务没结束就每 2 秒查一次,结束后重新拉一遍历史。
重新拉之前加了两个条件,避免把正在输入的对话冲掉:确实观察到任务在跑,或者任务结束不到 60 秒;并且当前对话里没有攻略。
实测:进入时 2 条气泡、没有攻略,36 秒后自己变成 3 条,期间没有刷新也没有点任何东西。
现在的状态
事件放在内存,保留 1 小时。进程重启会丢队列,行程不受影响,因为落库发生在任务里。
多副本部署要改两处:事件列表换成共享存储,asyncio.sleep(0.05) 的轮询换成订阅。
连点两次会起两个任务,现在由配额挡着,没有做幂等键。