知识图谱与图神经网络驱动电影推荐系统实战
简介:面向毕业设计及课程实践,提供一套结合知识图谱与图神经网络(KGCN)的电影推荐系统Python完整源码。系统涵盖数据处理、知识图谱构建、模型训练、Web端推荐展示等环节,并附带部署说明,适合需要参考完整工程实现或快速上手图神经网络项目的学习者。压缩包共37个文件,以21个Python脚本为主,涵盖数据加载、模型、工具函数等;同时包含5个dat数据集文件、5个zbak备份文件以及readme/md说明文档,整体体积14.88MB。当前已有49人学习下载。代码按数据层、模型层、应用层组织,注释较详细,附有多个辅助脚本与备份文件,可帮助理解从kg_loader、data_loader到KGCN layer与model的完整流程,也便于在本地复现电影推荐效果并扩展实验。
1. 当传统推荐算法解释不了「你为什么会喜欢这部电影」
电影推荐的冷启动和可解释性,一直是协同过滤的硬伤:用户历史行为稀疏时,相似度矩阵几乎失效;就算推荐命中,系统也说不清到底是哪个特征打动了用户。把知识图谱和图神经网络一起搬进来,相当于给推荐系统配了一张「行业关系网」和一个能在这张网上自动做信息聚合的「特征提取器」:电影、演员、导演、类型、用户历史行为被建成了三元组图结构,GNN 沿着关系路径把邻居信息一层层聚合到目标节点上,最终输出的向量既保留了内容语义,又能融合用户偏好。这篇文章面向的读者是:手里有一定 Python 功底、想从单机 demo 走向可部署工程的开发者,或者正在做毕设/项目申报、需要一个能讲清原理又能交出完整源码包的团队。全文按「图怎么建 → 模型怎么选 → 系统怎么拼 → 部署怎么稳」的顺序展开,每一步都给出参数和踩坑记录。
2. 知识图谱构建:从 MovieLens 原始表到三元组与邻接矩阵
2.1 为什么电影推荐场景最适合用知识图谱
传统 ItemCF 算的是「买了 A 的人也买了 B」,但电影推荐的核心逻辑是「因为演员、导演、类型、主题曲这些实体关系,所以这部电影与另一部相关」。知识图谱把这种关系显式编码为三元组(头实体, 关系, 尾实体),例如(《盗梦空间》, 主演, 莱昂纳多)、(莱昂纳多, 参演, 《荒野猎人》)。推荐系统拿到的不再是扁平的评分矩阵,而是一张可以多跳查询的关系网络。
好处有两个层面:第一,冷启动用户只要有 3~5 条历史行为,就能通过「用户—电影—演员—其他电影」的二跳关系找到候选集;第二,可解释性好,图谱路径本身就是推荐理由,这在做技术答辩或项目汇报时是天然加分项。常见做法是用 MovieLens 1M 数据集,加上 IMDb 的附加演员/导演表来补全实体关系——这样不用自己爬数据,也绕开了版权风险。
2.2 原始表到三元组:实体映射与关系去重
MovieLens 1M 解压后有三个核心文件:movies.dat(电影ID、标题、类型)、ratings.dat(用户ID、电影ID、评分、时间戳)、users.dat(用户画像)。但movies.dat里没有演员和导演,需要预先准备一份 IMDb 或 TheMovieDB 的补充数据,常见做法是把movies.dat里的电影标题和年份作为连接键去关联外部 API,或者直接用研究圈常用的kg.csv数据集(包含约 5 万部电影和 10 万+实体关系)。
下面是构建三元组的核心脚本骨架:
# build_kg.py import pandas as pd from collections import defaultdict # 1. 加载电影类型字段,拆分成 (movie_id, 关系, 实体) 三元组 movies = pd.read_csv('ml-1m/movies.dat', header=None, sep='::', names=['mid', 'title', 'genres'], engine='python') # 类型字段用 | 分隔,一部电影可能有多个类型 genre_triples = [] for row in movies.itertuples(): for genre in row.genres.split('|'): genre_triples.append((row.mid, 'belongs_to', genre)) # 2. 加载外部演员/导演表(假设列名固定) casts = pd.read_csv('kg_casts.csv', sep=',') # 含 mid, actor_name, director_name cast_triples = [] for row in casts.itertuples(): cast_triples.append((row.mid, 'stars_in', row.actor_name)) cast_triples.append((row.mid, 'directed_by', row.director_name)) # 3. 合并并去重,统计实体/关系数 all_triples = list(set(genre_triples + cast_triples)) entity_set = set() for h, r, t in all_triples: entity_set.add(h) entity_set.add(t) print(f'三元组总量: {len(all_triples)}') print(f'实体总量: {len(entity_set)}') # 导出三元组文件,模型读取用 with open('kg_final.txt', 'w') as f: for h, r, t in all_triples: f.write(f'{h}\t{r}\t{t}\n')逻辑说明:先把电影的类型字段转成belongs_to关系,这是成本最低的三元组来源;再从补充表里读演员和导演,分别映射成stars_in与directed_by。最后用集合去重,避免重复三元组导致后续邻接矩阵出现重边。
参数说明:ratings.dat的最低评分阈值一般定在 4 分以上,只把「真正喜欢」的行为纳入训练;users.dat里的职业、年龄字段如果暂时不用,就别塞进图谱——实体太多会让邻接矩阵膨胀,训练速度大幅下降。
2.3 实体与关系编号映射,以及邻接矩阵的稀疏存储
图神经网络不能直接吃字符串实体名,必须先给每个实体分配整数 ID、每种关系分配整数 ID,再构建统一的 ID 映射表。这一步疏忽很容易在后续模型加载时报KeyError,因为训练集和测试集的映射字典不一致。
# build_mapping.py import numpy as np from scipy.sparse import coo_matrix entity2id = {} id2entity = {} relation2id = {} id2relation = {} # 读三元组文件,生成映射 triples = [] with open('kg_final.txt', 'r') as f: for line in f: h, r, t = line.strip().split('\t') triples.append((h, r, t)) if h not in entity2id: entity2id[h] = len(entity2id) id2entity[entity2id[h]] = h # 尾实体和关系同样的逻辑省略 # 构建稀疏邻接矩阵 rows, cols, vals = [], [], [] for h, r, t in triples: h_id = entity2id[h] t_id = entity2id[t] # 这里做一步关键处理:把正向边和反向边都加进去 rows.append(h_id); cols.append(t_id); vals.append(1) rows.append(t_id); cols.append(h_id); vals.append(1) adj = coo_matrix((vals, (rows, cols)), shape=(len(entity2id), len(entity2id))) # 转成 CSR 格式,训练时按行切片取邻居 adj_csr = adj.tocsr() np.savez_compressed('kg_adj.npz', data=adj_csr.data, indices=adj_csr.indices, indptr=adj_csr.indptr, shape=adj_csr.shape)逻辑说明:coo_matrix适合构建邻接矩阵,因为三元组天然是坐标格式;转csr_matrix后,取某个实体的一阶邻居就变成adj_csr.indices[adj_csr.indptr[i]:adj_csr.indptr[i+1]],这比密集矩阵存储省掉几个数量级的内存。
参数说明:对于 MovieLens 1M 规模(约 1 万部电影、几千演员),邻接矩阵如果做成稠密的NxN浮点矩阵,内存轻松超过 800MB;转稀疏 CSR 后通常只有 10~20MB。如果你的环境内存吃紧,原因 80% 出在没用稀疏存储。
2.4 用户行为子图的构建逻辑
知识图谱管的是「内容与内容的关系」,用户行为是另一张子图:(user_id, 评分过, movie_id)。需要把行为子图和内容子图拼成一张图,这样 GNN 在更新某个电影的节点向量时,才能同时看到「它的导演是谁」和「哪些用户喜欢它」。
计算过程简单说,就是把用户节点也塞进实体表,用户 ID 偏移到实体数之后,再加一条rate关系。最终 GNN 输入由三部分组成:实体向量表(随机初始化或预训练)、邻接矩阵(负责邻居聚合)、用户-物品交互矩阵(负责监督信号)。这三部分在后面的模型代码里会一一对应。一个小建议:行为子图直接按评分 >=4 建立正边,评分 <4 的关系不要进图,否则模型会把「不喜欢」和「没看过」混为一谈,推荐准确率掉得很快。
3. 图神经网络选型:从 GCN 到 KGCN,为什么不能盲目堆层数
3.1 GCN 原理回顾与在推荐任务上的局限
图卷积网络的核心操作是「邻居聚合 + 特征变换」:每个节点的表示,等于自身特征和邻居特征的加权求和,再经过一个可学习的线性变换。单层 GCN 只能看到一阶邻居;两层 GCN 能看到二跳关系,比如用户 → 电影 → 演员 → 演员参演的其他电影。
标准 GCN 在推荐上有两个局限:第一,它的边权重是简单的 0/1 或度归一化,没有区分关系的语义类型——「导演」和「主演」都被当成同一种「相邻」,这不符合电影推荐的直觉;第二,GCN 不分关系类型、不分邻居重要程度,导致高度活跃实体(比如参演过几百部电影的老戏骨)会稀释掉聚合特征。解决前者用 R-GCN 或 KGCN,解决后者用注意力机制。
3.2 为什么推荐更常用 KGCN:关系感知的邻域聚合
KGCN 是为「知识图谱推荐」设计的模型,它给每种关系分配一个独立的线性变换矩阵,并在聚合时引入注意力权重:注意力权重 = softmax(邻居节点向量 v_i 与目标节点向量 u 的内积)。通俗讲,就是模型自己去学「哪些邻居对当前用户更重要」——用户喜欢科幻片,那么《星际穿越》的「类型 = 科幻」这条邻居的注意力权重会被拉高;用户是诺兰粉,那「导演 = 诺兰」这条边的权重也会上涨。
注意力公式可以写成:score = σ(u^T · W_r · v_i),其中W_r是关系 r 的变换矩阵,σ是激活函数。这个设计比普通 GCN 多做了一步「用户感知」——同一个邻居节点,在给不同用户做推荐时,聚合权重是不同的。这是 KGCN 能超过 GCN 基线的主要来源。
3.3 从零实现 KGCN 聚合层与用户评分预测
KGCN 的代码实现并不复杂,核心是采样邻居 + 加权聚合 + 输出预测分数。以下给出聚合层和预测层的核心实现:
# kgcn_model.py import torch import torch.nn as nn import torch.nn.functional as F class KGCNLayer(nn.Module): def __init__(self, in_dim, out_dim, n_relations): super().__init__() # 每种关系一个变换矩阵 self.relation_w = nn.Parameter( torch.randn(n_relations, in_dim, out_dim) * 0.1 ) self.self_transform = nn.Linear(in_dim, out_dim) def forward(self, entity_emb, neighbor_emb, relation_ids): # entity_emb: 中心节点向量 [batch, in_dim] # neighbor_emb: 采样邻居向量 [batch, n_neighbor, in_dim] # relation_ids: 每条边对应的关系ID [batch, n_neighbor] batch_size, n_neighbor, _ = neighbor_emb.shape # 根据关系ID取出对应的变换矩阵 rel_w = self.relation_w[relation_ids] # [batch, n_neighbor, in_dim, out_dim] # 邻居向量经过关系变换: [batch, n_neighbor, out_dim] transformed = torch.einsum('bni,bnij->bnj', neighbor_emb, rel_w) # 注意力分数:用中心节点与变换后邻居向量做内积 attn = torch.sum(entity_emb.unsqueeze(1) * transformed, dim=-1) attn = torch.softmax(attn, dim=-1) # [batch, n_neighbor] # 加权聚合邻居 agg = torch.sum(attn.unsqueeze(-1) * transformed, dim=1) # 残差连接回自身 out = F.relu(self.self_transform(entity_emb) + agg) return out class KGCN(nn.Module): def __init__(self, n_entities, n_relations, embed_dim, n_layers): super().__init__() self.entity_emb = nn.Embedding(n_entities, embed_dim) self.layers = nn.ModuleList([ KGCNLayer(embed_dim, embed_dim, n_relations) for _ in range(n_layers) ]) def forward(self, user_emb, movie_emb, neighbor_embs, relation_ids): # 逐层更新电影节点表示 x = movie_emb for layer, neigh, rel in zip(self.layers, neighbor_embs, relation_ids): x = layer(x, neigh, rel) # 预测分数:用户向量与更新后电影向量内积 score = torch.sum(user_emb * x, dim=-1) return torch.sigmoid(score)逻辑说明:neighbor_embs是在每个训练步从邻接矩阵里实时采样的固定数量邻居向量,采样数量是超参数n_neighbor,常见设为 8~16。torch.einsum在这里做的是一次小批量内所有邻居的关系变换,比循环快很多。注意力内积把中心节点和邻居变换后的向量做相似度打分,softmax 后再加权求和。残差连接让模型更深时也不容易梯度消失。
参数说明:嵌入维度embed_dim推荐 32~64 之间,维度太高在 1M 数据下容易过拟合;n_layers不要超过 3,最好直接设为 2。原因在于电影图谱的平均最短路径很短——「诺兰 → 蝙蝠侠 → 科幻类型 → 其他科幻片」只需要两跳就能覆盖大量候选电影。层数再多,所有节点向量趋于同质化,推荐精度不升反降,这是图神经网络里常见的「过平滑」现象。
3.4 训练策略:BPR 损失与负采样
KGCN 的监督信号来自用户的历史行为,但评分是一个隐式反馈问题。把「看过并打了 4 分以上」视为正样本,用贝叶斯个性化排序 BPR 损失来拉大正样本和负样本的得分差:
# train.py 核心训练循环片段 def bpr_loss(pos_scores, neg_scores): # 目标是让正样本得分明显高于负样本 return -torch.log(torch.sigmoid(pos_scores - neg_scores)).mean() for epoch in range(n_epochs): for batch in train_loader: # 每个正样本配 5 个随机负样本 pos_scores = model(user_emb, pos_movie_emb, pos_neigh, pos_rel) neg_scores = model(user_emb, neg_movie_emb, neg_neigh, neg_rel) loss = bpr_loss(pos_scores, neg_scores) optimizer.zero_grad() loss.backward() optimizer.step()逻辑说明:BPR 不要求预测分数接近真实评分,只要求正样本大于负样本。这比 MSE 回归更合理,因为用户打 4 分还是 5 分的主观差异很大,模型没必要纠结这个数字。每个正样本配 5 个负样本是常用的策略:太少则负样本区分度不够,太多则训练时间呈线性上升,收益递减。
参数说明:学习率建议从0.001起步,使用 Adam 优化器,权重衰减设1e-5。batch size 在 1024 到 4096 之间——KGCN 的邻居聚合操作对显存不敏感,batch 大一点反而能让 GPU 的矩阵运算更饱满。关键超参数还有一个l2_reg,如果发现训练集 loss 持续下降而验证集 Recall@5 不涨,先把正则系数往上调。
4. 把模型组装成系统:Flask 服务、推荐接口与前端联动
4.1 项目目录结构与源码模块划分
拿到完整源码包后,先别急着跑,把目录结构看一眼。一个能交付的系统,至少应该是 Datasets / Models / Services / Controllers / Static 五个模块分离的,而不是一个main.py里堆一千行。
movie_kg_recsys/ ├── datasets/ │ ├── ml-1m/ # 原始 MovieLens 数据 │ └── preprocess.py # 数据清洗与三元组构建脚本 ├── models/ │ ├── kgcn.py # 图神经网络模型定义 │ ├── train.py # 训练与模型保存 │ └── recommend.py # 加载模型、生成召回结果 ├── services/ │ ├── graph_storage.py # 用 NetworkX + SQLite 管理图谱 │ └── recommender.py # 推荐服务封装 ├── controllers/ │ └── api.py # Flask 蓝本,RESTful 接口 ├── static/ │ ├── index.html # 页面入口 │ └── app.js # 前端请求与渲染逻辑 ├── weights/ │ └── kgcn_epoch20.pth # 训练好的模型权重 └── requirements.txt这个结构的好处是:训练代码和部署代码隔离,模型换版本时只需要替换weights/目录下的权重文件,API 层不用动。requirements.txt里锁定torch<=2.0、flask==2.3、pandas==1.5、networkx==3.1这些版本,避免部署时因 numpy 版本冲突报np.bool不存在这类低级错误。
4.2 提供 RESTful 推荐接口:接收用户 ID,返回电影列表
模型训练完成后,推荐服务的工作分两步:先从训练好的 KGCN 里取用户 Embedding 和电影 Embedding,然后对全量电影做打分排序。打分可以在内存里用矩阵乘法一次算完,不需要每次请求都重新跑 GNN。
# services/recommender.py import torch from models.kgcn import KGCN class MovieRecommender: def __init__(self, model_path, entity_emb_path, movie_id_map): self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') self.model = KGCN( n_entities=100000, n_relations=8, embed_dim=64, n_layers=2 ) self.model.load_state_dict(torch.load(model_path, map_location=self.device)) self.model.eval() # 预先抽好所有电影向量,避免重复计算 self.user_emb = torch.load(entity_emb_path, map_location=self.device) self.movie_id_map = movie_id_map # {imdb_id: 内部实体ID} def recommend(self, user_id, top_k=20): # 从 user_emb 里取该用户向量 user_vec = self.user_emb[user_id].unsqueeze(0) # [1, dim] # 所有电影向量与用户向量的内积 = 推荐分 scores = torch.mm(user_vec, self.model.entity_emb.weight.T) scores = scores.squeeze(0) # 过滤掉用户已经看过的电影(可选,但强烈建议) top_k_ids = torch.topk(scores, top_k).indices.tolist() return [self.movie_id_map[i] for i in top_k_ids]逻辑说明:推荐阶段的关键优化是把模型切到eval()模式,并且只保留最终的实体嵌入矩阵参与矩阵乘法。torch.mm一次能算出用户对所有电影的打分,这在 1 万部电影规模的本地机器上只要几十毫秒。如果用户数量级很大,可以提前把用户向量矩阵整体保存,请求时做user_emb[user_id]索引,内存和速度都会好。
参数说明:top_k在线上环境设 20~50 即可;如果接入前端页面轮播图,一次返回 20 部足够。过滤已看电影这个逻辑最好写在推荐函数内部而不是前端过滤,否则前端拿到列表可能不够 20 个,页面就会缺位。
4.3 前端页面联动:搜索框、电影卡片与「推荐理由」展示
前端页面用原生 HTML + jQuery 是最省事的方案,不需要构建工具链。第一步把训练好的模型权重加载到 Flask 服务里,第二步在静态文件里用fetch调用推荐接口,第三步把电影信息和推荐路径渲染到卡片上。
推荐理由的展示是整套系统的差异点:后端在图谱里保存了路径信息,比如用户A → 喜欢《盗梦空间》 → 导演诺兰 → 执导《星际穿越》。推荐时把这条路径作为文本放在电影卡片的下方,用户能一眼看懂推荐逻辑。这一步的实现方式是:召回阶段不仅保留电影 ID,还同时从graph_storage里查询「电影 → 关系 → 电影」的连接路径,把路径的最后一个实体转成自然语言字符串。
前端调用接口的代码简版如下:
// static/app.js async function loadRecommendations(userId, topK) { const resp = await fetch(`/api/recommend?user_id=${userId}&top_k=${topK}`); const data = await resp.json(); const container = document.getElementById('movie-grid'); container.innerHTML = ''; data.movies.forEach(item => { const card = document.createElement('div'); card.className = 'movie-card'; card.innerHTML = ` <h3>${item.title}</h3> <p>${item.genres}</p> <p class="reason">推荐理由: ${item.reason_path}</p> <span class="score">${item.pred_score.toFixed(2)}</span> `; container.appendChild(card); }); }逻辑说明:推荐理由路径item.reason_path是推荐系统可解释性的直接体现,也方便在答辩或演示时快速说明模型内部行为。前端fetch 到 JSON后直接渲染卡片,不需要刷新页面。用 jQuery 也行,但原生 fetch 能少引一个依赖。
5. 实战避坑清单:图数据、训练和部署中最常见的 5 个翻车点
5.1 现象:训练到第 3 个 epoch loss 变成 NaN
通常原因有两个方向。第一,邻接矩阵里有自环或者重复边,导致归一化权重分母出现零;第二,学习率太大,关系变换矩阵relation_w初始化不当导致梯度爆炸。解决方法是:构建矩阵时显式去掉对角线元素;relation_w初始化标准差从 0.1 调到 0.01;学习率降到5e-4再试。如果 loss 仍然不稳定,检查是否有实体 ID 映射越界——训练集和验证集用了两套映射字典时,越界会让 Embedding 查到随机初始化的向量,表现就是 loss 异常跳动。
5.2 现象:MemoryError,Python 进程直接被杀掉
MovieLens 1M 本身很小,但如果你导入ratings.dat时用了sep='::'却忘记指定engine='python',pandas 会在 C 引擎下把整个文件当一列读入,内存直接爆掉。另一个常见元凶是:构建稠密邻接矩阵时用了np.zeros((n_entities, n_entities)),20000 实体 × 20000 × 8 字节就是 3.2GB。解决方法是全部改用scipy.sparse并通过train_test_split把数据切小,对测试环境只载入训练子图。
5.3 现象:模型训练完,推荐结果全是热门电影
这是隐式反馈训练中典型的流行度偏差。原因在于:越流行的电影在训练集里被点击的次数越多,BPR loss 在优化时会把「模型倾向」往高频样本上带。解决方式分两步:负采样时按流行度降采样(热门电影作为负样本的概率调低);评估指标不用整体的 Recall,而是看Recall@5在长尾电影切片上的表现。如果长尾召回率低于整体均值 30% 以上,说明模型没有学到内容语义,只是在背热度。
5.4 现象:Flask 接口响应慢,每次请求都要等 3 秒
最常见的原因是把「加载模型参数 + 构建用户向量」写进了每个请求的处理函数里。torch.load一次就有几百毫秒,加上从磁盘读 Embedding,攒到几秒很正常。解决方法是:Flask 应用启动时用全局变量缓存模型对象,只在__init__阶段载入一次;接口内部只做向量索引和矩阵乘法。更稳妥的做法是用gunicorn跑多 worker 时,把模型放到共享内存或者每个 worker 启动时加载并常驻。
5.5 现象:换一台机器跑不起来,torch和numpy版本冲突
把本地环境的requirements.txt直接pip install -r在干净服务器上往往报各种 C++ 扩展错误。原因多数是本地装了 GPU 版 torch,服务端没有 NVIDIA 驱动,pip 解析到不匹配的 CUDA 版本。解决办法是:部署机上先执行python -c "import torch; print(torch.__version__)"确认能正常导入;然后按部署机是否支持 CUDA 来决定安装 CPU 版还是 GPU 版,模型代码里已经做了torch.device('cuda' if torch.cuda.is_available() else 'cpu'),逻辑会自动降级。
6. 部署到 Ubuntu 服务器:用 Gunicorn 托管 Flask,并用 Docker 固定环境
6.1 为什么选 Ubuntu 作为部署目标
标题里提到的部署指南,最常见的目标环境是 Ubuntu 20.04 或 22.04 云服务器。相比于 Windows,Linux 下的 Python 包编译链更干净,内存管理更稳,而且用systemd守护 Flask 进程后可以做到崩溃自动重启。Ubuntu 的安装路径上没有奇怪的中文用户目录,torch模型路径不会出现编码问题。如果你的开发机是 Windows,建议把训练好的.pth权重文件通过scp上传到服务器,部署环节不要在 Windows 上跑。
6.2 安装依赖与启动 Gunicorn 多进程
部署过程分三步:创建虚拟环境、安装依赖、用 Gunicorn 启动。下面是一套可直接照抄的部署命令:
# deploy.sh 部署脚本片段 sudo apt update && sudo apt install -y python3.10-venv nginx # 1. 虚拟环境与依赖 python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple # 2. 确认 torch 能正常导入(重要!先跑这一句) python -c "import torch; print(torch.__version__)" # 3. 启动服务:4 个 worker,每个 worker 常驻模型 gunicorn -w 4 -b 127.0.0.1:8000 \ --timeout 120 \ --preload \ wsgi:app逻辑说明:--preload让 master 进程先加载模型,再 fork 出 4 个 worker,这样每个 worker 共享内存里的模型参数,避免每个 worker 重复torch.load导致内存翻四倍。--timeout 120是因为冷启动加载模型可能需要几秒,默认 30 秒超时会导致 worker 被杀。-b 127.0.0.1:8000绑定内网地址,外部请求由 Nginx 转发,这样更安全也方便加 HTTPS。
参数说明:worker 数量不是越多越好,推荐2 × CPU核数 + 1。4 核服务器开 4 个 worker 即可;如果每个请求的矩阵乘法压力大,实际验证下 CPU 占用率和接口延迟,把 worker 数压到 2 反而更稳定。
6.3 Nginx 反向代理与域名访问
Gunicorn 监听内网端口后,用 Nginx 做反向代理,把公网流量转到127.0.0.1:8000。Nginx 还能顺带缓存静态资源——前端页面和 JS 文件不必经过 Flask,直接由 Nginx 返回。
server { listen 80; server_name your_domain.com; # 前端静态文件 location /static/ { alias /home/ubuntu/movie_kg_recsys/static/; expires 7d; } # API 反向代理 location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 120s; } }重启 Nginx 后,访问http://your_domain.com就能直接看到前端页面,调用/api/recommend接口时请求会被 Nginx 转发给 Gunicorn。proxy_read_timeout 120s很关键,如果不加,遇到首次请求模型初始化导致的 2~3 秒延迟时,Nginx 会返回 504 网关超时。
6.4 用 Docker 固定环境,避免反复踩依赖坑
如果你嫌多台服务器手动配环境太累,最省心的方式是打包成 Docker 镜像。镜像内部固定 Python 3.10 + 依赖版本,宿主机只需要有 NVIDIA 驱动(GPU 场景)即可。Dockerfile 的关键点:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "--preload", "wsgi:app"]逻辑说明:基础镜像用python:3.10-slim而不是ubuntu,镜像体积更小、包管理一致性更好。COPY . .会把整个工程拷进镜像,weights/目录里的模型权重也在其中,所以--preload在这里不仅省内存,还缩短了镜像启动时间。
部署完以后做一次端到端验证:先curl http://127.0.0.1:8000/api/recommend?user_id=1&top_k=10看 JSON 是否返回,再打开前端页面确认电影卡片能正常渲染。如果推荐结果和本地开发机不一致,先怀疑数据处理阶段是否有随机种子——训练前给torch.manual_seed(42)和np.random.seed(42)打上,这样任何机器上复现结果都是相同的,排查问题会快很多。
我在实战中养成的习惯是每次改完模型配置,先把验证集上 Recall@5 的数字记录在代码注释里,再上服务器。因为部署环境跑一遍训练很耗时,有基线数字打底,才能在调参时判断「效果变好了还是玄学波动」。希望帮到你。
本文还有配套的精品资源,点击获取