
简介基于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 rasa3.6.2 # 验证安装 rasa --version这段命令里有个容易被忽略的点python -m venv创建的环境后续所有 rasa 命令都必须在这个激活的环境里执行否则会出现“command not found”或者调用了全局旧版本的情况。pip install rasa3.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 训练数据和 storiesdomain.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(textf抱歉目前数据库里还没有收录「{medicine_name}」的相关信息。) return [] # 拼接返回给用户的文本 reply ( f【{result[name]}】\n f适应症{result[indications]}\n f用法用量{result[dosage]}\n f副作用{result[side_effects]} ) dispatcher.utter_message(textreply) 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(textf已记录您的症状「{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(keylambda x: x[coverage], reverseTrue) 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: EntitySynonymMapperEntitySynonymMapper组件的作用就是把脑袋疼和头痛在实体抽取后映射到同一个标准化实体值头痛这样 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: 100data/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.yml、stories.yml和domain.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_medicine和utter_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做中文 ASRTTS 用edge-tts两个都是离线可部署的方案不需要担心数据出域的问题。# 语音入口的简单封装ASR Rasa HTTP API TTS import edge_tts from faster_whisper import WhisperModel # 1. ASR 加载中文模型 model WhisperModel(small, devicecpu, compute_typeint8) def speech_to_text(audio_path: str) - str: 语音文件转文字 segments, info model.transcribe(audio_path, languagezh) 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, jsonpayload, timeout5) if resp.status_code ! 200: return 服务暂时不可用请稍后再试。 messages resp.json() return \n.join(m[text] for m in messages if text in m)参数说明WhisperModel(small, devicecpu, compute_typeint8)是刻意压过的配置——small 模型的中文识别准确率已经不错int8量化让 CPU 也能以接近实时的速度推理需要更高精度就换medium但响应时间会翻倍。edge-tts是微软的离线语音合成接口库音色和自然度都够用而且没有额外费用。语音模块的坑主要在延迟。ASR 和 TTS 各自都要几百毫秒叠加 Rasa 推理总体延迟接近两秒这在语音对话里是可以接受的——人跟人说话本来就有停顿。但如果某个环节处理不好延迟会翻倍到四秒以上用户就会觉得机器人“反应迟钝”。排查办法是把三段延迟单独打点确定瓶颈在 ASR、Rasa 还是 TTS再针对性地做缓存或并发优化。在正式交付前我习惯做一套离线压测准备了大概 30 条医疗问答测试集覆盖正常路径、近义表达、混合问法、无结果路径四类用脚本批量跑并统计准确率指标。这套脚本的意义不在于一次性测完而是每次改了nlu.yml或 domain 之后重新跑一遍确认没有回归问题。不夸张地说这个习惯救了我很多次——如果不是压测脚本发现改了药品同义词之后导致“痛风”被误识别为“通風”有些 bug 可能到上线前才会被发现。整个医疗问答项目的最后一公里拼的不是模型调参而是系统鲁棒性没收录的药品怎么回答、连续追问怎么保持上下文、ASR 识别错了怎么用 NLU 兜底。这些细节处理好了你才有底气把对话机器人从 demo 推给真实患者用。我自己的习惯是每个功能模块上线前写死一页纸的验证清单对着清单逐条打钩。写假话推真药这种事在医疗场景一次也不能发生宁可让机器人口笨一点也不能让它不负责任地乱说。希望这篇笔记能帮你把 Rasa 医疗问答的全链路走通也少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取