用Rasa构建本地化医疗问答机器人:隐私、可控与多轮对话实践
2026/9/24 19:33:34 网站建设 项目流程

简介:基于Rasa框架的智能医疗机器人项目,面向毕业设计、课程设计与Python进阶开发者,涵盖医药问答、智能问药、疾病诊断、病症查询、症状查询、语音对话、闲聊等核心功能,可直接运行并二次扩展。压缩包共141个文件,以Python脚本、Rasa配置文件、文档说明与数据库文件为主:py文件封装业务逻辑和对话流程,yml文件定义NLU与故事规则,txt文档提供配置与部署说明,另有备份文件与资源文件,整体约100.72MB,目录结构清晰。目前已有41人学习下载。项目集成了知识图谱、Neo4j图数据库、语音识别与合成、天气查询等开放API,配有开发文档、环境配置说明和技术架构图,便于理解从意图识别到答案生成的完整链路;源码经过严格测试,可放心参考扩展,适合作为毕业设计起点或在此基础上增加新业务模块。

1. 医疗问答用 Rasa 而不是 ChatGPT:本地化、隐私与可控性才是刚需

医疗场景的问答机器人,和通用闲聊机器人有个本质区别:它不能答错,也不能信口开河。直接调大模型 API 方案看起来很省事,但医院或药企的私有化部署、患者数据的隐私合规、以及回答内容的可追溯性,都是通用大模型没法直接满足的。Rasa 作为目前最成熟的开源对话管理框架,把意图识别、实体抽取、对话状态管理和自定义 action 完整地串成了一条可落地的链路,尤其在医疗这种需要把“问症状→查药典→给建议”做成固定流程的场景,Rasa 的 rules 和 stories 机制比任何模型微调方案都要稳定可靠。

这个项目标题横跨了医药问答、智能问药、疾病诊断、症状查询和语音对话五大块,每一块在 Rasa 里都有对应的落地方式:NLU 负责理解患者说了什么,action server 负责去数据源查药典和疾病表,语音模块在前后两端做 ASR/TTS 转换。这篇笔记我会按真实项目推进的节奏来写,先搭骨架再填业务,最后把最容易翻车的几个坑逐条拆开。

2. 搭建最小骨架:从 pip 安装到跑通第一个对话

2.1 环境准备:Python 虚拟环境与 Rasa 版本选择

Rasa 3.x 对 Python 版本要求比较严格,3.8 到 3.10 是最稳的选择。Python 3.11 虽然在很多新项目里已经是默认版本,但 Rasa 的依赖树里有几个包(比如 tensorflow 相关的组件)在 3.11 下会出现莫名奇妙的编译错误,这是不少人在环境配置阶段就被劝退的第一道坎。我建议直接用 venv 建独立环境,不要用 conda,因为 Rasa 的依赖和 conda 的包管理偶尔会打架。

# 创建并激活虚拟环境 python -m venv rasa_medical_env source rasa_medical_env/bin/activate # 安装 Rasa 3.6 版本 pip install rasa==3.6.2 # 验证安装 rasa --version

这段命令里有个容易被忽略的点:python -m venv创建的环境,后续所有 rasa 命令都必须在这个激活的环境里执行,否则会出现“command not found”或者调用了全局旧版本的情况。pip install rasa==3.6.2指定版本是刻意为之的,因为 Rasa 3.7 以后把rasa init生成的默认项目结构做了调整,很多老教程的目录结构对不上。

如果你打算用 GPU 训练 NLU 模型,还需要单独装 tensorflow 的 GPU 版本,但说实话,对于医疗问答这种单轮意图识别任务,CPU 训练已经完全够用。Rasa 的 DIETClassifier 模型在中文数据上收敛得很快,几千条样本的训练时间通常在十分钟级别,没必要为了这个引入 GPU 运维的额外负担。

2.2 初始化项目结构与数据目录规划

跑通第一个对话前,先要理解 Rasa 项目的目录结构。rasa init会生成一个标准骨架,但我会在此基础上做调整,因为医疗问答的核心业务逻辑都在自定义 action server 里,和默认的 mood_bot 示例差距很大。

# 初始化项目(使用 --no-prompt 跳过交互式问题) rasa init --no-prompt # 查看生成的目录结构 tree -L 2

