做客服对话分析、用户反馈总结或者社交媒体情绪监控时,业务方最常问的一句话是:用户这句话到底在想什么。为了更清楚地回答这个问题,我通常会先搭建一个轻量级的中文短文本情绪与意图分析服务,项目代号就叫“sakura在想什么呢”。名字来自那句日常口语——看起来是在问一个角色在想什么,实际上是在做一次文本语义结构拆解:判断一句话表达了什么情绪,属于什么意图。
本文会沿着这条主线,从规则词典开始实现一个最小可运行的分析器,再用 FastAPI 把它包装成 HTTP 接口,最后给出调优、排错和生产落地建议。适合刚接触 NLP 工程化、想从零做一个可调用文本分析服务的读者。整个项目不需要 GPU,不需要标注数据,只要 Python 环境和几千行以内的代码就能跑起来。
1. 把“在想什么”翻译成可计算的工程问题
在工程上,回答“在想什么”不是要去揣测用户心理,而是把一段文本映射到有限的结构化标签。以客服场景为例,一句“网络太差了,一直崩溃”和一句“这个功能怎么用啊”,业务方需要知道的信息完全不同。前一句是用户的消极情绪在表达抱怨,后一句是用户的中性情绪在寻求帮助。如果系统能把这两种情况自动分开,后续就可以做工单分级、质量分析和智能回复。
所以这里要拆解两类信号:
“网络太差了,一直崩溃。” -> 情绪:negative;意图:complaint “这个功能怎么用啊?” -> 情绪:neutral;意图:inquiry一个是情绪倾向,一个是意图类型,二者组合起来才是这句话的完整结构。
1.1 一句话里的两类信号:情绪倾向和意图类型
情绪倾向(Sentiment)描述的是说话人对主题的情感态度。常见粒度有正向、负向、中性,也可以细分到惊讶、愤怒、失望等。情绪分析在文本挖掘里属于句子级分类任务,输入是文本,输出是标签和置信度。
意图识别(Intent Recognition)描述的是这句话想达到什么目的。用户说“怎么登录”,意图是询问;用户说“登录按钮不见了”,意图是问题反馈;用户说“建议增加记住密码”,意图是建议。意图识别通常是一个多分类问题,类别集合需要根据业务场景预先定义。
这两个任务经常一起出现,因为它们在产品上强关联:情绪决定处理优先级,意图决定处理方式。把两者同时输出,比单独做任何一个都更有价值。
1.2 为什么第一版选规则词典而不是深度学习模型
第一版用规则和词典,核心原因有三个:
- 不需要标注数据。深度学习模型需要有标签的训练集,冷启动阶段往往没有这些数据。
- 可解释、可调试。规则命中哪个关键词、触发哪个情绪词,逻辑是透明的,出问题可以直接改规则。
- 速度快、成本低。词典匹配和字符串检索都是毫秒级,不需要加载模型权重,也不依赖 GPU。
等系统运行一段时间,积累了足够多的 bad case 之后,再考虑用模型替换规则,效果会更好。这也符合“先上线、再迭代”的工程节奏。直接在第一版就引入大模型或微调模型,常常会因为数据质量、延迟和成本问题拖慢项目进度。
1.3 项目输入、输出与边界
“sakura在想什么呢”作为最小项目,处理范围这样定义:
- 输入:单条中文短文本,长度不超过 500 字。
- 输出:情绪标签、情绪分数、情绪置信度、命中情绪词、意图标签、意图置信度、命中关键词。
- 不处理多轮对话,不做指代消解,不识别专有名词实体。
这个边界是刻意的。先把单句分析做成稳定接口,后续再扩展上下文和实体抽取。
2. 环境准备:Python依赖、目录结构与规则文件
2.1 Python环境与依赖版本
推荐使用 Python 3.9 及以上版本。依赖方面,核心只需要三个库:jieba 负责中文分词,FastAPI 负责 HTTP 服务,uvicorn 负责启动服务。
| 依赖 | 版本建议 | 作用 |
|---|---|---|
| Python | 3.9+ | 运行环境 |
| jieba | 0.42.x | 中文分词 |
| fastapi | 0.110.x | HTTP 服务框架 |
| uvicorn | 0.29.x | ASGI 服务器 |
安装命令:
pip install jieba fastapi uvicorn如果是为了稳定交付,建议安装后执行pip freeze > requirements.txt固定住实际环境里的版本。下面这份requirements.txt可以作为初始参考,实际项目以自己的解析结果为准:
jieba==0.42.1 fastapi==0.110.0 uvicorn==0.29.02.2 项目目录结构
项目结构不需要复杂,但要把“分析逻辑”和“HTTP服务”分开,方便后续测试和扩展:
sakura-thinking/ ├── main.py ├── analyzer.py ├── data/ │ ├── emotion_dict.json │ └── intention_rules.json ├── requirements.txt └── README.mdanalyzer.py只负责文本分析,不依赖 FastAPI;main.py负责接收 HTTP 请求,调用分析器并返回结果。规则和词典放在data目录下,因为它们是外部配置,不应该硬编码在代码里。
2.3 用JSON准备情感词典和意图规则
情感词典包含三类信息:正向词、负向词、程度副词、否定词。注意这里的情感词要尽量选择能被 jieba 切分出来的词,例如“差”“崩溃”可以,“太差”这种短语通常会被切分成“太”和“差”,所以建议放在意图规则里用子串匹配。
创建data/emotion_dict.json:
{ "positive": ["开心", "喜欢", "满意", "期待", "好用", "感谢", "点赞"], "negative": ["差", "糟糕", "生气", "难过", "失望", "垃圾", "崩溃", "讨厌", "难用", "故障"], "degree": { "非常": 1.5, "特别": 1.5, "太": 1.3, "很": 1.2, "有点": 0.8, "稍微": 0.6 }, "negation": ["不", "没", "没有", "别", "不太", "无法"] }创建data/intention_rules.json:
{ "praise": { "priority": 10, "keywords": ["满意", "好用", "喜欢", "点赞", "太棒了"] }, "complaint": { "priority": 10, "keywords": ["太差", "垃圾", "慢死了", "故障", "崩溃", "难用", "不行"] }, "thanks": { "priority": 8, "keywords": ["感谢", "谢谢", "辛苦了"] }, "suggestion": { "priority": 6, "keywords": ["建议", "希望", "能不能", "可不可以", "如果"] }, "inquiry": { "priority": 5, "keywords": ["怎么", "为什么", "如何", "可以吗", "哪里", "是否"] } }为什么要用 JSON 而不是直接写在 Python 代码里?因为规则会频繁变化。业务方觉得“易仕”这个词属于投诉时,只需要改 JSON 让运营人员维护即可,不需要改动代码。priority字段用来处理多意图冲突,例如“希望你说一下这个功能怎么用”,同时命中了suggestion和inquiry,此时按优先级取高者会更合理。
3. 核心分析器实现:清洗、分词、情绪打分与意图匹配
3.1 文本清洗和分词:把一句话变成可匹配的最小单元
文本清洗要解决的问题是“如何不被标点和符号干扰”。中文文本里常见的逗号、句号、感叹号、问号,以及英文标点,都需要在匹配前去掉,否则“怎么用?”里的问号会影响规则判断。
分词使用 jieba。例如:
import jieba print(list(jieba.cut("网络太差了,一直崩溃")))输出类似:['网络', '太', '差', '了', ',', '一直', '崩溃']。分完词之后,情绪分析就可以对每个词做词典匹配,意图分析则可以直接用“原文子串匹配”,因为意图关键词往往是短语,例如“慢死了”“太差了”,子串匹配更稳定。
3.2 情绪打分:情感词、程度副词和否定词怎么配合
情绪打分的核心规则是这样:
- 遇到正向词,加 1 分。
- 遇到负向词,减 1 分。
- 如果情感词前一个窗口内出现程度副词,则把分数乘以对应权重。
- 如果出现否定词,则把分数符号反转,也就是“不开心”应该识别为负面。
- 最终把总分限制在
[-3, 3]之间,避免极端值影响展示。
创建analyzer.py:
import json import re from typing import List import jieba class SakuraAnalyzer: def __init__(self, emotion_dict_path: str, intention_rules_path: str): self.emotion_dict = self._load_json(emotion_dict_path) self.intention_rules = self._load_json(intention_rules_path) self.positive_words: set = set(self.emotion_dict.get("positive", [])) self.negative_words: set = set(self.emotion_dict.get("negative", [])) self.degree_words: dict = self.emotion_dict.get("degree", {}) self.negation_words: set = set(self.emotion_dict.get("negation", [])) self.punctuation_regex = re.compile(r"[\s。,!?!?,.;;、~~\-—]") # 预热分词器,避免首次请求耗时过高 jieba.lcut("初始化分词器") @staticmethod def _load_json(path: str): with open(path, "r", encoding="utf-8") as f: return json.load(f) def clean_text(self, text: str) -> str: text = self.punctuation_regex.sub("", text) return text.strip() def segment(self, text: str) -> List[str]: return list(jieba.cut(text)) def _local_weight(self, words: List[str], index: int, window: int = 2): weight = 1.0 negated = False start = max(0, index - window) for j in range(index - 1, start - 1, -1): w = words[j] if w in self.degree_words: weight *= self.degree_words[w] elif w in self.negation_words: negated = not negated return weight, negated def analyze_emotion(self, text: str): cleaned = self.clean_text(text) words = self.segment(cleaned) score = 0.0 hit_words = [] for i, word in enumerate(words): if word in self.positive_words: weight, negated = self._local_weight(words, i) delta = weight if not negated else -weight score += delta hit_words.append(word) elif word in self.negative_words: weight, negated = self._local_weight(words, i) delta = -weight if not negated else weight score += delta hit_words.append(word) score = max(-3.0, min(3.0, score)) if score > 0.2: emotion = "positive" elif score < -0.2: emotion = "negative" else: emotion = "neutral" confidence = min(0.95, 0.5 + abs(score) * 0.15) return { "emotion": emotion, "emotion_score": round(score, 4), "emotion_confidence": round(confidence, 4), "emotion_hit_words": hit_words, } def analyze_intention(self, text: str): cleaned = self.clean_text(text) results = [] for intent_name, config in self.intention_rules.items(): hit = [kw for kw in config["keywords"] if kw in cleaned] if hit: results.append({ "intention": intent_name, "hit_count": len(hit), "priority": config.get("priority", 0), "keywords": hit, }) if not results: return { "intention": "other", "confidence": 0.1, "matched_keywords": [], } results.sort(key=lambda x: (-x["priority"], -x["hit_count"])) top = results[0] confidence = min(0.9, 0.5 + 0.1 * top["hit_count"]) return { "intention": top["intention"], "confidence": round(confidence, 4), "matched_keywords": top["keywords"], } def analyze(self, text: str): emotion_result = self.analyze_emotion(text) intention_result = self.analyze_intention(text) return { "input_text": text, **emotion_result, **intention_result, }这里有两个关键方法要重点解释。
_local_weight方法负责处理否定词和程度副词。它从当前情感词的位置往前最多看两个词,倒序遍历,遇到程度副词就累乘权重,遇到否定词就翻转一次符号。这种窗口机制适合短句,能覆盖大多数“不太好”“非常差”这类结构,但不适合处理很长的多重否定句。
analyze_emotion里的置信度公式是简单启发式:情绪越极端,置信度越高。0.5 + abs(score) * 0.15表示基础置信度 0.5,每偏离中性 1 分增加 0.15,上限 0.95。
3.3 意图识别:关键词命中、优先级和置信度
意图识别的实现比情绪识别更简单,本质是对原文做关键词子串匹配。因为“太差”这种短语在分词后会被拆开,而子串匹配可以完整命中。
多个意图同时命中时,按priority降序、命中次数降序排序。例如“这个功能太差了,建议优化”同时命中complaint的“太差”和suggestion的“建议”,因为complaint的 priority 是 10,suggestion是 6,最终输出complaint。
置信度公式是0.5 + 0.1 * hit_count,上限 0.9。这样设计是为了让“单一关键词命中”不至于给出过高的置信度,至少留出优化空间。
3.4 把两类结果组装成统一返回结构
返回结构里同时包含原始文本、情绪信息、意图信息。这样一个前端或下游服务只需要调用一次接口,就能拿到完整分析结果。字段设计如下:
| 字段 | 类型 | 含义 |
|---|---|---|
| input_text | string | 原始输入文本 |
| emotion | string | positive / negative / neutral |
| emotion_score | float | 情绪总分,范围约 -3 到 3 |
| emotion_confidence | float | 情绪置信度 |
| emotion_hit_words | array | 命中的情绪词 |
| intention | string | 意图标签 |
| confidence | float | 意图置信度 |
| matched_keywords | array | 命中的意图关键词 |
这种结构的好处是:调试时可以直接看emotion_hit_words和matched_keywords,知道结果是由哪些词触发的。
4. 用FastAPI暴露分析接口:单条与批量的服务结构
4.1 搭建FastAPI应用和请求校验模型
main.py里做三件事:加载分析器、定义请求和响应模型、暴露接口。为了让服务不依赖当前工作目录,这里用Path(__file__).resolve().parent构造词典路径。
import logging import time from pathlib import Path from typing import List from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field from analyzer import SakuraAnalyzer logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("sakura") BASE_DIR = Path(__file__).resolve().parent analyzer = SakuraAnalyzer( str(BASE_DIR / "data" / "emotion_dict.json"), str(BASE_DIR / "data" / "intention_rules.json"), ) app = FastAPI(title="sakura在想什么呢", version="0.1.0") class AnalyzeRequest(BaseModel): text: str = Field(..., min_length=1, max_length=500, description="待分析文本") class AnalyzeResponse(BaseModel): input_text: str emotion: str emotion_score: float emotion_confidence: float emotion_hit_words: List[str] intention: str confidence: float matched_keywords: List[str] class BatchAnalyzeRequest(BaseModel): texts: List[str]初始化分析器放在模块顶层,是为了让服务启动时只加载一次规则和分词器,而不是每个请求都重复加载。jieba.lcut("初始化分词器")这行预热代码能明显减少第一次请求的耗时。
4.2 单条分析接口
单条接口接收 JSON 请求体,返回AnalyzeResponse。为了观察线上效果,在接口里记录耗时和输入文本。
@app.post("/analyze", response_model=AnalyzeResponse) def analyze_single(req: AnalyzeRequest): start = time.time() result = analyzer.analyze(req.text) cost_ms = (time.time() - start) * 1000 logger.info("analyze text=%s cost=%.2fms", req.text, cost_ms) return result这里不手动做异常捕获,因为通用异常会交给 FastAPI 返回 500,参数错误会返回 422。生产环境再加入业务告警即可。
4.3 批量分析接口和请求日志
批量接口用于分析多条文本,比如一次性处理一批问卷反馈。批量条数限制在 100 条以内,避免单次请求占用过多 CPU。
@app.post("/analyze/batch") def analyze_batch(req: BatchAnalyzeRequest): if not req.texts: raise HTTPException(status_code=400, detail="texts 不能为空") if len(req.texts) > 100: raise HTTPException(status_code=400, detail="单次最多分析 100 条") results = [] for text in req.texts: if not text.strip(): raise HTTPException(status_code=400, detail="批量文本中不能包含空字符串") results.append(analyzer.analyze(text)) logger.info("batch analyze count=%d cost=%.2fms", len(req.texts), time.time() * 1000) return {"total": len(results), "results": results}批量接口的返回值不直接绑定AnalyzeResponse,因为里面需要包一层total字段。这样调用方可以根据total判断是否丢数据。
5. 运行验证:启动服务、curl调用与bad case复盘
5.1 安装依赖并启动本地服务
在项目根目录执行:
cd sakura-thinking pip install -r requirements.txt uvicorn main:app --reload --port 8000正常情况下可以看到类似输出:
INFO: Uvicorn running on http://127.0.0.1:8000--reload参数适合开发环境,改代码后服务会自动重启。生产环境不要加--reload。
5.2 用curl验证三类典型文本
打开一个新终端,执行下面的请求。
第一类,中性询问:
curl -s -X POST http://127.0.0.1:8000/analyze \ -H "Content-Type: application/json" \ -d '{"text": "这个功能怎么用啊?"}'预期响应:
{ "input_text": "这个功能怎么用啊?", "emotion": "neutral", "emotion_score": 0.0, "emotion_confidence": 0.5, "emotion_hit_words": [], "intention": "inquiry", "confidence": 0.6, "matched_keywords": ["怎么"] }第二类,负面抱怨:
curl -s -X POST http://127.0.0.1:8000/analyze \ -H "Content-Type: application/json" \ -d '{"text": "网络太差了,一直崩溃"}'预期响应:
{ "input_text": "网络太差了,一直崩溃", "emotion": "negative", "emotion_score": -2.3, "emotion_confidence": 0.845, "emotion_hit_words": ["差", "崩溃"], "intention": "complaint", "confidence": 0.6, "matched_keywords": ["崩溃"] }注意这里情绪命中词是“差”和“崩溃”,而不是“太差”,因为 jieba 把“太差了”切成了“太”“差”“了”。意图识别用子串匹配,所以能命中“崩溃”。“太差”如果出现在文本中,也会被意图规则捕获。
第三类,正向表扬:
curl -s -X POST http://127.0.0.1:8000/analyze \ -H "Content-Type: application/json" \ -d '{"text": "你们这个工具很好用,我非常满意"}'预期响应:
{ "input_text": "你们这个工具很好用,我非常满意", "emotion": "positive", "emotion_score": 2.7, "emotion_confidence": 0.905, "emotion_hit_words": ["好用", "满意"], "intention": "praise", "confidence": 0.7, "matched_keywords": ["好用", "满意"] }“好用”前面有“很”,程度权重 1.2;“满意”前面有“非常”,程度权重 1.5,所以总分为 2.7。这说明程度副词机制生效了。
5.3 从bad case反推规则缺陷
规则系统一定会遇到 bad case,关键是能快速定位原因。举个例子,如果输入“这个方案不是不行”:
- jieba 切分可能得到“这个/方案/不是/不行”。
- 意图规则中“不行”属于 complaint,所以会输出“抱怨”。
- 但这句话的真实语义是“这个方案是可以的”。
出现这种情况有两个原因。第一,意图规则没有处理否定词;第二,业务中没有为“否定+负面词”的双重否定场景定义规则。解决方法是把这类句式加入正则例外,或者在业务对接层面接受这个误判率,等数据量大了再交给模型处理。
排查 bad case 时,不要只盯着最终标签,要看中间结果:emotion_hit_words命中了什么,matched_keywords命中了什么。这两个字段就是整个系统的可解释性。
6. 调优:词典粒度、否定词窗口、规则优先级与置信度
6.1 情感词典的粒度要与分词结果对齐
“太差”在情感词典里是负面词,但 jieba 分词成“太”和“差”,所以“太差”作为词典项几乎不会被命中。这解释了为什么情感词典里要放“差”,而不是只放“太差”。
一个实用的调优流程是:
- 收集一批代表业务场景的真实句子。
- 执行
list(jieba.cut(sentence)),把分词结果打印出来。 - 观察哪些词反复出现且明显有情绪倾向,把它们加入词典。
- 观察多字短语和分词的差异,用短语匹配补充。
这样比凭感觉堆词典词更高效。
6.2 否定词和程度副词的上下文窗口
_local_weight的窗口默认是 2,也就是只看情感词前面的两个词。这个值不是越大越好。
窗口太大会导致远距离否定词误伤,例如“我今天早上没有吃饭,但是很开心”中的“没有”离“开心”太远,不应该影响情绪。窗口太小的缺点是无法处理“不是很满意”这种连续结构,因为“很”和“不”都在窗口内,窗口至少要为 2。
建议先保留window=2,然后结合自己的语料统计。如果发现三重否定或长距离否定经常出现,就需要换思路:先做否定范围识别,再计算情绪分。
6.3 意图关键词需要避免互相覆盖
当前规则中suggestion包含“如果”,inquiry包含“怎么”。句子“如果不知道怎么操作,你会怎么处理”会同时命中多个意图。虽然priority可以排序,但如果业务上希望这类句子归为“询问”,就要调整关键词或优先级。
以下是几个常见冲突和调整建议:
| 冲突场景 | 当前配置 | 可能误判 | 调整方向 |
|---|---|---|---|
| “能不能”同时是询问还是建议 | suggestion 命中 | 可能忽略 inquiry | 优先保留 suggestion,或根据上下文切换 |
| “怎么”在提问中也可能出现在抱怨 | inquiry 优先级低 | 抱怨被判为询问 | 观察数据分布后调 priority |
| “不行”可能是否定反馈 | complaint 命中 | 双重否定误判 | 引入否定词前置例外规则 |
| “建议”在商务文本中可能是名词 | suggestion 命中 | 误判 | 维护领域停用词或白名单 |
调优的核心不是设计一套万能规则,而是建立 bad case 集,每次改规则都跑一遍回归,确保不会修一个新问题拆掉一个旧功能。
6.4 阈值与置信度的调节方式
当前情绪判定阈值是 0.2,也就是说分数大于 0.2 判为正,小于 -0.2 判为负,之间判为中性。这个值可以调:
- 调大阈值,会有更多句子被判为中性,减少误判,但会漏掉一些弱情绪表达。
- 调小阈值,能捕捉更多弱情绪,但中性噪声会变多。
置信度公式同样可以调整。如果业务对“高置信度”有更严格的要求,可以把公式改成0.5 + abs(score) * 0.2,上限保持不变;更合理的方式是准备一小批标注样本,统计分数和真实标签的关系,拟合一个更合适的映射函数。
7. 常见问题与排查:从分词不一致到请求报错
7.1 先按这个顺序排查,能省一半时间
遇到问题不要直接怀疑代码,按顺序检查:
- 输入是否正确:请求体字段名、Content-Type、中文字符编码。
- 文件路径是否正确:启动命令所在目录、JSON 文件是否存在。
- 词典和规则文件是否加载:打印
len(analyzer.positive_words)等字段。 - 分词结果是否符合预期:把
jieba.lcut的结果打印出来。 - 匹配逻辑是否正确:确认是子串匹配还是分词匹配。
- 服务是否加载了最新规则:
--reload是否生效,必要时手动重启。 - 端口和进程是否正常:8000 是否被占用。
- 日志里有哪条明确异常。
这个顺序的核心逻辑是:先排除输入问题和环境问题,再排查逻辑问题。
7.2 高频问题速查表
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 所有文本都返回中性情绪 | 情感词典没加载成功,或情感词与分词不一致 | 打印positive_words、negative_words长度,打印分词结果 | 检查 JSON 路径和 UTF-8 编码,扩充与分词对齐的词 |
| “太差了”识别为抱怨但情绪为 neutral | “太差”没有被分词命中,可能只命中了“差”但词典缺少“差” | 打印emotion_hit_words | 在负向词典中加入“差”,确认程度副词“太”生效 |
| 多字关键词始终不命中 | 词典里的词是 jieba 切不开的长词 | 打印jieba.lcut结果 | 改用子串匹配,或把短语放入意图规则 |
| 批量接口返回 400 | texts 为空或包含空字符串 | 检查请求体 | 增加字段校验,前端过滤空行 |
| 接口返回 422 | 字段名写错或 JSON 格式不对 | 查看 FastAPI 返回的错误详情 | 对照AnalyzeRequest字段名修正 |
| 端口被占用 | 上一次服务未退出 | lsof -i:8000或 `netstat -ano | findstr 8000` |
| 首次请求特别慢 | jieba 首次加载词典耗时较高 | 看日志时间戳 | 初始化时执行一次“预热”切分 |
7.3 规则类问题如何定位:打印中间结果
如果结果不符合预期,最直接的方法是在接口外写一段临时脚本,直接调用SakuraAnalyzer的中间方法:
from analyzer import SakuraAnalyzer analyzer = SakuraAnalyzer( "data/emotion_dict.json", "data/intention_rules.json", ) text = "这个方案不是不行" print("clean:", analyzer.clean_text(text)) print("words:", analyzer.segment(text)) print("emotion:", analyzer.analyze_emotion(text)) print("intention:", analyzer.analyze_intention(text))这样能一次性看到清洗后文本、分词结果、情绪命中词和意图命中词,规则缺陷会变得非常直观。相比直接调用 HTTP 接口,这种方式更适合调试。
8. 从学习项目到生产:模型化、监控与交付清单
8.1 学习环境能跑通,生产环境还需要补什么
学习环境只要服务能启动、接口能返回结果就行。生产环境还需要补齐下面这些能力:
- 配置外置化。词典路径、规则路径、服务端口、日志级别不能硬编码,要用环境变量或配置文件控制。
- 日志结构化。不要只打印一行文本,建议输出 JSON 日志,包含
text_hash、cost_ms、emotion、intention等字段。 - 结果监控。统计每类情绪和意图的数量占比、平均耗时、batch 大小,用指标暴露给监控系统。
- 规则版本管理。JSON 规则文件纳入 Git,版本号要随着规则更新而递增,方便回滚。
- 高频词缓存。如果同一个文本会被重复分析,可以在 Redis 或本地内存里做一层缓存,减少重复计算。
- 异常兜底。不要让分析器抛异常直接击穿接口,可以记录日志后返回一个默认的
other结果。
8.2 扩展方向:槽位抽取、多轮上下文与模型化
“sakura在想什么呢”目前只能输出情绪和意图。实际产品里往往还需要更多信息。
槽位抽取是第一个扩展方向。例如用户问“导出功能在哪里”,意图是询问,但“导出功能”是用户要问的对象。可以用正则或序列标注模型把关键实体抽取出来,直接跳到对应文档或操作入口。
多轮上下文是第二个方向。用户在前一轮问了“导出功能怎么用”,下一轮说“那删除功能呢”,这里的“那删除功能呢”只有结合上文才能判断意图。这时候需要为每个会话维护一个上下文状态。
模型化是第三个方向。词典规则在冷启动阶段好使,但泛化能力有限。当积累了几千条带标签数据后,可以用 BERT 类模型做文本分类,输出语情绪和意图。同时保留规则结果作为兜底,当模型置信度低时回退到规则。
无论是规则还是模型,输出结构都不要变。这样下游系统不用关心上游是怎么算出来的,只需要消费统一的 JSON 结构。
8.3 可复用的交付检查清单
把这个项目从本地脚本升级成可交付服务前,可以按下面这份清单做检查:
- [ ]
requirements.txt已固定实际安装版本 - [ ] 数据目录放在项目内部,使用基于
Path(__file__)的绝对路径 - [ ] 情感词典是 UTF-8 编码,能覆盖 positive / negative / neutral 三类验证样本
- [ ] 意图规则覆盖 praise / complaint / thanks / suggestion / inquiry 五类验证样本
- [ ] 单条接口和批量接口都做过 curl 验证
- [ ] 至少准备了 10 条 bad case 作为回归测试集
- [ ] 请求日志记录了输入文本、耗时和结果,且不包含用户敏感信息
- [ ] 服务启动无 warning,首次请求耗时在可接受范围
- [ ] 配置文件没有硬编码绝对路径和密钥
- [ ] README 写清楚了启动命令和数据目录结构
如果后续团队已经有标注数据和 GPU 资源,可以把规则结果作为 baseline,再用 ERNIE 或 BERT 模型做意图分类。这样既保留了可解释的兜底逻辑,又能用模型提高泛化能力。当前这个项目的最小闭环,就是先把情绪和意图两类信号输出稳定,至于“sakura在想什么”这个问题能回答到什么程度,取决于词典覆盖率、规则质量和后续模型迭代。