最近我把一个基于 PyQt6 的 AI Agent 智能客服平台开源了,代码全部整理干净放到了 GitHub 上。当初做这个项目的时候,找遍了全网也没看到几个把 PyQt6 和 AI Agent 串起来的完整案例,大多数开源项目都是 Web 页面,桌面端智能客服几乎是一片空白。所以我把整个实战过程里最核心的设计思路、关键代码、踩坑记录都整理出来,希望能给打算在桌面端做 AI 应用的朋友一点参考。
这个项目不是一个简单的聊天机器人 demo,而是一个完整的智能客服平台。它支持多轮对话、知识库检索、意图识别、人工接管,以及基于 Agent 的自动任务执行。界面层全部用 PyQt6 实现,底层接的是大模型 API,中间用 Agent 架构做意图判断和工具调用。无论你是想学 PyQt6 桌面开发,还是想了解 AI Agent 在真实业务里怎么落地,或者只是需要一个开箱即用的客服系统底座,这篇文章都值得看完。
1. 为什么用 PyQt6 来做 AI Agent 智能客服客户端
1.1 项目定位与选型考量
先说说这个项目最初的需求背景。当时我在做一个面向企业内部的知识问答系统,需要一个客服客户端,要求能跑在 Windows 和 Linux 上,界面响应快,能对接内部知识库,最好还能支持工单自动处理。第一个跳进脑子里的方案自然是 Web 前端,毕竟 React 生态成熟,组件库齐全,后端接个大模型就行。可我越想越觉得不对劲:一套完整的 Web 应用意味着要维护前端工程、后端服务、部署环境、用户权限体系,哪怕是个内部工具,这些成本一点都省不掉。
我重新梳理需求后发现,这个客服平台只需要少数几个管理员和客服人员使用,不需要对外开放,也不需要多租户隔离。这种场景下,桌面客户端反而更合适。数据直接落在本地,不用搭服务器,双击就能启动,资源占用还低。
在几个桌面技术方案里,Tkinter 太简陋,做现代感 UI 要自己画太多东西;Electron 本质是套壳浏览器,内存吃紧;Qt 的 Python 绑定 PyQt6 和 PySide6 则是我认为的最优解。PyQt6 的控件系统非常成熟,QSS 可以做任意样式的美化,信号槽机制天生适合处理异步消息,配合 QThreadPool 很容易实现多线程的 AI 请求并发。最终我敲定了 PyQt6 + Agent 架构的组合。
1.2 PyQt6 在 AI Agent 场景下的优势
可能有人会问,AI Agent 的核心逻辑在后端和大模型,客户端只是展示和交互,用什么 GUI 框架真的重要吗?我实际做完之后可以负责任地说,非常重要。
PyQt6 有一个其他框架很难替代的优势:事件循环和信号槽机制天然契合异步 AI 请求。大模型 API 动辄几秒到几十秒响应,期间不能让界面卡死。用 PyQt6 的 QThreadPool + QRunnable 把请求扔到后台线程,用 signal 把流式输出一段一段传回主线程刷新界面,整个过程非常顺滑。这在 Web 前端需要写一堆 callback 或者状态管理,在 PyQt6 里就是 signal 和 slot 的一次连接。
另外,PyQt6 的 QTextEdit 和 QListWidget 非常适合做消息展示,配合 QSS 自定义样式,能做出非常精致的聊天气泡效果。我用 QSS 实现了类似微信的左右气泡布局,用户消息靠右,AI 回复靠左,输入框圆角化,整体视觉效果不输 Web 版。对于需要本地文件读取、知识库导入、工单导出的客服场景,PyQt6 的 QFileDialog 和 QTableWidget 更是直接省去了写一堆文件上传下载接口的麻烦。
还有一点容易被忽略:PyQt6 是 GPL 许可证,如果你的项目不开源只内部使用,没有任何问题;如果像我一样准备开源,那也正好契合 GPL 精神。当然 PySide6 是 LGPL 更适合商业闭源,这个后面我会详细对比。
2. 整体架构与核心模块拆解
2.1 项目目录结构设计
项目结构是开源的命门,结构不好,读者打开仓库第一眼就劝退了。我最终确定的目录结构如下:
ai-customer-service/ ├── main.py # 程序入口 ├── requirements.txt ├── config/ │ └── config.toml # 全局配置文件 ├── core/ │ ├── agent/ │ │ ├── agent.py # Agent 核心调度 │ │ ├── memory.py # 对话记忆管理 │ │ ├── tools.py # 工具调用注册 │ │ └── prompts.py # 提示词模板 │ ├── llm/ │ │ └── llm_client.py # 大模型 API 统一封装 │ └── knowledge/ │ ├── loader.py # 知识文档加载 │ └── retriever.py # 检索逻辑 ├── database/ │ ├── models.py # 数据库模型 │ └── db_manager.py # SQLite 操作封装 ├── ui/ │ ├── main_window.py # 主窗口 │ ├── chat_widget.py # 聊天区域 │ ├── input_widget.py # 输入区域 │ ├── side_panel.py # 侧边面板 │ └── style.qss # 全局样式表 └── tests/ └── test_agent.py这样的分层思路很明确:core 目录纯后端逻辑,不依赖任何 Qt 组件,方便单测;ui 目录只负责界面展示和事件转发;database 独立封装存储;config 负责所有可调参数。这样一来,如果你想换掉 PyQt6 改用其他 GUI 框架,core 部分的 Agent 代码完全不用动。
2.2 核心模块职能拆解
Agent 调度模块是整个项目的大脑。它承担三个职责:第一,解析用户输入;第二,决定调用哪个工具;第三,组织上下文拼装提示词。我参考了业界常见的 ReAct 架构,让模型可以交替执行“思考 - 行动 - 观察”循环,直到生成最终回答。
LLM 客户端模块负责对接各家大模型 API。因为不同厂商的 API 格式差异很大,我封装了一层统一的接口,支持 ChatCompletion 风格的流式输出。目前适配了 OpenAI 风格的 API,以及几家国产大模型的兼容接口,修改配置文件即可切换。
知识库模块实现了最简单的 RAG(检索增强生成)。系统加载知识文档后,按文本块做向量化,收到用户问题时先做相似度检索,把 TOP-K 相关片段注入提示词,让模型基于给定的知识内容来回答,这就大大降低了模型胡编乱造的概率。
聊天界面模块是用户直接接触的部分。我实现了消息气泡渲染、流式打字机效果、发送状态管理、清空会话等功能。比较有技巧的地方在于消息气泡的异步更新,因为流式输出时每个 chunk 都可能触发一次界面刷新,如果性能处理不好,界面就会明显卡顿。
2.3 为什么选 SQLite 作为存储引擎
存储层面我直接用了 SQLite,没有引入 MySQL 或者 PostgreSQL。理由很直接:这是一个桌面端应用,目标部署环境就是一两台 Windows 或 Linux 机器,SQLite 零配置、单文件、稳定可靠,完全够用。
我用 SQLite 存三张表:conversations 保存会话元信息,messages 保存每一条聊天记录,knowledge_docs 记录知识文档的倒排索引。会话和消息通过 conversation_id 关联,这样用户切换会话时可以随时加载历史上下文,重启程序后聊天记录也不会丢。对比直接用 JSON 文件持久化,SQLite 的查询性能要好得多,尤其是消息量上万条之后差距非常明显。
3. PyQt6 界面开发的关键节点
3.1 从零搭建主窗口框架
主窗口我用了经典的左右分栏布局。左边是会话列表,可以新建对话、切换历史会话、删除会话;右边是聊天区域,即上面说的消息展示区加底部输入区。用 QSplitter 做分割器,用户可以自由拖动调整两侧宽度,这在 QMainWindow 里实现起来非常顺手。
# ui/main_window.py from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QSplitter from PyQt6.QtCore import Qt from ui.chat_widget import ChatWidget from ui.side_panel import SidePanel class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("AI 智能客服平台") self.resize(1200, 800) central = QWidget() self.setCentralWidget(central) layout = QHBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) splitter = QSplitter(Qt.Orientation.Horizontal) self.side_panel = SidePanel() self.chat_widget = ChatWidget() splitter.addWidget(self.side_panel) splitter.addWidget(self.chat_widget) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 3) splitter.setSizes([280, 920]) layout.addWidget(splitter) self.apply_styles()需要注意的一个细节是 splitter 的 stretchFactor。左边会话列表给 1,右边聊天区给 3,这样窗口拉大时聊天区占据更多空间。setSizes 设置初始像素宽度,注意这个值是相对值,具体表现受窗口实际大小影响。
3.2 消息气泡组件的三种实现方案对比
聊天界面最核心的组件就是消息气泡。我前后尝试了三种实现方案,每种都有各自的利弊。
方案一:QListWidget + setItemWidget。这是最直观的做法,每个 QListWidgetItem 里塞入一个自定义气泡 widget。优点是开发快,每条消息是一个独立 item,删除、插入都很方便。缺点是当消息数量很多时内存占用大,每一条消息都要构造 widget,而且 setItemWidget 之后 item 的高度计算偶尔会不准,出现相邻消息重叠的 bug。
方案二:QTextBrowser 纯文本渲染。用 HTML 排列消息,利用 QTextDocument 的富文本能力展示气泡。缺点是高度自定义的交互逻辑很难实现,比如点击消息复制、右键弹出菜单、长消息折叠等都很麻烦。
方案三:QScrollArea + QVBoxLayout 动态添加 widget。这是我最终采用的方案。外层是一个 QScrollArea,内部一个 QVBoxLayout,每来一条消息就往 layout 里 addWidget 一个气泡。配合 addStretch 让内容顶到顶部,消息多的时候自动出现滚动条。实测在 500 条消息以内滚动流畅度完全没问题。
# ui/message_bubble.py from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QSizePolicy class MessageBubble(QWidget): def __init__(self, text: str, is_user: bool, parent=None): super().__init__(parent) layout = QHBoxLayout(self) layout.setContentsMargins(12, 6, 12, 6) if is_user: layout.addStretch(1) bubble = self._create_bubble(text, user=True) layout.addWidget(bubble, 0) else: bubble = self._create_bubble(text, user=False) layout.addWidget(bubble, 0) layout.addStretch(1)气泡本身的样式我全部交给了 QSS,没有在代码里写任何颜色值。QSS 的好处是可以集中维护,换主题只需要改 style.qss 一个文件。
3.3 流式输出怎么做才不卡界面
流式输出是 AI 聊天体验的关键。想象一下,如果用户发一句“帮我查一下上个月的销售数据”,要等大模型全部生成完才一次性显示,界面上一个转圈圈好几秒,用户早就以为程序死了。流式输出的核心逻辑是:请求发出去之后,后台线程不断收到模型返回的片段,每收到一段就通过 signal 通知界面追加渲染。
这里有一个性能严重的坑:如果每收到一个 token 就触发一次文本渲染,高频刷新会让界面 CPU 占用飙升,肉眼还能看到明显的“闪烁”。我的解决方案是加了一个缓冲机制,只有累计收到一定数量的新字符(比如 20 个)或者距离上次刷新超过 100 毫秒,才真正刷新一次 UI。这样既能保证视觉上的连贯性,又不会把 UI 线程拖垮。
# core/llm/llm_client.py 中的流式回调示例 class LLMClient: def chat_stream(self, messages, on_chunk): # 调用底层 API,得到流式响应 for chunk in response: delta = chunk["choices"][0]["delta"].get("content", "") if delta: on_chunk(delta)# ui/chat_widget.py 中的缓冲刷新逻辑 class ChatWidget(QWidget): chunk_received = pyqtSignal(str) def __init__(self): self._buffer = "" self._last_update = 0 self.chunk_received.connect(self._on_chunk) def _on_chunk(self, delta: str): self._buffer += delta now = time.time() * 1000 if len(self._buffer) >= 20 or now - self._last_update > 100: self._append_buffer_to_bubble() self._last_update = now要特别提醒的是,PyQt6 的 signal 是线程安全的,但只能是 signal 跨线程传数据,不能在后台线程里直接操作任何 QWidget,否则轻则界面错乱,重则直接段错误崩溃。这个规矩一定要记住,我早期有几次把 model 对象直接丢到线程里调用,结果 Qt 直接报了 “Timers cannot be started from another thread” 的错误。
3.4 聊天区域与侧边栏的联动交互
聊天的输入框我直接用 QTextEdit,设置最大高度为 120,超过后自动出现内部滚动条,这样就实现了类似微信的多行输入自动扩展。按 Enter 发送、Shift+Enter 换行的快捷键逻辑,需要在 keyPressEvent 里重写。
# ui/input_widget.py from PyQt6.QtWidgets import QTextEdit from PyQt6.QtCore import Qt, pyqtSignal class InputWidget(QTextEdit): send_requested = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.setPlaceholderText("请输入你的问题,Shift+Enter 换行,Enter 发送") self.setMaximumHeight(120) def keyPressEvent(self, event): if event.key() == Qt.Key.Key_Return and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier: text = self.toPlainText().strip() if text: self.send_requested.emit(text) self.clear() event.accept() else: super().keyPressEvent(event)侧边栏放了三个功能入口:会话历史列表、知识库管理、系统设置。会话列表用 QListWidget,知识库管理用 QTreeWidget 展示文档树。我比较推荐的交互是点击会话项时通过 signal 通知聊天区切换上下文,这样两个模块之间的耦合度降到最低。整个项目里我大量使用 pyqtSignal 做模块间通信,模块之间的直接方法调用极少。
4. Agent 调度核心与多轮对话逻辑
4.1 Agent 的 ReAct 循环调度机制
Agent 是这一整套系统的灵魂。这一节拆开讲讲它的运行机制和代码实现。我把 Agent 的调度逻辑设计成了一个循环:模型先生成一段思考或者行动计划,如果计划中包含工具调用,就执行工具,再把结果拼回去让模型继续思考,直到模型认为信息足够,生成最终回答。
# core/agent/agent.py class Agent: def __init__(self, llm_client, tools, memory, knowledge_retriever): self.llm = llm_client self.tools = {t.name: t for t in tools} self.memory = memory self.retriever = knowledge_retriever def run(self, user_input: str) -> str: self.memory.add_user_message(user_input) # 先检索知识库,作为参考上下文 context = self.retriever.retrieve(user_input, top_k=3) messages = self.memory.get_messages() system_prompt = build_system_prompt( tools_desc=self.tools, knowledge_context=context ) messages.insert(0, {"role": "system", "content": system_prompt}) # ReAct 循环 for _ in range(self.max_iterations): response = self.llm.chat(messages) content = response step = parse_agent_step(content) if step["type"] == "final": self.memory.add_assistant_message(step["answer"]) return step["answer"] elif step["type"] == "tool_call": tool_result = self.tools[step["tool"]].execute(**step["args"]) messages.append({"role": "assistant", "content": content}) messages.append({"role": "tool", "content": str(tool_result), "name": step["tool"]}) return "抱歉,我没能在规定步数内完成你的请求。"这里最关键的是提示词的构造。我写了一个 build_system_prompt 函数,把“你有以下工具可用 + 搜索到的知识片段 + 只能基于知识库回答 + 不能编造”几个要素拼进去。实测下来,工具描述必须是 JSON 格式,模型才能稳定解析,如果用自然语言描述,模型偶尔会漏掉参数或者凭空发明参数。
4.2 多轮对话上下文管理
多轮对话的记忆管理是智能客服能否“懂人话”的关键。很多失败的聊天机器人都是每次请求只带当前一句话,模型当然不知道上下文,回答起来驴唇不对马嘴。我的记忆模块设计了两个层级。
短期窗口记忆是最常用的一层。每次请求,把最近的 6 轮对话(用户 + 助手各算一轮)拼进 messages 列表。为什么要限制 6 轮而不是全带?因为大模型上下文窗口有限,全部带上不仅浪费 token,还会让模型注意力分散,反而答不准。
超过 6 轮的更早对话如果完全丢弃显然也不合理。我加了一个 summarize 机制,当一轮对话结束时,如果消息数量超过阈值,就调用一个轻量模型把前面的内容总结成一段摘要,后续请求的时候把摘要放在 system prompt 里,再拼上最近 6 轮。这样就实现了“长期记忆在摘要里,短期细节在窗口里”的混合记忆架构。
4.3 tool calling 的实现细节
工具调用是这个平台能完成工单自动创建、产品库存查询等任务的基础。我把自己实现的几个工具完整列出来:
# core/agent/tools.py class CreateTicketTool: name = "create_ticket" description = "创建客服工单,入参:customer_name(str), issue_desc(str), priority(str, 可选 low/normal/high)" def execute(self, customer_name, issue_desc, priority="normal"): # 写入数据库 ticket_id = db_manager.create_ticket(customer_name, issue_desc, priority) return f"工单创建成功,工单号 {ticket_id}" class QueryOrderTool: name = "query_order" description = "查询用户订单状态,入参:order_no(str)" def execute(self, order_no): order = db_manager.query_order(order_no) return order.to_json()需要注意一点,工具名和参数描述尽量写详细、准确。大模型靠描述来决定要不要调用工具、传什么参数,描述含糊会导致调用动作频率低或者参数乱传。我最初写 QueryOrderTool 的参数描述是order_no,没说明格式,结果模型经常传一个完整的“订单号是 20240801”这样的字符串,解析后端就报错。后来我把描述改为入参:order_no(str),10位数字订单号,准确率立刻上来了。
5. 知识库管理与检索增强
5.1 知识文档的加载与分块
知识库模块让客服系统不再是一个只会胡说的“嘴替”。运营人员把产品手册、FAQ、售后政策等文档传进系统,客服问相关问题,系统就能基于这些真实资料回答。文档加载我用的是经典的分块策略,先把整篇文档按长度切片,每块包含完整语义段落,然后对每一块做 embedding 向量化。
分块大小是我调过很多次的参数。块太大检索精度低,比如把整个产品手册切一块,模型收到一大堆无关内容;块太小语义会被切断,模型理解不了上下文。我最终选了 500 字一块,重叠 50 字,保证相邻块之间的信息衔接不丢。这个值不是拍脑袋定的,它取决于向量模型的推荐输入长度,现在主流的 embedding 模型大多支持 512 到 1024 token 的输入,500 个中文字符换算成 token 差不多是 700 左右,在安全范围内。
5.2 检索与重排策略
用户提问时,检索器先计算问题的 embedding 向量,再用余弦相似度和所有知识块对比,返回 TOP-K。这个 K 我经验值是 3 到 5。K 太小可能漏掉正确答案,K 太大会把噪声带进提示词,干扰模型生成。我分别测过 K=3、K=5、K=10,最后线上版本固定为 3,准确率和回复质量的综合表现最稳定。
# core/knowledge/retriever.py import numpy as np class Retriever: def __init__(self, embedding_model, docs): self.embedding_model = embedding_model self.doc_embeddings = [embedding_model.embed(d["content"]) for d in docs] def retrieve(self, query, top_k=3): query_vec = self.embedding_model.embed(query) scores = [] for i, doc_vec in enumerate(self.doc_embeddings): score = cosine_similarity(query_vec, doc_vec) scores.append((score, i)) scores.sort(reverse=True) results = [] for score, i in scores[:top_k]: results.append({"content": self.docs[i]["content"], "score": score}) return results这里还有个容易踩坑的地方:不要把 score 小于某个阈值的检索结果也硬塞进去。如果知识库根本没有与问题相关的内容,模型会强行为这些不相关内容编造答案。我加了一个 0.35 的阈值,低于这个值的直接视为“知识库无相关内容”,Agent 就会转去走通用回复或者转人工。
5.3 让模型只基于知识库回答的提示词技巧
很多人在 RAG 系统里犯的错误是:知识检索出来了,提示词里却没有明确限制模型的使用范围,结果模型还是自由发挥。我的 system prompt 里写了一段严格要求:
你是一个基于知识库的智能客服助手。 回答时只能使用<knowledge>标签内给出的内容,如果知识库中没有相关内容,请明确回答“知识库中暂未收录该问题”。 不要编造事实,不要使用你自身预训练记忆中的信息来回答业务问题。这段提示词效果很好。少了它,模型会一本正经地编造产品价格和库存数据;加上之后,几乎所有超出知识库范围的内容都会得到诚实的回答。这块对客服系统尤其重要,因为它影响的是企业对外形象,一句错误信息带来的损失远比多花点 token 要严重。
6. 会话持久化与配置系统
6.1 SQLite 数据表设计与建模
会话存储我前面提到用了 SQLite,下面展开说下表设计。conversations 表结构很简单:id、create_time、title,其中 title 自动取第一句用户问题的前 20 个字。messages 表字段多一些:id、conversation_id、role(user/assistant/tool)、content、create_time。我把 role 直接存成文本而不是数字枚举,牺牲一点点存储空间换取代码可读性,实际用下来没毛病。
CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '新对话', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL, role TEXT CHECK(role IN ('user', 'assistant', 'tool')) NOT NULL, content TEXT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (conversation_id) REFERENCES conversations(id) );Qt 环境下操作 SQLite,我没有用 QSqlDatabase,而是直接用的 Python 内置 sqlite3 模块。原因很简单:sqlite3 更轻,API 更通用,测试时脱离 Qt 环境也能跑;QSqlDatabase 虽然提供了更便捷的 model/view 集成,但在这种简单场景下有点大材小用,反而给依赖管理增加负担。
6.2 配置驱动的 API Key 管理
把大模型 API Key 写死在代码里是开源项目的大忌。我搞了一个 config.toml 作为唯一配置入口:
[llm] provider = "openai" # 可选 openai / zhipu / qwen api_key = "sk-xxx" base_url = "https://api.example.com/v1" model = "gpt-4o-mini" temperature = 0.3 max_tokens = 2048 [agent] max_iterations = 5 system_prompt = "config/prompts/customer_service.txt" [retriever] embedding_model = "bge-small-zh" top_k = 3 min_score = 0.35启动时用 Python 的 tomllib 解析配置,LLM 客户端根据 provider 字段动态选择底层实现。这个设计让用户只需要改一个文件就能切换到不同模型厂商,不用动一行代码。需要温馨提醒的是,提交到 GitHub 之前一定要删掉真实的 API Key,要不然轻则被人薅羊毛,重则账号被风控甚至产生大额账单。推荐用 python-dotenv 加载真正机密的环境变量,config.toml 里只留占位符。
6.3 会话历史恢复与导入导出
会话恢复是桌面客服工具的必备功能。程序启动时,从数据库把最近 50 个会话加载进左侧列表,用户点击某个会话,聊天区就异步从 messages 表拉取该会话的全部消息。这里我做了个分页,每次只加载 100 条,需要往前翻的时候再追加加载,避免一条超长会话把界面的滚动控件卡到原地不动。
导入导出功能我用 JSON 文件实现。整个会话导出为 JSON,字段包括会话标题、创建时间、消息列表。导出的文件可以用在两种场景:一是批量灌入测试语料时分析客服质量;二是作为 Agent 微调的训练数据源。这些附加价值让一个普通的客服软件多了一层数据资产的属性。
7. 开源发布与项目工程化经验
7.1 开源许可证怎么选
这一步看着不起眼,其实很多人一知半解。PyQt6 本身是 GPL 协议,如果你的项目整体基于 PyQt6 再分发,通常意味着你的项目也得 GPLv3 开源。我直接选了 GPLv3,这样最省心,也最大程度保护开源生态,别人可以自由使用、修改、学习,但如果闭源商用,就要遵循 GPL 的条款要求。
如果你不希望自己代码被传染 GPL,建议用 PySide6(LGPL)重写界面层。PySide6 和 PyQt6 的代码兼容性很高,API 几乎一致,迁移成本不大。对你的读者来说,这里是个很容易踩的知识点,建议打算商用和闭源的朋友认真考虑许可证选择。
7.2 README 写作与示例配置
开源项目的 README 决定用户的第一印象。我花了不少时间打磨这个文件,核心是让用户拿到代码后 5 分钟内跑起来。README 里按顺序放了:项目简介与截图、目录结构说明、环境要求(Python 版本、依赖)、安装与运行步骤、配置文件说明、功能特性列表、开源协议说明。
其中我特别加了常见问题(FAQ)板块,把最容易遇到的问题提前写出来。比如“找不到 config.toml 怎么办”“运行报模型不存在怎么处理”“如何切换本地模型”这类问题,用户一看就明白,不用反复提 issue。
7.3 pyproject.toml 与依赖管理
依赖管理我没有用 requirements.txt 一列了事,而是直接用 pyproject.toml 声明项目信息。原因很简单,pyproject.toml 配合 uv 或者 pip install -e . 安装时,会把项目的脚本入口也注册好,用户可以直接在终端运行ai-customer-service命令启动,体验接近开箱即用的原生应用。当然 requirements.txt 我也保留了一份,因为很多国内用户用的还是 pip 配合阿里云镜像,直接 install 一行更省事。
[project] name = "ai-customer-service" version = "0.1.0" description = "基于 PyQt6 的 AI Agent 智能客服平台" requires-python = ">=3.10" dependencies = [ "PyQt6>=6.6.0", "openai>=1.30.0", "tomli>=2.0.0;python_version<'3.11'" ] [project.scripts] ai-customer-service = "main:main"版本号选择上我特意写了 PyQt6 的最低版本要求,因为早期 PyQt6 6.4 之前的版本对高分屏支持不好,会有界面模糊的问题。随手一个下限约束,可以省去很多小白用户的报障。
7.4 CI、测试与代码质量保障
开源项目最怕的就是“能跑但没法维护”。我配了一套极简的 CI:GitHub Actions 在每次 push 和 PR 时跑一遍 pytest 单测和 ruff 语法检查。代码单测主要覆盖 Agent 调度逻辑和知识检索逻辑,用 pytest-mock 模拟大模型 API 返回,不依赖真实网络请求,测试速度很快。UI 部分我一开始也想做自动化测试,后来发现 PyQt6 的界面测试要做很多 mock,性价比太低,干脆放弃了这部分,改为保证 core 层逻辑稳定,界面层靠人肉回归确保基础功能正常。
8. 踩坑实录与 PyQt6 避坑指南
8.1 PyQt6 版本坑:QSS 背景失效问题
我先是遇到一个诡异的问题:QSS 里给 QWidget 设置的背景色完全没有生效,整个界面白花花一片,像没加载样式。排查半天发现,纯 QWidget 默认不绘制样式背景,需要设置 WA_StyledBackground 属性,重新实现 paintEvent 或者直接改基类为 QFrame。解决方案很简单,在自定义 QWidget 的构造函数里加一行:
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)这个坑在 PyQt5 时代就有,到了 PyQt6 依然存在。任何自定义的 QWidget 子类,只要你想用 QSS 控制它的背景色、圆角、边框,就必须加这一行,否则样式不起作用。我在项目的所有自定义 Widget 类里都统一加了这行,避免样式丢失的怪问题。
8.2 信号槽断开与对象生命周期管理
PyQt6 里频繁创建和销毁 QThread,最怕的就是信号槽没断干净导致“重复触发”、“幽灵回调”。我有一次连续开新会话后,旧会话的请求还在后台跑,等 AI 返回消息后,一条消息被同时追加到了新旧两个会话窗口里。排查下来发现,是旧线程的 signal 没有在新会话创建时断开,造成了信号泄漏。
解决思路有两个:一是每次创建新线程时,把线程对象保存为成员变量,在启动新线程前先请求旧线程退出并断开所有信号;二是给每个会话绑定独立的请求 ID,处理回调时检查请求 ID 是否匹配当前激活会话,不匹配就丢弃。这两种方案我最后都用了,双保险。
8.3 高 DPI 与字体模糊问题
开发时在 Ubuntu 上一切正常,结果拿到 Windows 高分屏笔记本上一看,界面字全是虚的,像蒙了一层雾。原因是 PyQt6 在高 DPI 下需要显式启用缩放策略,并且设置方式在不同版本里还不一样。
在 PyQt6 6.4 之前,需要在导入 QApplication 前设置环境变量:
import os os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"从 6.4 开始,Qt 自动启用高 DPI 缩放,不需要手动设置了。我的做法是在 main.py 里加了一个版本判断,保证了 6.4 以下和以上的兼容。这套兼容代码加了之后,界面在 Windows、Linux 下的高分屏渲染都正常了,字体锐利,布局比例也协调。
8.4 PyInstaller 打包的几个大坑
打包成 exe 给没有 Python 环境的同事用,也是开源后高频出现的问题。我用 PyInstaller 打包时遇到的最大坑有两个。一是 QSS 资源文件打不进去,exe 启动后界面素颜。原因是 PyInstaller 默认只收集 Python 模块,不会自动收集 QSS、图片等非代码文件。解决方式是给 PyInstaller 传入--add-data "ui/style.qss;ui"参数,让样式文件跟着打进去。
第二个坑是打包出来的 exe 体积巨大,PyQt6 光 Qt 运行时核心就占小两百兆。我只能尽量用 UPX 压缩,不过 UPX 对 Qt 的某些 dll 会误判导致启动失败,试了几次后果断放弃,接受了 180MB 的体积。后来发现用 PyInstaller 的--exclude-module去掉用不到的 Qt 模块(比如 Qt3DAnimation、QtMultimedia),能把体积压缩到 120MB 左右,已经算很理想了。
9. 性能优化实测与后续扩展
9.1 消息渲染性能测试结果
我简单测了一下 500 条消息的会话,从数据库加载到全部渲染完成,在不做任何优化时耗时 1.8 秒左右,界面会有明显的白屏等待。优化后的方案(分批加载 + 每 50 条才刷新一次布局)把时间压到了 0.6 秒,体感几乎无感。所以对于聊天这种高频追加的场景,一定不要来一条消息就 update 一次布局,而是集中批次刷新。
流式输出的性能表现我也记录了一组数据:同样的一段 500 字回答,不做缓冲刷新的 UI 线程平均 CPU 占用 30%,用缓冲后降到 8% 左右。数字说明问题,缓冲这个设计虽然多写了几行代码,但完全值得。
9.2 后续可以怎么扩展
这个项目的架构给后续扩展留了很多余地。首先是多模型切换,目前支持 OpenAI 风格 API,你可以很方便地扩展接入本地部署的 llama.cpp、Ollama 或者国内其他模型服务。其次是语音输入,PyQt6 有 QAudioInput 接口,配合 ASR 模型可以实现语音客服,适合移动办公场景。最后是插件化工具,tools 目录下新增一个类并注册到 Agent 的 tools 字典里,不需要改 Agent 核心代码,就可以新增一个能力。
另外一个方向是把界面做得更像商业客服工作台:加一个工单表格视图,把 Agent 自动创建的工单都展示出来;加一个数据看板,统计接入会话数、平均回复时长、知识库命中率。这些我目前只做了一部分,后续会逐步完善到仓库里。
9.3 如果想商用需要注意什么
这个项目是以学习交流为主要目的开源的,如果要商业落地,有几点要提醒:第一,知识库内容的版权合规,客户公司上传的文档可能是保密资料,你要在部署方案里加入本地化部署支持,数据不出内网;第二,大模型 API 的调用成本和并发限制,需要做 token 用量统计和限流;第三,人工接管流程里的角色权限管理,客服人员和管理员应有不同权限,这里可以用 PyQt6 的权限控制来做,原始项目里是有实现的,但做的还比较轻。
我个人的体会是,这个项目最大的价值不在于某一项技术有多难,而在于把 PyQt6 的桌面开发能力、AI Agent 的调度设计、RAG 的知识管理,以及工程化落地这一整条链路打通了。很多朋友在学 AI 应用开发时总是习惯性地套 Web 技术栈,其实桌面端在内部工具、专业软件、本地化部署这些场景里,有不可替代的优势。
最后分享一个小经验:开源项目的代码质量,真的会被一棵树的 README 和示例配置影响。一个能 3 分钟跑起来的 demo,比长篇大论的理论介绍更能让人留下来。如果你也想做一个开源 AI 项目,先把“把新手导入成本降到最低”这件事做好,就已经成功一半了。