初始化之后的目录应该是data/存放 NLU 训练数据和 stories,domain.yml定义意图、实体、slot 和响应模板,actions/放自定义 action 的 Python 代码,config.yml控制 NLU pipeline 和对话策略。Rasa 默认生成的nlu.yml里全是“happy”“sad”这类情绪语料,对医疗项目没有任何参考价值,后续要把它们清空重写。

在动手写业务之前,还要做一件事:把stories.yml的初始示例删掉,只保留最基本的rules.yml框架。医疗对话的特殊性在于,同一个意图在不同上下文下可能触发完全不同的 action——比如“头痛”出现在“我头痛”和“我头痛三天了”里,实体抽取结果一样,但走的业务流程不同,这部分逻辑需要用 stories 来管理。

2.3 domain.yml 定义:医疗意图、实体与 slot 设计

domain 文件是整个对话系统的“合同”,NLU 训出来的意图和实体、对话管理用的 slot、action 返回的响应全部在这里声明。写完 domain 之后直接跑rasa validate能查出大半拼写问题,这是我在每个项目里都会养成的习惯。

# domain.yml 核心片段 version: "3.1" intents: - ask_medicine_info - ask_disease_info - ask_symptom_query - ask_drug_recommendation - ask_voice_start entities: - medicine_name - disease_name - symptom_name - duration_time slots: medicine_name: type: text mappings: - type: from_entity entity: medicine_name disease_name: type: text mappings: - type: from_entity entity: disease_name duration_time: type: text mappings: - type: from_entity entity: duration_time actions: - action_query_medicine_info - action_query_disease_info - action_recommend_drug

这里有一个关键设计:slots的类型用了text而不是categorical。原因是医疗实体太开放了,药品名有两万多种,不可能像订餐机器人那样枚举选项。from_entitymapping 的意思是,只要 NLU 抽到了对应实体,就自动填充 slot,不需要对话管理显式干预。这个配置对后续的 action 逻辑很重要,因为 action 读 slot 时不需要判断“slot 有没有被设置”的问题。

domain 里还刻意留了个ask_voice_start意图,这是为最后的语音对话预留的入口。语音场景下用户说的话往往是“开始问药”“我嗓子疼”,语句更短更口语化,单独开一个意图来收集这类语料,能显著提升语音入口的识别准确率。

2.4 写 NLU 和 Stories:用少量中文医疗语料跑通训练

初始化项目的最后一步是替换训练数据。医疗场景的 NLU 语料有一个特点:宁可每个意图只有 30 条高质量数据,也不要拼凑 200 条半吊子数据。因为 Rasa 的 DIETClassifier 对数据噪声很敏感,一条“布洛芬混悬液”被标注成medicine_name还是disease_name,直接影响实体抽取的边界。

# data/nlu.yml 核心意图语料 nlu: - intent: ask_medicine_info examples: | - [布洛芬缓释胶囊](medicine_name)是什么药 - 给我讲一下[阿莫西林](medicine_name)的用法 - [蒙脱石散](medicine_name)有什么用 - [氯雷他定](medicine_name)的副作用有哪些 - intent: ask_symptom_query examples: | - [头痛](symptom_name)持续两三天了怎么办 - 我[发热](symptom_name)到三十八度五 - [干咳](symptom_name)晚上特别严重 - 最近总是[胸闷](symptom_name)怎么回事 - intent: ask_drug_recommendation examples: | - [感冒](disease_name)吃什么药 - 推荐一下[高血压](disease_name)的常用药 - [过敏](disease_name)应该吃什么

写完nlu.yml之后要同步更新stories.yml。医疗问答大多数是单轮对话,不需要复杂的状态转移,所以 stories 可以写得很薄,但要预留后续多轮追问的路径。比如用户在问“头痛怎么办”之后,机器人应该主动追问“持续多久了”,这个追问逻辑在 stories 里体现为一个两步序列,这样对话树的扩展才有基础。

# data/stories.yml stories: - story: 症状查询带时长追问 steps: - intent: ask_symptom_query entities: - symptom_name: 头痛 slot_was_set: - symptom_name: 头痛 action: action_query_disease_info action: utter_ask_duration - story: 直接问药 steps: - intent: ask_medicine_info action: action_query_medicine_info

训练命令rasa train跑完之后,rasa shell可以直接在终端里对话测试。一个常见的问题是实体标注了但训练完不识别,这时候优先检查config.yml里有没有配实体抽取组件——从 Rasa 3.1 开始,DIETClassifier是实体抽取的主力,如果 pipeline 里没有它,那你在nlu.yml里标了再多实体也白搭。

