ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Rasa的医疗智能问答系统实践:NLU与多轮对话设计

基于Rasa的医疗智能问答系统实践:NLU与多轮对话设计 简介基于Rasa框架打造的智能医疗机器人项目覆盖医药问答、智能问药、疾病诊断、病症查询、症状查询、语音对话等核心功能并融入知识图谱、Neo4j图数据库、语音识别与合成技术及开放API适合作为毕业设计、课程设计或实际项目开发的完整参考。压缩包共118个文件以Python脚本、YAML配置、文本说明、XML数据及音频文件为主同时包含数据库文件、开发文档和环境配置说明整体约100.98MB。项目源码已经严格测试可直接运行并观察各功能模块效果方便在此基础上二次扩展。目前已有264人学习下载配套开发文档、技术架构与数据库设计可帮助读者快速理解Rasa对话流程、意图识别和知识图谱查询逻辑是学习智能医疗助手开发的实用资料。1. 医疗问答比闲聊难在哪Rasa又凭什么接得住药房窗口和导诊台每天被同样的问题轰炸“这个药一天吃几次”“发烧、咳嗽挂哪个科”。这些问题话术千变万化但意图高度固定答案基本来自可检索的知识库。把医药问答、智能问药、疾病诊断、病症查询、症状查询和语音对话放进同一个系统本质是在做两件事把用户的口语转成结构化查询再把查询结果用多轮对话送回去。Rasa在这个场景下的价值在于它的意图识别、实体抽取、规则对话和表单机制全部显式可控对医疗这种强合规、强可解释的业务比端到端生成模型更值得依赖而且整个链路可以私有化部署在 python 环境里不依赖外部服务也能完成验证。适合正在做医疗信息化、智能导诊或健康科普问答的团队参考。2. 医疗NLU建模先让Rasa读得懂药品、症状和疾病2.1 Pipeline组件选型这五个组件就够用了Rasa 3.x 的 NLU 是流水线架构输入文本会按顺序经过每个组件。医疗文本的一个显著特点是实体密度高“阿莫西林克拉维酸钾”是药名“右下腹持续性隐痛”里同时藏着部位、时间和疼痛性质。如果照搬通用闲聊任务的默认配置经常出现意图得分很高、实体边界却错得离谱的情况。我一般会自定义 config.yml只用下面五件套language: zh pipeline: - name: JiebaTokenizer dictionary_path: data/dicts/medical_words.txt - name: RegexFeaturizer - name: CountVectorsFeaturizer - name: CountVectorsFeaturizer analyzer: char_wb min_ngram: 1 max_ngram: 4 - name: DIETClassifier epochs: 200 constrain_similarities: true entity_recognition: true - name: FallbackClassifier threshold: 0.7 ambiguity_threshold: 0.1这个配置有四个关键决策。第一中文分词必须换掉默认 tokenizerJiebaTokenizer 配合自定义词典可以避免“阿莫西林”被切成“阿莫/西林”。第二CountVectorsFeaturizer 的 char_wb 模式按字符 n-gram 计算特征对未登录药品名、错别字和“芬必得”这类品牌名更抗噪声。第三DIETClassifier 同时训练意图分类和实体识别共享 embedding 层医疗语料通常不大这种联合训练比分开训练更容易收敛。第四FallbackClassifier 是最后一道保险置信度低于 0.7 时会被兜底处理避免把“我不知道”硬答成某个疾病。实体标签设计直接影响下游 action 的取值。下面是我在医疗项目里常用的第一版实体类型实体类型含义标注示例medicine药品名含通用名和商品名[布洛芬缓释胶囊](medicine)symptom症状词[持续低烧](symptom)disease疾病名[胃食管反流](disease)body_part身体部位[右下腹](body_part)疼痛duration持续时间疼了[三天](duration)negation否定词单独作为实体[没有](negation)发烧不要试图让模型自己去发现“布洛芬”和“芬必得”是同一个药这类别名关系更适合在后续 action 里维护 lookup 列表或同义词映射。模型只负责把用户说出的词原样抽取出来语义归一化交给业务层职责更清晰。2.2 训练数据不是越多越好而是边界越清晰越好NLU 训练数据是医疗机器人效果的上限。写 nlu.yml 时我习惯按意图组织示例每个意图至少覆盖陈述、疑问、口语省略三种句式- intent: ask_medicine_indication examples: | - 我想知道[布洛芬](medicine)是治什么病的 - [阿莫西林](medicine)的说明书我看看 - 这个[蒙脱石散](medicine)有什么用 - [芬必得](medicine)治不治牙疼 - intent: query_disease_symptom examples: | - [流感](disease)一般有什么症状 - [胃溃疡](disease)会疼吗 - 帮我查一下[高血压](disease)的表现打标时要遵守三条经验法则。第一实体边界要完整“布洛芬缓释胶囊”要么整个标成 medicine要么不标标一半反而会教坏模型。第二否定表达要显式标出 negation 实体并在意图示例里单独出现比如“我没有发烧”要出现在某个意图下而不是所有意图都收集“发烧”。第三同一实体词在不同句式里的位置要打散不要每句话都把药品名放在句首否则模型会学到位置先验真正的实体抽取能力反而没练出来。实际做的时候医疗词表很难一次建全。我的做法是先布置 30 条最典型的问药示例跑通流程然后拿真实用户日志去补每发现一个实体边界错误就加一条对应语料。靠堆量不如靠修正边界这一点在医疗场景里比通用聊天更明显。3. 医药问答与智能问药把知识库接进Rasa的动作链3.1 医药问答的技术路线怎么选同样是“这药治什么病”业界大致有三条路线。端到端生成模型最简单把药品说明书扔给大模型直接生成答案但医疗场景里一本正经地胡说八道是不可接受的回答错了还要承担责任。纯检索式问答把药品说明书切片段后用向量召回效果不错但需要额外维护 embedding 服务和向量库且对“用法用量”这类结构化的信息召回不稳定。我现在最常用的是第三种意图识别加自定义 Action从结构化药品库里直接取字段拼答案。三条路线的取舍可以这样看路线可解释性部署成本答案准确性适合阶段端到端生成低难以定位错误来源低不稳定有幻觉快速原型向量检索 RAG中依赖召回质量中需向量库中受段落切分影响大规模问答Rasa 实体 自定义 Action高逻辑可见可改低标准 Rasa 部署高字段直接映射结构化知识库3.2 用 Rules 把“问药”请求精确路由到 actionRasa 里处理高频且流程固定的请求最可靠的手段是 rules。它在 dialogue 管理里的优先级比其他策略高符合条件就直接触发指定的自定义 action不走模型预测适合医药问答这种答案完全由后端数据决定的任务。domain.yml 里先声明意图、实体、槽位和 actionintents: - ask_medicine_indication - ask_medicine_dosage entities: - medicine slots: medicine: type: text mappings: - type: from_entity entity: medicine actions: - action_query_medicinerules.yml 里把“用户问起药品功效”和“查询药品信息”绑定起来rules: - rule: 患者询问药品功效 steps: - intent: ask_medicine_indication - action: action_query_medicine - rule: 患者询问用法用量 steps: - intent: ask_medicine_dosage - action: action_query_medicine这里有一个容易被忽略的设计点意图只负责分类不负责携带答案模板。“功效”和“用法用量”最终可能查的是同一个药品库只是回答时拼装的字段不同。所以两个规则指向同一个 action由 action 根据 tracker 里记录的意图来决定返回哪些字段。如果每个意图都单独写一套查询逻辑后续维护会非常痛苦。3.3 用自定义 action 拉取药品信息Rasa 的自定义 action 跑在独立的 action server 进程里。这个 action 的核心逻辑是取实体槽位、查药品库、拼答案、把结果通过 dispatcher 发回对话流程。import json from typing import Any, Text, Dict, List from rasa_sdk import Action, Tracker from rasa_sdk.executor import CollectingDispatcher from rasa_sdk.events import SlotSet class ActionQueryMedicine(Action): def name(self) - Text: return action_query_medicine def run( self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[str, Any], ) - List[Dict[str, Any]]: medicine tracker.get_slot(medicine) if not medicine: dispatcher.utter_message(text您想问哪个药品告诉我药名就行。) return [] info self._lookup_medicine(medicine) if info is None: dispatcher.utter_message( textf没有找到“{medicine}”的信息请确认药名是否输入正确。 ) return [SlotSet(medicine, None)] response self._format_response(info) dispatcher.utter_message(textresponse) return [SlotSet(medicine, medicine)] def _lookup_medicine(self, name: str) - Dict[str, Any] | None: with open(data/medicine.json, encodingutf-8) as fp: db json.load(fp) if name in db: return db[name] # 兼容常见的品牌名/通用名匹配 for key, value in db.items(): if name in key or key in name: return value return None def _format_response(self, info: Dict[str, Any]) - str: dosage info.get(dosage, 请遵医嘱或查看说明书) indication info.get(indication, 不详) return ( f{info[name]} 适用于{indication}\n f用法用量{dosage}\n f不良反应{info.get(adverse_reaction, 详见说明书)} )逻辑分三段。tracker.get_slot用来从对话状态里取实体值如果用户只说“这个药治什么”没有带药名此时 medicine 槽是空的action 会反问药名而不是报错。_lookup_medicine里先做精确匹配再做包含匹配是为了应对“芬必得”与“芬必得缓释胶囊”这类省略表达。最后的SlotSet把已确认的药品名写回对话状态后续无论用户问这个药的哪个维度都不用再问一遍药名。启动顺序也容易踩坑。必须先启动 action server再启动对话服务否则对话进行到 action 时拿不到结果rasa train rasa run actions --actions actions rasa shell训练完成后开三个终端分别执行上面三条命令顺序不能颠倒。--actions actions是指定加载哪个模块下的 action 类如果项目里把 action 拆到了多个包这里要用点号路径指定。3.4 药品库设计时预留一个“别名”字段这份代码在实践中最大的坑不在 Rasa而在于药品名建模。同一个药品会有商品名、通用名、简称甚至用户自创的错别字。我建议在 medicine.json 的每条记录里增加alias列表把“布洛芬”“芬必得”“Ibuprofen”都维护进去{ 布洛芬缓释胶囊: { alias: [布洛芬, 芬必得, ibuprofen, 布洛芬缓释], indication: 用于缓解轻至中度疼痛如头痛、关节痛、牙痛等, dosage: 成人一次 1 粒一日 2 次, adverse_reaction: 少数患者可出现胃肠道不适 } }这样 NLU 只负责把用户说出的词抽出来药品归一化在 action 里通过 alias 完成分担了训练数据的压力。4. 多轮症状采集与轻量疾病诊断用表单收敛主诉4.1 表单把开放对话变成“主诉采集”的骨架疾病诊断和单纯的医药问答不同它必须通过多轮追问补全信息。患者说“我肚子疼”机器人不能直接甩一个疾病列表得先确认痛点位置、持续时间、有没有发热等伴随症状。这个场景用 Rasa 的 Form 来做比手写一堆规则判断更省心。Form 机制的核心是定义一个表单声明需要的槽位Rasa 会每轮自动检查所有 required slot 是否填满哪个为空就追问哪个。我在 domain.yml 里把“症状采集”设计成三个槽位forms: symptom_collect_form: required_slots: - symptom_primary - duration - severity slots: symptom_primary: type: text mappings: - type: from_entity entity: symptom duration: type: text mappings: - type: from_entity entity: duration severity: type: text mappings: - type: from_entity entity: severityrules.yml 里声明触发方式用户表达“想查病”时激活表单rules: - rule: 用户主诉时进入症状采集 steps: - intent: ask_diagnosis - action: symptom_collect_form - active_loop: symptom_collect_form从第一轮到最后一轮表单会依次询问空白槽位。这个设计对医疗行业的实际价值是用表单结构锁死主流收集路径避免对话跑偏同时又允许患者在任意一轮直接说出多个症状一次性填充多个槽位。4.2 槽位校验别让用户随便一句“不知道”卡死流程Form 的一个常见问题是用户回答的内容并不总能匹配到实体。比如机器问“这个症状持续多久了”用户回一句“我也说不准”此时 duration 槽位无法被填充表单会一直追问同一句话体验非常差。解决方式是自定义一个 validate action在槽位填充前拦截不合法的输入。from typing import Any, Text, Dict, List from rasa_sdk import Action, Tracker from rasa_sdk.events import SlotSet from rasa_sdk.executor import CollectingDispatcher from rasa_sdk.types import DomainDict class ValidateSymptomCollectForm(Action): def name(self) - Text: return validate_symptom_collect_form async def run( self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: DomainDict, ) - List[Dict[str, Any]]: current_slot next( (slot for slot in tracker.slots if slot duration or slot severity), None ) if tracker.get_slot(duration) is None: dispatcher.utter_message(text大概持续多久了几个小时、几天还是几个月) return [] if 不确定 in str(tracker.latest_message.get(text, )): dispatcher.utter_message(text好的我按普通情况继续判断稍后仍建议您就医。) return [SlotSet(duration, unknown)] return []这里的关键逻辑有两个。第一tracker.latest_message拿到用户本轮的原话通过关键词判断是否需要走降级分支决定是把槽位置为 unknown 还是继续追问。第二validate action 的返回值如果是空列表Rasa 会认为槽位未通过校验自动继续原有追问流程。这样设计后表单永远不会因为用户的否定回答而卡死同时还能把“不确定”作为合法值记录在对话状态里诊断算法看到 unknown 时应该主动降低相关权重而不是报错。4.3 两段式诊断症状重叠度打分加疾病信息反向查询收集完三个槽位后要做的不是让模型“猜”疾病而是用一个简单可解释的评分方法。医疗场景里我不推荐直接上深度学习模型做诊断样本量、伦理风险和解释性都过不了关。更实际的做法是维护一个疾病症状库对用户提交的症状集合做重叠度计算输出候选疾病和置信分。def diagnose(symptoms: set, disease_db: Dict[str, Dict]) - List[Dict]: results [] for disease, meta in disease_db.items(): disease_symptoms set(meta.get(symptoms, [])) overlap len(symptoms disease_symptoms) if overlap 0: continue score overlap / len(disease_symptoms) results.append({ disease: disease, score: round(score, 3), matched: list(symptoms disease_symptoms), }) results.sort(keylambda x: x[score], reverseTrue) return results[:3]评分逻辑就是把“用户描述的症状”和“疾病典型症状”做集合交集。递归除以疾病症状总数是为了惩罚万金油疾病一个疾病如果列了 20 种症状其中 2 个被命中得分应该明显低于只有 3 种症状且全部命中的疾病。这个打分不是诊断结论只用于排序推荐在话术上必须引导用户就医。病症查询和症状查询是同一个知识库的反向遍历。用户在上一轮查询了疾病名就把该疾病的典型症状列表返回用户描述症状就返回可能的疾病方向。这部分可以并进同一个自定义 action用对话状态里已有的 disease 或 symptom 实体判断查询方向。5. 语音对话接入从“打字问诊”到“语音入口”5.1 ASR 与 TTS 选型先看清楚隐私边界语音对话对智能医疗机器人不是可选项老年人、眼科病人、急诊场景都更依赖语音。语音模块拆成三个独立环节ASR 把用户语音转文字Rasa 处理文字对话TTS 把回答转成语音。Rasa 本身不提供任何语音能力需要外部组件配合。ASR 和 TTS 的选型通常有三种组合方案ASRTTS离线能力隐私考量全本地方案Vosk 中文模型pyttsx3 / espeak完全离线原始音频不出内网云端识别本地合成SpeechRecognition 公网识别服务pyttsx3半离线语音内容经过第三方服务全云方案云厂商语音接口云厂商 TTS依赖网络需签署数据处理协议医疗场景里音频属于敏感数据能离线尽量离线。Vosk 的中文模型大约 40MB在普通 CPU 上识别一句话的延迟在两秒内对问诊这种句式相对固定的场景完全可以接受。TTS 用 pyttsx3 可以快速出一版类似“用药提醒”的播报效果后续再替换成音色更自然的本地合成引擎。5.2 一条完整的语音问诊调用链语音接入 Rasa 的代码可以保持独立不侵入 Rasa 项目本身。外部脚本负责录音、识别、调用 Rasa REST API、再把返回文本合成语音。import speech_recognition as sr import pyttsx3 import requests # 1. 本地拾音与语音识别 recognizer sr.Recognizer() tts_engine pyttsx3.init() tts_engine.setProperty(rate, 170) # 语速医疗内容尽量放慢 with sr.Microphone() as source: recognizer.adjust_for_ambient_noise(source, duration0.5) print(请描述您的症状...) try: audio recognizer.listen(source, timeout5, phrase_time_limit10) user_text recognizer.recognize_vosk(audio) # 本地 ASR识别结果含 JSON 字段 except sr.WaitTimeoutError: print(未检测到声音) exit(1) # 2. 把识别文本发给 Rasa REST 接口 response requests.post( http://localhost:5005/webhooks/rest/webhook, json{sender: patient_test, message: user_text}, timeout10, ).json() # 3. 取 Rasa 返回的第一条文本并合成语音 if response: reply response[0].get(text) if reply: print(机器人:, reply) tts_engine.say(reply) tts_engine.runAndWait()这段代码要注意三个细节。listen的phrase_time_limit控制了单次说话的最大时长问诊场景里用户可能会停顿思考10 秒比较合适过长会拖慢整个交互节奏。REST 接口返回的是列表因为一次对话可能包含多轮槽位追问和多条文本输出这里取第一条文本播报实际项目里要遍历整个列表按顺序播报。recognize_vosk返回的是包含识别置信度的 JSON 字符串需要解析后再使用如果后续要把置信度低于阈值的语音重新反问用户在解析时就要保留这个字段。5.3 语音链路最常见的三个工程坑语音接入跑到 demo 阶段之后大部分问题出在语音和 NLU 的衔接上。第一个坑是 ASR 输出的文本没有标点符号用户说“发烧咳嗽三天了”识别结果可能是“发烧咳嗽三天了”Rasa 会把整个句子当成一个意图处理实体抽取容易被“三天”干扰。我的做法是在 ASR 转写后不管标点但在训练数据里刻意加入不含标点的示例让模型适配这种无标点文本。第二个坑是打断和超时。用户在表单追问过程中沉默或者只说了半句话都会让流水线卡住。除了在 validate action 里处理“不知道”之外还需要在语音层加一个静音检测超过 3 秒没检测到声音就主动发起追问把控制权交还给 Rasa 的规则流程。第三条是 TTS 播报和录音不能同时进行否则机器人说话的声音会被麦克风重新采集进去形成自激回声。实际项目里要用半双工模式播报时短暂关闭麦克风输入播报结束后再恢复。这段逻辑可以用状态机管理简单可靠。6. 验证与调优把准确率从“鸡肋”提到“可用”6.1 用 rasa test 逼出差数据训练完模型后不要急着接语音先做一轮离线评测。Rasa 自带rasa test命令会基于训练集随机抽取数据做验证并生成混淆矩阵和分类报告。rasa test nlu --nlu data/nlu.yml --config config.yml --cross-validation --folds 5--cross-validation做 5 折交叉验证比默认的固定切分更能反映真实泛化能力。跑完以后重点看results/目录下的intent_confusion_matrix.png和diag_report.json对角线之外的高频错分样本才是真正要修的。两个意图经常混在一起说明它们的训练语料在句式上过于相似需要为其中一个意图增加更多区分性表达。6.2 三个值得提前调好的参数第一个是 FallbackClassifier 的threshold。医疗场景里答错比拒答风险更高我倾向把阈值从默认 0.7 提到 0.85当模型不确定用户意图时直接返回“请重新描述”而不是硬匹配一个疾病。第二个是表单槽位在对话结束后的清理诊断 submit action 要把 symptom_primary 等槽位全部置为 None否则下一位用户说“我头痛”时系统会误以为是上一轮就诊的延续。第三个是实体抽取的上下文依赖Rasa 实体识别会参考周围词如果“发烧”和“发热”都要能触发同一实体除了加同义词还需要分别在两种表达下各写若干条训练语料。交叉验证报告出来后针对性加权比盲调超参划算得多。本文还有配套的精品资源点击获取
返回列表