“自然语言优化求解系统”这个系列写到第17篇,手里的demo总算能跑通一条完整链路:用户用大白话描述排产、分配、调度这类问题,系统自动抽取变量和约束,调用求解器算出可行方案,最后再把结果翻译回人能看懂的安排表。今天这篇不聊宏大架构,重点复盘我在“从自然语言到中间表示,再到求解器执行”这条主链路上反复折腾出来的实现方案,以及几个不试几次绝对发现不了的坑。
这一篇适合两类人看:一类是在做LLM Agent落地、想给大模型接上“数学求解”能力的开发者;另一类是已经在用OR-Tools、Gurobi这类求解器,但苦于业务方不会写建模代码的同学。看完你可以直接抄走整套设计思路,至少能少走半个月弯路。
1. 整体设计:这个系统的主链路到底怎么拆
1.1 系统模块划分与数据流向
整个系统从功能上切成了四个模块:语言理解层、中间表示层、求解引擎层、结果解释层。语言理解层负责接住用户的自然语言输入,把“有三台机器,五个工件,每个工件加工时间不同,怎么排总耗时最短”这种话变成结构化描述;中间表示层是核心枢纽,输出一份不依赖任何求解器的标准化JSON;求解引擎层负责把JSON翻译成CP-SAT模型并求解;结果解释层再把求解器的变量取值映射回用户听得懂的语言。
我在第10篇之前用的是“用户输入直接生成Python代码”的粗暴方案,看起来省事,实际维护成本极高。LLM生成的代码偶尔能跑通,但一旦模型结构复杂、约束变多,根本没法排查是用户意图理解错了,还是代码生成错了。加了中间表示层之后,每一层都可以单独校验:JSON结构是否合法、字段是否有缺失、约束是否冲突、求解是否可行。出了问题能定位到具体环节,这个改动带来的收益巨大。
1.2 为什么“自然语言直接转求解代码”走不通
直接让LLM写OR-Tools代码,就像让一个实习生直接改生产环境脚本——他能写,但没人敢保证正确性。实测中有三类问题频繁出现:第一,LLM会脑补变量和约束。用户只说“尽量早做完”,模型直接给所有工件加了一个不存在的截止时间;第二,生成的代码偶尔会有语法错误或者变量作用域错乱,必须人工逐行review;第三,也是我最头疼的,代码没法结构化对比。用户说“5个工件”,生成代码里只有4个,这种差异在纯代码里非常难被自动发现。
中间表示就是为了解决这三个问题。JSON Schema定义了严格的数据契约,LLM只需要输出结构化字段,不需要写任何执行逻辑。系统可以在进入求解器之前做全套静态检查:数值是否越界、工件事项是否遗漏、约束之间是否存在明显冲突。这样既保留了LLM处理自然语言的灵活度,又让后续的建模和求解完全处于可控状态。这个取舍是整个项目稳定运行的基础。
2. 中间表示层:让大模型输出可校验的JSON
2.1 中间表示的数据结构设计
针对排产调度这个核心场景,我设计了一份精简但够用的JSON Schema。整个结构主要包含四个部分:问题类型、工件列表、机器列表、约束列表和目标函数。以并行机调度为例,一份合法的中间表示长这样:
{ "schema_version": "1.0", "problem_type": "parallel_machine_scheduling", "jobs": [ {"id": "J1", "processing_time": 6}, {"id": "J2", "processing_time": 4}, {"id": "J3", "processing_time": 8}, {"id": "J4", "processing_time": 3}, {"id": "J5", "processing_time": 7} ], "machines": [ {"id": "M1"}, {"id": "M2"}, {"id": "M3"} ], "constraints": [ {"type": "release_time", "job": "J1", "value": 2} ], "objective": {"type": "minimize", "metric": "makespan"} }设计这份Schema我最大的体会是:字段越宽松,LLM发挥空间越大,出错概率也就越大。比如“processing_time”我强制要求必须是整数,用户说“两个半小时”就统一转成150分钟。机器ID和工件ID也统一用J1、M1这种格式,防止出现同名不同对象的混乱。约束字段只有type、job、value三个可选键,任何多余字段都会被校验器直接丢弃。
2.2 Prompt模板与少样本示例的实战配置
Prompt是整个中间表示层效果的关键。我试过把系统提示词写得极其详尽,罗列了十几条规则,结果模型反而束手束脚,字段输出不完整。后来改用“规则精简+少样本示例”的组合拳,效果稳定很多。核心Prompt如下,可直接参考使用:
你是运筹优化建模助手。用户会用中文描述一个调度/分配/排产问题。请你抽取其中的所有关键信息,输出严格的JSON。要求: 1. 只输出JSON,不要输出任何解释、Markdown代码块或注释。 2. processing_time必须为正整数,如果用户说“半小时”等非整数时长,统一换算成分钟。 3. “必须”、“不能”、“只能”后面的内容属于硬约束,放入constraints数组。 4. “尽量”、“优先”、“最好”后面的内容属于软约束,在constraints中用"soft": true标记。 5. 用户没有提到的信息,绝对不要臆造或补充。 参考示例: 用户输入:有3台机器,5个工件。J1加工6小时,J2加工4小时,J3加工8小时,J4加工3小时,J5加工7小时。J1必须2小时后才能开始加工。怎么安排总耗时最短? 输出: {"schema_version":"1.0","problem_type":"parallel_machine_scheduling","jobs":[...],"machines":[...],"constraints":[{"type":"release_time","job":"J1","value":2}],"objective":{"type":"minimize","metric":"makespan"}}少样本示例至少给两个,第二个最好覆盖“软约束+省略表达”的复杂场景。这样模型才能学会区分硬约束和软约束。temperature参数我固定在0.2,max_tokens设1500,既保证输出稳定,又足够容纳完整JSON。实测发现temperature超过0.7时,字段名偶尔会漂移——比如“processing_time”变成“time”——校验器直接拒收。
2.3 解析与校验:非法JSON、字段缺失的兜底处理
大模型输出JSON,即使Prompt写得再好,仍然要假设它会出错。我的兜底流程分三步:提取、解析、校验。提取阶段用正则找出第一个{到最后一个}之间的内容,防止LLM偶尔在JSON前后加了说明文字。解析阶段直接json.loads,如果失败,就把错误信息拼到Prompt里重试一次。这个“失败信息回填重试”机制效果出奇好,模型看一眼自己刚才的输出,基本都能自己改正。
校验阶段是真正的安全网。我逐项检查必填字段是否存在,数值是否大于0,机器ID是否有重复。校验失败时不直接报错,而是把校验失败的具体原因返回给用户,让用户补充或修改描述。这个交互澄清机制比让LLM反复乱猜要高效得多。有一次用户输入“五台设备加工九个件,2号设备不能加工J4”,系统正确识别出2号设备对应M2,并生成了禁止该分配的约束字段,这就是结构化校验带来的收益。
3. 求解器接入:用OR-Tools CP-SAT把IR编译成可执行模型
3.1 为什么选CP-SAT而不是线性规划求解器
排产调度类问题的天然形态是整数决策、逻辑约束、排序关系,CP-SAT在这类问题上几乎是天生的匹配。MILP求解器也能建模,但对“工件A要么在机器1要么在机器2”这种逻辑关系需要引入大量Big-M辅助变量,数值稳定性差,维护难度也大。CP-SAT自带布尔变量、区间变量和累积约束,表达调度问题可以用更贴近业务的语言,求解性能也足够优秀。
选型时我对比过Gurobi、SCIP和OR-Tools CP-SAT。Gurobi商业授权费用高,不适合个人项目;SCIP学术免费但Python接口不如OR-Tools顺滑;CP-SAT开源、免费、文档齐全,实测在10台机器、50个工件规模的问题上,几秒内就能给出高质量可行解。对于自然语言优化求解系统这个场景,它是最省心的选择。后续如果要扩展到更大规模,系统架构上也预留了替换求解引擎的空间,不会把路堵死。
3.2 从IR到CP-SAT的编译过程(含代码)
这份代码直接从我项目里扒下来简化过的版本,核心逻辑是把JSON里的jobs、machines、constraints逐步转成CP-SAT的变量和约束。完整的编译函数如下:
from collections import defaultdict from ortools.sat.python import cp_model def build_model(ir: dict): model = cp_model.CpModel() jobs = {j["id"]: j["processing_time"] for j in ir["jobs"]} machines = [m["id"] for m in ir["machines"]] # 决策变量:工件j是否分配到机器m x = {} for jid in jobs: for mid in machines: x[(jid, mid)] = model.NewBoolVar(f"x_{jid}_{mid}") # makespan:所有机器完工时间的最大值 horizon = sum(jobs.values()) + 1 makespan = model.NewIntVar(0, horizon, "makespan") # 每个工件必须分配到且仅分配到一台机器 for jid in jobs: model.Add(sum(x[(jid, mid)] for mid in machines) == 1) # 每台机器的总加工时长不超过makespan for mid in machines: model.Add( sum(x[(jid, mid)] * jobs[jid] for jid in jobs) <= makespan ) # 处理约束字段 for c in ir.get("constraints", []): if c["type"] == "release_time": # 简化为:该工件不能分配到总负荷最小的那台机器之前 # 实际项目会在这里添加更精确的时间维度变量 pass elif c["type"] == "forbidden_machine": jid = c["job"] mid = c["machine"] model.Add(x[(jid, mid)] == 0) model.Minimize(makespan) return model, x, makespan, jobs, machines def solve_and_extract(ir: dict): model, x, makespan, jobs, machines = build_model(ir) solver = cp_model.CpSolver() status = solver.Solve(model) if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): return {"status": "infeasible"} schedule = {} for (jid, mid), var in x.items(): if solver.Value(var) == 1: schedule[jid] = mid return { "status": "optimal" if status == cp_model.OPTIMAL else "feasible", "makespan": solver.Value(makespan), "schedule": schedule, "machine_loads": {mid: sum(jobs[jid] for jid in jobs if schedule[jid] == mid) for mid in machines} }编译过程中最值得注意的细节是整数的使用。CP-SAT对整数支持极好,但要求数值不能太大。horizon上界我直接用所有加工时间之和,既保证makespan一定被容纳,又不至于让变量域过大拖慢求解。实际项目上线后要加更多约束,比如顺序依赖、准备时间、批次限制,可以在build_model函数的约束解析部分逐步扩展,代码结构是稳定的。
3.3 结果解释器:把解翻译回人话
求解器输出了一个包含决策变量取值的字典,但业务用户根本不在乎x["J1"]["M2"] = 1这种底层表达。结果解释器的作用是把排程方案整理成可直接阅读的表格文本。我第一版结果是纯模板拼接,简单但缺少总结;后来加了一段“关键指标摘要”,把总完工时间、单台机器负载、最长加工路径一起输出,用户能一眼看出方案优劣。
def explain(result: dict) -> str: if result["status"] == "infeasible": return "根据当前约束条件,无法找到可行方案。建议放宽硬限制或减少工件数量。" lines = [f"最短完工时间约为 {result['makespan']} 小时。", "安排如下:"] machine_jobs = defaultdict(list) for jid, mid in result["schedule"].items(): machine_jobs[mid].append(jid) for mid, jids in machine_jobs.items(): lines.append(f"机器{mid}负责工件:" + "、".join(jids)) lines.append("各机器负载:") for mid, load in result["machine_loads"].items(): lines.append(f" 机器{mid}:{load} 小时") return "\n".join(lines)用前面例子跑一遍,输出大致是:“最短完工时间约为17小时。安排如下:机器M1负责工件:J1、J4;机器M2负责工件:J2、J5;机器M3负责工件:J3。各机器负载:M1:9小时,M2:11小时,M3:8小时。”用户不需要懂任何数学,一看就明白结果长什么样。后续我还计划把这段解释也交给LLM做二次润色,但模板输出作为稳定兜底,永远不会被跳过大模型状态不稳定。
4. 全链路复盘:五次实测与三个必踩的坑
4.1 测试用例设计:从明确约束到模糊表达
给系统做测试,不能只测理想输入。我设计了一套覆盖正常、模糊、冲突、超大规模四档难度的测试集,每次迭代后全量跑一遍。下面这张表是我最常用的核心用例:
| 用例编号 | 用户描述 | 期望结果 | 实测表现 |
|---|---|---|---|
| T01 | 三台机器、五个工件,加工时间明确,求最短完工时间 | 输出排程和makespan | 正常通过 |
| T02 | “J1要2小时后才能开始做” | release_time进入约束 | 一开始被忽略,加上少样本示例后修复 |
| T03 | “尽量让J2上午做完” | 软约束标记,不硬性阻断 | 正确标记为soft,但不参与求解 |
| T04 | “必须在1小时内全部完成” | 判断不可行并提示 | 返回infeasible,提示合理 |
| T05 | 用户把机器称为“设备”“台子”,工件称为“活”“订单” | 同义词识别正常 | 依赖Prompt同义词列表,仍会偶发漏识别 |
T03暴露了一个深层次问题:软约束目前只做了标记,没有在目标函数里体现优先级。CP-SAT支持加权目标函数,后续计划是把“尽量”类表述转成惩罚项系数,比如“尽量J2上午做完”翻译成“如果J2排到下午,目标函数加100”,这样系统会主动优化软约束而不至于直接无解。目前这个版本还做不到,我选择在解释器里直接忽略soft字段,避免给用户一种“系统会考虑”的误导。
4.2 常见问题排查速查表
这几个月维护系统下来,我把重复出现频率最高的问题整理成了排查表。遇到类似现象的读者可以先照着这张表检查,绝大多数情况都能快速定位。更重要的是,这些问题大多能通过Prompt调整或数据预处理来规避,尽量不做无谓的模型调参。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| LLM输出非法JSON | 模型在JSON前后加了Markdown代码块或解释文字 | 正则提取花括号内容;把解析错误回填Prompt重试一次 |
| processing_time出现小数 | 用户说“2.5小时”等非整数时长 | 在Prompt里规定统一转分钟;不进求解器前做int()转换 |
| 漏掉“必须”类硬约束 | 用户表达过于口语化,比如“不能把J4放M2” | 在少样本示例中增加类似的同义改写示例 |
| 求解结果无解 | 硬约束过强,实际上界不足 | 检查约束字段值是否合理;提示用户放宽条件 |
| makespan数值大得离谱 | 某些异常输入导致horizon超出预期 | 为horizon设置上限,比如所有加工时间总和的1.2倍 |
| 机器数量识别错误 | “三台设备”与“机器”不一致 | 在Prompt的预处理环节增加同义词归一化 |
排查表中最后一行“机器数量识别错误”最隐蔽,因为系统不会报错,只会默默少建一台机器,makespan反而偏大。后来我在Prompt里的“参考示例”中增加了设备/机器/台子的同义词说明,加上打日志记录了LLM输出的原始JSON,每次抽取错误都能快速回看,不用猜。这个“原始输出留痕”的习惯强烈建议从一开始养成。
4.3 实测过程中的几点深刻体会
第一,永远不要相信LLM会完全按照Prompt执行。即使有了中间表示,我仍然在正式求解之前加了一层校验器,全部字段通过检查才允许进入CP-SAT。这层校验器看起来多写了百来行代码,实际上节省了几十倍的排障时间。
第二,整数化处理能避免大量莫名其妙的bug。用户说“2.5小时”不会造成求解错误,但浮点数误差累积到几十个约束上,就会产出一些看似可行但实际不可行的方案。我统一把所有时长转成分钟整数,代码变简单,数值稳定性也大幅提高。
第三,系统的调试手段非常重要。我给每个模块都加了日志输出,尤其是LLM返回的原始JSON和求解器的状态码。排查问题的时候,先把日志定位到具体阶段——到底是抽取失败、校验失败还是求解失败——比整体代码里乱找要快得多。
5. 后续演进方向与个人规划
接下来的版本我准备做三件事:一是把软约束的优先级真正接进目标函数,让“尽量”类指令不再是被忽略的装饰品;二是接入更多问题类型,比如带先后顺序的流水车间、带批次限制的并行分批调度;三是做一个交互澄清模块,当校验器发现字段缺失时,主动向用户提问补全信息,而不是粗暴地重试一次。
这三件事做完,这个系统才算真正具备交付给业务方使用的潜质。我自己的体会是,这类“LLM+求解器”的应用,最难的部分从来不是算法,而是工程细节的反复打磨。中间表示层让模糊的自然语言变得可计算、可验证、可回滚,这是整套方案的定海神针。最后再分享一个小技巧:给LLM复述用户问题时,把抽取结果用中文念一遍给用户确认,这个环节能拦下不少因模型误解造成的返工。