3. 把药典和症状库接进 action server:医药问答的真正实现

3.1 数据源选型:JSON 文件、SQLite 还是知识图谱

很多参考项目把医药数据存成 JSON 文件,但真实业务里这种方案撑不住。药品说明书的结构非常复杂——适应症、禁忌症、相互作用、用法用量,每个字段都可能是一大段文本,JSON 存这种半结构化数据虽然读写简单,但检索效率低、更新困难。SQLite 是更务实的选择:单文件部署、支持 SQL 查询、事务保证数据一致性,而且 Python 标准库自带sqlite3模块,不需要额外引入 ORM。

# 建库并导入药品数据的核心表结构 sqlite3 medical.db <<EOF CREATE TABLE medicines ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, generic_name TEXT, indications TEXT, dosage TEXT, side_effects TEXT, interactions TEXT, contraindications TEXT ); CREATE TABLE diseases ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, symptoms TEXT, description TEXT, advice TEXT, risk_level TEXT );

这张表的设计有几个细节值得推敲。medicines表的name字段存的是药品商品名,generic_name存通用名,这两者在查询时必须做模糊匹配,因为用户说“芬必得”和“布洛芬缓释胶囊”指的是同一个药。diseases表的symptoms字段用逗号分隔的文本存储,虽然不满足数据库第三范式,但对查询场景来说查询简单性比范式更重要——反正我们不做症状维度的复杂统计。

如果你要处理的中文药品数据量特别大,可以考虑在medicines.name上加全文索引,SQLite 自带FTS5扩展,支持中文全文检索。不过这个优化可以等数据量超过一万条再做,前期 50 个常用药的规模,直接用 LIKE 查询就够了。

3.2 action server 的代码骨架:封装查询函数与返回格式

Rasa 的 action server 是独立于 Rasa Core 进程的 HTTP 服务,所有业务逻辑都放在actions/actions.py里。一个标准的 action 类需要继承Action基类,实现name()run()两个方法。run()方法的签名包含 dispatch 相关的跟踪器和对话追踪器,但真正干活的是从跟踪器里抽 slot 值去查数据库。

# actions/actions.py 核心部分 import sqlite3 from typing import Any, Text, Dict, List from rasa_sdk import Action, Tracker from rasa_sdk.executor import CollectingDispatcher DB_PATH = "medical.db" def query_medicine_by_name(medicine_name: str) -> Dict[str, str] | None: """按药品名或通用名查询""" conn = sqlite3.connect(DB_PATH) cur = conn.cursor() # 先用精确匹配,再用 LIKE 做包含匹配 cur.execute( "SELECT * FROM medicines WHERE name = ? OR generic_name = ?", (medicine_name, medicine_name) ) row = cur.fetchone() conn.close() if not row: return None columns = ["id", "name", "generic_name", "indications", "dosage", "side_effects", "interactions", "contraindications"] return dict(zip(columns, row)) class ActionQueryMedicineInfo(Action): def name(self) -> Text: return "action_query_medicine_info" def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: medicine_name = tracker.get_slot("medicine_name") if not medicine_name: dispatcher.utter_message(text="您想查哪个药品?请告诉我药品名称。") return [] result = query_medicine_by_name(medicine_name) if not result: dispatcher.utter_message(text=f"抱歉,目前数据库里还没有收录「{medicine_name}」的相关信息。") return [] # 拼接返回给用户的文本 reply = ( f"【{result['name']}】\n" f"适应症:{result['indications']}\n" f"用法用量:{result['dosage']}\n" f"副作用:{result['side_effects']}" ) dispatcher.utter_message(text=reply) return []

run()方法返回类型是List[Dict[Text, Any]],如果你不需要修改 slot,直接返回空列表即可。start 事件不是必须的,utter_message已经主动把回复推给了用户。这个实现的关键点在于数据库连接是每次查询新建的,虽然频繁打开关闭连接效率略低,但在医疗问答案的并发量级下完全够用,而且避免了连接池在多线程环境下抢连接的麻烦。

实体查不到的时候一定要给用户一个友好的兜底话术,而不是让 action 抛异常。Rasa 的 error handler 虽然能捕获 action 异常,但会给用户回一句“Oops”之类的英文报错,这对患者来说是极度不友好的。

3.3 智能问药和多轮追问:用 slot 串联上下文

