ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

本地聊天机器人App实战:PySide6+Ollama+DeepSeek

本地聊天机器人App实战:PySide6+Ollama+DeepSeek 简介基于PythonPySide6OllamaDeepseek的聊天机器人app源码包面向希望掌握桌面端GUI开发与大模型本地部署集成的中高级Python开发者适用于需要快速构建带自定义界面、可本地运行的智能对话工具场景。整套资源共39个文件压缩包仅1.77MB其中包含10个Python源码文件主程序、核心模块、UI界面模块、通用工具函数与测试脚本、8个QSS样式文件覆盖经典灰、质感银、轻盈蓝等多种界面主题、10张PNG图标与6张JPG图片、2个Markdown笔记、1个XMind思维导图及文本、JSON等配置文件目录按核心模块、样式、文档等分开组织便于按需查阅。已有233人学习下载。通过研读代码可掌握PySide6跨平台界面搭建、Ollama服务交互与Deepseek模型调用的完整流程学习到多主题切换、Emoji处理、配置管理等实际开发技巧项目自带测试文件和常见问题记录方便排查服务连接、画布渲染等环节适合在已有Python基础上快速上手也可作为课程设计或毕业设计的代码基础。1. 一个本地私有的聊天机器人App为什么选PySide6OllamaDeepSeek这个项目标题说的是一套完全跑在自己电脑上的聊天机器人桌面AppPython写逻辑PySide6画界面Ollama承载模型本地跑DeepSeek-R1系列蒸馏版。主要解决两个问题对话数据不出本机适合隐私敏感的办公场景不用申请API Key、不按token计费部署完一次推理只花电费和显存。适合先把本地大模型跑通、想往上面长一个完整交互界面的Python开发者也适合要给团队做内部问答工具的一线工程师。下面从选型理由讲到线程设计、打包和踩坑记录照着搭就能得到一个可继续扩展的原型。2. 先把Ollama和DeepSeek跑起来选型理由、环境准备与量化选择2.1 为什么是Ollama加本地DeepSeek而不是直接调API如果你只想快速交差直接调DeepSeek的云端API是最省事的注册、充值、复制Key、requests一把梭。但凡是做过内部工具的工程师都会遇到几个绕不开的问题公司不允许业务数据出内网报销流程比开发周期还长API在高峰期不稳定还要自己做重试和降级。本地部署把这些问题一次解决了Ollama就是目前把“本地跑大模型”这件事门槛压得最低的运行时。Ollama的核心价值是模型管理与推理调度一行命令下载模型一行命令拉起OpenAI兼容的HTTP接口默认监听127.0.0.1:11434。它内部做了KV缓存、上下文窗口管理和并发控制你不用自己去写显存换入换出的逻辑。对这个项目来说我们真正需要的是一个稳定、可本地访问、接口简单的模型服务Ollama恰好把这三件事包了。要注意的是标题里的DeepSeek在Ollama生态里指的不是那个671B参数的DeepSeek-V3满血版而是DeepSeek-R1系列的蒸馏版本常见的有1.5B、7B、8B、14B、32B等规格。Ollama模型库里写的是deepseek-r1:7b这种格式下载下来是经过GGUF量化压缩的权重。这一点先讲清楚否则你按着满血版的评价标准去衡量它翻车是必然的。2.2 安装Ollama、拉取DeepSeek-R1并处理下载慢的问题安装Ollama本身不复杂Windows和macOS用安装包Linux用官方脚本。真正让大批人卡住的是模型文件下载太慢Ollama默认从海外对象存储拉权重国内网络环境下1.5B模型还能忍7B模型几GB的文件经常下到一半就超时。我一般会跳过Ollama官方源直接从HuggingFace的镜像站下载GGUF文件再用Modelfile导入到Ollama里过程完全可控。# Linux 安装 OllamaWindows/macOS 直接下载安装包安装 curl -fsSL https://ollama.com/install.sh | sh # 创建虚拟环境避免把 PySide6 装进系统 Python python3 -m venv chatbot-env source chatbot-env/bin/activate # Windows 下执行 chatbot-env\Scripts\activate python -m pip install PySide6 requests # 如果只想用官方源碰运气直接拉 7B 蒸馏版 ollama pull deepseek-r1:7b代码里两个步骤值得展开说。创建虚拟环境这一步看似多余实际是后面所有“未安装pyside6”报错的防火墙Python安装教程和实际项目之间最大的落差就是包装错了环境。ollama pull deepseek-r1:7b拉的是默认的Q4_K_M量化版本推理质量与显存占用比较均衡后面会讲到怎么按硬件选规格。如果你发现ollama pull进度条长时间卡住或反复重试建议放弃官方源改走手动导入流程。先去镜像站把DeepSeek-R1-Distill-Qwen-7B-GGUF的Q4_K_M文件下载下来然后写一个Modelfile指向这个本地文件。这个流程在离线内网环境也适用一台机器下载完拷到其他机器直接ollama create。# Modelfile告诉 Ollama 用本地 GGUF 文件创建一个名叫 deepseek-r1-local 的模型 FROM ./deepseek-r1-7b-q4_k_m.gguf TEMPLATE {{- if .System }}|start_header_id|system|end_header_id| {{ .System }}|eot_id|{{- end }}|start_header_id|user|end_header_id| {{ .Prompt }}|eot_id||start_header_id|assistant|end_header_id| PARAMETER temperature 0.7 PARAMETER num_ctx 8192# 从 Modelfile 创建模型并验证 ollama create deepseek-r1-local -f Modelfile ollama listModelfile里的FROM支持本地路径TEMPLATE字段要跟着模型原生的对话模板走比如DeepSeek-R1-Distill-Qwen用的是Qwen的chat模板。PARAMETER temperature和PARAMETER num_ctx可以写在这里当默认值也可以后续在API请求里逐次覆盖后者更灵活。ollama create执行完后ollama list里能看到新模型这段流程也是后面打包分发时离线安装模型的备选方案。2.3 用curl验证接口通没通再开始写界面在写任何PySide6代码之前必须先确认模型能正常响应。很多人一上来就写几百行UI最后发现模型没拉下来或者接口地址不对等于白干。用curl打一次API是最快的冒烟测试。# 直接打到 Ollama 的 chat 接口流式返回 JSON curl http://127.0.0.1:11434/api/chat \ -d {\model\: \deepseek-r1-local\, \messages\: [{\role\: \user\, \content\: \用一句话介绍你自己\}], \stream\: false}返回里会有一个message.content字段里面的内容就是模型最终的回答。如果跑通这个App的“大脑”就确认可用了。建议把这一步当成脚本保存下来后面Debug模型问题时不至于和UI问题混在一起排查。stream改成true后会变成NDJSON流式输出每行一个JSON对象这是下面要做的流式聊天的数据基础。3. 用PySide6把App拆成界面层与请求层QThread、信号槽与流式渲染3.1 项目目录与模块划分把网络请求和界面彻底分开PySide6的问题是初学者最容易把requests直接写在按钮的回调里点一下发送窗口立刻假死。原因是请求模型推理是阻塞操作短则几秒长则几十秒放在Qt主线程里等于把事件循环堵死了界面连重绘都做不到。核心思路是网络请求放后台线程通过Qt的信号槽机制把增量文本传回主线程。我把这套源码拆成四个文件职责边界非常清楚目录结构如下chatbot/ ├── main.py # 程序入口创建应用和主窗口 ├── chat_window.py # 主窗口UI、用户输入、消息列表展示 ├── worker.py # Ollama请求线程流式读取、信号发回 └── requirements.txt # PySide6、requestsworker.py是核心它把“请求Ollama的HTTP调用”变成一个有生命周期、可中止的工作单元chat_window.py只关心用户动作和界面更新不碰任何网络代码。这样写的好处是后续想换掉Ollama、接OpenAI兼容接口只需要改worker内部实现界面一行不动。3.2 Worker线程流式读NDJSON用信号把增量推给界面# worker.py # 在后台线程中请求 Ollama /api/chat流式解析 NDJSON增量内容通过信号发回主线程 import json import requests from PySide6.QtCore import QThread, Signal class OllamaWorker(QThread): chunk_received Signal(str) # 每解析出一段文本就发一次 session_done Signal(dict) # 完整回复结束时发附带 token 统计 error_occurred Signal(str) # 网络错误、模型不存在等异常 def __init__(self, url, messages, model, temperature0.7, num_ctx8192, parentNone): super().__init__(parent) self._url url self._messages messages self._model model self._temperature temperature self._num_ctx num_ctx self._stopped False def stop(self): # 由主线程在用户点“停止”时调用run 里的循环会感知这个标记 self._stopped True def run(self): payload { model: self._model, messages: self._messages, stream: True, options: { temperature: self._temperature, num_ctx: self._num_ctx, }, } try: # timeout 用两段式连接超时 10 秒读取超时 120 秒 resp requests.post(self._url, jsonpayload, streamTrue, timeout(10, 120)) resp.raise_for_status() collected [] # iter_lines 按行读取Ollama 流式返回的是逐行 JSON for line in resp.iter_lines(decode_unicodeTrue): if self._stopped: break if not line: continue data json.loads(line) msg data.get(message, {}) if msg.get(content): collected.append(msg[content]) self.chunk_received.emit(msg[content]) if data.get(done): self.session_done.emit({ response: .join(collected), eval_count: data.get(eval_count, 0), eval_duration: data.get(eval_duration, 0), }) break except requests.exceptions.RequestException as e: self.error_occurred.emit(f请求 Ollama 失败: {e}) # 线程结束前把标记复位方便下一次复用一个新 worker self._stopped FalsestreamTrue是关键它让requests不一次性等完整响应而是按块接收iter_lines(decode_unicodeTrue)按\n切出Ollama流式返回的NDJSON行。行内的message.content字段是这次流式返回的增量文本直接emit出去。timeout写成元组是有讲究的连接阶段10秒读取阶段120秒。首token生成可能就要几十秒读超时给太短会把正常推理误判成网络超时。eval_count和eval_duration是本次请求的token数与耗时毫秒数可以在界面上显示“本次生成用了X tokens”这对观察R1在不同显存下的性能很有帮助。stop()方法只是把标记置位线程会等当前这个chunk处理完再退出不会暴力中断底层socket避免把Ollama的连接搞脏。3.3 主窗口消息列表、输入框、发送与停止逻辑主窗口用QListView加QStandardItemModel来展示对话流每条消息是一个item文本通过样式表区分用户与助手的底色。输入区用QTextEdit而不是QLineEdit因为多行输入是聊天场景的刚需。# chat_window.py # 主窗口组织布局连接信号管理 worker 生命周期 import json import time from PySide6.QtCore import Qt from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QListView, QMessageBox, ) from worker import OllamaWorker class ChatWindow(QMainWindow): def __init__(self, base_urlhttp://127.0.0.1:11434, modeldeepseek-r1-local): super().__init__() self._base_url base_url self._model model self._messages [] self._worker None self._pending_item None self._build_ui() def _build_ui(self): self.setWindowTitle(本地聊天机器人 App) central QWidget(self) self.setCentralWidget(central) root QVBoxLayout(central) self.list_view QListView() self.list_view.setModel(QStandardItemModel(self.list_view)) self.list_view.setStyleSheet( QListView { background: #f5f6f8; font-size: 14px; } ) root.addWidget(self.list_view, stretch1) input_row QHBoxLayout() self.input_edit QTextEdit() self.input_edit.setPlaceholderText(输入消息CtrlEnter 发送) self.input_edit.setFixedHeight(80) self.send_btn QPushButton(发送) self.stop_btn QPushButton(停止) self.stop_btn.setEnabled(False) input_row.addWidget(self.input_edit, stretch1) input_row.addWidget(self.send_btn) input_row.addWidget(self.stop_btn) root.addLayout(input_row) self.send_btn.clicked.connect(self._on_send) self.stop_btn.clicked.connect(self._on_stop) def _append_message(self, role, text): item QStandardItem(你 if role user else 助手) item.setText(f{你 if role user else 助手}: {text}) self.list_view.model().appendRow(item) self.list_view.scrollToBottom() return item def _on_send(self): text self.input_edit.toPlainText().strip() if not text or self._worker is not None: return self._append_message(user, text) self._messages.append({role: user, content: text}) self.input_edit.clear() self._worker OllamaWorker( f{self._base_url}/api/chat, self._messages, self._model, ) self._worker.chunk_received.connect(self._on_chunk) self._worker.session_done.connect(self._on_done) self._worker.error_occurred.connect(self._on_error) self._pending_item self._append_message(assistant, ) self.send_btn.setEnabled(False) self.stop_btn.setEnabled(True) self._worker.start() def _on_chunk(self, chunk): # 增量文本拼到等待回复的那一条 item 上 self._pending_item.setText( self._pending_item.text() chunk ) self.list_view.scrollToBottom() def _on_done(self, stats): self._worker None self.send_btn.setEnabled(True) self.stop_btn.setEnabled(False) self._save_history() def _on_error(self, message): self._worker None self.send_btn.setEnabled(True) self.stop_btn.setEnabled(False) QMessageBox.critical(self, 请求失败, message) def _on_stop(self): if self._worker: self._worker.stop() self._worker None self.send_btn.setEnabled(True) self.stop_btn.setEnabled(False)self._worker在这里同时承担了“锁”的角色worker未结束时置非空阻止重复发送请求避免多线程并发写self._messages导致上下文错乱。_pending_item保存了当前正在生成的助手消息item后续chunk到达时直接拼接到它的文本后面实现打字机效果。_on_stop里把worker置空但没有调用wait()用户点击停止后界面立刻恢复可用worker线程还在后台跑但它的_stopped标记会在下一次循环时生效并退出。这里要注意一个坑如果停止后马上又发消息新的worker和旧worker可能同时请求OllamaOllama默认是按请求串行处理的会有排队。想要彻底解决得给worker加一个wait()等待线程结束代价是界面会有微妙卡顿一般场景下可以接受后者。3.4 入口脚本与依赖清单把App拉起来# main.py # 入口创建 QApplication实例化主窗口进入事件循环 import sys from PySide6.QtWidgets import QApplication from chat_window import ChatWindow if __name__ __main__: app QApplication(sys.argv) window ChatWindow() window.resize(800, 600) window.show() sys.exit(app.exec())# requirements.txt PySide66.5.0 requests2.31.0QApplication是整个Qt程序的事件循环起点所有的信号槽调度都依赖它所以必须在创建任何窗口之前实例化。app.exec()会一直阻塞直到最后一个窗口关闭才会返回。这一步做完一个能对话的App原型已经在本地跑起来了。4. 多轮会话、历史导出与PyInstaller打包从Demo变成能用的软件4.1 会话持久化用JSON把上下文存到本地聊几轮就关掉应用再打开一切归零这种程度只能叫Demo。要让同事真正愿意用至少要解决两件事对话记录不丢能翻旧账。最简单的方案是每次session_done信号触发时把self._messages以JSON格式落盘文件名带时间戳。# 放在 worker session_done 的信号处理函数里 # 每次完整回复结束后追加保存这一组对话 import json import os import time from pathlib import Path def _save_history(self): history_dir Path.home() / .chatbot_history history_dir.mkdir(exist_okTrue) filename time.strftime(%Y%m%d_%H%M%S.json) payload { model: self._model, saved_at: time.time(), messages: self._messages, } with open(history_dir / filename, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent2)ensure_asciiFalse必须带上否则中文会变成\uXXXX转义序列文件里的内容没法直接读。每次保存全量messages会导致文件数量膨胀但对于几KB的JSON来说硬磁盘完全无压力。如果想做成多会话切换可以把文件名按会话ID分再在窗口加一个下拉框来切换这里的最小实现已经覆盖了“数据不丢”的核心诉求。4.2 上下文窗口裁剪超出num_ctx后的取舍策略随着对话轮数增加messages数组会不断变长最终会超出模型上下文窗口。Ollama对超长输入的处理是截断或报错体验都很糟。常见做法是保留最近的N轮丢弃早期轮次同时把一条system提示词钉在列表头部不动。# 发送前调用保留最近 6 轮同时保住首条 system 消息 MAX_TURN 6 def _trim_messages(self): if len(self._messages) MAX_TURN: return self._messages head [] if self._messages and self._messages[0][role] system: head [self._messages[0]] tail self._messages[-MAX_TURN:] return head tail这个策略牺牲了早期对话的“记忆”换取了请求体积的稳定。对7B模型来说num_ctx8192大约能容纳五千到六千个中文字符的上下文6轮问答一般够用。更好的方案是用向量数据库做检索增强但那属于大工程不在这个App的范畴内。参数上如果你想保留更长的历史可以把num_ctx调大但要留意显存占用后面避坑章节会细说。4.3 用PyInstaller打包PySide6应用隐藏导入与分发注意点打包PySide6应用有个常见坑PyInstaller静态分析找不到PySide6的动态导入模块运行打包后的exe时报ModuleNotFoundError。我在打包时会显式声明隐藏导入以应对Qt某些模块延迟加载的情况。pip install pyinstaller pyinstaller -D -w --name chat-local \ --hidden-import PySide6.QtXml \ main.py-D生成目录模式启动速度比单文件-F模式快很多调试也方便。-w是去掉控制台窗口但调试期建议先不加保持控制台可以看见print输出。打包产物目录里的chat-local.exe需要和安装了Ollama的机器一起分发因为应用本身不内置模型推理能力。这里有个桌面应用分发时特别容易被忽视的问题用户的机器上可能没启动Ollama。程序启动时最好检测一下http://127.0.0.1:11434是否可连不行就弹提示再尝试用subprocess.Popen拉起Ollama的可执行文件。Windows下Ollama安装后会在%LOCALAPPDATA%\Programs\Ollama\ollama.exeLinux下通常是/usr/local/bin/ollama这块做扎实了同事拿到压缩包解压就能用而不是先看一圈排错文档。5. Ollama本地部署避坑R1复读、下载卡住、UI假死等高发问题5.1 DeepSeek-R1总把“思考过程”混进回答里流式字段读错位很多人在界面上看到模型输出一堆thinking包起来的推理过程或发现流式聊天时前面一大段都是“嗯、让我想想”之类的碎碎念以为是模型坏了。这其实是DeepSeek-R1系列的特性它会先在reasoning_content字段里输出推理过程再在content字段里输出整理后的回答。Ollama的流式JSON里这两个字段是分开的如果你的解析代码把reasoning_content也当正文拼接最终展示的就是一篇思维草稿。解决方法是明确区分这两个字段content的增量拼到正文区域reasoning_content的增量单独收集可以用来在界面上展示一个“思考中”的折叠区。如果完全不需要思考过程可以用num_predict限制总token数但更稳妥的做法是保留它因为R1的最终回答质量严重依赖前面的推理链强行砍掉会明显变笨。5.2 ollama pull卡在99%不动镜像源不可用或文件校验失败拉模型卡进度条是这个项目最高发的翻车现场尤其是7B以上的模型。现象是进度条到99%后长时间无变化最后提示下载失败或校验值不匹配。原因大多是网络层丢包导致分片重组失败或默认镜像源对该文件限速。解决路径有两条。一是反复重试偶尔能碰上网络稳定窗口但概率玄学。二是换成手动导入流程用浏览器或下载工具先把GGUF文件完整拉下来校验文件大小再写Modelfile创建模型。这条路径100%可控也是内网环境的标准做法。下载时尽量选择分卷文件或单文件GGUF避免下载工具临时断点导致的文件损坏。5.3 点发送后窗口假死拖动都不响应请求阻塞了Qt事件循环界面假死是PySide6初学阶段最典型的错误在on_send里直接写requests.post同步等待模型返回。模型推理几秒到几十秒整个UI线程卡死窗口变成“未响应”状态。原因不是PySide6不行而是阻塞调用放错了线程。解决方法是严格按照第3章的线程模型来所有网络请求放QThread子类的run()里界面刷新只通过信号槽。如果已经用QThread还是卡检查你是不是在worker里又调用了窗口对象的方法跨线程直接调用Qt控件是不安全的正确姿势永远是signal。还有一点QThread的finished信号只在线程正常退出时触发网络异常路径不要依赖它做UI恢复。5.4 提示“未安装pyside6”但pip明明装过虚拟环境与解释器错位这个报错几乎每天都有人在问。现象是python -m pip install pyside6运行正常但执行源码时提示找不到模块。原因大概率是安装用的Python解释器和运行脚本的解释器不是同一个系统里可能装了多个Python或者pip属于全局环境而vscode里选的解释器是虚拟环境。解决方法是弃用pip install直装统一用python -m pip install pyside6这样能保证装到当前python命令对应的解释器里。如果还报错在源码里加一行import sys; print(sys.executable)看运行时解释器路径再比较pip列表里的包路径错位一目了然。创建虚拟环境后IDE右下角切到该环境再跑这类问题就不再出现了。5.5 显存被吃满或推理到一半OOMnum_ctx和并行加载参数背锅Ollama默认会把加载过的模型驻留在显存中多个模型轮着用或者上下文窗口开太大显存很快就爆。现象是推理到一半报failed to allocate memory或者模型加载后其它应用直接变卡。原因有两类一是num_ctx设置过大7B模型开32K上下文显存占用可能翻倍二是Ollama串行加载多个模型不卸载新模型需要把旧模型挤出显存。解决方式靠环境变量控制在启动Ollama前设置好# 限制同时加载的模型数量并缩短模型驻留显存的时间 OLLAMA_MAX_LOADED_MODELS1 OLLAMA_KEEP_ALIVE5m OLLAMA_NUM_PARALLEL1 ollama serveOLLAMA_MAX_LOADED_MODELS1让Ollama只保留当前模型OLLAMA_KEEP_ALIVE5m控制无请求后多久卸载模型OLLAMA_NUM_PARALLEL1禁止同一模型并发推理避免多路请求叠加显存压力。如果机器只有8GB显存deepseek-r1:14b这类模型建议别碰老老实实用7B并把num_ctx控制在8192以内。6. 把模型调教成想要的助手预加载、系统提示词与零代码换模型代码跑通之后真正让这个App从“能聊”变成“好用”靠的全是细节。先说冷启动预加载模型首次对话要加载权重7B模型在机械硬盘上可能要等半分钟体验很差。Ollama支持keep_alive参数可以在应用启动时给一个空请求让模型提前加载进显存。# 启动后立即保活模型避免用户第一次输入等太久 import requests requests.post(http://127.0.0.1:11434/api/generate, json{ model: self._model, prompt: , keep_alive: 30m, })这个请求会加载模型并让其驻留显存30分钟用户打开App后第一次提问基本秒回。代价是显存被提前占用办公场景下完全可以接受。第二个实用技巧是系统提示词在界面上加一个可编辑的“角色设定”输入框发送请求前把该字符串作为role: system塞进messages头部。比如设定“你是一个Python导师回答控制在200字以内”能显著压缩R1的思考长度回答风格也更可控。配合前面的上下文裁剪函数系统提示词永远保留在头部。第三个技巧是换模型。这套App里只有self._model一个字段耦合了模型名在窗口顶部加一个QComboBox列出deepseek-r1:7b、qwen3:8b、llama3.1:8b等本地模型名切换后重新初始化self._messages列表即可。Ollama的接口格式统一换模型不需要改worker任何代码。这个能力让同一个App既能当编程助手也能切换到更适合中文闲聊的模型。我踩过最结实的一坑是把所有逻辑塞进一个文件里最后改个按钮样式都要翻三百行代码。后来不管项目多小都强迫自己拆出worker和window两层界面不碰请求请求不碰控件出问题五分钟定位。今天的这套结构也建议你从这个习惯开始。希望帮到你。本文还有配套的精品资源点击获取
返回列表