开门见山说个场景:老板甩过来一句话,“把下个季度的排班优化一下,别让任何人连续上七天班,每天至少三个人在岗,成本尽量低”。你一听就明白,但写代码的人明白,能让代码也明白吗?这就是自然语言优化求解系统要干的事——用户用大白话描述一个优化问题,系统自动把它翻译成数学模型,调用求解器算出结果,再翻译回人能看懂的执行方案。
我最近从零搭了这么一套系统【0-1】版本,今天把整个链路、关键代码、踩过的坑一次性讲清楚。这篇适合两类人:一类是做运筹优化但没搞过 NLP 的,另一类是懂 NLP 但不知道优化问题怎么建模的。读完你能照着自己的项目复刻一套最小可用版本。
1. 一句话需求背后的“隐性工程”:自然语言优化求解到底要拆几层
自然语言优化求解的难点从来不在求解器本身,而在“从自然语言到数学模型”这一跳。先把这句话拆开看:自然语言 + 优化 + 求解。优化求解本身已经非常成熟,有 OR-Tools、HiGHS、SCIP 这些工业级求解器;自然语言理解也有各种现成方案。难就难在两者之间那个翻译层,它既要有 NLP 的语义理解能力,又要能输出结构严谨、无歧义的数学表达。
1.1 一个看起来简单的需求,落地时有多复杂
拿“每天至少三个人在岗”这句话举例。人类理解起来毫无障碍:每天、至少、三个人、在岗,四个词就够了。但计算机要回答的问题远不止这些:“每天”是哪一天到哪一天?“在岗”对应什么决策变量?是每人每天一个 0-1 变量吗?“至少三个”是硬约束还是软约束?如果刚好凑不出三个人怎么办?人员和班次之间还有没有别的规则,比如不能安排休息日?
如果让大模型直接输出一个数学公式,第一次可能对,第二次换一种问法就错。我实测过让主流 LLM 直接写目标函数和约束,十个问题里大概有四五个会有细节错误,比如把“不超过 40 小时”写成“大于等于 40 小时”,或者漏掉“每天”这种频次限定。这个稳定性问题不是靠换模型能解决的,需要一层显式的中间表示来控制。
1.2 系统的输入输出定义与能力边界
做【0-1】版本最重要的一件事,是先划清边界。我的系统定位是:处理带明确决策变量的运筹优化问题,输出可执行的方案。具体来说:
- 输入:一段自然语言描述,包含决策对象、目标方向、约束条件、相关数值。
- 输出:一组具体的决策变量取值,以及对应的目标函数值。
- 不处理:开放式的“帮我写个方案”这类没有清晰目标的问题,也不处理描述性、预测性的 NLP 任务。
如果你把边界划得太宽,系统会变成四不像。0-1 版本先把“排班、分配、调度、资源规划”这几类典型场景吃透,比什么都能聊一点要实用得多。我内部总结了一个分类:凡是问题里能找出一组“谁在什么时候做什么”的决策矩阵,基本可以归入这一类。
1.3 0-1 版本的最小可用目标
我给这个版本定的验收标准很简单:给出一段不超过 200 字的排班或分配需求,系统能在 3 秒内给出一个合理可执行的方案,并且无解时能明确告诉我哪条约束和哪条约束冲突了。这个标准定完之后,后续所有技术选型都围绕它展开——要可控、要快、要能解释。
2. 架构选型:我为什么走“NL→IR→Solver→回译”这条链路
这个项目的技术选型我纠结了很久。一开始想走捷径,后来老老实实回到经典架构:自然语言解析 -> 结构化中间表示(IR) -> 求解器求解 -> 结果回译。这条链路看起来多了一层,但实际上是最稳的方案。
2.1 三条候选路线的对比
我实际评估过三种方案。
第一种:端到端,让大模型直接读需求,理解后直接输出答案。这个方案在原型演示时效果炸裂,因为模型很会“编”。但一测严肃场景就露馅,它无法保证答案满足所有约束,也不会告诉你哪条做不到。它本质是文本生成,不是计算。优化问题的核心是计算正确性,文本生成模型无法为此背书。
第二种:让大模型输出数学公式或可执行代码。这个方案比直接出答案靠谱一些,但同样有稳定性问题。同一个需求换个说法,模型可能输出完全不同的建模逻辑,目标函数系数、约束名称都可能漂移,后续根本没法做回归测试。最致命的是,数学公式写错了很难自动发现。
第三种:自然语言 -> 中间表示 -> 求解器,也就是我最终采用的方案。核心是让系统先产出一份结构化的“问题规格书”,这个规格书既可以被人类检查,也可以被机器校验。解析错了就在这一层改,不把错误传导到求解结果里。
2.2 中间表示的核心价值:可调试、可缓存、可回归
中间表示设计得好,整个系统就是积木式的。用户说了一句需求,先变成 IR,IR 再转成求解器模型。这意味着我可以拿到一段文本之后,先不看求解结果,只看 IR 就知道系统理解对了没有。调试效率成倍提高。
同时,IR 层还能做缓存。同样的问法——哪怕措辞不同但 IR 相同的需求,第二次请求可以直接返回历史结果。这个优化在 0-1 版本里非常实用,因为测试阶段会反复用同一批问题轰炸系统。到了后面接入真正的 LLM 组件后,IR 也可以作为 LLM 输出的校验格式,模型只负责填槽,不负责自由发挥。
2.3 组件清单和大致分层
- NLU 层:负责词法、句法、槽位识别,把文本里出现的决策对象、数值、约束词抽出来。0-1 版本以规则 + spaCy 为主,可选择接入 LLM。
- IR 层:定义数据结构和校验逻辑,将 NLU 抽取结果组装成优化问题描述。
- 模型映射层:定义 IR 到求解器特定 API 的转换规则。
- 求解层:接入 OR-Tools,提供诊断信息和超时保护。
- 回译层:把求解结果转回自然语言报告,附上必要的统计指标。
这个分层让每一层都能独立测试。我写代码的时候养成了一个习惯:每层都必须有对应的“可视化”输出。UI 还没做之前,IR 层用 JSON 打印,求解层用文本打印,回译层直接输出 Markdown 表格。哪个环节出了问题,一眼就能定位。
3. 解析层实战:把自然语言拆成“变量、目标、约束”三件套
NLU 层是整条链路里最“脏”的活。它不像求解器有严格数学定义,也不像 IR 层有清晰数据结构,它要跟人类千奇百怪的表达搏斗。我的做法是:不要试图理解所有语义,只抽取三样东西——决策变量、目标、约束。
3.1 决策变量识别:先找“谁、在什么对象上、做什么选择”
决策变量是优化问题的地基。识别它的思路很简单:先找到领域对象,再找到对象上有哪些可变的“安排”。
在排班场景里,典型的一句话是“给 5 个员工排下周值班表”。“员工”是决策对象的维度,“下周每天”是另一个维度,“是否值班”是变量的取值类型。我用 spaCy 做依存句法分析,先抽名词短语作为候选对象,再由领域词典判断哪些名词是决策对象。领域词典里维护了“员工、人员、门店、仓库、设备”这类实体词,以及“排、安排、制定、分配”这类触发动词。
还有一类变量藏在量词短语里,比如“每天 3 个人”“每周不超过 4 天”。这里的“天”“周”是时间维度,数字是右侧约束的一部分。0-1 版本我倾向用正则先把数字和单位绑定,再交给后面的约束解析器。正则虽土,但在固定场景里比模型可靠得多。
3.2 目标识别:方向词、程度词和比较结构
目标通常由“最小化、最大化、降低、提高、尽量、均匀”这类词触发。最小化/最大化属于显性目标,问题不大。难在“尽量”和“均匀”这种软目标,它们不是数学语言,需要翻译成可量化的指标。
“尽量让每个人值班次数均匀”,我会把“均匀”映射为一个 minimax 目标:最小化“最大值班次数减去最小值班次数”。这属于建模决策,不是 NLU 的文本抽取问题。NLU 层只需要标记出这是一个“均匀性目标”的候选,具体的数学形式由建模模板决定。
“降低总成本”这类比较级表达,需要识别出比较的对象是“总成本”,然后确认隐含的优化方向是“减少”。我整理了一个目标词表,分为两类:贪小类(最小化、降低、减少、压缩、节约)和贪大类(最大化、提高、增加、覆盖全部)。这个词典需要跟着行业反复扩充,没有一劳永逸的办法。
3.3 约束识别:否定词、范围词、频次词和关系结构
约束是优化问题里最容易出错的部分。用户说“别让任何人连续上七天班”,这句话有双重否定味道。正则层面很难优雅处理,我用了两个手段:一是句法分析拿到否定词“别”和动词“上”的关系;二是维护一个隐含规则“别让 X 做 Y”映射为“X 不做 Y”的约束。
范围词也很关键。“每个人每周不超过 4 天”里,“不超过”对应上限约束;“至少 3 人在岗”对应下限约束。我抽出一组数量短语后,会把它们在句子中的位置和动词关联起来,判断它是一个绝对约束(“必须满足”)还是软约束(“尽量满足”)。判断依据是情态词:出现“必须、不得、禁止、不能”一律是硬约束;出现“尽量、最好、希望”则标记为软约束候选。
约束关系里还有一种隐藏结构:“如果 A 上班,那么 B 也必须上班”。这是逻辑蕴含,NLU 层只负责把它抽象成“A→B”的边,具体建模放到模型映射层处理成线性不等式。
3.4 可选增强:用 LLM 做槽位填充并用 Schema 约束输出
如果你的目标场景更开放,可以在这个解析层接入 LLM,但不是让它自由发挥。我给 LLM 设计了一个 JSON Schema,要求它从文本里抽取固定的槽位:决策对象、时间维度、目标类型、约束列表(类型、数量、强度)。Schema 本身已经定义了 80% 的正确性,LLM 只需要做“填空题”。
我用过一个简化版的 prompt 模板:给定用户原句,输出一个固定格式 JSON,包含decision_variables、objective、constraints、parameters四个字段。相对直接让 LLM 写公式,这种方法在测试集上的准确率从 60% 提高到了 80% 以上。剩下的 20% 错误,靠 IR 层的规则校验和人工修正兜底。
4. 中间表示(IR)的设计:一份让人类和机器都能读懂的“优化问题规格书”
IR 是整条链路里投入产出比最高的设计。它本质上是一份优化问题的规格说明书,既要严格到能被程序无歧义解析,又要宽松到能容纳自然语言抽取出来的模糊信息。我最终选择用 Python 的 dataclass 加 JSON 序列化来表达,方便调试也方便对接求解层。
4.1 核心数据结构:Problem / Variable / Objective / Constraint
先看一个最简版本的 IR 设计。每个优化问题,本质上由四部分组成:
- Variable:决策变量。我统一用
name + dims来描述,dims是多个维度的集合。比如员工排班中,维度就是“员工集合 × 天集合”。 - Parameter:已知参数。比如“每天至少 3 人”里的 3,“每人每周上限 4 天”里的 4。
- Objective:目标函数。我限制为单目标,0-1 版本不做多目标加权,保证可解释性。
- Constraint:约束列表。每条约束包含类型、涉及的变量范围、上下界。
I 用 dataclass 实现的大致骨架如下:
from dataclasses import dataclass, field from typing import Dict, List, Optional @dataclass class Variable: name: str # 例如 "assign" dims: List[str] # 例如 ["employee", "day"] type: str = "binary" # binary / integer / continuous @dataclass class Objective: sense: str # "min" / "max" expression: str # 例如 "sum(assign * cost)" 或 "maximize(assign)" form: str = "linear" # 建模类型,由解析层标记 @dataclass class Constraint: name: str # 例如 "staffing_coverage" kind: str # "sum_lower" / "sum_upper" / "implication" / "forbidden" variables: List[str] # 涉及的变量名 operator: str # ">=" / "<=" / "==" value: float # 右侧数值 strength: str = "hard" # "hard" / "soft" @dataclass class ProblemIR: variables: Dict[str, Variable] parameters: Dict[str, float] objective: Optional[Objective] constraints: List[Constraint]别小看这个看似简单的结构。它逼着我把所有自然语言表达都落到三类约束里:数量约束、变量关联约束、禁止型约束。凡是落不进去的,就先不带进求解器,而是输出一个“未识别约束清单”,让用户确认。这个“确认”环节帮我防住了大量解析错误。
4.2 决策变量的组织方式:一维二维还是三维
自然语言里很少直接说“二维变量”,但描述里隐含了维度信息。“每个员工每天是否值班”就是一个二维变量,维度是员工和天。“每个门店每天的库存量”也是二维。“每个员工每天每班次”则是三维。
IR 层不直接创建具体的变量实例,而是记录维度和取值类型,真正展开留给模型映射层。这样做的原因是:维度可能很大,比如 100 个员工 × 30 天 = 3000 个变量,求解层才需要真正的变量数组,IR 层只需要一个描述。我踩过的一个坑是:早期 IR 直接展开变量,结果 3000 个变量打印出来的 JSON 有几十万字符,既没法看也没法改。
4.3 约束规范化:把千奇百怪的说法变成少数几种数学模式
约束的自然语言表达千变万化,但落到数学上就那么几类。我的 IR 层把约束归为五类:
- 范围约束:变量总和在某个区间,对应“每天至少 3 人”。
- 比例约束:两个总和的比值有要求,对应“熟练工的比例不低于 30%”。
- 顺序约束:变量之间的先后关系,多用于调度问题。
- 互斥约束:两个选项不能同时选,对应“同一天不能既排白班又排夜班”。
- 蕴含约束:一个选择触发另一个选择,对应“如果 A 上夜班,则 B 也必须上夜班”。
这个分类表是我不断积累出来的。每一种自然语言表达被归入某个类别时,我都会在代码里留下一段注释,记录用户原话和对应模板。这些模板累计到一定程度后,系统对常见表达的吸收能力会越来越强,这种积累速度是纯 LLM 方案完全比不上的。
4.4 IR 的校验逻辑:宁可说“没听懂”,不要给错结果
我给自己定了一条规矩:IR 无法完整校验时,不允许进入求解器。校验包括三部分:
第一,变量和约束引用是否一致。约束里用到的变量名必须是已定义的变量,否则直接报错。
第二,数值参数是否齐全。“每天不超过 4 天”这句话里,“不超过”有了,但缺少对象是“每个人每周还是每天”,解析结果里会有object字段留空,校验器会提示需要补充。
第三,约束强度是否明确。硬约束和软约束如果没有被标记,默认按硬约束处理,避免系统为了迎合一个无关紧要的“尽量”而把整个模型搞到无解。
如果校验失败,系统不抛异常,而是返回一段话:“您提到的一些要求我还没有完全理解:XX,请再说明一下。”这个体验比给一个错误结果强得多,也减少了调试期的大量困惑。
5. 求解引擎接入与容错:从 IR 到求解器模型的最后一公里
IR 定好了,求解层反而是整个项目里最不需要“创新”的部分。因为求解器就是拿来主义,关键是选型和容错。我先说结论:0-1 版本我选 OR-Tools CP-SAT,因为它处理离散排班问题又快又稳,还自带诊断信息。如果你的问题以线性规划为主,可以考虑 HiGHS;如果想快速写原型,PuLP 更简单,但它底层求解器性能弱一些。
表格对比一下我实际测过的三个方案:
| 方案 | 适合场景 | 模型表达能力 | 求解速度 | 诊断能力 | 上手难度 |
|---|---|---|---|---|---|
| OR-Tools CP-SAT | 排班、分配、组合优化 | 支持整数和布尔运算,很灵活 | 快 | 有sufficient_assumptions、hints | 中等 |
| HiGHS | 大型线性规划 / 整数规划 | 线性能力强,非线性弱 | 快 | 无解原因需要自己分析 | 中等 |
| PuLP | 教学和验证模型 | 标准线性/整数 | 一般 | 弱 | 低 |
5.1 为什么最终选了 OR-Tools CP-SAT
自然语言优化场景有个特点:约束常常带条件逻辑。“如果安排了 A,那么 B 就不能排同一天”“同一人连续上班不能超过 5 天”。这些逻辑用线性规划表达非常别扭,但用 CP-SAT 的布尔变量的OnlyEnforceIf就很简单。它把整数规划、布尔逻辑和区间约束揉在一起,非常适合从自然语言生成的这种“奇形怪状”的模型。
另外 CP-SAT 求解过程中可以给超时限制。自然语言需求往往来自交互式场景,用户不可能等 5 分钟看排班结果。我设置默认求解上限 5 秒,超时后用当前最优解兜底。实测绝大部分 50 个员工 × 30 天的排班问题在 2 秒内能给出可用解。
5.2 从 IR 到 CP-SAT 模型的映射规则
这一层就是把 IR 里的变量、约束、目标翻译成 OR-Tools API 调用。大致映射规则如下:
- 二元变量映射成
model.NewBoolVar(name)。 - 整数变量映射成
model.NewIntVar(lb, ub, name)。 - 数量约束“每天至少 3 人”映射为
sum(vars) >= 3。 - 互斥约束“不能同时安排 A 和 B”映射为
a + b <= 1。 - 蕴含约束“A 则 B”映射为
a <= b或OnlyEnforceIf。 - 均匀性目标映射为引入辅助变量
max_shift和min_shift,然后最小化它们的差值。
映射层必须保持“薄”。我的习惯是每映射一条约束就打印一行调试日志,日志格式为[IR约束名] -> [求解器API]。调试时打开日志,能看到自然语言到底被翻译成了什么数学表达,对排查语义错误特别有效。
from ortools.sat.python import cp_model def build_model(ir: ProblemIR): model = cp_model.CpModel() # 为每个变量创建 CP-SAT 变量 cp_vars = {} for vname, var in ir.variables.items(): if var.type == "binary": cp_vars[vname] = { # 这里按维度展开时统一用名称拼接 key: model.NewBoolVar(f"{vname}_{key}") for key in expand_dims(var.dims) } # 添加约束 for c in ir.constraints: expr = sum(cp_vars[v][k] for v in c.variables for k in ...) if c.operator == ">=": model.Add(expr >= c.value) elif c.operator == "<=": model.Add(expr <= c.value) else: model.Add(expr == c.value) return model, cp_vars上面是简化示例,实际代码里还会处理“员工集合”和“天集合”的名称对应。一个常见错误是维度名称不匹配——IR 里叫employee,求解层展开时叫staff,结果约束全串了。我统一在一处维护了一个字典,所有维度名必须经过它转换。
5.3 无解的诊断与降级策略
自然语言生成的模型特别容易无解。最典型的情况是:用户既要“每天至少 3 人”,又要“每人每周不超过 2 天”,而员工总数只有 3 人,这时交叉约束根本凑不出来。
CP-SAT 不会直接告诉你“哪个约束冲突了”,但你能用假设和松弛来定位。我的做法是:第一遍用全部硬约束求解,若无解,则进入“软约束诊断模式”。把所有硬约束逐步放宽,比如把“至少 3 人”降为“至少 2 人”,看哪一条约束放松后模型变为可行。这个过程能自动生成一句人话:“当前要求与员工总数不匹配:每天至少需要 3 人,但每周只有 2 个可用班次可分配。”
无解时的用户反馈比有解时的报告更重要。你在生产环境里接触到的大部分问题,都属于需求本身自相矛盾。提前把这个诊断链路做好,比调优求解参数更值得。
5.4 软约束的惩罚成本设计
自然语言里那些“尽量、最好、希望”怎么办?我的方案是将软约束转化为目标函数里的惩罚项。用户说“尽量让每人值班次数均匀”,我不会把它设为硬约束,而是计算每个人值班次数的最大值和最小值,在目标函数里加上penalty * (max_shifts - min_shifts)。
这里有个坑要注意:惩罚系数不能凭感觉乱设。系数太小,软约束形同虚设;系数太大,可能为了均匀牺牲其他关键目标。我一般用一个基准:先把硬约束求解完,得到一个最优目标值,然后再把软约束的惩罚系数设置为该目标值的 1%~5%,这样不会喧宾夺主。这个比例是我在多个场景里试出来的经验值,未必适用所有领域,但作为起点很稳。
6. 结果回译:把数值解翻回“人话”并给出可执行动作
求解器吐出来的是一堆 0 和 1,用户要的是一张能直接拿去执行的表格。结果回译层的任务,就是把解翻译成人话,并且把关键指标列清楚。这个层做得好不好,直接决定系统给人的第一印象。
6.1 从变量取值到执行方案
拿排班问题来说,求解器给出的二进制决策变量assign[员工][天]=1表示该员工当天值班。回译层要做的:第一,汇总每个员工的累计划班数;第二,按天列出当天值班人员名单;第三,检查硬约束是否全部满足。
我输出的格式是一个 Markdown 表格,每天一行,字段包括“日期、值班人员、当天人数、备注”。如果系统发现某天人数刚好踩线,会在“备注”列提示“刚好满足最小需求,建议预留机动人员”。这个提示不是求解器给的,是回译层的启发式规则,但对最终用户非常有用。
如果求解用的是软约束,回译层还要单独列出软约束的满足情况:“值班均匀度:差异不超过 1 天(要求:尽量均匀)”。把硬约束和软约束分开报告,用户就能知道哪些是必须做到的,哪些是尽量优化到的。
6.2 可解释性:为什么是这个方案
很多用户会问“为什么这么排”。想回答这个问题,需要用到对偶分析或松弛分析。对 LP 类问题,影子价格能告诉你哪条约束是瓶颈;对 CP-SAT 这类离散模型,影子价格不是直接输出,但你可以通过对比两个模型来回答:一个是带约束的解,一个是去掉某条约束后的解,后者的目标改进量就是这个约束的“隐性成本”。
我用一个简化的办法:在求解完成后,额外跑一次“仅满足硬约束”的最小成本模型,然后和“硬约束 + 软约束”的完整模型对比。两者的目标函数差值,就是系统为了满足这些“尽量”付出的代价。回译层的“代价说明”里就写:为了让大家排班更均匀,总成本比最低成本方案多了 500 元。这比干巴巴的数字解释强得多。
6.3 交互式修正的入口
0-1 版本不可能一次就把所有用户意图猜对,所以结果回译的末尾要留一个“如果觉得方案不合理,可以告诉我”的交互入口。这个入口对接的不是求解器,而是 IR 层的约束调整器。用户说“小王别排那么多班”,约束调整器先判断小王是谁,然后找到所有assign[小王][天]=1的记录,自动增加一条硬约束sum(assign[小王][:]) <= 1并重新求解。
如果没有这个入口,用户只能拿到一次结果,不满意就得重来,系统会给人“很笨”的感觉。加了调整入口之后,系统反而显得聪明,因为每个调整都会立刻反馈成新的方案。
7. 端到端案例:用自然语言调度一个“一周排班”问题
理论说了这么多,来一个能跑的完整案例,把全链路串起来。
7.1 用户输入与期望输出
我模拟一段用户输入:“给 5 个员工排下周的值班表,每天至少 2 人值班,每人每周不超过 4 天,每周一必须包含张三,尽量让每个人值班天数均匀。”
人类看到这段话,大脑里已经自动完成建模了。接下来的代码演示,就是让系统复现这个过程。
7.2 解析层的中间结果
解析层先经过分词、实体识别、槽位抽取,得到的 IR JSON 大致长这样:
{ "variables": { "assign": { "dims": ["employee", "day"], "type": "binary" } }, "parameters": { "employees": ["张三", "李四", "王五", "赵六", "孙七"], "days": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"], "min_daily": 2, "max_weekly": 4, "must_include": {"day": "周一", "employee": "张三"} }, "objective": { "sense": "min", "type": "uniformity" }, "constraints": [ {"kind": "sum_lower", "variables": ["assign"], "value": 2}, {"kind": "sum_upper", "variables": ["assign"], "value": 4}, {"kind": "must_include", "variables": ["assign"], "value": 1} ] }到这里,机器已经“听懂”了。剩下的交给模型映射层,把它翻译成 CP-SAT 可以吃的模型。
7.3 求解代码骨架与运行
下面这个代码是完整的 0-1 版本核心逻辑,我把它精简到能直接运行的长度:
from ortools.sat.python import cp_model employees = ["张三", "李四", "王五", "赵六", "孙七"] days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] model = cp_model.CpModel() x = {} for e in employees: for d in days: x[(e, d)] = model.NewBoolVar(f"x_{e}_{d}") # 每天至少 2 人 for d in days: model.Add(sum(x[(e, d)] for e in employees) >= 2) # 每人每周不超过 4 天 for e in employees: model.Add(sum(x[(e, d)] for d in days) <= 4) # 周一必须包含张三 model.Add(x[("张三", "周一")] == 1) # 均匀性目标:最小化最大值班天数与最小值班天数的差 each_total = {} for e in employees: each_total[e] = sum(x[(e, d)] for d in days) max_total = model.NewIntVar(0, 7, "max_total") min_total = model.NewIntVar(0, 7, "min_total") model.AddMaxEquality(max_total, [each_total[e] for e in employees]) model.AddMinEquality(min_total, [each_total[e] for e in employees]) model.Minimize(max_total - min_total) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = 5 status = solver.Solve(model) print(solver.StatusName(status)) if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): for d in days: on_duty = [e for e in employees if solver.Value(x[(e, d)])] print(f"{d}: {', '.join(on_duty)}")跑出来的结果大概会是:周一张三在列,人数每天 2 到 3 人,每个人总值班天数差异不超过 1 天。这就是“尽量均匀”在数学上的体现。
7.4 回译与展示
求解完成后,回译层输出一份带统计的报告:
- 总值班人次:16 人次。
- 每人值班天数:张三 3 天、李四 3 天、王五 3 天、赵六 3 天、孙七 4 天。
- 最大差距:1 天。
- 硬约束验证:全部满足。
如果系统发现问题,比如某天只有 1 人值班,会在报告里标红,并附上原因分析:“今天满足最低 2 人在岗要求,但当前仅有 1 人,因为剩余可用班次受限。”
7.5 0-1 版本的项目目录结构
最后给一下我实际使用的目录结构,方便你搭自己的项目时有个参考:
nl_opt_solver/ ├── nlu/ # 自然语言解析:实体、关系、槽位抽取 ├── ir/ # 中间表示定义与校验 ├── mapping/ # IR 转求解器模型 ├── solver/ # 求解封装、超时、诊断 ├── interpreter/ # 结果解释与回译 ├── tests/ # 测试用例,按场景分类 └── examples/ # 排班、运输、背包等示例输入8. 从 0-1 过程中踩过的坑与实用建议
最后这部分是我最想分享的。网上讲自然语言优化的文章很多,但很少有人把踩坑细节写出来。我整理几个真实的教训。
8.1 解析层最容易被忽略的问题:同义表达和省略主语
自然语言解析最大的敌人不是长句,而是短句。用户说“周三的班少排人”,系统很难判断这是“周三当天的人数要少”,还是“周三这天要减少某人的班次”。这种省略主语的表达,0-1 版本处理不了,我的对策是:在回译层反问用户。系统会输出“您说的‘少排人’,是指周三总人数要少,还是某个具体员工的班次要少?”这个反问的成本很低,但能避免后续一连串错误。
另一个常见问题是同义表达。“不能上夜班”和“别安排夜班”“夜班不要”在语法结构上完全不同,但意图相同。我在词典里把“不要、别、不能、禁止、不得”统一映射到一个否定集合,再用句法关系确认否定对象是“夜班”而不是“员工”。
8.2 建模层的坑:“均匀”有不止一种建法
“尽量均匀”这个目标,新手很容易直接写成最小化总值班天数的方差。我在测试中发现方差模型用 CP-SAT 求解很慢,因为方差是二次函数,离散优化里二次目标代价很高。后来改成“最小 化最大-最小 差值”(minimax)模型,求解速度提升了十几倍,而且效果更符合人类直觉。小技巧:对“均匀性”需求,优先用AddMaxEquality和AddMinEquality辅助变量,不要直接用方差。
8.3 求解层的坑:整数模型的系数爆炸
自然语言模型里经常出现“每、各、全部”这类全称量词。把这些词硬编码成对每个元素加约束时,模型大小会指数膨胀。比如“每个员工在连续 7 天内至少休息 2 天”这一条需求,如果直接展开成所有连续窗口的组合,会产生大量约束。这个场景要用滑动窗口去重,不是每个窗口都建约束,而是用滚动统计的方式合并同类约束。这个优化能把模型规模缩小一个数量级。
8.4 验收与回归:没有标注数据怎么测
自然语言优化系统最缺的就是测试数据。我的做法是:手工收集 100 条真实场景描述,按场景分成 5 类,每一类都写好预期 IR 和预期结果。每次改了解析逻辑,就跑一遍这 100 条。不求每条都 100% 正确,但要求正确率不低于上次。这个方法朴素,但能保证系统一步步变好,而不是越改越乱。
你如果暂时没有真实数据,可以拿几个经典运筹问题反推:运输问题、背包问题、排班问题,每个都写 20 种不同说法。这些说法就是天然测试集。
8.5 后续扩展的方向
0-1 版本跑通之后,自然会有几个扩展方向。往 NLP 深处走,可以把规则解析换成基于语义角色的抽取模型,让系统兼容更多表达。往优化深处走,可以支持多目标加权和用户偏好的动态调整。往产品走,可以加一个基于 IR 的“自动生成约束说明”功能,让用户每次求解前先看到系统对自己需求的理解,减少误解。
我个人觉得最值得做的是 IR 之上的“方案对比”。用户往往会问“如果每天必须 3 人才行呢”这种 if-what 问题。因为系统已经有了 IR,只需要改一个参数重新求解,就能立刻给出对比结果。这个能力比任何花哨的 NLP 功能都更贴近实际运营场景。
这套系统最核心的心得就一句:别急着让机器学会理解所有话,先让它学会把理解到的内容“说”给你听。IR 层就是那个“说”的出口,当你和系统能在 IR 层达成共识时,后面的求解反而是水到渠成的事。