智能问药是标题里最容易做深的功能。用户说“感冒吃什么药”,你的 action 不能直接把所有感冒药列出来,要先追问症状(发热还是咳嗽、有没有痰),再结合禁忌症条件过滤,最后给出 1 到 2 个候选并附带说明。

# 智能问药的多轮追问实现 class ActionRecommendDrug(Action): def name(self) -> Text: return "action_recommend_drug" def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: disease_name = tracker.get_slot("disease_name") symptom_name = tracker.get_slot("symptom_name") duration = tracker.get_slot("duration_time") # 信息不足先追问,而不是硬推药 if not symptom_name and not duration: dispatcher.utter_message(text="为帮您选择更合适的药品,请问您目前最明显的症状是什么?有没有发热或咳嗽?") dispatcher.utter_message(text="也可以告诉我这个症状持续多久了。") return [] # 有症状信息时按医学经验规则筛选 if symptom_name: if symptom_name == "发热" or symptom_name == "高烧": dispatcher.utter_message(text="建议先测量体温。体温超过38.5℃可考虑使用对乙酰氨基酚或布洛芬退热,请务必按说明书剂量服用。") elif symptom_name == "干咳": dispatcher.utter_message(text="干咳无痰可考虑右美沙芬,如有痰则适合氨溴索或乙酰半胱氨酸。建议先明确是干咳还是湿咳。") else: dispatcher.utter_message(text=f"已记录您的症状「{symptom_name}」。建议您先观察,若症状持续不缓解请及时就医。") return []

这个实现里最值得学习的是“追问优于硬答”的思路。好多问答项目把推荐药品做成了一条线上答案,输入症状直接输出药名,这在医疗场景非常危险——没问清是干咳还是湿咳就推荐药,完全可能给错。Rasa 的事件系统天然支持这种多轮追问,只要在 action 里不直接返回结果而是utter_message追问,Core 就会等待用户下一轮输入。

为了存储多轮追问中的临时信息,domain 里还要加一个叫query_context的 text slot,用来标记当前对话进行到哪一步。比如用户回答“有点咳嗽”之后,action_recommend_drug需要知道上一轮问的是“干咳还是湿咳”,这种跨轮状态管理用 slot 来做最自然。

3.4 疾病诊断和病症查询:规则引擎的轻量实现

这里要澄清一个概念:标题里的“疾病诊断”不是真的要做医学诊断,那是需要临床医生执业资质的决策,我们只能做“基于症状特征匹配的可能性筛查与就医建议”。这块功能我用了一个轻量规则引擎,本质上就是一张映射表:症状组合 -> 疑似疾病 -> 风险等级。

# 症状-疾病映射表与匹配函数 SYMPTOM_DISEASE_RULES = { ("发热", "干咳", "乏力"): ("普通感冒", "低风险"), ("发热", "干咳", "乏力", "呼吸困难"): ("肺部感染", "中高风险"), ("头痛", "恶心", "喷射性呕吐"): ("颅内压异常", "高风险"), ("关节痛", "晨僵", "对称性"): ("类风湿关节炎", "中风险"), } def match_disease(symptoms: List[str]) -> List[Dict[str, str]]: """根据用户报告的症状集合匹配疑似疾病""" matched = [] for symptom_set, (disease_name, risk_level) in SYMPTOM_DISEASE_RULES.items(): # 计算用户症状与规则症状的重合度 overlap = len(set(symptoms).intersection(set(symptom_set))) if overlap >= 2: # 至少两个症状命中才触发 matched.append({ "disease": disease_name, "risk": risk_level, "coverage": round(overlap / len(symptom_set), 2), }) # 按覆盖率排序 matched.sort(key=lambda x: x["coverage"], reverse=True) return matched

我把这个匹配逻辑放在action_query_disease_info里,action 先收集用户已经说出的所有症状实体,组装成一个列表,再调用match_disease函数。仔细看overlap >= 2这个阈值:这是刻意设的门槛,避免单个症状命中过多疾病造成虚惊。比如“头痛”一个症状能匹配几十种疾病,但如果同时有“头痛+恶心”,指向性就明确多了。

在症状实体转疾病匹配的中间层,还必须做一次同义词归一化,否则用户说“脑袋疼”,而规则表里存的是“头痛”,匹配直接失败。这个归一化在 NLU 的synonyms配置里就能做:

