资讯动态

Flask+Rasa中文任务型对话:从意图识别到槽位填充的工程实践

发布时间:2026/10/14 1:41:19 来源:尧图企业网站定制
简介基于Flask与Rasa的中文任务型对话机器人完整项目面向Python开发者、NLP初学者以及需要快速搭建客服、问答或业务导流场景的工程师既能用于学习对话系统原理也可直接作为项目原型。压缩包共109个文件除Python源码外还包含YAML配置文件、JSON数据集、TXT说明文档、PDF部署文档以及实体识别模型等资源整体仅7.51MB轻量便携。目前已有117人学习下载代码基于Python 3.7以上版本开发部署文档详细按提示安装依赖即可运行小白也能轻松上手。项目内置对话训练数据、故事图与实体抽取组件可直接替换数据完成多场景定制适合毕业设计、课程项目或企业内部对话机器人快速落地。如需二次开发源码结构清晰注释与文档配套便于扩展意图识别和对话流程。1. FlaskRasa 中文任务型对话机器人这份源码包到底让你少做哪些事先想一个场景用户在对话框里说“帮我订一张明早北京到上海的高铁票”。如果后台只有 FAQ 匹配这句基本就断掉了——它既不是某篇文章的标题也不是一段可以查库的原文而是一个“意图 槽位”都还没拆开的自然语言请求。真正要落地到业务里去你需要先把意图识别成“订票”、把“明早、北京、上海”填进三个槽位再通过多轮追问把缺的日期和座位等级收齐。这件事不是写几个 if 就能扛住的它需要一整套 NLU 加对话管理再加业务回调的组合。标题里这套基于 Flask Rasa 的中文任务型对话机器人方案恰好把这三块都补齐了Flask 负责把用户消息接进来、维护会话上下文、回调你的业务接口Rasa 负责意图识别、实体抽取、槽位跟踪和对话策略压缩包里的源码、部署文档和全部数据资料则让你不必从零去攒训练数据和调参。适合谁手上有具体业务订票、查库存、预约登记需要快速搭出可演示、可继续训练的中文多轮对话原型而不是只想看看概念图的同学。2. 任务型对话的架构分工为什么网关用 Flask、对话管理交给 Rasa2.1 任务型对话为什么不能只靠规则匹配很多人第一次接触任务型对话时第一反应是用关键词正则加状态机。正则确实在“天气查询”“查快递”这类句式相对固定的场景能跑通但一旦用户换说法比如“明早的票还有吗”“北京到上海最早那班几点”关键词变体立刻失控。任务型对话的核心不是把句子匹配上而是把句子里的意图和槽位抽出来再根据当前上下文决定下一步问什么。Rasa 在这里承担的是“理解 决策 生成回应”三个环节。理解靠 NLU 组件它从训练数据里学会把“明早”识别成时间实体、把“北京”识别成出发城市决策靠 policy它根据意图、槽位填充情况和历史对话选择下一个动作生成回应则输出模板文本或触发自定义动作。这三点合在一起构成了一个闭环用户说一句 → 更新对话状态 → 判断槽位是否齐了 → 缺哪个就追问哪个。所以这个项目的价值不是“用 Flask 写机器人”而是把“理解”和“业务”这两层分开理解层的迭代速度非常快今天加一种说法补几条训练数据重训一遍就能生效业务层的代码则要保持稳定和可控不能被 NLU 模型的不确定性波及。这正好对应 Rasa 负责理解、Flask 负责业务接口的分工。2.2 Flask 做网关、Rasa 做大脑职责边界与请求流转先看一张典型的请求流转顺序不需要画图按这个节奏去读代码就够用户消息 → Flask /api/chat 路由 → 生成或读取 sender_id → 转发到 Rasa /webhooks/rest/webhook → Rasa NLU 策略 → 返回响应列表 → Flask 解析响应并回调业务接口 → 组装回复返回前端Flask 在其中的角色是一个网关。它不负责理解自然语言但负责三件 Rasa 默认不好做的事。第一统一对外暴露接口前端只需要对接一个地址不需要知道 Rasa 的端口和协议细节第二维护用户会话通过 sender_id 区分不同用户并支持把会话状态存到 Redis 或多进程共享存储里第三承接业务动作比如查票、下单、写工单这些动作如果直接写在 Rasa 自定义动作里会让训练数据和业务代码耦合在一起升级任何一边都会互相拖累。Rasa 侧则只需要专注对话能力。它对外提供 REST 接口训练好的模型加载后就能根据消息返回一组动作。常见部署形态是 Flask 开一个 8000 端口给前端用Rasa 开 5005 端口只对 Flask 暴露。这样隔离后模型重训、替换版本都不会影响到前端接口的稳定性。下面这张职责表在部署文档里通常会直接列出来照着它去理代码结构最省时间层组件负责内容不负责内容接入层FlaskHTTP 路由、会话 ID 管理、业务回调意图识别、槽位跟踪对话层RasaNLU 实体抽取、对话策略、表单管理业务数据查询、外部系统对接动作层Action Server调用业务接口、拼装响应、操作数据库不直接接收前端请求数据层训练语料 业务库提供 NLU 训练样本、槽位映射、业务数据无这样的分层会直接影响后面你自己的改造方向想加强理解能力去补训练数据、调 pipeline想加强业务整合去改 Flask 路由和自定义动作想支持更多渠道也是在 Flask 层加适配器。清晰的分工是这套方案最值得参照的地方。2.3 为什么不直接用 Rasa 对外提供服务有人会问Rasa 本身不是有 REST 接口吗为什么还需要 Flask 包一层直接暴露给前端不是更省事常见做法是 Rasa 自带的 /webhooks/rest/webhook 接口确实能直接收消息但它有两个现实短板。第一安全控制不方便。Rasa 的接口不会替你处理鉴权、限流、IP 白名单这些网关功能直接暴露在公网等于把模型推理能力裸奔。第二响应结构与业务不匹配。Rasa 返回的是对话动作列表前端拿到手还得自己拼业务数据如果中间再夹一层 token 校验、日志埋点、错误码统一Flask 这个薄网关就是最合适的承接点。还有一个隐蔽问题Rasa 进程专注于模型推理如果把它同时当业务接口用遇到并发稍高就容易阻塞Flask 进程可以单独做连接管理和限流两边的生命周期互相独立。从一个团队协作的角度看这种拆分也更有益。算法同学可以只在 Rasa 项目里加语料、重训练、跑效果评测不去碰 Flask 代码后端同学则把 Flask 当普通的服务编排项目维护。两者通过一个约定好的 message 和 response 结构通信互不越界。这就是我建议你按照这套架构去读源码的顺序先看 Flask 的请求入口再看 Rasa 的模型配置最后看两者之间那份约定好的数据结构。3. 从中文数据到可对话的模型Rasa 训练的最小流程与三块拼图3.1 中文 NLU 数据格式与标注要点拿到压缩包里的数据资料后第一步不是急着跑训练而是先把数据目录的结构摸清楚。Rasa 的项目通常包含 data/nlu.yml、data/stories.yml、data/rules.yml 三个文件再加上 config.yml 和 domain.yml。这个命名非常直观但新手最容易忽略的是 NLU 格式里的“实体别名”和“正则表达式”字段。先看 nlu.yml 的一个最小示例version: 3.0 nlu: - intent: book_train examples: | - 帮我订 [明天早上](time) 从 [北京](city) 到 [上海](city) 的高铁票 - [北京](city) 到 [上海](city) 有最早的一班吗 - 我要买 [后天](time) 去 [广州](city) 的票 - intent: ask_price examples: | - 这趟车多少钱 - 二等座票价是多少这里每个方括号是实体的标注方式左边是原文本右边是实体类型。Rasa 会从这些标注里学到“北京”在句子里大概率是出发城市“明天早上”是时间实体。注意两个细节同一句话里可以标多个同类型实体Rasa 需要靠位置和上下文来区分出发地还是目的地实体值不一定等于原文本可以加别名比如“早上”归一化成 morning这一层兜底建议在数据阶段就做好不要留给代码去硬编码。实体归一化在中文场景里特别重要。用户说“明早”“明日早晨”“明天上午”词面完全不同如果不归一化槽位值就会五花八门。部署文档里通常会建议在 Rasa 配置里加一个 RegexEntityExtractor配合 patterns 文件做日期和时间的正则兜底再配合 mapping 属性的 from_entity 把不同实体名统一到一个槽位。这样即使 NLU 模型对冷门说法识别失败正则还能接住一部分。3.2 训练配置与命令先把一个简单意图跑出来数据准备好了接下来就是写 config.yml 和 domain.yml然后执行训练命令。中文任务型对话的配置和英文项目有一个明显差异默认的 WhitespaceTokenizer 对中文不友好因为中文没有天然空格必须换成 jieba 分词。这通常是新手碰到的第一个会自动失败的环节——不是代码问题是分词器问题。一份最小可跑的 config.yml 长这样language: zh pipeline: - name: JiebaTokenizer dictionary_path: data/dictionary.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 - name: ResponseSelector epochs: 100 policies: - name: MemoizationPolicy - name: RulePolicy - name: TEDPolicy epochs: 100 max_history: 6我一般会建议把 char_wb 这个 1-4 元字符特征放在 jieba 后面因为中文未登录词太多纯靠词级别特征很容易漏掉新词。字符 n-gram 对这种场景是重要兜底。DIETClassifier 的 epochs 不是越大越好中文小数据集 80 到 120 轮内基本收敛超过 150 轮就要开始观察是否过拟合。接下来在项目根目录执行训练和对话测试# 进入项目虚拟环境后执行 rasa train # 训练完成会生成 models/ 目录下的 tar.gz 模型文件 # 用交互式命令行测试单轮意图识别 rasa shellrasa train 没有特殊参数它会自动读取 config.yml、domain.yml 和 data/ 下的全部训练文件。常见错误是结构不完整比如 domain.yml 里声明了某个 intent但 nlu.yml 里没有对应样本训练会直接失败。rasa shell 是验证意图识别最快的方式输入“帮我订明天早上的票”观察输出的 intent 置信度和实体抽取结果。如果置信度低于 0.5说明样本数量或者特征配置还有问题优先去补同类说法而不是调阈值。3.3 规则、故事与表单对话管理层的三块拼图Rasa 对话管理有非常抽象的三个概念rules、stories、forms。很多人卡在这个阶段其实就是没分清这三个东西各自管哪一段对话流程。我建议按这个顺序理解。Rules 负责“不能出错”的流程比如问候、感谢、再见以及业务上的强制跳转。RulePolicy 会无条件执行规则里定义的动作适合写死每个意图对应的固定 response。Stories 负责“可能多种走法”的多轮路径比如用户先问票再问时间再改目的地每一步都可能不同TEDPolicy 从 stories 里学习常见路径但允许路径发生变化。Forms 则是专门为槽位收集设计的机制它会在缺失某个槽位时自动追问直到所有必填槽位收集完整。一个表单场景的 stories 示例stories: - story: book train full path steps: - intent: book_train - action: train_order_form - active_loop: train_order_form - slot_was_set: - requested_slot: time - intent: inform - action: train_order_form - slot_was_set: - from_city: 北京 - slot_was_set: - requested_slot: to_city这里的核心是 requested_slot。它告诉 Rasa 当前正在等哪个槽位用户回复之后表单继续循环直到全部槽位齐了才执行自定义动作提交表单。我发现很多“机器人一直追问同一句话”的线上问题根因就在这里requested_slot 没有按顺序释放导致表单不断卡在同一个槽位上。排查方法不是改代码而是把每次响应的 debug 输出打开看当前 active_loop 和 requested_slot 到底停在哪一步。4. 让 Flask 替用户把话递给 RasaHTTP 接口、会话保持与槽位回填4.1 用官方 SDK 还是自写 HTTP 转发Flask 接 Rasa 的方式有两种一种是用 rasa-sdk 里的 RasaHTTP 客户端另一种是自己写一个 requests 调用。两种我都用过结论是项目初期自己写转发更值得因为接口结构简单你很快能理清两者之间的数据契约。Rasa 的 REST 接口路径固定为 /webhooks/rest/webhook请求体只需要两个关键字段sender 和 message。sender 就是用户唯一 ID换成你自己的业务用户 ID 也可以message 是用户原始输入。Rasa 返回的是一个列表列表里每个元素包含 recipient_id、text 等字段也可能包含 custom 字段用于传递自定义动作的业务结果。自写转发最大的好处是中间可以加日志。哪些消息 NLU 置信度低、哪些槽位一直没填上、哪些用户反复触发同一条 fallback这些日志是后续调优的原材料。如果直接用 SDK这些过程就被封装掉了出了问题还要再去翻 SDK 源码。所以先自写一个能跑通的最小转发再考虑是否替换成 SDK。4.2 Flask 路由、会话管理与超时处理一个最小可用的 Flask 转发路由大概长这样注意代码里的注释已经标出了每个环节的关键点import uuid import requests from flask import Flask, request, jsonify app Flask(__name__) RASA_URL http://127.0.0.1:5005/webhooks/rest/webhook # 用字典模拟会话 ID 存储生产环境建议替换为 Redis 并设置过期时间 SESSION_POOL {} app.route(/api/chat, methods[POST]) def chat(): payload request.get_json() user_id payload.get(user_id) message payload.get(message, ).strip() if not user_id: user_id str(uuid.uuid4()) if not message: return jsonify({code: 400, msg: empty message}), 400 # 每个用户分配独立 sender_id避免会话串台 sender_id SESSION_POOL.get(user_id) if not sender_id: sender_id str(uuid.uuid4()) SESSION_POOL[user_id] sender_id rasa_resp requests.post( RASA_URL, json{sender: sender_id, message: message}, timeout10 ) if rasa_resp.status_code ! 200: # 记录日志后返回友好提示不要把 Rasa 的错误堆栈直接抛给前端 return jsonify({code: 502, msg: dialog service unavailable}), 502 replies rasa_resp.json() texts [item.get(text) for item in replies if item.get(text)] return jsonify({code: 0, replies: texts})这段代码里有几个参数值得仔细看。timeout10 是 Rasa 推理的超时上限如果模型推理较长或 Action Server 回调业务耗时这个数字要相应调大建议结合你的业务接口 P95 延迟来定不要拍脑袋取个固定值。sender_id 用 uuid 生成而不是直接用 user_id是因为 Rasa 对 sender 字段没有做字符串安全处理直接拼接业务主键可能在特殊字符上出问题uuid 是最稳妥的。SESSION_POOL 用字典模拟只适合本地联调。生产环境下我一般用 Rediskey 为业务用户 IDvalue 为 sender_id同时设置 30 分钟过期。这样用户隔很久再回来对话自动重置避免槽位残留影响下一次任务。重置这一点非常关键很多线上机器人出错都是因为上一次订单的“北京”“上海”还残留在槽位里用户这次只是问个票价结果后台默认他还要订票。会话过期策略其实就是变相解决这个问题的兜底。4.3 上下文传递与槽位回填的自定义动作Flask 转发只是把消息送进去真正的业务回调发生在 Rasa 的 Action Server。Action Server 是一个独立进程默认监听 5055 端口Flask 不需要直接跟它通信Rasa 模型会通过动作名触发对应的 Python 类。常见的部署流程是先在 actions/actions.py 里写好动作类然后启动 action server最后再启动 Rasa 服务。一个查询票价的自定义动作示例from typing import Any, Text, Dict, List from rasa_sdk import Action, Tracker from rasa_sdk.executor import CollectingDispatcher class ActionQueryPrice(Action): def name(self) - Text: return action_query_price def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) - List[Dict[Text, Any]]: # 从对话跟踪器里取槽位值这些值来自 NLU 实体抽取和表单回填 from_city tracker.get_slot(from_city) to_city tracker.get_slot(to_city) time tracker.get_slot(time) if not all([from_city, to_city, time]): dispatcher.utter_message(text出发地、目的地和出行时间都告诉我才能查询哦) return [] # 这里对接你自己的业务接口超时和异常要在这里兜住 price query_train_price(from_city, to_city, time) dispatcher.utter_message( textf{from_city}到{to_city}{time}最低二等座票价是{price}元 ) return []注意 tracker.get_slot 返回的是 Rasa 维护的全局槽位值表单填入的值和 NLU 抽取的值都会落到这里。一个常见问题是槽位名的大小写和类型不匹配比如 domain.yml 里定义的槽位是 from_city而 NLU 实体叫 FromCityslot mapping 没有配置那这个槽位永远填不进值。排查这种问题建议在动作里把 tracker.slots_to_validate() 的结果打出来一眼就能看出当前会话到底持有哪些槽位。Action Server 的启动命令是 rasa run actions它必须和 Rasa 主服务同时运行。很多人只启动了 rasa run 就测试自定义动作得到的是 “Failed to execute custom action”这其实是少开了一个进程不是代码问题。启动时的端口、日志级别部署文档一般都会写在最显眼的位置。5. 部署与联调最容易翻车的 5 个坑现象、原因、解决5.1 中文乱码与响应编码不对现象Flask 接口返回的中文变成 “\u4e00\u65e9”或者前端收到的是乱码Rasa 侧打印日志正常但 HTTP 返回后编码就坏了。原因Rasa 返回的 JSON 本身是 UTF-8但 requests 库在解码时没有强制指定编码而 Windows 下部分环境的默认编码是 GBK导致字符串在 Flask 侧已经失真。另一种情况是 Flask 响应没有显式设置 Content-Type 里的 charset。解决在 Flask 的 jsonify 基础上显式指定响应编码或者在 requests 请求后强制 resp.encoding utf-8。我一般还会在 Flask app config 里加一句 JSON_AS_ASCII False让返回内容保留中文原文调试时肉眼看日志更方便。说到底这不算 Rasa 的坑是 Python 服务在跨平台部署时的老问题提前统一编码能少掉一半的联调扯皮。5.2 Rasa 版本升级后模型无法加载现象本地训练好的模型能跑换到服务器后 rasa run 报模型加载失败或者部署文档里写的命令在新版本里已经提示 deprecated。原因Rasa 的配置 schema 变化比较快某次大版本迭代后config.yml 里的 pipeline 组件名、policy 名称都可能被改名旧模型文件对应的元数据不兼容。压缩包里的部署文档如果基于某个版本编写而你环境里装的是另一个大版本就会出现这种隐形不兼容。解决严格按部署文档里的 requirements.txt 创建虚拟环境并把 Python 版本也锁死。我用 pyenv 加 requirements.txt 双重保险每个项目一个独立环境。如果确实要在新版本上跑不要直接加载旧模型先 rasa train 重新训练再对比新旧模型在同一批验证集上的效果差异。永远不要在生产环境里“顺便升级”依赖。5.3 自定义动作反复报 Failed to execute custom action现象对话进行到某一步Rasa 返回文本里带着 “Failed to execute custom action” 或者 “An exception occurred while executing custom action”。原因最常见的是 action server 没有启动Rasa 主服务找不到 5055 端口上的动作执行进程。也有可能是代码异常被 Rasa 吞掉了真实堆栈只出现在 action server 的日志里而你没看那个终端窗口。解决先确认两个服务都在跑rasa run 和 rasa run actions 必须同时存在。接着去 action server 的控制台看堆栈而不是盯 Rasa 主日志。我通常在 actions.py 里直接 print再配合日志开关把槽位值、请求参数都打出来。把动作代码拆到最小可复现再加回业务逻辑能快速定位问题。5.4 不同用户的对话串台了现象用户 A 在订票流程中回复内容却是用户 B 上一轮请求留下的查询结果或者用户 A 刚问到一半突然被问到“你是不是要查快递”。原因Rasa 用 sender 字段区分会话如果 Flask 层给所有用户都传了同一个 sender比如固定字符串 “default”所有人的对话状态会落在同一个 tracker 里互相覆盖。解决Flask 层每个用户生成唯一 sender_id并在服务端维护映射关系。更保险的做法是把 sender_id 换成用户登录态里的 user_id 加一个随机后缀比如 user_123_ab12既方便追踪又不至于泄露真实主键。这个问题在联调时特别隐蔽因为你一个人测的时候永远不会暴露至少要两个浏览器同时测才能复现。5.5 表单卡在同一个槽位反复追问现象用户在表单流程里已经回答了出发城市机器人还是继续问“从哪里出发”或者机器人连续两轮问同一个问题用户已经烦了。原因requested_slot 的流转逻辑依赖 stories 或表单里的 slot_was_set 描述。如果 story 里没有让表单把已填槽位标记为完成Rasa 的策略会认为这个槽位还是空值。另一个隐蔽因素是实体抽取成功但 slot mapping 里没把实体和槽位绑定导致槽位值根本没写入。解决先看表单动作的日志输出确认实体识别结果确实是“北京”再确认 domain.yml 的 slot mapping 字段指向的实体名是 from_city 而不是 FromCity。如果实体抽取正常但槽位仍为空就在 stories 里补一条“用户已提供该槽位”的路径让策略有样本可以学习。这类问题靠眼力检查很难发现一定要把 debug 日志打开逐轮跟踪。6. 上线前先做效果验证三个自查脚本加上一个调优习惯6.1 用最小验证集跑一次意图与槽位回归上线前的手动测试永远是不够的我习惯写一个极简回归脚本把常见的正确说法和故意写错的说法都放进去每次模型更新后跑一遍防止老功能被新数据冲掉。这个脚本只依赖 requests不需要引入测试框架import json, requests FLASK_URL http://127.0.0.1:8000/api/chat cases [ {message: 帮我订明天早上北京到上海的高铁票, expect_topic: book_train}, {message: 这趟车多少钱, expect_topic: ask_price}, {message: 查一下天气, expect_topic: fallback}, ] for case in cases: resp requests.post(FLASK_URL, json{user_id: test_user, message: case[message]}).json() replies .join(resp.get(replies, [])) print(case[message], , replies)这段代码的意义不在打印而是让你保留一份“每轮改动后都能跑”的检查清单。跑完肉眼看一遍回复比看 N 个评测指标更直接。如果某条 case 的输出明显偏离预期说明新训练数据或配置改动影响了旧路径这时再去 review 那一轮的数据变更而不是盲目调参。6.2 槽位覆盖率的量化检查意图准确率只是基础任务型对话真正要关心的是槽位覆盖率。我自己常用一个笨办法准备 8 到 10 条测试句每条句子固定包含若干槽位值跑完后人工比对槽位是否全部出现在回复中。槽位覆盖率低于八成说明 NLU 实体识别还不够稳优先补的就是这些实体在更多句式里的表现。这个检查在每次 rasa train 之后都做一遍可积累成一份长期的手工评测记录。6.3 一个值得记住的调优习惯所有效果问题先看数据后看参数。置信度低、实体抽错、槽位不填绝大多数根因是训练样本分布不够或者标注不一致而不是模型参数没调好。我的习惯是每次只改一个变量这一轮只补数据下一轮只调配置然后把验证集结果记录下来对比。有一次线上用户反馈“订票”识别成“退票”排查后发现是语料里“退”字出现在大量订票句子的否定形式里导致特征相互干扰。这个问题的解法不是调阈值而是单独为退票意图补充更多样本。这个问题当时就是用上面的回归脚本暴露出来的。线上机器人没有一劳永逸的配置把验证流程固化下来每次改动都跑一遍新功能才会越攒越稳。希望这些踩过的坑能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