1. 系统整体架构与方案选型
1.1 核心链路:从“人话”到“最优解”到底发生了什么
先说个场景。你手上有个排班需求,用户原话是“我们有6个老师,每天要上8节课,每人一周最多上5天,尽量别让张文老师安排在周五下午”。如果你手动建模型,得先把这段话翻译成变量、约束和目标函数,再写求解代码。这个过程不难,但每次都不一样,特别烦。所以我想做的,不是某个排班系统,而是一个“中间翻译层”:用户说什么,系统能自动把约束抽出来,生成一个优化模型,然后求解,最后把结果用自然语言还回去。
这就是自然语言优化求解系统的核心价值。它不是直接做问答,也不是简单搜索答案,而是把自然语言里的要求转成数学规划模型,再调求解器算出可执行方案。整个链路可以浓缩成三句话:
- 用户说了一段约束和目标;
- 系统识别意图、抽取实体、生成数学表达式;
- 求解器算出结果,系统把结果翻译回人能看懂的话。
我做第一版时,并没有一上来就上大模型、深度学习这类重武器,而是先用“规则引擎+轻量NLP+ORTools”搭了一个能跑通的最小闭环。后面再看情况逐模块替换。这么做最大的好处是可控:每一层输入输出都明确,出了问题能很快定位,不至于一个黑盒把所有事搅在一起。
1.2 为什么选择“规则引擎+轻量NLP”而不是堆大模型
在动手之前,我翻了很多方案。最让我犹豫的是:到底要不要直接用大模型做意图识别和实体抽取?大模型确实厉害,你喂一段话,它能给你“JSON输出”,连实体带关系全给你抽出来。但真落地到“优化求解”,我劝你先别急。原因有三点:
- 第一,解释性。优化求解本身就需要严谨的数学表达。如果实体抽取错了,用户问的是“明天上午”,模型抽成了“明天”,约束差一个时间段,结果就是完全不同的排班。你很难向业务方解释“为什么大模型会看漏”。
- 第二,可控性。规则是固定的,你可以测试一百条用例;大模型是有概率的,同一个表达丢进去,可能这次给你“8点”,下次给你“8:00”。差别不大,但在约束系统里,这种不确定性会传染到最终结果。
- 第三,成本。纯规则方案一台小机器就能跑,大模型要么走API付费,要么自己部署显卡,性能还不一定跟得上。
所以我的选型思路是:核心业务逻辑必须用确定性代码实现,自然语言理解部分可以先用正则和模板“兜底”,等数据量多了、规则压不住了,再在最外层嵌入一个实体抽取模型。这样不会上来就把系统做成一个拆不开的毛线团。
1.3 技术栈与整体项目结构
第一版我用的是:
- Python 3.10+,主要开发语言;
- FastAPI,做API服务;
- Google OR-Tools,做优化求解,重点是CP-SAT求解器;
- Pydantic,做输入输出数据结构校验;
- 纯正则模板,做意图识别和实体抽取,先不上深度学习。
项目结构我建议这样拆:
nl_opt_solver/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── parsers/ │ │ ├── intent.py # 意图识别 │ │ └── entity.py # 实体抽取 │ ├── models/ │ │ └── schedule.py # 排班模型定义 │ ├── solvers/ │ │ └── cp_solver.py # OR-Tools求解封装 │ └── formatters/ │ └── result.py # 结果转自然语言 ├── tests/ │ └── test_pipeline.py └── requirements.txt模块怎么分,比用什么框架更重要。我踩过一版把解析和求解写在一起的坑,后面改起来想哭。现在的原则是:解析模块只负责“把自然语言变成结构体”,模型模块只负责“把结构体变成数学表达式”,求解模块只负责“算”,结果模块只负责“说”。
2. 核心模块解析:自然语言怎么变成“数学题”
2.1 意图识别:先搞清楚用户要什么
自然语言落到优化求解,第一步不是抽实体,而是判断用户想要哪一种优化问题。是“排序问题”“分配问题”,还是“路径问题”?这步错了,后面全白搭。
比如“帮我把这五个订单让两辆车送掉,每辆车不超过十件货物”是车辆路径装载问题;“给我安排一个考试时间表,每门课不超过三场,一个学生不能连考两场”是一个约束满足问题。虽然都有“时间”和“资源”,但模型结构完全不同。
第一版我用的是关键词规则。比如:
- 包含“安排”“排”“排班”“计划” → 排班/调度;
- 包含“路线”“配送”“运输” → 路径问题;
- 包含“分配”“分给”“每个人” → 分配问题。
你别嫌土。这种规则在垂直领域真的够用。因为用户到了真实的排班/优化系统里,表达方式其实高度重复,不会像闲聊那样千变万化。我在测试集里放了200句真实业务表达,纯关键词规则能做到85%左右的正确识别;真正识别不了的是那种绕了三个弯的表达,比如“我不想让这个累活都让老张干”,这种后面再接分类模型处理,而不是一开始就为难规则。
意图识别的输出不要只给一个字符串,最好给一个结构体,包含意图类型、置信度、候选项。这样后面接线的时候,碰到低置信度还可以转人工兜底。
2.2 实体抽取:时间、地点、数量、对象一个不能少
实体抽取是本系统的重头戏。我把优化建模需要的实体分成这么几类:
- 资源实体:老师、设备、车辆、教室;
- 任务实体:课程、订单、会议、病人;
- 时间实体:具体时刻、时间段、截止时间;
- 空间实体:城市、校区、房间、仓库;
- 数量实体:容量、班次数、人数;
- 约束实体:互斥关系、偏好、最大最小限制。
规则抽取的效果,核心靠“同义词表+正则模板”。比如抽“老师名字”,我把系统中预设的老师列表直接拿来匹配;没有列表的场景,就靠上下文触发词“老师”“张老师”“李老师”这类称呼。
举个例子,用户说:
“周一到周五每天上午9点到12点安排数学课,数学老师只有王老师和刘老师,同一个时段只能一个人上课。”
我先按“周一到周五”抽时间范围,再按“上午9点到12点”抽可用时段,再按“数学”抽课程类型,再按“王老师/刘老师”抽资源列表,最后按“同一个时段只能一个人上课”触发排他约束。
每个实体抽取完,要带上原文位置和归一化后的值。比如“每天上午”归一化成[“09:00-12:00”],“周一到周五”归一化成[1,2,3,4,5]。这里如果只存原文,后面建模还是没法用,所以必须有一个“归一化中间层”。
2.3 业务规则到数学约束的映射
实体抽完,最难的就是怎么把自然语言里的“潜台词”翻译成数学表达式。
你听到“每个老师每天最多上两节课”,这句背后是一个求和约束。如果用x[i][j][k]表示老师i在星期j的第k节课是否上课,那约束就是:
对所有老师i,对每个星期j,sum(x[i][j][k] for k) <= 2再比如“同一个时段只能一个老师上数学课”,这意味着在当时段内sum(x[i][时段])的值为1。再比如“王老师和刘老师不能排在同一天”,那就要加互斥约束。
我整理了一个非常基础的映射表,新手可以照着套:
| 自然语言表达 | 数学模型 | 说明 |
|---|---|---|
| 最多、不超过、不能多于 | sum(...) <= N | 上限约束 |
| 至少、不低于 | sum(...) >= N | 下限约束 |
| 必须、一定要 | sum(...) == N | 硬性相等 |
| 同一个时间段只能一个 | sum(...) <= 1或 +=1 | 互斥约束 |
| 尽量、偏好、最好 | 目标函数加权 | 软约束 |
| 不能同时 | pair 约束,二元变量相加≤1 | 互斥 |
| 不能在某时间段 | 强制变量为0 | 局部剔除 |
这里有个关键经验:自然语言里的“尽量”意味着它不是硬约束,而是目标函数里的优先级。比如“尽量别让张老师上晚自习”如果写成硬约束,可能无解;但如果放进目标函数设置惩罚权重,系统就能在满足其他硬约束的前提下优先避开。这个坑非常常见,我跟很多同行交流时都发现他们在最初版本里把所有“尽量”都做成==0,然后求解器报无解,找半天原因。
3. 实操过程:从零跑通最小MVP
3.1 环境准备与依赖安装
这部分我尽量写清楚,因为很多人卡在环境上。
我建议创建一个干净的虚拟环境,Python版本要3.8以上,推荐3.10。因为OR-Tools对Python版本的适配有要求,太老或太新都容易踩坑。
python3.10 -m venv venv source venv/bin/activate pip install --upgrade pip pip install ortools fastapi uvicorn pydantic这里OR-Tools是核心。它会带一个CP-SAT求解器,可以处理整数规划、布尔约束、目标优化,非常适合排班、调度这类问题。安装时如果慢,可以用国内镜像源,但这不是必须的,正常官网源也稳定。
装完之后你可以在Python里测试一下:
from ortools.sat.python import cp_model print(cp_model.CpSolver().Version())能输出版本号就说明环境OK。
3.2 先写一个能识别意图和抽取实体的解析器
我不建议一开始就写复杂的类继承体系,用一个简单的解析器类把逻辑收拢即可。代码可以这样起步:
import re from dataclasses import dataclass, field @dataclass class ParsedResult: intent: str = "" teachers: list = field(default_factory=list) courses: list = field(default_factory=list) timeslots: list = field(default_factory=list) max_per_day: int = 0 constraints: list = field(default_factory=list) class NLParser: def __init__(self): self.known_teachers = ["王老师", "刘老师", "张老师"] self.known_courses = ["数学", "语文", "英语"] def parse_intent(self, text: str) -> str: if "排" in text or "安排" in text: return "schedule" if "路线" in text or "配送" in text: return "routing" return "unknown" def parse_teacher(self, text: str) -> list: found = [] for t in self.known_teachers: if t in text: found.append(t) return found def parse_courses(self, text: str) -> list: return [c for c in self.known_courses if c in text] def parse_timeslots(self, text: str) -> list: slots = [] if "周一到周五" in text: slots = [(d, "09:00-12:00") for d in range(1, 6)] elif "每天" in text: slots = [(0, "09:00-12:00")] return slots这一步的要点是:每个方法只做一件事,返回的数据结构尽量简单,不要混在一起。我最初用一个“万能正则函数”去抽所有实体,结果一个正则写错,整个系统抽出来的时间全是错的,后来拆成独立方法才好调试。
3.3 用OR-Tools构建课程排班模型
这里我以“排课”这个最经典的场景来演示完整建模过程。
假设问题是这样的:
- 5个老师,3门课,周一到周五每天上午有3个时段;
- 每个时段最多安排一门课;
- 每个老师每天不超过2个时段;
- 每门课每周至少上3次;
- 尽量让王老师少上第三时段。
变量定义:
x[teacher][course][weekday][slot] = 1 # 表示老师在星期weekday的slot时段上course课然后用CP-SAT求解。核心代码我给了详细注释:
from ortools.sat.python import cp_model def build_schedule_model(parsed: ParsedResult): model = cp_model.CpModel() teachers = parsed.teachers courses = parsed.courses weekdays = list({d for d, _ in parsed.timeslots}) slots = list({s for _, s in parsed.timeslots}) # 创建变量 x = {} for t in teachers: for c in courses: for d in weekdays: for s in slots: x[t, c, d, s] = model.NewBoolVar(f"x_{t}_{c}_{d}_{s}") # 约束1:同一时段只能上一门课 for d in weekdays: for s in slots: model.Add(sum(x[t, c, d, s] for t in teachers for c in courses) <= 1) # 约束2:每个老师每天不超过2个时段 for t in teachers: for d in weekdays: model.Add(sum(x[t, c, d, s] for c in courses for s in slots) <= 2) # 约束3:每门课每周至少3次 for c in courses: model.Add(sum(x[t, c, d, s] for t in teachers for d in weekdays for s in slots) >= 3) # 目标函数:尽量让王老师少在第三时段上课 penalty = [] for c in courses: for d in weekdays: if "王老师" in teachers: penalty.append(x["王老师", c, d, 2]) model.Minimize(sum(penalty)) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = 10 status = solver.Solve(model) return status, solver, x这一段里的关键点:
- 变量全部用BoolVar,因为“上不上课”是一个0/1决策;
- 约束用
Add累加,直接表现为线性不等式; - 目标函数用
Minimize,这样系统会优先找“王老师晚课少”的方案; - 求解时间上限设置为10秒,防止复杂场景下无限跑。
实际跑下来,小规模场景(5个老师、3门课、10个时段)基本秒出结果,速度不是问题。但如果把老师数量扩大到50、课程扩大到30,变量数会快速增长,这时你就得考虑稀疏化或分块求解。
3.4 把求解结果转成自然语言回复
这一步特别容易被忽略,但用户体验好不好全看这里。求解器输出的是一堆x = 1的变量,用户当然不想看这个。你要做一个result_formatter,把排课结果按时间排序,再拼成可读的句子。
我的格式化逻辑大概是这样的:
def format_schedule(solution, timeslot_map): lines = [] for (teacher, course, weekday, slot), val in solution.items(): if val == 1: lines.append( f"星期{weekday} 第{slot+1}时段:{teacher} 上 {course}" ) return "\n".join(sorted(lines))这里有个细节:不要只排一个列表,还要考虑聚合。比如一周结果很多,你应该按天分组,再按时段排序。我在第一版里全部打平成一个大列表,用户反馈“看着头晕”,后来改成“星期一分组+课时统计”,效果好很多。
3.5 用FastAPI包成接口,验证闭环
解析、建模、求解、格式化都跑通后,我用FastAPI把它们包成一个POST接口。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Query(BaseModel): text: str @app.post("/solve") def solve(query: Query): parser = NLParser() parsed = parser.parse(query.text) status, solver, x = build_schedule_model(parsed) result_text = format_schedule(...) return { "intent": parsed.intent, "status": solver.StatusName(status), "result": result_text }然后启动服务:
uvicorn app.main:app --reload在另一终端测试:
curl -X POST http://127.0.0.1:8000/solve \ -H "Content-Type: application/json" \ -d '{"text": "周一到周五安排数学和语文,老师有王老师和刘老师,每天最多2节课"}'如果前面模块设计合理,返回结果会是类似:
{ "intent": "schedule", "status": "OPTIMAL", "result": "星期1 第1时段:王老师 上 数学\n星期1 第2时段:刘老师 上 语文\n..." }到这里,一个自然语言优化求解系统的第一版闭环就真正跑通了。
4. 常见问题与排查技巧实录
4.1 自然语言生成的模型无解,怎么办
这是我最常遇到的问题。用户明明描述得很合理,但模型跑出来是INFEASIBLE。
排查思路不要乱,先确认问题出在解析还是建模。我当时写了一个“打印全部约束”的调试函数,把所有Add的约束表达式按行打出来,然后逐条跟原始需求对照。结果发现有一半的情况是“实体抽取出了问题”:用户说“每门课每周至少3次”,但我把“3次”抽成了“3个老师”,导致约束变成了“每门课3个老师同时上课”,当然无解。
另一次是数字归一化问题。用户说“每天上午”,解析器生成了周一到周五每天9点到12点,但建模时把时段定义成0、1、2,结果目标函数里我用slot=2表示第三时段,定义错位,约束瞬间混乱。
所以无解时先做三件事:
- 打印实体抽取结果,看实体值对不对;
- 打印所有约束的数学形式,看有没有过于苛刻;
- 尝试把“尽量”类约束从硬约束改成目标函数惩罚。
4.2 求解速度慢,模型太大跑不动
小规模场景一定没问题,但真实业务可能老师有100人、课程50门、时间槽位50个,变量一下就是25万个,不加控制直接跑10分钟也不奇怪。
我的经验是:
- 先用
max_time_in_seconds设置时间上限; - 用启发式初始解,CP-SAT其实自带了很多搜索策略,但你可以在建模时给变量添加初始赋值;
- 把问题切块。如果业务允许,按天分块排,然后合并;
- 减少无用的变量创建。比如某个老师根本不能上某门课,那干脆不建这个变量,而不是建了之后强制为0。
另一个容易被忽略的点:sum在CP-SAT里很常用,但如果你在目标函数里写了一个大sum包含大量零变量,求解器也会被拖慢。能用局部约束时别写成全局大和目标,一定要对稀疏结构有意识。
4.3 用户表达太随意,规则接不住
规则引擎肯定有天花板。用户一句“别让老张太累吧”,没有“最多”“最少”“不超过”这类关键词,规则很难识别成约束。
我第一版的处理方式是加“口语化归一表”:
- “别让”“尽量不要”“太累” → 优先级降低的目标惩罚;
- “怎么都行” → 不生成硬约束;
- “随便排” → 用默认目标函数,比如均匀分配。
如果碰到表里也查不到的长句,我干脆返回“这句话我还没学会,请换一种说法”。这个“拒答”机制很重要,因为它避免让你在模型层瞎猜之后给用户一个错误但有自信的方案。
之后我会专门加一个轻量文本分类器或者用开源模型做实体抽取,但业务规则映射依然保留在代码里。你可以把“自然语言理解”看作不断升级的模块,但“数学建模”的骨架不能换。
4.4 结果解释不人性化,用户看不懂
求解器给你一个最优解,不代表用户看得懂。特别是当系统告诉你“星期1第1时段王老师上数学”时,用户更想知道“为什么不是刘老师上语文”。
我后来在结果里加了解释性的辅助字段:把命中的关键约束列出来,比如“满足王老师每天不超过2节”“满足每门课每周至少3次”,并且标注哪些是硬约束、哪些是软约束。用户至少能反查系统是不是按照他的要求做的。
还有一个小技巧:不要把“最优”写得太满。因为很多优化模型会把软约束也优化,结果虽然全局最优,但某个老师可能一周一次都没排到。这时你应该在结果里提示“当前方案在满足硬约束的前提下,尽量减少了王老师第三时段课程”。让用户知道这是一道选择题,不是天降真理。
5. 写在第一版结束后的几句体己话
这个系统第一版做下来,我最深的体会是:自然语言优化求解系统的难点,通常不在求解器,而在“翻译”的完整性和可解释性。模型不好,可以换更好的求解器;求解慢,可以调参、分块;但如果你把用户的“尽量”理解成“必须”,或者把“第三时段”翻译错位,那后面所有聪明都会变成错误。
所以我在整个开发过程中,刻意保持每个环节都能被检查和打断。解析结果先打印,建模约束再打印,求解状态要打印,最终方案格式化后还要人眼扫一遍。宁可流程笨一点,也不要让错误在中间无声地放大。
如果你是刚开始做这个方向,我的建议是从一个非常小的垂直场景起步,比如“老师排课”或“仓库拣货路径”,不要一开始就想着做一个通用系统。垂直场景里的自然语言表达有限,规则足够覆盖,你也能把所有精力集中在“把优化建模做扎实”这件事上。等这个闭环稳定了,再慢慢扩展新的意图类型、新的实体种类、新的求解目标。
后面我会继续更新这个系列的第二篇,大概是做“更灵活的实体抽取”和“怎么把大模型安全地接入到现有规则管线里”,如果你也在做类似的事,欢迎一起交流踩坑经验。