ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flask项目实战:就业信息管理、智能推荐、薪资预测与AI咨询全拆解

Flask项目实战:就业信息管理、智能推荐、薪资预测与AI咨询全拆解 一个Flask项目里同时塞下就业信息管理、智能推荐、薪资预测和大语言模型AI咨询听起来像把四个系统焊在一起但一步步拆开做每个模块反而都不难。这套系统我前前后后改了三个版本从最开始只有岗位CRUD的雏形到最终跑通推荐、预测和AI问答全链路踩了不少坑也攒了不少可以直接复用的思路。这篇文章会把我的拆分方式、核心代码、选型理由和部署阶段最典型的坑都整理出来给正在做同类毕业设计或实际项目的朋友当一份参照。1. 三个重量功能压在一个Flask应用里架构怎么拆分才不崩1.1 为什么选Flask而不是Django做这个系统之前我先把技术栈翻来覆去对比过一轮。Django自带Admin后台和完整的ORM开箱即用但问题是它太重模板、中间件、认证体系一整套规则摆在那里项目一大反而觉得被框架牵着走。SpringBoot更不用说了Java系那套配置和时间成本对一个人开发的项目来说不太划算。Flask正好卡在中间核心极简路由自己写ORM想用SQLAlchemy就用想裸写SQL也没人拦模板系统Jinja2够用而且Flask的扩展生态非常全——Flask-SQLAlchemy、Flask-Login、Flask-Migrate基本都是官方推荐级别稳定的库。最关键的是对于“推荐算法薪资预测LLM咨询”这种算法和Web耦合度偏高的系统Flask的灵活性能让我自定义API接口和数据处理逻辑时不被框架限制。一个实际的体会如果你的项目里有超过三个相对独立的业务模块用Flask时一定要提前做好模块化规划不然后面所有蓝图挤在一个app.py里几千行代码翻起来会非常痛苦。我的做法是采用应用工厂模式加蓝图每个功能域一个包。1.2 蓝图(Blueprint)把推荐、预测、咨询拆成独立模块最初版本我把所有路由都写在app.py里大概跑到第600行的时候光是在文件里找某个接口的入口就得来回滚动十分钟。后来重构时彻底改成了蓝图层级结构大概是这样的project/ ├── run.py ├── config.py ├── app/ │ ├── __init__.py # create_app工厂 │ ├── models/ # 所有数据库模型 │ ├── main/ # 首页、登录、注册等 │ ├── job/ # 岗位信息管理与检索 │ ├── resume/ # 简历模块 │ ├── recommend/ # 智能推荐算法 │ ├── salary/ # 薪资预测 │ ├── chat/ # 大语言模型AI咨询 │ └── utils/ # 通用工具app/__init__.py里用一个工厂函数把所有东西串起来这是Flask项目可维护性的命根子# app/__init__.py from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate db SQLAlchemy() migrate Migrate() def create_app(config_namedefault): app Flask(__name__) app.config.from_object(config[config_name]) db.init_app(app) migrate.init_app(app, db) # 注册蓝图 from app.main import main_bp from app.job import job_bp from app.recommend import recommend_bp from app.salary import salary_bp from app.chat import chat_bp app.register_blueprint(main_bp) app.register_blueprint(job_bp, url_prefix/job) app.register_blueprint(recommend_bp, url_prefix/recommend) app.register_blueprint(salary_bp, url_prefix/salary) app.register_blueprint(chat_bp, url_prefix/chat) return app这样拆分之后每个模块的负责人哪怕只是一个人可以独立修改自己的部分互相不干扰。而且推荐、工资预测、AI咨询这三个模块的依赖天然不同——推荐算法那次我为了调参装了一堆科学计算库薪资预测要用pandas和sklearnAI咨询又要用requests调用外部API。如果全部堆在一个包里环境的噩梦会从第一天开始。2. 数据模型这块我改了三版最后沉淀成四张核心表2.1 用户、岗位、简历、投递记录的字段设计第一版我参考很多现成的管理系统把表拆得很碎用户表、学生表、企业表、管理员表各搞一张结果联查多到怀疑人生。后来重构时决定合并角色用一张users表加role字段区分身份省掉一堆重复的用户名、密码字段。岗位表是最核心的一张表推荐和薪资预测都要靠它。以下是定型后的字段结构# app/models/models.py class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(256), nullableFalse) role db.Column(db.String(16), defaultstudent) # student/company/admin created_at db.Column(db.DateTime, defaultdatetime.utcnow) class Job(db.Model): __tablename__ jobs id db.Column(db.Integer, primary_keyTrue) company_id db.Column(db.Integer, db.ForeignKey(users.id)) title db.Column(db.String(128), nullableFalse) city db.Column(db.String(64)) salary_min db.Column(db.Integer) salary_max db.Column(db.Integer) degree db.Column(db.String(32)) # 本科/硕士/不限 experience db.Column(db.String(32)) # 应届/1-3年/3-5年 tags db.Column(db.String(255)) # 逗号分隔如 Python,Flask,后端开发 industry db.Column(db.String(64)) # 互联网/金融/制造业 company_size db.Column(db.String(32)) description db.Column(db.Text) created_at db.Column(db.DateTime, defaultdatetime.utcnow) is_active db.Column(db.Boolean, defaultTrue) class Resume(db.Model): __tablename__ resumes id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(users.id)) file_path db.Column(db.String(256)) content db.Column(db.Text) # 从附件解析出来的纯文本 skills db.Column(db.String(255)) education db.Column(db.String(32)) major db.Column(db.String(64)) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) class Application(db.Model): __tablename__ applications id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer) job_id db.Column(db.Integer) status db.Column(db.String(16), defaultsubmitted) # submitted/viewed/accepted/rejected created_at db.Column(db.DateTime, defaultdatetime.utcnow)有几个字段设计上的细节想提醒一下岗位 tags 用逗号分隔的字符串虽然看起来有点反范式但在推荐系统做实时召回时非常方便直接LIKE %Python%就能捞数据不必专门建一张中间表。薪资字段我用了min和max两个整数因为最终预测的标签可以取(salary_min salary_max) / 2这样标签构建成本极低。行业和公司规模这两个字段如果一开始不预留后面薪资模型做特征时会抓瞎。2.2 行为日志表是推荐和预测共用的“石油”第三版重构时我加了一张行为日志表这张表刚开始觉得没啥用做到推荐模块时才发现没有它协同过滤和冷启动完全无从下手。行为日志表记录用户在系统里每一次关键操作class BehaviorLog(db.Model): __tablename__ behavior_logs id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, nullableFalse) job_id db.Column(db.Integer, nullableFalse) action db.Column(db.String(16), nullableFalse) # view/favorite/apply score db.Column(db.Float, default1.0) # 权重分 created_at db.Column(db.DateTime, defaultdatetime.utcnow)我会在前端对岗位详情页的浏览量埋点用户点击“收藏”和“投递”按钮时也向后端发一条日志。评分策略很简单浏览记1分收藏记3分投递记5分。这个打分虽然粗暴但实际效果立竿见影——推荐算法不再只是望着空空的数据库发呆。这里有个非常容易犯的错误Flask后端接收前端传参时request.args.get()和request.form.get()拿到的数据全部是字符串。埋点接口如果直接往数据库写score不手动转成float后期算相似度时会踩“字符串和数字混在一起”的雷。我习惯在接口入口先做一层类型转换写个_safe_float()之类的工具函数兜底。3. 智能推荐不是越复杂越好先规则召回再协同过滤精排3.1 冷启动阶段基于标签和规则的召回方案推荐模块最开始最容易犯的毛病是——数据库里根本没有足够的用户行为就急着上协同过滤。我第一版就是这样模型训练出来每个用户推荐列表全是空的因为新用户没有行为记录算不出相似度。后来把流程改成两段式。当用户的行为记录少于一定阈值比如5条时走规则召回根据用户的专业和简历技能去匹配岗位的tags再叠加城市和学历过滤条件。这个方案不需要任何模型一条SQL就能搞定SELECT * FROM jobs WHERE is_active 1 AND city 北京 AND degree IN (本科, 不限) AND (tags LIKE %Python% OR tags LIKE %后端%) ORDER BY created_at DESC LIMIT 20;这段逻辑放在推荐服务里先读取当前用户的专业和技能列表再把技能拼接成LIKE模糊查询条件。优点是响应快、不打架、可解释性极强缺点是推荐结果偏静态缺乏个性化排序。但作为冷启动阶段的兜底它已经足够了。3.2 用户行为丰富后ItemCF的Python实现当用户行为记录超过阈值就切换到基于物品的协同过滤ItemCF。核心思想很简单如果用户投递过岗位A而A和B经常被同一批人同时浏览或投递那B值得推荐给该用户。物品相似度计算公式我用的余弦相似度# 构建物品-用户的倒排表 item_users {} for log in behavior_logs: item_users.setdefault(log.job_id, set()).add(log.user_id) # 计算物品间的共现矩阵和相似度 from collections import defaultdict import math cooccur defaultdict(lambda: defaultdict(int)) item_user_count {item: len(users) for item, users in item_users.items()} for users in item_users.values(): for u in users: # 这里简化为计算每个用户下物品两两共现 pass def calc_item_sim(itemA, itemB): usersA item_users.get(itemA, set()) usersB item_users.get(itemB, set()) if not usersA or not usersB: return 0.0 inter len(usersA usersB) if inter 0: return 0.0 denom math.sqrt(item_user_count[itemA] * item_user_count[itemB]) return inter / denom实际项目里我不会每次请求都实时算全量相似度那样太慢了。我的做法是每天晚上定时任务跑一次把每个岗位的Top-N相似岗位列表写入Redis推荐接口读缓存直接返回。这样线上请求的耗时能控制在100毫秒以内用户体验完全不受算法计算影响。3.3 推荐结果的兜底策略与缓存即便是ItemCF成熟之后也要保留一层兜底逻辑。新注册用户、没有简历、没有任何行为的“三无用户”在系统里太常见了。我的兜底策略分三层行为日志足够 → 走ItemCF个性化列表行为日志不够但有简历信息 → 走规则标签召回啥都没有 → 推全站最新的热门岗位按浏览量倒序。这三层逻辑写成一个链式调用函数任何一层有结果就返回不继续往下走。此外所有推荐结果我都会缓存15分钟防止用户刷几次页面就重复计算一遍。4. 薪资预测模型特征工程做完模型随便选4.1 数据清洗与特征构造薪资预测这一块我前后试了很多方案最终发现决定预测效果的不是模型有多复杂而是特征能不能准确反映岗位差异。我用的是历史岗位信息加外部公开数据模拟出来的训练集标签就是(salary_min salary_max) / 2单位是K/月。特征工程里最重要的几个维度城市一线城市薪资普遍高10%-30%直接用label encoding会强加顺序关系我改用平均薪资统计值替换学历要求本、硕、博之间有明显的阶梯关系可以映射成有序数值经验年限解析“应届/1-3年/3-5年”这类字符串取其上限值作为数值特征行业映射为目标行业的平均薪资偏移量公司规模规模越大薪资往往越高但差距不那么线性技能标签判断是否包含Python、Java、算法、Java后端等高薪关键词作为0/1特征。import pandas as pd from sklearn.preprocessing import LabelEncoder def build_features(df): df df.copy() df[avg_salary] (df[salary_min] df[salary_max]) / 2 df[exp_year] df[experience].map({应届: 0, 1-3年: 2, 3-5年: 4, 5年以上: 6}) df[degree_rank] df[degree].map({不限: 0, 大专: 1, 本科: 2, 硕士: 3, 博士: 4}) # 城市编码用训练集中该城市平均薪资替代 city_salary_mean df.groupby(city)[avg_salary].transform(mean) df[city_salary_avg] city_salary_mean df[has_algorithm] df[tags].str.contains(算法|机器学习|深度学习, naFalse).astype(int) df[has_java] df[tags].str.contains(Java|后端, naFalse).astype(int) return df这个过程中我踩过一个典型的坑直接用LabelEncoder给城市编号模型会把城市当有序变量用比如编码0的城市被当成比编码1的城市“等级低”导致预测结果完全偏离实际。切到用目标编码Target Encoding之后误差立刻降了一截。4.2 回归模型对比与调参经验模型选择上我对比了三种线性回归、随机森林、GradientBoosting。结果在我的数据集上很明显模型MAER²训练耗时LinearRegression2.84K0.61快RandomForestRegressor2.21K0.71中GradientBoostingRegressor1.97K0.76较慢实际项目中我选的是GradientBoosting性能最好。调参时重点关注n_estimators和max_depthn_estimators从50调到150收益最大超过200后模型变化很小但耗时线性上升。max_depth我控制在4-6之间防止过拟合。另外需要做交叉验证而不是随便切一个训练集就定结论。from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import cross_val_score model GradientBoostingRegressor( n_estimators150, max_depth5, learning_rate0.08, random_state42 ) scores cross_val_score(model, X_train, y_train, cv5, scoringneg_mean_absolute_error) print(平均MAE:, -scores.mean())模型训练好之后我用joblib.dump(model, salary_model.pkl)保存到项目目录下。注意pkl文件不要丢在static目录里否则用户能直接下载你的模型文件。4.3 把模型封装成Flask可调的服务预测接口设计得尽量简单前端传岗位信息字段后端返回预测薪资和区间。这里有两个细节要注意模型是全局加载的不能放在请求函数里每次load否则并发时磁盘I/O会拖垮性能前端传进的特征要和训练时保持一致漏一个字段模型就会报错或者预测出离谱的结果。# app/salary/views.py from flask import Blueprint, request, jsonify import joblib import pandas as pd salary_bp Blueprint(salary, __name__) model joblib.load(app/salary/salary_model.pkl) salary_bp.route(/predict, methods[POST]) def predict_salary(): data request.get_json() df pd.DataFrame([{ city: data[city], degree: data[degree], experience: data[experience], industry: data[industry], company_size: data[company_size], tags: data[tags], }]) df build_features(df) pred model.predict(df)[0] return jsonify({ predicted_salary: round(float(pred), 2), range: [round(float(pred) - 2, 2), round(float(pred) 2, 2)] })周边薪资区间我用 ±2K来展示这是在验证集上大概90%预测误差都在2K以内之后定的经验值比直接给死一个固定区间要可信得多。5. 大语言模型AI咨询功能接口对接、流式输出与上下文管理5.1 国产大模型API的选型与鉴权方式AI咨询模块我最初考虑的是本地部署开源模型后来算了一下服务器内存占用和推理速度果断放弃决定用国内大模型平台的开放API。国产几家大厂的API在简历优化、面试问题回答、就业咨询这些场景上完全够用而且免费额度和调用文档都很清楚。以实际接入的一个模型平台为例核心逻辑就是HTTP调用import requests def call_llm(messages, api_key, streamTrue): headers { Authorization: fBearer {api_key}, Content-Type: application/json } payload { model: glm-4-flash, messages: messages, stream: stream } resp requests.post( https://open.bigmodel.cn/api/paas/v4/chat/completions, headersheaders, jsonpayload, streamstream ) return resp鉴权方式现在基本都是API Key放在请求头里没有太复杂的签名逻辑。Key一定要放在服务端配置或环境变量里不能写在前端代码中否则任何人抓一下接口都能看到你的Key白嫖你的额度。5.2 Flask后端怎么做流式输出AI回答如果等全部生成完再一次性返回用户要等好几秒甚至十几秒体验非常差。所以这里要上流式输出SSE。前端的fetch接口用ReadableStream逐步读取返回的文本块实现“打字机效果”。Flask后端对应的写法# app/chat/views.py from flask import Blueprint, request, Response, stream_with_context chat_bp Blueprint(chat, __name__) chat_bp.route(/stream, methods[POST]) def chat_stream(): user_msg request.get_json().get(message) messages build_messages(user_msg) # 从session或redis取历史 def generate(): resp call_llm(messages, api_key, streamTrue) yield data: start\n\n for line in resp.iter_lines(decode_unicodeTrue): if line: try: data json.loads(line.split(data:, 1)[-1]) delta data[choices][0][delta].get(content, ) if delta: yield fdata: {json.dumps({text: delta})}\n\n except Exception: continue yield data: [DONE]\n\n return Response(stream_with_context(generate()), mimetypetext/event-stream)这里有个很隐蔽的问题开发时用Flask自带的dev server跑流式没问题但用生产部署后如果直接flask run脚本反向代理的缓冲配置不对会把SSE内容攒到最后一起返回前端打字机效果直接碎掉。我的解决方式是部署层关掉缓冲或者在返回头里加X-Accel-Buffering: no。5.3 上下文长度、Token费用与安全过滤LLM咨询模块最核心的问题不是怎么调API而是怎么管理上下文。每个独立问题直接调用大模型效果很差AI会“失忆”。我的做法是维护一个对话历史列表每次把最近的6轮对话一起发给模型# redis里存每个user_id的messages def build_messages(user_id, new_msg): history redis.get(fchat_history:{user_id}) or [] history.append({role: user, content: new_msg}) # 超出轮数就丢掉最早的 if len(history) 12: history history[-12:] return history轮数太多会导致Token超限费用也高太少则AI记不住前文。6轮左右是平衡点大概覆盖1000-1500个Token既够用又不会超长。安全过滤我做了三层后端对用户输入做关键词过滤命中明显风险词直接拒绝回答在System Prompt里明确AI的角色是“就业咨询助手”不该回答的问题要拒绝对AI输出也做一次渲染转义防止模型输出意外内容污染页面。把这三层做完再在页面上测试各种刁钻问题基本能把风险控住。6. 部署上线踩过的坑附件绝对路径、静态文件和Windows服务器6.1 附件路径错误的根因相对路径在服务模式下失效这应该是Flask项目部署时最常见的问题之一。本地开发时一切正常简历上传后也能正常访问一旦部署到服务器上或者用waitress这类生产级服务器启动附件全部404。我排查了很久最后的根因非常基础代码里用了相对路径。# 错误的写法 UPLOAD_FOLDER uploads问题在于flask run启动时当前工作目录是项目根目录所以uploads/能精确定位到正确位置。但用生产服务器启动、或者用系统服务方式加载时当前工作目录可能变成systemd默认目录或者C:\Windows\System32uploads/就指向了一个完全不存在的地方。上传的文件不知道被写到哪里去了自然没法读取。正确做法是永远基于__file__构建绝对路径import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) UPLOAD_FOLDER os.path.join(BASE_DIR, .., uploads) app.config[UPLOAD_FOLDER] os.path.abspath(UPLOAD_FOLDER)这段代码看起来不起眼但它是整个部署流程里绝大多数“本地OK线上404”问题的根源。不光是上传目录模型文件pkl、SQLite数据库文件、模板扩展文件全部要用绝对路径定位。Windows下还要注意路径分隔符别再自己写死/或\用os.path.join自动适配。6.2 静态文件404和路由陷阱附件能访问了紧接着又会出现新的404。这次是Jinja2模板里引用的CSS、JS文件全部加载不出来。排查后有两个原因第一个是url_for(static, filenamestyle.css)没有按规范写直接写死了static/style.css但项目的static目录实际在蓝图的子包下面路径对不上。第二个是自定义路由和静态文件冲突——我把某个接口配成了/static/path:filename结果直接覆盖了Flask默认的静态文件路由。解决办法静态文件统一用url_for生成自定义路由尽量避免占用/static、/uploads这类保留前缀。如果你一定要把上传文件的访问接口暴露出去建议单独起一个蓝图main_bp.route(/uploads/path:filename) def uploaded_file(filename): return send_from_directory(app.config[UPLOAD_FOLDER], filename)6.3 部署到Windows服务器时的进程管理这个项目最终部署在Windows Server上有几个和Linux完全不同的坑。首先不能再用flask run裸跑因为flask自带的开发服务器并发能力太差而且会暴露Werkzeug调试器存在安全隐患。我换成了waitress一个纯Python实现的WSGI服务器安装和启动都简单pip install waitress waitress-serve --listen0.0.0.0:5000 app:create_app()但这样还不够因为窗口一关进程就没了。Windows上我用了NSSM注册成系统服务让应用开机自启、异常自动拉起。NSSM的核心配置就是指定python解释器和上面的waitress-serve命令再把日志输出重定向到文件。部署完成后一定要验证两个东西一是上传一个真实文件确认附件能上传能访问二是用命令行的curl请求一次LLM流式接口确认SSE输出不是一次性返回。这两个验证过了基本可以说这系统在服务器上跑稳了。最后再分享一点实操体会整个项目做下来最花时间的不是算法本身而是Flask把算法、模型、外部API还有文件系统这些东西黏合在一起时的各种边界情况。别急着一次性把代码写完每接一个模块就跑通一个冒烟用例再往下走。这套流程虽然慢一点但真的能帮你避开“所有模块都对不上”这种最糟的局面。
返回列表