arq 在工作流中的使用 & 任务打断机制
核心认知:两个不同层面的"打断"
项目里有两种完全不同的"打断",容易混在一起,先分清楚:
| arq 取消 | LangGraph 中断 | |
|---|---|---|
| 触发方 | 外部主动调用 job.abort() |
图节点内部主动调用 interrupt() |
| 语义 | "别跑了,不要这个结果了" | "先停一下,等用户确认再继续" |
| 谁在做决定 | 调用方(比如用户点了停止按钮) | 图自己(比如需要人工审批的节点) |
| 任务最终状态 | 被打断,可能有不完整的 checkpoint | job 正常 complete,checkpoint 完整落在中断点 |
| 后续动作 | 通常任务作废,重新发起 | 调 /resume,同一个 session 继续跑 |
| 判断依据 | arq 的 JobStatus |
snapshot.next / snapshot.interrupts |
一句话区分:arq 取消是"外部强行叫停一个正在跑的协程";LangGraph 中断是"图自己决定暂停,压根没被打断,只是主动睡着了"。
一、arq 在工作流中的使用
1. 提交任务:入队,不同步执行
job = await redis.enqueue_job("run_rag_chat_task", payload, session_id, _job_id=session_id)
API 进程收到请求后不直接跑耗时逻辑,丢进 Redis 队列,立刻返回 PENDING,真正执行在独立的 worker 进程里。
2. _job_id 天然去重
同一个 job_id 还在排队/执行中时,enqueue_job 直接返回 None。
这一行替代了早期手写 dispatcher 里那套 TaskRecord + 状态检查的逻辑。
3. 状态查询:直接用 arq 原生状态
job = Job(session_id, redis)
status = await job.status() # deferred / queued / in_progress / complete
不再自己维护一层 PENDING/RUNNING/SUCCESS 的映射表,少一层容易漂移、忘记同步的翻译逻辑。
4. worker 独立进程配置
API 进程和 worker 进程各自独立部署,靠同一个 Redis 通信,互不感知细节。
二、arq 的取消问题(技术层面)
job = Job(session_id, redis)
aborted = await job.abort()
场景:用户点"停止生成",任务还在排队或正在执行,需要强行打断。
- 排队中:直接从队列摘除,worker 永远拿不到它
- 执行中:arq 给对应的
asyncio.Task发cancel(),asyncio 在下一个await
点抛出CancelledError。因为graph.ainvoke(...)内部全是await,
能被自然打断——前提是allow_abort_jobs=True
这一层跟图内部在做什么完全无关,纯粹是外部叫停一个正在跑的协程。
三、LangGraph 中断(业务层面)
图执行到需要人工审批/确认的节点(rag_interrupt_node)时,主动调用interrupt(),把当前状态存进 checkpointer,然后暂停——不是被外部打断,是图自己"睡"在那里。
返回告诉调用方:"这次 ainvoke 是正常跑完的,还是卡在中断点"。
与任务被取消是两回事:中断时任务在 arq 层面依然是正常 complete(没被 abort,没抛异常),只是业务上还没走到终点。
恢复LangGraph中断
不是"续上旧任务",是"提交一个新 job",复用同一个 session_id 作为新的 _job_id,重新提交一次执行。
LangGraph 靠 checkpointer 里存的状态知道该从哪继续,不需要 arq 这边保留任何"上次任务"的记忆。
四、一个容易漏掉的边界情况
如果任务正处于 LangGraph 中断状态(等用户确认),这时候用户点了"取消"会怎样?
这时候没有正在执行的 job 可以 abort——因为它已经 complete 了(只是业务上停在中断点)。调 job.abort() 大概率返回 False,因为 arq 认为这个 job 已经跑完了。
正确处理方式:这种场景下的"取消"应该是一个业务操作,走/resume 接口,传 decision: "cancel"即可。