第2267篇:餐饮和本地生活AI——菜单理解和智能点餐推荐
2026/4/30大约 8 分钟
第2267篇:餐饮和本地生活AI——菜单理解和智能点餐推荐
适读人群:本地生活平台工程师、Java后端开发者、餐饮科技技术团队 | 阅读时长:约14分钟 | 核心价值:从餐饮行业的真实痛点出发,实现菜单智能解析、个性化推荐和动态定价的工程方案
做本地生活平台的时候,有个数据让我印象很深:平台上接入了几十万家餐厅,但菜单数据质量极差。同样是一道"红烧肉",有的商家写"红烧东坡肉",有的写"东坡红烧肉",有的写"江南东坡肉",还有直接写"招牌肉"。
结果是什么?用户搜索"红烧肉",很多商家的这道菜就搜不到;推荐系统也没法做菜品维度的推荐,因为菜品信息本身就是一锅粥。
更别提还有大量商家是直接拍了菜单图片上传,或者手写菜单拍照,系统根本无法解析。有一段时间我们靠人工团队录入菜单,那个运营成本高到令人发指,而且准确率也不稳定。
这个问题有了多模态LLM之后,才真正有了优雅的解决方案。
餐饮AI系统架构
菜单智能解析
多模态菜单解析服务
@Service
public class MenuParsingService {
@Autowired
private OpenAIClient openAIClient;
@Autowired
private MenuStandardizationService standardizationService;
@Autowired
private DishCategoryRepository categoryRepo;
/**
* 解析菜单——支持图片、PDF和文本三种输入
*/
public MenuParseResult parseMenu(MenuParseRequest request) {
String restaurantId = request.getRestaurantId();
MenuInputType inputType = request.getInputType();
return switch (inputType) {
case IMAGE -> parseFromImage(request.getImageBytes(), restaurantId);
case PDF -> parseFromPdf(request.getPdfBytes(), restaurantId);
case TEXT -> parseFromText(request.getText(), restaurantId);
};
}
/**
* 图片菜单解析(手机拍照场景最多)
*/
private MenuParseResult parseFromImage(byte[] imageBytes, String restaurantId) {
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String prompt = """
请分析这张餐厅菜单图片,提取所有菜品信息。
对每道菜,提取:
- name: 菜品名称(原文)
- price: 价格(数字,人民币元)
- description: 描述(如果有)
- category: 菜品类别(冷菜/热菜/主食/汤品/饮品/甜点/小吃等)
- special_tags: 特殊标签(招牌菜/新品/辣/素食/限时等)
注意:
1. 如果价格不清楚,标注为null
2. 如果图片模糊无法识别,标注unclear:true
3. 保留原始菜名,不要修改
4. 如果是套餐,识别为category:套餐
返回JSON:
{
"dishes": [
{"name": "菜名", "price": 38.0, "category": "热菜", "special_tags": ["招牌菜"],
"description": "描述", "unclear": false}
],
"restaurant_type": "识别到的餐厅类型(中餐/西餐/日料等)",
"parse_confidence": 0.85,
"unclear_areas": ["区域1描述"]
}
""";
ChatCompletionResponse response = openAIClient.createChatCompletion(
ChatCompletionRequest.builder()
.model("gpt-4o")
.messages(List.of(
ChatMessage.userMessage(List.of(
ContentPart.text(prompt),
ContentPart.imageBase64("image/jpeg", base64Image)
))
))
.responseFormat(ResponseFormat.JSON_OBJECT)
.maxTokens(3000)
.build()
);
String jsonResult = response.getChoices().get(0).getMessage().getContent();
RawMenuData rawData = JsonUtils.parseObject(jsonResult, RawMenuData.class);
// 菜品标准化处理
List<StandardizedDish> standardizedDishes = rawData.getDishes().stream()
.map(raw -> standardizationService.standardize(raw, restaurantId))
.collect(Collectors.toList());
return MenuParseResult.builder()
.restaurantId(restaurantId)
.dishes(standardizedDishes)
.restaurantType(rawData.getRestaurantType())
.parseConfidence(rawData.getParseConfidence())
.unclearAreas(rawData.getUnclearAreas())
.build();
}
/**
* 菜品标准化:将商家自定义名称映射到标准菜品体系
*/
@Service
public static class MenuStandardizationService {
@Autowired
private OpenAIClient openAIClient;
@Autowired
private StandardDishRepository standardDishRepo;
@Autowired
private EmbeddingService embeddingService;
@Autowired
private VectorStore dishVectorStore;
public StandardizedDish standardize(RawDish rawDish, String restaurantId) {
// 1. 向量相似度匹配标准菜品
float[] dishVector = embeddingService.embed(rawDish.getName() + " " +
Optional.ofNullable(rawDish.getDescription()).orElse(""));
List<StandardDish> candidates = dishVectorStore.search(
dishVector,
SearchParams.builder()
.topK(5)
.minSimilarity(0.80)
.filter("category", rawDish.getCategory())
.build()
);
// 2. 如果相似度足够高,直接映射
if (!candidates.isEmpty() && candidates.get(0).getSimilarityScore() > 0.92) {
StandardDish matched = candidates.get(0);
return buildStandardizedDish(rawDish, matched, restaurantId);
}
// 3. LLM辅助标准化(相似度不够高的情况)
StandardDish standardDish = llmAssistedStandardization(rawDish, candidates);
// 4. 如果是全新菜品,创建新的标准化条目
if (standardDish == null) {
standardDish = createNewStandardDish(rawDish);
standardDishRepo.save(standardDish);
// 更新向量库
dishVectorStore.upsert(standardDish);
}
return buildStandardizedDish(rawDish, standardDish, restaurantId);
}
private StandardDish llmAssistedStandardization(RawDish rawDish,
List<StandardDish> candidates) {
if (candidates.isEmpty()) return null;
String candidatesDesc = candidates.stream()
.map(c -> String.format("- %s(%s)", c.getName(), c.getDescription()))
.collect(Collectors.joining("\n"));
String prompt = String.format("""
请判断以下餐厅菜品是否与候选标准菜品匹配:
餐厅菜品:%s(%s)
候选标准菜品:
%s
如果匹配,返回最匹配的标准菜品名称;如果都不匹配,返回"NO_MATCH"。
只返回匹配的名称或"NO_MATCH",不要其他内容。
""",
rawDish.getName(),
Optional.ofNullable(rawDish.getDescription()).orElse(""),
candidatesDesc
);
String result = callLLM(prompt, "gpt-4o-mini").trim();
if ("NO_MATCH".equals(result)) return null;
return candidates.stream()
.filter(c -> c.getName().equals(result))
.findFirst()
.orElse(null);
}
}
}个性化推荐引擎
@Service
public class DishRecommendationService {
@Autowired
private UserFlavorProfileService profileService;
@Autowired
private CollaborativeFilteringService cfService;
@Autowired
private DishFeatureService dishFeatureService;
@Autowired
private OpenAIClient openAIClient;
/**
* 个性化菜品推荐
*/
public DishRecommendationResult recommend(RecommendationRequest request) {
String userId = request.getUserId();
String restaurantId = request.getRestaurantId();
RecommendContext context = request.getContext();
// 1. 获取餐厅菜品列表
List<StandardizedDish> menuDishes = menuRepository.findByRestaurantId(restaurantId);
if (menuDishes.isEmpty()) {
return DishRecommendationResult.empty();
}
// 2. 获取用户口味画像
UserFlavorProfile profile = profileService.getProfile(userId);
// 3. 协同过滤推荐(基于相似用户的选择)
List<String> cfRecommendedDishIds = cfService.recommend(userId, restaurantId, 20);
// 4. 基于内容的推荐(基于用户口味偏好 vs 菜品特征)
List<String> contentBasedDishIds = contentBasedRecommend(profile, menuDishes, 20);
// 5. 融合两种推荐
Map<String, Double> dishScores = mergeScoringStrategies(
cfRecommendedDishIds, contentBasedDishIds, menuDishes
);
// 6. 上下文过滤(早餐/午餐/晚餐/宵夜,天气等)
dishScores = applyContextFilter(dishScores, menuDishes, context);
// 7. 取Top推荐
List<RecommendedDish> topDishes = dishScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(8)
.map(entry -> buildRecommendedDish(entry.getKey(), entry.getValue(), profile))
.collect(Collectors.toList());
// 8. 生成推荐理由(增强可解释性)
topDishes = generateRecommendationReasons(topDishes, profile, context);
return DishRecommendationResult.builder()
.userId(userId)
.restaurantId(restaurantId)
.recommendations(topDishes)
.build();
}
private List<String> contentBasedRecommend(UserFlavorProfile profile,
List<StandardizedDish> menuDishes,
int topK) {
// 计算每道菜与用户口味的匹配分
return menuDishes.stream()
.map(dish -> {
DishFeatures features = dishFeatureService.getFeatures(dish.getId());
double score = computeFlavorMatch(profile, features);
return Map.entry(dish.getId(), score);
})
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
private double computeFlavorMatch(UserFlavorProfile profile, DishFeatures features) {
double score = 0.0;
// 口味匹配(辣度、甜度、油腻度)
double spicyDiff = Math.abs(profile.getSpicyPreference() - features.getSpicyLevel());
double sweetDiff = Math.abs(profile.getSweetPreference() - features.getSweetLevel());
score += (1 - spicyDiff / 5.0) * 0.3; // 辣度差距归一化
score += (1 - sweetDiff / 5.0) * 0.2;
// 饮食限制过滤(绝对约束)
if (profile.isVegetarian() && !features.isVegetarian()) return -1.0;
if (profile.hasAllergy(features.getAllergens())) return -1.0;
// 食材偏好
for (String ingredient : features.getMainIngredients()) {
if (profile.getLikedIngredients().contains(ingredient)) score += 0.1;
if (profile.getDislikedIngredients().contains(ingredient)) score -= 0.2;
}
// 菜系偏好
if (profile.getPreferredCuisines().contains(features.getCuisineType())) {
score += 0.2;
}
return Math.max(0, score);
}
private List<RecommendedDish> generateRecommendationReasons(List<RecommendedDish> dishes,
UserFlavorProfile profile,
RecommendContext context) {
// 批量生成推荐理由,减少LLM调用次数
String dishesInfo = dishes.stream()
.map(d -> String.format("菜品:%s,特点:%s,用户相关性:%s",
d.getDishName(), d.getFeatureSummary(), d.getMatchReason()))
.collect(Collectors.joining("\n"));
String prompt = String.format("""
请为以下推荐菜品生成简短、口语化的推荐理由(每条不超过15字)。
用户画像:%s偏好,%s口味
场景:%s
菜品列表:
%s
返回JSON数组,与输入顺序对应:
["推荐理由1", "推荐理由2", ...]
""",
profile.getCuisinePreferenceSummary(),
profile.getFlavorPreferenceSummary(),
context.getMealType().getDescription(),
dishesInfo
);
String jsonResult = callLLM(prompt, "gpt-4o-mini");
List<String> reasons = JsonUtils.parseList(jsonResult, String.class);
for (int i = 0; i < Math.min(dishes.size(), reasons.size()); i++) {
dishes.get(i).setDisplayReason(reasons.get(i));
}
return dishes;
}
}用户口味画像构建
@Service
public class UserFlavorProfileService {
@Autowired
private OrderHistoryRepository orderRepo;
@Autowired
private ReviewRepository reviewRepo;
/**
* 基于历史订单和评价构建口味画像
*/
public UserFlavorProfile buildProfile(String userId) {
// 近6个月的订单历史
List<OrderItem> orderItems = orderRepo.findByUserIdAndDateRange(
userId, LocalDate.now().minusMonths(6), LocalDate.now()
);
if (orderItems.isEmpty()) {
return UserFlavorProfile.defaultProfile();
}
// 统计点过的菜品特征
Map<String, Long> cuisineFrequency = orderItems.stream()
.map(item -> getDishCuisine(item.getDishId()))
.filter(Objects::nonNull)
.collect(Collectors.groupingBy(s -> s, Collectors.counting()));
// 从评价中提取偏好信号
List<DishReview> reviews = reviewRepo.findByUserId(userId);
Map<String, Double> ingredientScores = new HashMap<>(); // 食材 -> 正负评分
List<String> allergyHints = new ArrayList<>();
for (DishReview review : reviews) {
DishFeatures features = dishFeatureService.getFeatures(review.getDishId());
double sentiment = review.getRating() >= 4.0 ? 1.0 : review.getRating() <= 2.0 ? -1.0 : 0.0;
features.getMainIngredients().forEach(ing ->
ingredientScores.merge(ing, sentiment, Double::sum)
);
}
// 辣度偏好(从历史菜品的辣度统计)
double avgSpicyLevel = orderItems.stream()
.mapToDouble(item -> getDishSpicyLevel(item.getDishId()))
.average().orElse(2.5);
// 喜欢和不喜欢的食材
List<String> likedIngredients = ingredientScores.entrySet().stream()
.filter(e -> e.getValue() > 2.0)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
List<String> dislikedIngredients = ingredientScores.entrySet().stream()
.filter(e -> e.getValue() < -1.0)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
// 偏好菜系(按频率排序,取前3)
List<String> preferredCuisines = cuisineFrequency.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(3)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
return UserFlavorProfile.builder()
.userId(userId)
.spicyPreference(avgSpicyLevel)
.likedIngredients(likedIngredients)
.dislikedIngredients(dislikedIngredients)
.preferredCuisines(preferredCuisines)
.isVegetarian(checkVegetarianPattern(orderItems))
.updatedAt(LocalDateTime.now())
.build();
}
}动态定价和营销推荐
@Service
public class DynamicPricingService {
@Autowired
private OpenAIClient openAIClient;
/**
* AI辅助商家定价建议(不是强制执行)
*/
public PricingRecommendation generatePricingAdvice(String restaurantId, String dishId) {
DishSalesStats salesStats = salesRepository.getStats(restaurantId, dishId, 30);
CompetitorPricing competitorPricing = competitorPricingRepo.find(dishId, restaurantId);
String prompt = String.format("""
请为餐厅菜品提供定价建议(仅供参考)。
菜品:%s
当前价格:%.1f元
近30天数据:
- 日均销量:%.1f份
- 加购率(同时购买其他菜品的比例):%.0f%%
- 好评率:%.0f%%
- 退款率:%.1f%%
周边竞品价格:%s
请分析:
1. 当前定价是否合理
2. 建议调整方向(涨价/降价/维持)及理由
3. 如果建议调整,建议幅度和预期影响
注意:只给建议,最终决策由商家自己做。
""",
dishRepository.findById(dishId).orElseThrow().getName(),
salesStats.getCurrentPrice(),
salesStats.getDailyAvgVolume(),
salesStats.getCrossBuyRate() * 100,
salesStats.getPositiveRatingRate() * 100,
salesStats.getRefundRate() * 100,
formatCompetitorPricing(competitorPricing)
);
String advice = callLLM(prompt, "gpt-4o-mini");
return PricingRecommendation.builder()
.restaurantId(restaurantId)
.dishId(dishId)
.advice(advice)
.generatedAt(LocalDateTime.now())
.build();
}
}餐饮AI工程经验
1. 菜单数据质量是一切的基础。推荐系统再好,如果菜品数据是乱的,效果就是零。菜单解析和标准化是餐饮AI最重要的基础工作,值得花大力气做好。
2. 推荐要考虑场景上下文。早上推夜宵,热天推火锅,都是很差的体验。时间、天气、节日等上下文信号对推荐效果影响很大,不能做成纯粹的历史行为预测。
3. 新用户冷启动问题。新用户没有行为数据,要依赖:位置(附近同类用户的偏好)、注册信息(城市、年龄)、本次浏览行为(点了什么、停留多久)做即时特征。
4. 推荐多样性不能忽视。算法容易陷入"回音室",一直推用户以前点过的菜,实际上用户也想偶尔尝试新菜。推荐列表要有探索性,保证一定比例的新菜推荐,平衡精准度和多样性。
5. 餐厅需要的不只是用户推荐,还有商家侧工具。帮商家分析哪些菜卖得好、哪些菜成本高但利润低、竞品在推什么——这些商家侧的AI工具,对平台的商家留存有很高价值。
