Satori Bot 待办事项深度解读:从架构治理到多平台 AI Agent 的工程实践
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
导读
Satori Bot 是 AIRI 仓库中一个基于 Satori 协议、通过 Koishi 桥接连接多聊天平台(QQ、Telegram、Discord、Lark)的独立事件驱动 AI Agent。integrations/satori-bot/todolist.md记录了该项目从 P0 稳定性攻坚到 P2 类型安全收尾的完整工程治理轨迹。本文以该待办清单为骨架,逐项还原其背后的架构设计、源码实现与演进逻辑,帮助读者理解:如何用"待办即架构决策"的方式管理一个自主循环 AI Agent 的稳定性、逻辑链完整性与代码质量。
1. 待办清单的整体结构:优先级即架构观
integrations/satori-bot/todolist.md将工作项按三档优先级组织:
| 优先级 | 主题 | 核心关切 |
|---|---|---|
| 🟡 P1 | 架构完善与逻辑优化 | 性能与体验:Action 截断、上下文记忆 |
| 🟢 P2 | 增强功能与类型安全 | 可观测性(Trace 日志)与类型收紧(消除as any) |
| ✅ 已完成(P0/P1/P2 归档) | 稳定性、逻辑链、类型、持久化 | 竞态、死锁、非阻塞调度、API 滥用防护等 |
这种"优先级即架构观"的组织方式本身极具参考价值:P0 聚焦核心稳定性与并发架构,P1 聚焦逻辑链完整性与用户体验,P2 聚焦长期可维护性。它反映了一个真实项目的治理逻辑——先保证"不出事",再保证"体验好",最后保证"能长期演进"。
待办清单中记录的三条核心工程决策
已完成清单中明确记录了以下关键工程决策,本文后续章节会逐一展开:
- 并发模型:
onMessageArrival与周期性任务改为并发执行,锁粒度下放到 Channel 级别; - 逻辑链保护:
trimActions自动回溯 +MAX_LOOP_ITERATIONS = 5硬性上限; - 持久化重构:从 lowdb 全量重写升级为 Drizzle ORM 增量更新模式。
2. P0 稳定性攻坚:竞态修复、锁粒度与并发调度
2.1 基于 ID 的精准删除,修复短时记忆清理竞态
清单归档的第一项是"修复短时记忆清理的竞态条件:实现基于 ID 的精准删除,防止异步消息丢失"。在 scheduler.ts 中可以看到该设计的落点:
- 每条进入队列的事件在
pushToEventQueue时由nanoid()生成唯一 ID(见 db.ts 中的pushToEventQueue); - 消费完成后通过
removeFromEventQueue(currMsg.id)按 ID 删除,而不是对整个队列做数组重写。
这种"按 ID 精准增删"的模式避免了异步场景下基于索引操作导致的错位删除与消息丢失。当currMsg.id缺失时,代码才回退到saveEventQueue全量保存(scheduler.ts 中onMessageArrival内的分支处理),保证极端情况也有兜底。
2.2 锁粒度下放到 Channel:消除全局死锁
清单记录的第二项是"消除全局锁死锁:将锁粒度下放到 Channel 级别,并引入try...finally强制释放机制"。
在 types.ts 中,ChatContext携带isProcessing: boolean标志,这就是 Channel 级锁的载体:
onMessageArrival在触发某 Channel 的处理前设置chatCtx.isProcessing = true;- 处理流程包裹在自调用异步函数中,
finally块内将isProcessing复位(见 scheduler.ts 中onMessageArrival尾部); - 周期性循环
loopIterationPeriodicForExistingChannels在启动新任务前检查if (chatCtx.isProcessing) continue,从而避免同一 Channel 的重入处理。
try...finally的作用在于:即使handleLoopStep抛出异常,锁也会被强制释放,不会因一次失败而让整个 Channel 永久卡死。
2.3 非阻塞调度:事件消费与周期性任务并发执行
onMessageArrival采用isQueueConsumerRunning模块级标志实现单消费者串行消费,但处理过程本身被设计为非阻塞:
- 每条事件消费后,先将其加入
unreadEvents并落库,然后立即eventQueue.shift()消费下一条; - Channel 的具体推理循环(
loopIterationForChannel→handleLoopStep)通过(async () => {...})()自调用异步函数在后台并发执行,不阻塞队列的继续消费。
周期性任务loopPeriodic则通过递归setTimeout每PERIODIC_LOOP_INTERVAL_MS(默认 60 秒,见 constants.ts)扫描所有有未读事件的 Channel。其关键优化是:只处理unreadEvents[channelId]非空的 Channel,避免无意义的 LLM 调用。整个调用链为:
loopPeriodic → loopIterationPeriodicForExistingChannels → ensureChatContext → handleLoopStep3. P1 逻辑链保护:智能 Action 截断与循环上限
3.1 问题背景:为什么不能直接截断 Action 历史
在handleLoopStep中,每次迭代后dispatchAction会把{ action, result }追加到chatCtx.actions。长期运行后该数组会无限增长,必须截断以控制 LLM 上下文大小。
但朴素截断存在一个致命问题:若保留段的首个 Action 恰好是continue,那么 LLM 看到的动作序列就缺失了触发该continue的前置动作(如"先read_unread_messages再continue继续推理"的逻辑链),导致模型"失忆"、推理链断裂。
3.2trimActions的回溯实现
清单中"智能 Action 截断:实现trimActions自动回溯"对应的实现在 utils.ts:
export function trimActions(actions, max, keep) { if (actions.length <= max) return actions let startIndex = actions.length - keep // 回溯:避免以 'continue' 开头而丢失其前置上下文 while (startIndex > 0) { const currentAction = actions[startIndex].action if (currentAction.action === 'continue') { startIndex-- } else { break } } return actions.slice(startIndex) }核心逻辑是:先按keep数量保留最近动作,然后向前回溯,只要保留段起点仍是continue,就继续前移包含其触发者,直到起点为普通动作。这样保证了逻辑链的完整性。
调用位置在 scheduler.ts 的handleLoopStep中:
chatCtx.actions = trimActions(chatCtx.actions, MAX_ACTIONS_IN_CONTEXT, ACTIONS_KEEP_ON_TRIM)两个常量(constants.ts)含义如下:
| 常量 | 默认值 | 作用 |
|---|---|---|
MAX_ACTIONS_IN_CONTEXT | 50 | 上下文中的 Action 数量上限,超过才触发截断 |
ACTIONS_KEEP_ON_TRIM | 20 | 触发截断时保留的最近 Action 数量 |
MAX_UNREAD_EVENTS | 100 | 单 Channel 未读事件上限,超出则从尾部裁剪 |
MAX_RECENT_INTERACTED_CHANNELS | 5 | 最近活跃 Channel 的追踪数量上限 |
LOOP_CONTINUE_DELAY_MS | 2500 | continue迭代之间的间隔(毫秒) |
PERIODIC_LOOP_INTERVAL_MS | 60000 | 周期性扫描间隔(毫秒) |
SLEEP_DURATION_MS | 30000 | sleep动作的默认时长(毫秒) |
MAX_LOOP_ITERATIONS | 5 | 单次循环的最大迭代次数 |
3.3MAX_LOOP_ITERATIONS = 5:防幻觉硬上限
handleLoopStep的 while 循环在每次迭代前检查:
if (iterationCount >= MAX_LOOP_ITERATIONS) { // 记录日志并 break,防止无限循环 }这是清单中"阻断 API 滥用"的实现:即使 LLM 不断输出continue,循环也会在 5 次迭代后被强制终止,避免由幻觉或 API 故障导致的无限推理与费用失控。
4. P2 增强:可观测性与类型安全
4.1 待办中的 Trace 日志增强
清单中仍处于开放状态的 P2 项包括:
- 监控增强:为所有 Action 执行增加更详细的 Trace 日志;
- 类型收紧:持续检查并消除残留的
as any类型断言。
其中"为 Action 执行增加 Trace 日志"的增强方向,在 dispatcher.ts 的现有日志基础上延伸:
log.withField('action', validatedAction.action).debug('Executing action')以及异常路径:
log.withError(error as Error).error('Action execution failed')每次执行的 Action 名称、成功/失败状态都会记录在日志中。dispatchAction的错误处理非常讲究:任何解析失败、Handler 缺失或执行异常都会返回success: false但shouldContinue: true的ActionResult,把错误以文本形式注入 Action 历史回传给 LLM,让模型"看到"自己的错误并有机会自我纠正,而不是直接中断循环。
从源码结构看,未来若落实 Trace 增强,可在
chatCtx.actions.push处记录耗时、入参摘要与结果类型,与 utils.ts 中已有的formatDebugContext(输出队列长度、未读统计、最近 3 条 Action 摘要)配合,形成完整观测链路。
4.2 已完成:消除 Any 类型滥用
清单归档的"消除 Any 类型滥用:修复了 LLM 解析、数据库 Schema 等多处的类型退化"在代码中有多处印证:
- LLM 输出解析:
dispatchAction使用 valibot 的v.safeParse(ActionSchema, actionPayload)对 LLM 输出做运行时 Schema 校验,ActionSchema是 6 种动作 Schema 的 union(见 types.ts),并导出Action = v.InferOutput<typeof ActionSchema>类型; - 数据库 Schema:
db.insert(messages).values({...})全部走 Drizzle ORM 的类型化 API(见 db.ts); - Satori API 类型对齐:修复
SatoriMessageCreateResponse与运行时 Schema 的不一致(对应归档项"修复 Satori API 类型不匹配")。
4.3 已完成:移除全局暴力退出
归档项"移除全局暴力退出:在process.on('unhandledRejection')中移除process.exit(1)"的工程意义在于:全局退出会杀死整个进程,导致内存中的队列状态与进行中的推理全部丢失。移除后,未处理的 Promise 拒绝只记录日志,交由持久化层与重启恢复机制兜底(见下文第 6 节的状态一致性讨论)。
5. 持久化重写:从 lowdb 全量重写到 Drizzle 增量更新
5.1 归档的核心工作
清单归档项"重写队列持久化 I/O:实现 Drizzle ORM 的增量更新模式"是 P2 中最有分量的改动。根据 PERSISTENCE.md 的记录,存储层完成了从lowdb(JSON 全量重写)到PGlite + Drizzle ORM的迁移:
- PGlite:PostgreSQL 的 WASM/Node 实现,数据目录由
DB_PATH配置(默认data/pglite-db,见 config.ts); - Drizzle ORM:类型安全的 SQL 构建器,migration 在启动时自动执行(
initDb调用migrate(db, ...),见 db.ts)。
5.2 四张核心表(见 schema.ts)
| 表 | 职责 | 索引 |
|---|---|---|
channels | 已发现 Channel 的元数据(ID、名称、平台、self_id) | 主键 |
messages | 持久化消息日志 | channel_id+timestamp索引 |
event_queue | 待处理的 Satori 事件持久队列 | 主键 |
unread_events | 各 Channel 的未读事件持久存储 | 主键 |
5.3 增量更新模式取代全量重写
对比旧的"全量重写"(每次变更把整个队列序列化回 JSON 文件),新的增量模式采用精准 SQL 操作:
- 入队:
pushToEventQueue按事件插入一行(nanoid()生成 ID); - 出队:
removeFromEventQueue(id)按 ID 删除单行; - 未读追踪:
pushToUnreadEvents增量写入,clearUnreadEventsForChannel按 Channel 清理; - 消息记录:
recordMessage按消息插入,getRecentMessages(channelId, 10)按时间倒序取最近 10 条用于 LLM 上下文重建。
这套设计的直接收益是:I/O 从 O(队列长度) 的全量写入降为 O(1) 的单行操作,同时在崩溃恢复时,事件队列与未读事件都能从磁盘原样续跑。
6. 状态一致性:内存记忆与磁盘持久化的缺口管理
PERSISTENCE.md 明确指出当前"Memory-First"策略下仍存在的两个缺口,这是理解该架构局限性的关键:
AbortController句柄在重启后会丢失:进程重启后,进行中的 LLM 请求无法再被中断;- 活跃会话的
ChatContext仅驻留内存:重启后需依赖messages表重建历史,未读事件则从unread_events表恢复。
已弥合的缺口包括:
- 事件队列(
event_queue)与未读事件(unread_events)完全持久化,崩溃后可以从断点续跑; - 对话历史可从
messages表按channel_id重建,保证重启后 LLM 上下文的连续性。
这种"关键状态落盘、会话上下文驻留内存 + 按需重建"的策略,是单体 Agent 在"响应性能"与"状态持久性"之间的务实折中。
7. 从待办清单看整体消息流架构
将待办清单与 HANDLER.md 对照,可以还原出完整的"事件 → 队列 → 调度 → LLM → 分发"链路:
Phase 1 入站: SatoriClient (WS) → 事件解析 → processedIds 去重 → eventQueue 入队并落库 Phase 2 消费: onMessageArrival → ensureChatContext → 过滤 bot 自身消息 → 写入 unreadEvents → 触发 channel 循环 Phase 3 推理: handleLoopStep → imagineAnAction (Persona + 未读状态 + Action 历史 → LLM JSON Action) Phase 4 分发: dispatchAction → ActionSchema 校验 → globalRegistry 查找 Handler → 执行 Phase 5 续环: ActionResult.shouldContinue → 等待 2.5s → 递归下一轮(上限 5 次)两个核心 Action 的分发逻辑(见 read-messages.ts 与 send-message.ts):
read_unread_messages:批量取出指定 Channel 的未读事件,格式化为文本块作为 Action Result,随后清空该 Channel 的未读池——下一轮循环 LLM 会从 History Actions 中看到这段文本并决定如何回复;send_message:执行前再次检查unreadEvents,若推理期间有新消息到达,可能中止发送以优先读取;成功后把回复同时持久化到 DB 与内存messages。
整个消息流中,channel.id是所有上下文(ChatContext、unreadEvents、数据库记录)的统一主键。
8. 实践启示:如何用"待办即架构"管理 Agent 项目
satori-bot/todolist.md虽是内部管理文档,却提供了一个高价值的工程模板:
- 用优先级标签锚定架构阶段:P0 并发与稳定性 → P1 逻辑链与体验 → P2 可观测性与类型安全,符合"先稳定、再体验、后治理"的演进节奏;
- 把核心决策写进归档清单:竞态修复方案、锁粒度选择、截断策略、循环上限、持久化模式,每一条都指向明确的源码位置,成为后来者的"决策索引";
- 开放项保持可执行的颗粒度:如"将较旧的 actions 压缩为 Summary 存入 LLM Context,而非直接丢弃",这是对
trimActions丢弃策略的明确演进方向(用摘要压缩替代硬截断,进一步降低信息损失); - 类型安全作为长期债务管理:将"消除
as any"作为持续跟踪项,配合 valibot Schema 与 Drizzle 类型化 API,把运行时错误前移到编译期。
对于任何正在构建自主循环(ReAct 式)Agent 的团队,这份待办清单与其背后的源码实现,都是一份可复用的工程范式。
关键文件索引
| 关注点 | 文件路径 |
|---|---|
| 待办清单与演进轨迹 | todolist.md |
| 消息流架构全解 | HANDLER.md |
| 持久化与状态一致性 | PERSISTENCE.md |
| Satori 事件字段定义 | EVENT.md |
| 循环调度与截断调用点 | scheduler.ts |
| 常量与上限定义 | constants.ts |
trimActions回溯实现 | utils.ts |
| Action Schema 与上下文类型 | types.ts |
| Action 校验与分发 | dispatcher.ts |
| PGlite + Drizzle 增量 I/O | db.ts |
说明:根据该模块 README.md 的声明,
src/core/(循环与规划逻辑)是 AIRI 主线框架稳定前的临时替代实现;Dispatcher 与数据库层将被保留,未来以"工具模块"形式暴露给 AIRI Core 使用。本文所描述的消息流与持久化细节,均以当前仓库中的独立运行版本为准。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考