- Published on
从零构建 HealthMate:一个会说话、会记事、会提醒的桌面健康 AI 桌宠
- Authors

- Name
- Tails Azimuth
从零构建 HealthMate:一个会说话、会记事、会提醒的桌面健康 AI 桌宠
演示视频:【我做了一个健康AI桌宠】
这不是一篇"我用了什么框架"的流水账,而是一篇记录真实工程决策、踩坑和复盘的文章。如果你正准备用它准备面试,重点看「核心设计」和「难点与解决」两章,那里藏着最多的思考过程。
1. 项目背景
HealthMate 的定位是 Personal Healthcare Agent——一个像《超能陆战队》里大白一样的个人健康助手。我把它做成了一个桌面宠物:深色圆角小屏幕上有一双圆角矩形大眼睛,平时安静地待在桌面角落,会主动问候、提醒你起来活动,也能陪你聊天、记住你的健康档案、每天自动归档对话内容。
第一阶段的 MVP 只要求"能聊天",但我在架构上的要求是:未来半年内加入 Memory、RAG、Tools、多 Agent 等功能时,不需要推倒重来。
2. 技术选型
| 层级 | 选择 | 理由 |
|---|---|---|
| 语言 | Python 3.12+ | AI 生态最成熟,LangGraph/LangChain 原生支持 |
| Web 框架 | FastAPI | 异步原生、Pydantic 集成、自动 OpenAPI 文档 |
| Agent 框架 | LangGraph | 显式状态图、可编译、支持流式事件 |
| 对话框架 | LangChain Core | 统一 Message / Tool 抽象 |
| LLM 抽象 | 自研 Provider 层 | 模型不写死,OpenAI / DeepSeek / Qwen / Ollama 可配置 |
| 数据库 | SQLite + SQLAlchemy | MVP 轻量、单文件、后续可平滑迁移 PostgreSQL |
| ASR | faster-whisper (base) | 本地推理、中文效果好、无需 API |
| TTS | edge-tts | 免费、低延迟、支持流式返回 MP3 |
| 桌面端 | Electron | 跨平台、Web 技术栈复用前端 |
| 图表 | Chart.js | 轻量,健康看板趋势图 |
| 依赖管理 | uv | 快、锁文件、团队复现一致 |
3. 整体架构
┌────────────────────────────────────────────────────────┐
│ Electron 桌宠 │
│ ┌────────────┐ ┌───────────────────────────────┐ │
│ │ 大眼睛宠物 │ │ 设置 / 档案 / 历史 / 看板 │ │
│ │ 对话气泡 │ │ 模型管理 / 语音开关 / 状态灯 │ │
│ └─────┬──────┘ └──────────────┬────────────────┘ │
└────────┼─────────────────────────┼─────────────────────┘
│ REST / 录音 / 播放 / IPC │
┌────────▼─────────────────────────▼─────────────────────┐
│ FastAPI 后端 │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Chat │ │ Voice │ │Profile │ │Archive │ │
│ │ Router │ │ Router │ │ Router │ │ Router │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │
│ ▼ ▼ ▼ ▼ │
│ LangGraph 多 Agent 流水线 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Data │──▶│ Knowledge │ │ Memory │ │
│ │ Analyzer │ │ Agent │ │ Agent │ │
│ │ (总控) │◀──│ (工具) │ │ (归档) │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ LLM Provider SQLite/SQLAlchemy faster-whisper │
│ Registry 健康档案/每日归档 edge-tts │
└──────────────────────────────────────────────────────────┘
3.1 为什么选 LangGraph 而不是 LangChain AgentExecutor?
面试常被问到这个问题。我的答案是:Agent 本质是一个带状态的图,而 LangGraph 把"状态转移"显式化。
- AgentExecutor 是封装好的黑盒,你想在"生成"和"执行工具"之间插入一个记忆节点,只能靠 hack。
- LangGraph 的
StateGraph让你把每个环节写成节点,节点之间用边和条件路由连接。后续加 Memory Node、Summary Node、Planner Node 都只是"加一个节点 + 改一条边"。 - 它原生支持
astream_events,这是后面做流式聊天的基础。
3.2 目录设计背后的可扩展性
app/
├── agent/ # LangGraph 图、状态、节点
│ ├── graph.py # 图的组装与编译
│ ├── state.py # AgentState (TypedDict + add_messages)
│ └── nodes/ # supervisor(数据分析师)、archiver(记忆)
├── agents/ # 可被 LLM 调用的工具(档案、联网检索)
├── api/ # 路由层,只做协议转换
├── core/ # 配置、日志、异常
├── database/ # 模型、CRUD、连接
├── llm/ # Provider 抽象与注册表
├── schemas/ # Pydantic 请求/响应
├── voice/ # ASR / TTS
└── main.py # 应用入口
核心原则:路由层不知道 LLM 怎么调,Agent 层不知道数据库怎么存,LLM 层不知道前端长什么样。 每一层只依赖下一层的接口,这样任何一个模块替换都不影响其他模块。
4. 核心设计
4.1 统一的 LLM Provider 抽象
这是 MVP 里最重要的设计之一。用户要求"不要把模型写死,以后换模型只改配置"。我做了两层:
第一层:Provider 接口
from abc import ABC, abstractmethod
from typing import Sequence
from langchain_core.messages import BaseMessage, HumanMessage
class BaseLLMProvider(ABC):
def __init__(self, api_key: str = "", base_url: str = "", model: str = "") -> None:
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.model = model
@abstractmethod
def _call(self, messages: list[dict]) -> str:
"""底层调用 LLM API,messages 是 OpenAI 格式。"""
def chat(self, message: str) -> str:
"""单轮对话。"""
return self._call([{"role": "user", "content": message}])
def invoke(self, messages: Sequence[BaseMessage]) -> str:
"""多轮对话,自动把 LangChain Message 转成 API 格式。"""
raw = []
for msg in messages:
role = "user" if isinstance(msg, HumanMessage) else "assistant"
raw.append({"role": role, "content": msg.content})
return self._call(raw)
第二层:Provider 注册表
def get_provider() -> BaseLLMProvider: # 读 .env 默认配置
def get_provider_for_model(model_id: str) -> ... # 读内置 MODEL_REGISTRY
def create_provider(config: dict) -> ... # 用前端传入配置,优先级最高
OpenAI、DeepSeek、Qwen、Ollama 都只实现 _call;它们对上层暴露同一套 chat / invoke。前端设置里存了一组自定义模型,聊天时把 {provider, api_key, base_url, model} 传回后端,后端通过 create_provider 现场构造,不碰全局 .env。
4.2 AgentState:把"多轮对话"变成显式状态
AgentState 不是简单 dict,而是带合并语义的 TypedDict:
from typing import Annotated, NotRequired, TypedDict
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages] # 新消息自动追加
llm_config: NotRequired[dict | None] # 当前轮使用的模型配置
user_profile: NotRequired[dict | None] # 运行时缓存档案
should_archive: NotRequired[bool]
add_messages 由 LangGraph 提供,保证每轮节点写入的 Message 会自动合并进历史,而不是由我手写 messages = old + new。
图的流转是:
START → supervisor
│
├─ 有 tool_calls ─→ ToolNode ─→ supervisor
│
└─ 没有 tool_calls ─→ archiver ─→ END
supervisor 是数据分析师总控;ToolNode 执行健康工具;archiver 在回答结束后把当天内容归档。未来想加 Planner、Summarizer,只需要注册节点、改一条条件边。
4.3 Tools 层:让 LLM 自己决定"要不要查"
健康领域不能只靠模型记忆,所以我把外部能力全部包装成 LangChain @tool:
read_user_profile:读当前健康档案update_user_profile_field:用户说身体变化时更新对应字段search_health_knowledge:DuckDuckGo 检索,结果写入knowledge_cacheread_recent_archives:读取近 7 天归档,让 Agent 知道近期趋势
例如:
@tool
def read_user_profile() -> str:
"""读取当前用户的健康档案。每次对话时先调用此工具获取用户的基本健康信息。"""
p = get_profile()
if not p:
return "用户尚未填写健康档案。请引导用户填写基本信息。"
return json.dumps(p, ensure_ascii=False, indent=2)
一个容易被忽略的细节是:检索结果有缓存表,相同 query 直接命中缓存,既省 API 又让回答更稳定。
4.4 健康档案与每日归档
档案字段覆盖年龄、性别、身高、体重、慢性病、过敏史、用药、饮食、运动、睡眠、吸烟、饮酒、目标。数据库用 SQLAlchemy + SQLite 三张表:
user_profiles:当前健康档案(单用户,后续可加 user_id)daily_archives:每天一份归档(摘要、关键点、情绪、建议)knowledge_cache:联网检索结果缓存
归档还支持按关键词、年份、月份搜索,前端有独立的历史记录面板和 Chart.js 健康看板。
4.5 桌宠前端:大眼睛怎么"活"起来
前端是一份自包含的 index.html,用 CSS + 原生 JS 实现:
- 眼睛:两个纯白圆角矩形,表情靠形状和瞳孔位置变化(高兴、思考、警觉、伤心、困倦、睡着),配合 3-5 秒自动眨眼。
- CRT 扫描线:
repeating-linear-gradient叠加层,模拟老式显示屏质感。 - 对话气泡:出现在眼睛下方,文字逐字流式显示,气泡高度随内容增长,超过上限后内部滚动(隐藏滚动条),最新内容始终可见。
- 状态灯:右上角一个小光点,就绪为绿色、说话/聆听/思考中为黄色、出错为红色;原来的文字状态栏被替换,状态在远处也能一眼看到。
- 交互:单击桌宠本体打开设置,按住拖动移动窗口,输入框区域不拦截点击;因为无边框窗口不再走系统原生 drag,窗口移动通过 IPC 用屏幕坐标增量完成。
- 动态窗口:Electron 默认 180×120;聊天时根据气泡内容实时调整窗口高度;打开设置时放大到 380×520。
5. 遇到的难点与解决
面试官最想听的往往是这部分。我挑几个真实的坑:
5.1 Electron 小窗口 vs 设置面板
问题:桌宠窗口只有 180×120,设置面板根本放不下。
方案:通过 IPC 让渲染进程通知主进程动态调整窗口:
// preload.js
resizeWindow: (w, h) => ipcRenderer.send('resize-window', {width: w, height: h})
// main.js
ipcMain.on('resize-window', (e, {width, height}) => mainWindow.setSize(width, height))
打开设置 → 放大窗口;关闭设置 → 缩回桌宠尺寸。还处理了一个细节:保存健康档案后不能直接缩回,因为设置面板还开着,要等所有覆盖层都关闭才缩回。
5.2 输入框把发送按钮挤出去了
问题:启动时发送按钮不可见,打开一次设置再关掉又恢复正常。
原因:flex 布局里 <input> 的 min-width: auto 默认值让它无法收缩到内容固有宽度以下,把按钮挤出了容器;打开设置触发的窗口 resize 强制了重排,才"看起来恢复"。
解决:flex: 1; min-width: 0;。这是经典 flexbox 坑,一行修复。
5.3 删除按钮点了没反应
问题:设置里删除模型没反应。
原因:DOM 引用漏了 confirmOverlay,点击删除时 confirmOverlay.classList 抛 ReferenceError,后续逻辑全被跳过。
收获:这类"静默失败"往往发生在事件回调里,报错被吞掉。排查时先看控制台,再检查闭包引用的变量是否真的初始化了。
5.4 看板一直"加载失败"
问题:健康看板报"加载失败",但接口本身是好的。
原因:重写前端时把 Chart.js 的 CDN <script> 标签弄丢了,Chart 是 undefined,new Chart() 在 .then() 里抛错,落到了 .catch() 显示加载失败。
收获:排查"加载失败"先分清是接口失败还是渲染失败;当时应该让错误信息更具体,而不是笼统显示"加载失败"。
5.5 移动项目目录后 uvicorn 启动失败
问题:把 backend/ 目录扁平化到项目根目录后,uv run uvicorn 报 Failed to spawn: uvicorn。
原因:venv 里所有 console script(uvicorn、pytest 等)的 shebang 写死了旧路径 /.../backend/.venv/bin/python3,移动目录后指向不存在的位置。
解决:批量修正 .venv/bin 下 34 个脚本的 shebang,并把启动命令改成 uv run python -m uvicorn,不再依赖 console script 的 shebang,更健壮。
5.6 httpx ASGITransport 测不了无限 SSE 流
问题:写 SSE 接口的单测时,client.stream() 永远不返回。
原因:httpx 的 ASGITransport 会等待 ASGI 应用完整结束才返回响应,而 SSE 是无限流。
解决:把 SSE 生成器抽成可独立测试的函数,单测直接驱动生成器 + 队列验证广播逻辑;端到端用真实 uvicorn + curl 验证。这让我认识到:测试工具也有自己的边界,不是所有行为都能在内存传输层里模拟。
5.7 长回复在小气泡里放不下
问题:回复长时窗口高度有上限,超出部分看不见。
方案:两条腿走路——窗口高度随气泡内容动态增长(每加一个字检测一次 offsetHeight),到上限后气泡内部滚动,旧文字从上方滚出,最新内容始终在底部可见。
5.8 每次重启桌宠都"复读"上一条回答
问题:明明没有发消息,每次重启桌宠都会把上一次的回答重新弹出来,一开始以为是缓存。
原因:启动流程从 localStorage 读聊天历史后,主动检查最后一条消息;只要它是 assistant,就调用 showBubble(last.content)。所以不是缓存问题,而是"启动时主动回放"的逻辑。
解决:保留历史存储,但删掉启动时的回放分支。聊天记录仍然存在设置页历史里,重启后不会再"自己说话"。
5.9 macOS 无边框窗口的隐形不可点击区
问题:右上角按钮明明在 DOM 里,却 hover 不出来、点不进去。
原因:frame: false, transparent: true 的 Electron 窗口在 macOS 顶部约 20px 会留下一块不接收鼠标事件的隐形标题栏区域,按钮放在 top: 0 附近时永远收不到 hover。
解决:先避开顶部盲区,再把系统标题栏行为打开并隐藏红绿灯:
titleBarStyle: process.platform === 'darwin' ? 'customButtonsOnHover' : undefined,
if (process.platform === 'darwin') {
mainWindow.setWindowButtonVisibility(false)
}
同时把"原生 drag 区域 + 点击按钮"的冲突拆开:桌宠区域不再使用 -webkit-app-region: drag,改为渲染进程监听 mousedown/mousemove/mouseup,通过 IPC 增量移动窗口;没移动的单击再触发打开设置。这样单击和拖拽就不会互相抢事件。
6. 测试与质量
后端用 pytest + httpx ASGITransport + mock LLM 做接口级测试,覆盖:
- 聊天接口(默认模型 / 自定义 llm_config / 参数校验)
- SSE 生成器与广播逻辑
- 健康档案 CRUD
- 归档搜索
- LLM Provider 注册表
测试的 mock 策略是:只 mock LLM 调用,不 mock 应用本身,这样图编排、路由、数据库能真实跑一遍。
7. 项目收获与复盘
- 架构先行,功能后补:MVP 只有聊天,但 LLM Provider 抽象、Agent 状态图、分层目录都是为后续扩展准备的。改动新功能时基本没有"伤筋动骨"。
- 小窗口 UI 是另一种工程:180×120 的桌面宠物迫使我把每个像素都用好,也让我学会了 IPC 窗口控制、自定义拖拽、flexbox 细节和动画状态管理。
- 真实用户反馈驱动:很多 bug 是实际使用中暴露的(删除没反应、看板失败、重启复读、点不到按钮),比单元测试更能暴露设计问题。
- 调试要有耐心和工具:SSE 单测卡死、venv shebang 失效、无边框窗口盲区这类问题,定位过程本身就是很好的工程经验。
8. 未来规划
- RAG:基于个人健康记录和每日归档做检索增强,回答更个性化
- 长期记忆:多日趋势分析,体重/睡眠/情绪变化曲线
- 主动健康干预:定时提醒、用药追踪、运动打卡
- 更丰富的多 Agent:Planner(制定计划)、Summarizer(跨日总结)、Guardrail(安全审核)
- 多用户:从单用户扩展到带认证的多用户体系
项目代码:5SRT7/HealthMate
