自然语言优化求解这件事,我最早是在一个排产项目里被逼着做的。当时业务方丢过来一句话:“帮我把这周的产线排一下,尽量别加班,交期别拖。”听起来像人话,但要把这句话变成求解器能吃的数学模型,中间隔着一整套翻译工作。后来我干脆想,能不能让系统自己完成这层翻译——用户说人话,系统输出可求解的模型、跑出结果、再用人话解释回来。这就是我动手搭这套自然语言优化求解系统的起点。
这篇是系列的第 0-1 篇,重点不在堆功能,而在把骨架立起来:怎么把一句自然语言拆成结构化的优化问题,怎么选求解策略,怎么用 KKT 条件做结果校验,以及怎么让 LLM 在其中扮演“翻译官”而不是“算命先生”。如果你做过运筹优化、碰过 LLM 应用,或者单纯想搞明白“大模型到底能不能算数学题”,这篇应该能给你一些能直接抄的工程思路。
1. 先想清楚:为什么不能让 LLM 直接给答案
1.1 一个反直觉的实测结论
我最初的做法很朴素:把用户的自然语言描述直接丢给 LLM,让它输出最优解。测试了二十来个线性规划小题,结果惨不忍睹。不是格式错,就是约束漏,最要命的是它给出的“最优解”经常连可行域都不在。比如一个简单的两变量问题,约束是 x+y≤10、x≥0、y≥0,让它最大化 3x+2y,它一本正经地告诉我 x=7、y=5,加起来 12,直接违反约束。
这件事让我彻底放弃“LLM 当求解器”的幻想。LLM 的本质是概率语言模型,它擅长的是模式匹配和语言生成,不是数值迭代。你让它解方程,它是在“回忆”见过的类似题目长什么样,而不是真的在算。规模小、结构常见的时候它可能蒙对,一旦变量多一点、约束绕一点,它就开始编。
所以正确的分工是:LLM 负责把自然语言翻译成结构化问题,求解器负责真正求解,校验模块负责兜底。LLM 是翻译官,不是数学家。
1.2 系统要解决的三层问题
把需求拆开看,这套系统其实要解决三层问题,一层比一层难:
第一层是语义解析。用户说“尽量别加班”,系统得知道这是个软约束,对应目标函数里的加班惩罚项;“交期别拖”是硬约束,对应每个任务的完成时间上限。这层靠 LLM 的语义理解能力,但必须用结构化 schema 约束它的输出。
第二层是模型构建与求解。解析出来的东西要变成标准的优化问题形式——目标函数、决策变量、约束条件。然后根据问题类型选求解器:线性问题用 LP,整数问题用 MILP,非线性连续问题可能得上梯度类方法或者差分进化。
第三层是结果校验与解释。求解器给出的解,得验证它真的满足约束(KKT 条件在这里派上用场),然后翻译回自然语言告诉用户“为什么这么排”。
这三层里,第一层和第三层是 LLM 的主场,第二层是传统优化的地盘。把边界划清楚,系统才稳。
1.3 为什么选差分进化做兜底
关键词里出现了差分进化,这不是随便选的。实际业务里的优化问题,很多是非线性、非凸、甚至目标函数没有解析表达式的(比如仿真优化)。这类问题梯度类方法容易陷局部最优,而差分进化(Differential Evolution, DE)作为一种群体智能算法,不依赖梯度,全局搜索能力不错,实现也简单。
我的策略是分层求解:能用精确求解器的(LP/MILP)优先用精确方法,保证最优性;遇到非线性、非凸、或者求解器报 infeasible 但怀疑是数值问题的,切到差分进化做启发式搜索,给一个“够好”的可行解。DE 在这里的角色是兜底,不是主力。
2. 语义解析层:把“人话”翻译成结构化问题
2.1 定义一套中间表示 schema
LLM 输出最怕自由发挥,所以第一步是定义一套严格的中间表示(Intermediate Representation, IR)。我用 JSON Schema 约束,核心字段包括:
{ "problem_type": "LP | MILP | NLP | ...", "variables": [ {"name": "x1", "type": "continuous|integer|binary", "lower": 0, "upper": null, "desc": "..."} ], "objective": { "sense": "minimize|maximize", "expression": "3*x1 + 2*x2", "soft_terms": [{"expr": "...", "weight": 100, "desc": "加班惩罚"}] }, "constraints": [ {"expr": "x1 + x2 <= 10", "type": "hard|soft", "penalty": 0, "desc": "..."} ], "metadata": {"raw_text": "...", "assumptions": ["..."]} }这套 schema 的关键设计点在于:软约束和硬约束分开。硬约束是必须满足的,软约束是尽量满足、违反要付代价的。业务语言里大量存在“尽量”“最好”“优先”这类词,它们对应的都是软约束。把软约束单独拎出来,转成目标函数里的惩罚项,这是让模型贴近真实业务的关键。
另外assumptions字段很重要。LLM 解析时一定会做假设,比如“用户没说变量是否可负,我假设非负”。把这些假设显式记录下来,后面校验和解释的时候能追溯,也方便用户纠正。
2.2 用 few-shot 把解析准确率拉上来
光给 schema 不够,LLM 还是容易漏字段或者乱填。我的做法是配一组 few-shot 示例,覆盖典型场景:排产、装箱、路径、资源分配。每个示例都是“自然语言输入 → 标准 IR 输出”的配对。
实测下来,few-shot 的数量和质量比模型大小更影响解析准确率。我用一个中等规模的模型,配 8 个精心设计的示例,解析准确率(字段完整且语义正确)能从 60% 出头拉到 85% 以上。示例的设计要点是:每个示例突出一种语言现象,比如一个专门练“软约束识别”,一个专门练“多目标权衡”,一个专门练“隐含约束补全”。
还有一个技巧是让 LLM 先输出推理过程再输出 JSON。虽然多花点 token,但解析质量明显更稳。我一般要求它按“识别决策变量 → 识别目标 → 识别约束 → 标注软硬 → 输出 JSON”的顺序来。
2.3 解析结果的自动校验
LLM 输出完 IR,不能直接信,得先过一遍自动校验。我写了个校验器,检查几件事:
- 变量引用一致性:目标函数和约束里出现的变量,必须在
variables里定义过。 - 量纲合理性:如果变量有物理含义(比如时间、数量),检查上下界是否合理。
- 约束可满足性预判:把硬约束单独拎出来,用松弛变量做个快速可行性检查,如果明显矛盾(比如 x≥10 且 x≤5),直接报错让 LLM 重新解析。
- 表达式语法:把表达式字符串解析成符号表达式,语法错的直接打回。
这一步能拦掉相当一部分低级错误。校验不通过时,把错误信息连同原始输入一起回喂给 LLM,让它修正,一般重试一两次就能过。
提示:校验器不要做得太“聪明”。它的职责是拦截明显错误,不是替 LLM 做语义判断。语义层面的对错,最终还是靠 few-shot 和人工抽检来保证。
3. 求解层:精确方法与差分进化的分工
3.1 问题类型判定与求解器路由
IR 拿到手,第一件事是判定问题类型,然后路由到对应的求解器。判定逻辑大致是这样:
| 特征 | 判定类型 | 求解器选择 |
|---|---|---|
| 目标与约束全线性,变量连续 | LP | 单纯形/内点法 |
| 含整数/二进制变量,线性 | MILP | 分支定界 |
| 目标或约束非线性,可求导 | NLP | SLSQP/内点法 |
| 非线性、非凸、无梯度 | 黑箱优化 | 差分进化 |
| 含逻辑约束(if-then) | 需线性化 | MILP + 大M法 |
路由这块我踩过一个坑:一开始想全用 MILP 统一处理,把连续问题也离散化。结果精度和性能都崩了。后来老老实实按类型分流,LP 走 LP,MILP 走 MILP,各用各的强项。
对于含逻辑约束的情况,需要先做线性化。比如“如果开机器 A 就必须开机器 B”,可以引入二进制变量和大 M 法转成线性约束。这部分我放在 IR 到标准模型的转换层里做,LLM 不参与,纯规则处理。
3.2 差分进化的参数怎么调
差分进化用在兜底场景,参数配置直接决定它能不能在合理时间内给出可用解。我常用的配置是:
- 种群规模 NP:取 10 到 20 倍变量维度,但不超过 200。维度高的时候种群太小会早熟,太大又慢。
- 缩放因子 F:0.5 到 0.9 之间,我一般取 0.7。F 大探索强,F 小开发强。
- 交叉概率 CR:0.8 到 0.95,取 0.9。CR 高有利于多样性。
- 变异策略:
DE/rand/1/bin做通用,DE/best/1/bin在收敛慢的时候切。 - 停止条件:最大迭代 1000 代,或者种群适应度方差小于阈值,或者连续 50 代无改进。
约束处理是 DE 的难点。我用的是罚函数法:违反硬约束的解,适应度加上一个很大的惩罚项;违反软约束的,按权重加惩罚。罚系数需要调,太小了约束守不住,太大了搜索空间被压扁。我的经验是罚系数取目标函数量级的 100 到 1000 倍,然后根据实际收敛情况微调。
3.3 精确解与启发式解的取舍
什么时候用精确解,什么时候接受启发式解,这个判断很关键。我的原则是:
- 如果问题规模在精确求解器能处理的范围内(比如 MILP 变量几百个以内),优先精确求解,拿到最优性证明。
- 如果精确求解器超时(我设 60 秒上限)或者报 infeasible 但怀疑是数值问题,切 DE。
- 如果问题本身就是黑箱、非凸,直接上 DE,不浪费时间。
DE 给出的解,我会再跑一遍可行性校验。如果连硬约束都不满足,说明罚系数或者参数有问题,需要调整重跑。如果满足硬约束、只是目标值比精确解差一点,那就接受,并在结果里标注“启发式解,非全局最优”。
4. 校验层:用 KKT 条件给结果上保险
4.1 KKT 条件到底在验什么
KKT(Karush-Kuhn-Tucker)条件是带约束优化问题最优解的必要条件。对于连续可微的问题,一个点要成为局部最优,得满足:梯度条件、原始可行性、对偶可行性、互补松弛。听起来抽象,落到工程上其实就三件事:
- 原始可行性:解满足所有约束。这个最基础,必须查。
- 互补松弛:如果某个不等式约束没取等号(即不紧),那它对应的拉格朗日乘子必须为零。这能帮我们发现“解是不是真的在边界上最优”。
- 对偶可行性:不等式约束的乘子非负。
我用 KKT 做校验,主要不是为了证明最优性(那需要凸性保证),而是为了发现异常。比如求解器给了一个解,原始可行性过了,但互补松弛大面积不满足,那很可能这个解有问题,要么是求解器没收敛,要么是模型本身有毛病。
4.2 数值校验的容差设置
KKT 是理论条件,实际数值计算里不可能严格等于零,得设容差。我的经验值:
- 原始可行性容差:1e-6。约束违反量超过这个就判不可行。
- 互补松弛容差:1e-5。乘子乘以松弛量,超过这个值就认为互补松弛被破坏。
- 梯度条件容差:1e-4。这个最松,因为数值梯度本身有误差。
容差不能设太严,否则浮点误差会让你误判;也不能太松,否则真问题被放过。这几个值是我在多个项目里调出来的,对大多数中小规模问题够用。
对于 DE 给出的解,KKT 校验要谨慎。DE 不保证满足 KKT,所以它给出的解 KKT 不满足是正常的。这时候我只查原始可行性,KKT 只作为参考信息记录,不作为拒绝依据。
4.3 校验失败时的排查链路
校验失败时,我有一套固定的排查顺序,避免瞎猜:
- 先查原始可行性。不满足约束,说明求解结果本身不可用。看是哪个约束违反、违反多少。
- 再查模型本身。把 IR 拿出来,人工看一遍约束和目标有没有写错。LLM 解析错误经常在这里暴露。
- 然后查求解器状态。看求解器返回的状态码,是 optimal、infeasible 还是 time limit。infeasible 的话,用松弛变量找出是哪组约束冲突。
- 最后查数值问题。如果模型没问题、求解器说 optimal,但 KKT 校验异常,可能是问题病态(条件数大),需要做变量缩放或者换求解器。
这套链路走下来,大部分问题都能定位。我遇到最多的是第 2 步——LLM 把某个约束的方向搞反了,或者漏了一个隐含约束。
5. 把三层串起来:一次完整的端到端跑通
5.1 一个排产场景的完整流程
拿开头那个排产需求举例,走一遍完整流程。
用户输入:“这周有 5 个订单要排到 3 条产线上,每个订单有交期和工时,尽量别加班,交期别拖。”
第一步,语义解析。LLM 输出 IR:决策变量是每个订单分配到哪条产线(二进制变量 x[i][j]);目标是最大化交期满足率、最小化加班时间(软约束转惩罚);约束包括每个订单必须分配一条产线、每条产线的总工时不超过可用工时(硬约束)、交期尽量满足(软约束)。
第二步,模型构建。这是个典型的指派问题加软约束,转成 MILP。硬约束用等式和不等式表达,软约束的违反量作为额外变量进目标函数。
第三步,求解。MILP 求解器跑,几秒出结果。如果规模大或者有非线性,切 DE。
第四步,校验。查原始可行性,所有硬约束满足;查 KKT,互补松弛正常。通过。
第五步,解释。把求解结果翻译回自然语言:“订单 1、3 分到产线 A,订单 2、5 分到产线 B,订单 4 分到产线 C。产线 A 周六需要加班 2 小时,其余产线正常。订单 2 的交期比要求晚半天,因为产线 B 产能紧张。”
这一整套跑下来,用户拿到的是能直接用的排产方案,而不是一堆数字。
5.2 各层之间的接口设计
三层之间靠 IR 和求解结果两个数据结构衔接。IR 是解析层的输出、求解层的输入;求解结果是求解层的输出、校验层和解释层的输入。接口设计的关键是信息不丢失:IR 里要保留原始文本和假设,求解结果里要保留求解器状态和 KKT 校验信息,这样出问题能追溯。
我一开始图省事,IR 里只存结构化字段,把原始文本丢了。结果解释层想引用用户原话的时候抓瞎。后来加上metadata.raw_text和assumptions,整个链路才顺。
5.3 实测中的意外情况
跑通之后遇到几个没想到的情况,记下来给后来人参考。
一个是变量命名冲突。LLM 解析时可能给两个不同含义的变量起一样的名字,比如两个场景都用 x1。校验器能查出来,但报错信息不友好。后来我在 IR 里强制变量名带语义前缀,比如order1_lineA,冲突就少了。
另一个是软约束权重难定。用户说“尽量别加班”,这个“尽量”到底值多少?我一开始让 LLM 猜权重,结果它给的权重忽大忽小。后来改成让用户确认,或者用一组默认权重先跑,把结果给用户看,再让用户调。权重这东西,本质是业务偏好,机器猜不准。
还有一个是DE 的随机性。同样的输入,DE 两次跑出来的解可能不一样。这在需要可复现的场景里是问题。我的处理是固定随机种子,并且在结果里标注“启发式解,存在随机性”。
6. 工程落地时我踩过的几个坑
6.1 LLM 输出的 JSON 解析失败
LLM 输出 JSON 经常带点“私货”,比如前后加解释文字、用单引号、末尾多个逗号。直接json.loads十有八九报错。我的处理是写一个健壮的解析器:先用正则把 JSON 块抠出来,再用宽容的解析库(比如允许尾逗号的),最后才用标准库。还不行就回喂给 LLM 让它重新输出纯 JSON。
更稳的做法是用结构化输出能力。现在不少模型支持强制 JSON schema 输出,能大幅降低解析失败率。如果模型支持,优先用这个。
6.2 求解器超时与降级策略
精确求解器遇到大规模问题会超时。我的策略是设超时上限,超时后不直接失败,而是降级:把当前找到的最好可行解拿出来用,同时标注“未证明最优”。如果连可行解都没有,才切 DE。
降级策略要提前设计好,不能等超时了才临时想。我在求解层封装了一个统一的solve接口,内部处理超时、降级、重试,上层不用关心。
6.3 结果解释的“人话”程度
解释层最容易犯的错是“翻译腔”——把求解结果机械地念一遍。好的解释应该回答用户真正关心的问题:方案是什么、为什么这么排、哪里做了妥协。我一般让 LLM 按“结论 → 关键约束满足情况 → 妥协点 → 建议”的结构来组织解释,并且要求它引用具体的订单号和产线名,不要泛泛而谈。
解释层还有个细节:不要过度承诺。如果解是启发式的,要明说“这是近似最优,可能存在更好的方案”。如果某个软约束被违反了,要明确告诉用户违反了多少、为什么。诚实比好听重要。
7. 这套骨架还能往哪长
骨架立起来之后,扩展方向其实挺多的。我目前想到几个:
一是多轮交互。用户看到结果后说“产线 A 的加班能不能再少点”,系统能理解这是调整软约束权重,重新求解。这需要把对话状态和 IR 关联起来。
二是模型库沉淀。把常见的优化问题模式(指派、装箱、路径、排产)做成模板,LLM 解析时先匹配模板,匹配上了直接套,匹配不上再从头解析。这样准确率和速度都能提升。
三是求解过程可视化。把 DE 的收敛曲线、MILP 的分支定界树画出来,让用户看到求解过程,增加信任感。
四是约束学习。从用户对结果的反馈里学习隐含约束。比如用户反复调整某个约束,系统可以主动问“是不是要把这个约束固化下来”。
我个人在实际操作中的体会是,这套系统最难的不是某个单点技术,而是三层之间的衔接和容错。LLM 会犯错,求解器会超时,校验会误报,每一层都得有兜底。把兜底做扎实,系统才敢用。至于 LLM 和传统优化的边界,我的经验是:凡是涉及数值计算和严格逻辑的,交给传统方法;凡是涉及语言理解和人机交互的,交给 LLM。这条线划清楚,后面的事就顺了。