
柔性车间调度问题也就是常说的FJSPFlexible Job Shop Scheduling Problem是我这段时间一直在折腾的方向。传统数学规划方法在小规模实例上还行规模一旦上去求解时间直接失控启发式算法又特别依赖人工规则设计换一个车间场景就得重新调。所以我转到深度强化学习路线上来——具体说是用PyG把调度实例建模成图让GNN自动提取工序和机器之间的关系再用Stable-Baselines3里的PPO算法训练调度策略。这篇文章把整个项目从零到一的复现链路完整记录下来包括数据集格式解析、二部图构造、Gym环境封装、PPO训练参数以及我踩过的不少坑。如果你正在做车间调度优化、智能制造执行系统或者想入门图强化学习方向这篇文章应该能帮你省不少时间。1. 项目背景为什么是“图神经网络强化学习”这条路线1.1 FJSP到底在解什么问题FJSP的经典定义是车间里有若干台机器仓库里有一批待加工工件每个工件由多道工序组成每道工序可以在多台机器上加工但在不同机器上的加工时间可能不一样。调度的任务是给每道工序选一台合适的机器并排出合理的开工顺序让整个批次的最大完工时间Makespan尽量小。注意这里的“柔”字很关键。如果每道工序只能在一台机器上加工那是传统作业车间调度JSPABC工序锁定机器难度低一截。一旦机器选择放开问题就变成了两个子问题机器选择machine selection和工序排序operation sequencing。这两个子问题互相耦合你选了一台空闲机器可能会让某道关键工序堵住整条产线你让某道工序优先开工又可能占掉后面更重要工序的资源。现实场景里这个问题的规模很吓人。就拿公开数据集里中等规模的mk01来说10个工件、6台机器每道工序平均有3到5台可选机器穷举所有可能调度方案的组合数早就超过天文数字。生活化类比就是你同时接到好几个外卖订单每个订单有多个制作步骤每个步骤可以丢给不同厨师做但每个厨师对不同步骤的熟练度不一样。你既要决定每个订单先做哪一步又要决定这一步丢给谁目标是让所有订单最快出餐。稍微复杂一点人手就完全不够用了。所以FJSP本质上是一个组合优化问题解空间巨大且带强约束。传统精确求解器比如CPLEX、OR-Tools的CP-SAT在几十个工件的规模内还能打再往上就只能在启发式算法里打转了。1.2 为什么选GNNRL这套组合我之前试过遗传算法和OR-Tools在小规模实例上效果都不错但有两个痛点始终绕不开一是人工设计交叉变异算子非常繁琐换个约束条件就要改一大堆代码二是这类算法每次求解都要从零开始算没法利用“相似问题”的求解经验换一批订单又要重新跑一遍。深度强化学习提供了一条完全不同路线把所有调度决策过程看成马尔可夫决策过程让智能体每一步做一个局部决策——选哪道工序、分配给哪台机器——然后环境返回奖励算法通过PPO这类策略梯度方法去优化长期收益。训练好一个模型后推理时就只需走一遍决策网络速度非常快而且类似结构的实例可以直接泛化不用重新求解。那为什么非要用图神经网络因为FJSP实例天然可以建模成二部图一边是工序节点一边是机器节点如果某道工序能在某台机器上加工这两个节点之间就有一条边边的属性就是加工时间。GNN的消息传递机制可以逐层聚合邻居节点信息自动学到“这台机器负载偏高”“这道工序是卡脖子节点”这类隐含特征省掉了大量人工特征工程。至于为什么选PyG和Stable-Baselines3这两个库原因很务实PyG对二部图的构建、batch采样、GCN/GAT等算子封装得非常完善比自己写消息传递省太多精力SB3的PPO实现经过大量项目验证API统一日志回显齐全比从零撸一个强化学习算法靠谱得多。整个项目其实就是“Gym环境 PyG图特征提取 SB3策略优化”三段式拼装拆开来看每一步都不神秘。1.3 整体架构一览在动手写代码之前先把这个项目的模块划分明确下来。我习惯画一张模块职责表写代码时按表推进不容易乱组件职责核心依赖数据解析器读取公开FJSP实例文件转换成Python对象numpy, pandas图构建器把调度实例和当前进度转成PyG的Data对象torch_geometric调度环境管理状态转移、机器占用、工序进度、奖励生成Gymnasium策略网络接收图状态输出工序与机器的决策分布PyG, torch.nnRL训练框架更新策略参数、保存模型、日志记录Stable-Baselines3评估与可视化跑完整调度、算Makespan、画甘特图matplotlib这套架构的好处是每一层都可以单独替换测试。比如你想把PPO换成A2C只需要改一行模型定义想把GCN换成GAT只需要在策略网络里换一个图卷积算子。我强烈建议你照着这个模块划分来组织代码后面调试时能少掉很多头发。2. 数据集与建模FJSP实例怎么变成PyG能吃的图2.1 公开FJSP数据集格式与解析这个项目用的数据集本质上是学术界公开的经典FJSP实例最常用的是Brandimarte系列mk01到mk10和Fattahi系列。Brandimarte系列规模适中、约束清晰非常适合用来验证算法正确性。项目里“附数据集”这部分我做的事情就是把这类公开实例文件整理成统一格式并写好了解析脚本省得大家再去找各种格式混乱的版本。Brandimarte文件的格式是这样第一行通常是两个数字第一个是工件数量第二个是机器数量。随后每个工件单独占一行第一个数字表示该工件的工序数之后每两个数字一组分别是“可加工机器编号”和“加工时间”。举个例子4 5 2 1 8 2 4 3 1 5 3 6 4 7 2 2 3 4 9 3 1 4 3 2 5 1这个例子里有4个工件、5台机器。第二行的意思是工件1有2道工序第1道工序可以在机器1上加工耗时8或机器2上加工耗时4第2道工序只能在机器1上加工耗时5。不同工件的工序数可以不一样这是柔性车间的典型特征。解析代码其实非常简单关键点在于把机器编号统一转成从0开始方便和PyG的节点索引对齐import numpy as np def load_fjs(path): with open(path, r) as f: lines f.readlines() first list(map(int, lines[0].strip().split())) n_jobs, n_machines first[0], first[1] jobs [] for line in lines[1:1 n_jobs]: nums list(map(int, line.strip().split())) n_ops nums[0] ops [] idx 1 for _ in range(n_ops): machine nums[idx] - 1 # 转成0索引 time nums[idx 1] ops.append((machine, time)) idx 2 jobs.append(ops) return n_jobs, n_machines, jobs真实的mk系列文件里行首可能有空格行尾可能有换行符差异所以解析时一定要用strip()把空白去掉。还有个小坑是有些数据集用制表符分隔有些用空格但统一用split()处理这两种情况都能兼容。2.2 二部图构建工序节点与机器节点怎么设计特征数据解析出来后调度实例还是一个离散的工序列表没法直接丢给神经网络。我采取的做法是每次决策时把它构建成一张二部图工序节点和机器节点各占一边边表示“这道工序能在这台机器上加工”边的属性就是加工时间。光有结构还不够节点必须带上特征GNN才能学到有价值的信息。这一步是整个项目里最需要经验的地方特征设计的好坏直接影响最终调度效果。我实验后确认下面这组特征比较稳工序节点特征所属工件ID归一化到[0,1]区间该工序在工件内部的序号该工序剩余可加工机器数量该工序的平均加工时间除以全局平均加工时间做归一化该工序是否已完成的标志位该工件剩余工序数机器节点特征当前累计负载已分配的总加工时间当前最早可用时间还能处理的工序数量该机器的平均加工速度全局平均时间除以该机器平均时间为什么要加入这些特征因为它们本身就是排产员最关心的信息。机器的负载率、工序的机动性有多少替代机器、工件的剩余工作量这些是调度决策的核心依据。GNN能自动从邻居聚合中学到更复杂的组合特征但前提是把这些原始信号都喂进去。图的构建逻辑是这样初始化时把所有工序节点和机器节点都建好工序和机器之间连边。每个决策步环境状态发生改变就需要重新构建图把已完成工序对应的节点特征中的“是否已完成”置成1把机器负载、可用时间等特征同步更新。这里不推荐做增量更新初期先用全量重建把逻辑跑通性能问题后续再优化。下面是一个简化的构建函数骨架import torch from torch_geometric.data import Data def build_graph(jobs, machine_load, machine_free_time, op_status, avg_time): op_nodes [] machine_nodes [] edge_index [] edge_attr [] op_node_id 0 for job_id, job in enumerate(jobs): for op_idx, op in enumerate(job): # 工序节点特征按前面设计拼接 op_feat [ job_id / max(len(jobs), 1), op_idx / max(len(job), 1), len(op) / max_alt, np.mean([t for _, t in op]) / avg_time, float(op_status[job_id][op_idx]), (len(job) - op_idx) / len(job) ] op_nodes.append(op_feat) for machine_id, proc_time in op: edge_index.append([op_node_id, len(jobs) machine_id]) edge_attr.append([proc_time / avg_time]) op_node_id 1 for m_id in range(n_machines): machine_feat [ machine_load[m_id] / max_load, machine_free_time[m_id] / horizon, machine_capacity[m_id], machine_avg_speed[m_id] ] machine_nodes.append(machine_feat) x torch.tensor(op_nodes machine_nodes, dtypetorch.float) edge_index torch.tensor(edge_index, dtypetorch.long).t().contiguous() edge_attr torch.tensor(edge_attr, dtypetorch.float) return Data(xx, edge_indexedge_index, edge_attredge_attr)代码里的归一化处理特别重要。如果直接把原始加工时间、机器负载丢进网络数值范围可能从几十到几千PPO对这种尺度差异非常敏感训练很容易震荡甚至不收敛。统一归一化到0到1之间训练稳定性会好很多。2.3 注意图不是静态的每个决策点都要重画我第一版实现偷懒只在初始化时构建了一次图后面每步只更新节点特征的值结果发现GNN的邻居结构根本对不上。因为调度过程中一道工序完成之后它和机器之间的边其实就不应该再参与后续决策了但如果图结构不变GNN还是会聚合到这些“死节点”的信息输出自然一塌糊涂。后来改成了每个决策步都重新build_graph。这样做的代价是多了些计算量但逻辑非常清晰每次环境返回的观测就是当前调度状态下的完整图。PyG的Data对象构造本身不慢瓶颈主要在图卷积运算上用小数据集跑完全能接受。这里还要提醒一个容易出错的点PyG的Data对象在传给策略网络时是整块放在GPU上还是CPU上要搞清楚。我一开始忘了把edge_index转成long类型结果跑GCNConv时报了类型不匹配错误。建议在build_graph里就统一做一次.contiguous()和类型转换别把这些细节留给后面训练时踩。3. 环境封装让Stable-Baselines3跑起来的关键接口设计3.1 Gym环境的三个核心接口SB3训练智能体时默认跟环境交互是走Gymnasium的老一套接口reset、step、observation_space、action_space。FJSP环境和平常用的CartPole这类玩具环境最大的不同在于它的状态不是定长的向量而是一个动态变化的图。reset的逻辑比较简单把工序状态全部置为未完成所有机器空闲时间清零负载清零当前时间归零然后调用build_graph构造初始观测返回。step是核心每次接收一个动作执行调度决策推进状态返回新观测、奖励和结束标志。observation_space怎么声明PyG的Data对象是动态结构没法直接塞进SB3的Box空间。我实验后的做法是定长padding先算好这个实例最多有多少个工序节点和机器节点构建一个固定大小的输入向量把工序特征、机器特征、边信息都按固定位置排好缺失位置填0。然后在策略网络里再把这个向量还原成图结构或者直接在特征提取器里解析。这样observation_space就可以声明成gym.spaces.Box(low0, high1, shape(max_nodes, feat_dim), dtypenp.float32)整个训练流程顺畅很多。action_space我用的是MultiDiscrete第一个维度表示选哪道工序第二个维度表示选哪台机器。FJSP的决策天然分成两步——先选工序再选机器分开建模比联合动作收敛得快。如果你把动作定义成“同时选工序机器”的联合索引动作空间会爆炸而且无效动作比例很高网络很难学。有个现实问题每道工序可用的机器集合不一样直接输出“机器5”可能对当前选的工序是非法动作。SB3原生不直接支持动作掩码但有两种变通方案一是把无效动作对应的logit设成一个非常大的负数比如-1e8softmax之后概率几乎为0二是把动作掩码加进观测特征里让网络自己学着不去选。我两种都试过第一种实现简单、收敛也更快但需要改一下策略网络的forward逻辑第二种更“干净”但网络得多学一层“哪些动作合法”的映射训练要更久。小规模实验建议先用第一种。3.2 状态转移与奖励设计FJSP环境的状态转移本质上是事件驱动的。每执行一个动作就把一道工序安排到某台机器上然后更新相关时间线。我实现的时候维护了几个核心变量每台机器的完工时间列表、每道工序的完成状态、每台机器的累计负载、全局当前时间。step函数的处理流程如下把动作解析成job_id, op_idx, machine_id。检查合法性该工序是否未完成、前序工序是否已完成、机器编号是否在该工序的可选集合里。计算开工时间取“全局当前时间”和“机器空闲时间”的较大值。计算完工时间开工时间加上加工时间把这条任务追加到机器的任务表里。将工序标记为完成更新机器空闲时间、机器累计负载。更新全局当前时间为所有事件中的下一个关键时间点。检查是否所有工序都完成如果是则置done为True。奖励设计是这个项目的灵魂。我第一版只在episode结束时给一个“负Makespan”的稀疏奖励结果训练了几十万步智能体完全没学会任何有效策略。后来改成密集奖励每一执行一步就返回负数反馈智能体才算是真正“开窍”。具体公式我当时这么写的reward - (start_time - self.current_time) - processing_time / self.scale意思是每一步决策拖得越久惩罚越重选的机器加工时间越长惩罚也越重。这样智能体天然会倾向于优先开工、并把工序分配给加工时间短的机器。episode结束时再加一个额外的Makespan惩罚把长期目标也接上。还有一个非常关键的经验奖励必须做归一化。如果不归一化PPO对奖励的尺度特别敏感同一个奖励公式在mk01上效果不错换到mk10上可能直接Loss变成NaN。我的做法是把所有时间量纲除以该实例所有工序平均加工时间或者除以一个预估的下界比如关键路径下界让奖励值基本落在-1到0之间。3.3 和SB3结合的自定义策略网络SB3默认的MLP策略做不了图卷积所以需要自定义一个ActorCriticPolicy在特征提取部分接上GNN。我先解释一个容易踩的坑有些人会把图编码器放在环境内部直接输出一个固定长度的向量然后喂给SB3的MlpPolicy。这样训练是完全断开的——环境输出是numpy数组梯度无法反向传播到GNN参数图编码器根本学不了最后效果和随机特征差不多。正确做法是自定义Policy类在extract_features里调用GNN让整个网络端到端一起训练from stable_baselines3.common.policies import ActorCriticPolicy class FJSPPolicy(ActorCriticPolicy): def __init__(self, observation_space, action_space, lr_schedule, **kwargs): super().__init__(observation_space, action_space, lr_schedule, **kwargs) self.gnn GraphEncoder(...) # 内部是GCNConv或者GATConv self.mlp MLP(...) # 接在GNN输出后面判断动作和价值 def extract_features(self, obs): graph obs_to_graph(obs) # 把定长观测还原成Data对象 return self.gnn(graph) # 输出图嵌入输入给MLP第一次写到这里时最容易被卡住因为ActorCriticPolicy内部的mlp_extractor、action_net、value_net这些组件的尺寸要对齐。我的经验是先搭一个“线性baseline”不接GNN直接把定长观测拉平进MLP跑通环境、奖励、训练流程确认没问题后再替换成GNN特征提取器。这样出问题时能准确定位是环境的问题还是网络的问题而不是两坨问题缠在一起。4. 训练与评估从零跑通PPO调度完整流程4.1 环境安装与版本搭配这个项目依赖的库比较多版本一旦没对齐光是装环境就能耗掉半天。我用的组合是Python 3.10 PyTorch 2.1 PyG 2.4 Stable-Baselines3 2.1 Gymnasium 0.29。安装顺序有讲究先把PyTorch装好再装PyG最后装SB3。因为PyG会依赖PyTorch的底层算子顺序反了很容易把torch版本覆盖掉。pip install torch torchvision pip install torch-geometric pip install stable-baselines32.0,3.0如果你是GPU环境torch的安装要选对应CUDA版本的命令这个去PyTorch官网复制即可。PyG在较新版本可以直接pip install torch-geometric如果遇到编译错误可以单独装配套的torch-scatter和torch-sparse。SB3从2.x开始默认对接Gymnasium而不是老的gym所以自定义环境时import gymnasium as gym。4.2 训练脚本的结构整个训练脚本的逻辑其实很简洁解析数据集 → 创建环境 → 定义策略 → 训练 → 评估。核心代码骨架如下from stable_baselines3 import PPO n_jobs, n_machines, jobs load_fjs(./data/mk01.fjs) env make_fjsp_env(jobs, n_machines, ...) model PPO( FJSPPolicy, env, learning_rate3e-4, n_steps2048, batch_size256, clip_range0.2, ent_coef0.01, vf_coef0.5, n_epochs10, gamma0.99, verbose1, seed42, ) model.learn(total_timesteps500_000) model.save(fjsp_ppo_mk01)这里有几个参数需要注意。n_steps2048表示PPO每收集2048步数据做一次更新而FJSP一个episode一般只有几十步到上百步所以一条轨迹不是太大没问题。batch_size256是每次梯度更新的样本量跟n_steps搭配着看如果显存不够可以调小。ent_coef是熵系数0.01能让智能体保有一定探索性太小容易过早陷入局部最优。4.3 参数怎么选PPO关键超参数调优经验PPO参数网上到处是模版但FJSP场景下有一些特殊情况。我把推荐值和调整方向整理在下面参数推荐值调整经验learning_rate3e-4初始用3e-4如果Loss发散去到1e-4收敛太慢再升回来n_steps2048~4096过小会导致采样状态方差大过大训练太慢batch_size128~512显存允许范围内用256比较稳clip_range0.2这是PPO默认值一般不需要动gamma0.98~0.995奖励做了归一化后0.99基本够用ent_coef0.01确定性策略可以先从0开始探索不足再升到0.01n_epochs10数据量紧张时反而不能设太大容易过拟合旧数据训练过程中最值得关注的日志指标是ep_rew_mean。在奖励设计正确的前提下这个值应该总体趋势向上虽然有一定波动但不会长期停滞。如果跑了20万步还在原地打转大概率不是参数问题而是环境或特征设计有问题。4.4 评估与甘特图可视化训练完成后下一步是评估效果。评估时要用deterministicTrue也就是每一步都取概率最大的动作不采样。我习惯拿一个实例跑10次不同随机种子记录平均Makespan、最优Makespan和标准差这样能看出方案的稳定性。单纯看数字不够直观最好把调度结果画成甘特图。甘特图能直观暴露出很多问题某台机器长期空闲、某道工序被排得特别靠后、整条产线有明显的瓶颈堆积。我写的绘图函数长这样import matplotlib.pyplot as plt def plot_gantt(schedule, n_machines): fig, ax plt.subplots(figsize(12, 6)) colors plt.cm.tab20.colors for machine_id, tasks in schedule.items(): for job_id, op_idx, start, end in tasks: ax.barh( machine_id, end - start, leftstart, height0.6, colorcolors[job_id % 20], edgecolorblack, labelfJ{job_id}O{op_idx} ) ax.set_yticks(range(n_machines)) ax.set_xlabel(Time) ax.set_ylabel(Machine) plt.grid(axisx, linestyle--, alpha0.6) plt.show()我在mk01上训练完得到的甘特图效果是10个工件、6台机器Makespan从随机策略的68左右降到47左右。对比数据可以参考下表具体数值随随机种子和训练轮数有波动但GNNPPO的组合在规模和泛化能力上都比手写规则强方法mk01 Makespan说明随机调度68多次采样取均值FIFO先到先服务53经典基础规则SPT最短加工时间优先51经典启发式规则PPOGNN47本文方案确定性策略5. 常见问题我把踩过的坑和排查方法整理成了一张表5.1 高频报错速查表这个项目从环境搭建到训练跑通我前前后后踩了几十个坑很多是查半天文档才发现是版本或类型问题。下面这张表是我整理的高频问题碰到类似报错直接对照排查报错/现象根本原因解决方法ImportError: No module named torch_scatterPyG的底层依赖没装全按torch版本安装配套的torch-scatter、torch-sparse wheel包SB3报错Env is not a gymnasium envSB3和gym版本对不上检查是否用了老版gymSB3 2.x要求GymnasiumValueError: Expected 2D input tensor观测向量的shape没对齐检查环境observation_space和策略网络输入层维度训练日志出现NaN loss学习率过高或奖励数值太大调低learning_rate把奖励做归一化处理训练曲线完全不上涨奖励太稀疏或者特征设计不对改成密集奖励检查GNN能否正常梯度回传推理时总是选择无效动作动作mask没生效把无效动作的logit设为极小值或者加入动作mask特征图卷积报类型错误edge_index不是long类型构造时加.long().contiguous()5.2 训练不收敛的排查思路训练不收敛是强化学习项目的常态尤其FJSP这种动态环境下问题可能出在环境、特征、网络、奖励任何一个环节。我总结了一套排查顺序按这个顺序查效率最高。第一步冻结随机种子。在环境、模型、PyTorch三处都固定seed确保每次训练可复现。如果连可复现都做不到后面根本没法判断改动是否有效。第二步人工验证环境逻辑。用随机策略跑几个episode打印每一步的状态、动作、奖励确认工序状态转移和机器时间更新没有错漏。第三步上线性baseline。先用MLP替代GNN跑一遍如果线性基线都不涨问题大概率在环境或奖励设计上如果线性基线能涨而GNN不涨才需要检查图特征提取这块。第四步观察奖励分布。在训练早期打几次日志看奖励有没有全部堆在一个极端值附近如果标准差为0说明环境可能根本没给有效反馈。5.3 数据集相关的坑数据集这块也有不少暗坑。第一不同来源的FJSP实例格式差异很大有的第一行是“工件数 机器数 最大工序数”有的直接是“工件数 机器数”解析前必须先看头几行确认。第二机器编号有的是从1开始有的是从0开始统一转成0索引再做二部图否则边会连错节点。第三某些数据集里会出现“同一道工序在相同机器上重复列出两次”的情况解析时要做一次去重。第四时间单位不代表真实意义跨数据集训练时一定要做归一化否则模型在一个数据集上训练完换到另一个数据集完全不work。我的建议是先把mk01这个最小实例彻底跑通确认代码和逻辑都对再逐层扩展到更大数据集。一次引入过多变量只会让调试难度爆炸。最后分享一点体会FJSP用深度强化学习跑通demo并不难难的是把环境状态转移和特征设计做对。我强烈建议你拿到任何数据集后先用纸笔手推一个小实例确认你和环境对“下一步能选哪些工序”“机器空闲时间怎么算”这些基础问题的理解完全一致再上GNN和PPO否则后面全是纠缠不清的bug。这个项目后续还能往可重入车间、AGV协同调度、多目标优化能耗拖期等方向扩展核心就是改环境的状态转移和奖励函数模型这块基本能复用。希望这篇记录能帮你少走点弯路。