# domain.yml 里的实体同义词配置 entities: - symptom_name # config.yml 的 pipeline 里加 RegexFeaturizer + DIETClassifier pipeline: - name: WhitespaceTokenizer - name: RegexFeaturizer - name: LexicalSyntacticFeaturizer - name: CountVectorsFeaturizer - name: CountVectorsFeaturizer analyzer: char_wb min_ngram: 1 max_ngram: 4 - name: DIETClassifier epochs: 100 - name: EntitySynonymMapper

EntitySynonymMapper组件的作用就是把脑袋疼头痛在实体抽取后映射到同一个标准化实体值头痛,这样 action 里拿到的symptom_name永远是一致的,不用在业务代码里做二次兜底。这是整个医疗问答链路里最容易忽略但价值最高的配置之一。

4. 意图识别与实体抽取的调优:中文医疗场景的特殊处理

4.1 加入中文同义词库与自定义词表

Rasa 的默认 tokenizer 对中文是按空格切词的,但中文句子根本没有空格。所以第一步必须换 tokenizer,这个问题网上讨论很多——JiebaTokenizer是最常用的方案,配合自定义词典,能让“布洛芬缓释胶囊”这种专业药品名不被拆成“布洛芬”“缓释”“胶囊”三个 token,实体抽取的准确率会大幅提升。

# config.yml 的完整 pipeline language: zh pipeline: - name: JiebaTokenizer dictionary_path: "data/jieba_dict.txt" - name: RegexFeaturizer - name: LexicalSyntacticFeaturizer - name: CountVectorsFeaturizer - name: CountVectorsFeaturizer analyzer: char_wb min_ngram: 1 max_ngram: 4 - name: DIETClassifier epochs: 100 constrain_similarities: true - name: EntitySynonymMapper policies: - name: MemoizationPolicy - name: RulePolicy - name: TEDPolicy max_history: 5 epochs: 100

data/jieba_dict.txt是一个纯文本文件,每行一个词,把药品名、疾病名、症状名都塞进去:

布洛芬缓释胶囊 对乙酰氨基酚 上呼吸道感染 类风湿关节炎 高血压 糖尿病

JiebaTokenizer 在加载时会把这个自定义词典合并进默认词典,分词优先级高于默认词表。这里有个容易踩的坑:词典文件路径是相对路径还是绝对路径,在 Rasa 3.x 里建议用相对路径,因为 action server 的工作目录和训练时的工作目录可能不一致。

4.2 处理模糊表述:从用户角度设计语料而不是从数据库角度

在收集医疗问答语料时最常见的错误是“数据太干净了”。比如用户不会说“查询一下布洛芬缓释胶囊的适应症”,而是会说“布洛芬是治啥的”“我发烧吃这个行吗”“这个药管用吗”。NLU 模型的泛化能力很大程度上取决于语料里混合了多少这种口语化、不完整的表达。

我整理一个小技巧:把数据库里的药品名找一个同事,让他不看任何脚本、随口把查询需求说出来,录下来转成文字,直接扔进nlu.yml。这样拿到的语料比你自己绞尽脑汁想半个月还管用。比如我拿到过这些真实语料:

- intent: ask_medicine_info examples: | - [布洛芬](medicine_name)多少钱 - [感冒灵颗粒](medicine_name)小孩能吃吗 - [阿莫西林](medicine_name)吃了头晕咋回事 - [氯雷他定](medicine_name)一天吃几次

这些句子在语法上不完整,有些甚至没有直接点名要查“说明书”,但医生和药剂师一听就懂。Rasa 的 DIETClassifier 在处理这类口语化语料时表现相当不错,前提是训练集里得有足够多的这种“非标准问法”,而不是只有“查询XX说明书”这种句式。

4.3 用 rasa data validate 和交互式学习校准对话流

数据准备得差不多后,rasa data validate这步不能省。它专门检查nlu.ymlstories.ymldomain.yml之间的不一致:比如 stories 里用了一个 domain 里没定义的 intent,或者规则里引用了不存在的 action,这条命令能全部扫出来。

# 校验数据文件的一致性 rasa data validate # 用训练好的模型在终端里对话 rasa shell # 在交互界面里逐步验证意图和实体

最实用的是rasa interactive,可以用对话界面实时测试并实时修正。比如用户说“我嗓子疼”,如果模型把它识别成ask_symptom_query,但你期望的是ask_disease_info,直接在交互界面里把意图纠正过来,并说一句“正确的意图是 xx”,Rasa 会把这次正确的对话追加到 stories 文件里。

