这篇文章配合两张 archify 出的图写:架构图聚焦组件 + 事件流,拓扑图聚焦本地 + 云端的配置差异。图本身是交互式 HTML(暗/亮主题切换 + PNG/SVG 导出),文本里把它转写成 ASCII + 表格的形式,方便 RSS 和搜索引擎。
一句话定位
mini-agent 是一个事件驱动的个人 AI 助手守护进程:systemd user service 24/7 跑着,Telegram bot + Web API 双入口,LangGraph 维持会话状态,pi 子进程执行代码任务,cron + watchdog 两条腿驱动后台工作。一份代码在本地家用机和阿里云 ECS 上以两种姿态运行,靠 /sync/* 端点和 origin 字段把两端粘起来。
两张图的角色
| 图 | 关心的问题 | 颗粒度 |
|---|---|---|
| 架构图 | 这个系统由哪些组件构成?组件之间怎么连? | 单实例内部,组件 + 事件流 |
| 拓扑图 | 一份代码怎么在两台机器上跑出两种行为? | 跨实例,配置差异 + 同步路径 |
先看架构图(单实例内部),再看拓扑图(两端差异),整个项目的运行形态就清楚了。
架构图:组件分层 + 事件流
4 层组件
- 通道层 (Channels) —
TelegramChannel(python-telegram-bot v22+ async, polling 120s watchdog) +WebChannel(aiohttp 嵌入式服务器, SSE 流式 + JSON 双形态)。Bearer 令牌认证, 24 小时 TTL, 内存字典存 session。 - 核心脊柱 (Core spine) —
EventBus(asyncio pub/sub, 同步 handler + 错误隔离) +ConversationManager(LangGraph StateGraph + AsyncSqliteSaver, 4 小时压缩一次活跃 thread 的旧消息成 summary, 存memories.db) +MessageHandler(双模式路由: 话题模式直接走 pi RPC, 默认模式走 IntentRouter)。 - Worker / Actor — 7 个后台工作者, 5 个由 cron 驱动, 2 个由事件驱动 (见下表)。
- 存储层 — SQLite 多库 (检查点 / memories / todos / schedules) + 日复一日的 rotated 日志 (
chat-YYYY-MM-DD.log,result-YYYY-MM-DD.log)。
事件契约:EventBus 上跑的 6 种事件
EventBus 是项目的中央神经, 但它不是匿名广播——handler 订阅的是具体事件类型, 这才是路由契约。
| 事件类型 | 发布者 | 订阅者 | 用途 |
|---|---|---|---|
message_received | TelegramChannel / WebChannel | MessageHandler | 普通聊天文本 |
task_command | 同上 (用户敲 /task) | MessageHandler → CodeTaskExecutor | 长任务执行 (pi RPC + 上下文注入) |
query_command | 同上 (/query) | MessageHandler → CodeTaskExecutor | 单轮查询 (pi RPC, 默认不 record_turn) |
topic_command | 同上 (/query_start / /query_end) | MessageHandler → ConversationManager.switch_thread | 话题隔离 |
file_changed | watchdog inotify | BlogSyncTask | 博客目录变更触发 git push |
cron_tick | APScheduler | CronDispatcher → 注册的 TaskActor | 定时任务分发 |
记住这一点就不会把 EventBus 误当成”什么事件都往里扔的总线”——它是强类型路由表, 每条事件都有明确的 producer / consumer 配对。
Worker / Actor 模型:7 个后台工作者
cron 驱动 (5 个 TaskActor, 注册在 CronDispatcher): ├── TodoReminderActor ── cron ── todo 提醒推送 ├── ScheduledMessageActor ── cron ── 按 cron 表达式发定时消息 ├── MemoryConsolidationActor ── 每 4h ── 压缩活跃 thread 旧消息成 summary ├── DailyDigestActor ── 每天 08:00 ── aihot AI 资讯 (调 pi) └── SyncTodosSchedulesActor ── 每天 11:00 ── 双向同步 (仅本地端注册)
事件驱动 (2 个 Worker, 不走 cron): ├── BlogSyncTask ── file_changed ── git add/commit/pull --rebase/push └── CodeTaskExecutor ── /task /query ── pi RPC (默认) 或 claude CLI (回退)为什么 BlogSyncTask 不归 cron 管 — 这是个关键设计。文件系统事件 (inotify) 比 cron 更实时更准确, 而且 watchdog 与 scheduler.enabled 解耦, 意味着云端关掉调度器时, BlogSyncTask 仍然在推 git。这是 dual-instance 推送能成立的根本。
为什么 SyncTodosSchedulesActor 只在本地注册 — 同步是单点责任, 两端都发起会撞 LWW (last-write-wins) 竞态。云端 sync.enabled=false, 这个 Actor 根本不注册到 CronDispatcher, 而不是注册了不干活。
拓扑图:本地 + 云端 并排
5 个配置开关切角色
同一份代码, 同一个 config.yaml 模板, 通过 5 个布尔 / 字段切出两种运行姿态:
| 配置项 | 本地 (origin=0) | 云端 (origin=1) | 含义 |
|---|---|---|---|
telegram.enabled | true | false | 云端不跑 bot, 避免和本地抢 polling |
scheduler.enabled | true | false | 云端不发起任何 cron_tick |
web.bind_public | false (loopback) | false (nginx 反代) | 两端都绑 127.0.0.1, nginx 在云端做反代 |
web.silent_errors | false | true | 云端认证失败返 444, nginx 静默断连 |
sync.enabled | true (主动) | false (被动) | 本地是同步发起方, 云端只 serve |
注:
BlogSyncTask不受scheduler.enabled控制——它 watchdog 驱动。这是云端关闭调度器时 dual-push 仍能成立的关键。
本地独有 vs 云端独有 vs 两端共有
| 本地独有 | 云端独有 | 两端共有 |
|---|---|---|
TelegramChannel (polling 活跃) | WebChannel 作为 /sync/* 服务端 | EventBus (代码相同, 云端闲置) |
SyncClient + SyncTodosSchedulesActor | (无 SyncClient) | BlogSyncTask (dual-push 关键) |
| 5 个 cron Actor 全部运行 | CronDispatcher 空转 | WebChannel (端点相同) |
telegram.enabled=true | telegram.enabled=false | 共享后端: LLM / pi / GitHub |
同步契约:origin + LWW + 复合主键
两端都需要写 todos / schedules (Telegram 命令 + Web UI 都能改), 所以这部分必须双向复制。聊天上下文 / 记忆摘要 / 博客内容不同步——它们是端本地状态。
同步走 WebChannel 的 /sync/todos 和 /sync/schedules 两个端点, 复用 Bearer 令牌 (本地配置 sync.peer_password, 云端配置 web.password, 两边是同一个值)。流程:
本地 cron (每日 11:00) └─▶ SyncTodosSchedulesActor.handle() └─▶ SyncClient ├─ POST /auth ─────▶ 拿 Bearer 令牌 ├─ POST /sync/todos ──▶ 推本地变更到云端 ├─ GET /sync/todos?since=<iso> ──▶ 拉云端变更 ├─ POST /sync/schedules ──▶ ... └─ GET /sync/schedules?since=<iso> └─▶ upsert_sync_row (last-write-wins on updated_at)冲突解决极简: updated_at 大的覆盖小的。对个人 agent 这够了——单用户, 并发改同一行的概率极低, 没有”合并语义”需求。
避免两端 PK 冲突靠 origin 字段:
legacy schema: PRIMARY KEY (id)new schema: PRIMARY KEY (origin, id) origin = 0 (本地) / 1 (云端) -- 来自 local_origin 配置 updated_at ISO8601schema_migration.py 在 startup 时跑, idempotent——如果 origin 列已存在, 立即返回; 否则用 SQLite 的 _new 表模式重建 (ALTER TABLE 改不了 PK)。这个 migration 必须在 CREATE INDEX IF NOT EXISTS 之前跑, 因为索引引用了新 schema 才有的 updated_at 列。
补漏机制: 启动 60s 后, 如果 last_pull > 25h (说明上次同步失败或机器长期离线), 自动跑一次同步。
关键设计权衡
把这些取舍讲清楚, 比堆砌功能列表更有用。
1. 不是 HA, 是单向容错
云端不是本地的热备。本地死了, Telegram / cron / 同步发起全部停。云端只覆盖”反向代理 + 静态博客 + Web 入口”这一层。把它当 HA 会得出错误的容量规划——它的存在是为了给公网 API 一个稳定的入口, 同时维持博客可用性。
2. cron 单点 (本地)
cron 只在本地跑, 云端 scheduler.enabled=false。这是为了避免 dual-write 竞态——SyncTodosSchedulesActor 两端都跑的话, 同一时刻两端都向对方 POST, LWW 策略下谁赢取决于网络延迟, 不可预测。单点发起 + 双向复制是更可控的模型。
3. BlogSyncTask 与 scheduler 解耦
这是 dual-push 能成立的关键。BlogSyncTask 订阅的是 watchdog 的 file_changed 事件, 不走 cron_tick。云端 scheduler.enabled=false 时, EventBus 仍然有 file_changed 流过 (因为 watchdog 不依赖 scheduler), BlogSyncTask 仍然推 git。
两端都向同一个 GitHub 仓库 push, 用 git pull --rebase 避免竞争——第二端 push 前先把第一端的 commit rebase 到本地, 然后快进 push。失败时重试。
4. 双 LLM 后端: DeepSeek + 百炼
不是冗余, 是分工:
- DeepSeek (默认 chat 模型) —
ConversationManager的对话主引擎, 走 OpenAI 兼容 API - 百炼 qwen3.6-flash (意图分类) —
IntentRouter专用, 独立base_url(token-plan 计费), 用更便宜更快的模型做分类粗筛
意图分类调用很频繁 (每条消息都要分类), 用贵模型烧钱; 用快模型降低延迟。get_intent_model(config) 走单独的 backend section, 不会回退到 chat backend。
5. CodeTaskExecutor 双后端: pi RPC + claude CLI
默认走 pi RPC (子进程 JSONL 协议, 20 分钟超时, stateless — 每次新 subprocess, 匹配老 claude --print 行为)。task_backend 配置 claude 时回退到 claude CLI。
CodeTaskExecutor 传 cwd=pi_workspace (默认 ./pi-workspace) 给 pi——软隔离, 防 pi 把 /query 误解为任务时顺手改了 mini-agent 自己的源码。pi 仍然是同 user 子进程, 能 cd 任何地方, 这是软屏障不是硬安全边界。
6. 静默错误模式 (云端专属)
web.silent_errors=true 时, WebChannel 的认证失败和坏 origin CORS preflight 返 HTTP 444, 让 nginx error_page 444 =444; 静默断连。扫描器拿不到任何信号——既没有 JSON 错误体, 也没有 nginx 默认错误页。通过沉默实现的纵深防御, 对公网暴露的服务尤其重要。
一句话总结
mini-agent 的设计精髓不在任何单点, 而在两套解耦:
- EventBus + 事件类型 把通道 / 处理器 / 工作者解耦, 每个组件只订阅自己关心的事件
- 配置开关 + origin 字段 把同一份代码在两台机器上跑出互补而非冗余的姿态
一张架构图说清”组件之间怎么连”, 一张拓扑图说清”两端怎么分工”, 这就是这篇文章想传达的全部。
注: 文中所有 IP / 域名 / 仓库 / 令牌均为占位符, 实际部署时替换为自己的。架构图和拓扑图为 SVG 矢量图, 跟随站点的
prefers-color-scheme自动切换暗/亮主题。交互式 HTML 版本 (mini-agent-architecture-zh.html,mini-agent-topology.html) 在仓库mini-agent/根目录下, 支持点击交互和 PNG/JPEG/WebP/SVG 导出。