简介:一份聚焦餐饮业智能菜单推荐系统落地实践的技术文档,面向餐饮行业技术开发人员、算法工程师及数字化项目负责人,帮助读者理解如何借助 DeepSeek 完成从需求分析到系统部署的完整实现。文档共30页,以pdf格式提供,压缩包约2.17MB,内含1个pdf文件,内容完整、目录与图表显示正常,便于查阅与检索。目前已有64人学习。内容从餐饮业竞争态势、消费者需求变化与传统点餐方式局限切入,系统梳理智能菜单推荐系统的需求分析、整体架构与分层设计,并展开 DeepSeek 技术原理、特征学习、搜索机制及在推荐场景中的适用性。随后覆盖数据收集与预处理、模型选择与训练、损失函数与优化器、评估调优、接口设计与开发、部署优化、监控维护等关键环节,最后通过实际应用案例与效果评估总结业务指标、技术指标及成功经验,为项目落地提供可参考的工程路径。
1. 从塑封菜单到实时推荐:餐饮场景为什么值得重构点餐链路
一家日均翻台三次的连锁中餐厅,后台躺着四十多万条点餐明细,顾客坐下之后看到的还是那张印着一百多道菜的塑封菜单。数据早就够用了,缺的是把点餐记录、评价、用餐场景翻译成「下一道该点什么」的那一层。这份《餐饮业实战:DeepSeek 驱动的智能菜单推荐系统落地细节》讲的正是这一层怎么搭起来。
它的价值不在炫技。高峰期服务员腾不出手介绍菜品,纸质菜单对忌口顾客毫无提示,新客永远只点那三道招牌——这些问题单靠排序算法解决不了,但用「先算偏好、再让大模型在候选集里挑并给出理由」这套组合,能压下去一大半。真正要处理的是数据口径、召回与排序的边界、模型超时兜底、推荐理由的合法性校验,任何一环松掉,线上就是一堆无效推荐。
适合谁看:做餐饮 SaaS 点餐模块的后端工程师、给连锁品牌搭数据中台的同学,以及准备把大模型接进业务又怕它胡说八道的技术负责人。前置知识只要 Python 和 SQL,剩下的可以照抄。
2. 分层架构与 DeepSeek 在召回-排序链路中的接入位置
最常见的错误设计,是把整本菜单塞进 prompt 让模型直接输出推荐。180 道菜的描述加营养成分,prompt 轻松过万 token,单次响应从秒级拖到十几秒,高峰期 QPS 一上来就是排队超时。DeepSeek 该出现在链路的中后段,而不是入口。
2.1 四层架构与存储分工
系统按数据层、处理层、服务层、表示层切分,各层之间只走接口,方便单层替换。存储不要一把梭全塞进 MySQL:结构化字段适合关系库,菜品长文本描述和用户评价适合文档库,高频读取的推荐结果适合放 Redis。
| 层 | 职责 | 常见组件 | 关键约束 |
|---|---|---|---|
| 数据层 | 菜品、用户、订单、评价存储 | MySQL + MongoDB + Redis | 菜品库是推荐白名单,推荐结果不得越界 |
| 处理层 | 特征计算、召回、重排、理由生成 | Python 离线任务 + DeepSeek | 重活离线算,在线只做轻计算 |
| 服务层 | 对点餐端暴露 HTTP 接口 | FastAPI / Flask | 单次请求 P99 控制在 800ms 内 |
| 表示层 | 扫码点餐页、自助机、小程序 | 前端页面 | 推荐位不超过 8 个,避免选择过载 |
菜品写入用一段简单脚本就能跑通,注意description字段要限制长度,否则后面拼 prompt 会失控。
import mysql.connector # 菜品主数据写入,建议放在后台管理系统而不是推荐服务里 conn = mysql.connector.connect( host="127.0.0.1", user="app_rw", password="***", database="restaurant" ) cur = conn.cursor() sql = """INSERT INTO dishes (dish_id, name, price, category, spicy_level, description) VALUES (%s, %s, %s, %s, %s, %s)""" val = ("D1024", "宫保鸡丁", 28.0, "川菜", 2, "鸡腿肉丁配油酥花生,微辣回甜") cur.execute(sql, val) conn.commit() print(cur.rowcount, "row inserted") cur.close(); conn.close()dish_id是后面做白名单校验的主键,必须业务唯一且不可复用;spicy_level用 0~3 的整数编码,比存「微辣」「中辣」更好算距离;description控制在 30 字以内,它会被拼进候选集,长度直接影响 token 消耗。
2.2 召回层与排序层的边界
召回层干的是粗筛:规则(场景、时段、忌口、库存)+ 协同过滤(相似用户点过的菜)把几百道菜压到 40~60 道。排序层才交给 DeepSeek,让它在这批候选里挑 8 道并写一句推荐理由。这样切分有三个实打实的好处——token 从万级降到千级,延迟可控;候选集是白名单,模型没机会推荐下架菜;召回策略可以单独调,不用每次动 prompt。
注意:餐厅在午市高峰的 QPS 可能是平峰期的十倍以上,任何在请求链路上同步调外部接口的设计,都要预留降级路径。
2.3 推荐服务接口的封装
对外只暴露一个 POST 接口,内部串起缓存、召回、重排、兜底四步。缓存键按「门店 + 用户 + 场景」组合,同一用户在短时间内重复下拉菜单不重复计算。
from fastapi import FastAPI from pydantic import BaseModel import redis, json app = FastAPI() r = redis.Redis(host="10.0.0.12", port=6379, db=0, decode_responses=True) class RecRequest(BaseModel): user_id: str store_id: str scene: str = "family" # family / business / friends people: int = 2 @app.post("/v1/recommend") def recommend(req: RecRequest): key = f"rec:v3:{req.store_id}:{req.user_id}:{req.scene}" hit = r.get(key) if hit: # 10 分钟缓存,挡住高峰期重复请求 return {"source": "cache", "items": json.loads(hit)} recall = recall_by_rule_and_cf(req.user_id, req.store_id, topk=60) ranked = rerank_with_deepseek(recall, req) # 失败返回 None,不抛异常 if not ranked: ranked = fallback_hot(req.store_id, topk=8) # 兜底走门店热销榜 r.setex(key, 600, json.dumps(ranked)) return {"source": "model", "items": ranked}scene影响召回规则,商务宴请偏向少骨少壳的菜,家庭聚餐偏向分量菜;topk=60是召回数量,太小会让模型没有挑选空间,太大则 token 和延迟一起涨;缓存 TTL 设 600 秒而不是更长,是因为库存变化和售罄信息需要较快反映。
3. 数据采集、清洗与口味特征工程
推荐效果的上限不在模型,在数据口径。同一道「毛血旺」在不同门店的category字段可能被录成「川菜」「热菜」「特色菜」,这种脏数据进到模型里,再好的 prompt 也救不回来。
3.1 数据来源与字段口径
门店自有订单是主数据源,第三方平台数据只作为补充,且必须走官方开放接口拿授权范围内的字段,不建议自行抓取。传感器侧主要用来算实时客流,用于调整推荐位的展示时机。
| 数据源 | 关键字段 | 用途 | 更新频率 |
|---|---|---|---|
| 订单明细 | user_id, dish_id, qty, order_time | 构建用户偏好画像 | 实时 / T+1 汇总 |
| 顾客评价 | order_id, rating, content | 菜品质量分、负反馈过滤 | T+1 |
| 会员资料 | age_band, gender, 忌口标签 | 冷启动兜底特征 | 变更时 |
| 客流传感器 | 进店人数、时间分布 | 判断推荐时机与并发预估 | 分钟级 |
3.2 SQL 抽取与清洗
抽取阶段就要把明显脏的数据挡在训练集之外。退单、测试账号、异常数量这三类不清理掉,用户画像会被严重带偏。
-- 抽取近 90 天有效点餐明细,作为画像与评估的基础样本 SELECT o.user_id, o.dish_id, o.qty, o.order_time, d.category, d.spicy_level, d.price FROM orders o JOIN dishes d ON d.dish_id = o.dish_id WHERE o.order_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) AND o.status = 'paid' -- 只算已支付,退单不进样本 AND o.user_id NOT LIKE 'test%' -- 剔除测试账号 AND o.qty BETWEEN 1 AND 20; -- 过滤录入错误造成的超大数量落到 Python 侧再做一轮去重和填充。评分缺失不要直接删行,用菜品均值填充比丢弃整条订单更划算——一条订单里还有 category、spicy_level 等一堆有用信号。
import pandas as pd df = pd.read_csv("orders_90d.csv") df = df.drop_duplicates(subset=["user_id", "dish_id", "order_time"]) df["rating"] = df["rating"].fillna(df.groupby("dish_id")["rating"].transform("mean")) df["rating"] = df["rating"].fillna(df["rating"].mean()) # 新品无评分时的二次兜底 df.to_csv("orders_clean.csv", index=False)drop_duplicates的 subset 必须带上order_time,否则同一用户在不同日期点同一道菜会被误删;两次fillna的顺序不能颠倒,先按菜品聚合再全局兜底,才能保留菜品的个体差异。
3.3 口味偏好特征与向量化
用户画像不需要复杂模型,一张品类分布表加几个标量就够用。关键是加权方式——单次点 5 份的爆款菜品不能主导整个画像。
import numpy as np def build_taste_vector(rows): """rows: [(category, spicy_level, price, qty), ...]""" cat, spicy_s, spicy_w, price_s, price_w = {}, 0.0, 0.0, 0.0, 0.0 for c, spicy, price, qty in rows: w = min(qty, 3) # 单次超过 3 份不再线性加权 cat[c] = cat.get(c, 0.0) + w spicy_s += spicy * w; spicy_w += w price_s += price * w; price_w += w norm = sum(cat.values()) or 1.0 return { "category": {k: v / norm for k, v in cat.items()}, "spicy_pref": spicy_s / spicy_w if spicy_w else 1.0, "price_pref": price_s / price_w if price_w else 45.0, } def cos(a, b): keys = set(a) | set(b) va = np.array([a.get(k, 0.0) for k in keys]) vb = np.array([b.get(k, 0.0) for k in keys]) na, nb = np.linalg.norm(va), np.linalg.norm(vb) return float(va @ vb / (na * nb)) if na and nb else 0.0min(qty, 3)这个截断是实战里调出来的:聚餐场景下一桌点 5 份同款饮料,不做截断会把用户画像直接拉偏。price_pref用加权均价而非最高价,避免偶尔一次商务宴请把价格偏好永久抬高。
3.4 冷启动与稀疏场景处理
新用户没有历史订单,新菜品没有交互数据,这两类问题要分开处理。新用户走「场景规则 + 门店热销 + 忌口过滤」组合召回,并在点餐页显式问一句「能吃辣吗」,把标签当场沉淀下来;新菜品给一个为期 14 天的流量扶持位,每天固定曝光给口味相近的用户群,攒够交互再回到正常排序。评估时把新用户单独分桶算指标,混在一起算会掩盖问题。
4. DeepSeek API 调用、Prompt 约束与超时降级
排序层是整个系统里唯一强依赖外部服务的地方,也是最容易出事的地方。参数配错、没有超时、没有校验,任何一个都会演变成线上事故。
4.1 开放平台调用与本地部署的取舍
门店数量在几十家以内、QPS 不高的情况下,直接调用开放平台的 API 最省事,接入成本和运维成本都低,按 token 计费的模式在推荐这种短请求场景下花不了多少钱。如果对数据不出内网有硬性要求,或者门店规模大到调用量已经超过自建成本,再考虑本地化部署,但要提前算清楚显存、并发和版本升级这三笔账——本地部署后模型升级需要自己维护推理服务,这是很多人低估的部分。
| 参数 | 建议值 | 说明 |
|---|---|---|
| temperature | 0.3 ~ 0.5 | 推荐要可复现,高随机性会让 A/B 结果没法看 |
| max_tokens | 600 ~ 900 | 8 条推荐加理由足够,再多是浪费 |
| response_format | json_object | 省掉正则抠 JSON 的脏活 |
| timeout | 4 ~ 6 秒 | 超时立即降级,别让用户等 |
| 重试次数 | 2 次,指数退避 | 只对网络类错误重试,参数错误重试没意义 |
4.2 调用封装与重试边界
封装函数必须把异常全部吞掉并返回None,由上层决定降级,绝不能让外部服务异常穿透到点餐页面。
import os, json, time, requests BASE = os.getenv("DS_BASE", "https://api.deepseek.com/v1") KEY = os.getenv("DS_KEY") def call_deepseek(user_prompt, sys_prompt, timeout=6, retries=2): payload = { "model": "deepseek-chat", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_prompt}], "temperature": 0.4, "max_tokens": 800, "response_format": {"type": "json_object"}, } for i in range(retries + 1): try: resp = requests.post(f"{BASE}/chat/completions", headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}, json=payload, timeout=timeout) resp.raise_for_status() return json.loads(resp.json()["choices"][0]["message"]["content"]) except Exception: if i == retries: return None # 交给上层兜底,不向上抛异常 time.sleep(0.3 * (2 ** i)) # 指数退避,避开瞬时抖动重试只包住网络超时和 5xx,鉴权失败、参数错误这类重试三次也没用,反而会拖长尾延迟。timeout设 6 秒是基于点餐页的容忍度倒推的:超过这个时间,用户已经手动往下翻了,推荐结果再准也没意义。
4.3 Prompt 模板与候选集注入
候选集用紧凑格式注入,每行一道菜,字段间用竖线分隔,比 JSON 数组省三成 token。
SYS = ("你是餐厅点餐助手。只能从给定候选菜品中挑选,不得新增、改名或编造菜品。" "输出 JSON:{\"items\":[{\"dish_id\":\"...\",\"reason\":\"不超过20字\"}]}") def build_prompt(candidates, taste, scene, people): lines = [f"{c['dish_id']}|{c['name']}|{c['category']}|辣度{c['spicy_level']}|{c['price']}元" for c in candidates] return (f"场景:{scene},用餐人数:{people}\n" f"用户偏好:偏好辣度{taste['spicy_pref']:.1f},常见客单价{taste['price_pref']:.0f}元\n" f"候选菜品:\n" + "\n".join(lines) + "\n请推荐 8 道,按推荐优先级排序。")场景和人数放在最前面,是因为这两项对菜品组合的影响最大;把偏好写成数值而不是「喜欢辣」这类自然语言,可以减少模型自由发挥的空间。
4.4 幻觉抑制:dish_id 白名单校验
模型偶尔会拼出候选集里不存在的dish_id,或者把同一道菜推荐两次。校验环节是必须的,不能用「模型应该不会错」来赌。
def validate(raw, candidates): valid = {c["dish_id"] for c in candidates} out, seen = [], set() for it in raw.get("items", []): did = str(it.get("dish_id", "")) if did not in valid or did in seen: continue # 编造的、重复的一律丢弃 seen.add(did) out.append({"dish_id": did, "reason": str(it.get("reason", ""))[:20]}) return out[:8]白名单来源是召回层输出的候选集,而不是整本菜单,这样即使模型置信度再高也不会推出已下架或已售罄的菜。理由字段截断到 20 字,是为了防止前端推荐位被长文本撑破。
4.5 缓存、限流与规则兜底
def get_rec(user_id, store_id, scene, candidates, taste, people): key = f"rec:v3:{store_id}:{user_id}:{scene}" hit = r.get(key) if hit: return json.loads(hit) raw = call_deepseek(build_prompt(candidates, taste, scene, people), SYS) items = validate(raw, candidates) if raw else [] if len(items) < 4: items = fallback_hot(store_id, topk=8) # 有效结果不足 4 条整体降级 r.setex(key, 600, json.dumps(items)) return itemslen(items) < 4这个阈值比「空结果才降级」更实用:只返回两三条时,前端推荐区会明显空一块,体验反而比统一的兜底列表更差。缓存键里的v3是 prompt 版本号,改模板时同步升版本,避免新旧结果混在缓存里。
5. 效果评估与排错:让推荐变成可度量的链路
上线只是开始,没有度量就没法优化。离线先算三个指标:HitRate@8 衡量推荐位里有没有用户真的会点的菜,NDCG@8 衡量排序位置是否合理,品类覆盖率衡量推荐是否被少数爆款垄断。覆盖率长期低于 40%,说明模型在「吃」门店热销榜,需要回看召回层的多样性策略。
埋点必须记录推荐版本,否则 A/B 结果没法归因。
-- 每次推荐落一条日志,用于离线回算 HitRate@8 INSERT INTO rec_log (user_id, store_id, scene, prompt_ver, item_ids, source, created_at) VALUES (%s, %s, %s, %s, %s, %s, NOW()); -- item_ids 存逗号分隔的 dish_id 列表,后续与订单表 JOIN 判断命中线上按门店维度做 A/B,同一用户始终落在同一分桶,避免口味差异污染实验。观察窗口至少两周,餐饮有明显的周中周末差异,一周的数据容易得出反向结论。
常见故障基本集中在下面这几类:
| 现象 | 大概率根因 | 处理方式 |
|---|---|---|
| 推荐区长时间空白 | 模型超时后兜底逻辑未命中缓存 | 检查降级分支是否写入了缓存 |
| 推荐结果高度雷同 | 召回候选集重复或排序未去重 | 在召回出口加一层去重 |
| 推荐了下架菜 | 候选集取自全量菜单而非在售清单 | 召回 SQL 增加在售与库存过滤 |
| 请求量正常但延迟飙升 | 高峰期缓存击穿,同键并发回源 | 加互斥锁或短 TTL 空值缓存 |
| 指标突然掉点 | prompt 版本变更未做灰度 | 灰度发布,保留上一版本可回滚 |
最后一条是我在所有大模型接入项目里都保留的习惯:把 prompt 版本号写进rec_log,用 7 天滑动窗口对比 HitRate@8,跌幅超过 5% 自动切回上一个版本。改一句 prompt 就可能让推荐效果整体偏移,人工盯盘永远慢半拍,让版本号进日志、让指标说话,才不至于在周末晚高峰被动救火。
本文还有配套的精品资源,点击获取