Aalis 架构总览
本文档描述 Aalis 框架的整体架构设计、核心流程和扩展机制。
设计哲学
Aalis 核心遵循忒修斯之船原则:Core 只提供最小化基础设施(事件、服务容器、中间件管道、插件生命周期),所有功能——LLM 调用、消息存储、对话编排、平台接入——由可插拔插件提供。核心的任何行为均可被插件拦截、修改或完全替换。
类型与接口层面:所有业务服务接口(LLM / Memory / Storage / Tools / Commands / Gateway / WebUI / Authority / Agent 等)由对应的 @aalis/api-* 包提供,core 不持有任何业务接口。详见 api 包架构。
@aalis/core 对外暴露:
- 运行时基础设施:
App/Context/EventBus/ServiceContainer/HookRegistry/ConfigManager/Logger/PluginManager - 四个扩展点:
ServiceTypeMap/AalisEvents/HookContextMap/ContributionPointMap(均通过 declaration merging 由@aalis/api-*注入业务键) AalisConfig仅声明基础字段(name/logLevel/plugins/disabledPlugins/servicePreferences)加[key: string]: unknown兜底;业务字段(owners / deniedCapabilities / authorityOverrides / confirmOverrides 等)由对应 api-* 通过 declaration merging 注入,core 不知晓其语义ConfigManager是纯内存配置中枢:自身不读写文件,save()把整份配置快照原样委托给宿主注入的ConfigProvider.save()(无 provider 时静默忽略),对所有顶层字段一视同仁、不含任何业务特例(合并默认值时mergeDefaultsConfig()也是先填 core 已知字段、再透传其余)
业务数据契约与领域类型一律不在 core:Message / ContentSegment / ToolCall(OpenAI 协议形状,跨载体复用)与 getSenderLabel / prefixSender / getMessageName 在 @aalis/schema-message,ToolDefinition / ToolFunction 在 @aalis/api-tools,UserIdentity 在 @aalis/api-authority,ModelRef / resolveLLMModel 在 @aalis/api-llm。
宿主层 vs 核心层(Bootstrap 边界)
@aalis/core 在物理上是环境无关的内存运行时:package.json 零运行时依赖,源码不 import 任何 node:fs / node:path / node:os / node:child_process,不调用 process.cwd() / process.argv / console.*,也不读 process.env(devMode 由宿主显式注入)。这意味着同一份 core 理论上可跑在浏览器、Worker、Deno 等任何 JS 运行时。
业务插件同样受约束:直接 import
node:fs/node:child_process/node:os/node:http(s)被 biome 拦截,必须改走@aalis/api-storage/@aalis/api-process。完整白名单与豁免理由见 node-usage-policy。
环境耦合全部收敛在宿主层 @aalis/runtime(monorepo 由仓库根 src/index.ts 一行 startAalis() 拉起),通过 new App({ ... }) 注入到 core:
| AppOption | 抽象(在 core) | 默认实现(在 @aalis/runtime) | 职责 |
|---|---|---|---|
config / configProvider | AalisConfig / ConfigProvider | createFsYamlConfigProvider() | 配置读 / 写 / fs.watch 热重载 |
pluginLoader | PluginLoader | createFsPluginLoader() | 扫描 packages/ + dynamic import |
restartStrategy | RestartStrategy | createProcessRespawnStrategy() | child_process.spawn 重启进程 |
dataDir | string | 由 yaml provider 决定 | 数据目录绝对路径 |
devMode | boolean | process.env.NODE_ENV !== 'production' | dev 校验开关 |
另外 @aalis/runtime 的 startAalis 还负责 stdout/stderr console-sink、文件日志、终端状态恢复、子命令分发、SIGINT 优雅退出 —— 这些都是纯宿主关切,core 完全不知情。
系统分层
┌──────────────────────────────────────────────────────────────┐
│ 平台层 (Platform Layer) │
│ CLI · WebUI (Express+WS+React) · OneBot v11/v12 │
├──────────────────────────────────────────────────────────────┤
│ 流控层 (Flow Control Layer) │
│ ChatFlow: 消息缓冲 → 触发评分 → 空闲检测 → 打字延迟 │
├──────────────────────────────────────────────────────────────┤
│ 任务编排层 (Task Layer) │
│ SessionManager: 会话树 · 子任务并行 · 平台配置继承 │
│ Scheduler: Cron 定时任务 · 主动执行 │
│ TodoList: 任务跟踪 · 子任务协调 │
├──────────────────────────────────────────────────────────────┤
│ 对话编排层 (Agent Layer) │
│ DefaultAgent: 消息构建 → LLM 调用 → 工具循环 → 上下文裁剪 │
├──────────────────────────────────────────────────────────────┤
│ 服务层 (Service Layer) │
│ LLM · Memory · Embedding · VectorStore · Persona · Tools │
│ Skills · ImageRecognition · WebSearch · Office │
│ 接口由 api-* 提供,实现可多提供者并存 │
├──────────────────────────────────────────────────────────────┤
│ 核心框架层 (Core Layer) │
│ App · Context · ServiceContainer · PluginManager │
│ EventBus · HookRegistry · ConfigManager · Logger │
│ Lifecycle · DisposableChain(资源内核,不导出) │
│ 4 个扩展点:ServiceTypeMap / AalisEvents / HookContextMap │
│ / ContributionPointMap │
│ (业务接口均在 api-*,core 不持有) │
└──────────────────────────────────────────────────────────────┘消息处理完整流程
用户输入 (CLI / WebUI / OneBot)
│
▼
Platform 适配器接收 → 发出 inbound:message 事件
│
▼
App 路由 → Agent.handleMessage(incoming) 作为中间件默认行为
│
├─ 1. ctx.runHook('agent:input:before', { message, metadata }, defaultAction)
│ │
│ ├─ [ChatFlow 中间件] 流控拦截/缓冲
│ ├─ [其他插件中间件]
│ └─ 全部通过 → defaultAction() 进入 Agent 处理
│
├─ 2. buildMessages()
│ └─ [系统提示词] + [历史消息(≤50)] + [当前用户消息]
│
├─ 3. 组装 agent:prompt 贡献 → 物化为带归属标识的 system 块
│ ├─ plugin-memory-vector / memory-summary: 语义记忆、摘要(context 槽)
│ ├─ plugin-user-profile / user-relation: 档案、关系(identity 槽)
│ └─ plugin-skills: 技能库路标与已激活正文(knowledge 槽)
│
├─ 4. ctx.runHook('agent:llm:before') ← 拦截者审已成型的 messages
│ └─ plugin-tool-search: 替换工具列表为搜索层
│
├─ 5. trimMessages() ← 按 token 预算裁剪
│
├─ 6. LLM.chatStream() → 流式输出 → outbound:stream 事件
│
├─ 7. ctx.runHook('agent:llm:after')
│
├─ 8. 工具调用循环 (最多 maxToolIterations 次)
│ ├─ ctx.runHook('agent:tool:before')
│ ├─ ctx.getService<ToolService>('tools')!.execute() ← 权限检查 + 执行
│ ├─ ctx.runHook('agent:tool:after')
│ └─ 追加工具结果 → 继续调用 LLM
│
├─ 9. ctx.runHook('agent:reply:before')
│ └─ plugin-persona: outputFormat JSON 解析
│
├─ 10. 保存到 memory (用户+助手消息)
│
└─ 11. emit('outbound:message') → 各平台输出给用户核心扩展机制
Aalis 提供四种互补的扩展手段,覆盖不同粒度的定制需求:
1. 中间件管道 (Hooks)
插件通过 ctx.middleware(hook, fn) 注册中间件,拦截核心流程的各阶段。中间件可修改数据或中断流程。同一钩子内多个 handler 按注册顺序执行洋葱模型(无优先级数字);跨钩子顺序由调度方(如 plugin-gateway)显式决定。
// 拦截消息(不调用 next = 中断整个管道)
ctx.middleware('agent:input:before', async (data, next) => {
if (shouldBlock(data.message)) return; // 中断
data.message.content += ' [已审核]'; // 修改
await next(); // 继续
});2. 服务替换 (Service IoC)
任何服务都可以被替换。提供同名服务的插件自动参与优先级竞争:
// 注册自定义 Agent 实现
ctx.provide('agent', myAgent, { priority: 20 });3. 事件监听 (EventBus)
松耦合的发布/订阅模式,用于响应系统事件而不干预流程:
ctx.on('outbound:message', async (msg) => { /* 记录日志、统计等 */ });4. 贡献点 (Contribution Points)
向共享产物提交一块内容,排布权归收集方。与 hooks 的分工:改写或截停既有流程 → hooks;向共享产物添加自己的一块 → 贡献点。贡献者拿只读视图、不掌握控制流(无短路、无排序影响力、看不到他人产出),因此重复注入、排布漂移、错误连坐在 API 上无法表达。
// 往 LLM 提示词交一块(agent:prompt 是 plugin-agent 定义的贡献点)
ctx.contribute('agent:prompt', {
id: 'my-block',
anchor: 'context',
build: async view => (view.dryRun ? null : `补充上下文:${await load(view.sessionId)}`),
});
// 任何插件也可拥有自己的贡献点:收集并自行决定执行策略
for (const { key, spec } of ctx.collect('my-plugin:panel')) { /* ... */ }5. Declaration Merging
第三方插件可通过 TypeScript 声明合并来扩展核心类型:
declare module '@aalis/core' {
interface AalisEvents {
'scheduler:tick': [jobId: string];
}
interface HookContextMap {
'schedule:before': { jobId: string; cron: string };
}
interface ContributionPointMap {
'my-plugin:panel': { id: string; render(): string };
}
}服务 IoC 与多实现解析
服务注册
ctx.provide('llm', deepseekService, { priority: 10 });provide 不接受 capabilities 选项——内核 DI 只关心「按名解析服务实例」。 领域级筛选(如按 LLM 模型的 tool-calling / vision 能力挑模型、按 storage root 选权限) 由各 -api 包在服务实例自己的元数据上处理(如 LLM 把能力挂在 model handle 上), 不再经过内核的服务能力匹配层。
服务消费
const llm = ctx.getService<LLMModel>('llm');getService(name) 只接受服务名,返回当前胜者实例(不再有第二个 capabilities 参数)。
多实现解析顺序
同一服务可有多个提供者。解析顺序为 偏好 > 优先级 > 注册顺序: getService() 先看是否有用户偏好的 entry,否则取 priority 最高者(同优先级取先注册者)。
llm 服务:
[0] plugin-llm-deepseek (priority=10) ← getService('llm') 默认胜者
[1] plugin-llm-openai (priority=0)服务偏好
用户可通过配置(servicePreferences)或 WebUI Services 页切换首选提供者 (ctx.preferService(name, contextId))。偏好者总是 getService() 的第一返回值, 即使其 priority 低于其他 entry;切换偏好会发出 service:preference-changed, 驱动 whenService 订阅者重挂。
插件生命周期
register
│
▼
pending ──(所有 required 依赖满足)──→ activating ──→ active
▲ │
│ │
└───(依赖服务被移除)────────────────────────────────┘
disabled ←─(手动禁用)─ active
│
└─(手动启用)─→ pending → ...统一状态机:recompute(reason)
PluginManager 只有一个外部可见的状态变更入口:recompute(reason)。所有生命周期路径 (服务注册/移除、启用/禁用、配置更新、bounce、关机)都被归一为 RecomputeReason 后 汇入同一状态机。
| Reason | 触发场景 |
|---|---|
service-up | service:registered 反应式调用 |
service-down | service:unregistered 反应式调用;仅下游声明 requiresBounceOnDepChange: true 时才级联 bounce(默认否) |
plugin-state-changed | enable/disable/updateConfig/bounce 后调用(softReload() 薄壳) |
shutdown | App.stop() 调用(stopAll() 薄壳) |
单轮两阶段(拓扑保证)
每轮 recompute 先按 provider→consumer 拓扑排序(Kahn),然后:
- Phase A 反向遍历 dispose:单轮内消费者先于提供者 dispose。整体停机(
app.stop())就是一轮 shutdown;单插件 unload / disable / bounce 先拆该插件、其下游下一轮才降级,dispose hook 不能假定依赖服务仍在。 - Phase B 正向遍历 activate(非 shutdown):提供者先于消费者 active。
如本轮有变动则进入下一轮,直到稳定(fixed-point)或达到轮次上限(maxRounds = 2×插件数 + 8)。service-up / service-down 在第二轮起退化为 plugin-state-changed,避免无限 optional bounce。
隔离粒度
ctx.fork(id)— 复用全部根子系统,仅独立_disposables。适合"同 App 内一个独立插件实例"。- 完全隔离 — 需要独立事件总线、独立日志通道时,应直接
createApp({ events, services, hooks, ... })创建新的App实例。Logger可注入独立LogHub隔离日志缓冲。 - 按会话/租户差异化配置不需要上下文隔离——用键控解析(参考 session-manager 的
resolveConfig(sessionId)模式)。 - 曾经的实验性
ctx.createScope(id)(ScopedServiceContainer+ScopedConfigManager叠加隔离)已在 0.7.0 移除:全生态零消费者,且共享事件/钩子/文件系统的边界不足以承担"沙盒"语义(此前文档即预告"长期未消费可能被精简")。 whenService(name, cb)是稳定 API:每次 provider 上线都调一次cb,下线/ctx dispose 自动调上次返回的 cleanup,跨 bounce 自动重挂——是消费 hub 型服务的推荐入口(参见 docs/core/context.md)。
Context dispose 推荐 API
| 场景 | API |
|---|---|
| 监听事件 | ctx.on(event, fn) — dispose 时自动注销 |
| 注册中间件 | ctx.middleware(hook, fn) — dispose 时自动注销 |
| 注册服务 | ctx.provide(name, impl, { priority }) — dispose 时自动注销 |
| 清理外部资源(连接、定时器、子进程) | ctx.onDispose(() => cleanup()) |
| ⚠️ 绕过自动清理 | 无公开通道——ctx.serviceContainer 属 @internal、无版本承诺,插件不应使用 |
中间件钩子管道
钩子(Hook)是命名的中间件管道,插件可拦截核心流程的各阶段。
执行模型
ctx.runHook(hookName, data, defaultAction?) → reachedEnd: boolean
│
▼
handler A(先注册)─── await fn(data, next)
│ next() │ 不调用 next() → 链终止
▼ ▼
handler B(后注册) 管道返回 false,defaultAction 不执行
│ next()
▼
defaultAction() ← 所有 handler 通过后执行关键约定:
- 同一钩子内多个 handler 按 注册顺序 执行洋葱模型,无优先级数字
- 不调用
next()即中止整个管道(含 defaultAction);ctx.runHook()返回false - 跨钩子的顺序由调度方(如 plugin-gateway)显式决定
Gateway 入站生命周期相位
入站消息按以下命名相位顺序串行执行;任一相位被 swallow 即停止后续调度:
| 相位 | 数据载荷 | 占据者 | 默认动作 |
|---|---|---|---|
inbound:command | InboundPhaseData | plugin-commands | (无) |
inbound:flow | InboundPhaseData | plugin-flow-control | (无) |
inbound:trigger | InboundPhaseData | plugin-trigger-policy | (无) |
inbound:dispatch | InboundPhaseData | — | agent.handleMessage(message) |
InboundPhaseData = { message, metadata, agent },对象在四个相位间共享传递。 第三方插件可注册到任一相位获得清晰的语义位置——无需理解优先级数字、无需与其他插件协商占位。
其他钩子
| 钩子名 | 数据 | 用途 |
|---|---|---|
outbound:dispatch | { message, metadata } | 出站主管道;默认动作是 emit('outbound:message'),handler 可脱敏 / 限速 / 审计。 |
agent:input:before | { message, metadata } | 修改/拦截收到的消息(图像识别、文件提取) |
agent:turn:after | { message, reply, sessionId, metadata } | agent 回复周期完成后(摘要触发、子任务完成检测) |
agent:llm:before | { messages, tools, sessionId } | 修改发给 LLM 的消息列表和工具(记忆注入、技能注入、工具搜索替换) |
agent:llm:after | { response, messages } | 处理 LLM 返回的响应 |
agent:tool:before | { name, args, toolCallContext } | 修改工具调用参数 |
agent:tool:after | { name, result, toolCallContext } | 处理工具返回结果 |
agent:reply:before | { content, sessionId } | 修改最终回复内容(persona JSON 解析) |
入站请使用
inbound:*相位,出站请使用outbound:dispatch。
遥测事件
gateway:phase:done 在每个 inbound 相位结束后发出,携带 { phase, reachedEnd, durationMs, sessionId, platform },可用于度量耗时与 swallow 率,对主流程零侵入。
扩展自定义钩子
插件可以定义并触发自己的钩子,第三方可注入 handler:
// 定义钩子的插件
await ctx.runHook('my-plugin:before', { task: taskData }, async () => {
// defaultAction
});
// 注入 handler 的第三方插件
ctx.middleware('my-plugin:before', async (data, next) => {
data.task.modified = true;
await next();
});权限与安全
权限是两条正交的轴:轴 A「等级」决定谁可以执行,轴 B「确认」做 HITL 意图核对 (owner 同样受确认轴约束,以抵御提示词注入借权)。两轴由 plugin-authority 统一裁决。
轴 A · 数字等级裁决(开放整数等级)
每个外部身份 → 一个整数等级(缺省 0,封禁 = 负数)
owner → ∞(靠 owners 列表归属,不在等级轴上,永不能被设成有限值)
每个操作 → 一个最低等级 minLevel
minLevel 解析(首个命中赢):
1. authorityOverrides[capability] ← owner 逐条覆盖成任意整数
2. risk 派生 ← safe→0 · sensitive→1 · dangerous→2
3. visibility 兜底(无 risk 时) ← public→0 · restricted→2
裁决 resolveAccess(首个命中赢):
1. deniedCapabilities glob 全局硬禁 → 拒(压过一切,含 owner;配置总闸,非 per-user)
2. isOwner → 放行
3. level >= minLevel → 放行;否则拒(封禁=负数连 minLevel=0 都不过)没有命名档位(受信/管理员等表面名仅是等级整数的别称)、没有能力委托图、 没有 per-user 的逐条能力授予/禁用列表。owner 只管理权限(不能自授): WebUI authority 页(仅 owner)+ 指令 /level(设某用户等级)与 /auto(自动确认模式)。 模型详见 docs/plugins/plugin-authority.md。
轴 B · 确认(HITL 意图核对)
确认轴与等级轴正交,由独立的 plugin-session-confirm(HITL 协调器)执行, 经 setConfirmHandler 注册进 authority。risk 声明同时设两轴默认值 (如 dangerous = visibility:'restricted' + confirm:'session')。
触发命中 confirm 的操作
│
▼
可跳过确认? (shouldSkipConfirm,always 永不可跳)
├─ skipConfirm(系统/受信源,如 scheduler 无人可点)→ 跳过
├─ auto 模式 且 触发者是 owner 本人 → 跳过
└─ 否则 → 向 confirmHandler 发起确认
│
▼
回复 Y → 放行本次
回复 YS → 放行本会话(限时,session 记住)
其它 → 取消confirm:'always' 是最高危档:每次都必须有人确认,永不被 session 记住、 也不被 auto 模式跳过(cron 等无人确认场景直接拒)。
上下文窗口管理算法
trimMessages() 采用五阶段裁剪策略适配 LLM 上下文窗口:
可用 token = contextLength - maxTokens - 512(安全余量)
保护规则:
1. 首条系统消息 (主提示词) — 永不删除
2. 最新用户消息 (当前任务上下文) — 永不删除
3. 最后一组工具调用 (assistant+tool 成组) — 永不删除
4. Hook 注入的系统消息有独立预留额度 (memoryTokenBudget)
裁剪阶段:
第一阶段: 压缩超大系统消息 (最少保留 200 字符)
第二阶段: 截断过长工具输出 (>1500 字符 → 保留前 500)
第 2.5 阶段: 精简思考内容 (删除旧迭代、截断最新)
第三阶段: 摘要旧工具调用组 (压缩为 "[tool] → result" 格式)
第四阶段: 从最旧开始删除非系统消息 (保护最新用户消息 + 工具组)
第五阶段: 删除 Hook 注入的系统消息 (最后手段)
压缩后延续提示:
当裁剪删除 ≥6 条消息时,自动注入系统提示:
"由于上下文长度限制,部分历史消息已被压缩或移除。
请基于当前可见的上下文继续完成任务。"事件列表
core 自持的十一个基础设施事件(app:* 五个屏障、service:* / plugin:* / plugins:changed 六个通知)及其时序见 core/events.md;业务事件由各 -api 包注入,按包查见扩展点索引 §2。 总线上没有 dispose 事件,清理副作用用 ctx.onDispose(fn);memory:clear 是钩子不是事件,见 events.md 的钩子节。
向量语义记忆
索引流程
仅对已落库的入站用户消息建索引(inbound:message:archived),助手/出站消息不入向量库。
inbound:message:archived → embedding.embed(prefixSender(text)) → vectorstore.add(vector, metadata)检索与注入
检索通过 agent:prompt 贡献(context 槽)完成,非独立 hook、无优先级数字;token 干跑(dryRun)跳过真实检索。
agent:prompt 贡献 (context 槽):
1. 提取最后一条用户消息
2. embedding.embed(query)
3. vectorstore.search(queryVector, topK*4) ← 粗召回
4. 阈值过滤 + 跨会话模式过滤
5. 时间加权重排:
finalScore = (1-timeWeight) * semanticScore + timeWeight * recencyScore
recencyScore = exp(-0.1 * daysSince)
6. 取前 topK,扩展上下文窗口并去重
7. 物化为 system 块(带日期和来源标注)