ARTICLE DETAIL

资讯详情

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

openEva:自托管常驻AI数字秘书的架构设计与实践经验

openEva:自托管常驻AI数字秘书的架构设计与实践经验 我做了个小项目叫openEva定位是一个 24 小时在线待命的数字秘书。这半年来它一直跑在家里的旧迷你主机上帮我处理日程、待办、碎片记录、会议纪要甚至一些简单家务提醒全天候不停机。这篇文章就把整个项目从思路、架构、实现到踩坑过程完整拆一遍希望能给想自己做“常驻式 AI 助手”的朋友提供一套能直接照做的参考方案。先说清楚 openEva 到底解决什么问题。它不只是又一个聊天机器人而是一个有记忆、有工具调用能力、能主动推送信息的后台常驻服务。用一句话概括就是把散在手机备忘录、微信群置顶、邮箱提醒、日历事件里的信息统一收口到一个入口再由一个带着上下文记忆的调度器替我把该记的记下来、该提醒的按时提醒、该查的自动查好。适合谁用适合有一定本地开发能力、愿意折腾自托管服务的人或者团队里想搭一个共享“值班助理”的极小规模场景。1. 项目启动前我先想清楚了这三件事1.1 为什么不做成手机 App而要做成后台服务最早想过做成手机 App但很快否掉了。数字秘书这类工具核心痛点不是交互界面而是“待命感”。大多数人打开手机 App 是需要主动操作的但秘书的价值在于你不用管它它自己会按照约定时间做事情。App 被系统杀后台、通知权限被清理、省电模式限制联网这些都会让“7x24 小时待命”变成一句空话。所以我把它设计成一个常驻后台的服务进程跑在内网的一台迷你主机上通过多种协议接入IM 机器人、Webhook、HTTP API、定时任务。手机端只承担“发消息”和“收通知”的入口职责核心调度和数据处理不依赖手机系统。这样做的直接好处是稳定我实测过openEva 在 Debian 服务器上连续跑了 137 天没重启过进程中间只更新过三次代码。1.2 干净依赖 vs 全家桶我选了更克制的技术组合搭建这种系统市面上已经有几个大而全的框架比如某些集成型 AI 助手平台。但我选 openEva 自建时技术选型刻意做得很克制核心就三个原则没有强依赖所有核心能力都基于公开协议和通用接口比如 HTTP、WebSocket、Markdown、JSON Schema。组件可替换语音识别、LLM 生成、向量检索、任务调度各自独立每个模块都可以换成别的实现。本地优先数据默认存本地文件或本机数据库不上传私有云隐私可控。技术栈最终定格为Python 3.11 FastAPI 做 API 层SQLite 做结构化数据存储Chroma 做向量记忆库APScheduler 做定时任务Whisper 本地做语音转文字LLM 接口通过统一 client 封装。这个组合的优势是每一层我都能看懂、能改、能排查不会出现“框架替我做了一堆事情但我完全不知道哪里出问题”的窘境。1.3 功能边界什么该做什么坚决不做立项时最容易犯的错是功能贪多。我列过一张需求表画出了开必须先做和后置的功能。先做的是指令解析与意图识别帮我记一下、提醒我、查一下、汇总定时提醒与日程事件管理倒班/周期类提醒比如“每周五下午提醒我提交周报”基于向量记忆的“事件回溯”上个月我跟谁聊过某项目的结论通过 Webhook 对接第三方系统如 CI 通知、监控告警语音输入手机端发语音服务端转文字坚决不做的、至少第一版不做的陌生人对话/社交功能复杂的角色扮演忽略用户权限约束的无边界工具调用情感陪伴类话术这个边界很重要。数字秘书的本质是“可靠地执行指令”不是“什么都能聊”。明确边界之后后续不管是做代码还是做测试都能快速判断一个需求到底该不该加。2. openEva 的核心架构与关键设计决策2.1 一次请求的完整旅行从消息到动作我画过一条最核心的调用链路不画图用文字描述就是消息源IM Bot / HTTP API / 语音文件 → 网关适配层Nginx FastAPI统一鉴权 → 意图路由器轻量分类器 LLM 二次确认 → 上下文组装短期会话记忆 长期向量记忆 → 动作执行器日程 / 提醒 / 检索 / 天气 / Webhook 等 → 结果反馈Markdown 文本 / 结构化卡片 / 语音合成这条链路里最容易被忽视的是“上下文组装”。数字秘书要表现出“懂你”靠的不是问一句答一句而是它能自己抓取相关记忆。比如你跟它说“把上次那个方案改一下”它的记忆系统需要在几毫秒内从向量库里检索出“上次那个方案”到底指哪个文件、哪次会议、哪个版本。这个设计我放在后面单独讲。2.2 为什么选择 SQLite Chroma 双存储记忆分为两类结构化的确定信息和语义化的模糊信息。确定信息如“明天 10 点和周总开会”适合放在 SQLite 表里查询快、可排序、可过滤。模糊信息如“上个月有个关于预算调整的讨论结论是我们暂缓采购”适合切成 embedding 存进向量库靠语义相似度召回。两个存储之间通过 entity_id 关联。比如 SQLite 里有一条会议记录 id101向量库里对应的记忆块也带上 source_entity_id101。这样当我问“上次预算会的结论是什么”向量检索能快速定位到相关记忆块再回 SQLite 取完整上下文。用双存储而不是只用一个向量库是因为纯向量检索在精确时间范围和状态筛选上表现不佳而 SQLite 擅长这个。2.3 工具调用我给 openEva 装了一盒子“手”光会聊天不算秘书能办事才算。openEva 的工具层采用了“Function Calling 白名单 二次确认”模式。每个工具都是一个 JSON Schema 描述的函数LLM 根据当前对话决定调用哪个函数、传什么参数然后由调度器在沙箱里执行。我重点说几个自建时容易忽略的设计工具返回结果要结构化不要返回一坨文字返回 JSON再由 LLM 转述给用户。这样输出的稳定性和可解析性都更好。危险操作必须二次确认比如删除日历、发送外部消息、执行 shell 命令这些必须触发确认流程openEva 会反问“你确定要删除这条记录吗”。工具调用要设超时外部 API 慢是常态我给 HTTP 类工具统一设了 10 秒超时LLM 等待上限 30 秒超过就报错提醒用户重新描述。2.4 7x24 小时待命任务调度系统设计秘书最核心的能力是“别忘事”所以调度系统是 openEva 的生命线。我基于 APScheduler 做了一套带持久化的任务队列所有定时任务在创建时写入 SQLite进程重启后自动恢复。任务分三种一次性提醒比如“5 分钟后提醒我给财务发邮件”。周期提醒比如“每个工作日早上 9 点半提醒我站会”。条件触发比如“当内网某个 Webhook 收到构建失败通知时提醒我”。调度器的核心逻辑是把自然语言时间表达式解析成 Cron 表达式。我写了一个 parser支持“明天下午 3 点”“每两周周三”“月底最后一天”这类日常表达。手写 parser 比直接让 LLM 输出 cron 更稳因为 parser 是确定性逻辑而 LLM 输出可能偶尔格式错乱。3. 核心功能模块拆解与实操要点3.1 语音输入让秘书能“听”到手机端发语音是最常用的交互方式。我在服务端接了 Whisper 本地推理收到语音文件后先转成 16kHz 单声道 WAV再交给 Whisper base 模型识别。识别结果返回文本然后走和文字输入完全相同的链路。实操有几个细节格式转换要前置各端发来的语音格式五花八门务必统一转成 wav 或 flac否则 Whisper 偶尔会出现异常识别。模型按机器配置选迷你主机 CPU 较弱我用的是 base 模型识别速度大概 1-2 秒一条。如果机器有 NVIDIA 显卡可以直接换 large-v3准确率更高。识别结果要保留置信度openEva 会在返回时附带 whisper 的 confidence score低于阈值就提示“我没听清可以再发一次”。3.2 意图识别与指令路由怎么区分“闲聊”和“干活”这是整个项目里最容易翻车也最需要调优的一环。openEva 的路由策略是“两步走”第一步用一个轻量化的规则分类器快速判断消息类型规则基于关键词和正则。比如消息里出现“提醒”“记住”“几点”等词直接归为 task 意图出现“查一下”“搜一下”“找找”则归为 search 意图。这一步速度快、成本低大部分日常指令都能命中。第二步如果规则分类器置信度低比如消息是“你觉得呢”就走 LLM 二次分类让模型从预设意图列表中选一个并附带简短理由。这种做法比纯 LLM 路由更划算大量高频指令不需要调用大模型响应快且省费用只有少数模糊表达才使用大模型。要注意的点是规则分类器必须持续迭代。我每次遇到误分类的句子都会记下来定期把新句式补充进规则库。三个月下来规则库从最初的 20 个正则膨胀到了 60 多个误判率下降了大概 35%。3.3 记忆系统它是怎么做到“还记得”记忆是所有数字秘书类产品最被期望也最难做好的功能。openEva 的记忆分三个层级会话内的短期记忆保存当前对话最近 10 轮用于维持上下文的连贯性。用户维度的长期记忆按 user_id 隔离保存用户的偏好、常用联系人、常去地点等。全局事件记忆所有经过确认的事实性信息会议结论、项目状态变更会切片存入向量库。记忆写入的时机也很关键不是每句话都记。openEva 会等一轮对话结束时判断当前轮是否包含“可沉淀信息”比如用户给出明确结论、约定时间、修改决定等才执行存储。这个判断我用了一个 prompt 模板让 LLM 返回一个 JSON 数组标注哪些句子值得沉淀、应该归到哪个类别。实测效果比全量存储好很多向量库里不会堆满无意义的闲聊。3.4 权限控制和多端接入内网部署的安全底线openEva 最开始只服务我自己后来家里人也会用所以必须做权限。自建系统最怕的是把人家的生活数据搞乱我做了三层控制用户隔离每个用户有独立 user_id数据按 user_id 隔离互不可见。消息来源绑定每条消息要么来自 IM 账号绑定微信号/Telegram chat_id要么来自带 Token 的 HTTP 请求不能匿名操作。操作分级读操作所有用户可执行写操作仅限本人数据和共享空间危险操作删除、批量修改必须有管理员标记。HTTP API 统一走 Nginx 反代 Bearer Token 鉴权并且限制了回调来源 IP。Webhook 接收端我加了一层重放保护相同事件签名 5 分钟内不能重复执行这能防止消息重放导致重复提醒。4. 实操过程从零部署一个可用的 openEva4.1 环境准备与目录结构我的运行环境是一台旧的 Intel NUCDebian 128GB 内存。为了避免污染系统 Python我用了 venv Docker 混合方案核心服务跑在 Docker 里宿主机只留一个轻量的 supervisor 进程做守护。目录结构大概长这样/opt/openeva/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── router.py # 消息路由 │ ├── broker.py # 平台适配层 │ ├── engine.py # 意图识别引擎 │ ├── memory/ │ │ ├── sqlite_store.py │ │ └── vector_store.py │ ├── skills/ │ │ ├── calendar.py │ │ ├── reminder.py │ │ ├── search.py │ │ └── webhook.py │ ├── sched/ │ │ └── scheduler.py │ └── config.yaml ├── data/ │ ├── openeva.db │ └── chroma/ ├── models/ # 本地 Whisper 模型 ├── logs/ ├── docker-compose.yml └── .env依赖文件我用 requirements.txt 固定版本核心是fastapi、uvicorn、apscheduler、chromadb、openai、whisper、pydantic。其中openai库只是作为通用 LLM client 使用底层服务方可以任意切换只要兼容接口即可。4.2 配置项设计把可变的全部外置好的配置设计是“改配置不动代码”。openEva 的配置集中在config.yaml我提取了五个高频配置组llm.provider / llm.model / llm.temperature模型服务商、模型名、采样温度。memory.top_k向量检索返回条数默认 5。scheduler.timezone时区务必显式设置默认 Asia/Shanghai。platform.im / platform.webhook不同接入平台的 token。speech.model_sizeWhisper 模型大小。示例配置片段llm: provider: openai-compatible base_url: http://192.168.1.20:8000/v1 api_key: ${LLM_API_KEY} model: qwen temperature: 0.3 memory: top_k: 5 scheduler: timezone: Asia/Shanghai job_store: sqlite:////opt/openeva/data/openeva.db speech: model_size: base language: zh我把base_url和api_key用环境变量注入防止密钥泄漏到代码仓库。所有密钥只存在.env文件里并且.env已加入.gitignore。4.3 消息路由与调度核心代码实现消息路由器是 openEva 的入口代码写得比较薄核心逻辑是把不同来源的消息统一成Message对象然后交给engine.handle()。# app/router.py from pydantic import BaseModel class Message(BaseModel): user_id: str source: str # im / http / voice content: str msg_id: str async def route_message(msg: Message): # 统一入口后先判断是否语音文本 if msg.source voice: text await asr.transcribe(msg.content) msg.content text result await engine.handle(msg) return result调度器我抽取成了一个独立服务因为它要跨进程访问 SQLite。APScheduler 的调度器初始化时指定 jobstore 指向 SQLite这样即使主进程崩溃重启任务还在。# app/sched/scheduler.py from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.jobstores.sqlite import SQLiteJobStore job_store SQLiteJobStore(urlsqlite:////opt/openeva/data/openeva.db) scheduler AsyncIOScheduler(timezoneAsia/Shanghai) scheduler.add_jobstore(job_store)核心的提醒任务执行函数会在触发时读取任务绑定的 JSON 数据再通过对应平台推送消息。async def fire_reminder_callback(job_id: str, payload: dict): # 通过平台适配层推送消息 await broker.push( user_idpayload[user_id], textpayload[text], sourcepayload[source], )4.4 工具调用落地以“查天气”为例给 openEva 添加一个新技能并不复杂核心是三步写一个执行函数写对应的 JSON Schema然后在意图分类器里加上 skill type。# app/skills/weather.py import httpx async def get_weather(city: str, date: str today): url fhttps://api.example.com/weather params {city: city, date: date} async with httpx.AsyncClient(timeout10) as client: resp await client.get(url, paramsparams) return resp.json()然后把这个函数注册成 skill并声明它的 schema 给 LLM 看{ name: get_weather, description: 查询某城市某日天气, parameters: { type: object, properties: { city: {type: string, description: 城市名}, date: {type: string, description: 日期默认 today} }, required: [city] } }引擎拿到 LLM 返回的工具调用指令后再从SKILL_REGISTRY字典里找到对应函数执行。整个注册机制用一个装饰器就能搞定最简单的实现就是维护一个字典key 是 skill 名称value 是一个 dataclass包含“执行函数 schema 是否需要确认”。4.5 对接 IM 平台让秘书住进聊天框对接 IM 是让 openEva 真正“可用”的关键一步。我这里以接入一个 WebSocket 协议的开源 IM 为例具体思路同样适用于其它群组。核心工作有两个接收消息事件并转发给 Router把 Router 的返回结果发回到聊天对话。消息接收端我用了互斥锁防止多线程处理同一消息造成重复执行。发送端则维护一个按 chat_id 分组的发送队列后端 LLM 如果响应慢前面多条结果会排队依次发送避免并发推送把 IM 服务器封掉。# app/broker.py class IMBroker: def __init__(self): self.send_queue asyncio.Queue() async def handle_incoming(self, raw_event): msg Message( user_idraw_event[sender_id], sourceim, contentraw_event[content], msg_idraw_event[msg_id], ) await route_message(msg) async def push(self, user_id: str, text: str, source: str): await self.send_queue.put((user_id, text, source))5. 常见问题与排查技巧实录5.1 LLM 偶尔罢工会导致整条链路卡住最开始我没有给 LLM 调用设置全局超时和重试结果模型服务偶发卡顿导致整条链路阻塞。排查时发现其实光设置 HTTP 超时不够LLM 客户端需要分别设置timeout和max_retries并且调用外面还要包一层 asyncio.wait_for给极限情况兜底。解决思路给 LLM 调用设 30 秒最大等待超时返回“服务正忙请稍后再试”。重试策略用指数退避第一次 1 秒第二次 2 秒第三次 4 秒最多 3 次。如果是工具函数执行慢则要在工具层而不是 LLM 层做超时。5.2 时区导致提醒时间错乱这是使用本地服务器最容易踩的坑。服务器默认 UTC 时间但中国用户用的是 UTC8直接把 Cron 表达式“每天早上 9 点”写进去实际触发时间会差 8 个小时。排查时我发现不仅 Cron 表达式APScheduler 存储 job 的时间戳也全是 UTC特别容易混。统一方案所有服务端时间存储一律用带时区的 ISO 8601 字符串所有 Cron 表达式显式标明时区调度器全局设置timezoneAsia/Shanghai数据库表里时间字段统一存 UTC 时间戳展示层再做时区转换。5.3 语音识别偶尔出现“大哥您说的是啥”Whisper 在安静环境下效果不错但家庭环境常有电视、小孩声、微信提示音等噪音识别结果可能全错。我加了两个缓解措施语音前 0.2 秒做简单的 VAD语音活动检测去掉空白音频段。识别结果置信度低于 0.6 时openEva 会回复“我听得不太清楚能不能再发一次”而不是傻傻地拿错结果去执行任务。当然如果设备支持直接使用 WebRTC VAD 库更可靠我是在笔记本跑的服务所以用了简单一点的能量阈值算法。5.4 任务重复执行重启后提醒发了两遍曾经遇到一次诡异问题重启 openEva 后本来应该只触发一次的提醒连发了两条。查进日志发现问题出在 FastAPI 开启 autoreload 模式上reload 会让 worker 进程启动两次APScheduler 的 jobstore 里同一个 job 被两个进程同时加载。解法是在生产环境关闭 reload并且给 scheduler 增加一个分布式锁我用一个基于文件锁fcntl.flock的互斥保证只有一个进程持有调度器。如果你有用 Redis用 SETNX 做锁更稳。5.5 记忆库里堆满了垃圾导致检索不准向量检索跑了一段时间后会发现召回结果里大量无关内容因为我把相似度过高的去重阈值设得太低同一个会议结论被重复写入了好几次导致检索时相关的那几条反而被淹没。优化措施有两个写入前做相似度检查与向量库中已有内容余弦相似度高于 0.9 就跳过。写入时用“源实体去重”如果同一 entity_id 已经存在记忆块采用覆盖写而不是新增。这样记忆库的体积能维持在一个稳定范围检索质量也会慢慢提升。5.6 常见问题速查表现象可能原因排查方向消息没有响应平台适配层未收到事件检查 WebSocket 连接、日志中的 raw_event任务触发时间不对时区设置没统一检查服务器时区、Cron 表达式、APScheduler timezone提醒重复发送多进程抢占 jobstore关闭 autoreload加分布式锁LLM 响应超时没有设全局超时给 LLM 调用加 asyncio.wait_for工具执行无结果外部 API 超时调大工具超时时间检查外网连通性语音识别错乱环境噪音/格式问题加 VAD 检测、统一转 wav 格式重启后任务丢失SQLite jobstore 未持久化检查 jobstore url 是否指向文件而非内存5.7 两条走后门才踩出来的效率技巧最后分享两个从实际使用中得到的效率技巧。一个是“批量导入旧数据”的方法。用了 openEva 一段时间后想让它能回忆微信里的聊天记录但聊天记录是导出的 HTML没法直接灌。我写了一个离线解析脚本先把每条记录清洗成纯文本再用 LLM 按 200-300 字窗口做摘要产出结构化记忆块后写入向量库。经过这么一轮“帮我找找去年十月关于服务器采购的讨论”这种查询答案精准度翻了一倍。另一个是“给秘书加自动化动作”的思路。我能想到最实用的场景是把它接到家庭监控或设备状态检测上openEva 可以每 5 分钟拉一次内网设备在线状态发现设备掉线就主动提醒。这个定时轮询的任务很简单但非常赚好感因为用户完全不用问秘书自己就把问题报告给你。6. 写在最后的个人经验这个项目做下来我最深的体会是数字秘书不一定要“聪明绝顶”但一定要“稳定可靠”。LLM 能力再强如果提醒老是不准、记忆老是丢用户很快就会放弃使用。所以如果你也要做类似的东西我的建议是先把非 AI 的部分打磨到极致——任务调度、存储持久化、消息去重、超时处理这些基础能力决定了用户是否愿意长期用下去。AI 只是外壳可靠才是内核。另外一个经验是项目别急着做大先从一个具体场景入手。我最初的版本只做“定时提醒 语音记录”跑了两周确认自己每天都用、不觉得烦之后才慢慢加了意图识别、向量记忆和 Webhook 扩展。这种迭代节奏虽然慢一些但每一步都踩实了后面踩坑的几率会小很多。openEva 现在每天大概处理 80 到 150 条请求其中语音占三成、定时提醒占四成、主动询问占两成剩下的是 Webhook 自动通知。它不一定是最聪明的助手但确实成了我这边真正“随时能找到人”的秘书。如果这篇文章里的思路和代码片段对你有启发不妨也试着在本地搭一个最简版本先让它每天帮你记三件事、提醒两次慢慢你就会发现这个方向值得继续玩下去。
返回列表