我最近把一套“基于AI + Spring Boot的电影评论情感分析系统”从零搭到了上线。起因很普通:负责内容运营的同学每天要手动翻上万条评论,才能判断一部新片的口碑到底是真好还是假好。表面看是体力活,实际上情绪判断标准还不统一,同一个“还行”,在不同人眼里可能一个算好评一个算差评。所以我就想着,能不能用模型自动读评论,把“电影好不好看”这件主观的事,翻译成客观的情绪信号。
这套系统最终做成的是:AI模型负责读懂中文评论的情绪,Spring Boot负责把能力包装成稳定的接口给业务方调用。单条评论可以返回正面、负面标签和置信度,一部电影的所有评论可以聚合成口碑曲线,运营同学打开后台就能看到新片上映首周的情绪起伏。如果你也在做类似的内容理解项目,或者想给已有系统接一个情感分析能力,这篇就把我从数据处理、模型选型到Spring Boot接入、上线压测的全过程拆开讲。
1. 从“想看评价”到“想看情绪曲线”:这个系统的定位
动手之前,我习惯先问一件事:这个东西到底要为谁解决什么问题?如果只是在上线前临时接一个开源模型,发个接口就完事,那根本不需要写这篇文章。真实场景比想象中复杂得多,电影评论也不是普通的文本分类任务。
1.1 为什么电影评论值得单独做一个情感分析任务
电影评论和一般电商评论、新闻评论最大的区别在于,它天然带着“表演欲”。很多人写影评不是在陈述事实,而是在表达审美立场。比如“这导演前面铺了那么久,就为了最后十分钟让你哭,我偏不哭”,这句话从字面看是中性描述,但情绪是负面的、带着对抗感。又比如“特效值回票价,剧情嘛,大家心里有数”,前半句表扬,后半句阴阳,整句情绪其实是负面的。
这种文本对情感分类模型非常不友好。通用情感分析模型如果只在标准数据集上训练,遇到这种口语化、反讽、省略句多的电影评论,准确率会掉到让人怀疑人生的程度。所以我要做的第一件事,就是让模型专门“泡”在电影评论这种语体里,而不是拿一个通用模型硬扛。
电影评论还有一个特点:口碑有很明显的时效性。上映首周和上映三个月后的评论,情绪分布完全不同。首周评论多数来自粉丝和首批观众,情绪容易两极分化;长尾评论反而更接近普通观众的真实感受。这意味着,系统不能只看静态标签,还要关注情绪随时间的变化,后面我会讲到如何用接口把时序聚合做进来。
1.2 这个系统要交付的三种能力
需求方一开始只说“帮我看看这部片口碑怎么样”,但真正落地时我把它拆成了三层能力。
第一层是单条评论的情感识别。输入一段文本,输出情感类别(正面、负面)和对应的概率分数。这是最基础的能力,所有上层统计都依赖它。第二层是电影维度的口碑聚合。同一部电影下的所有评论,按天或按周统计正面评论占比、负面评论占比、平均情感得分,形成口碑曲线。第三层是异常评论识别。短时间内大量重复的正面短评、格式高度一致的“控评”内容,不能简单当作真实口碑算进去,要在聚合前单独打标记。
这三层对应了三个独立的Spring Boot模块:评论接入模块、推理服务模块、聚合统计模块。评论接入模块收集来自不同渠道的评论,清洗后发给推理服务;推理服务统一调用AI模型;聚合统计模块负责按电影、按时间做汇总。这样拆的好处是,模型升级的时候只需要替换推理服务,不需要动数据接入和业务展示。
1.3 技术栈划分:为什么Spring Boot只做编排,不做模型推理
现在很多人一提AI项目,就想着Java直接把模型加载进内存。但从工程角度看,Spring Boot和Python模型推理是两套完全不同的生命周期。Spring Boot擅长做接口编排、参数校验、数据库操作、任务调度,Python生态才擅长做文本处理和模型推理。
我把系统拆成了两个进程:一个Java进程负责业务接口,一个Python进程负责模型推理。Java进程通过HTTP调用Python进程。为什么不直接在Java里加载模型?后面第四章会详细对比三种路线,这里先给结论:独立推理进程可以让模型迭代和业务发版完全解耦。我训练一版新模型,直接在Python服务上替换模型文件,重启一次Python服务即可,Java服务一行代码都不用改。
这种拆法也带来一个新问题:多一次网络调用,延迟会不会变高?实测下来,在同一个内网环境里,单次推理服务调用增加约2到5毫秒,对评论分析这种非实时强交互场景完全可以接受。何况可以通过连接池和并发优化把影响压到更低。有时候,架构上的“不优雅”反而比“全塞一起”更容易维护。
2. 让模型先学会看懂中文评论:语料、清洗与标注的实操过程
模型不是拿来就能用,尤其是中文影评领域,数据和清洗决定了下限。我见过很多项目把大量时间花在调参上,结果数据没洗干净,线上预测经常被一条带emoji的短评带偏。这个阶段我花了大约两周,比模型训练的时间还长。
2.1 语料从哪里来:平台自采加公开语料组合
没有自有数据的团队,第一步都会去找公开中文情感数据集。我的做法是用了一份某高校开源的中文影评子集,大概有几万条带评分的数据,同时又从我们自己的评论后台导出了一批真实评论。真实评论里什么样的人都有:有人用繁体字,有人全程emoji,有人一句话里夹杂英文,还有大量“沙发”“前排”“mark”这种无意义内容。公开数据集再干净,也没法覆盖这么野生的文本。
自采评论要特别注意合规问题。我这边只取了我们自己平台用户主动提交的评论,且用途限定在口碑分析和内容推荐,不会把用户文本直接丢给外部接口。如果你用的是爬虫拿其他平台的数据,务必先确认对方条款是否允许,别给项目埋雷。
2.2 文本清洗的具体规则
清洗不是把停用词删一删那么简单。我踩过最典型的坑是“不要”被当成停用词删掉,导致“不要去看”变成“去看”,直接判成正面。后来我干脆放弃通用停用词表,只维护了一份极简停用词表,里面只放“的”“了”“嗯”“啊”这类无情绪词。
我的清洗顺序是固定的:
- 去掉HTML标签、URL、不可见字符;
- 繁体转简体;
- 标点统一:全角转半角,中文逗号句号保留不变;
- 表情符号映射:常见的正面表情映射为“真好”,负面表情映射为“真差”;
- 连续重复字符合并,比如“哈哈哈哈哈哈”合并成“哈哈”;
- 不做分词,直接交给模型处理。
这里想特别说下表情符号映射。现在的年轻用户经常只发一条“😄”就算评价,模型字符表里如果没有这个token,就是一段无意义文本。我维护了一张表情到情感词的映射表,遇到表情直接替换成对应情感词,等于让模型“看得到”表情背后的情绪。这个操作简单,但对准确率提升非常明显。
2.3 标注策略:为什么我放弃了三分类
刚开始我也想做正面、负面、中性三分类,觉得更精细。但标注了三百条之后发现,中性样本的一致性极低,同一句“剧情还行”有人标中性,有人标正面,还有人标负面。强行做三分类,模型会学到一个模糊边界,线上预测结果两边不靠。
所以我做了一个务实取舍:只做正面和负面二分类。中性文本怎么处理?我把评分3星的评论从训练集里剔除,只保留1星、2星作为负面,4星、5星作为正面。这个策略看起来粗暴,但让标注一致性大幅提高。上线后遇到中性评论,模型会输出较低置信度的正面或负面标签,业务层可以设定阈值,置信度低于阈值的统一落到“无法判断”。
我还做了一轮人工复核。从两万条训练数据里随机抽了两百条,让三个同学分别打标,统计两两一致率。结果是两两一致率约85%,有分歧的文本主要集中在讽刺和反语上。这类样本不会被扔掉,而是单独放在测试集里,用来观察模型在反讽文本上的表现。
3. 模型选型的真实对比:轻量级方案和深度模型我都测了一遍
模型选型没有标准答案,完全取决于你的算力、数据量和线上延迟要求。我很清楚自己的约束:开发期有一台带GPU的机器,但不可能让线上模型一直占着GPU跑;推理接口的期望延迟在200毫秒以内;训练数据不算多,只有几万条。在这个前提下,我做了三轮对比。
3.1 第一轮:TF-IDF加逻辑回归当基线
我先用最传统的TF-IDF加逻辑回归做了一个基线,不是为了省事,而是为了给后面的模型定一个及格线。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression vectorizer = TfidfVectorizer(max_features=20000) model = LogisticRegression(C=1.0, max_iter=1000) X_train = vectorizer.fit_transform(train_texts) model.fit(X_train, train_labels)这个基线在验证集上的准确率约85%,看起来不错,但仔细看混淆矩阵会发现,负面类别的召回率明显偏低。原因是电影评论里负面表达更委婉,“睡着了”“提前走了”这类文本很难只靠词频抓住。逻辑回归本质是词袋模型的加强版,它对词序和上下文语义几乎无感。不过它有一个价值:推理极快,单条文本在CPU上只需要几毫秒,非常适合做流量异常时的降级方案。后来我把这个基线模型也保留了下来,作为高并发兜底。
3.2 第二轮:LSTM和它的不稳定性
接着我试了比较经典的BiLSTM加注意力机制。它比逻辑回归强的地方是能看词序,理论上能抓住“虽然…但是…”这类转折结构。但我在训练中遇到了很明显的不稳定问题:同一批数据跑两次,准确率能差两个百分点。而且LSTM需要做分词,分词器的选择会直接影响结果。
我在测试中发现过一个问题:同一个句子的不同分词结果,模型预测从正面跳到负面。比如“这电影/真/不/错”,如果分词器把“真不错”切到一起,模型容易判正面;如果切成“真/不/错”,模型可能判负面。这是词边界引发的语义漂移。BiLSTM的收益相对于它的不稳定性来说,并不划算。
3.3 第三轮:预训练模型和它的蒸馏版
最后我把主力模型定在了预训练语言模型这条路上。第一版直接用了中文预训练的BERT模型微调,效果确实好,验证集准确率到了93%,反讽测试集上也有明显提升。但问题也很实在:单条文本在GPU上推理约30到50毫秒,在CPU上要几百毫秒到一秒不等,线上不可能给每个请求都配上GPU。
我的解法是采用蒸馏后的轻量级模型。具体做法是用一个大模型当老师,把自己的预测结果搬到一个小模型上,小模型参数量只有原来的四分之一,推理速度和准确率之间取得了一个相对平衡。最终小模型在CPU上的单条推理时间约60到100毫秒,验证集准确率保持在90%左右,虽然比大模型低一点,但换来了不用GPU就能跑的可能性。
下面是三套方案的综合对比:
| 方案 | 验证集准确率 | CPU单条推理耗时 | 训练成本 | 可解释性 | 最后用途 |
|---|---|---|---|---|---|
| TF-IDF + 逻辑回归 | 85% | 3-5ms | 极低 | 很强 | 高并发降级方案 |
| BiLSTM + Attention | 87% | 20-30ms | 中等 | 较弱 | 放弃 |
| 蒸馏预训练模型 | 90% | 60-100ms | 较高 | 弱 | 主力在线模型 |
这个结果也说明一个道理:模型不是越复杂越好,如果你的业务能接受90%准确率,那CPU推理、低延迟的轻量模型就是最优解。为了一个点去上GPU集群,性价比往往不高。
4. Spring Boot 和 Python 模型怎么握手:推理服务化的三种路线
模型训练好之后,最难的部分才刚刚开始。Spring Boot服务怎么把文本交给模型,再拿回结果,这件事我调研了很久,最终选了独立推理服务。
4.1 三种路线的真实取舍
第一条路线是把模型转换成ONNX格式,用Java直接加载推理。听起来最干净,没有网络调用,但实际操作中坑很多。中文分词器和模型词表需要对齐,转换过程稍有不慎,线上结果就和训练时不一致。有一天我为了排查一个“为什么转成ONNX后准确率降了5%”的问题,折腾了整整一天,最后发现是动态维度设置的问题。这种路线适合团队里没有Python运维支持的场景,但对训练和推理的工程要求极高。
第二条路线是用Java调Python的推理服务,模型文件放在Python那侧。这也是我最后选用的方案。Python进程可以自己管理模型加载、预处理和推理,Spring Boot只管把评论文本传过去,再把结果拿回来。这个路线的开发成本最低,模型迭代也最灵活。
第三条路线是把推理逻辑嵌入到消息队列里。评论先进队列,Python消费者拉消息、算结果、写回数据库,Spring Boot只负责投递和查询。这种方式吞吐量高,但不适合需要即时返回结果的在线接口。我把它用在了离线聚合统计上,每天半夜把新增评论统一过一遍模型,更新口碑曲线。
4.2 推理接口的数据契约
Python推理服务我提供了一个简单的REST接口,用JSON格式交换数据。请求格式长这样:
{ "reviewId": "123456", "text": "前面节奏太慢,但结尾真的很催泪" }响应格式长这样:
{ "code": 0, "data": { "reviewId": "123456", "sentiment": "positive", "confidence": 0.87 } }Spring Boot这边的Controller不需要任何模型相关代码,只负责参数校验、调用远程推理、超时处理。我特意把reviewId做成透传字段,这样即使推理请求乱序返回,业务层也能准确对上号。
超时处理我设了三级:连接超时、读超时、整体调用超时。连接超时设为2秒,读超时设为3秒,整体超时控制在5秒以内。一旦Python服务返回超时,Spring Boot会走降级逻辑,直接调用本地TF-IDF基线模型,保证接口不会因为AI服务抖动而报错。
4.3 在线批量与异步:不能把训练期思维带到线上
训练模型的时候,我们会用batch一次喂几十条文本,因为GPU需要批量处理才有效率。但在线推理不一样,一个请求对应一个用户,如果强行把多个用户的请求攒在一起等批量,延迟反而会变得不可控。
我的做法是Python端固定batch_size=1,推理服务内部维持一个模型实例,所有请求交替使用这个实例。对CPU推理来说,batch_size=1能最大程度避免单个请求阻塞队列。
Spring Boot端则需要把耗时长的HTTP调用放到异步线程里。我用的是CompletableFuture,把评论分析和评论入库做成异步链路,接口立即返回“分析中”状态,业务方通过轮询或回调拿到结果。这里的核心思路是:不能让AI推理的几十毫秒延迟,阻塞主业务请求的吞吐。
CompletableFuture<SentimentResult> futureResult = CompletableFuture.supplyAsync( () -> sentimentClient.analyze(review), aiExecutor);这个aiExecutor线程池我设置了20个核心线程,最大线程数50,队列容量200。线程数太大会导致Python服务被瞬时打满,太小则会在高峰时堆积请求。每天凌晨的离线批量任务是另一条Channel,走消息队列,不占用在线线程池。
5. 上线前踩过的五个坑:每一处都让预测结果偏离真实
这个部分原本是我记录在工单里的排错笔记,整理出来应该对同样做类似系统的人有帮助。每一个坑都真实发生过,并且都让预测结果在“看起来没问题”的时候悄悄偏掉。
5.1 模型文件在容器里被加载了两次,显存差点爆掉
第一次做性能测试的时候,压测还没跑完,Python服务就开始报资源不足。查内存监控发现,容器里有两个模型副本在同时加载。原因是Flask app初始化的时候加载了一次模型,某个后台线程又因为一个懒加载的逻辑加载了一次。单模型文件大约几百MB,两次加载直接吃掉了容器大部分内存,再加上推理时还要开临时缓存,服务很快就撑不住了。
解决办法很朴素:模型实例改成模块级单例,启动时检查全局变量是否已存在,存在就直接复用。随后又在健康检查接口里加了一个“预热”逻辑,服务启动后先跑一条推理请求,确保模型真的能响应了,Spring Boot那边才会开始转发流量。
5.2 训练和推理的预处理不一致,结果悄悄漂移
这个坑可以说是情感分析项目里最容易犯、又最难发现的。训练的时候,我的预处理函数里做了全角转半角、繁体转简体;推理服务里,我把这些都写在一个文本清洗脚本里。后来有次升级推理服务,我手滑把全角转半角的步骤漏掉了。因为推理请求里绝大多数文本是全角标点,这个漏掉在单条评论上看不出什么问题,但整体评估时发现准确率掉了两个点。
最终我把预处理函数抽成一个独立模块,训练过程和推理服务从同一个代码库里引入,并且加了一个校验用例:固定输入三句带繁体、全角、表情符号的文本,无论训练还是推理,输出必须完全一致。从那之后,“训练时能跑通、线上结果不对”的问题再没出现过。
5.3 截断位置错误导致后半段才是情绪的评论判反
在线推理时为了控制计算时间,设置了最大文本长度128个token。原本的策略很简单:从头开始截断。问题来了,中文影评有一个常见结构:前面一大段讲剧情、讲画面、讲演员,最后一句才落“总之失望透顶”。从头截断会丢失结尾,模型看到前半段全是叙事,自然倾向判成中性或正面,但真实情绪在句尾。
我改成了首尾结合截断:前64个token保留,后64个token保留,中间丢弃。这样既保留开头背景,也保留结尾态度。改完之后,那些“长铺垫、短结论”的评论预测准确率明显上升。也因为这个教训,我把文本长度统计加到了监控里,随时关注有多少评论到了截断边界。
5.4 并发压测时Python推理服务成为瓶颈
Spring Boot这边压测到每秒100个请求时,自己CPU还很健康,但Python推理服务的响应时间开始从80毫秒一路涨到400毫秒,再往后直接超时。过去看日志,Python端在上一个推理还没结束的时候,新的请求已经堆积在HTTP线程池里了。
我先给Python服务加了并发控制,限制同一时刻最多处理4个推理任务。超过的请求直接返回429,Spring Boot收到后进入降级逻辑。这个“快速失败”策略比让请求无限排队要好得多,因为拖死每个请求比牺牲一小部分请求更可怕。后来又优化了模型推理本身的CPU线程数,让单次推理占用从接近100%降到70%左右,留出余量给服务框架本身。
5.5 粉丝刷评导致负样本过度集中
上线后我对比过一个现象:某部热度很高的电影,评论情感分布里正面占比异常高,高到和另一个平台的口碑完全对不上。抽了几百条文本出来,发现大量格式高度一致的短评,比如“主演演技绝了”“剧情太赞了”反复出现。这些短评本身确实是正面,但由于内容过于同质化,模型很容易把“粉丝刷评”误当成真实口碑。
我做了两个调整:一是在评论接入层做相似文本去重,按照SimHash把重复度高的评论合并成一条;二是在聚合统计时加入置信度加权,当情感置信度低于0.6时不对口碑曲线贡献权重。这样即使有大量重复短评涌入,也不会把整体口碑拖向一边倒。
6. 上线后的关键指标:除了延迟,还必须盯着漂移
系统上线只是开始,真正让我天天看监控面板的,是那些看起来不起眼、但能反映模型健康度的指标。很多项目上线第一天很稳,过了一个月却悄悄变差,就是因为只看了接口延迟和错误率,没有盯数据分布变化。
6.1 接口层的基础监控指标
接口层我重点看三件事:QPS、TP95延迟、错误率。QPS用来判断峰值流量是否超过设计上限;TP95比平均延迟更能反映真实体验,因为平均延迟会被大多数低延迟请求拉低;错误率则包括超时、服务不可用、结果解析失败。
我给这三个指标都设了阈值和对应动作:
| 指标 | 正常范围 | 超阈值后的动作 |
|---|---|---|
| QPS | 0-50(当前业务量级) | 扩容Python推理服务实例 |
| TP95延迟 | <200ms | 检查CPU占用,必要时切降级模型 |
| 错误率 | <1% | 查看日志,判断是超时还是模型异常 |
6.2 结果漂移:最难发现的问题
接口健康不代表模型健康。我每周会跑一次人工抽检,随机抽取300条评论,让模型输出情感标签,再由我人工核对,算出一个“人工一致率”。这个一致率如果连续两周下滑,就说明模型可能已经跟不上当前用户的语言习惯,需要增量训练。
还有一个更隐蔽的漂移信号:按电影维度统计正面评论占比,会发现某些明显是烂片的电影,正面占比居然超过30%。这种情况通常不是模型错了,而是文本来源出了问题,比如大量非真实评论涌入。所以我把“正面占比变化率”也作为一个业务指标接入了监控看板,超过设定的变化率就触发告警,让运营同学人工介入看看评论列表。
另外,模型本身也需要定期更新。我的做法是每个月把新增的、带有明显情绪倾向的评论加入训练集,重新微调一次模型,替换Python服务里的模型文件。替换时先把新模型部署在预发布环境,跑一周影子流量,对比新旧模型在同一条评论上的预测差异,确认没有大面积翻车再全量切换。
7. 如果重新做一遍,我会调整的三件事
这套系统从设计到上线,走了大半年,回头复盘有三个地方如果重新做,我会在第一天就改掉。
第一,我会先定义好“中性”的商业含义,而不是直接扔掉。虽然三分类在技术上一度让我头疼,但业务方其实很需要“这部片的口碑里有多少人是无感的”。扔掉中性,等于把“既不夸也不骂”的用户全部强行归边,这在长尾电影的舆论观察里会造成偏差。更好的做法不是做三分类,而是做二分类加一个“低置信度待定”状态,让业务层自己定义这块怎么处理。
第二,我会更早引入模型版本管理。现在每次更新模型,虽然Python服务可以独立替换,但新旧模型在边界样本上到底差在哪,没有系统记录下来。如果一开始就让每次上线自动保存一份影子评测报告,后面调优会轻松很多。
第三,我会把人工评估集拆得更讲究一些。现在的测试集是随机抽样,虽然整体指标好看,但反讽、谐音、方言这些难样本没有单独建集。这些样本才是中文情感分析最值钱的部分。后来我把历次线上翻车的案例都收进了“硬样本集”,每版模型上线前必须过一遍这个集,效果比单纯看准确率有用得多。
如果你也要做一个类似的情感分析系统,我的建议是:先别急着微调大模型,花两周把数据清洗、评估集和监控体系搭扎实,再往上走。模型可以慢慢换,数据地基一旦塌了,上线后的每一次调优都是在补救。