自然语言优化求解:LLM+中间表示+OR-Tools全链路复盘 “自然语言优化求解系统”这个系列写到第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. 中间表示层让大模型输出可校验的JSON2.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.2max_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(fx_{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。各机器负载M19小时M211小时M38小时。”用户不需要懂任何数学一看就明白结果长什么样。后续我还计划把这段解释也交给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复述用户问题时把抽取结果用中文念一遍给用户确认这个环节能拦下不少因模型误解造成的返工。