- 人工智能
- AI Agent
- MCP 服务
- 工具调用
- GUI 自动化
- 语音
- 数字人
- 交互助手
【免费下载链接】airi
💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。
本篇技术指南以 Satori Bot 待办事项文档 为骨架,系统梳理 AIRI 项目中独立运行的 Satori Bot(事件驱动 AI Agent)在稳定性、并发架构、逻辑链完整性与类型安全四个层面的工程演进:既回顾了已归档落地的 P0–P2 优化成果(竞态条件修复、Channel 级锁、非阻塞调度、智能 Action 截断、硬性循环上限等),也明确了仍待推进的 P1/P2 规划(Action Summary 压缩、Trace 日志、as any收尾)。读完你将掌握这套"Mini-Core"(事件队列 → 调度器 → LLM Planner → Action Dispatcher → 数据库持久化)在真实多平台场景下的关键设计决策与实现细节。
背景:Satori Bot 在 AIRI 中的定位
AIRI 的 Satori Bot 位于 integrations/satori-bot,是一个STANDALONE、事件驱动的 AI Agent:通过 Satori Protocol 与 Koishi 桥接连接 QQ、Telegram、Discord、Lark 等多个聊天平台,内置一个"自主思考循环"(autonomous thought loop)。按 README 的说明,其src/core/部分是临时性的 Mini-Core,用于模拟未来 AIRI 主框架 Core 的行为;Dispatcher 与数据库部分会被保留,并作为"类工具模块"暴露给 AIRI Core。因此 todolist.md 中记录的大部分优化,本质上是在为一个"未来会被替换的临时核心"做严谨的工程收敛,其并发与持久化经验对最终架构同样有迁移价值。
模块的关键代码路径:
- 循环与逻辑(临时):
src/core/(loop、planner、session、dispatcher) - Adapter(保留):
src/adapter/satori/(Satori 协议处理) - Capabilities(保留):
src/capabilities/(Action 执行)
核心链路总览:一条消息如何被处理
在深入各项优化之前,先建立整体认知。消息处理的调用链可从 queue.ts 与 scheduler.ts 中还原:
- 事件进入:
setupMessageEventHandler监听message-created事件,先以${channel.id}-${message.id}生成messageId,通过botContext.processedIds去重,再将事件写入 Drizzle 的event_queue表(pushToEventQueue),随后推入内存eventQueue。 - 队列消费:
onMessageArrival以isQueueConsumerRunning标志保证单消费者,逐条取出队列头部消息,立即执行协议侧持久化(recordChannel/recordMessage),并把消息推入对应频道的unreadEvents。 - 循环调度:对每个频道调用
loopIterationForChannel→handleLoopStep,后者在循环内调用 LLM Planner 生成 Action,再通过dispatchAction执行。 - 状态持久化:频道、消息、未读事件、事件队列全部落库 PGlite(
src/lib/db.ts),进程重启后可恢复。
todolist.md 中 P0 的三项成果,正是围绕这条链路中最容易出问题的三个点展开:竞态清理、锁粒度、阻塞调度。
P0 归档:核心稳定性与并发架构
1. 修复短时记忆清理的竞态条件:基于 ID 的精准删除
问题本质:旧实现清理"短时记忆"(未读事件)时如果采用"按频道整体覆盖/清空"的方式,在并发场景下会误删处理期间新到达的事件,导致异步消息丢失。
落地验证:read_unread_messagesAction(read-messages.ts)如今采用基于 ID 的精准删除:
- 先快照"将要读取"的事件 ID 集合:
const readEventIds = unreadEventsForThisChannel.map(item => item.id); - 内存侧只移除
readEventIds中命中的条目,botContext.unreadEvents[channelId] = ...filter(item => !readEventIds.includes(item.id)),因此处理期间新到达的事件被完整保留; - 数据库侧调用
deleteUnreadEventsByIds(channelId, ids),其实现(db.ts)使用 Drizzle 的inArray(unreadEvents.id, ids)做定点批量删除,而非全表清空。
这套"快照 ID → 差值过滤 → IN 删除"的模式,是典型的口令式精准清理,可有效杜绝异步覆盖丢消息。
2. 消除全局锁死锁:锁粒度下放到 Channel 级别
问题本质:若使用单一全局锁保护所有频道的处理,多频道并发时容易出现互等死锁,且单个频道处理慢会拖垮全局吞吐。
落地验证:锁被下沉到ChatContext 的isProcessing布尔字段(types.ts),每个频道各自持锁,互不阻塞。释放锁统一放在finally中强制完成,例如onMessageArrival中触发频道处理的自调用异步函数:
chatCtx.isProcessing = true ;(async () => { try { await loopIterationForChannel(botContext, satoriClient, chatCtx, currMsg.event) } catch (err) { botContext.logger.withError(err as Error).withField('channelId', chatCtx.channelId).log('Error in channel-specific loop') } finally { chatCtx.isProcessing = false // try...finally 强制释放锁 } })()同样的isProcessing检查也出现在周期性循环中(scheduler.ts):若频道正在处理,周期性任务直接跳过,避免重复进入。try...finally保证了无论成功、异常还是中断,锁最终都会被释放,这是消除"死锁残留"的关键手法。
3. 实现非阻塞调度:消息到达与周期性任务并发
问题本质:早期实现对每条消息都串行await完整循环,导致高流量下吞吐低下、后续消息被排队阻塞。
落地验证:调度被拆成两条并发路径:
- 即时反应路径:
onMessageArrival中处理频道时使用自调用 async 函数"fire-and-forget",不 await 完整循环,让队列消费者可以立即继续处理其他频道(scheduler.ts)。 - 周期性路径:
startPeriodicLoop启动的递归setTimeout定时器,每隔PERIODIC_LOOP_INTERVAL_MS = 60 * 1000(constants.ts)扫描所有"有未读事件"的频道,对每个频道并行启动后台处理(;(async () => {...})()),并跳过正在处理的频道。
两条路径共享isProcessing这把频道级锁来避免同一频道的处理互相打架,但不同频道之间、即时与周期任务之间都能并发执行。
P1 归档:逻辑链完整性与体验优化
4. 智能 Action 截断:trimActions自动回溯
问题本质:LLM 上下文中的 Action 历史不能无限累积,粗暴截断会破坏成对逻辑链——典型场景是send_message+continue:若截断后恰好以continue开头,LLM 将失去该continue所承接的前置动作语义,导致"上下文失忆"。
落地验证:trimActions(actions, max, keep)(utils.ts)实现了回溯式截断:
let startIndex = actions.length - keep // Backtrack to avoid starting with a 'continue' action which lacks its previous context while (startIndex > 0) { const currentAction = actions[startIndex].action if (currentAction.action === 'continue') { startIndex-- } else { break } } return actions.slice(startIndex)即:先保留最近keep条;若保留段的起点恰好是continue,则继续向前多保留一条,直到起点不是continue为止,确保 LLM 看到的动作序列语义完整。其在循环中的调用点为chatCtx.actions = trimActions(chatCtx.actions, MAX_ACTIONS_IN_CONTEXT, ACTIONS_KEEP_ON_TRIM)(scheduler.ts)。相关常量:MAX_ACTIONS_IN_CONTEXT = 50、ACTIONS_KEEP_ON_TRIM = 20(constants.ts)。
5. 阻断 API 滥用:MAX_LOOP_ITERATIONS = 5硬性上限
问题本质:Action 的shouldContinue语义(见continue/send_message等 Action 返回值,几乎都返回shouldContinue: true)允许 LLM 连续"自主思考"多步。若无上限,LLM 可能陷入无限循环,持续消耗 API 配额。
落地验证:handleLoopStep的 while 循环在每次迭代前检查if (iterationCount >= MAX_LOOP_ITERATIONS),达到 5 次即记日志并break(scheduler.ts);MAX_LOOP_ITERATIONS = 5定义于 constants.ts。此外每轮循环之间还有LOOP_CONTINUE_DELAY_MS = 2500的间隔(await new Promise(r => setTimeout(r, LOOP_CONTINUE_DELAY_MS))),既防止 LLM 连续空转,也给新事件打断提供时间窗口。
P2 归档:类型安全与基础设施
6. 修复 Satori API 类型不匹配
待办中记录的"使SatoriMessageCreateResponse的接口定义与运行时 Schema 保持一致",从源码看已形成"类型定义 + 运行时校验"双份对齐的规范:
- 接口侧:
SatoriMessageCreateResponse(types.ts)定义了id、content?、channel?、guild?、member?、user?、created_at?、updated_at?等可选字段; - Schema 侧:
SatoriMessageCreateResponseSchema定义于 schema.ts; - 消费侧:
SatoriAPI.sendMessage对/message.create的响应执行v.parse(v.array(SatoriMessageCreateResponseSchema), response)(api.ts),在运行时用 valibot 强制校验并丢弃多余字段,使内存中的对象与 Schema 严格一致。
7. 移除全局暴力退出
问题本质:早期在process.on('unhandledRejection')中直接process.exit(1),任何一次未捕获的 Promise 拒绝都会导致整个 Bot 进程粗暴退出、丢失内存状态。
落地验证:当前入口 index.ts 中,unhandledRejection与uncaughtException处理器都只记录结构化错误日志(含cause字段),不再调用process.exit,让进程有机会继续存活、等待重启或由上层守护进程接管。只有在main()自身致命失败时才exit(1)(index.ts)。
8. 消除 Any 类型滥用
待办提及修复了"LLM 解析、数据库 Schema 等多处的类型退化"。从源码看,如今这些路径都有严格的类型边界:
- LLM 解析:
imagineAnAction先用best-effort-json-parser的parse解析 LLM 原始输出,再通过v.parse(ActionSchema, actionToValidate)做 valibot 校验(llm-client.ts);ActionSchema是continue/break/sleep/list_channels/send_message/read_unread_messages六个判别式 union(types.ts)。 - 数据库 Schema:
src/lib/schema.ts用 Drizzle 的pgTable定义了channels、messages、eventQueue、unreadEvents四张表,字段类型明确(json事件体、bigint时间戳、text主键等),并给messages建立了channel_timestamp_idx索引用于按频道检索最近消息。 - Dispatcher 层:
dispatchAction对 action payload 同样先v.safeParse(ActionSchema)再执行,非法 payload 直接返回系统错误而非类型崩溃(dispatcher.ts)。
9. 重写队列持久化 I/O:Drizzle 增量更新模式
问题本质:旧版持久化对队列采用"全量覆盖写"(先清空整表再整体写入),在队列频繁变动时会放大磁盘 I/O,且清空与写入之间窗口期的崩溃会导致数据丢失。
落地验证:现在的事件队列持久化(db.ts)全面改为增量更新:
- 入队:
pushToEventQueue生成nanoid()后单条insert; - 出队:
removeFromEventQueue(id)按主键delete; - 批量消费时逐条
shift()+removeFromEventQueue(currMsg.id),仅在极端兜底场景才使用saveEventQueue全量重写(注释中明确注明"best way is NOT to use saveEventQueue for single items")。
未读事件侧同样有对应的增量 API:pushToUnreadEvents(插入)、deleteUnreadEventsByIds(inArray定点删除,见上文 P0-1)、clearUnreadEventsForChannel(按频道清理),以及进程启动时的loadEventQueue/loadUnreadEvents恢复机制。整个 Bot 采用PGlite(嵌入式 PostgreSQL)+ Drizzle ORM,数据库路径默认data/pglite-db(config.ts)。
待办规划:P1 架构完善与 P2 增强
P1:重构 Action 截断逻辑,防止上下文失忆(策略层)
todolist.md 中当前唯一的 P1 待办是进阶策略:将较旧的actions压缩为文本 Summary存入 LLM Context,而非直接丢弃。这比现阶段的trimActions回溯截断更进一步——不满足于"保住成对逻辑链",而是让被裁掉的历史以摘要形态继续参与 LLM 推理。结合 llm-client.ts 可见,目前 Action 历史是以- Action: {json}, Result: {json}的文本行注入用户消息的,因此该规划的落点大概率是"生成历史摘要文本替换尾部被截断的部分",并与MAX_ACTIONS_IN_CONTEXT/ACTIONS_KEEP_ON_TRIM的阈值协同。
P2:监控增强与类型收紧
- Trace 日志:为所有 Action 执行增加更详细的 Trace 日志。当前
dispatchAction仅在执行时记录一条debug级日志(log.withField('action', ...).debug('Executing action'),dispatcher.ts),异常时记录error级日志。规划方向是在 Action 的execute前后记录更细粒度的输入输出、耗时与结果快照,便于多平台、多频道场景下的问题追溯。 - 类型收紧:持续检查并消除残留的
as any类型断言。从源码看,llm-client.ts中仍有(req as Record<string, unknown>).think = false(llm-client.ts)这类为兼容 Ollamathink参数而做的类型放宽,可作为收尾工作的起点。
运行环境与配置
如果你要在本地把这份代码跑起来验证上述行为(README):
- 前置依赖:Node.js ≥ 18.0.0、pnpm ≥ 8.0.0、运行
server-satori插件的 Koishi 实例,以及任意 OpenAI 兼容的 LLM 服务(Ollama、vLLM、DeepSeek 等)。 - 安装:
pnpm install - 配置:
cp .env .env.local,关键变量如下(对应 config.ts 的 valibot 校验逻辑,配置缺失或非法会在启动时打印错误并退出):
# Satori 配置 SATORI_WS_URL=ws://localhost:5140/satori/v1/events SATORI_API_BASE_URL=http://localhost:5140/satori/v1 SATORI_TOKEN= # 可选;Koishi 未开启鉴权时可留空 # LLM(OpenAI 兼容) LLM_API_KEY=your_api_key_here LLM_API_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4 LLM_RESPONSE_LANGUAGE=English LLM_OLLAMA_DISABLE_THINK=false- 运行:开发模式
pnpm --filter @proj-airi/satori-bot dev(热重载);生产模式pnpm --filter @proj-airi/satori-bot start。 - 提示词与数据:人物与系统提示词位于
src/core/planner/prompts/*.velin.md;PGlite 数据库位于data/pglite-db。
小结
从 todolist.md 的归档项可以看出,AIRI Satori Bot 的稳定性建设遵循了一条清晰的工程路径:先用频道级锁 +try...finally消除并发死锁与资源泄漏,再用基于 ID 的精准删除与 Drizzle 增量持久化堵住数据丢失,接着用trimActions回溯截断与MAX_LOOP_ITERATIONS硬上限约束 LLM 的自主循环,最后以 valibot 运行时校验全面收敛类型边界。这套"Mini-Core"的经验——尤其是锁粒度设计、差分清理与上下文完整性保护——正是未来 AIRI 主 Core 接管认知循环时可以直接迁移的资产。
- 人工智能
- AI Agent
- MCP 服务
- 工具调用
- GUI 自动化
- 语音
- 数字人
- 交互助手
【免费下载链接】airi
💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。
相关推荐
AIRI Satori Bot 状态持久化架构解析:内存优先(Memory-First)与 PGlite + Drizzle 可靠恢复
AIRI Satori Bot 状态持久化架构解析:内存优先(Memory First)与 PGlite + Drizzle 可靠恢复 本文以 AIRI 仓库中
人工智能AI AgentMCP 服务工具调用GUI 自动化语音数字人交互助手后端AIRI Satori Bot 结构化动作系统提示词解析:JSON Action 驱动的自主循环实现
AIRI Satori Bot 结构化动作系统提示词解析:JSON Action 驱动的自主循环实现 导读 本文以 AIRI 仓库 integrations/s
人工智能AI AgentMCP 服务工具调用GUI 自动化语音数字人交互助手后端Vibe终极语音转文字指南:从零开始掌握高效转录技巧
Vibe终极语音转文字指南:从零开始掌握高效转录技巧 在数字化信息时代,音频和视频内容无处不在,但将语音内容转换为可编辑的文字却常常成为效率瓶颈。Vibe语音转
人工智能语音音频桌面应用本地部署模型推理服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考