说句实话把“人话”变成一个能求最优解的数学模型这个方向我琢磨了很久。最开始纯粹是业务里的痛点每次排生产计划都要拉着懂运筹的人把口头需求一点点翻译成约束和目标函数稍不注意就漏条件。后来我决定自己动手做一个“自然语言优化求解系统”让不太懂建模的人也能用自己的话描述问题系统自动完成抽取、建模、求解、解释这条链路。[0-1]那篇只搭了一个手工写死的原型这一篇也就是[0-2]做的是把链路真正串起来并在一组常见问题上跑出可复现的结果。这篇内容适合两类读者一类是想用大模型做结构化输出的开发者另一类是经常被运筹建模门槛卡住、希望用自然语言驱动求解的业务同学。我会把从系统设计、解析层搭建、求解映射到结果转述的完整过程讲清楚并重点扒一扒实际调试中遇到的坑。整个过程不依赖任何专用建模平台核心就是“大模型API 中间表示 通用求解器 一个校验层”整体成本可控单次问题求解链路耗时大约在5秒以内。1. 系统整体拼图这个阶段到底在搭哪一块1.1 一条完整链路包含什么先把我脑中的目标系统拆成四段问题摄入接收用户自然语言描述比如“三台设备五个订单每个订单有不同的交期希望总拖期最小”。结构化中间表达IR把自然语言翻译成统一的结构化结果包含变量、目标函数、约束条件、求解方向等。求解建模把结构化结果映射到数学规划模型交给求解器算出最优解。结果自然语言化把求解器吐出的变量取值和规划方案组装成用户一眼能明白的排班表、分配计划或文字说明。这四段里风险最大的不是第四段而是第二段和第三段的衔接。如果让大模型直接生成一段求解器的代码听起来很酷但实际跑起来问题一堆命名冲突、运算符优先级搞错、API版本差异、生成代码里混入不存在的方法。所以我在[0-1]阶段就定下了原则大模型只干它最擅长的事也就是把“自然语言”翻译成“结构化字段”至于真正的数学建模和求解交给严格的代码逻辑去完成。1.2 0-1做了什么0-2做了什么[0-1]阶段我只验证了一件事把两个固定的排班问题写成JSON模板再用硬编码的方式把JSON映射成求解器代码最终确实能求出最优解。但那个原型离“能用”差得很远问题稍微换一种说法模板就失效了。[0-2]阶段我做了三项改造把解析层从“示例模板匹配”升级为“字段抽取 JSON Schema约束”让模型输出的结构更统一。明确限定系统第一阶段支持的问题类型只覆盖线性目标、线性约束、整数和布尔变量范围收敛了正确率才能上去。加了结果转述层把求解器的原始输出转成排版清晰的文字方案这一步对业务用户非常关键。1.3 技术选型为什么是“大模型API 中间IR 通用求解器”选型的核心逻辑是每个部件只做自己最擅长的事。大模型的优势是理解语义、抽取实体和关系但并不擅长精确的数值推导和代码生成。通用求解器的优势是稳定、可靠、有严格的最优性保障但不理解自然语言。中间IR就是两者的缓冲区把大模型的输出限制在一个安全的范围内再由程序去生成真正的模型代码。在求解器选择上我对比过两类。一类是偏向学术研究的开源系统功能强大但建模门槛高另一类是Python环境下更友好的建模工具比如我最终选用的PuLP。为什么选PuLP而不是其他原因是这套系统第一阶段处理的都是线性或整数规划问题PuLP的语法足够简单变量定义和约束追加的逻辑很直观而且它对纯Python环境的依赖极轻部署成本低。更重要的是PuLP的错误信息相对友好出了问题能快速定位到具体哪一行约束。2. 解析层怎么让大模型按规矩说话2.1 设计一个够用的中间JSON Schema解析层最怕的不是模型“听不懂”而是模型“发挥太自由”。同样一句“每个任务只能分配给一个人”模型可能输出十种不同的表达方式后面就没法统一处理了。所以第一步是给模型定死一个输出结构。我设计的IR结构包含四大部分{ variables: [ {name: x_0_0, type: binary, desc: 任务0是否分配给工程师0}, {name: y_0, type: integer, desc: 任务0的完成时刻} ], objective: { direction: minimize, expression: 3*x_0_0 3*x_0_1 4*x_1_0 5*x_2_1, desc: 总工时最小 }, constraints: [ {expression: x_0_0 x_0_1 1, desc: 任务0只能分配给一个工程师}, {expression: x_0_0 x_1_0 x_2_0 1, desc: 工程师0一天最多一个任务} ], config: { solver_time_limit: 10, gap_limit: 0.05 } }这个结构本身并不复杂但它的设计有一个重要取舍变量描述和表达式是分开的。变量描述让模型不用理解数学符号也能给业务语义而表达式字段让求解映射层可以直接按字符串处理。实践下来这个结构对常见问题的覆盖度能达到八成左右。2.2 提示词设计里最容易踩的三个坑第一次跑这个解析层我用的是最朴素的提示词“请把下面的问题转换成JSON”。结果得到的答案惨不忍睹有把约束写进目标函数的有把变量名用中文命名的还有直接输出了一段Python代码的。踩了几天坑之后我把提示词重新设计成了三段式系统角色定义、输出Schema说明、具体问题输入。但这里还有三个坑必须单独说。第一个坑是“没有给不适用情况留出口”。比如用户输入“这个月的总利润是多少”这根本不是优化问题模型却硬生成一个目标函数。后来我在提示词中明确加了如果输入不构成优化问题请直接输出{status: not_supported}。第二个坑是“样本太少导致变量类型错乱”。布尔变量、整数变量、连续变量模型经常搞混。解决办法是在提示词里给每个变量类型加一个现实映射比如“binary表示只能取0或1的开关决策integer表示人数、台数、时长等离散数量”。第三个坑是“约束语义不对齐”。模型能识别出“每个人一天最多完成一个任务”但把它写成sum 1还是sum 1经常弄反。我的处理是要求模型在输出约束时必须同时输出自然语言描述这样即使用户不懂数学也能在运维层面发现错误。2.3 不信任大模型的输出校验与修复链不管你提示词写得多细大模型偶尔还是会输出非法JSON或者JSON结构合法但语义明显异常。所以我的流程是拿到输出后先做结构校验再做字段级校验最后才进入求解环节。结构校验我用的是Pydantic字段级校验则包含以下几项变量名是否重复、是否包含非法字符目标表达式中引用的变量是否都存在于变量列表中约束表达式的运算符是否只包含加减乘、等号、不等号是否存在明显缺失比如没有变量或没有目标函数。校验失败时我不会直接返回错误而是走一条修复链第一次尝试用规则修正修正不了就把校验错误信息和模型原始输出一起塞回给模型让它自己重新生成。这一招在实际使用中把解析成功率拉高到了接近九成。3. 中间表示到求解器的最后一公里3.1 有限问题门类其实是刻意收敛第一阶段我不指望系统能处理所有优化问题只把范围锁定在“线性/整数线性规划”。为什么这么怂因为一旦放开非线性中间IR的表达能力要求就会急剧上升校验器的复杂度也会成倍增长。我的目标是先把最小可用闭环跑稳再逐步扩展。当前支持的问题类型可以整理成一张表问题类别典型场景目标函数示例约束示例分配问题任务分配、人员指派最小化总成本每个任务只能一人、每人最多若干任务排班问题班次安排、工程师排期最小化总工时或拖期每人每天最多一个班次、连续上班上限生产调度订单排产、机器调度最小化完工时间机器容量限制、订单交期背包/装载资源筛选、装载组合最大化总价值总重量或总体积上限每类问题虽然业务场景不同但在数学上都能表达成布尔变量或整数变量配合线性约束的形式。这也是为什么一个IR模型能通用处理多类问题。3.2 一个通用的IR到PuLP的映射代码核心的映射代码其实不长。我在项目里维护了一个ir_to_model.py文件核心逻辑是三步先定义变量再追加约束最后设置目标。import re import pulp def parse_expr_with_whitelist(expr, allowed_vars): # 只允许数字、运算符和已存在的变量名杜绝任意代码执行 tokens re.findall(r[A-Za-z_]\w*|\d\.?\d*|[\-*/], expr) for tok in tokens: if re.match(r[A-Za-z_]\w*, tok) and tok not in allowed_vars: raise ValueError(f未定义的变量: {tok}) return expr def build_model(ir): objective_direction ir[objective][direction] model pulp.LpProblem( nl_opt_problem, pulp.LpMinimize if objective_direction minimize else pulp.LpMaximize ) variables {} for var_info in ir[variables]: var_type { binary: pulp.LpBinary, integer: pulp.LpInteger, continuous: pulp.LpContinuous }[var_info[type]] variables[var_info[name]] pulp.LpVariable( var_info[name], catvar_type ) allowed_vars set(variables.keys()) objective_expr parse_expr_with_whitelist( ir[objective][expression], allowed_vars ) model eval(objective_expr, {__builtins__: {}}, variables) for cons in ir[constraints]: expr parse_expr_with_whitelist(cons[expression], allowed_vars) model eval(expr, {__builtins__: {}}, variables), cons.get(desc, ) return model看到eval先别慌这里我做了白名单限制表达式里出现的每一个变量名都必须来自前面定义的变量集合同时把内置函数全部禁掉了。这样eval能解析的最小范围就只剩“变量引用、数字和运算符”即使模型抽取出恶意的字符串也不会被执行。实际生产环境中如果你还想更保守可以把表达式解析换成纯正则拆token的方式连eval都不需要。3.3 求解配置与整数变量的哑变量处理在真实跑数的时候有几个细节特别影响结果质量。一个是求解时间设置如果问题规模小PuLP很快就能出最优解但如果问题规模大我会加上timeLimit和gapLimit避免卡死。另一个是整数变量和布尔变量的组合很多人会直接写model x_0 x_1 x_2 1这没问题但要注意如果约束里混入了一个连续变量整个约束就变成混合整数规划求解时间可能会明显上升。我的经验是能建模成布尔变量的决策就不要用整数变量代替布尔变量在分支定界过程中的剪枝效率更高。还有一个容易被忽略的点时间类参数的处理。自然语言中经常出现“下午三点前完成”“交期是12月30日”我在IR中统一用整数时间索引来表达不在模型层直接处理日期。日期和索引之间的换算在解析层完成这样求解器只关心纯粹的数值关系不会因为日期格式问题报错。4. 求解结果怎么再变回人话4.1 先机器渲染再大模型润色而不是跳过第一步很多人以为结果转述就是把求解结果丢给大模型说一句“帮我总结一下”但这样做最大的问题是幻觉。大模型看到一堆数字极易脑补出求解器根本没算过的指标比如“相比原方案节约了30%成本”但实际数据里根本没有“原方案”这个字段。我采用的方式是“两步走”先用模板做机器渲染把求解结果转成一份严格忠实于数据的结构化文本再让大模型在这份文本的基础上做语言润色。润色阶段我会明确要求只能复述给定数据不能新增任何数字和结论。def render_solution(solution, variables_meta): lines [] for var_name, value in solution.items(): if value 1 and variables_meta[var_name][type] binary: lines.append( f{variables_meta[var_name][desc]}启用 ) elif variables_meta[var_name][type] integer: lines.append( f{variables_meta[var_name][desc]}{value} ) return \n.join(lines)这步模板渲染相当于给大模型加了一道护栏它的任务只是把“已启用的决策”和“数字取值”整理成更通顺的排班表或要点说明而不是凭空分析。4.2 一个防幻觉的技巧把原始solver输出注入prompt我试过在提示词里写“请根据以下数据生成说明”模型仍然会偶尔多嘴。后来我把原始输出和模板渲染结果同时注入提示词并要求模型逐条对齐情况好了很多。具体做法是这样的渲染文本里每一行都对应一个变量ID提示词里写明“如果你的回答中出现了渲染文本中没有的变量或数值视为错误”。另外我会让模型先输出一个“数据核对结果”比如“已核对共启用3个任务总工时为12”再输出正式的自然语言方案。多这一步核对模型的编造率会明显下降。4.3 临界场景无解和无穷大小的自然语言解释业务里最让人崩溃的不是求不出最优解而是系统只说一句“无解”。用户根本不知道是哪几个条件冲突了。所以我在0-2阶段处理了两个临界场景。第一个是“无解”。PuLP求解后如果状态是Infeasible我会把原始约束列表拉出来逐条加入一个简化模型做松弛测试先去掉所有约束看是否有解再逐个加回约束尝试定位导致冲突的组合。虽然这个方法不保证100%精准但当问题规模不大时它会给出一个很实用的提示比如“任务A不能同时满足‘必须在工程师1处加工’和‘工程师1的容量已满’这两个条件”。第二个是“无穷目标”。如果模型变量定义太少或约束没写全目标函数可能无界。遇到这种情况我不会直接把原始报错抛给用户而是提示“目标函数缺少有效约束请补充资源限制或业务边界”。这一句话往往就能让业务方反应过来“哦我确实没说每个人最多干几小时。”这个提示本身就是系统的价值。5. 0-2阶段实测复盘那些跑崩又重新站起来的地方5.1 测试集设计从“答对”到“通用”这一阶段我准备了八个测试问题刻意覆盖不同领域工程师排班、设备分配、订单排产、仓库装载。每个问题我都提前写好了人工校验后的最优解或合理解作为基准。测出的整体通过率用最严格标准算大约从[0-1]的40%提到了80%。剩下的20%主要集中在解析层对复杂长句的拆分失败。测试问题类型是否得到可行解是否达到最优/近优主要问题点三名工程师完成四个任务总工时最小分配是是无五台设备排产最小化总拖期调度是是无班次安排每人每周最多五天排班是是无重量约束下最大化装载价值背包是是无订单交期与设备容量同时限制混合是否约束缺了一个容量条件长句描述的项目优先级分配分配是是解析耗时较长包含“尽量”“最好”等模糊词语义模糊是否模型把模糊词当成了硬约束用户输入非优化问题边界否-成功识别为不支持类型5.2 三个典型故障还原排故障的过程比顺利跑通更值得记录我把三个印象最深的故障场景还原一下。故障一JSON超长截断导致括号不闭合。模型输出一长串约束超过了接口返回的最大长度限制结果JSON被直接截断。修复方案不是简单地调大长度上限而是在提示词里要求模型“用最精简的字段描述约束避免重复展开所有变量组合”并在解析层增加自动补全右括号的容错逻辑。故障二模型自创了不存在的魔法值。有一次测试里模型在约束里写了个常量x_0 5000 6000但5000这个数字在原始问题里根本没有出现。这其实是模型在“合理化”业务描述。我的修复办法是在提示词里加入硬性规定“所有出现在表达式中的数字必须能从用户输入中找到对应依据禁止自行推导数值。”同时在后端对常数项做异常值检测超出业务合理区间的直接告警。故障三目标函数无界求解器一直算不出结果。最初把责任推给求解器后来定位到是变量集合定义过大但约束缺失造成目标可以无限压小。这个问题的根因在解析层而不是求解层。我的教训是IR结构中的constraints数组绝不能为空如果模型检测不到足够约束应该在解析阶段就拦截而不是等到求解超时才发现。5.3 耗时与成本经验0-2阶段的实测过程中单次问题的完整链路耗时分布大致如下阶段平均耗时说明大模型解析自然语言2.5秒取决于提示词长度和输出token数校验与修复0.2秒通常走一次规则修复即可求解器建模求解0.8秒中小规模问题结果渲染与润色1.5秒润色提示词较短整体链路在5秒上下浮动。成本方面单次请求的模型调用费用很低一个优化问题的解析加润色各一次调用加起来几乎可以忽略不计。这里需要提醒的是解析阶段的输出token过多会显著增加耗时所以提示词里我特意限制了“嫌疑变量”的数量宁可在校验层发现缺失再补抽一次也不要让模型一次性生成超大JSON。6. 没有写进代码里的经验再分享几个代码之外的心得。第一中间IR是整个系统的锚点。大模型可以换、求解器可以换但只要IR结构稳定整个系统的替换成本就非常低。后续如果觉得解析效果不好我可以只优化提示词如果觉得求解能力不够我可以把PuLP换成更专业的求解器IR和下游代码都不用大改。这就像盖房子先定了户型图后续装修怎么换都不塌。第二一定要给每一次请求留审计日志。我一开始没在意直到有一次模型解析了一个错误的约束我花了半天才从一堆变量里揪出来。后来我把模型原始输出、校验结果、修复记录、最终IR和求解器状态全部存成全链路日志出问题直接在日志里对照排查速度快了十倍。这个习惯尽早养成。第三别追求一次解决所有问题。我原计划把非线性优化也纳入0-2范围后来亲手把它砍掉了。砍掉之后系统的可靠性立刻上了一个台阶因为可校验的边界变得很清晰。当前这套系统在处理线性可建模问题上的能力已经足够撑起内部工具场景如果未来真要支持更复杂的随机优化或多目标优化那也应该是另一个系列的故事了。下一步我打算把IR结构升级成系数矩阵透传的形式这会让大型问题的构建效率更高同时考虑把“结果可视化”直接集成到输出层让排班结果不只是文字还能生成一张甘特图。这个系列还会继续更新但眼下这套从自然语言到最优方案的闭环已经能在不少业务场景里实打实地省人力了。