技术选型:PySide6 + SQLite + 大模型 API 分层架构
一句话摘要:智学文枢采用「PySide6 桌面壳 + SQLite 本地库 + 云端大模型 API」的组合,用 UI / Service / Data 三层切分职责,兼顾桌面体验、数据主权与 AI 能力三者的平衡。
一、引言
从第一性原理问这个问题:一个 AI 私教工作台,最本质的技术约束是什么?
- 用户是谁? 是老师、家长、一对一家教。他们不需要网页部署,要的是双击就能跑的桌面工具,数据(学生档案、错题、成绩)绝不能放到公共服务器上------这决定了本地桌面应用 + 本地数据库。
- AI 能力从哪来? 分析、讲解、变式、追问都是大模型能力。本地跑 7B 模型推理慢、显存门槛高、效果不稳定;调用云端 API 只需一把 Key,效果与迭代速度都占优------这决定了大模型走云端 API。
- 两头怎么接? 桌面 UI 与云端 AI 之间必须有一个"数据中转站",把 AI 返回的 JSON 解析、落库、再喂给下一个引擎------这决定了必须分层。
于是技术栈的答案几乎是被约束出来的:PySide6(桌面 UI)+ SQLite(本地存储)+ OpenAI 兼容大模型 API(AI 能力)。本文从源码讲解这三块各自承担什么、以及它们如何被组织成分层架构。
二、核心原理
2.1 为什么是 PySide6 而不是其他?
PySide6 是 Qt 6 的官方 Python 绑定,选择它的理由在对比中更清晰:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| PySide6 | 控件全、性能好、QSS 换肤、官方长期维护 | 体积大(约 200MB) | ✅ 选择 |
| PyQt6 | 与 PySide6 同源,GPL/商业双许可 | 授权条款需付费(商业闭源) | 商业产品避开 |
| Tkinter | 零依赖 | 控件丑、无现代样式、做不出暗色主题 | 放弃 |
| Electron/Web | 前端生态强 | 内存占用高、打包大、对 Python 数据层不友好 | 放弃 |
关键点:项目全部使用官方 PySide6 命名空间(QApplication、Signal、Slot),而非 PyQt 的 pyqtSignal------这是 Qt for Python 的官方 API 风格,也是后续所有代码引用的基础。
2.2 为什么是 SQLite + SQLAlchemy?
单机桌面应用的数据量(几百个学生、几千道题)远未到需要数据库服务的程度。SQLite 是进程内文件数据库,零部署、单文件、事务完备。项目在其上叠加 SQLAlchemy ORM,获得两层收益:
- 声明式模型 :
data/models.py用类定义表结构,Base.metadata.create_all()一键建表; - 迁移能力 :
Database.init_tables()内置多段迁移逻辑,老库升级无需手工 SQL。
2.3 大模型为什么走 OpenAI 兼容接口?
AIClient 只认 OpenAI 兼容的 /chat/completions 协议,好处是服务商可替换 :constants.py 中登记了 qwen(通义千问)与 deepseek 两个服务商,配置里换 baseUrl + apiKey + model 即可切换,引擎代码零改动。
2.4 分层架构总览
#mermaid-svg-oz2BIvu2QEwoUHcY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oz2BIvu2QEwoUHcY .error-icon{fill:#552222;}#mermaid-svg-oz2BIvu2QEwoUHcY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oz2BIvu2QEwoUHcY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oz2BIvu2QEwoUHcY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oz2BIvu2QEwoUHcY .marker.cross{stroke:#333333;}#mermaid-svg-oz2BIvu2QEwoUHcY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oz2BIvu2QEwoUHcY p{margin:0;}#mermaid-svg-oz2BIvu2QEwoUHcY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster-label text{fill:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster-label span{color:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster-label span p{background-color:transparent;}#mermaid-svg-oz2BIvu2QEwoUHcY .label text,#mermaid-svg-oz2BIvu2QEwoUHcY span{fill:#333;color:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY .node rect,#mermaid-svg-oz2BIvu2QEwoUHcY .node circle,#mermaid-svg-oz2BIvu2QEwoUHcY .node ellipse,#mermaid-svg-oz2BIvu2QEwoUHcY .node polygon,#mermaid-svg-oz2BIvu2QEwoUHcY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oz2BIvu2QEwoUHcY .rough-node .label text,#mermaid-svg-oz2BIvu2QEwoUHcY .node .label text,#mermaid-svg-oz2BIvu2QEwoUHcY .image-shape .label,#mermaid-svg-oz2BIvu2QEwoUHcY .icon-shape .label{text-anchor:middle;}#mermaid-svg-oz2BIvu2QEwoUHcY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oz2BIvu2QEwoUHcY .rough-node .label,#mermaid-svg-oz2BIvu2QEwoUHcY .node .label,#mermaid-svg-oz2BIvu2QEwoUHcY .image-shape .label,#mermaid-svg-oz2BIvu2QEwoUHcY .icon-shape .label{text-align:center;}#mermaid-svg-oz2BIvu2QEwoUHcY .node.clickable{cursor:pointer;}#mermaid-svg-oz2BIvu2QEwoUHcY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oz2BIvu2QEwoUHcY .arrowheadPath{fill:#333333;}#mermaid-svg-oz2BIvu2QEwoUHcY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oz2BIvu2QEwoUHcY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oz2BIvu2QEwoUHcY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oz2BIvu2QEwoUHcY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oz2BIvu2QEwoUHcY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oz2BIvu2QEwoUHcY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster text{fill:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY .cluster span{color:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oz2BIvu2QEwoUHcY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oz2BIvu2QEwoUHcY rect.text{fill:none;stroke-width:0;}#mermaid-svg-oz2BIvu2QEwoUHcY .icon-shape,#mermaid-svg-oz2BIvu2QEwoUHcY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oz2BIvu2QEwoUHcY .icon-shape p,#mermaid-svg-oz2BIvu2QEwoUHcY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oz2BIvu2QEwoUHcY .icon-shape .label rect,#mermaid-svg-oz2BIvu2QEwoUHcY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oz2BIvu2QEwoUHcY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oz2BIvu2QEwoUHcY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oz2BIvu2QEwoUHcY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据层 data/
服务层 services/
UI 层 ui/
httpx JSON
MainWindow 主窗口
Store 信号驱动状态层
Pages 13个业务页面
AI 五引擎 + AIClient
业务服务: 试卷/统计/PDF
Database SQLAlchemy引擎
Repository 8个子仓库
ConfigManager 配置
ImageStore 图片存储
云端大模型 API
SQLite 单文件
UI 层通过 Store(信号槽)与页面通信,页面不直接碰数据库;服务层调用 AI 与 Repository;数据层封装全部 SQL。这条单向依赖链是整篇代码"可维护性"的根基。
三、代码实现
3.1 大模型 API 客户端(服务层与云端的边界)
AIClient 用 httpx 同步客户端发起请求,核心是 chat 与 chat_json 两个方法:
python
# 源码位置:services/ai/ai_client.py L95-L113(节选)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": 4096,
}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
reply = data["choices"][0]["message"]["content"]
logger.info("[AIClient.chat] 响应成功 content(前300字): %s", str(reply)[:300])
return reply
except httpx.HTTPStatusError as e:
raise RuntimeError(f"API返回错误 {e.response.status_code}: {e.response.text[:200]}")
except httpx.RequestError as e:
raise RuntimeError(f"网络请求失败: {e}")
值得注意的细节:
chat_json默认带response_format={"type": "json_object"}强制 JSON 输出,但变式生成会显式传json_object=False(ai_client.py L145-L146 注释:避免 json_object 模式与数组输出冲突导致解析失败)------这是实战踩坑后补的开关。- 若模型返回 400(不支持 JSON 模式),
chat_json自动降级为普通chat调用(L157-L160)。 - API Key 不是硬编码,而是从
ConfigManager读取,且通过_require_ai()做授权门禁(L22-L26)。
3.2 SQLite 引擎与建表
data/database.py 用单例模式管理引擎与会话:
python
# 源码位置:data/database.py L55-L91(节选)
class Database:
"""数据库管理器 --- 单例"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
ensure_dirs()
self.engine = create_engine(
DB_URL, # sqlite:///~/.ai-coach/database/ai_coach.db
echo=False,
connect_args={"check_same_thread": False},
)
event.listen(self.engine, "connect", _set_sqlite_pragma) # PRAGMA foreign_keys=ON
self.SessionLocal = sessionmaker(bind=self.engine, expire_on_commit=False)
self._initialized = True
def init_tables(self):
"""创建所有表(如果不存在)并执行迁移"""
Base.metadata.create_all(self.engine)
self._migrate_schema()
...
三个细节值得学习:
- 单例 :
__new__保证全应用只有一个Database,避免多引擎句柄各自持有连接; check_same_thread=False:允许子线程(如 AI 请求的回调线程)访问连接,这是 Qt 多线程场景下的必要配置;expire_on_commit=False:提交后对象属性不失效,UI 层读取 ORM 对象更省心。
3.3 配置管理:API Key 加密落盘
config_manager.py 把用户配置(含 API Key)写到 ~/.ai-coach/config.json,Key 用 Fernet 对称加密保护:
python
# 源码位置:data/config_manager.py L13-L16
APP_DATA_DIR = os.path.join(os.path.expanduser("~"), ".ai-coach")
CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.json")
# 用于派生加密密钥的盐值
_ENCRYPT_SALT = "zhixuewenshu_2024"
默认配置按节点组织(ai / aiocr / app),AI 节点内还支持嵌套的提示词开关(science.force_step_by_step 等),这些开关最终由 prompt_config.py 的 resolve_prompt_config 合并注入各引擎的 Prompt。
3.4 主题:QSS 换肤
暗色主题由 ThemeManager 加载 QSS 文件整体应用,一行 app.setStyleSheet(qss) 完成全局换肤:
python
# 源码位置:ui/theme/theme_manager.py L11-L18
def apply_theme(self, app: QApplication):
"""加载dark.qss并应用到QApplication"""
try:
with open(self.DARK_QSS_PATH, "r", encoding="utf-8") as f:
qss = f.read()
app.setStyleSheet(qss)
except FileNotFoundError:
pass # 主题文件不存在时使用默认样式
这是 PySide6 相比 Tkinter 的碾压级优势:不写任何控件级样式代码,全局换肤。
四、设计权衡 / 踩坑记录
4.1 SQLAlchemy ORM vs 裸 sqlite3
裸 sqlite3 :代码直白但每张表要手写建表 SQL 和 N 个样板 CRUD,迁移靠手工脚本;SQLAlchemy :模型即表结构、create_all 一键建表、迁移逻辑与 init_tables 内聚。项目最终选择 ORM,代价是学习成本,换来的是 8 个 Repository 的实现成本大幅下降(见 11-15 篇)。
4.2 httpx vs requests
两者 API 相似,项目选 httpx 的主要理由是它原生支持 HTTP/2 与连接池复用,且 with httpx.Client(timeout=timeout) 的上下文管理比 requests 的全局 session 更清晰。AI 场景长连接复用对连续多次引擎调用有明显收益。
4.3 云端 API vs 本地模型
这是整个技术选型里最重要的取舍。本地跑模型(Ollama/vLLM)数据完全不出本机,但:
- 7B 级模型对"知识断层诊断""讲解词"这类长文本任务效果明显弱于 70B+ 云端模型;
- 用户机器 GPU 情况不可控,推理延迟不可接受。
因此选择云端 API,并把数据主权留给本地:所有学生、题目、分析结果都存在用户自己的 SQLite 里,AI 只是"加工厂"不是"仓库"。
4.4 踩坑:SQLite 线程模型
最初在子线程里写库偶发 SQLite objects created in a thread can only be used in that same thread。解决分两层:引擎层 check_same_thread=False + 会话层由 Repository 每次新建短会话(repo.close() 及时归还连接)。这提醒我们:SQLite 本身线程安全,但 SQLAlchemy 会话必须按线程管理。
4.5 为什么不用 QSharedMemory 做单实例?
选型文档曾考虑 Qt 官方的 QSharedMemory 做单实例,但它在进程崩溃/被杀时可能残留共享内存段,导致后续实例永远拿不到锁。项目最终改用文件锁 + 本地 Socket 方案(app/single_instance.py,见 05 篇),更可靠、跨进程通信也更灵活。
五、小结
- 技术栈是"约束推导"的结果:桌面私密性 → PySide6 + SQLite;AI 效果 → 云端大模型 API;二者衔接 → 分层架构。
- 三层单向依赖(UI → Service → Data)让 13 个页面、5 个引擎、8 个仓库各归其位。
AIClient屏蔽服务商差异(OpenAI 兼容协议),chat_json的json_object开关与 400 降级是实战沉淀。- SQLite 选型的关键不在"轻",而在"数据主权"------所有敏感数据留在用户本机
~/.ai-coach/下。