别让大模型算钱:我把优惠券组合优化写成了确定性算法

起因很简单:我让大模型算「巨无霸 + 薯条 + 可乐怎么点最便宜」,它给了我一个错误的价格。


一、先说结论

优惠券叠加是个精确计算问题,而大模型在做多步算术和带约束的推理时并不可靠。所以我做了一个很反直觉的分工:

取数交给 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 万次计价是不能硬跑的。三层剪枝把它压了下来:

  1. 每个「想吃的」只保留最多 4 个候选商品(按名称/标签打分匹配)
  2. 加购最多 2 件,且只从最便宜的 6 件里挑
  3. 单方案最多叠 3 张券,且组合前先做一次可适用性预筛

第三层剪枝最关键------先用「券的 item_codes 和菜篮有没有交集」过一遍,把明显用不上的券直接踢出池子。实战中 15 张券里往往只有 3~5 张真的进得了组合。

实测效果 (内置样例数据):理论空间 13 万 → 实际只评估出 13 个可行方案,毫秒级返回。


五、一个容易踩坑的顺序问题

这是我在实现里觉得最值得说的一处:满减必须最后结算。

因为「满 35 减 7」这类券,门槛是按小计 判定的。而小计在单品券和组合价券生效前后是不一样的。计价顺序必须是:

  1. 先处理 item_price(单品改价)
  2. 再处理 percent(折扣)
  3. 用 bundle_price 打包价替换掉被组合覆盖的商品(这些商品从明细里移除,不参与小计)
  4. 拿着这个"已经扣过单品优惠"的小计,再判定满减门槛
  5. 最后校验赠品门槛
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 项自测)都开源在这里:

👉 github.com/hujjun/mcd-...

如果这个思路对你有启发,点个 Star 就是最好的支持 ⭐️

本项目是「麦当劳程序员创意开发大赛」参赛作品,基于麦当劳 MCP 开发,非官方产品。

相关推荐
IT_Octopus1 小时前
【零基础入门 LLM 开发 · Day 11】:ChatOpenAI vs init_chat_model——一行换供应商
java·前端·javascript
柚yuzumi1 小时前
React 19 Hooks 入门:函数组件的四大法宝 (useState / useEffect / useRef / useContext)
前端·javascript·架构
deli0071 小时前
撒下 200 个种子点,平面为什么自己长成蜂窝?Voronoi 图实验室
前端
大文说跨境2 小时前
多账号环境隔离方案技术选型:指纹浏览器、VPS 与云手机的三种架构对比
java·开发语言·前端
咕白m6252 小时前
使用 C# 将 TIFF 转换为 PDF
前端·c#
全栈项目管理程序猿2 小时前
ArcGIS JS 基础教程(28):图层渲染顺序管理
前端·javascript
huakoh2 小时前
MCP 工具报错走哪条通道:三条探针的最小复现检查
前端
用户033307413952 小时前
ParadeDB 的 pg_search 0.26 把十词 BM25 搜索从 129ms 降到 29ms
前端
不爱说话郭德纲2 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app