← 返回文章选择

MediAC 的报告有了版本,它读的是哪一轮

报告改成后台任务后,快照读的是哪一轮、进程内任务的边界、前端为何还在等。

报告改成后台任务以后,接口先交回 job id,页面查询状态,再读生成结果。上一份报告还在,新任务继续跑,这比每次把整份回答堵在聊天请求里好处理。这套“先受理、再查询”在 HTTP 里有现成的语义:RFC 9110 的 202 Accepted 就是请求已受理、处理未完成,响应“有意不作承诺”,但要描述当前状态,并给出一个 status monitor。但用户接着补充信息时,问题来了:这份报告到底包括哪一轮?

三份记录,三个保存时点

记录 内容 什么时候写
ReportJob 任务是否完成 调度那一刻
ReportVersion 产出 生成完成后
SessionIntake — 跟着对话更新,不固定于调度那一刻

三份记录有关联,保存时点却不同。九月对照现存的 backend/main.py 与 backend/db.py,调度时没有把输入全部冻结下来。

schedule:记录任务、触发方式和 scheduled_from_turn
执行任务:先标 running,再读取消息与当前 intake
生成完成:保存 report、query_snapshot、intake_snapshot

版本号回答不了“含不含第四轮”

假设第三轮后调度,第四轮信息在任务读取前已经写入,执行时就可能读到新 intake;任务先读完的话,又不会包含后来的更新。scheduled_from_turn 留了触发轮次,但当前消息查询没有按这个字段截断。版本号本身回答不了“是否包含第四轮”。

消息查询还有一个被变量名掩盖的细节:它叫 recent_user_messages,实际按时间升序再 LIMIT。会话长了,取到的是最早的有限几条用户消息,不是最新几条。PostgreSQL 的 SELECT 文档讲 LIMIT 时说得很直白:取哪一段完全由 ORDER BY 决定,没有唯一的排序就会拿到不可预测的子集。取哪一段看 ORDER BY,跟函数名无关。

于是两条输入不一定停在同一轮:消息取哪一段由这条查询决定,intake 读的是当时那一份。快照把两条都留了底,可以回查这次实际选了什么,但它记的是执行时读到的内容,不是调度时的输入。

缺的是一个区间,不只是版本号

“快照”这个词在数据库里是绑定时刻的。PostgreSQL 的 Read Committed 隔离级别下,一条 SELECT 看到的是它开始那一刻的数据,同一事务里连续两条 SELECT 也可能看到不同的数据。这里的消息和 intake 是两次独立读取,各有各的时刻,中间没有共同基准。

调度系统处理这个问题的办法,是把“这次跑哪段数据”写成显式字段。Airflow 的 Dag Run 文档给每次运行分配一个 data interval,logical date 是区间起点而不是实际执行时刻,运行通常在区间结束之后才调度。对照一下,scheduled_from_turn 只记了起点那一侧。我觉得报告该覆盖到第几轮,应该写成两端都明确的区间,再让消息查询和 intake 读取都按它来。

报告从调度到读取输入、生成及保存版本快照的时点关系

后台任务之外,还有一处等待

当前任务由 asyncio.create_task 在 API 进程内启动,用字典追踪正在运行的任务。数据库有 queued/running 状态,不等于已经有独立 worker 或进程重启后的自动恢复。同一会话已有 queued/running job 时,调度返回现有任务,不会每触发一次就另开一份。

进程内起任务,边界在哪

asyncio.create_task 这条路有两个官方点名的坑。Python 文档要求保存返回的 task 引用,因为事件循环只持弱引用,没人引用的 task 可能跑到一半就被回收;推荐做法是放进集合,完成后用 add_done_callback 移除。所以字典追踪有必要,用完不清理也是问题。第二个坑是失败:从不被 await 的 task 出错时,异常只在被回收时打一条 “Task exception was never retrieved” 日志。数据库里有 queued/running 状态,失败也未必看得见。

换成真正的独立 worker,要多付的代价也清楚。FastAPI 的 BackgroundTasks 说明建议,不需要同一进程、不共享内存变量的重任务,用 Celery 这类工具,代价是要配一个 RabbitMQ 或 Redis 这样的消息队列。Celery 的介绍里,broker 在客户端和 worker 之间传消息,独立 worker 进程常驻盯着队列。

重启后自动恢复也有代价。Celery 的任务文档说明,默认执行前就确认消息;开 acks_late 改成执行后确认,worker 中途崩溃时任务可能被执行多次,所以任务得幂等。这和“同一会话复用已有 job”是连在一起的问题,要一起设计。

成熟队列的状态表也可以拿来对照。RQ 的 job 文档有 queued、started、finished、failed、stopped 等状态,stopped 表示 worker 被停掉,而且不会自动重试;还有 last_heartbeat 用来判断任务是否还活着。只有 queued/running 两档时,一个显示 running 的任务死没死,是看不出来的。

不想加组件,现有的 PostgreSQL 也能撑一段:

需求 原语 代价
多个消费者抢任务 FOR UPDATE SKIP LOCKED 文档明说视图不一致,不适合一般用途
同一会话只能有一个进行中的 job 部分唯一索引,如 WHERE status IN ('queued','running') 只约束满足谓词的行

用索引兜住,“先查有没有 running job,没有再插入”在并发下就不会开出两份。Stripe 的幂等请求文档还提醒了两个边界:key 有保留期限,以及参数和原请求不一致时要报错。放到这里,就是终态之后还复用不复用,补了新信息的触发算不算同一个任务。

前端也还没完全放开。现存 App.tsx 的发送流程拿到报告 job 后会 await pollJob(...),直到轮询返回才在 finally 里把 loading 设回 false,而发送按钮受 loading 控制。后端任务可以独立跑,这个页面的正常发送路径仍会等报告轮询。这是九月整理时看到的未完成处,不能说整条交互已经完全解耦。

接下来要分开的两件事

后续要改,我会把两件事分开:

  1. 先定义报告应覆盖哪一刻的输入;
  2. 再让前端聊天的 loading 只对应聊天请求。

第二件可以借 TanStack Query 的后台取数指示的思路:status === 'pending' 只表示首次硬加载,isFetching 单独表示正在取数,包括后台刷新。拆成两个状态量,发送按钮只看聊天请求,报告生成只做提示。要不要引入这个库另说,模式本身是通用的。

手动生成、轮数触发和红旗触发是教学流程的调度规则,不说明医学判断可靠。查一份旧报告时,先找对应 job,再看实际保存的输入快照,才知道当时哪些资料进入了生成。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。