这是一个黑匣子慢慢变透明的好办法。不要一个劲在nlu.yml里堆语料却不验证,因为意图识别准确率和语料量不是线性关系,尤其是医疗领域,会出现一个 intent 语料过多、把另一个 intent 的样本整体压过去的问题。

4.4 多实体共存时的抽取策略

医疗问答里有一类特殊难题:一句话里可能出现两个药品实体、或一个药品加一个症状。比如“我吃了布洛芬还吃了阿莫西林,现在胃不舒服”,这句话包含medicine_name=布洛芬medicine_name=阿莫西林symptom_name=胃不舒服三个实体。

默认的 DIETClassifier 对多实体抽取能力是有限的,尤其在同一类型实体连续出现时,很可能只抽到第一个。我的做法是在 pipeline 里增加约束和调优:

# 从 tracker 里捞全部实体,而不只是第一个 slot entities = tracker.latest_message.get("entities", []) medicine_names = [e["value"] for e in entities if e["entity"] == "medicine_name"] symptoms = [e["value"] for e in entities if e["entity"] == "symptom_name"]

这里有个关键点:tracker.get_slot("medicine_name")永远只会返回最后一个被填充的 slot 值,但在医药问答场景中,action 需要拿到“这句话里出现了哪些药”,所以必须直接读latest_message里的entities列表而不是走 slot。如果你的业务实现里同时需要“全部实体”和“当前对话焦点”,就需要在 domain 里定义两个 slot:一个 text 类型存当前焦点,一个 list 类型存本轮全部实体。

5. 避坑指南:医疗对话机器人常见的 5 个翻车点

5.1 实体标注被正则规则污染

现象:NLU 训练完成后,明明标注了布洛芬作为medicine_name,但在测试时布洛芬缓释胶囊中的布洛芬被切成了两个 token,实体抽取失败。

原因:这是 JiebaTokenizer 的一致性问题。训练时 Jieba 词典里可能没有加载布洛芬缓释胶囊这个词,分词结果和预测时不一致,导致实体边界偏移。另一个更隐蔽的原因是 config.yml 里加了RegexFeaturizer但没配好正则特征,正则匹配结果把实体边界搞乱了。

解决:把高频药品全名加入jieba_dict.txt,确保训练和推理用的是同一个词典;同时在nlu.yml里对这类长词单独做标注,让模型见过多种切分方式。再用rasa shell实际验证几个没在训练集里出现过的药品名,看实体抽取是否稳定。

5.2 stories 和 rules 冲突导致对话一直不匹配

现象:用户说什么,机器人都回“抱歉,我没理解”,Fallback 被触发得异常频繁。

原因:我在第 2 章提到过 stories 和 rules 要分开。Rasa 的 RulePolicy 和 MemoizationPolicy 在事件驱动机制下是有优先级的:如果一个意图同时被 stories 和 rules 覆盖,且 stories 要求先触发 action A、rules 要求先触发 action B,整个对话树就乱了。

解决:把稳定的单轮业务(查药品、查疾病)放进rules.yml,把有多轮追问的流程(智能问药、疾病诊断)放进stories.yml,两个文件里的意图尽量不要重叠。如果必须重叠,用priority参数控制——rule 的优先级默认高于 story,手动调整可能会导致意外。

5.3 实体是中文但 slot 是英文,跨模块对不上

现象:NLU 正确抽出了disease_name=高血压,但 action 里拿到的 slot 是空字符串或者None

原因:domain.yml 里 slot 的mappings写错了,比如把from_entity指到了disease而不是disease_name。这类错误高速频发,尤其当你从前一个项目复制 domain 文件改意图名时。

解决:在run()方法里加一行调试日志,直接把tracker.slots_to_validate()tracker.current_state()['slots']打印出来,一眼就能看出 slot 到底填了什么值。修复后把slot_was_set的值也打印一遍,确保实体到 slot 的链路是通的。

5.4 医学兜底话术被当成正常回复

现象:用户问了一个数据库里没有的罕见病,action 返回“抱歉暂无收录”,Rasa 误把这句当作正常回复,继续期待下一轮输入,没有触发任何错误处理。

原因:action 的返回有数据和无数据的路径都成功了,这在业务逻辑上没问题,但从对话体验上太生硬。用户会觉得机器人“不懂装懂”。

解决:在 domain.yml 里给每个 query action 配两个不同的话术模板——utter_no_such_medicineutter_no_such_disease。无数据时返回的文本里加上“请提供更精确的名称或描述”,把这个分支做成可学习的状态,后续用户补充信息后可以继续走原 action。

