每到毕业季,总有一批人对着选题表发呆。“旅游路线推荐系统”这个名字听起来不算新鲜,但把 Django 和 LLM 大模型绑在一起以后,它的可做性和含金量完全变了。我这次要拆的,就是一套基于 Django + LLM 大模型的智能路线规划与个性化推荐系统,核心链路涵盖用户画像、协同过滤召回、路线多约束规划、数据分析可视化和大模型生成推荐解释,既适合计算机方向毕业设计,也适合往大数据方向延伸改造。
这套系统解决的核心问题有三个:用户不知道去哪玩,系统不知道给用户推什么,以及推了以后用户不知道为什么要推。传统推荐只能解决前两个,大模型进来之后第三个问题也能解决,推荐理由、行程解说甚至实时偏好对话都可以由它生成。所以它不只是把几个技术名词堆在一起,而是把一条完整的、可以讲出故事的推荐链路真正落到了 Web 项目里。
适合谁来参考?如果你正在准备做毕设,或者想在公司内部做一个轻量的旅游推荐 Demo,这篇文章按模块拆好、按代码讲透,可以直接照着搭。如果你只想看整体思路,前两节内容也够用。
1. 项目定位与整体设计思路
1.1 为什么是“旅游 + 推荐 + LLM”这个组合
先说选题。很多同学想找“既有技术含量、又能轻松讲清楚”的题目,结果一头扎进电商推荐。电商推荐被做了多少年,导师一听就想睡觉。旅游推荐相对没那么拥挤,而且天然适合叠加不同技术:
- 数据维度多:景点有评分、票价、游玩时长、经纬度、标签、月度热度;用户有地域、消费等级、出行偏好。把这些揉在一起做分析,很容易出图表。
- 业务场景清晰:推荐不是推完就结束,后面还要做路线规划,这就比单纯推荐多了“约束满足”的味道。
- 大模型有合适的切入位置:推荐理由生成、行程文案、意图对话,这些都是传统推荐系统不擅长的内容生成任务,LLM 做起来却非常顺手。
这套题目的大数据属性也成立。行为日志、景点基础信息、用户属性三类数据一合并,就是一个小型特征工程场景。答辩时你完全可以从“用户画像怎么构建”“评分稀疏怎么处理”“热度如何影响排序”这几个角度展开,都是评审老师会感兴趣的点。
1.2 为什么后端框架锁定 Django
备选项无非是 Flask、FastAPI、Django 三选一。我第一次搭这个系统时也纠结过,后来定了 Django,理由很实在:
- 自带 ORM 和 Admin 后台,毕设阶段可以少写大量搬数据的小页面。
- 自带用户认证体系,旅游推荐系统必须区分用户身份、记录行为,Django 的 auth 模块直接省了一整套注册登录逻辑。
- MVT 架构在论文里特别好画图,架构图出来就是标准的三层结构,方便写“分层设计”那一章。
- 生态成熟,Redis、Celery、第三方登录这些扩展全是现成的。
FastAPI 性能更好,但毕设系统不需要支撑高并发。Django 牺牲一点性能,换来开发效率和论文描述上的省心,这笔账怎么算都划算。如果你担心 Django 不够新潮,可以在接口层直接用 Django REST Framework 写 API,前端用 Vue 独立渲染,既保留了 Django 的优势,又让系统看起来不那么传统。
注意:不要被网上“Django 过时”的说法带偏。框架只是工具,重点是你用它讲了什么完整的故事。能用 Django + LLM 把推荐链路跑通,本身就是很完整的工程能力展示。
1.3 系统架构:三层链路 + 两类接口
我把系统整体切成三个层次:
第一层是数据层。存放用户信息、景点库、用户行为记录、路线生成记录。这一层解决的是“系统有什么”的问题。
第二层是推荐引擎层。核心是召回和排序两个阶段。召回负责从几千个景点里筛出几十个候选,排序负责把用户最可能感兴趣的放到最前面。这一层解决的是“系统推什么”的问题。
第三层是大模型应用层。接收用户输入的模糊需求,比如“我想去海边玩三天”,翻译成结构化条件,再结合推荐结果生成路线解说和推荐理由。这一层解决的是“为什么推、怎么玩”的问题。
两类接口也很清晰:一类是 REST API,给前端页面用,负责登录、景点列表、推荐结果、收藏行为;另一类是大模型 API 的封装接口,把提示词模板、参数调节、结果解析都收敛在后端,前端只拿到整理好的 JSON。
这种分层的直接好处是,论文里每一层都能单独画图、单独写原理。我在实际答辩时,评审老师问我“推荐结果到底怎么算出来的”,我直接把排序公式和代码贴出来讲了两分钟,这个问题就过去了。分层的价值不只在代码里,也在答辩的口径里。
2. 核心模块需求拆解
2.1 用户画像构建:从注册到行为采集
用户画像是后面所有推荐动作的基础。我在系统里设计了显式和隐式两条采集路径。显式路径是注册时填写的常住城市、偏好的旅行风格、预算档位;隐式路径是用户在系统里的点击、收藏、搜索行为。两者都不难,难的是怎么把采集到的碎片信息变成可计算的标签结构。
最稳妥的做法是做一个标签表,每个标签有权重。用户在注册时选了“亲子游”,该标签初始权重就是 1.0;用户点击了 3 个自然风光类的景点,自然风光标签权重每次叠加 0.3;搜索了“海滨”,海滨类标签加 0.5。有了这套权重,就不需要把用户粗暴地归成某一个类型,而是用一组标签向量来表示。
另外一个容易被忽略的点是行为的时间衰减。用户三个星期前点击过某个景点,和昨天点击过,意义完全不同。我对行为权重乘了一个 exp(-λt) 的衰减因子,λ 取 0.02 左右,时间以天为单位。这样不需要专门写定时任务去清过期数据,计算标签时自然会把旧行为压轻。
2.2 基于物品协同过滤的召回与热度补偿
推荐系统最怕的是冷启动和稀疏,但完全不做协同过滤又显得没有“推荐味”。我的做法是用 ItemCF 做召回,再加热度补偿兜底。
ItemCF 的核心公式是计算物品之间的相似度:
[ w_{ij} = \frac{|N(i) \cap N(j)|}{\sqrt{|N(i)| \times |N(j)|}} ]
N(i) 表示对景点 i 产生过行为的用户集合。两个景点共同被越多的用户点击,它们之间的相似度就越高。这个公式比简单的交集计数好的一点是,它做了归一化,避免了“热门景点和所有景点都相似”的问题。
召回的流程是:找到用户最近有过正反馈行为的景点集合 U,对 U 中的每个景点 i,找出和它最相似的 top K 个景点,合并去重,得到候选集。候选集一般控制在 50 到 100 个。这一步不需要太精细,关键是保证用户感兴趣的类别没有被漏掉。
热度补偿放在召回之后。如果候选集中某些景点近 30 天的访问热度特别高,就给它们加一个小幅热度分,避免召回结果过于冷门。但这个分不能太大,否则就变成了纯热门推荐。我在代码里把热度权重设在 0.2,语义就是“用户偏好占八成,热度趋势占两成”。
2.3 路线规划的多约束贪心策略
推荐完成之后,最体现“规划”二字的环节来了:把推荐的景点串成一条可执行路线。这不是把景点排个序就行,实际要考虑的约束有:
- 景点之间的地理位置距离,不能上午还在城东、下午就跑到城西去;
- 每个景点的建议游玩时长,总时长不能超过用户设定的行程预算;
- 每天的行程不能排得太满,留出吃饭和交通时间。
我把问题简化成一个带约束的序列选择问题。常用做法是贪心算法:从用户所在位置或酒店位置出发,每一步从尚未选择的候选景点中,选择距离当前点最近、且剩余时间足够容纳游玩时长的评分最高景点。选择完一个景点后,把位置更新为该景点,继续下一个。
为了不显得太机械,我加了一个局部优化操作:在生成完整路线后,检查相邻景点之间是否有车程特别大的,如果有,就尝试交换这两个景点的顺序,看总距离是否下降。这个操作类似最朴素的 2-opt 思想,代码量不大,效果却非常直观——演示的时候,路线图上的连线不会出现交叉和回头路。
注意:路线规划是论文里的“算法亮点”章节,一定要结合具体的约束公式来写,不能只说“用了贪心算法”。把时间窗口、地理距离、用户偏好权重写清楚,算法的完整度立刻就上去了。
2.4 大模型智能模块:意图识别与内容生成
这是整套系统的加分项,也是一个需要控制风险的环节。我从一开始就不建议把大模型放在推荐链路的全流程里跑,一是速度慢,二是推荐结果不可控。我的设计是让大模型只做两件事:意图翻译和文案生成。
意图翻译发生在用户输入自然语言需求时。用户可能说“我想去山里面清净三天”,这时候需要把这句话解析成结构化条件,比如目的地类型偏向“自然”“山峰”,预算档位中等,出行天数 3 天。这部分最初我打算用规则匹配,后来发现用户说法太多样,规则写不完,最终交给大模型,效果立竿见影。
文案生成则是在推荐结果定下来之后。大模型根据最终确定的景点序列,写一份两三百字的行程概要,再给每个景点写一句推荐理由。这类文案针对特定景点的组合,直接套模板会很生硬,大模型生成出来的自然度要好很多。
每次调用大模型时,我都要求它返回严格 JSON 结构,并在提示词里给好示例。这一点非常重要:不限制输出格式的话,解析结果时会是你整个项目里最痛苦的部分。
3. 实操过程与核心环节实现
3.1 数据库建模:从景点表到行为表
Django 的 ORM 写起来省事,但模型设计是否合理,直接决定后续开发和演示是否顺利。我的模型规划是这样的:
# models.py 核心表设计 from django.db import models from django.contrib.auth.models import User class City(models.Model): name = models.CharField(max_length=50, unique=True) province = models.CharField(max_length=50, verbose_name="省份") hot_level = models.FloatField(default=0, verbose_name="城市热度") def __str__(self): return self.name class ScenicSpot(models.Model): name = models.CharField(max_length=100) city = models.ForeignKey(City, on_delete=models.CASCADE, related_name="spots") category = models.CharField(max_length=50, choices=( ("nature", "自然风光"), ("culture", "人文历史"), ("leisure", "休闲度假"), ("food", "美食探店"), ), default="nature") score = models.FloatField(default=0, verbose_name="评分") price = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name="门票价格") duration_hours = models.FloatField(default=2.0, verbose_name="建议游玩小时数") longitude = models.FloatField(verbose_name="经度") latitude = models.FloatField(verbose_name="纬度") tags = models.CharField(max_length=200, blank=True, verbose_name="标签,逗号分隔") monthly_heat = models.FloatField(default=0, verbose_name="近30天热度") def __str__(self): return self.name class UserBehavior(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="behaviors") spot = models.ForeignKey(ScenicSpot, on_delete=models.CASCADE) action_type = models.CharField(max_length=10, choices=( ("click", "点击"), ("collect", "收藏"), ("order", "购买"), ), default="click") created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ models.Index(fields=["user", "created_at"]), models.Index(fields=["spot", "created_at"]), ]行为表加复合索引很重要。我在实际测试中发现,行为记录到了几千条以后,不带索引的查询会明显变慢,加了 (user, created_at) 和 (spot, created_at) 两个索引后,推荐接口的响应时间直接降了一半。
路线生成表我用了一个 JSONField 存景点 ID 列表和行程说明。JSONField 看起来很偷懒,但在毕设阶段非常实用,不需要为“路线和景点的多对多关系”额外建关联表,而且序列化传给前端也顺手。
3.2 推荐引擎的得分计算全流程
推荐引擎的核心函数不需要多复杂,关键是把计算流程写清楚。我当时实现了一个函数,输入用户 ID,输出按得分排序的景点列表。
# recommender.py 简化版推荐引擎 import math from datetime import datetime, timedelta from django.utils import timezone from .models import ScenicSpot, UserBehavior def get_similar_spots(spot_id, top_k=10): # 获取与该景点有共同行为用户的其他景点 behavior_users = UserBehavior.objects.filter( spot_id=spot_id, action_type__in=["click", "collect", "order"] ).values_list("user_id", flat=True) candidate_behaviors = UserBehavior.objects.filter( user_id__in=behavior_users ).exclude(spot_id=spot_id).values_list("spot_id", flat=True) spot_count = {} for sid in candidate_behaviors: spot_count[sid] = spot_count.get(sid, 0) + 1 n_i = len(set(behavior_users)) sim_scores = {} for sid, cnt in spot_count.items(): n_j = UserBehavior.objects.filter( spot_id=sid, action_type__in=["click", "collect", "order"] ).values("user_id").distinct().count() sim_scores[sid] = cnt / math.sqrt(n_i * n_j) return sorted(sim_scores.items(), key=lambda x: x[1], reverse=True)[:top_k] def recommend_for_user(user_id, top_n=20): user = User.objects.get(id=user_id) recent_behavior_spots = UserBehavior.objects.filter( user_id=user_id, created_at__gte=timezone.now() - timedelta(days=30), ).order_by("-created_at") interacted_spot_ids = set() score_dict = {} for behavior in recent_behavior_spots: if behavior.spot_id in interacted_spot_ids: continue interacted_spot_ids.add(behavior.spot_id) for similar_spot_id, sim in get_similar_spots(behavior.spot_id, top_k=5): time_weight = math.exp(-0.02 * max((timezone.now() - behavior.created_at).days, 0)) behavior_weight = {"click": 0.5, "collect": 1.0, "order": 1.5}[behavior.action_type] if similar_spot_id not in interacted_spot_ids: score_dict[similar_spot_id] = score_dict.get(similar_spot_id, 0) + sim * behavior_weight * time_weight # 热度补偿:在得分基础上加一个小的热度分 hot_spots = ScenicSpot.objects.filter(id__in=score_dict.keys()).values_list("id", "monthly_heat") heat_range = max([h for _, h in hot_spots], default=1) - min([h for _, h in hot_spots], default=0) for sid, _ in hot_spots: if heat_range > 0: score_dict[sid] += 0.2 * (_ - min([h for _, h in hot_spots], default=0)) / heat_range ranked_spots = sorted(score_dict.items(), key=lambda x: x[1], reverse=True)[:top_n] return [ScenicSpot.objects.get(id=sid) for sid, _ in ranked_spots]这里有几个细节值得说。行为权重设置成点击 0.5、收藏 1.0、购买 1.5,是因为“购买”比“收藏”行为能更确定性表达用户真实兴趣。时间衰减用了指数形式,比线性衰减更符合遗忘曲线的规律。
实际演示时,我拿一个注册了三天的测试账号,先去点击了三个自然风光类景点,再刷新推荐页,发现推荐结果里自然风光的占比明显上升,而且新推荐的景点有“西岭雪山”“九寨沟”这类和已点击景点同类的池子。这个链路跑通后,“个性化推荐”这个标题才算立得住。
提示:实际运行时要留意查询次数。上面这段代码为了可读性写得比较直白,会产生多次数据库查询。如果你在自己的项目里发现接口太慢,可以把第一次近 30 天行为查询批量取回来,然后用内存 dict 做统计,数据库只负责一次查询。
3.3 大模型接口封装与结构化返回
大模型部分我单独封装了一个服务层,前端页面不了解大模型的存在,只面向后端 API。调用大模型的代码大致是这个模式:
# llm_service.py 大模型服务封装 import json import requests SYSTEM_PROMPT = """ 你是一个旅游规划助手。请根据用户需求、候选景点列表和用户画像,生成一条合理的旅游路线。 你必须严格返回 JSON 格式,不要输出任何额外内容,格式如下: { "intent": {"destination_type": "自然风光", "days": 3, "budget_level": "中等"}, "route": [{"spot_id": 1, "recommend_reason": "..."}], "summary": "整体的行程说明,200字以内" } """.strip() def parse_user_request(user_text, spot_list): prompt = SYSTEM_PROMPT + f"\n用户需求:{user_text}\n候选景点:{spot_list}" try: response = call_llm_api(prompt, temperature=0.2) result = json.loads(response) if "intent" not in result: raise ValueError("缺少 intent 字段") return result except json.JSONDecodeError: return parse_user_request_fallback(user_text) except Exception as e: return { "intent": {"destination_type": "综合", "days": 2, "budget_level": "中等"}, "route": [], "summary": "根据候选景点自动生成了基础路线。" } def call_llm_api(prompt, temperature=0.2): # 以通用的 HTTP 调用方式为例,实际接入时替换为对应服务商的 SDK payload = { "prompt": prompt, "temperature": temperature, "max_tokens": 1024, "response_format": {"type": "json_object"} } response = requests.post( "http://your-llm-service/api/generate", json=payload, timeout=30, ) response.raise_for_status() return response.json()["output"]三个细节值得展开:
第一,temperature 设置成 0.2,而不是默认的 0.7 或 1.0。推荐理由是给用户看的工具性文案,需要稳定和准确,不需要天马行空的创造性。温度越低,格式越稳,胡编概率越低。
第二,response_format 这个参数如果服务商支持,务必带上。它强制模型输出合法 JSON,能省掉后面一大堆正则匹配的麻烦。如果服务商不支持结构化输出,那就在提示词里反复强调,并给一个坏例子说“不要这样输出”。
第三,一定要有兜底逻辑。我在实际运行里遇到过模型返回 200,但解析不了 JSON 的情况,那时候直接返回一个写死的模板结果,保证接口不挂。演示阶段稳定性大于一切,宁可内容普通一点,也不能在评审老师面前出现空白页面。
3.4 数据分析与可视化面板
数据分析这块是最容易让系统“看起来丰满”的部分。我在系统里做了一个简单的统计面板,五个图表:
- 各城市景点数量与平均评分分布;
- 近 30 天景点热度 Top 10;
- 用户收藏行为的类别占比;
- 各价格区间的景点数量分布;
- 每日新增行为数量趋势。
数据来源全部从数据库聚合查询得出,然后用接口返回给前端。图表本身用 ECharts 在前端渲染,因为 ECharts 对后端语言没有依赖,给 Django 项目里塞一个静态资源就能用。
需要注意一个常见错误:不要为了展示“大数据”硬造几十万条假数据。毕设阶段用真实可解释的数据更稳妥。我在项目里预置了两个城市、四十几个景点、几百条行为记录,图表已经很好看了。数据量太大反而会让评审质疑数据的真实性。
可视化模块的价值在于:它让“数据分析”四个字有了落脚点。答辩时你可以指着热度 Top10 图说系统如何用热度数据做冷启动兜底,指着用户类别占比图说如何优化画像权重。图表不只是装饰,更是解释推荐逻辑的工具。
4. 常见问题与排查技巧实录
4.1 大模型返回不稳定,前端渲染直接崩掉
这是我对接大模型时踩过的最大一个坑。早期没有强制解析兜底,一度出现用户输入需求后页面白屏,原因就是模型返回的 JSON 多了个逗号,json.loads 直接抛异常。
解决路径分两层。第一层,在服务端把解析逻辑全部包进 try-except,任何解析失败都返回默认路线模板。第二层,对模型输出做一次清洗再解析,比如把输出内容中可能出现的 Markdown 代码块标记去掉,用正则提前把json 和剥离,再把非法内容替换为空白。加上这两层后,整个功能再没有出现白屏问题。
另外,大模型生成耗时长是必然的。前端要设计一个“生成中”的状态,配合 loading 动画,防止用户重复点击按钮。后端则需要做幂等控制,同样的请求短时间内不要重复调用模型,可以用 Redis 做 30 秒缓存,命中后直接返回上一次的结果。
4.2 新用户没有任何行为数据,推荐列表惨不忍睹
冷启动是推荐系统永恒的问题,毕设系统也是一样。新注册用户没有行为,ItemCF 召回结果为空,推荐页一片空白,体验很差。
我的处理策略是三层兜底:第一层,如果用户注册时填了常住城市或偏好类型,直接用城市和类别做筛选;第二层,用近 30 天热度排序,取热门景点填充;第三层,推荐页同时展示“热门推荐”和“猜你喜欢”两个区块,前一个区块永远不依赖用户行为,所以即使后一个区块为空,页面也不空。答辩时这个问题被问到的概率很高,提前准备一下冷启动应对方案,会显得思考深入。
4.3 推荐接口响应太慢,页面一直转圈
第一次完整测试时,推荐接口平均耗时四五秒,体验很差。排查下来有三个原因:ORM 查询数量过多、相似度计算里对每个景点都单独发起 COUNT 查询、每次请求都实时调用大模型。
优化顺序是这样的:先把用户行为查询一次性取到内存,相似度计算中需要的用户数改成批量统计,能省掉 80% 的数据库查询;再把推荐结果缓存 10 分钟,同一用户重复访问直接走缓存;最后把大模型调用放进异步任务队列,页面先用旧推荐结果渲染,模型生成完后再通过刷新或 WebSocket 更新内容。改动之后,推荐接口降到了 500 毫秒以内,大模型部分对主要流程不再产生阻塞。
4.4 答辩与演示时容易被追问的问题
我整理了几个评审老师大概率会问的问题,以及我建议的回答方向:
- 推荐算法为什么选 ItemCF 而不是 UserCF?——旅游场景下用户兴趣变化快,而物品的相似关系相对稳定;用户之间的相似性计算复杂度随用户量增长快,毕设阶段控制复杂度。
- 大模型的角色会不会让推荐结果失控?——大模型只负责文案和意图解析,不负责评分排序,推荐结果仍由协同过滤得分决定,形成了“计算负责真实偏好,生成负责表达”的双层结构。
- 如何评估推荐效果?——因为没有线上效果数据,用离线指标评估。我用了简单的 Top-N 命中率,在预置测试集上计算推荐列表覆盖用户真实偏好的比例,整体约 60% 左右。这个数字不算高,但对毕设场景足以说明方法有效性。
三个问题都有明确回答逻辑,基本不会卡壳。关键是提前把这些写在论文和答辩 PPT 里,不要等老师问了再临时组织语言。
最后说一点个人体会。这套系统的技术点不算新,但组合方式很聪明:Django 负责把工程链路串完整,协同过滤负责守住推荐算法的底线,LLM 则把用户感受从“系统给我推了景点”提升到“系统像一个旅行助手一样理解我的需求”。
我在实际搭这套系统的过程中,最大的收获不是学会了某个框架,而是理解了一个朴素的道理:在一套混合系统里,每种技术只负责它最擅长的事。推荐引擎不擅长内容生成,就别硬写模板;大模型不擅长稳定计算,就别让它决定排序。边界划分清楚之后,系统稳定性和开发效率都会明显提升。如果后续想扩展,这个项目还可以增加多日行程拼接、好友同行路线共享、基于轨迹的相似用户聚类,这些都是顺着既有架构往里加模块的事,不需要推翻重来。
如果你正准备上手做这个方向,我建议你先把推荐链路跑通,再加语言交互,最后补数据分析。按这个顺序推进,每一步都有可演示的成果,做起来不会慌。