
很多读者在学完 Python 基础之后都会萌生一个念头做一个属于自己的聊天机器人。网上现成的对话机器人项目并不少但要么重度依赖大模型 API申请 key、付费、调参一条龙入门成本偏高要么工程结构过于复杂路由、ORM、消息队列一堆概念让人根本没法下手还有一类是纯粹的玩具代码只能按固定规则回固定话术看不出一个对话系统应有的样子。本文准备换个思路不依赖重框架用一个容易二次开发的轻量架构带你把一个叫 airi酱 的私人 AI 助手从 0 到 1 搭起来。这个项目虽然代码量不大但不等于简陋。它会包含对话系统里最重要的几个模块意图识别、上下文记忆、回复生成、HTTP 服务接口以及如何接入常见聊天平台的思路。跑通之后你可以把它继续扩展成团队值班机器人、个人知识库问答入口、接口异常通知助手甚至作为你接入大模型 API 之前的本地底座。文章每一段代码都会说明“为什么这么写”最后还会整理常见报错和工程化建议新手照着敲有经验的读者也能快速拿去改造。1. 背景与核心概念1.1 airi酱 是什么airi酱 是一个轻量对话助手项目的名字。你可以把它理解成一个“有名字、能记忆、能回答问题、能被外部程序调用”的机器人服务。它并不是某个商业产品的名称而是我们自己定义的一个项目。你完全可以给它改成其他名字比如 “小默”“阿泽” 之类架构完全不用变。起名字这件事看起来不起眼但在对话系统设计里其实有讲究机器人拥有稳定的称呼会让使用者更容易接受“对面是一个有身份的助手”而不是一堆代码拼接出来的回复。从技术层面看airi酱 需要具备以下能力接收用户输入判断用户想做什么意图识别。根据意图生成自然回复回复生成。能记住最近几轮对话内容必要的时候记住用户信息上下文记忆。以 HTTP 接口的方式对外提供服务方便其他系统或聊天平台调用。这四件事就是绝大多数对话机器人的最小闭环。1.2 为什么需要一个私人 AI 助手很多人第一反应是现在大模型那么强为什么不直接接大模型 API还要自己写规则这个问题很实际。但你需要明白大模型 API 解决的是“生成内容”的问题而一个真正能落地到业务里的机器人还要解决“怎么接入业务”“怎么管控权限”“怎么记录上下文”“怎么在出问题时快速降级”这些工程问题。自己从零搭一个轻量助手价值主要有三点第一学习对话系统的核心概念。意图识别、槽位提取、上下文管理、回复策略这些是 NLP 和机器人开发的基础。如果一开始就接大模型 API这些概念很容易被一带而过。第二快速落地重复性问答。团队里常见的“密码是多少”“测试环境地址是什么”“发版流程怎么走”用规则加知识库就能解决根本不需要动用大模型响应快、成本低、结果稳定可控。第三作为后续接大模型的“底座”。airi酱 设计时会把回复生成层单独封装将来你要接入大模型 API只需要替换一个实现类其余接口、记忆、日志逻辑都可以复用。1.3 常见应用场景airi酱 这类轻量助手的典型场景包括团队群值班机器人定时推送发版提醒、线上日志告警、值班安排群成员也可以通过 机器人 查询信息。个人助手放到自己的服务器上通过接口查询时间、天气、待办事项或者作为语音助手的文本处理后端。学习实验用一套完整的工程结构来理解对话系统之后再往里面加 NLP 模型、知识图谱、大模型 API 都方便。内部工具入口把一些内部命令封装成对话技能例如输入“创建测试用户”就自动调后台接口完成操作。1.4 技术选型思路airi酱 的技术栈选择遵循“够用、好懂、好扩展”的原则Python开发效率高NLP 生态丰富适合快速原型。Flask轻量 Web 框架写一个/chat接口非常直接不引入 Django 的重量级概念。规则式意图识别先用正则和关键词实现等数据量大了再替换成机器学习模型或大模型 API。deque 记忆窗口用 Python 标准库里的双端队列保存最近 N 轮对话避免内存无限增长。很多初学者容易犯一个错误一上来就追求“智能”结果被各种框架和技术栈淹没。正确做法是先跑通最小闭环再逐步替换薄弱模块。airi酱 的架构就是按照这个思路设计的。2. 环境准备与项目初始化2.1 开发环境清单在开始写代码之前先确认环境。本文示例以常见环境为例具体版本需要根据你的项目实际情况调整。操作系统Windows 10/11、macOS、Linux 均可。Python建议 3.8 及以上版本。pipPython 自带用于安装依赖。Flask2.x 系列即可。requests调用外部 HTTP 接口时使用。pytest运行单元测试。工具任意文本编辑器或 IDE推荐 VS Code 或 PyCharm测试接口可以用 Postman 或命令行 curl。检查命令python --version pip --version如果 Python 版本过低建议先升级否则部分语法特性可能无法使用。2.2 项目目录结构创建一个airi_chan目录“chan” 是日语 “酱” 的常见罗马音写法目录结构如下airi_chan/ ├── app.py # Flask HTTP 入口 ├── assistant.py # airi酱 核心对话逻辑 ├── memory.py # 对话记忆模块 ├── config.py # 基础配置 ├── notifier.py # 企业微信群机器人推送示例 ├── wechat.py # 微信公众号接入示例思路 ├── test_assistant.py # pytest 单元测试 ├── requirements.txt # 依赖清单 └── README.md # 项目说明把不同职责拆到不同文件里是为了让代码更容易维护。对话逻辑、记忆存储、HTTP 入口分开后修改其中一块不会影响其他部分。2.3 创建依赖清单在项目目录下创建requirements.txtflask2.2,3.0 requests2.28 pytest7.0安装依赖cd airi_chan python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install -r requirements.txt这里使用虚拟环境venv目的是隔离项目的第三方包避免和系统其他 Python 包互相污染。这是一个从第一天就该养成的习惯。3. 核心设计对话系统的基本组成在写代码之前先把对话系统的几条主线理清楚。这样后面读代码时你会知道每一段代码在整条链路里扮演什么角色。3.1 一次对话请求的完整链路当用户向 airi酱 发送一条消息时数据会经过以下链路用户发送 HTTP 请求到/chat接口请求体是 JSON包含message字段。Flask 解析请求把用户消息交给AiriAssistant对象的reply方法。reply方法先做空值校验然后调用_match_intent进行意图匹配。根据匹配结果调用对应的意图处理函数生成回复文本。把用户消息和助手回复写入对话记忆。将回复包装成 JSON 响应返回给调用方。这条链路非常直观也便于调试。你可以在任意一步打印日志定位问题出在“入口解析”“意图识别”还是“回复生成”。3.2 意图识别模块意图识别解决的是“用户这句话想干什么”的问题。最小可用的实现方式是用正则表达式和关键词匹配。例如包含“你好”“嗨”“hello”→ 打招呼意图。包含“几点”“日期”“星期”→ 查询时间意图。包含“我叫”“我是”→ 用户自我介绍意图。匹配不到任何规则时走默认回复分支告诉用户“我暂时没理解”。真实项目里意图识别往往用分类模型。但规则式实现有三个好处零成本、可解释、容易快速迭代。先跑通规则再根据对话日志积累训练数据是更稳妥的演进路径。3.3 上下文管理上下文管理是对话系统和新手玩具代码之间的一道分水岭。没有记忆的机器人每次对话都是“失忆”的用户说“我叫小李”下一句问“我叫什么”机器人就答不上来。airi酱 的上下文管理包含两层一是最近几轮对话的历史记录用于将来拼接上下文喂给模型或统计会话走势。二是用户长期属性例如记住用户的名字。后者虽然只是一个简单变量但体现的是“状态”设计思想。实现上对话历史用collections.deque(maxlenN)存储。deque的好处是当元素超过maxlen时会自动丢弃最旧的数据正好符合“只保留最近 N 轮”的需求不需要手动维护列表长度。3.4 回复生成回复生成是对话系统里最灵活的部分。在 airi酱 里每个意图对应一个处理函数函数返回字符串作为回复文本。这样做的好处是每个意图的回复逻辑独立互不干扰。以后要接大模型 API只需把默认回复函数替换为调用 API 的实现。回复内容可以在函数内部组合变量例如把当前时间拼进回复里。这种“意图 → 处理函数 → 回复文本”的结构是对话机器人比较通用的设计模式。4. 完整实战从 0 到 1 搭建 airi酱下面进入实战环节。请按顺序创建文件代码可以直接复制使用。4.1 编写记忆模块文件路径airi_chan/memory.py# memory.py from collections import deque class ConversationMemory: 基于 deque 的对话记忆只保留最近 N 轮对话 def __init__(self, max_turns10): self.max_turns max_turns self.history deque(maxlenmax_turns) def add(self, role: str, content: str) - None: 记录一轮消息role 取值为 user 或 assistant self.history.append({role: role, content: content}) def get_history(self) - list: 返回完整的对话历史列表 return list(self.history) def clear(self) - None: 清空所有对话历史 self.history.clear()这里把记忆模块单独封装而不是直接写在 assistant 里是为了将来替换存储层更方便。现在用的是默认内存实现以后可以扩展一个RedisMemory类接口保持一致。4.2 编写助手核心逻辑文件路径airi_chan/assistant.py# assistant.py import random import re from datetime import datetime from memory import ConversationMemory class AiriAssistant: airi酱 助手核心类负责意图识别与回复生成 def __init__(self, nameairi酱, max_turns10): self.name name self.memory ConversationMemory(max_turnsmax_turns) self.user_name None def reply(self, message: str) - str: 对外暴露的回复入口 if not message or not message.strip(): return 请说点什么吧我在听。 message message.strip() handler self._match_intent(message) reply handler(message) # 更新记忆 self.memory.add(user, message) self.memory.add(assistant, reply) return reply def _match_intent(self, message: str): 规则式意图匹配返回对应的处理函数 if re.search(r(你好|您好|嗨|哈喽|hi|hello|在吗), message, re.IGNORECASE): return self._handle_greet if re.search(r(时间|几点|日期|几号|星期), message): return self._handle_time if re.search(r(你是谁|你叫什么|介绍一下你自己), message): return self._handle_whoami if re.search(r(天气|气温|下雨|温度), message): return self._handle_weather if re.search(r(谢谢|感谢|辛苦), message): return self._handle_thanks if re.search(r(再见|拜拜|下次聊|bye), message, re.IGNORECASE): return self._handle_bye if re.search(r(我叫|我是|我的名字是), message): return self._handle_introduce return self._handle_default def _handle_greet(self, message: str) - str: return f你好呀我是{self.name}有什么可以帮你的吗 def _handle_time(self, message: str) - str: now datetime.now() week_map [一, 二, 三, 四, 五, 六, 日] return ( f现在是 {now.strftime(%Y-%m-%d %H:%M:%S)} f星期{week_map[now.weekday()]}。 ) def _handle_whoami(self, message: str) - str: return ( f我是{self.name}一个基于 Python 搭建的轻量对话助手。 目前支持时间查询、简单闲聊和上下文记忆 后续可以扩展天气、TODO、知识问答等技能。 ) def _handle_weather(self, message: str) - str: # 示例中返回占位信息真实项目可申请天气 API 后替换此实现 return 天气功能还没有接入真实数据源。你可以参考文中思路申请一个天气 API 后替换此实现。 def _handle_thanks(self, message: str) - str: return 不客气还有其他需要帮忙的吗 def _handle_bye(self, message: str) - str: return 再见啦下次聊 def _handle_introduce(self, message: str) - str: m re.search( r(?:我叫|我是|我的名字是)\s*([\u4e00-\u9fa5A-Za-z0-9]{1,10}), message, ) if m: self.user_name m.group(1) return f你好{self.user_name}我已经记住你的名字啦。 return 可以告诉我你的名字吗比如“我叫小明”。 def _handle_default(self, message: str) - str: fallbacks [ 这个问题我暂时还不会正在学习中。, 我还没理解你的意思换个说法试试, 你可以问我时间、让我记住你的名字或者和我打个招呼。, ] return random.choice(fallbacks)几个关键点说明第一个是_match_intent返回的是函数对象而不是字符串。这样每个意图只需在规则表里注册一次后续处理函数内部逻辑变化不影响匹配流程。第二个是re.search而不是re.match。search会扫描整段文本只要包含关键词就能命中更适合口语化输入match要求从头匹配在真实对话场景里很容易漏掉意图。第三个是记忆更新放在reply方法的统一位置而不是散落到每个意图处理函数里。这样确保无论命中哪个意图对话历史都会被记录。4.3 编写配置与 HTTP 服务文件路径airi_chan/config.py# config.py class Config: 基础配置项生产环境建议通过环境变量覆盖 HOST 0.0.0.0 PORT 5000 BOT_NAME airi酱 MAX_HISTORY_TURNS 10文件路径airi_chan/app.py# app.py from flask import Flask, jsonify, request from assistant import AiriAssistant from config import Config app Flask(__name__) assistant AiriAssistant( nameConfig.BOT_NAME, max_turnsConfig.MAX_HISTORY_TURNS, ) app.route(/health, methods[GET]) def health(): 健康检查接口便于部署后确认服务状态 return jsonify({code: 0, status: ok}) app.route(/chat, methods[POST]) def chat(): 对话接口接收 JSON 格式的 {message: 你好} data request.get_json(silentTrue) # 参数校验 if not data or not isinstance(data, dict) or message not in data: return ( jsonify({code: 400, message: 请求体必须是 JSON且包含 message 字段}), 400, ) message str(data.get(message, )).strip() if not message: return jsonify({code: 400, message: message 不能为空}), 400 try: reply assistant.reply(message) return jsonify({code: 0, reply: reply}) except Exception: # 生产环境建议记录完整堆栈到日志文件 app.logger.exception(处理对话请求失败) return jsonify({code: 500, message: 服务内部错误}), 500 if __name__ __main__: # 生产环境建议使用 gunicorn 启动不要直接使用 Flask 开发服务器 app.run(hostConfig.HOST, portConfig.PORT, debugFalse)这里的参数校验很多人会忽略。get_json(silentTrue)在请求体不是合法 JSON 时会返回None配合后面的not data判断可以避免接口直接抛 400 异常。每一层边界都守好线上服务才稳定。注意app.run(debugFalse)。开发时debugTrue可以自动重启但生产环境绝不能开启 debug 模式否则会暴露调试器带来严重安全隐患。4.4 运行与测试启动服务cd airi_chan python app.py看到如下输出说明启动成功* Running on all addresses (0.0.0.0) * Running on http://127.0.0.1:5000打开另一个终端用 curl 测试curl -X POST http://127.0.0.1:5000/chat \ -H Content-Type: application/json \ -d {message:你好}预期返回{code: 0, reply: 你好呀我是airi酱有什么可以帮你的吗}继续测试时间查询和记忆功能curl -X POST http://127.0.0.1:5000/chat \ -H Content-Type: application/json \ -d {message:现在几点} curl -X POST http://127.0.0.1:5000/chat \ -H Content-Type: application/json \ -d {message:我叫小李}最后一条请求后airi酱 会回答“你好小李我已经记住你的名字啦。”注意这里“记住”目前只是保存在进程内存变量里服务重启后名字就会丢失。要持久化需要接入 Redis 或数据库这一点在后面的最佳实践里会提到。4.5 编写单元测试文件路径airi_chan/test_assistant.py# test_assistant.py from assistant import AiriAssistant def test_greet(): bot AiriAssistant() reply bot.reply(你好) assert airi酱 in reply def test_time(): bot AiriAssistant() reply bot.reply(现在几点) assert 现在是 in reply def test_memory(): bot AiriAssistant() bot.reply(我叫小李) assert bot.user_name 小李 def test_empty_message(): bot AiriAssistant() reply bot.reply( ) assert 请说点什么吧 in reply运行测试pytest -v看到 4 个用例全部通过说明核心逻辑的基本行为符合预期。写单元测试的意义在于后面你修改意图匹配规则或回复文案时能第一时间发现是否破坏了原有功能。4.6 扩展接入企业微信群机器人airi酱 的 HTTP 接口搭好后接入聊天平台就变成了“把平台消息转发给接口”的活。这里以企业微信群机器人为例展示推送消息的实现思路。文件路径airi_chan/notifier.py# notifier.py import requests def send_wecom_group_message(webhook_url: str, content: str) - dict: 发送文本消息到企业微信群机器人。 webhook_url 由企业微信群机器人生成形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxxx payload { msgtype: text, text: {content: content}, } resp requests.post(webhook_url, jsonpayload, timeout10) resp.raise_for_status() return resp.json()调用示例from notifier import send_wecom_group_message webhook https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key你的key send_wecom_group_message(webhook, airi酱 服务已启动监控正常。)注意webhook 地址包含群机器人的密钥一定不能提交到公开仓库。建议通过环境变量读取而不是硬编码在代码里。4.7 扩展微信公众号接入思路微信公众号接入本质上分两步验证服务器地址然后被动接收用户消息并回复。这里给出核心代码思路具体字段会因公众号类型不同而有差异请以微信官方文档为准。文件路径airi_chan/wechat.py# wechat.py import hashlib import time from xml.etree import ElementTree as ET from flask import Flask, request from app import assistant WECHAT_TOKEN your_random_token wechat_app Flask(__name__) def check_signature(token, timestamp, nonce, signature): 微信公众号签名校验token、timestamp、nonce 排序后做 SHA1 tmp_arr [token, timestamp, nonce] tmp_arr.sort() tmp_str .join(tmp_arr) return hashlib.sha1(tmp_str.encode(utf-8)).hexdigest() signature def parse_user_message(xml_data): 从微信推送的 XML 中解析用户消息这里只提取文本内容 root ET.fromstring(xml_data) return root.findtext(Content) or def build_text_reply(xml_data, reply_content): 构造被动回复的 XML 结构 root ET.fromstring(xml_data) from_user root.findtext(FromUserName) to_user root.findtext(ToUserName) reply_xml f xml ToUserName![CDATA[{from_user}]]/ToUserName FromUserName![CDATA[{to_user}]]/FromUserName CreateTime{int(time.time())}/CreateTime MsgType![CDATA[text]]/MsgType Content![CDATA[{reply_content}]]/Content /xml return reply_xml.strip() wechat_app.route(/wechat, methods[GET, POST]) def wechat_entry(): if request.method GET: # 首次接入时微信后台会发起 GET 请求验证服务器 signature request.args.get(signature, ) timestamp request.args.get(timestamp, ) nonce request.args.get(nonce, ) echostr request.args.get(echostr, ) if check_signature(WECHAT_TOKEN, timestamp, nonce, signature): return echostr return signature check failed, 403 # POST接收用户消息并回复 xml_data request.data user_msg parse_user_message(xml_data) reply assistant.reply(user_msg) return build_text_reply(xml_data, reply) if __name__ __main__: wechat_app.run(host0.0.0.0, port5001, debugFalse)这里要特别说明示例代码把微信公众号入口拆成了独立 Flask 应用是为了演示逻辑清晰。实际部署时更推荐把/chat、/health、/wechat等路由合并到同一个 Flask 应用里用 Blueprint 组织不同模块这样只需要启动一个服务进程统一管理也方便。安全方面公众号的 token 必须严格保密并且只允许微信服务器调用该接口建议加上 IP 白名单。5. 常见问题与排查思路新手在搭建 airi酱 的过程中大概率会遇到下面这些报错。这里整理成一张速查表然后对高频问题展开说明。问题现象常见原因解决思路ModuleNotFoundError: No module named flask未安装依赖或未激活虚拟环境执行pip install -r requirements.txt确认虚拟环境已激活OSError: [Errno 98] Address already in use5000 端口被占用修改config.py端口或使用lsof -i:5000查找进程中文输出乱码控制台编码不是 UTF-8Linux 执行export PYTHONIOENCODINGutf-8Windows 切换代码页多用户同时使用上下文串了用全局变量保存会话改为按 session_id 维度存储生产环境用 Redis微信公众号验证失败token 不一致或签名算法写错核对后台 token确认排序加 SHA1 后比较接口返回 500关键路径异常未捕获看日志堆栈给业务代码增加异常处理5.1Address already in use端口被占用这是 Flask 开发时最常见的报错之一。因为默认端口 5000 经常被其他进程占用。排查步骤lsof -i:5000找到占用进程的 PID 后可以结束该进程也可以直接在config.py里改端口PORT 5001建议优先改端口不要轻易 kill 系统里不认识的进程避免误伤其他服务。5.2 中文乱码问题如果在终端里看到类似的乱码通常是编码问题。程序本身的文件保存的是 UTF-8但终端输出时使用了其他编码。Linux/macOS 下执行export PYTHONIOENCODINGutf-8Windows PowerShell 下可以先把当前代码页切换为 UTF-8chcp 65001另外所有源文件都要用 UTF-8 编码保存这一点在编辑器的右下角状态栏可以确认。5.3 多用户上下文互相串扰当多个用户同时调用 airi酱 的/chat接口时所有请求都共用同一个AiriAssistant实例self.user_name就成了全局状态。用户 A 说“我叫小李”用户 B 来问“我叫什么”可能会得到小李的名字。这个问题本质上是因为对话状态没有按用户隔离。解决方法是在接口层引入session_id每个用户一个会话实例或者把上下文存储到 Redis以session_id作为 key。生产环境必须处理这个问题否则机器人完全无法多人使用。6. 最佳实践与工程化建议airi酱 跑通只是第一步。真正把它用到项目里还需要补齐下面这些工程细节。6.1 配置管理代码里不要出现明文密钥和 webhook 地址。推荐把配置从代码中剥离通过环境变量注入。比如export BOT_NAMEairi酱 export WECHAT_TOKENyour_token export WECOM_WEBHOOK_URLhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxxxPython 侧读取环境变量import os BOT_NAME os.getenv(BOT_NAME, airi酱) WECHAT_TOKEN os.getenv(WECHAT_TOKEN, )这样项目代码可以公开密钥留在服务器环境变量或部署平台的 Secret 管理里。6.2 日志与可观测性不要把print当作日志。建议使用 Python 标准库logging按级别输出并配置文件输出import logging logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(name)s %(message)s, filenameairi.log, )日志至少要记录每次对话请求的时间、用户消息、回复内容、耗时、是否有异常。这些数据不仅能帮你排查问题还能用于分析用户高频提问反哺意图规则优化。6.3 安全边界airi酱 的/chat接口如果直接暴露在公网任何人都能调用可能存在滥用风险。建议做好三层防护第一接口鉴权。在请求头里增加 token服务端校验通过才处理。例如from flask import request API_TOKEN your_api_token app.route(/chat, methods[POST]) def chat(): token request.headers.get(X-API-Token, ) if token ! API_TOKEN: return jsonify({code: 403, message: forbidden}), 403 # 后续处理逻辑第二限流。对接入频率做限制防止被刷接口。单机场景可以用简单的计数器分布式场景可以用 Redis 的滑动窗口。第三不追踪敏感信息。如果 airi酱 接入到业务系统要注意日志里不能出现密码、身份证号、手机号等敏感字段必要时对日志做脱敏处理。6.4 生产部署生产环境不要用python app.py启动。Flask 自带的是开发服务器性能和安全都不适合生产。推荐使用 gunicorn 启动gunicorn -w 4 -b 0.0.0.0:5000 app:app-w 4表示启动 4 个 worker 进程。这里注意多进程下内存里的ConversationMemory不会共享因为每个 worker 是独立进程。如果需要跨 worker 共享会话状态必须把记忆层替换为 Redis 实现。此外建议在 Flask 前面加一层 Nginx 做反向代理统一处理 HTTPS 证书、静态资源、访问日志和基础限流Flask 只负责业务逻辑。6.5 扩展能力设计airi酱 的意图处理函数目前是平铺在assistant.py里的。技能变多以后建议把每个技能拆成独立模块通过一个“技能注册表”统一管理。例如skills [] def register_skill(keyword_pattern, handler): skills.append({pattern: keyword_pattern, handler: handler})新增技能时只需要写一个处理函数然后注册关键词规则不需要改动reply主流程。这个模式对应到后端领域就是插件化设计的思想可以显著降低后续维护成本。6.6 接入大模型 API 的替换思路当规则式对话满足不了需求可以考虑把回复生成层替换为大模型 API。核心改造点只有一个把_handle_default等回复函数的内容替换为调用大模型接口的逻辑。示例思路如下def _handle_llm(self, message: str) - str: messages [{role: system, content: 你是 airi酱}] for item in self.memory.get_history(): messages.append({role: item[role], content: item[content]}) # 调用你可用的大模型