中文情感分析这个方向早几年做的时候大家还在玩词向量加双向LSTM现在再看主流工程方案基本都转到预训练模型上来了。这次要记录的项目是我最近用 Python 和 Transformers 从零搭的一套大语言模型情感分析系统输入一段中文评论文本输出情感极性和置信度。整套系统经历了数据清洗、模型微调、效果评测到 API 上线这几个阶段中间踩了不少坑也走了一些弯路整理出来希望对正在做同类系统的朋友有参考价值。这套系统面向的典型场景很明确电商评论分析、客服工单情绪识别、舆情监控。这些场景有一个共同点——文本短、口语化严重、情感表达隐晦比如“这个手机壳好看是好看就是太滑了”这种带转折的句子简单规则和关键词方案根本接不住。用大语言模型来做优势在于语义理解能力强能捕捉转折、反讽、隐喻这些上下文信息劣势在于模型大、推理贵如果直接调云端大模型 API单次调用成本和时间都不划算。所以这次项目选择了“开源大模型 本地微调 自主部署”这条路线兼顾效果、成本和控制力。适合谁参考主要是两类人一类是刚接触 NLP 的同学可以从头看清楚一套情感分析系统完整长什么样另一类是已经在用传统文本分类方案、想往大模型方向迁移的开发者。1. 项目整体设计与技术选型思路1.1 为什么选择“微调”而不是纯提示工程先聊一个关键问题既然大语言模型本身就能做情感分析为什么不直接写 Prompt 调云端接口还要费劲微调我一开始也这么想。当时用某个在线大模型 API 试了一批样本效果确实不错但问题也很明显第一是成本生产环境每天可能要处理几十万条评论按 Token 计费根本烧不起第二是延迟情感分析往往是中台服务上游接口等不了三秒以上的响应第三是可控性在线模型的决策边界是个黑盒什么时候改个措辞输出就变了完全不受你控制第四是数据安全用户的评论数据不可能都传到外部接口去分析。微调这条路解决了这些问题。把模型放到自己的服务器上推理成本只有 API 调用费的零头延迟也能压到百毫秒级别更重要的是模型行为可以通过训练数据来约束。当然微调也有代价它需要标注数据、显卡资源、训练调参的精力但一次投入之后长期受益在数据量成百上千条的场景下这笔账是划算的。还有一个思路上的差别值得说纯 Prompt 的本质是“让模型凭知识回答问题”微调的本质是“让模型学会你的任务格式”。情感分析不是通用对话任务它需要模型在固定类别空间上稳定输出微调正好能做到这一点。所以我的结论是——探索阶段用 Prompt 验证可行性生产环境用微调保证稳定性。1.2 模型选型中量级分类模型与大语言模型的取舍确定微调路线之后下一个问题就是选哪个底座模型。我在项目里实际对比了三类方案方案代表模型参数量显存需求中文能力适用场景传统预训练分类模型BERT、RoBERTa-wwm-ext1亿~3亿2~8GB较强纯分类任务、资源有限通用大语言模型Qwen 系列、Baichuan 系列70亿~140亿16~40GB强复杂语义、多任务、生成需求超大模型 API云端商用模型千亿级不适用最强少量数据、探索验证刚开始我倾向于直接用 BERT 系模型因为在情感分类这种单标签任务上它完全能打而且部署轻量。但项目背景里有一个需求变化改变了这个判断除了情感极性业务方还希望系统能给出情绪强度的判断并且要求对“反讽”“转折”这类语言现象有基本的识别能力。BERT 对这类语义的捕捉能力有限因为它的训练目标是 MLM完形填空对语言中的隐含态度理解不够深。所以我最终选择了通用大语言模型作为基座。具体用的是 7B 级别的中文开源模型配合 LoRA 微调。这个选择有三个理由一是 7B 模型在单卡上能跑显存压力可控二是 LoRA 只训练少量参数微调成本低三是大模型学到的语义先验比较丰富在处理转折、反讽这类语言现象时明显优于 BERT 系模型。当然如果你的任务只是最简单的“好评差评”二分类且数据量不大直接用 BERT 也是合理选择不用盲目追大模型。1.3 开发环境与版本锁定大模型项目最烦的就是环境问题。Transformers 库迭代快PyTorch 版本和 CUDA 版本的组合也很敏感一个版本不对就可能踩到各种兼容性坑。我这次项目的环境配置如下实测可以稳定运行python3.10 pytorch2.1.0 transformers4.38.0 peft0.8.0 datasets2.16.0 accelerate0.26.0 bitsandbytes0.43.0 fastapi0.110.0 uvicorn0.27.0训练阶段用的是一张 24GB 显存的显卡推理阶段用的是同配置的服务器。7B 模型在 24GB 卡上训练 LoRA 是可行的但显存会比较紧张后面我会专门讲怎么把显存省下来。版本锁定这件事必须多说一句不要追求最新版。Transformers 某些新版本会改动内部实现导致旧代码报错bitsandbytes 对 CUDA 版本尤其敏感装之前必须确认自己的 CUDA 版本兼容。我建议把 requirements.txt 里的版本都固定下来不要用大于号。2. 数据处理决定上限的关键环节2.1 数据来源与清洗规则这次项目用的数据集是某电商平台的公开评论数据共 30 万条涵盖多个商品类目。原始数据质量参差不齐里面混着大量无效文本、重复评论和噪声。数据的清洗直接决定了训练效果的上限——如果输入模型的是垃圾文本模型学到的就不可能是一个干净的语义空间。我的清洗流程大概是这样的先做去重完全相同的评论只保留一条然后再去无效文本包括纯数字、纯标点、无意义字符串比如“asdfgh”这类再做繁体转简体因为模型词表中简体字的覆盖率更高最后是长度过滤只保留 5 到 300 个字的文本。太短的文本没有上下文信号太长的文本可能包含多个主题都不适合直接做单标签情感分析。清洗之后数据剩下 26 万条左右。需要注意的是清洗过程中不要过度处理比如不要把“卧槽”“牛批”这种口语词强行规范化这些词恰恰是情感表达的载体模型需要见到真实的语料形态。2.2 标签体系设计三分类还是五分类情感分析的标签体系直接决定了模型的输出空间。这次项目一开始业务方给的需求是二分类好评和差评。但我看了一下数据分布发现大量评论是“中性的”——比如“物流正常包装完整”这类文本不表达明显的正面或负面情绪硬分到好评或差评都会污染模型。所以我建议把标签体系定为三分类正面、负面、中性。这样既符合业务直觉也减轻了标注工作量。另外我额外增加了一个维度情绪强度。这个维度不是模型的直接输出而是通过后处理推导出来的——对话中出现“太”“特别”“简直”这类程度副词时我会在置信度上做加权调整。这种方式比直接训练五分类如“强烈负面、轻微负面、中性、轻微正面、强烈正面”要简单得多而且效果也够用。事后对比下来五分类训练的数据量需求更高标注难度也大三分类加后处理的方案在工程上更划算。标签的分布也要注意。清洗后的数据中正面的约 12 万条负面的约 9 万条中性的约 5 万条。稍微有一点不平衡但不算严重所以我没有做过采样或降采样。如果数据不平衡比例超过 3:1建议做一下类别加权或者在采样时保持各类别比例均衡否则模型容易偏向样本量大的类别。2.3 训练集、验证集与测试集划分数据划分是一个容易被低估的环节。很多项目随手用train_test_split按 8:2 切一下就开始训练忽视了三个问题类别的分层抽样、数据分组聚集、分布漂移。我这次是这样做的先按类别分层抽样保证训练集、验证集、测试集中的正负中比例一致然后把重复用户或相似评论分组切分避免同一个用户的重复评论同时出现在训练集和测试集里造成“数据泄漏”。所谓数据泄漏就是模型在训练时已经见过测试集样本的相似文本评估时效果虚高上线后立刻打回原形。具体数量方面训练集 20 万条验证集 3 万条测试集 3 万条。验证集用于训练过程中的早停和模型选择测试集只在最终评估时使用一次避免对测试集反复调参导致过拟合。这个区分很重要我在一些团队代码里见过用同一份数据既调参又评 估的做法最终上线效果惨不忍睹。3. 模型微调实战核心环节拆解3.1 加载预训练模型与 Tokenizer模型加载是整个流程里最早的实操环节但也是最容易出错的地方。主要涉及AutoTokenizer和AutoModelForCausalLM两个核心类再加上 PEFTParameter-Efficient Fine-Tuning库的 LoRA 准备。这里有一个重要的选择是把它当普通分类模型加载还是当生成模型加载。情感分析本质上是分类任务有两个实现路径。路径一是在预训练模型上加一个线性分类头模型输出 logits 后接 Softmax 得到类别概率——这是 BERT 时代的经典做法也适用于大型语言模型基座路径二是用 generate 的方式让模型输出“正面/负面/中性”这几个文本再解析文本得到结果——这是纯生成式做法。我这次选用了路径一。原因很直接分类头的推理速度更快、输出更可控、也不需要额外处理生成文本的解析逻辑。生成式做法虽然灵活但在工业级情感分析场景下没有必要反而引入输出格式不稳定等风险。实现代码如下from transformers import AutoTokenizer, AutoModelForCausalLM model_path your-base-model-path tokenizer AutoTokenizer.from_pretrained(model_path) # 部分模型需要设置 pad_token否则动态批处理会报错 if tokenizer.pad_token is None: tokenizer.pad_token tokenizer.eos_token model AutoModelForCausalLM.from_pretrained( model_path, torch_dtypetorch.bfloat16, device_mapauto, )这里有几个细节要注意。torch_dtype我用了bfloat16它比float16在训练时更稳定不容易出现 loss 变成 NaN 的问题。device_mapauto会自己把模型分配到可用的 GPU 上省去手动指定设备号的麻烦。关于 pad_token很多开源模型的 tokenizer 没有设置训练时动态批处理会报错直接拿 eos_token 顶上即可。3.2 LoRA 参数配置与训练目标LoRALow-Rank Adaptation是这次微调的核心技术。它的思想很巧妙冻结原始模型的所有权重只训练一小部分低秩矩阵训练完成后把低秩矩阵加到原始权重上。这样做的好处是显存占用少、训练速度快、而且不容易破坏模型已经学到的通用知识。具体到 LoRA 的配置有四个超参数需要关注。r是低秩矩阵的秩决定了新增参数量一般 8 到 16 之间就够了7B 模型不需要更大的值alpha是缩放系数控制低秩矩阵对原始权重的贡献大小通常设为r的 2 倍dropout设为 0.1 防止过拟合target_modules指定哪些模块加 LoRA我这次选的是[q_proj, v_proj, k_proj, o_proj]也就是把所有注意力投影层都加上 LoRA。from peft import LoraConfig, get_peft_model lora_config LoraConfig( r8, lora_alpha16, lora_dropout0.1, target_modules[q_proj, v_proj, k_proj, o_proj], biasnone, task_typeCAUSAL_LM, ) model get_peft_model(model, lora_config) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) total_params sum(p.numel() for p in model.parameters()) print(f可训练参数占比: {trainable_params / total_params:.4%}) # 实际输出在 1% 以下这是 LoRA 的正常水平训练目标方面因为我是在生成模型上加了分类任务所以我自定义了一个简单的分类逻辑在输入末尾添加一个特殊提示符号模型预测该位置对应的类别。传统的做法是用AutoModelForSequenceClassification但 7B 模型如果换成这一架构分类头是随机初始化的领域迁移能力反而弱。我的方法是在原有CausalLM上让模型学习“看到输入和提示后输出分类标签”的模式这样既保留了语言模型的语义先验又能输出稳定的类别。3.3 训练参数设置学习率、Batch Size、梯度累积与混合精度说到训练参数很多人喜欢照搬别人博客里的数值这种做法有风险。参数之间是互相耦合的同一个学习率在小模型上没问题换到 7B 模型上可能就发散。我这次用到的参数组合如下参数数值说明训练轮数3大模型微调轮数不宜多容易过拟合学习率2e-4LoRA 常用学习率范围是 1e-4 ~ 5e-4学习率调度cosine比线性衰减更平滑收尾效果更好Batch Size32动态批处理按最大长度动态分组梯度累积步数2等效 batch size 为 64最大序列长度128评论场景够用长文本可适当加长混合精度bf16省一半显存训练更稳定优化器AdamW大模型微调标配权重衰减0.01控制过拟合关于学习率值得多说一句。全参微调 7B 模型一般用 1e-5 到 2e-5而 LoRA 微调因为更新参数少学习率可以放大到 2e-4 左右。我一开始用 1e-5 试跑了一轮loss 下降极其缓慢浪费了不少时间。经验值LoRA 的学习率可以在全参微调的基础上放大 10 到 20 倍但也不要超过 5e-4否则 loss 很容易爆炸。梯度累积是一个显存优化技巧。当单卡放不下较大的 batch size 时可以把一个大 batch 拆成多个小 batch依次算梯度但不更新参数攒够指定步数后再统一更新。我这里实际 batch size 32梯度累积 2 步等效 batch size 64。注意累积步数乘以 batch size 不要太大太大的等效 batch size 会让模型收敛变慢。3.4 训练过程监控与早停策略训练过程不是挂着跑就完事了如果 loss 不降或者验证集指标不升做几天纯等待的效率极差。我的建议是训练过程中同时监控训练 loss 和验证集指标每跑完一个 epoch 就评估一次。早停策略是连续两个 epoch 验证集 F1 没有提升就停止训练回滚到最佳检查点。我这次实际训练只跑完了第二个 epoch第三个 epoch 验证集 F1 已经开始回落这就是典型的过拟合信号。大模型过拟合和传统模型有点区别它的训练 loss 可以降得很低但验证集却早已过峰所以不能只看训练 loss 决定训练轮数。评估指标上我没有只看 Accuracy而是同时看了 macro-F1。为什么因为中性类别的样本量明显小于正负面Accuracy 会被多数类带偏。F1 会分别计算每个类别的精确率和召回率再取平均能更好反映模型在小类别上的表现。from sklearn.metrics import classification_report, f1_score # 伪代码真实实现会通过 Trainer 或自定义评估循环调用 test_preds model_predict(model, test_dataloader) report classification_report(test_labels, test_preds, target_names[negative, neutral, positive]) print(report)最终测试集上的效果是 Accuracy 88.5%macro-F1 87.2%。说实话中文情感分析能稳定做到这个水平已经能直接支撑业务了。为了验证模型的可信度我还抽了一批 bad case 人工分析发现错误主要集中在“反讽”和“长文本多情感”两类问题上这也是当前所有情感分析系统的共性难点。4. 部署上线与系统集成4.1 模型导出与权重合并训练完成后模型权重是以 LoRA 适配器adapter的形式保存在磁盘上的这时候还不能直接把适配器当完整模型用。部署前需要做一次merge_and_unload操作把 LoRA 的低秩矩阵合并回原始模型的权重里得到一份完整的模型文件。from peft import PeftModel base_model AutoModelForCausalLM.from_pretrained( model_path, torch_dtypetorch.float16, device_mapcpu, ) peft_model PeftModel.from_pretrained(base_model, ./lora_checkpoint) merged_model peft_model.merge_and_unload() merged_model.save_pretrained(./merged_model) tokenizer.save_pretrained(./merged_model)这一步有个细节要注意合并时torch_dtype建议先用 float16因为导出后再转换可能会丢失精度。如果内存比较紧张也可以加载为 bf16 再合并效果差别不大。合并之后的权重就从一个“基座模型 增量补丁”变成完整的微调模型可以直接用AutoModelForCausalLM.from_pretrained(./merged_model)加载推理。如果你的场景需要极低延迟可以考虑把模型导出为 ONNX 格式。但以我的经验Transformer 大模型的 ONNX 导出过程相当折腾要在算子兼容上消耗大量时间。除非你的部署环境对延迟非常敏感否则直接用 Transformers 原生推理就够了。4.2 推理服务 API 设计部署这块我选用了 FastAPI轻量、自带文档、支持异步请求天然适合这类模型服务中间层。接口设计上主要考虑三件事请求参数、响应结构、错误处理。from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI(titleSentiment Analysis Service) class InferenceRequest(BaseModel): text: str return_prob: bool True class InferenceResponse(BaseModel): sentiment: str confidence: float probabilities: dict def predict_sentiment(text, return_probTrue): inputs tokenizer(text, return_tensorspt, truncationTrue, max_length128) with torch.no_grad(): outputs model(**inputs) # 从 logits 推导分类结果 probs torch.softmax(outputs.logits, dim-1)[0] label_idx torch.argmax(probs).item() return sentiment_map[label_idx], probs[label_idx].item(), { label: round(prob, 4) for label, prob in zip(sentiment_map, probs) } app.post(/predict, response_modelInferenceResponse) def predict(req: InferenceRequest): sentiment, conf, probs predict_sentiment(req.text, req.return_prob) return InferenceResponse(sentimentsentiment, confidenceconf, probabilitiesprobs)响应格式里我刻意加了confidence和probabilities两个字段。原因在于业务方拿到一个情感标签还不够他们希望知道模型对这个判断有多大把握。标签为“中性”但置信度只有 0.51 的样本和标签为“正面”置信度 0.98 的样本下游处理逻辑完全不同。置信度信息能给到业务侧更大的灵活性。4.3 性能优化量化、缓存与批处理上线之前我先压了一轮性能结果并不理想单条请求延迟 1.2 秒QPS 只有个位数。这对于生产环境是不可接受的。做了三步优化之后延迟降到了 200 毫秒左右QPS 提到了几十。第一步是动态量化。把模型权重复制为 int8 精度推理显存占用减少一半以上延迟减少约 30%。用 Transformers 的bitsandbytes可以轻松加载量化后的模型model AutoModelForCausalLM.from_pretrained( ./merged_model, load_in_8bitTrue, device_mapauto, )第二步是加缓存。很多评论数据的重复率其实很高比如同一用户对多个相似商品的评论、不同用户引用同一句话用 LRU 缓存把常见的文本结果存下来直接规避了重复推理的消耗。注意缓存的 key 需要用文本本身而不是请求 ID否则缓存命中率会低得可怜。第三步是动态批处理。用 FastAPI 的异步特性在前端收集短时间内到达的请求攒够一定数量再打包成一个 batch 喂给模型。这一招对大模型特别有效因为 GPU 的并行能力不会被单个请求浪费。5. 常见问题与排查实录5.1 显存不足CUDA OOM24GB 显存加载 7B 模型再训练 LoRA稍微配置不小心就会 OOM。我遇到过几次排查方向依次是确认是否开启了混合精度bf16 能减一半显存减少 batch size 并用梯度累积补偿检查是否有残留的中间变量没有释放比如累积了张量的 Python 列表开启 gradient checkpointing 进一步省显存。其中 gradient checkpointing 值得单独说一句它通过训练时重新计算前向激活值而不是保存所有激活值来省显存代价是训练速度下降约 20%但显存占用可以减少 30% 以上。5.2 中文文本乱码或效果差中文数据集最常见的问题就是编码混乱GBK 编码的文本被当 UTF-8 读取会出现乱码。排查思路很简单清洗脚本里统一转成 UTF-8读取时用errorsignore容错。如果模型效果差还需要考虑另一个问题tokenizer 对中文的分词粒度。开源中文模型的 tokenizer 一般都经过了中文本土化优化但如果你选的是偏英文优化的模型中文会被切得很碎语义信息大量丢失。选底座模型之前一定先确认它在中文上的词表覆盖和分词质量。5.3 模型不收敛或者 Loss 震荡这个问题我排查过一次。当时的学习率设成 5e-4前几步 loss 下降很快但进入后半程后 val loss 出现大幅震荡。最后定位是学习率太高加上 bf16 精度下梯度更新幅度被放大。换成 cosine 调度器并配一个 10% 的 warmup就是刚开始用很小的学习率预热几步问题迎刃而解。大模型微调里 warmup 不是可选项是必须项。5.4 推理延迟高延迟问题在前面部署部分已经讲了量化、缓存、动态批处理三个手段。补充一个容易被忽略的点max_length设置过大会拖慢推理速度。如果你的业务场景评论平均长度不到 100 字把max_length128就够了不要设置成 512。Transformer 的推理时间和序列长度是非线性相关的序列每长一倍计算量翻几倍。问题常见原因解决方向CUDA OOM未开混合精度、batch 过大开启 bf16、梯度累积、gradient checkpointing中文效果差编码混乱、tokenizer 中文能力弱UTF-8 统一编码、选中文友好的底座模型训练震荡学习率偏高、无 warmup降低学习率、加 warmup、用 cosine 调度推理太慢未量化、单请求推理、序列过长动态量化、动态批处理、限制 max_length最后再分享一个我个人的体会这套系统做完之后真正让我觉得值回票价的不是那 88.5% 的准确率而是“微调 部署”这条链路跑通了之后换领域、换任务只是换数据和重训的问题。我把这套代码整理成了项目模板后面再接新业务时只需要准备新的标注数据改一下评估脚本就能交付新模型。你在做同类系统的时候也建议把数据处理、训练、部署分成独立模块不要揉在一个脚本里——这种模块化的工程习惯在后续迭代时会给你省下大量时间。