每年考研季,咨询我最多的问题不是“怎么复习”,而是“老师,我该报哪个学校”。这确实是个难题:实力强的怕考不上,实力弱的不甘心,而关键信息——历年分数线、报录比、专业课难度、区域认可度,全都散落在各个网站和文件里。后来在带毕业设计时,我接到一个很有代表性的题目:用 Django 搭一个考研院校推荐系统,让 LLM 参与需求理解和择校匹配,再用大数据可视化的方式把分数线、报录比、招生趋势整合成可交互的大屏。今天把整套系统的设计思路、核心模块的落地代码,以及我在实际开发中踩过的坑都写下来,给正在做类似题目的同学一个能直接参考的完整方案。
这个项目适合三类人看:一是正在做“Django + LLM + 推荐系统 + 可视化”方向毕业设计的本科生,二是想入门大模型应用开发、但不想一上来就碰分布式架构的开发者,三是准备做大数据方向作品集、需要一套能演示能讲解的完整系统的同学。
1. 内容整体设计与思路拆解
1.1 考研择校场景里的真实需求是什么
我在设计这个系统之前,先把“考研择校”这件事拆成了用户视角下的几个具体问题。第一是专业匹配:本科专业能报什么方向,跨考可行不可行,这是用户最底层的约束。第二是院校梯度:用户需要知道自己处于“冲、稳、保”哪个区间,这决定了推荐结果的排序逻辑。第三是趋势判断:各院校分数线每年波动很大,光看一年数据没有意义,必须看三到五年的变化曲线。第四是地域偏好:很多人考研后要在当地就业,学校所在城市的产业资源和认可度会影响选择。
这些需求在传统搜索式页面里很难满足。原因在于,用户在表达需求时用的是自然语言,比如“我想去南方,计算机专业强,竞争不要太大”,这种描述很难变成一条 SQL 查询。而 LLM 恰恰擅长把这类模糊的自然语言转化成结构化条件,这也是我在这个项目里坚持引入大模型而不是只做规则筛选的核心原因。
1.2 系统模块划分:数据、推荐、预测、展示四层
整个系统我按功能拆成四个核心模块,每个模块独立开发,最后通过 Django 的 MTV 架构整合到一起。
数据管理模块负责院校信息、专业目录、历年分数线、报录比的录入和清洗。这个模块看起来不起眼,但它决定了后面所有功能的上限。数据不干净,推荐得再准也是错的。
推荐引擎模块是系统的核心。我采用了“规则过滤 + LLM 精排”的混合架构。第一层用确定的规则把范围缩小,比如地域、学科门类、是否 985/211、考试科目是否匹配;第二层把剩余院校连同用户背景一起交给 LLM,让它按“冲刺、稳妥、保底”三档输出排序结果和推荐理由。
分数线预测模块基于历年数据做趋势建模。我用的是时序思想加特征工程,不依赖复杂的深度模型,因为考研分数线样本量太小,每年只有一次数据,复杂模型反而容易过拟合。最终方案是“差分趋势 + 加权回归”,在毕业设计这个体量下足够用了。
可视化大屏模块负责把推荐结果、预测曲线、报录比分布、地图院校分布统一展示。技术上选 ECharts,图表种类灵活,地图资源和社区示例也丰富,适合做课程展示和答辩演示。
1.3 为什么技术栈选 Django 而不是 Flask 或 FastAPI
这个项目我坚持用 Django,有几个实际考量。第一,推荐系统需要管理大量结构化数据,Django 的 ORM 在处理关联查询、动态筛选和管理后台方面效率极高,开发管理后台几乎是零成本。第二,毕业设计通常要求“功能完整、文档齐全”,Django 自带 Admin、认证、表单校验、分页,这些能省下大量时间。第三,后面我会用到 WebSocket 做后台数据主动推送,Django Channels 虽然配置麻烦一点,但和 Django 生态的整体集成度比在 Flask 里硬塞异步要舒服得多。
FastAPI 在异步性能和自动生成 API 文档上确实更现代,但它的生态偏轻量,管理后台、认证、权限这些都要自己搭。如果目标是快速交付一个完整可演示的系统,Django 更适合。
2. 核心细节解析与实操要点
2.1 数据库建模:六张表把业务串起来
我设计数据模型时遵循一个原则:一张表只负责一类业务实体,推荐和预测逻辑通过外键和查询集关联,不搞大宽表。最终落地的核心表是这六张:
- 院校表:存学校名称、所在省份、城市、院校层次(985/211/双一流/普通)、院校类型(综合/理工/师范等)、是否自划线。
- 专业表:存专业代码、专业名称、所属学科门类、考试科目组合。这个表决定了“跨考匹配”的可行性判断。
- 分数线表:核心业务表,关联院校和专业,存历年复试线、录取线、报考人数、录取人数、报录比、推免人数。
- 学生画像表:记录用户的本科院校层次、本科专业、目标专业、意向城市、英语/数学基础评分等。
- 推荐结果表:缓存每次推荐的院校列表、冲刺/稳妥/保底标签、LLM 生成的推荐理由。
- 用户行为表:记录用户对推荐院校的收藏、点击、对比行为,为后续改进推荐算法积累数据。
Django 模型里有一个细节要注意:院校和专业的关联是多对多,因为一所学校开多个专业,同一个专业也多所学校开设。但如果把分数线直接挂在多对多关系上,查询会非常绕。我的做法是让分数线表自己作为中间模型,通过两个 ForeignKey 分别指向院校和专业,再加一个UniqueConstraint保证同一院校同一专业在同一年度只有一条记录。这样既保留了多对多语义,又让分数线查询变成对单一表的简单筛选。
2.2 LLM 接入:不是所有逻辑都该交给大模型
很多同学拿到题目的第一反应是“所有推荐都让大模型生成”,这个思路在实践中一定会出问题。首先是成本问题,一次完整推荐要处理几十所院校的数据,全部塞进 Prompt 会产生大量 Token 消耗。其次是准确性,LLM 对数值型数据(比如历年分数线)的处理能力并不好,它更擅长语义理解和文本生成。
我在项目里对 LLM 的定位是三个具体职责:
需求解析:用户输入“想去江苏浙江这边,计算机相关专业,竞争不要太激烈”,由 LLM 提取出省份倾向、专业方向、竞争容忍度这三个结构化参数。这也是“LLM 的 token 三个点”在实际里的用法——用户的问题是 Query,院校特征数据是 Key,最终生成推荐理由时,模型结合两者输出 Value。
特征补全:有些院校的专业实力在结构化的标签数据里看不出来,比如“这个学校的计算机在业界口碑很好”。我把这类信息预先整理成文本描述,作为应该检索到的 Key 存在数据库里,Prompt 生成推荐理由时让模型引用这些信息,避免它自己编造。
推荐理由生成:这是 LLM 最擅长也最不容易出错的部分。规则过滤和预测模型算出候选集和分数,LLM 只需要对每个候选院校写两到三句推荐理由,并给出“冲刺、稳妥、保底”的判断。所有数值决策由传统算法完成,LLM 只做解释层,这个分工让系统既稳定又省成本。
2.3 推荐策略的实现:两条腿走路才稳
第一层规则过滤我用 Django ORM 做链式筛选,参数来自用户画像和 LLM 解析出的结构化条件。地域过滤就是province__in = target_provinces,层次过滤就是level__in = ['985', '211']或反向排除,专业过滤比较复杂,要先判断用户的本科专业是否能报考目标专业,这里用专业表中的考试科目匹配来解决。
第二层是评分排序。我为每个候选院校计算一个综合指数,加权项包括:近三年复试线均值、分数波动方差、报录比、院校层次系数、城市发展指数。这些因子标准化到 0 到 1 后按权重相加。权重我调了很久,最后定下的经验值是分数线稳定性占 0.3,层次系数占 0.25,报录比占 0.25,城市指数占 0.2。然后按综合分从高到低排序,结合预测模块给出的“录取概率”划分三档:高分而且概率高的是“保底”,中分中概率是“稳妥”,低分高概率之外的定为“冲刺”。
这里有个经验容易踩坑:很多推荐系统只按分数高低推荐,会推一堆用户根本考不上的名校。加了“录取概率”分档之后,推荐结果才真正对用户有参考价值。实现上,这个概率来自下一节说的分数线预测模型,两套算法之间是数据联动的关系。
2.4 分数线预测:小样本场景下怎么做才有说服力
分数线预测难处在于数据太少,一年一个样本点,五六年数据也就五六个点,直接用神经网络没有意义。我的做法是把问题转成“相对趋势预测”而不是“绝对分数预测”:先算目标院校专业近五年的分数线均值,再预测下一年的变化量 Δ。变化量用加权一阶差分来估计,近两年的差分值权重高,更早的权重低,相当于给了时间衰减。
举例来说,某专业近三年复试线分别是 350、358、364,差分值是 8 和 6,取权重 0.6 和 0.4,预测下一年的变化量就是 8×0.6 + 6×0.4 = 7.2,那么预测分数就是 364 + 7.2 ≈ 371。这个模型虽然简单,但在答辩时很容易讲清楚原理,而且能用一个具体的 Excel 表复现整个计算过程。我还在这个基准输出上加了一层修正,把 985/211 院校的报考热度系数乘进去,热门院校的预测增量会略上调,反之略下调。
这里必须强调,预测模块的定位不是保证准确,而是给出一个可解释的参考区间。在系统界面上我特意展示“预测值 ± 5 分的波动带”,这个做法既诚实,也体现了数学建模的严谨性。
3. 实操过程与核心环节实现
3.1 Django 项目初始化和 App 划分
我用的是 Django 4.2 LTS 版本,Python 3.10。创建项目后按业务边界划分了四个 App:schools管理院校和专业数据,recommend负责推荐引擎和 LLM 接入,forecast做分数线预测,dashboard做可视化接口和数据聚合。
这里特别建议读者从第一天就按这个方式拆分,而不是把所有代码堆在一个app里。毕设写到最后最痛苦的就是改需求,App 边界清楚,改推荐逻辑时不会动到可视化代码,调试定位问题也快得多。
创建一个 App 的固定命令是:
python manage.py startapp schools python manage.py startapp recommend python manage.py startapp forecast python manage.py startapp dashboard然后在settings.py的INSTALLED_APPS里依次注册。这步做不做对运行没影响,但后面执行makemigrations时会决定哪些模型会被识别,注册漏掉一个,数据库表就建不出来。
跨 App 访问数据模型时,用from schools.models import School这类导入没问题,但要注意不要形成循环导入。我遇到过的情况是:recommend里导入了schools的模型,schools的一个信号处理器又反向导入了recommend的工具函数,启动时直接报错。解决办法是把公共工具函数抽到项目根目录下的utils.py里,任何 App 都只依赖utils,不跨 App 互相依赖。
3.2 推荐接口的完整实现
推荐接口是系统的核心,我直接展示一个精简后仍然能跑通的逻辑骨架,方便对照自己的代码查漏补缺。
# recommend/services.py from django.db.models import Q from schools.models import Major, SchoolScoreLine def rule_filter(user_profile, parsed_params): qs = Major.objects.filter( school__province__in=parsed_params["provinces"], school__level__in=parsed_params["levels"], ) # 考试科目匹配:本科专业与目标专业公共科目数 >= 2 qs = qs.filter( exam_subjects__overlap=user_profile["undergraduate_subjects"] ) return qs.distinct() def score_calculation(candidates, weights): scored = [] for major in candidates: records = SchoolScoreLine.objects.filter( major=major ).order_by("-year")[:3] avg_line = sum(r.line for r in records) / len(records) volatility = max(r.line for r in records) - min(r.line for r in records) score = ( weights["stability"] * (1 - volatility / 100) + weights["level"] * major.school.level_coeff + weights["competition"] * (1 - records[0].competition_ratio) + weights["city"] * major.school.city_index ) scored.append((score, major, avg_line)) return sorted(scored, key=lambda x: x[0], reverse=True)这个阶段有三个隐藏细节要特别留意。
第一个是查询集的惰性和执行时机。rule_filter返回的qs直到被遍历时才真正查数据库。如果你想在得到候选结果之前就检查数量,必须调用.count()或先list()一下,否则后续又添加了.distinct()之类的操作,SQL 会被组合在一起,行为和你预期的不一样。
第二个是.filter(exam_subjects__overlap=...)只在 PostgreSQL 的 ArrayField 上才支持,SQLite 会报错。我用的是 PostgreSQL 部署,但如果读者本地用的是 SQLite,就得改成先查出来再在 Python 里做集合交集。这个坑很容易踩,建议提前确认自己的数据库类型。
第三个是分数线记录不足三年的问题。有些新增专业只有一年数据,len(records)作为分母会得出很离谱的分数。我在代码里做了一个兜底:记录数少于两年的院校直接降权,并在推荐理由里注明“历史数据较少,仅供参考”。这样既保住了程序的稳定性,也提高了推荐的诚实度。
3.3 LLM 接入的封装细节:超时、重试与降级
这一节可能是整个项目里最容易失控的部分,因为大模型接口的不确定性比传统代码高得多。我在项目里做了一层统一的LLMClient封装,核心逻辑包括超时控制、重试机制和降级策略。
# recommend/llm_client.py import json import time import requests class LLMClient: def __init__(self, api_key, base_url, timeout=30): self.api_key = api_key self.base_url = base_url self.timeout = timeout def chat(self, prompt, system_prompt="", max_retries=3): for attempt in range(max_retries): try: resp = requests.post( f"{self.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.api_key}"}, json={ "model": "qwen-plus", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}, ], "temperature": 0.3, }, timeout=self.timeout, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] except requests.exceptions.Timeout: # 超时重试,但最后一次异常直接抛出 if attempt == max_retries - 1: raise except requests.exceptions.RequestException as e: if attempt == max_retries - 1: # 降级:返回空字符串,由上层走规则推荐 return "" time.sleep(2 ** attempt) return ""这里有几个参数值得说明。temperature我设成 0.3,原因是推荐理由生成需要稳定,太高的随机性会让同一用户每次刷新得到完全不同的推荐结果,这在演示现场是灾难;但设为 0 又会让文本非常机械,选 0.3 算是在一致性和自然度之间的平衡。超时配置 30 秒听起来很长,但实际大模型首 token 返回可能就要 10 到 20 秒,太短的超时在低峰期也会误伤。
降级策略是系统设计里最重要的一环。我在推荐流程里做了判断:如果LLMClient.chat()返回空字符串,说明模型调用失败,系统自动退回到“规则过滤 + 评分排序”的纯传统推荐模式,保证用户在任何情况下都能拿到推荐结果,只是推荐理由那一栏会显示“模型服务暂不可用”。答辩时这个问题一定会被问到,你如果能说清楚降级逻辑,反而是加分项。
3.4 可视化大屏的实现:数据接口先行
可视化部分我在dashboardApp 里提供一组只读数据接口,前端通过 AJAX 拉取 JSON 后交给 ECharts 渲染。大屏我设计了四个区域:
分数线预测趋势图:横轴是年份,纵轴是分数线,每条线代表一个推荐院校的目标专业。我用的是折线图加平滑曲线,在最后一年数据点后延伸出预测区间,看起来直观而且答辩时好讲。
报录比热力地图:按省份展示报考热度。这个图我用 ECharts 的地图加visualMap组件,数据来自分数线表里的报录比字段按省份聚合。
推荐院校对比雷达图:一个页面上同时展示三所推荐院校的五个维度得分(分数线稳定性、层次系数、报录比、城市指数、学科热度)。这个图在做“冲稳保”三档次对比时效果特别好,评委一眼就能看出推荐逻辑。
核心指标数字卡:顶部放几个大数字,显示本年度推荐总人数、平均预测分数线、推荐匹配率、院校覆盖率。数字卡虽然简单,但大屏的整体视觉层次一下子就有了。
ECharts 接口约定的 JSON 结构长这样:
{ "school": "浙江大学", "major": "计算机技术", "years": [2020, 2021, 2022, 2023, 2024], "lines": [355, 363, 358, 371, 366], "prediction": { "next_year": 2025, "value": 371, "lower": 366, "upper": 376 } }前端渲染时,预测区间我加了一个标记点区域,用markArea组件把 366 到 376 的区域标出来,视觉上很清楚。前端代码不复杂,但有一个细节要注意:ECharts 的异步载入场景下,如果接口还没返回就执行setOption,图表会空白。我用fetch拿到数据后再初始化图表,而不是页面加载时立即初始化,可以避免这个常见问题。
3.5 后台主动推送数据:Django Channels 的配置实录
这部分的起因是指导老师在中期检查时提了一个需求:希望后台数据更新后,前端大屏能实时刷新,而不是手动刷新页面。这就要用到 WebSocket。Django 里的解决方案是 Channels,配合 Redis 作为 channel layer。
我踩过的坑集中在三处。第一,Channels 只支持 ASGI 模式,所以项目里要加asgi.py,并且runserver命令要换成daphne -b 0.0.0.0 -p 8000 project.asgi:application,否则 WebSocket 连接根本建立不起来。第二,channel_layer的 Redis 配置必须和视图中使用的 Redis 是同一个,不然消息推送不到客户端。第三,前端监听消息时要处理重连逻辑,因为服务端重启或 Redis 闪断都会导致连接断开。
一个典型的后端推送代码片段:
# dashboard/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class DashboardConsumer(AsyncWebsocketConsumer): async def connect(self): await self.channel_layer.group_add("dashboard", self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard("dashboard", self.channel_name) async def push_update(self, event): await self.send(text_data=json.dumps(event["data"]))这个消费者本身不产生数据,它只负责把频道里收到的消息转发给所有连接的前端页面。真正触发推送的地方在数据更新接口里,更新完数据库后往dashboard组发一条事件。这样大屏上所有相关图表都会收到通知,再去拉最新接口刷新数据。
4. 常见问题与排查技巧实录
4.1 ORM 查询一次比一次慢:N+1 问题
开发前期我查院校列表时,直接在模板里循环major_list,然后每访问一个major.school.name就触发一次新的 SQL 查询。页面加载 200 条记录,后台瞬间多了 200 条查询,慢得离谱。这个问题的标准解法是select_related和prefetch_related,前者用于 ForeignKey 和 OneToOne 关系,后者用于多对多关系和反向查询。
我的查询改成:
majors = Major.objects.select_related("school").prefetch_related("scores")select_related("school")把院校表通过 JOIN 一次拉出来,prefetch_related("scores")则把每个专业关联的分数线批量查出并缓存。这样查询次数从两百多次压到两次左右。
排查这类问题有个实用工具:在settings.py里暂时启用django.db.backends的connection.queries,或者装django-debug-toolbar。我当时是在终端的shell_plus里手动统计 SQL 条数来定位的。答辩时提到自己用select_related解决 N+1 查询,这个点非常加分,比单纯说“系统性能好”有说服力得多。
4.2 LLM 接口超时导致整页卡住
这是我最开始没处理好的地方。第一次跑通全流程时,推荐接口里直接同步调用大模型接口,高峰期一次请求要等 30 多秒才能返回,页面长时间白屏。用户的体验极其糟糕,哪怕有降级逻辑,体验也不够好。
后来我的做法是三步走。第一步,把 LLM 调用从同步改成异步任务,使用 Django 的Celery+ Redis 作为 broker,推荐请求先返回“计算中”的状态,大模型生成完后再通过 WebSocket 推送到前端。第二步,给同一个用户的三小时内推荐结果加 Redis 缓存,重复请求直接读缓存。第三步,设置更短的超时(15 秒),超时后立刻进入降级模式,不再无限等待。
当时我的取舍是:毕业设计场景下“快”比“准”更重要。评委现场演示时,等十几秒出结果已经算缓慢,等三十秒以上基本是一场灾难。宁可先出规则推荐结果,再让 LLM 结果后补刷新,也不要让用户干等。
4.3 分数线预测数值震荡厉害怎么办
初版预测直接对原始分数做线性回归,结果预测值经常出现夸张的跳跃,比如 360 分直接跳到 390 分。原因很简单:原始分数序列里存在异常年份,比如某年专业课特别难,复试线突然降了 20 分,线性回归对这种异常点非常敏感。
我做的修正有两层。第一层是数据平滑,用三年移动平均替代单年值作为建模目标。第二层是约束变化量,预测的 Δ 不能超过历史最大差分的 1.5 倍,这个约束避免了爆炸式预测。这里提醒一下,这类约束条件在论文里一定要写清楚,它属于建模层面的决策,不是一个技术实现细节,答辩时老师很可能就针对这个问。
4.4 Redis 可视化工具和缓存管理的配合
系统里有多个地方用了 Redis:缓存推荐结果、存 WebSocket 连接信息、存短期访问计数。调试这些数据时,命令行redis-cli查看键值非常麻烦,我推荐用Another Redis Desktop Manager这个可视化客户端。连接上之后可以直接浏览键列表,查看某个 key 的剩余过期时间,手动删除脏数据,比敲命令高效得多。
调试过程里最容易出现的问题是缓存键冲突。比如两条不同专业的推荐结果,如果键名只包含用户 ID,那么不同专业会互相覆盖。我的键规范是recommend:{user_id}:{major_code}:{province_hash},一眼就能看出缓存属于哪个用户和什么条件。在可视化工具里排查乱掉的缓存时,这种有结构的键名能让你迅速定位问题来源。
5. 一个必须做的扩展:把系统讲成一个故事
这个项目做到尾声时,我逐渐体会到一件事:毕业设计不只是代码,更是一个需要评委在十分钟内理解你思路的作品。单纯演示功能远远不够,必须把整个系统串成一条线。我的答辩主线是:用户输入自然语言需求 → LLM 解析成结构化条件 → 规则过滤缩小候选集 → 评分排序和预测模型输出三档推荐 → LLM 生成推荐理由 → 可视化大屏呈现完整数据逻辑。这条主线贯穿了市面上最受关注的 Djangdo 后端技术、LLM 应用、推荐算法、数据预测和可视化五块内容,每一块都有实际代码支撑。
最终交付时我建议做成三部分:一份项目源码仓库、一篇包含技术选型论证和算法设计的论文、一个可以现场演示的大屏页面。PPT 的每一页对应系统的一个模块,演示时按模块逐步点击,让评委跟着你的思路走,而不是一上来就甩出一个大屏让他们自己琢磨。
我这个系统里最有价值的经验其实是混合架构:LLM 负责理解和表达,传统算法负责决策和计算。在大模型被热议的当下,很多人容易走极端,要么全用大模型,要么完全不用。实际工程里,让大模型做它擅长的语义和生成,把数值决策交给确定性的算法,系统稳定性、成本和效果才能同时兼顾。这个原则放在考研推荐场景里成立,放在几乎所有业务系统里都成立。