一、生产场景真实故障复盘(来自线上调度系统事故台账)
全部来自工作室业务扩张阶段真实故障,记录现象、损失、根因、事后修复方案:
- 事故 1:单实例批量任务,单镜头 OOM 雪崩整批任务全部终止现象:单机 4090 运行 55 个漫剧镜头批量任务,其中某一个镜头分辨率过高触发 CUDA OOM,ComfyUI 进程崩溃;简易 for 循环脚本直接抛出异常退出,剩余 42 个未执行任务全部丢失,没有保存任务状态。损失:已跑完的 13 个镜头产物保留,剩余全部需要重新提交,浪费 2 小时 GPU 算力。 根因:同步串行脚本没有持久化任务状态,无状态机,单个任务异常直接导致主程序崩溃,没有断点续跑能力。
- 事故 2:无脑重试逻辑,幂等失效造成重复渲染,算力翻倍消耗现象:网络抖动导致 http 轮询超时,脚本直接重新提交同一个镜头任务;同一个分镜在 ComfyUI 队列提交两次,生成两份完全一样的视频片段,显卡负载翻倍。损失:GPU 算力无效消耗,输出目录产生重复文件,后续资产台账出现重复记录。 根因:缺少幂等键设计,重试直接重新提交 prompt,没有查询历史任务状态,不区分 “任务已经在执行” 和 “任务真正失败” 两种场景ComfyUI。
- 事故 3:任务僵尸挂起,任务标记为运行中,Worker 实际卡死现象:AnimateDiff 长视频片段推理,ComfyUI 内部节点卡死,既不报错也不完成;调度脚本一直轮询等待,任务永久处于 running 状态,队列后面所有任务阻塞排队。损失:工作站 GPU 空闲,但是队列全部卡住,人工介入才可以清理僵尸任务。 根因:缺少任务最大执行超时阈值,没有 Worker 进程健康状态探测机制。
- 事故 4:加急客户任务无法优先执行,队列 FIFO 先进先出阻塞业务现象:普通连载批量任务占据全部队列,临时客户加急修改镜头任务只能排队等待全部批量任务执行完毕;客户改稿交付延期。 根因:简易脚本没有优先级队列设计,任务无业务权重区分。
- 事故 5:多机多 Worker 部署,任务分配冲突,同一个 GPU 同时接收 2 个大显存任务现象:两台 4090 工作站接入调度系统,调度逻辑没有做显存水位检测,将两个 AnimateDiff 视频生成任务同时下发给同一台机器,显存打满 OOM。损失:两个任务全部失败,ComfyUI 服务重启。 根因:调度器只看 Worker 在线状态,不读取 GPU 显存实时占用,没有任务准入限流判断。
- 事故 6:异常类型不做区分,不可重试错误反复重试浪费算力现象:工作流 JSON 节点缺失自定义节点、LoRA 文件不存在,属于永久性业务错误;脚本依旧反复重试 3 次,每次都会加载模型,占用显卡资源。损失:连续失败重试,占用 GPU,阻塞其他正常任务。 根因:重试逻辑没有做异常分类,没有区分临时网络故障(可重试)和配置 / 资产缺失(不可重试)两类错误。
核心生产结论:调度系统的核心目标,不只是把任务发送给 ComfyUI,而是管控任务全生命周期状态,隔离故障,保护算力,保障业务优先级,支持故障降级,记录完整任务日志,对接下游资产管控系统。绘图、视频生成是算力执行层;调度编排是整个漫剧生产线的大脑。
二、系统整体架构、分层模型与状态机定义
2.1 系统分层架构
漫剧任务编排调度系统 ├─接入层(任务提交入口) │ ├─命令行提交脚本 │ ├─简易HTTP接口(支持外部业务调用,例如漫剧分镜解析模块直接推送任务) │ └─任务导入:读取shot_list.json分镜,批量生成任务集合 ├─调度核心层(调度器主进程,单实例运行) │ ├─持久化任务存储(sqlite本地数据库,不需要部署Redis,适合中小工作室) │ ├─优先级队列管理器:高优先级(客户加急改稿)、普通优先级(连载批量生产) │ ├─任务状态机管理器 │ ├─Worker注册与健康探测模块:定时轮询每个ComfyUI节点状态、GPU显存水位 │ ├─任务分发器:根据Worker负载、显存余量做任务分发 │ ├─异常分类器:区分临时异常/永久异常,控制重试次数 │ └─钩子回调模块:任务成功/失败触发回调,自动写入资产台账日志 ├─Worker执行层(多台工作站ComfyUI实例,横向扩展) │ ├─Worker‑A:4090‑24G,负责图生图分镜生成任务 │ ├─Worker‑B:4090‑24G,负责AnimateDiff图生视频任务 │ └─Worker‑C:3090‑24G,轻量任务:TTS、FFmpeg音视频拼接 └─下游对接层 └─成功任务产物 → 文件落盘 → 调用资产扫描脚本,写入资产元数据台账
2.2 任务完整状态机定义(核心,避免僵尸任务)
表格
| 状态 | 含义 | 状态流转说明 |
| pending | 待入队 | 任务已经入库,还未进入队列 |
| queued | 队列等待 | 已经进入优先级队列,等待分配空闲 Worker |
| running | 正在执行 | 已经下发到某个 ComfyUI Worker,正在推理 |
| completed | 执行成功 | 推理完成,产物已经保存 |
| failed_retryable | 失败‑可重试 | 临时故障(网络抖动、瞬时 OOM),还可以继续重试,不超过最大重试次数 |
| failed_final | 永久失败 | 不可重试错误,例如工作流缺失节点、LoRA 缺失、图片素材不存在,直接终止任务 |
| timeout_kill | 超时杀死 | 任务超过最大执行时间,判定僵尸任务,强制标记终止 |
状态流转:pending → queued → running → completedpending → queued → running → failed_retryable →(未超最大重试次数)→ queuedpending → queued → running → failed_retryable →(超过重试上限)→ failed_finalpending → queued → running → timeout_kill
关键约束:同一个 task_id 全局唯一,作为幂等键;不允许重复提交相同 task_id,避免重复生成任务ComfyUI。
2.3 Worker 节点元数据注册模型
每个 Worker 节点向调度器上报信息:
- Worker 唯一 ID、网络访问地址
http://ip:8188; - 硬件标签:支持任务类型
["img2img","i2v_video","ffmpeg"]; - GPU 总显存、当前已使用显存;
- 当前正在运行任务数量;
- 健康状态:healthy /busy/unhealthy;
- 任务最大并发数(漫剧生产环境建议单 Worker 并发 = 1,AI 大显存推理不适合多并发)。
2.4 工程目录完整结构
manju_scheduler/ ├─config/ │ ├─scheduler_config.yaml # 调度全局配置:超时时间、最大重试次数、优先级、worker注册列表 │ └─task_hook_config.yaml # 任务成功失败钩子回调配置 ├─db/ │ └─task_database.db # sqlite持久化任务数据库 ├─workflow_template/ # ComfyUI API工作流模板,调度器读取后动态注入参数 │ ├─img2img_api.json │ └─animate_diff_api.json ├─src/ │ ├─db_model.py # sqlite数据库模型,任务表、worker注册表 │ ├─state_machine.py # 任务状态机逻辑 │ ├─worker_health.py # worker健康探测、GPU显存读取 │ ├─task_dispatcher.py # 任务分发器逻辑 │ ├─comfy_worker_client.py # 封装访问各个远端ComfyUI节点客户端,幂等键支持 │ ├─scheduler_main.py # 调度器主循环入口 │ ├─task_hook.py # 任务完成钩子,对接资产台账系统 │ └─cli_submit.py # 命令行提交任务工具,可以读取shot_list.json批量导入任务 ├─test_case/ # 测试用例:模拟网络故障、模拟OOM、模拟永久错误 │ ├─test_retry_logic.py │ └─test_worker_down.py └─tools/ └─export_task_log.py # 导出全部任务执行日志csv,用于复盘统计
三、环境部署说明
硬件适配
- 调度器进程对显卡无要求,可以运行在普通办公电脑;
- Worker 节点为运行 ComfyUI 的 GPU 工作站,可以局域网多台机器;
- 单 Worker 推荐:RTX3090/4090‑24G;12G 显卡适合图生图,不适合大批量 AnimateDiff 视频任务。
Python 依赖环境
conda create -n manju_scheduler python=3.10 -y conda activate manju_scheduler pip install requests pyyaml sqlalchemy psutil pandas tqdmGPUtil
- sqlalchemy:sqlite 数据库 ORM 封装,管理任务状态持久化;
- GPUtil:Worker 节点读取 GPU 显存占用;
- pandas:导出任务执行统计日志 csv,用于线上故障复盘。
Worker 端 ComfyUI 启动必须开启监听局域网:
python main.py --listen 0.0.0.0 --port 8188 --force‑fp16,防火墙开放 8188 端口。
四、核心工程代码实现
4.1 db_model.py 数据库 ORM 模型
# src/db_model.py from sqlalchemy import create_engine, Column, Integer, String, Float, Text, DateTime, Boolean from sqlalchemy.orm import declarative_base from datetime import datetime Base = declarative_base() class TaskItem(Base): __tablename__ = "task_table" id = Column(Integer, primary_key=True, autoincrement=True) task_id = Column(String(64), unique=True, nullable=False) #全局幂等键 priority = Column(Integer, default=1) # 1普通,10高优先级加急 task_type = Column(String(32)) # img2img / i2v_video status = Column(String(32), default="pending") max_retry = Column(Integer, default=2) current_attempt = Column(Integer, default=0) assign_worker_id = Column(String(64), nullable=True) workflow_json = Column(Text) input_params = Column(Text) output_path = Column(String(512), nullable=True) error_msg = Column(Text, nullable=True) create_time = Column(DateTime, default=datetime.now) start_time = Column(DateTime, nullable=True) finish_time = Column(DateTime, nullable=True) timeout_seconds = Column(Integer, default=600) class WorkerRegister(Base): __tablename__ = "worker_table" id = Column(Integer, primary_key=True, autoincrement=True) worker_id = Column(String(64), unique=True) endpoint = Column(String(256)) tag_support_type = Column(String(256)) max_concurrent = Column(Integer, default=1) gpu_total_vram = Column(Float) gpu_used_vram = Column(Float, default=0.0) health_status = Column(String(32), default="unhealthy") last_heartbeat = Column(DateTime, default=datetime.now) engine = create_engine("sqlite:///../db/task_database.db", connect_args={"check_same_thread":False}) def init_db(): Base.metadata.create_all(engine) if __name__ == "__main__": init_db() print("数据库初始化完成")
4.2 state_machine.py 任务状态机逻辑
# src/state_machine.py from enum import Enum class TaskStatus(Enum): PENDING = "pending" QUEUED = "queued" RUNNING = "running" COMPLETED = "completed" FAILED_RETRYABLE = "failed_retryable" FAILED_FINAL = "failed_final" TIMEOUT_KILL = "timeout_kill" def judge_task_next_status(current_status, current_attempt, max_retry, is_temp_error:bool): """ 状态流转判断 :param current_status: 当前任务状态 :param current_attempt: 当前重试次数 :param max_retry: 最大允许重试次数 :param is_temp_error: True=临时异常可重试 False=永久异常不可重试 :return: next_status """ if current_status == TaskStatus.RUNNING.value: if is_temp_error: if current_attempt < max_retry: return TaskStatus.FAILED_RETRYABLE.value else: return TaskStatus.FAILED_FINAL.value else: return TaskStatus.FAILED_FINAL.value elif current_status == TaskStatus.FAILED_RETRYABLE.value: return TaskStatus.QUEUED.value else: return current_status def is_task_timeout(start_timestamp, timeout_sec): from datetime import datetime now = datetime.now().timestamp() if start_timestamp is None: return False return (now - start_timestamp) > timeout_sec
4.3 comfy_worker_client.py Worker 客户端,幂等键封装
# src/comfy_worker_client.py import requests import uuid import json from io import BytesIO from PIL import Image class ComfyWorkerClient: def __init__(self, endpoint): self.endpoint = endpoint.rstrip("/") self.client_id = str(uuid.uuid4()) def upload_image(self, pil_img:Image.Image): buf = BytesIO() pil_img.save(buf, format="PNG") fname = str(uuid.uuid4())+".png" resp = requests.post(f"{self.endpoint}/upload/image", files={"image":(fname, buf.getvalue())}, timeout=120) resp.raise_for_status() return fname def submit_workflow_idempotent(self, workflow_dict, idempotency_key): payload = { "prompt": workflow_dict, "client_id": self.client_id, "idempotency_key": idempotency_key } res = requests.post(f"{self.endpoint}/prompt", json=payload, timeout=120) res.raise_for_status() return res.json()["prompt_id"] def get_history(self, prompt_id): r = requests.get(f"{self.endpoint}/history/{prompt_id}", timeout=120) return r.json() def check_worker_health(self): try: r = requests.get(f"{self.endpoint}/system_stats", timeout=10) return r.status_code == 200 except Exception: return False
4.4 worker_health.py Worker 健康探测,读取显存水位
# src/worker_health.py from sqlalchemy.orm import sessionmaker from db_model import engine, WorkerRegister import GPUtil import requests from datetime import datetime Session = sessionmaker(bind=engine) def probe_single_worker(endpoint): try: resp = requests.get(f"{endpoint}/system_stats", timeout=8) data = resp.json() gpu_info = data["gpu"] used_vram = gpu_info["used_vram"] / 1024 total_vram = gpu_info["total_vram"] / 1024 return {"health":True,"used_vram_gb":used_vram,"total_vram_gb":total_vram} except Exception: return {"health":False,"used_vram_gb":0,"total_vram_gb":0} def worker_heartbeat_scan(): session = Session() all_workers = session.query(WorkerRegister).all() for w in all_workers: res = probe_single_worker(w.endpoint) w.last_heartbeat = datetime.now() if res["health"]: w.health_status = "healthy" w.gpu_used_vram = res["used_vram_gb"] else: w.health_status = "unhealthy" session.commit() session.close() def get_available_worker(task_type:str, require_min_vram_gb:float=16): """筛选健康、显存充足、支持对应任务类型的worker""" session = Session() candidates = [] workers = session.query(WorkerRegister).filter(WorkerRegister.health_status=="healthy").all() for w in workers: support_list = w.tag_support_type.split(",") if task_type not in support_list: continue remain_vram = w.gpu_total_vram - w.gpu_used_vram if remain_vram >= require_min_vram_gb: candidates.append(w) session.close() return candidates
4.5 scheduler_main.py 调度器主循环(简化版本)
# src/scheduler_main.py import time import json import uuid from sqlalchemy.orm import sessionmaker from db_model import engine, TaskItem from state_machine import judge_task_next_status, TaskStatus, is_task_timeout from worker_health import worker_heartbeat_scan, get_available_worker from comfy_worker_client import ComfyWorkerClient Session = sessionmaker(bind=engine) MAX_LOOP_SLEEP = 1.5 def main_scheduler_loop(): print("漫剧任务调度器启动...") while True: worker_heartbeat_scan() session = Session() # 处理超时僵尸任务 running_tasks = session.query(TaskItem).filter(TaskItem.status == TaskStatus.RUNNING.value).all() for t in running_tasks: if is_task_timeout(t.start_time.timestamp(), t.timeout_seconds): t.status = TaskStatus.TIMEOUT_KILL.value t.error_msg = "任务执行超时,判定僵尸任务,强制终止" # 获取待排队任务,高优先级优先 pending_tasks = session.query(TaskItem).filter( TaskItem.status.in_([TaskStatus.PENDING.value, TaskStatus.FAILED_RETRYABLE.value]) ).order_by(TaskItem.priority.desc()).all() for task in pending_tasks: task.status = TaskStatus.QUEUED.value session.commit() # 根据任务类型分配worker req_vram = 10.0 if task.task_type=="img2img" else 18.0 avail_workers = get_available_worker(task.task_type, require_min_vram_gb=req_vram) if len(avail_workers) <= 0: continue selected_worker = avail_workers[0] task.assign_worker_id = selected_worker.worker_id task.status = TaskStatus.RUNNING.value task.start_time = time.localtime() task.current_attempt +=1 session.commit() # 下发任务给worker try: client = ComfyWorkerClient(selected_worker.endpoint) wf_dict = json.loads(task.workflow_json) prompt_id = client.submit_workflow_idempotent(wf_dict, idempotency_key=task.task_id) # 此处省略轮询逻辑,生产环境建议拆分为独立的结果收集协程 print(f"task {task.task_id} 下发至 worker {selected_worker.worker_id} prompt_id={prompt_id}") except Exception as e: is_temp_err = "Connection" in str(e) or "timeout" in str(e).lower() next_st = judge_task_next_status(task.status, task.current_attempt, task.max_retry, is_temp_err) task.status = next_st task.error_msg = str(e) session.commit() session.commit() session.close() time.sleep(MAX_LOOP_SLEEP) if __name__ == "__main__": main_scheduler_loop()
4.6 scheduler_config.yaml 配置样例
global: max_retry: 2 task_timeout_img2img: 480 task_timeout_video: 900 scheduler_loop_sleep: 1.5 worker_list: - worker_id: "worker‑4090‑01" endpoint: "http://192.168.1.100:8188" tag_support_type: "img2img,i2v_video" max_concurrent: 1 - worker_id: "worker‑3090‑01" endpoint: "http://192.168.1.101:8188" tag_support_type: "img2img" max_concurrent: 1
五、生产级调优、故障分类、运维策略
5.1 任务资源划分与显存阈值基准
表格
| 任务类型 | 最低剩余显存要求 | 任务超时阈值 | Worker 适配 |
| ComfyUI 图生图 img2img | ≥10GB | 480 秒 | 全部 24G 显卡,12G 显卡可兼容 |
| AnimateDiff 漫剧动态片段 i2v_video | ≥18GB | 900 秒 | 优先 24G RTX4090;12G 显卡极易 OOM |
生产强制约束:单 Worker 最大并发固定等于 1;AI 图生视频任务多并发会直接显存溢出,不要追求多任务并行。
5.2 异常分类策略(决定是否允许重试)
✅允许重试(临时异常 failed_retryable)
- 网络连接超时、连接拒绝;
- ComfyUI 瞬时 CUDA OOM,单次偶然显存溢出;
- Worker 短暂心跳丢失,后续恢复健康;
❌禁止重试(永久异常 failed_final)
- 工作流 JSON 节点缺失,缺少自定义节点;
- LoRA/Checkpoint 模型文件不存在;
- 输入源图片素材文件丢失;
- 提示词、参数严重格式错误;
如果属于永久异常,任务直接终止,写入 error_msg 日志,人工排查修复素材配置之后,重新新建 task_id 提交任务,不允许旧 task_id 反复重试。
5.3 Worker 运维实操规范
- Worker 节点不要同时跑其他大显存程序,避免抢占 GPU 显存;调度器读取的显存是 ComfyUI 上报的系统 GPU 显存,其他软件占用显存也会被统计;
- Worker 节点 ComfyUI 设置
--lowvram仅用于 12G 显卡;24G 显卡不开启 lowvram,避免推理速度下降; - Worker 出现 unhealthy 状态:优先检查工作站是否锁屏、显卡驱动崩溃、ComfyUI 进程是否意外退出;
- 调度器进程独立运行,不和 ComfyUI 抢显卡资源,可以放在普通办公电脑,只负责分发任务、状态管理。
5.4 和上游下游系统对接完整链路
完整打通前面三篇全部工程模块,形成完整漫剧全链路闭环:剧本shot_list.json分镜脚本 → cli_submit.py批量导入调度系统生成任务 →调度器分发到各个Worker ComfyUI实例 → 图生图生成分镜产物 / AnimateDiff生成动态片段 →任务成功钩子触发,调用资产扫描脚本,写入资产元数据台账 → TTS音频、FFmpeg合成成片 → Release基线冻结 + SHA256审计清单 → 夸克网盘基线包冷备归档交付
5.5 日志与复盘机制
调度系统数据库保存全部任务的:task_id、优先级、worker 分配、开始结束时间、重试次数、错误信息。通过export_task_log.py导出 csv 日志,业务出现成片质量问题、算力异常消耗时,可以回溯每一个镜头全部执行记录,定位是参数问题、Worker 硬件问题还是调度逻辑问题。
六、网盘配套资源说明
网盘链接:https://pan.quark.cn/s/4c0c6c64b463包内完整资源清单:
- 整套调度系统完整 Python 源码,包含上面全部模块;
- yaml 配置模板、sqlite 初始化脚本;
- ComfyUI 多 Worker 部署说明文档,防火墙、局域网访问配置 SOP;
- 测试用例脚本:模拟网络异常、模拟 OOM 故障;
- shot_list.json 批量导入任务示例;
- 任务日志导出脚本,csv 样例;
- 故障排查手册:Worker 离线、任务僵尸挂起、幂等重复任务处理步骤;
- docker‑compose 一键部署脚本,适合有容器环境的团队。
七、总结与扩展方向
这套任务编排调度系统解决单机脚本在业务扩张之后的一系列致命工程缺陷:持久化状态机、幂等任务、Worker 健康探测、显存水位准入、异常分类重试、任务优先级。调度系统不是面向互联网大厂分布式集群的复杂重型系统,而是面向中小漫剧工作室,基于 sqlite 本地数据库,不需要部署 Redis 消息队列,普通硬件即可落地。
很多团队把全部精力放在绘图、视频模型调参,忽略任务调度编排;当单集镜头数量上涨、多工作站协同之后,没有调度编排会带来大量隐性成本:算力浪费、任务丢失、加急业务阻塞、故障难以排查。调度系统作为整个生产线的大脑,和推理管线、资产管控系统三者缺一不可。