2285 words
11 minutes
mini-agent 架构详解:事件总线、Actor 模型与双实例拓扑

这篇文章配合两张 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 字段把两端粘起来。

两张图的角色#

关心的问题颗粒度
架构图这个系统由哪些组件构成?组件之间怎么连?单实例内部,组件 + 事件流
拓扑图一份代码怎么在两台机器上跑出两种行为?跨实例,配置差异 + 同步路径

先看架构图(单实例内部),再看拓扑图(两端差异),整个项目的运行形态就清楚了。

架构图:组件分层 + 事件流#

mini-agent 架构图

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_receivedTelegramChannel / WebChannelMessageHandler普通聊天文本
task_command同上 (用户敲 /task)MessageHandlerCodeTaskExecutor长任务执行 (pi RPC + 上下文注入)
query_command同上 (/query)MessageHandlerCodeTaskExecutor单轮查询 (pi RPC, 默认不 record_turn)
topic_command同上 (/query_start / /query_end)MessageHandlerConversationManager.switch_thread话题隔离
file_changedwatchdog inotifyBlogSyncTask博客目录变更触发 git push
cron_tickAPSchedulerCronDispatcher → 注册的 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, 而不是注册了不干活。

拓扑图:本地 + 云端 并排#

mini-agent 部署拓扑

5 个配置开关切角色#

同一份代码, 同一个 config.yaml 模板, 通过 5 个布尔 / 字段切出两种运行姿态:

配置项本地 (origin=0)云端 (origin=1)含义
telegram.enabledtruefalse云端不跑 bot, 避免和本地抢 polling
scheduler.enabledtruefalse云端不发起任何 cron_tick
web.bind_publicfalse (loopback)false (nginx 反代)两端都绑 127.0.0.1, nginx 在云端做反代
web.silent_errorsfalsetrue云端认证失败返 444, nginx 静默断连
sync.enabledtrue (主动)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=truetelegram.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 ISO8601

schema_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。

CodeTaskExecutorcwd=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 导出。

mini-agent 架构详解:事件总线、Actor 模型与双实例拓扑
https://sgjki547.top/posts/mini-agent-架构详解-事件总线与双实例拓扑/
Author
SGJki
Published at
2026-07-05
License
CC BY-NC-SA 4.0