1. 先定边界:第一期聊天机器人到底做什么,以及为什么是 Flask + TensorFlow
很多刚接触智能聊天机器人的朋友,一上来就想做一个像 ChatGPT 那样什么都能聊的产品,结果卡在模型部署和前后端对接上,项目直接烂尾。我自己的建议是:第一期不要贪大,先跑通一个"最小可用闭环"——用户在网页输入问题,后端能识别出意图,然后返回预设回复。这个过程看着简单,但它把 Python Flask、TensorFlow、网页交互三块拼图完整串了起来,后面的优化才有附着点。
这套组合里,Flask 负责 Web 服务层,它非常轻,一个文件就能启动一个 HTTP 服务,特别适合把 TensorFlow 模型包装成接口对外提供。TensorFlow 则负责最核心的意图识别,我们用文本分类的方式训练一个小型神经网络模型,输入是用户问题的文本,输出是该问题属于哪个预定义意图类别。第一期不追求对话记忆、不追求上下文理解,只做一个"识别意图 + 返回对应回复"的闭环。
为什么不是用更重的 Django?因为第一期项目规模很小,Django 自带 Admin、ORM、中间件等一大堆功能,但聊天机器人当下只需要一个接收 POST 请求、返回 JSON 的接口,Flask 的成本明显更低,路由写起来也直观,排错时体感更轻松。至于 TensorFlow,虽然它的功能范围很大,但我们要用它来训练和加载模型,社区里关于模型的保存、加载、部署的资料非常多,踩坑时容易找到参考。
这一期做完,你会得到一个可以真实运行的网页:浏览器打开一个聊天界面,敲一句"你好",机器人回一句问候;敲一句"讲个笑话",机器人给你返回一个笑话。整个项目的数据流向是这样的:浏览器前端把用户输入通过 fetch 请求发送给 Flask 接口,Flask 调用已经加载进内存的 TensorFlow 模型,模型把文本转换成向量并输出各个意图类别的概率,Flask 取出概率最高的意图,再查表找到对应的回复文本,最后以 JSON 格式返回给前端,前端把它渲染成聊天气泡。这就是完整链路。
2. 动手前的准备:目录设计、虚拟环境与依赖版本清单
开始写代码之前,先把项目目录和依赖理清楚。很多人习惯打开编辑器就写,写到一半发现模型文件、训练脚本、Web 脚本全堆在一个目录里,后期想扩展非常痛苦。我的做法是第一期也保持一个干净的目录结构,给后续留好位置。
2.1 推荐的项目目录结构
chatbot_project/ ├── app.py # Flask 主程序 ├── train.py # 模型训练脚本 ├── requirements.txt # 依赖清单 ├── data/ │ └── intents.json # 意图训练数据与回复表 ├── model/ │ ├── intent_model.keras # 训练好的模型 │ ├── tokenizer.pkl # 分词器 │ └── label_encoder.pkl # 标签编码器 ├── templates/ │ └── index.html # 前端聊天页面 └── static/ └── style.css # 页面样式(可选)data/intents.json是训练数据的唯一来源,它同时承担了"训练样本"和"回复表"两个职责,这是小项目里最高效的做法。model目录下保存的是训练产物,不交给训练脚本直接修改。app.py和train.py分开,训练归训练,服务归服务,避免把两件事写进同一个文件。
2.2 创建虚拟环境与安装依赖
Python 项目最好都用虚拟环境隔离依赖,尤其是 TensorFlow 这种依赖体积大、版本敏感的重型库。我创建一个虚拟环境并激活:
cd chatbot_project python -m venv venv source venv/bin/activateWindows 环境下激活命令是venv\Scripts\activate,这个是老生常谈,但新手经常在这里卡住。
然后是依赖文件,我整理了一份经过验证的requirements.txt:
tensorflow==2.15.0 Flask==2.3.3 scikit-learn==1.3.2为什么没有写 numpy、pandas 之类?因为 TensorFlow 和 scikit-learn 会自动拉取兼容的 numpy 版本,手动指定反而容易产生版本冲突。TensorFlow 2.15 是一个比较稳定的版本,对 Python 3.9 到 3.11 都支持得不错。如果你用 Python 3.12,建议先降级到 3.11,因为部分 TensorFlow 版本在 3.12 上的编译产物不完整,安装时可能报错。
安装命令:
pip install -r requirements.txt如果你的网络环境下载 TensorFlow 很慢,可以换用国内镜像源,比如下面这种写法:
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple2.3 关于版本的一个关键提醒
我第一次搭的时候,直接用了最新版 TensorFlow,结果训练脚本里tensorflow.keras.preprocessing.text.Tokenizer出现了行为差异,导致后面加载模型和分词器时对不上。后来我固定版本,整个过程就顺了。
这里给一个实战建议:所有训练脚本和服务脚本里的关键库,依赖清单必须锁定主版本号。聊天机器人这种小项目,不需要追新,稳定是第一位。另外,Flask 2.x 的app.run()在调试模式下默认只能处理单线程请求,如果你后续要模拟并发,需要显式指定threaded=True,这个我在第 6 节会展开说。
3. TensorFlow 意图识别模型:从数据准备到可复用的推理函数
这一节是整个项目的核心之一。我先把训练数据设计好,再讲文本序列化,最后给出模型训练代码和推理封装。你跟着做,能在十分钟内得到自己的意图模型。
3.1 训练数据怎么组织:JSON 格式的双重职责
训练数据我放在data/intents.json里,结构如下:
{ "intents": [ { "tag": "greeting", "patterns": ["你好", "嗨", "在吗", "hello", "hi"], "responses": ["你好呀,我是演示机器人", "您好,很高兴见到你!"] }, { "tag": "weather", "patterns": ["今天天气怎么样", "下雨了吗", "明天多少度", "会下雪吗"], "responses": ["我是演示机器人,暂时查询不了天气,换个问题试试吧。"] }, { "tag": "joke", "patterns": ["讲个笑话", "来个段子", "有没有好笑的"], "responses": ["为什么程序员总是分不清万圣节和圣诞节?因为 Oct 31 == Dec 25 :-) 哈哈!"] }, { "tag": "bye", "patterns": ["再见", "拜拜", "我要走了", "结束对话"], "responses": ["再见,欢迎随时回来找我聊天。"] } ] }每个意图有三个字段:tag是意图的机器标识,patterns是用户可能说的各种表达方式,responses是这个意图对应的回复。这里有一个设计细节:回复不只一条,后续扩展时可以做随机选择,让机器人显得不那么死板。第一期我直接取第一条回复,逻辑最简单,运行也最稳定。
你可能会问:fallback 兜底回复放在哪?我的做法是不放进训练集,因为在服务端做兜底判断时,根据置信度阈值来决定是否返回兜底回复,比硬把它作为一个训练类别更可靠。这个阈值策略在第 6 节会仔细讲。
3.2 文本预处理:Tokenizer、序列填充与标签编码
机器学习模型吃的是数字,不是字符串。我们需要把用户输入的文本转换成固定长度的整数序列,并把意图标签转换成模型能够输出的类别索引。这里用 TensorFlow 自带的Tokenizer和 scikit-learn 的LabelEncoder来做。
import json import pickle import numpy as np from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from sklearn.preprocessing import LabelEncoder with open("data/intents.json", "r", encoding="utf-8") as f: data = json.load(f) patterns = [] tags = [] response_dict = {} for intent in data["intents"]: for pattern in intent["patterns"]: patterns.append(pattern) tags.append(intent["tag"]) response_dict[intent["tag"]] = intent["responses"] tokenizer = Tokenizer(num_words=2000, oov_token="<OOV>") tokenizer.fit_on_texts(patterns) sequences = tokenizer.texts_to_sequences(patterns) max_len = max(len(seq) for seq in sequences) X = pad_sequences(sequences, maxlen=max_len, padding="post") encoder = LabelEncoder() y = encoder.fit_transform(tags) y = np.array(y)重点解释两个细节。第一,num_words=2000表示最多保留词频最高的 2000 个词,这对一个小型意图识别任务完全够用,还能防止训练数据里出现太多低频生僻词干扰模型。第二,oov_token="<OOV>"的作用是,模型在预测阶段遇到训练集里没出现过的词时,不是直接丢弃,而是映射到一个统一的"未知词"标记,这样序列长度才能保持一致。这个参数非常实用,很多教程都忽略,结果在线推理时文本长度对不上,模型报错。
pad_sequences里的padding="post"表示在句子后面补零,maxlen统一取所有样本里的最大长度。这里我直接用训练集的最大长度作为标准,后续推理时也使用同一个长度,保证输入维度一致。
3.3 模型定义与训练:用文本分类的思路实现意图识别
意图识别本质上就是文本分类。我们输入是一个整数序列,输出是每个意图类别的概率分布。模型结构不需要太复杂,我用 Embedding 层加全局平均池化,再接全连接层:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, GlobalAveragePooling1D, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping from sklearn.model_selection import train_test_split vocab_size = min(len(tokenizer.word_index) + 1, 2001) embedding_dim = 32 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) model = Sequential([ Embedding(vocab_size, embedding_dim, input_length=max_len), GlobalAveragePooling1D(), Dense(32, activation="relu"), Dropout(0.5), Dense(len(encoder.classes_), activation="softmax") ]) model.compile( optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"] ) early_stop = EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True) model.fit( X_train, y_train, validation_data=(X_test, y_test), epochs=200, batch_size=16, callbacks=[early_stop], verbose=1 ) model.save("model/intent_model.keras")为什么用 Embedding + GlobalAveragePooling1D 而不是 LSTM?因为第一期项目的数据量很小,每个意图只有几条样本,用 LSTM 这种序列模型很容易过拟合,而且推理速度相对更慢。全局平均池化会把整个序列的 Embedding 向量做一个平均,相当于把整句话压缩成一个稠密向量,对短文本分类来说性价比非常高。这里我故意加了 Dropout 层,也是因为数据量小,不约束一下模型参数,训练集准确率能到 100%,测试时却一塌糊涂。
EarlyStopping的 patience 我设为 10,意思是验证集损失连续 10 轮不下降就停止训练,并恢复到验证损失最小的那一轮权重。小数据训练经常会出现前几轮下降很快、后面反复震荡的情况,这个机制能帮我们自动找到一个合适的训练轮数,不用死磕 epoch 设多少。
训练结束后,模型文件被保存为.keras格式。这是 TensorFlow 2.11 之后推荐的格式,比 HDF5(.h5)对新特性支持得更好。
3.4 编写推理封装函数:加载模型、分词器与标签编码器
训练完成只是第一步,真正要用起来,必须把"文本 -> 意图标签"这个过程封装成一个干净的函数。这里有个大坑:只保存模型是不够的,Tokenizer和LabelEncoder也必须一起保存,否则在线推理时没法把用户的新文本转换成模型需要的数字序列。
def save_artifacts(): with open("model/tokenizer.pkl", "wb") as f: pickle.dump(tokenizer, f) with open("model/label_encoder.pkl", "wb") as f: pickle.dump(encoder, f)推理函数单独放在一个模块里,方便被 Flask 调用:
import pickle import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.sequence import pad_sequences model = load_model("model/intent_model.keras") with open("model/tokenizer.pkl", "rb") as f: tokenizer = pickle.load(f) with open("model/label_encoder.pkl", "rb") as f: encoder = pickle.load(f) MAX_LEN = model.input_shape[1] def predict_intent(text): if not text or not text.strip(): return "fallback", 0.0 seq = tokenizer.texts_to_sequences([text]) padded = pad_sequences(seq, maxlen=MAX_LEN, padding="post") probs = model.predict(padded, verbose=0)[0] idx = int(np.argmax(probs)) confidence = float(probs[idx]) intent = encoder.inverse_transform([idx])[0] return intent, confidence一个容易被忽略的细节:model.input_shape[1]可以直接取到模型期望的输入长度,这样就不用在推理代码里再硬编码一个max_len,避免了训练脚本和推理脚本之间长度不一致的问题。我第一次写的时候就硬编码了 7,后来发现训练数据里某条样本长度比 7 长,模型训练时用的实际最大长度是 9,推理时 pad 到 7,预测结果完全不对,而且不报错,非常隐蔽。用model.input_shape[1]是最稳妥的。
预测时,置信度是 softmax 输出的最大值。当所有类别的概率都差不多高时,说明模型对用户输入没有把握,这时不应该硬选那个最高概率类别,而应该走兜底逻辑,这部分放到第 6 节和第 3 节的边界判断一起讲。
4. 把模型接进 Flask:路由设计、模型常驻内存与 JSON 响应
模型训练好了,接下来要做的是让浏览器能够调用它。Flask 在这里扮演一个轻量 API 网关的角色。很多人把 Flask 想得很神秘,其实它就是一个 Python 写的 Web 框架,可以监听端口、接收 HTTP 请求、返回响应。这一节我会把app.py从零到完整拆开讲。
4.1 Flask 应用基础:初始化与网页路由
from flask import Flask, request, jsonify, render_template app = Flask(__name__) @app.route("/") def index(): return render_template("index.html")render_template会去templates目录下找index.html文件,这样访问根路径时,浏览器就能看到聊天页面。为什么把页面文件放在templates目录而不是直接放一个静态 HTML?因为后续如果要做服务端渲染,比如动态传入用户会话 ID,这个目录结构更方便。第一期先按规范来,成本很低。
4.2 模型常驻内存:绝对不要在请求内加载模型
一个非常重要、但很多新人会踩的坑:不要在chat()函数内部去加载模型。我看到过有人这么写:
@app.route("/chat", methods=["POST"]) def chat(): model = load_model("model/intent_model.keras") # 不推荐 ...这会导致每一个用户消息进来,Flask 都重新加载一遍模型文件。TensorFlow 模型加载本身可能要几秒钟,而且占用大量内存,多几个请求就能把开发机拖垮。正确的做法是在模块加载时就初始化模型、分词器和标签编码器,让它们常驻内存:
# 全局作用域加载一次 model = load_model("model/intent_model.keras") with open("model/tokenizer.pkl", "rb") as f: tokenizer = pickle.load(f) with open("model/label_encoder.pkl", "rb") as f: encoder = pickle.load(f)这样模型只在 Flask 服务启动时加载一次,后续每个请求都直接复用内存里的模型实例。你要是用 Gunicorn 等多进程部署,每个 worker 进程会各加载一份模型,这是部署层面的事,第一期用 Flask 自带的开发服务器加单进程模式就够了。
4.3 聊天接口设计:POST /chat 的入参、出参与业务逻辑
接口协议我设计得很简单。前端向/chat发送 JSON:
{ "message": "你好" }后端返回:
{ "reply": "你好呀,我是演示机器人", "intent": "greeting", "confidence": 0.97 }reply是要渲染到网页上的回复文本,intent和confidence是调试信息,方便我们观察模型有没有判断准。下面的代码实现了这个逻辑:
@app.route("/chat", methods=["POST"]) def chat(): data = request.get_json(silent=True) or {} user_text = data.get("message", "").strip() if not user_text: return jsonify({ "reply": "你好像还没输入内容,再说一次试试?", "intent": "empty", "confidence": 0.0 }), 200 intent, confidence = predict_intent(user_text) intent_data = next( (item for item in intents_list if item["tag"] == intent), None ) if intent_data is None or confidence < 0.65: reply_text = "我还没理解你的意思,换个说法试试吧。" intent = "fallback" else: reply_text = intent_data["responses"][0] return jsonify({ "reply": reply_text, "intent": intent, "confidence": round(confidence, 4) })我把intents_list在启动时读入内存,并且把responses结构直接放在里面。这样代码里不需要硬编码任何意图名,新增意图只需要改 JSON 数据文件。这里有两个细节需要说明。
第一,request.get_json(silent=True)加上or {},是为了避免前端传了非 JSON 数据时程序直接抛异常。silent=True表示解析失败时返回None,然后我们用空字典兜底,让接口始终能返回结构化 JSON。
第二,阈值我暂时取 0.65,这个数值不是固定的,需要根据你的数据量和实际测试调整。如果设得太高,很多模糊问题会被打回兜底;设得太低,模型乱猜错得离谱。我一般在第一次联调之后,把测试中明显错误的预测结果打印出置信度,观察正常回答和错误回答的置信度分布,再决定阈值。这个调参过程在第 6 节会有实例。
4.4 启动入口与调试模式注意事项
if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)host="0.0.0.0"允许局域网内的其他设备访问,方便你在手机上测试聊天界面。threaded=True让 Flask 开发服务器以多线程方式处理请求,避免模型推理耗时较长时,第二个请求排队等半天。注意,debug=True在开发阶段很好用,改代码自动重载,但生产环境一定不要开。
启动后访问http://127.0.0.1:5000,你就能看到聊天页面。如果页面里你发现只有 HTML 结构没有样式,那是因为我为了减少文件数量,直接把样式写在页面内了,这不是 Flask 的 bug。
5. 网页端聊天界面:用原生 HTML + JavaScript 走通交互
后端 API 已经就绪,现在要让用户在浏览器里跟机器人对话。我选择不引入 Vue、React 这些前端框架,因为第一期只需要一个输入框和一个消息列表,原生 JavaScript 的fetch就足够了。框架的引入会让项目复杂度陡增,对理解前后端交互没有帮助。
5.1 聊天页面的基本结构
templates/index.html我写得很精简,核心结构如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>智能网页聊天机器人</title> <style> body { font-family: sans-serif; max-width: 720px; margin: 40px auto; background: #f5f7fb; } #chat-box { height: 400px; overflow-y: auto; border: 1px solid #ddd; border-radius: 8px; background: #fff; padding: 16px; } .message { margin-bottom: 12px; padding: 8px 12px; border-radius: 6px; max-width: 70%; } .user { background: #dbeafe; align-self: flex-end; margin-left: auto; } .bot { background: #f3f4f6; } .input-row { display: flex; gap: 8px; margin-top: 12px; } input { flex: 1; padding: 10px; border: 1px solid #ccc; border-radius: 6px; } button { padding: 10px 16px; border: none; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; } </style> </head> <body> <h2>演示聊天机器人</h2> <div id="chat-box"></div> <div class="input-row"> <input type="text" id="user-input" placeholder="输入你的问题,回车发送"> <button id="send-btn">发送</button> </div> <script src="/static/chat.js"></script> </body> </html>注意样式里我用.user设置margin-left: auto,让用户消息靠右显示,给机器人消息保留默认靠左,这样聊天气泡一左一右,视觉上更像聊天工具。前端静态文件chat.js放在static目录下,Flask 默认会从这个目录提供静态资源。
5.2 使用 fetch 发送请求:不刷新页面的关键在于这步
static/chat.js的代码:
const chatBox = document.getElementById("chat-box"); const input = document.getElementById("user-input"); const sendBtn = document.getElementById("send-btn"); function appendMessage(text, sender) { const div = document.createElement("div"); div.className = "message " + (sender === "user" ? "user" : "bot"); div.textContent = text; chatBox.appendChild(div); chatBox.scrollTop = chatBox.scrollHeight; } async function sendMessage() { const text = input.value.trim(); if (!text) return; appendMessage(text, "user"); input.value = ""; try { const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }) }); const data = await response.json(); appendMessage(data.reply, "bot"); } catch (err) { appendMessage("网络或服务出错了,请稍后再试。", "bot"); } } sendBtn.addEventListener("click", sendMessage); input.addEventListener("keydown", (e) => { if (e.key === "Enter") sendMessage(); });这个交互模型非常简单:用户点击发送或按回车,前端把输入框内容追加到聊天区显示,然后通过fetch异步发给后端,拿到data.reply后再追加到聊天区。整个过程页面完全不会刷新。await和async让代码看起来像同步逻辑,实际上不会阻塞界面。
有一个体验细节值得注意:发送后,后端推理可能需要一秒左右,如果用户连续点发送,前端会发出多个请求。好消息是 Flask 端是线程安全的,每个请求独立处理;坏消息是回复顺序可能和请求顺序不完全一致。要解决这个问题需要加请求序号,第一期我觉得没必要,但要有这个意识。
5.3 几个常见联调问题:中文乱码和跨域
联调过程中,新手最容易遇到两个问题。
第一个是中文乱码。前端页面通常没问题,但如果你直接用命令行curl测试后端接口时发现中文乱码,很可能是终端编码问题,不一定是接口问题。后端返回 JSON 时,Flask 默认会以 UTF-8 编码,只要前端页面在<head>里声明了<meta charset="UTF-8">,显示就不会乱。
第二个是跨域问题。如果你不是通过http://127.0.0.1:5000访问页面,而是直接在文件管理器里双击 HTML 文件打开,那么页面地址是file:///...,而接口地址是http://127.0.0.1:5000,这属于跨域请求,浏览器会拦截。解决办法很简单:通过 Flask 启动的地址来访问页面,也就是http://127.0.0.1:5000。如果确实需要前后端分离部署,可以在 Flask 里加 CORS 配置,但第一期完全不必。
这一节运行成功后,你应该能体验到完整的交互闭环。接下来我们要做的,不是急着加功能,而是把模型和接口放在真实测试环境里打磨。
6. 联调实测与坑点记录:当机器人答非所问时
接口通了、页面能聊了,最激动人心但也最容易暴露问题的时刻就是联调。我拿几个典型输入做了一轮完整的实测,这里面暴露出的问题,比写十段代码都更有价值。
6.1 实测场景:正常问题、模糊问题与完全无关的问题
我准备了六组测试输入,记录模型返回的意图和置信度,结果如下:
| 用户输入 | 模型判断意图 | 置信度 | 最终回复 |
|---|---|---|---|
| 你好 | greeting | 0.98 | 你好呀,我是演示机器人 |
| 在不在 | greeting | 0.91 | 你好呀,我是演示机器人 |
| 今天会下雨么 | weather | 0.73 | 演示机器人暂时查不了天气 |
| 讲个冷笑话 | joke | 0.86 | 返回了笑话 |
| 喂? | greeting | 0.42 | 触发了兜底回复 |
| 帮我写一首诗 | greeting | 0.35 | 触发了兜底回复 |
前四组测下来都符合预期,第五组"喂?"很有意思,模型觉得它像问候,又不太确定,置信度只有 0.42。第六组"帮我写一首诗",模型猜测重叠到 greeting 上,但概率也只有 0.35。
这两个例子最能说明阈值的重要性:如果我们不看置信度,直接返回 greeting 回复,用户会觉得这个机器人完全没听懂。加了 0.65 的阈值之后,低置信度的输入全部走兜底回复,体验反而更诚实。第一期做演示项目,用户对"我还没理解"这种回复的容忍度,远高于对"答非所问"的容忍度。
6.2 阈值到底怎么设:观察置信度分布比拍脑袋可靠
我不建议把阈值拍脑袋设成 0.7 或 0.5。更靠谱的办法是收集一批测试问题,把预测的置信度打印出来观察。我通常会建立一个类似下面的测试列表:
test_cases = [ ("你好", "greeting"), ("再见", "bye"), ("今天天气怎么样", "weather"), ("讲个笑话", "joke"), ("帮我写一首诗", None), ("你好吗", "greeting"), ] for text, expected in test_cases: intent, conf = predict_intent(text) print(f"{text}: {intent}, {conf:.4f}")跑一遍以后,把那些expected为空但置信度仍然偏高的输入单独分析,判断是训练数据里缺少相似表达,还是模型唯一特征没学好。阈值的作用是兜底,不是优化,真正要提升识别准确率,还得回到训练数据质量上。
6.3 踩坑记录:四个让我排查了很久的问题
第一个坑是 Tokenizer 的 word_index 不一致。我一开始在 Flask 代码里重新创建了一个 Tokenizer 并重新拟合训练文本,然后把它保存到 pickle。表面上看没有任何报错,但实际推理时,由于分词器的词索引映射和训练时不一致,模型接收的序列完全错误,预测结果乱成一团。解决方法是只在训练阶段创建并拟合 Tokenizer,然后一步到位保存同一个对象,服务端直接加载,绝不要重新拟合。
第二个坑是模型输入长度不匹配。我在 3.4 节已经提到,用model.input_shape[1]而不是硬编码长度。这里再补充一个细节:.keras格式保存的模型,input_shape是包含None的,比如(None, 9),所以取[1]是正确的索引,取[0]会得到None。
第三个坑是训练样本太少导致过拟合。我最初每个意图只给了两条样本,训练准确率很快就到 100%,但换一句说法就识别不出来。后来我把每个意图的 patterns 扩充到 5 条以上,并刻意加入一些噪声表达,效果明显改善。对于小数据量的意图分类,样本覆盖面远比模型结构重要。
第四个坑是 Flask 开发服务器在 Windows 上的 reloader 问题。开了debug=True时,如果模型加载在新线程里执行,偶尔会出现重复加载或内存暴涨。我后来把模型加载放在全局作用域,并且用use_reloader=False来规避这类玄学问题。开发时改代码不自动重启虽然麻烦点,但模型常驻内存的项目用这种方式更省心。
6.4 给并发测试留个心眼
虽然第一期没打算做高并发,但我看到很多同学喜欢开着浏览器多个标签页测试,这其实已经算是并发请求了。Flask 开发服务器默认是单线程的,一个请求在跑 TensorFlow 推理的时候,第二个请求只能排队。我在app.run里显式加了threaded=True,实测两个标签页同时提问也能正常返回,没有出现相互阻塞。不过这只是开发服务器的能力边界,真要部署上线,还得换成 Gunicorn 配合多 worker。这个话题留给后续篇章展开。
7. 第一期收尾:从能跑到能改的代码才算入门
到这一步,你的智能网页聊天机器人已经具备完整的最小闭环:网页输入、后端识别意图、返回预设回复。我在实际调试过程中体会最深的一点是,这个项目真正的门槛不在 TensorFlow 也不在 Flask,而在于把"模型训练产物"和"Web 服务运行时"这两套生命周期完全不同的东西,正确、稳定地组装在一起。很多人写训练脚本很顺,一到服务化就崩,往往是忽略了 Tokenizer 保存、输入长度对齐、模型加载时机等细节。
另外一个我觉得很值得养成的习惯是:所有接口返回的 JSON 里,除了给用户看的reply,一定要带上intent和confidence。这两个字段在你调试时是救命稻草,能立刻告诉你"机器人答非所问是模型猜错了,还是回复表查错了"。等你积累了一定量的真实用户输入,再回头把这些日志喂给训练集,模型会越用越准。
第一期我们刻意没有引入数据库、没有做多轮上下文、也没有用更大规模的预训练语言模型。不是这些技术不重要,而是它们都应该建立在稳定的基础闭环之上。下一期我会优先做两件事:一是把意图识别的训练数据扩充到 50 个类别以上,并加入简单的同义词扩展;二是引入会话级记忆,让机器人能够记住前面几轮聊天的关键信息。你可以先把当前项目跑熟,再准备数据,等下一篇文章发布后直接按新的工程结构扩展就行。