- Fix window drag: install eventFilter on live2d_container, central, and _live2d_widget; fix super() call in dynamic class - Fix text input: remove WA_TransparentForMouseEvents from _chat_container - Force QT_QPA_PLATFORM=xcb on Linux (wayland has mouse event issues) - Add HealthTracker module, update AgentBrain with health integration - Update scheduler and memory modules - Add v5_modify documentation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.3 KiB
EzVibe 关键设计决策记录
本文档记录 EzVibe 项目中的关键设计决策及其理由。
1. 技术栈选择
1.1 为什么用 SQLite + NumPy 而非 ChromaDB
决策:使用 MemoryStore(SQLite) + VectorEngine(NumPy) 实现向量检索。
原因:
- 轻量:ChromaDB 需要额外的服务端进程,SQLite 是单文件零依赖
- 隐私:所有数据存储在本地,不经过任何第三方服务
- 可控:向量维度、索引策略完全可控,便于调试
- 测试友好:SQLite 路径可配置,单元测试使用内存数据库
:memory:
备选方案考虑:
- ChromaDB:功能更完善,但增加了运行时复杂度
- FAISS:大规模场景性能更好,但当前规模(< 1000 条记忆)NumPy 足够
结论:当前阶段 SQLite + NumPy 是最优解,FAISS 作为预留(vector_mode="faiss" 接口已预留)。
1.2 为什么用 TF-IDF DummyEmbedder 而非 Ollama
决策:DummyEmbedder 使用 TF-IDF 实现,开发者/测试环境不依赖 Ollama 服务。
原因:
- 离线可用:开发者在没有运行 Ollama 的情况下也能跑测试
- 启动快:不需要等待 Ollama 服务启动
- 确定性:TF-IDF 输出固定(给定相同语料库),有利于单元测试复现
限制:
- TF-IDF 是词袋模型,不理解语义("奶茶"和"咖啡"在向量空间可能很远)
- 不适用于生产环境(请使用 OllamaEmbedder 或 OpenAIEmbedder)
结论:开发测试用 Dummy,生产部署用 Ollama/OpenAI。接口已统一为 EmbedderBase。
1.3 为什么延迟构造 PetWindow 类
决策:ui/pet_window.py 使用 type() 动态构造 PetWindow 类,而非模块级导入。
原因:
- Dummy 模式兼容:headless 测试不导入 Qt,避免 ImportError
- Qt 版本兼容:PySide6 和 PyQt6 API 略有差异,延迟构造可以在运行时检测
- 避免循环导入:模块级导入
from PySide6 import QtWidgets会触发 Qt 初始化
代码示例:
# 旧方案(有问题)
from PySide6 import QtWidgets # 模块级导入,PySide6 不可用时直接崩溃
# 新方案(当前)
def _make_pet_window_class():
if _load_qt() is None:
raise ImportError("需要 PySide6 或 PyQt6")
# 动态构造...
PetWindow = None # 占位符
def create_pet_window(...):
global PetWindow
if force_dummy or _load_qt() is None:
return DummyPetWindow(...)
if PetWindow is None:
PetWindow = _make_pet_window_class()
return PetWindow(...)
2. 架构设计
2.1 为什么分离感知层和调度器中的 ActivityDetector
现状:感知层(KeyboardMouseMonitor._activity)和调度器(BehaviorScheduler._activity)各有一个 ActivityDetector 实例。
原因:
- 解耦:感知层负责数据采集,调度器负责行为决策,职责分离
- 独立测试:两个组件可以独立单元测试
问题:
- 两处逻辑略有差异(窗口大小、基准计算)
- 可能导致数据不一致
改进方向(见 EVOLUTION_ROADMAP.md):
- 统一使用感知层的单例
get_global_monitor()._activity - 调度器引用而非独立实例
2.2 为什么 brain 和 scheduler 都有主动行为决策
现状:
AgentBrain.decide_action():对话中触发的主动行为BehaviorScheduler.check_and_trigger():定时调度触发的主动行为
原因:
- 触发源不同:brain 响应用户输入,scheduler 响应时间/活跃度
- 优先级不同:scheduler 的 P0 提醒需要打断,brain 的行为是 P3
问题:
- 边界模糊,可能导致行为重复触发
decide_action()和check_and_trigger()可能同时返回同一个行为
改进方向:
- 明确分工:
brain负责对话触发的闲聊行为(P3),scheduler负责定时触发的健康提醒(P0/P1) - 共享冷却时间:两个模块的行为共享
_action_cooldown
2.3 为什么不使用消息队列中间件
决策:使用 Python 内置 queue.Queue 而非 Redis/RabbitMQ。
原因:
- 轻量:不需要额外的进程和服务
- 本地优先:桌宠是本地应用,不需要分布式架构
- 延迟可接受:事件传递延迟 < 10ms,完全满足需求
结论:当前规模不需要消息队列中间件。如果未来需要跨进程通信,可以引入 asyncio.Queue。
3. 行为设计
3.1 为什么用蒙特卡洛采样而非确定性转移
决策:EmotionEngine 使用蒙特卡洛随机采样选择下一状态。
原因:
- 自然感:桌宠不会每次都以相同方式反应,增加变化和惊喜
- 符合文档:设计文档明确要求"概率转移"
- 可调节:随机种子可配置,单元测试可复现
代价:
- 行为不完全可预测
- 可能导致情绪频繁切换(已通过
min_residence_seconds缓解)
3.2 为什么 annoyed 状态禁止 P0 打扰
现状:emotion == "annoyed" and behavior.priority <= 1 → 禁止打扰
问题:
- 用户连续工作 3 小时不休息,情绪可能变为
annoyed - 此时屏蔽久坐提醒,失去健康监测意义
改进方向:见 ARCHITECTURE_REVIEW.md 的"柔性降级"方案。
3.3 为什么用 [ACTION: type:description] 标签
决策:从 LLM 回复中解析 [ACTION: type:description] 标签来触发主动行为。
原因:
- 灵活性:LLM 可以根据上下文决定是否触发行为、触发什么行为
- 可读性:标签格式人类可读,便于调试
- 解耦:行为决策在 LLM 端,行为执行在本地端
限制:
- 依赖 LLM 按格式输出(可能不听话)
- 正则解析可能匹配到用户输入中的类似文本
改进方向:
- 严格模式:LLM 必须按格式输出,否则忽略
- 容错模式:优先匹配
[ACTION:开头的内容
4. 性能与资源
4.1 为什么 OCR 使用 pytesseract 而非云服务
决策:OCR 使用本地 pytesseract,不依赖云服务。
原因:
- 隐私:屏幕截图不经过任何第三方
- 离线可用:不需要网络连接
- 成本:无 API 调用费用
代价:
- CPU 密集,电池消耗
- 准确率不如云端服务
改进方向:用焦点窗口提取替代全屏 OCR(见 ARCHITECTURE_REVIEW.md)。
4.2 为什么用 pynput 而非其他监听方案
决策:使用 pynput 实现全局键鼠监听。
原因:
- 跨平台:支持 Windows/macOS/Linux
- 活跃维护:pynput 是当前最活跃的全局监听库
- 简单 API:回调式设计,易于使用
替代方案:
pyhook(Windows only)keyboard(仅键盘,无鼠标)appnope(macOS only)
结论:pynput 是当前最优解。
5. 未来决策预留
以下决策暂未落地,但在代码中已预留接口:
| 决策 | 预留方式 |
|---|---|
| FAISS 向量检索 | VectorEngine(mode="faiss") 接口已预留 |
| 多后端 LLM | LLMBackend 策略模式,支持 Ollama/OpenAI/Dummy |
| 多语言 | System Prompt 支持中文,当前固定 zh_CN |
| 番茄钟模式 | EmotionState 预留 TOMATO 状态(未启用) |
| 系统通知 | show_reminder() 预留 show_system_notification() 接口 |