简介面向餐饮行业技术开发者与AI应用实践者的技术指南聚焦如何借助DeepSeek构建个性化智能菜单推荐系统解决传统点餐信息不全、效率低、缺乏个性化等痛点。资源包内含1个PDF文件大小约2.17MB共30页内容完整、条理清晰。文档从餐饮业现状与需求分析讲起依次展开DeepSeek技术基础、系统整体架构设计、数据收集与预处理、模型构建与训练、接口设计与开发、系统部署与性能优化并以实际应用案例进行效果评估最后总结挑战与未来方向。读者可掌握从需求梳理、数据清洗、模型选型到接口实现、部署监控的完整落地路径获得架构分层思路、训练调优方法与评估指标设计。目前已有64人学习下载适合希望将DeepSeek应用于餐饮推荐场景的中高级开发者。1. 餐饮门店做菜单推荐为什么最后都落在了 DeepSeek 上一家 30 家门店的连锁快餐品牌扫码点单页里那个「猜你喜欢」的六宫格点击率长期趴在 2% 以下。运营换了三版人工规则——按销量排、按毛利排、按新客老客排——都没救回来。问题不在规则本身而在于菜单推荐要同时满足五类互相打架的约束口味偏好、当前库存、出餐时长、毛利结构、过敏原与忌口。传统协同过滤在这种场景下很难受单店活跃 SKU 一两百个日均订单几十到几百单用户-菜品交互矩阵稀得几乎全是零今天新上的季节菜更是零交互冷启动直接卡死。DeepSeek 这类大模型的价值不在于「更懂吃」而在于它能把「想吃辣但不要香菜预算 30 以内赶时间」这种口语化描述直接翻译成一批带理由、受约束的候选排序。适合谁有扫码点单或小程序、有订单明细、暂时不想重建推荐中台的中小连锁以及想先把推荐位跑起来再谈算法的技术负责人。2. DeepSeek API 接入与智能菜单推荐的数据底座落到工程上第一件要定的事不是模型是数据形态。菜单推荐系统的输入从来不是「用户」和「菜品」两个实体而是用户画像、菜品画像、门店实时状态三份数据的交集。这三份数据的粒度决定了后面 prompt 能写多细、召回能召回多准。2.1 为什么稀疏的餐饮订单不适合直接上协同过滤先算一笔账。假设单店有 150 个在售 SKU一个月的有效订单是 6000 单平均每单 3 个菜那就是 18000 条交互。看起来不少但摊到 150 个 SKU 上平均每个菜每月只有 120 次曝光级的交互而其中 80% 的订单集中在 30 个招牌菜上剩下 120 个菜根本拿不到足够样本。矩阵稀疏度算下来通常在 99% 以上用矩阵分解做出来的隐向量基本是噪声。更麻烦的是餐饮的两个特性菜单换季和门店差异。连锁品牌每季度换 20% 的 SKU新菜的交互从零开始不同门店因为商圈不同同一个菜在 A 店是爆款、在 B 店没人点。协同过滤要靠「相似用户的相似选择」来推断可这两条都让相似性变得不稳定。提示如果你的品类 SKU 超过 500 个、日订单超过 5000 单协同过滤仍然值得作为召回通道之一SKU 少、订单少的场景LLM 的内容理解加约束推理反而是更省事的路线。几种常见路线在餐饮场景下的对比可以看这张表方案冷启动约束表达可解释性落地成本人工规则销量/毛利无影响强弱极低协同过滤ItemCF/矩阵分解差弱弱中内容相似度向量召回好中中中LLM 内容理解 约束推理好强强低到中实际生产里很少只用一种。常见做法是向量召回做粗排DeepSeek 做重排和理由生成规则做硬约束兜底。2.2 菜品、订单明细、口味标签三张表怎么建数据结构不复杂但字段要提前想清楚尤其是后面要喂给模型的字段。下面这三张表是我在餐饮项目里用得比较顺的最小集合-- 菜品画像表既服务于向量召回也服务于 prompt 拼装 CREATE TABLE dish_profile ( dish_id BIGINT PRIMARY KEY, shop_id BIGINT NOT NULL, -- 0 表示全品牌通用菜品 dish_name VARCHAR(64) NOT NULL, category VARCHAR(32), -- 热菜/凉菜/主食/饮品 price_cent INT NOT NULL, -- 以分为单位避免浮点误差 spicy_level TINYINT DEFAULT 0, -- 0-3辣度 cook_seconds INT, -- 标准出餐秒数用于赶时间场景 gross_margin DECIMAL(4,3), -- 毛利率0.650 表示 65% allergens JSON, -- [peanut,shrimp,celery] tags JSON, -- [下饭,重口,招牌,解腻] on_shelf TINYINT DEFAULT 1, -- 是否在售 menu_version VARCHAR(16) -- 菜单版本号缓存键要用 ); -- 订单明细既是行为日志也是口味标签的来源 CREATE TABLE order_item ( id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL, user_id BIGINT NOT NULL, shop_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, qty INT DEFAULT 1, ordered_at DATETIME NOT NULL, channel VARCHAR(16) -- dine_in / takeaway / mini_program ); CREATE INDEX idx_user_time ON order_item(user_id, ordered_at); -- 用户口味标签离线 T1 聚合避免每次请求都扫全量订单 CREATE TABLE user_taste_tag ( user_id BIGINT PRIMARY KEY, spicy_pref TINYINT, -- 近 30 天点过的菜的平均辣度 avg_price INT, -- 客单价单位分 dislike JSON, -- [cilantro,liver] 忌口 repeat_dish JSON, -- 高频复购菜品 id用于去重 updated_at DATETIME );字段设计上有几个点值得说明。price_cent用整数存价格是因为后面要把价格拼进 prompt 做预算约束浮点数会出现「29.999999」这种让模型判断困难的垃圾输入。cook_seconds单独存是为了支持「赶时间」这类时段性需求出餐时长在高峰期是硬约束而不是软偏好。menu_version看起来多余但它是缓存键的一部分——菜单一换缓存必须整体失效否则会出现推荐下架菜的事故。allergens和dislike分开存是因为前者是平台责任必须过滤后者是用户偏好可以软处理两者后续的处理路径完全不同。2.3 deepseek api 如何调用一次最小可用的推荐请求数据齐了先跑通一次最小调用。DeepSeek 的接口兼容 OpenAI 的 SDK 形态所以不用换一套 HTTP 客户端改base_url和model就能用。下面这段是把用户标签和候选菜品拼成 prompt、拿回结构化推荐的最小例子from openai import OpenAI import json client OpenAI( api_keyYOUR_DEEPSEEK_API_KEY, # 从 deepseek 开放平台控制台申请 base_urlhttps://api.deepseek.com/v1, # 兼容 OpenAI 协议的入口 timeout3.0, # 点单页场景超时必须短 ) SYSTEM_PROMPT 你是一名连锁餐厅的菜单推荐助手。 你只能从给定的候选菜品中选择禁止编造候选列表之外的菜品 id。 输出必须是 JSON格式{items:[{dish_id:123,reason:不超过15字}]} 最多推荐 6 个按推荐优先级从高到低排列。 def build_user_prompt(taste: dict, dishes: list[dict]) - str: # 候选菜品只保留必要字段减少 token 也减少模型跑偏的空间 slim [ { id: d[dish_id], name: d[dish_name], price: d[price_cent] / 100, spicy: d[spicy_level], cook: d[cook_seconds], tags: d[tags], } for d in dishes ] return json.dumps( { user: { spicy_pref: taste[spicy_pref], avg_price: taste[avg_price] / 100, dislike: taste[dislike], recent_repeat: taste[repeat_dish][:3], }, candidates: slim, scene: 扫码点单用户已进店希望快速下单, }, ensure_asciiFalse, ) def recommend(taste, dishes): resp client.chat.completions.create( modeldeepseek-chat, # 通用对话模型具体名称以官方文档为准 messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: build_user_prompt(taste, dishes)}, ], temperature0.3, # 推荐要稳温度压低 max_tokens600, response_format{type: json_object}, ) return json.loads(resp.choices[0].message.content)几个参数值得单独说。temperature0.3是刻意的推荐位不像聊天用户连着刷两次看到完全不同的结果会怀疑系统在乱推温度过高会让同一份输入产生大幅抖动。response_format{type:json_object}是让模型输出可解析 JSON 的关键开关用了它之后仍然要在 system prompt 里把格式描述清楚两个条件同时满足才稳定。timeout3.0是链路预算倒推出来的扫码点单页等超过 1.5 秒用户就开始怀疑卡顿后面第 4 章会算这笔账。max_tokens600是防止模型在理由文案上发散写小作文6 条推荐、每条理由 15 字600 个 token 有足够余量。2.4 API 还是本地部署deepseek价格、并发与数据边界的取舍这一步是很多团队真正纠结的地方。门店订单里带手机号、带消费金额直接送到公网接口法务那边一般会要求做数据脱敏——把 user_id 换成不可逆哈希、去掉手机号、金额只保留区间这些做完之后再谈调用。维度走开放平台 API本地化部署开源权重首期投入几乎为零需要 GPU 机器与运维单次成本按 token 计费量小很划算固定成本量大摊薄峰值并发受账号配额限制需提前申请由机器算力决定数据边界出域必须脱敏数据不出内网模型迭代跟随官方更新自行升级权重与推理框架适用规模中小连锁、单品牌大型连锁、多品牌、强合规场景我的建议是按量分阶段走日推荐请求在几万次以内直接走 API把工程精力花在召回和约束上等单日 token 消耗稳定超过一个阈值、或者合规明确要求不出域再考虑用 vLLM 这类推理框架把开源权重本地化部署起来。本地部署真正的成本不在机器在运维——显存不够要调并行度、版本升级要做回归、并发上来要加批处理调度这些都得有人跟。另外无论走哪条路都要把调用封装成一层内部服务业务代码只认recommend(user_id, shop_id)这个函数底层换 API 还是换本地模型对上层透明。3. 用系统提示词和结构化输出把推荐约束成可下单的 JSON第 2 章跑通的是「能返回推荐」这一章要解决的是「返回的推荐能直接下单」。这两件事之间隔着一堆脏活模型可能返回不存在的菜品 id、可能推荐已经售罄的菜、可能给花生过敏的用户推宫保鸡丁、可能在 JSON 外面裹一层 markdown 代码块。工程上要把这些风险拆成两类模型负责的那类用 prompt 和校验管住模型不该负责的那类用代码兜死。3.1 提示词骨架角色、五类约束、输出契约一个能长期用的 system prompt 通常包含四段角色设定、可用候选的边界声明、约束优先级、输出契约。约束优先级这段最容易被省略但它恰恰是模型在冲突时做决策的依据。比如「毛利高」和「用户忌口」冲突时必须明确忌口优先。你是连锁餐厅「XX小馆」的点单推荐助手服务于堂食扫码点单场景。 候选范围只能从 candidates 中出现的菜品 id 里挑选任何不在其中的 id 都视为无效。 约束优先级从高到低 1. 用户忌口与过敏原命中即排除不做任何妥协 2. 用户预算推荐组合总价不得超过 avg_price 的 1.2 倍 3. 出餐时间scene 中标注「赶时间」时只选 cook_seconds 480 的菜 4. 口味匹配优先与 spicy_pref 接近的辣度 5. 多样性同一 category 最多推荐 2 个避免全推主食 6. 毛利以上条件相同时毛利的排序作为最后 tie-breaker 输出契约严格输出 JSON不要输出任何解释性文字或 markdown 代码块。 格式{items:[{dish_id:int,reason:不超过15字}]} 数量3 到 6 个不足 3 个时按实际数量返回。优先级写清楚之后模型的行为会稳定很多。实测下来「多样性」这条放在第四位之后能显著减少「六个推荐里四个都是凉菜」的情况而把「毛利」压到最后一档是因为一旦把它提前模型会频繁推高毛利的滞销菜用户点击率反而掉。3.2 强制 JSON 输出与 Pydantic 校验重试response_format能解决大部分格式问题但解决不了字段类型和 id 合法性。解析层要做三件事剥离可能的代码块包裹、用 schema 校验、校验失败带错误信息重试一次。import re, json from pydantic import BaseModel, ValidationError class Item(BaseModel): dish_id: int reason: str class RecommendResp(BaseModel): items: list[Item] def parse_recommendation(raw: str, valid_ids: set[int]) - list[Item]: # 1. 有些情况下模型仍会包一层 json先做一次剥离 text raw.strip() if text.startswith(): text re.sub(r^[a-zA-Z]*\n?|$, , text).strip() # 2. schema 校验 try: parsed RecommendResp.model_validate(json.loads(text)) except (json.JSONDecodeError, ValidationError): return [] # 交给上层决定是重试还是降级 # 3. id 白名单校验模型编造的 id 直接丢弃而不是报错 return [it for it in parsed.items if it.dish_id in valid_ids]这里的处理顺序是有讲究的。json.JSONDecodeError和ValidationError合并成一类因为这两种失败的处置方式一样——重试或降级。而 id 白名单校验选择「静默丢弃」而不是抛异常是因为一个编造的 id 不应该让整批推荐作废丢掉它、保留剩下的有效项用户体验远好于整块推荐位空着。重试策略上我一般只重试一次且第二次把 temperature 降到 0并在 user 消息里附上上一次的解析错误通常就能拿到合法结构。3.3 库存、过敏原、时段这类硬约束必须放在模型外面这是最重要的一条工程纪律凡是「错一次就是事故」的约束都不能只依赖模型。花生过敏的用户收到含花生的推荐是有可能进医院的下架菜出现在推荐位是运营会直接打电话骂人的。这两类必须用代码在送模型之前过滤掉。def hard_filter(dishes: list[dict], shop_state: dict, taste: dict) - list[dict]: now_hour shop_state[hour] sold_out set(shop_state[sold_out_dish_ids]) user_allergens set(taste[allergens]) # 用户在健康档案里登记的过敏原 user_dislike set(taste[dislike]) # 口味层面不喜欢软过滤 result [] for d in dishes: if d[dish_id] in sold_out: continue # 售罄直接排除 if set(d[allergens]) user_allergens: continue # 过敏原命中直接排除 if now_hour 10 and d[category] not in (早餐, 饮品): continue # 时段菜单约束 if d[dish_name] in user_dislike: continue # 忌口直接排除 result.append(d) return result这段过滤放在 prompt 拼装之前模型看到的候选集已经是「安全且可售」的它只需要在这个集合里做排序和取舍。这样做还有个附带好处候选集变小了token 消耗跟着降第 5 章算成本账时这部分能省掉不少。user_dislike之所以也放在硬过滤里而不是交给模型判断是因为「不喜欢香菜」这种偏好一旦被模型按 80% 概率遵守用户会感知到系统「时灵时不灵」确定性体验比概率性体验重要得多。3.4 推荐理由文案的生成与去重推荐位的点击率有很大一部分来自那句十几字的理由。「招牌菜很多老客复购」比「根据您的口味推荐」的点击率高出一个量级。理由的生成交给模型没问题但要限长、去重、且不允许出现绝对化用语。处理方式是在解析之后加一道后处理把理由文本按长度截断到 15 字以内对重复度高于阈值的理由做改写或替换为品类通用话术。实际项目里我准备了十几条通用话术作为兜底池比如「本店招牌出餐快」「和你常点的口味接近」「清爽解腻配主食刚好」当模型给的理由明显雷同或者超过长度时直接替换。理由里还要做一次敏感词与绝对化表述的过滤餐饮广告法对「最」「第一」这类词有明确限制过一遍词表比事后被投诉划算。4. 向量召回加 DeepSeek 重排的两段式链路到第 3 章为止链路还是「把所有在售菜品塞给模型」。SKU 少的时候能扛住一旦单店 SKU 上到三四百、再加上跨门店通用菜品prompt 会膨胀到几千 token延迟和成本都不好看。这时候需要把链路拆成两段向量召回做粗排把候选从几百压到几十DeepSeek 做重排在几十个候选中做精细排序和理由生成。4.1 菜品向量化与粗排召回粗排的目标不是准是「不丢」。把用户的历史偏好、当前时段、门店信息拼成一条查询文本跟菜品画像的向量做相似度比较取 top 50 送进重排。菜品侧向量在菜品上架时算一次、入库缓存用户侧向量可以按小时级更新没必要每次请求都算。import numpy as np def cosine_topk(query_vec: np.ndarray, dish_matrix: np.ndarray, dish_ids: list[int], k: int 50) - list[int]: # 向量都已做 L2 归一化点积即余弦相似度 sims dish_matrix query_vec idx np.argpartition(-sims, k)[:k] # 只取 topk避免全量排序 idx idx[np.argsort(-sims[idx])] return [dish_ids[i] for i in idx] def build_query_text(taste: dict, shop: dict) - str: # 查询文本要覆盖偏好、场景、约束三类信号 parts [ f辣度偏好{taste[spicy_pref]}, f客单价{taste[avg_price]/100:.0f}元, f喜欢{、.join(taste[prefer_tags][:3])}, f当前时段{shop[hour]}点, f门店类型{shop[shop_type]}, ] return .join(parts)np.argpartition而不是全量argsort是因为候选基数在几千级别时前者是 O(n)后者是 O(n log n)在点单高频场景下这点差异会累积成可观的 CPU 开销。召回数量取 50 是一个经验值太小会丢候选太大重排的 token 成本压不住50 在多数餐饮品类里能覆盖 90% 以上的有效候选。注意粗排的 embedding 不必和重排用同一个模型。DeepSeek 在这条链路里的角色是重排和理由生成向量化用性价比更高的开源 embedding 模型即可两者解耦升级互不影响。4.2 上下文裁剪把 50 个候选压进 token 预算50 个候选如果每个都带完整字段prompt 很容易冲到三四千 token。裁剪的原则是只给模型做决策必需的字段其余字段在模型返回 id 之后由代码补回来。字段是否进 prompt原因dish_id是返回值必须能映射dish_name是模型理解品类需要price是预算约束要用spicy_level是口味匹配要用cook_seconds是赶时间场景要用tags是限 3 个影响语义判断gross_margin否排序靠代码 tie-breakdish_desc否占 token 且对排序贡献小图片地址否返回后由前端按 id 取按这张表裁完50 个候选大约在 1200 到 1800 token 之间加上 system prompt 和用户画像单次请求的输入稳定在 2000 token 出头。毛利字段不进 prompt 是刻意的把毛利交给代码在模型输出之后做同分排序既省 token又避免了模型为了推高毛利而牺牲相关性。4.3 重排调用、结果解析与降级重排的调用方式和第 2 章的最小调用基本一致差别在于返回的 id 要映射回完整菜品对象并且必须有一条降级路径。def rerank_and_hydrate(taste, shop, recalled: list[dict]) - list[dict]: valid_ids {d[dish_id] for d in recalled} try: raw call_deepseek(taste, shop, recalled) # 返回 JSON 字符串 items parse_recommendation(raw, valid_ids) if len(items) 3: raise ValueError(推荐数量不足) order {it.dish_id: i for i, it in enumerate(items)} reason {it.dish_id: it.reason for it in items} # 用模型给的顺序重排并补回完整字段 picked sorted([d for d in recalled if d[dish_id] in order], keylambda d: order[d[dish_id]]) for d in picked: d[reason] reason[d[dish_id]] return picked[:6] except Exception: # 降级按近 7 天销量取前 6理由用通用话术 return fallback_by_sales(shop[shop_id], limit6)降级路径不是可选项。大模型调用会超时、会限流、会在高峰期返回 5xx任何一次没有兜底的失败都会让推荐位在用户面前变成空白。fallback_by_sales只需要一条按销量的 SQL成本极低但能把可用性拉到 100%。另外注意第 3 行的数量校验模型偶尔会只返回一两个结果这种「合法但不合格」的响应比直接报错更难发现必须显式检查。4.4 缓存键、超时预算与点单页首屏扫码点单页的体验红线是首屏 1.5 秒内出内容所以链路各段的耗时必须提前分配而不是上线后被动优化。链路环节目标耗时超时阈值超时后动作用户标签读取Redis5 ms50 ms用默认画像硬约束过滤本地计算10 ms50 ms跳过用上一版候选向量召回30 ms100 ms退化为按品类取候选DeepSeek 重排800 ms1200 ms降级到销量榜结果落库与埋点10 ms50 ms异步写不阻塞返回缓存键设计上我一般用rec:{user_id}:{shop_id}:{menu_version}:{hour_bucket}这个组合。hour_bucket按小时切分而不是精确到秒是因为同一小时内用户的偏好基本不变按秒切分等于没有缓存menu_version必须在键里菜单一换缓存整体失效这是防止推下架菜最省事的一招。命中缓存时直接返回整条链路的 P99 能压到 20 毫秒以内实际命中率在午晚高峰能到 40% 左右对成本和延迟都是明显收益。5. 灰度验证、成本核算与高频排错推荐系统上线只是开始真正决定它能不能活下来的是验证方式和运维细节。5.1 离线指标和线上灰度怎么切离线评估不能只看准确率餐饮推荐位的目标是「被点」所以指标要围绕曝光-点击-加购这条链路来设。我一般看四个NDCG6 衡量排序质量、推荐位点击率CTR、推荐菜加入购物车的比例、以及推荐菜的毛利贡献占比。前两个看效果后两个看商业价值四个指标一起涨才是真的好。注意离线指标涨、线上 CTR 不涨的情况很常见原因通常是离线评估用的是历史曝光数据而线上是新的推荐位曝光分布变了。所以离线只做粗筛最终决策看线上。灰度分桶用 user_id 哈希取模控制组走原销量榜实验组走新链路比例从 5% 起步。观察周期至少覆盖一个完整的营业周期——工作日和周末的菜单偏好差异很大只看三天工作日数据会得出错误结论。如果门店用了企业微信做运营把实验组和对照组的日度指标推到店长群里反馈往往比数据报表更快暴露问题比如「推荐的菜后厨做不出来」这类只有一线才知道的坑。5.2 一笔真实的成本与延迟账按单店日均 500 次推荐请求、每次输入 2000 token、输出 300 token 估算日消耗约 115 万 token。走开放平台按 token 计费时这笔开销在中小连锁的可接受范围内真正需要盯的是缓存命中率和候选裁剪这两项做扎实能省掉一半以上的调用量。延迟方面有缓存时 20 毫秒以内无缓存时主要成本在重排的 800 毫秒P99 控制在 1.3 秒以内是可以做到的。如果门店规模继续扩大、单日 token 消耗逼近需要本地化部署的临界点就按第 2 章说的思路把这层调用封装成内部服务再换后端。5.3 高频排错清单几个反复出现的问题和对应处置模型返回不存在的菜品 id白名单校验必须做且选择静默丢弃而非整批作废。输出被 markdown 代码块包裹即使开了 json_object仍要在解析层做一次正则剥离。推荐结果同一用户反复刷新不一致把 temperature 固定到 0 或 0.3并把缓存键加上 menu_version 和 hour_bucket。上下文超长报错先查候选裁剪是否生效再看用户复购列表有没有无上限拼接repeat_dish取前 3 个就够。高峰期大面积超时不要盲目加大 timeout先确认是不是重排挤占了连接池把重排的超时压到 1200 毫秒并强制降级。推荐菜后厨做不出来cook_seconds在高客流时段要动态下调阈值这个值最好由门店实时排队数据驱动而不是写死。把 temperature 固定为 0.3、缓存键带上菜单版本号、硬约束永远放在模型外面这三条能消掉上线初期大部分的客诉和运维告警。本文还有配套的精品资源点击获取
