起因很简单:我让大模型算「巨无霸 + 薯条 + 可乐怎么点最便宜」,它给了我一个错误的价格。
一、先说结论
优惠券叠加是个精确计算问题,而大模型在做多步算术和带约束的推理时并不可靠。所以我做了一个很反直觉的分工:
取数交给 MCP,算术交给代码,表达交给 Agent。
这篇文章讲的就是中间那个「算术」------一个 492 行、纯 Python 标准库、零依赖的确定性优化器,它能把一顿饭从 ¥47.00 压到 ¥21.90。
二、这个问题到底难在哪
先把它定义清楚:给定一份门店菜单、一堆优惠券、以及用户想吃的东西,求总价最低的合法组合。
难点不在"算",在于搜索空间:
- 用户说了 3 样想吃的,每样有多个规格/候选 → 假设每样留 4 个候选,就是
4³ = 64种基础菜篮 - 为了凑满减,允许加购 1~2 件便宜商品 →
64 + 64×6 + 64×C(6,2) = 1408种菜篮 - 手上有 8 张券,单方案最多叠 3 张 →
C(8,0)+C(8,1)+C(8,2)+C(8,3) = 93种券组合
1408 × 93 ≈ 13 万次计价,每一次还要判断"这张券在这个菜篮里能不能用、和别的券冲不冲突"。
真正的折磨在于:这还只是一个小场景。门店券 + 麦麦省可领券 + 我的券 + 一键领取的新券,四路合并之后券池轻松上 15 张。
而最要命的是------这个场景不允许"差不多" 。价格错一位数,用户到收银台就付不出来。
所以我的第一原则是:不要让模型碰钱。
三、建模:把五花八门的券收成 5 类
麦当劳的券看起来花里胡哨,但拆开看,语义只有 5 种:
| type | 语义 | 关键字段 | 例子 |
|---|---|---|---|
item_price |
单品改价:把某商品价格改成固定值 | item_codes, override_price |
巨无霸专享价 ¥18.9 |
percent |
单品折扣:对匹配商品打折 | item_codes, discount_pct |
中薯条 5 折 |
threshold |
满减:小计达标才减 | min_spend, discount |
满 35 减 7 |
bundle_price |
组合价:每组各挑 1 个,打包固定价 | groups, price |
1+1 随心配 ¥12.9 |
free_gift |
赠品:达标送商品,不影响价格 | min_spend, gift_codes |
满 20 送派 |
落到 JSON 长这样:
json
{
"id": "COMBO-1P1-12.9",
"type": "bundle_price",
"title": "1+1 随心配 ¥12.9",
"groups": [
["BIGMAC", "MCSPICY", "MCCRISPY", "DOUBLE_CHEESE"],
["FRIES_M", "COKE_M", "SPRITE_M", "PIE_TARO"]
],
"price": 12.9,
"stackable": false
}
这套抽象的价值在于:任何新券活动都能落进这 5 类里,新增玩法通常只需要扩一个分支,不用改搜索逻辑。
还有一条纪律:判不准就降级,绝不猜价格 。券面识别不出类型时,降级成 threshold 或直接忽略,并在回复里说明「该券规则无法自动核算,已按单点计」。
四、算法:两层搜索 + 三层剪枝
结构上是嵌套的两层搜索:
- 外层搜菜篮:从用户"想吃的"出发,生成候选商品组合,再考虑要不要加购
- 内层搜券组:给定一个菜篮,在所有合法券组合里找最便宜的那组
内层核心循环大概是这样:
ini
best_total = float("inf")
for r in range(0, min(MAX_COMBO_COUPONS, len(pool)) + 1):
for combo in combinations(pool, r):
# 互斥检查:任意两张券冲突就跳过
if any(coupon_conflict(coupons[a], coupons[b])
for a, b in combinations(combo, 2)):
continue
res = price_with_basket(basket, combo, coupons_by_id)
if res and res["total"] < best_total:
best_total, best = res["total"], res
但 13 万次计价是不能硬跑的。三层剪枝把它压了下来:
- 每个「想吃的」只保留最多 4 个候选商品(按名称/标签打分匹配)
- 加购最多 2 件,且只从最便宜的 6 件里挑
- 单方案最多叠 3 张券,且组合前先做一次可适用性预筛
第三层剪枝最关键------先用「券的 item_codes 和菜篮有没有交集」过一遍,把明显用不上的券直接踢出池子。实战中 15 张券里往往只有 3~5 张真的进得了组合。
实测效果 (内置样例数据):理论空间 13 万 → 实际只评估出 13 个可行方案,毫秒级返回。
五、一个容易踩坑的顺序问题
这是我在实现里觉得最值得说的一处:满减必须最后结算。
因为「满 35 减 7」这类券,门槛是按小计 判定的。而小计在单品券和组合价券生效前后是不一样的。计价顺序必须是:
- 先处理
item_price(单品改价) - 再处理
percent(折扣) - 用
bundle_price打包价替换掉被组合覆盖的商品(这些商品从明细里移除,不参与小计) - 拿着这个"已经扣过单品优惠"的小计,再判定满减门槛
- 最后校验赠品门槛
ini
# 1. 先算商品小计(已应用单品改价 / 折扣,组合价商品被跳过)
subtotal = 0.0
for it in basket:
if it["code"] in consumed: # 已被组合价券打包
continue
eff = base
if code in price_overrides:
eff = min(eff, price_overrides[code])
if code in percent_overrides:
eff = min(eff, round(base * percent_overrides[code], 2))
subtotal += eff
# 2. 组合价券的打包价并入小计
for bl in bundle_lines:
subtotal += bl["price"]
# 3. 阈值判定必须基于"上面算完的小计"
for cid in coupon_ids:
if coupons[cid]["type"] == "threshold" and subtotal >= coupons[cid]["min_spend"]:
...
顺序反了会同时踩两个坑:门槛判错 (用原价判,虚高)和重复抵扣(单品券和满减算到同一件商品上)。
六、组合价券要「贪心吃掉最贵的那件」
1+1 随心配 ¥12.9 这类券是性价比之王,但它有个分配问题:每组各挑一件,挑哪件?
答案很直接------挑最贵的。因为组合是固定价,被覆盖的商品越贵,你赚得越多。
ini
for group in coupon["groups"]:
cand = [c for c in group if c in basket_codes and c not in consumed]
if not cand:
return None
cand.sort(key=lambda c: basket[c]["price"], reverse=True)
picked.append(cand[0]) # 吃掉这组里最贵的那件
consumed.add(cand[0])
这个贪心是最优的:组合价固定,每组独立,最大化被覆盖商品的原价和 = 最大化节省。
七、一条刻意"少报优惠"的规则
叠加策略上我做得很保守:
标了
stackable: false的券,与任何其他券互斥。拿不准就置false。
麦当劳绝大多数单品券、套餐券本来就是不叠加的,所以这个保守假设符合现实。代价是可能少报一点优惠,但换来一个更重要的保证:
方案价 ≤ 用户实付价。
宁可少报,也绝不给用户一个"到店付不出来"的假低价。而且下单前还会再调一次 MCP 的 calculate-price 复核------Agent 说的价,必须等于收银台的价。
八、可验证,是这套设计最大的红利
因为算术完全不依赖模型,它就变得可测试 了。核心逻辑带 13 项断言,覆盖 5 类券、门槛校验、互斥规则、预算过滤、热量过滤,还有一条专门测确定性:
css
a = optimize(MENU, COUPONS, base_req)
b = optimize(MENU, COUPONS, base_req)
check("相同输入结果一致", a == b)
makefile
$ python selftest.py
结果: 13 passed, 0 failed
同一份输入,永远得到同一个输出。 这是概率模型给不了的。
另外整个优化器是纯标准库、零依赖 的,而且内置了脱敏的样例菜单和券数据------没有 MCP Token 也能完整跑通全流程,方便复现和审查:
bash
python scripts/optimizer.py # 直接看优化结果
python scripts/optimizer.py --json # 同时输出结构化 JSON
python scripts/selftest.py # 13 项自测
九、实测输出
内置样例(巨无霸 + 中薯条 + 可乐,手上 7 张券):
markdown
门店: SZ0001 想吃的: 巨无霸、中薯条、可口可乐 中杯
不使用优惠券的话, 直选约 ¥47.00
------------------------------------------------------------
🥇 方案 1 到手价 ¥21.90 (原价 ¥47.00, 省 ¥25.10 / 53%) 约 1040 kcal
· 可口可乐 中杯 ¥9.00
· 1+1 随心配 ¥12.9 ¥12.90 [组合价]
🎟️ 用券: 1+1 随心配 ¥12.9
ℹ️ 比「直接按想吃的点」省 25.10 元
ℹ️ 折扣力度 53%, 属于强力优惠组合
------------------------------------------------------------
共评估 13 个可行方案, 展示前 5 名。
省 53%,一顿饭砍掉一半。 而且它给出的不是"最优答案"一个,是带约束的 Top-N:预算内、热量内,一目了然。
十、几个设计取舍
- 纯标准库,零依赖:能被塞进任何沙箱直接跑,不用装环境
- 数据与逻辑分离 :输入是 MCP 返回归一化后的 JSON,输出是结构化方案;优化器只负责「算」,不负责「说」,文案交给 Agent
notes字段要坦白:如果最优方案是为了用券而加购了东西,必须显式提示「比原意向贵 X 元(为使用优惠券加购)」,让用户自己判断值不值- 不做无解的强行解:预算/热量过严时如实返回空方案,并建议「放宽哪一项」,绝不突破用户约束
十一、最后
这套思路不止能用在点餐------任何「有明确规则、但组合爆炸、且结果必须精确」的场景都适用:机票/酒店的券组合、电商满减凑单、优惠券叠加试算。
核心就一句话:让模型去做它擅长的(理解意图、取数、表达),把算术还给代码。
完整的实现(含 5 类券建模、剪枝搜索、13 项自测)都开源在这里:
如果这个思路对你有启发,点个 Star 就是最好的支持 ⭐️
本项目是「麦当劳程序员创意开发大赛」参赛作品,基于麦当劳 MCP 开发,非官方产品。