5.5 长对话性能衰减和内存溢出

现象:对话跑了几轮之后,响应时间从 200ms 涨到 1s 以上,甚至有几次直接报内存错误。

原因:这是 Rasa 的 tracker store 机制引起的。默认内存模式下,每个 session 的 tracker 全部驻留在内存,用户多轮对话后 tracker 里积累了完整对话历史。医疗问答又特别依赖“问症状→追问→再追问”,对话轮数天然比闲聊场景多。

解决:改用 Redis tracker store,把 tracker 状态持久化到外部存储:

# 启动 action server 时指定 Redis tracker store rasa run actions --tracker-store redis --tracker-store-host localhost --tracker-store-port 6379

与此同时,在 domain.yml 的 session_config 里设置session_expiration_time: 60,超过 60 分钟无交互自动清理 session,这个对长会话内存释放非常关键。

6. 语音对话接入与压测:从 demo 到可交付的最后一公里

语音对话是标题里的重头戏之一,也是市面上很多医疗机器人项目里的“玄学”模块——看起来简单,接起来到处是坑。完整的语音链路是三段:ASR(语音转文字)→ Rasa 对话处理 → TTS(文字转语音)。Rasa 本身不包含 ASR/TTS,需要外部接入。我用的是faster-whisper做中文 ASR,TTS 用edge-tts,两个都是离线可部署的方案,不需要担心数据出域的问题。

# 语音入口的简单封装:ASR + Rasa HTTP API + TTS import edge_tts from faster_whisper import WhisperModel # 1. ASR 加载中文模型 model = WhisperModel("small", device="cpu", compute_type="int8") def speech_to_text(audio_path: str) -> str: """语音文件转文字""" segments, info = model.transcribe(audio_path, language="zh") return "".join(segment.text for segment in segments) # 2. 调用 Rasa 的 HTTP 接口获取对话回复 import requests def chat_with_rasa(user_message: str, sender_id: str = "patient_001") -> str: """通过 Rasa HTTP API 发送消息并接收回复""" url = "http://localhost:5005/webhooks/rest/webhook" payload = {"sender": sender_id, "message": user_message} resp = requests.post(url, json=payload, timeout=5) if resp.status_code != 200: return "服务暂时不可用,请稍后再试。" messages = resp.json() return "\n".join(m["text"] for m in messages if "text" in m)

参数说明WhisperModel("small", device="cpu", compute_type="int8")是刻意压过的配置——small 模型的中文识别准确率已经不错,int8量化让 CPU 也能以接近实时的速度推理;需要更高精度就换medium,但响应时间会翻倍。edge-tts是微软的离线语音合成接口库,音色和自然度都够用,而且没有额外费用。

语音模块的坑主要在延迟。ASR 和 TTS 各自都要几百毫秒,叠加 Rasa 推理,总体延迟接近两秒,这在语音对话里是可以接受的——人跟人说话本来就有停顿。但如果某个环节处理不好,延迟会翻倍到四秒以上,用户就会觉得机器人“反应迟钝”。排查办法是把三段延迟单独打点,确定瓶颈在 ASR、Rasa 还是 TTS,再针对性地做缓存或并发优化。

在正式交付前我习惯做一套离线压测:准备了大概 30 条医疗问答测试集,覆盖正常路径、近义表达、混合问法、无结果路径四类,用脚本批量跑并统计准确率指标。这套脚本的意义不在于一次性测完,而是每次改了nlu.yml或 domain 之后重新跑一遍,确认没有回归问题。不夸张地说,这个习惯救了我很多次——如果不是压测脚本发现改了药品同义词之后导致“痛风”被误识别为“通風”,有些 bug 可能到上线前才会被发现。

整个医疗问答项目的最后一公里拼的不是模型调参,而是系统鲁棒性:没收录的药品怎么回答、连续追问怎么保持上下文、ASR 识别错了怎么用 NLU 兜底。这些细节处理好了,你才有底气把对话机器人从 demo 推给真实患者用。

我自己的习惯是,每个功能模块上线前写死一页纸的验证清单,对着清单逐条打钩。写假话推真药这种事在医疗场景一次也不能发生,宁可让机器人口笨一点,也不能让它不负责任地乱说。希望这篇笔记能帮你把 Rasa 医疗问答的全链路走通,也少踩几个我当年踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询