Skip to content

memory 服务 ​

1. 定位 ​

memory 是会话记忆与持久化层。它把对话消息(Message)按 sessionId 落库,对外提供一组读写能力:拉取历史、范围查询、跨会话最近消息、归档裁剪、结构化元数据。

agent 构建 LLM 上下文、checkpoint 回滚、summary 压缩等所有依赖「记住对话」的功能,都建立在它之上。

  • 服务注册名:memory(描述符 memory,消费方经 memory.use 调用)
  • 契约包:@aalis/api-memory
  • 参考实现:@aalis/plugin-memory-sqlite(默认推荐)、@aalis/plugin-memory-inmemory(fallback)、@aalis/plugin-memory-mongodb

该服务只负责「存与取」,不负责「写进去」的内容编排。真正决定一条消息内容构成的是 message-archive(见下文「谁消费」)。

2. 契约 ​

接口与类型来自 @aalis/api-memory。消息类型 Message 来自 @aalis/schema-message,包含 role / content / kind / timestamp / segments / metadata / reasoningContent / toolCalls。

2.1 核心接口(必须实现) ​

MemoryService 的必须实现面由以下方法构成:

ts
saveMessage(sessionId: string, message: Message): Promise<void>;
getHistory(sessionId: string, limit?: number): Promise<Message[]>;   // 仅未归档,按 timestamp 升序;条数只数对话消息
clearSession(sessionId: string): Promise<void>;
archiveSpan(sessionId: string, span: { count: number; keep: readonly number[] }): Promise<number>; // 归档最早的 count 条活跃消息,keep 列出的位置不归档,返回归档条数

getHistory 的语义由参考实现确立:只返回 archived=0 的消息,按 timestamp, id 升序输出;默认 limit=50。条数只数对话消息:schema-message 的 CONTROL_KINDS(回合记录、压缩分隔线这类事件标记)不占条数,读出的是第 limit + 1 新的那条对话消息之后的全部活跃消息,夹在其间的标记一并带回,对话消息不超过 limit 条时是全部活跃消息。countHistory 同一口径。

带条数的读法(getHistory、getFullHistory 的 limit,getRecentMessagesAcrossSessions 的 query.limit,updateMessageContent 的 recentLimit)一个规则:条数是非负整数或 Infinity(调用方保证),为 0 时返回空(updateMessageContent 一条都不改),为 Infinity 时返回全部(跨会话查询照样收窄到 crossSessionMaxLimit)。MongoDB 的 limit(0) 表示不限条数、SQLite 的 LIMIT 绑不了 Infinity,自写后端透传条数之前要先处理这两个值。

archiveSpan 按与 getHistory 相同的顺序(时间,同毫秒按写入序)把最早的 count 条活跃消息标为 archived,其中 keep 列出的位置(这一段里从 0 数的下标)不归档、原样留在活跃历史里,不做物理删除;count 与 keep 的元素都是非负整数(调用方保证),count 为 0 时什么都不归档,大于活跃条数时这一段就是全部活跃消息,keep 里落在段外的下标不起作用、重复的与一个相同。位置按调用这一刻的活跃历史算,之后追加的新消息在段外;调用方在调用之前核对快照。原子性按后端分档:sqlite 与 inmemory 全成或全不成,mongodb 是一次 updateMany、不是事务。压缩靠它落定,缺了没有降级可走,所以是必填。

2.2 可选方法(? 修饰,按需实现) ​

ts
clearAll?(): Promise<void>;                                          // 清空所有会话 + 归档(不含元数据)
getFullHistory?(sessionId, limit?): Promise<Message[]>;             // 含已归档,供 UI 展示;事件标记也占条数
countHistory?(sessionId): Promise<number>;                          // 活跃对话消息的条数(不含 CONTROL_KINDS),与 getHistory 按条数读同一口径

// 范围查询(向量召回时扩展上下文窗口用)
getMessagesBySessionRange?(
  sessionId: string, fromTs: number, toTs: number,
  roles?: Array<Message['role']>, excludeKinds?: string[],
): Promise<Message[]>;                                               // [fromTs,toTs] 升序

// 跨会话最近 N 条(跨会话历史注入用)
getRecentMessagesAcrossSessions?(
  query: RecentMessagesAcrossSessionsQuery,
): Promise<RecentMessageRecord[]>;

// 结构化元数据(namespace 隔离,(namespace,key) 唯一)—— 摘要等场景的旁路存储
saveMetadata(namespace, key, data): Promise<void>;
getMetadata(namespace, key): Promise<Record<string,unknown>|undefined>;
listMetadata(namespace): Promise<MetadataEntry[]>;   // MetadataEntry = { key; data; updatedAt }
listMetadataKeys?(namespace): Promise<string[]>;      // 只列键、不读 data,读不出的条目也列出(按命名空间整体清理用)
deleteMetadata(namespace, key): Promise<void>;
commitMetadata(ops): Promise<void>;                  // 批量提交,原子性按后端分档

updateMessageContent?(sessionId, oldText, newText, recentLimit?): Promise<number>; // 含 oldText 的消息(含归档)里最近 N 条做文本替换
deleteMessagesByTimestamps?(sessionId, timestamps): Promise<number>; // 按时间戳批删(回滚整轮)

2.3 跨会话查询类型 ​

RecentMessagesAcrossSessionsQuery 的字段如下:

  • limit: number(必填)— 按 timestamp DESC 取最近 N 条,最终升序返回。
  • sinceTs? / platform?(按 metadata.platform 过滤)/ excludeSessionIds?(通常排除当前会话,避免与会话内 history 重复)。
  • roles?:省略时实现应默认 ['user','assistant']。
  • kinds? 白名单 / excludeKinds? 黑名单(黑名单优先)。

RecentMessageRecord = { sessionId: string; message: Message }——结果按条带上来源 sessionId。契约对实现的硬性约束是:仅返回未归档、DESC 取 limit 后升序、按 platform / excludeSessionIds / roles / sinceTs 过滤。

2.4 配套的钩子与事件契约(同包声明) ​

api-memory 还通过 declaration merging 注入以下钩子、贡献点与事件(钩子注入 @aalis/api-hooks 的 HookContextMap,贡献点注入 @aalis/api-contributions 的 ContributionPointMap,事件注入 @aalis/core 的 AalisEvents):

  • Hook 'memory:clear':统一编排各子系统的记忆清除,scope: 'session'|'all',中间件把各子系统结果填进 results[]。persona 等插件靠监听此钩子参与清除,不是直接调 memory 服务。结果行的可选字段 type 是这一行所属的清理类型:处理某个清理类型的插件登记它(见下一条),并在各行标注它,成败都标。/clear 据此判断显式指定的类型有没有处理者,不标注会被回执误报为「没有已启用的插件处理」。只在清理全部会话时清的数据(跨会话的,如用户档案),由处理它的插件在会话级清理、types 显式含它时自己推一行未清理的结果。
  • 贡献点 'memory:clear-type':清理类型的登记表,spec 是 { id, label }(ClearTypeContribution)。id 是类型名(/clear -t 的写法、结果行的 type),label 是显示名,可以是文字对象。/clear 只认登记了的类型,帮助按登记表列出;同一类型可由几家登记,显示名取收集次序里第一家的。
  • Event 'memory:messages-deleted':消息被按时间戳删除后广播,下游存储(如向量库)据此同步清理。
  • Event 'memory:messages-rewritten':某会话已落库的消息正文被改写(如出站附件补上识图描述)后广播,载荷 { sessionId, messages },messages 是改写后的整条(时间戳不变)。改写的一方在改写成功后发(message-archive 补描述、media 的 update_image_description),建派生索引的下游按会话与时间戳换掉旧条目。负载带会话 id 与正文,不能当编排的触发源。
  • Event 'history:changed':会话历史发生结构性变化,前端据此重新拉取。
  • Event 'session:compressing':会话压缩的进度(开始、压成、没压成)。压缩本身是服务 compression,见 api-memory。

3. 谁提供 / 谁消费 ​

提供方(reference impls) ​

包priority说明
plugin-memory-sqlite10默认持久化,uses: { storage },全量实现所有可选方法
plugin-memory-inmemory-100进程内 fallback,不持久化,同样全量实现可选方法
plugin-memory-mongodb5MongoDB 后端

DI 按名挑选:本插件偏好 > 全局偏好 > priority > 注册顺序(见 docs/concepts/service-model.md)。sqlite(10)默认高于 inmemory(-100),两者同时装载且没有偏好时选中 sqlite。

消费方(典型读写点) ​

  • plugin-message-archive(写入唯一入口):saveMessage 经它封装,是消息进库的标准路径。它声明 uses: { memory }。
  • plugin-agent(构建 LLM 上下文):按会话设置 historyLimit 定发给模型的那一段(活跃历史超过它时成段丢弃最早的,见 plugin-agent):后端实现了 countHistory 时先数活跃历史里的对话消息,再用 getHistory 只读起点之后的那一段;没实现、或 historyLimit 为 0 与没设置时用 getHistory(sessionId, Infinity) 读全部活跃历史。拼进 messages。
  • plugin-checkpoint(回滚):以 optional(memory) 声明依赖、持有 memory 的选择层,先看 present 再经 use 调用 deleteMessagesByTimestamps,并 emit memory:messages-deleted / history:changed。
  • plugin-memory-summary(压缩):用 getHistory(sessionId, Infinity) 读全部活跃历史,压成摘要后用 archiveSpan 归档切分点之前的消息(摘要模型挑出、原样保留的不归档),摘要本体存进 saveMetadata / getMetadata(namespace 为 SUMMARY_NAMESPACE)。
  • plugin-memory-vector(召回):监听 memory:messages-deleted 清除同时间戳的向量,监听 memory:messages-rewritten 按改写后的正文重收录,用 getMessagesBySessionRange 扩窗,并用 getMetadata / saveMetadata 存取 embedding 模型的存量标记(namespace memory-vector)。
  • plugin-message-archive 与 plugin-media(改写已落库的消息):经可选的 updateMessageContent 改写,改到了发 memory:messages-rewritten;后端没有实现它时不改写。
  • 其余广泛消费:session-manager、user-profile、user-relation、media、commands、todo-list、tool-session、maimai、adapter-onebot、image-sender 等。

4. 写一个 provider ​

最小必须 vs 可选 ​

必须实现 saveMessage / getHistory / clearSession / archiveSpan。其余方法带 ? 可选,但缺失会导致对应功能降级:

  • 不实现 getRecentMessagesAcrossSessions → 跨会话历史注入功能直接 no-op。
  • 不实现 clearAll → 含 context 的 /clear all(含不带类型的)整条拒绝执行、不清任何类型;不含 context 的类型照常清理。
  • 不实现 deleteMessagesByTimestamps → checkpoint 回滚失效。
  • 不实现 countHistory → 设了历史条数上限的会话,plugin-agent 每轮读全部活跃历史再切出发给模型的那一段,读量随会话增长。
  • 不实现 listMetadataKeys → 各插件经 /clear 整体清空命名空间时按 listMetadata 枚举键,读不出的条目留在库里。

参考实现(sqlite、inmemory)都完整实现了可选面。如果要实现一个能替换默认 memory 的完整后端,建议对齐它们。metadata 五方法是必填的(saveMetadata / getMetadata / listMetadata / deleteMetadata / commitMetadata)——不实现则无法通过编译;它们没有可用的降级路径(缺少存储后端即功能不可用),消费方直接调用、不带存在性守卫。其余八个方法可选,消费方一律带存在性守卫(如 if (memory.getFullHistory) … else 回落 getHistory),缺失只触发功能降级、不会崩溃。listMetadata 遇到读不出的条目(数据损坏、不是对象)要跳过并记一条点名 namespace 与 key 的 warn,不让整个命名空间读失败;读不到写入时间时 updatedAt 返回 0。listMetadataKeys 只列键、不读 data,读不出的条目也要列出:插件整体清空命名空间时经 clearMetadataNamespaces 按它列键,连这些条目一并删除(见 api-memory)。

双源元数据必须同步 ​

provides / uses 既要在源码导出,也要写进 package.json 的 aalis.service(见 docs/concepts/manifest-metadata.md)。sqlite 的两处:

源码: 源码(packages/plugin-memory-sqlite/src/index.ts):export default definePlugin({ provides: [memory], uses: { provide, storage, … } })。 package.json aalis.service:

json
{ "service": { "provides": ["memory"], "required": ["storage"] } }

可编译最小骨架 ​

ts
import { memory } from '@aalis/api-memory';
import type { MemoryService, MetadataEntry, MetadataOp } from '@aalis/api-memory';
import { definePlugin, provide } from '@aalis/core';
import { CONTROL_KINDS, type Message } from '@aalis/schema-message';

class MyMemoryService implements MemoryService {
  // 每个会话一个按写入序的数组,归档只打标记:留下的原文早于之后归档的消息,活跃、归档分两个列表就排不对
  private store = new Map<string, Array<{ message: Message; archived: boolean }>>();
  private meta = new Map<string, Map<string, { data: Record<string, unknown>; updatedAt: number }>>();

  private active(sessionId: string) {
    return (this.store.get(sessionId) ?? []).filter(e => !e.archived);
  }
  async saveMessage(sessionId: string, message: Message): Promise<void> {
    const arr = this.store.get(sessionId) ?? [];
    arr.push({ message, archived: false });
    this.store.set(sessionId, arr);
  }
  async getHistory(sessionId: string, limit = 50): Promise<Message[]> {
    // 条数为 0 时为空,不带最后一条对话消息之后的标记
    if (limit === 0) return [];
    const active = this.active(sessionId);
    // 只数对话消息:第 limit+1 新的那条之后的全部,夹在其间的事件标记一并带回
    const said = active.flatMap((e, i) => (CONTROL_KINDS.includes(e.message.kind ?? '') ? [] : [i]));
    const before = said[said.length - limit - 1];
    return active.slice(before === undefined ? 0 : before + 1).map(e => e.message);
  }
  async clearSession(sessionId: string): Promise<void> {
    this.store.delete(sessionId);
  }
  async archiveSpan(sessionId: string, { count, keep }: { count: number; keep: readonly number[] }): Promise<number> {
    const kept = new Set(keep);
    const span = this.active(sessionId).slice(0, count).filter((_, i) => !kept.has(i));
    for (const entry of span) entry.archived = true;
    return span.length;
  }
  async saveMetadata(namespace: string, key: string, data: Record<string, unknown>): Promise<void> {
    const ns = this.meta.get(namespace) ?? new Map();
    ns.set(key, { data, updatedAt: Date.now() });
    this.meta.set(namespace, ns);
  }
  async getMetadata(namespace: string, key: string): Promise<Record<string, unknown> | undefined> {
    return this.meta.get(namespace)?.get(key)?.data;
  }
  async listMetadata(namespace: string): Promise<MetadataEntry[]> {
    return [...(this.meta.get(namespace)?.entries() ?? [])].map(([key, v]) => ({
      key,
      data: v.data,
      updatedAt: v.updatedAt,
    }));
  }
  async deleteMetadata(namespace: string, key: string): Promise<void> {
    this.meta.get(namespace)?.delete(key);
  }
  async commitMetadata(ops: readonly MetadataOp[]): Promise<void> {
    for (const op of ops) {
      if (op.op === 'put') await this.saveMetadata(op.namespace, op.key, op.data);
      else await this.deleteMetadata(op.namespace, op.key);
    }
  }
}

export default definePlugin({
  name: '@aalis/plugin-memory-myimpl',
  provides: [memory],
  uses: { provide },
  apply({ provide }) {
    provide.use(memory, new MyMemoryService(), { priority: 10 });
  },
});

若 provider 是单实例(不按子上下文分裂),不需要 per-entry entryId;memory 后端一贯是每进程单实例,直接 provide.use(memory, svc, { priority }) 即可。

5. 标准消费方式 ​

业务句柄 memory.use 是活的,选择、缺席与固定同一家的规则见 选择层与业务层。

ts
import { memory } from '@aalis/api-memory';
import { definePlugin, logger, optional } from '@aalis/core';

export default definePlugin({
  name: '@acme/plugin-memory-consumer',
  uses: { memory: optional(memory), logger },
  async apply({ memory, logger }) {
    if (!memory.present) return;
    try {
      await memory.use.getHistory('session-id', 50);
    } catch (err) {
      logger.use.warn('获取历史消息失败:', err);
    }
  },
});
  • 硬依赖:声明 uses: { memory }(如 message-archive),缺失时框架不会激活你的插件,调用处不必另写判空。
  • 可选依赖:optional(memory) + 先看 memory.present 降级(agent 采用这种方式)。
  • 可选方法守卫:调用可选方法前先判断存在性 —— if (memory.use.getFullHistory) await memory.use.getFullHistory(...)。
  • 有状态资源随换人重挂:用 memory.follow(attach),见 follow。

6. 能力 / 风险 → 影响 ​

  • 本服务不做 authority 鉴权。memory 是内部基础设施服务,调用方拿到引用即可读写任意 sessionId 的全部消息;没有 visibility/risk 分级,也没有逐调用确认。跨会话隔离完全依赖调用方传入正确的 sessionId,以及 getRecentMessagesAcrossSessions 的 excludeSessionIds / platform 过滤。provider 不得自行添加额外鉴权门,否则会破坏 agent 流水线。authority 模型见 docs/plugins/plugin-authority.md 与 docs/concepts/security-model.md。
  • 持久化要走 storage 契约。sqlite 后端不直接拼接文件系统路径,而是用 caps.storage.use.resolveLocalPath(uri, 'write') 解析 '<root>:/path'(默认 data:/aalis.db)。storage 不是沙箱(见 docs/concepts/storage-uri-grammar.md),但通过它可以拿到框架统一的根隔离与路径解析;自写 provider 落盘时应沿用这种方式,而不是裸用 fs。
  • 删除与改写要广播。实现 deleteMessagesByTimestamps 的 provider,删除后下游(向量库、前端)靠 memory:messages-deleted / history:changed 事件同步;updateMessageContent 改写之后靠 memory:messages-rewritten。emit 事件是动手的消费方(删除是 checkpoint,改写是 message-archive 与 media)的责任,不是 memory 服务自身。
  • PII 注意。消息原文(含用户昵称、平台 ID 等)会原样落库。示例代码一律用占位符,不要在 configSchema、默认值或日志里硬编码真实账号信息。

7. 边界与注意事项 ​

  • getHistory 只看未归档,getFullHistory 才包含归档。archiveSpan 之后段里没留下的消息变为 archived=1,agent 上下文看不到它们;summary 正是利用这一点,把历史压成摘要再归档。新 provider 要维护 archived 字段,archiveSpan 与 getHistory 用同一个顺序,否则归档的不是摘要覆盖的那一段;getFullHistory 按原来的顺序返回,留下的与归档的照时间交错。
  • excludeKinds 的 NULL 语义:sqlite 与 mongodb 采用「kind IS NULL 不被排除」的保守语义,新后端应对齐,否则跨实现切换时控制类消息的过滤行为会漂移。
  • 跨会话查询的 limit 收窄与 overscan:getRecentMessagesAcrossSessions 会把 query.limit 收窄到 crossSessionMaxLimit(默认 1000),且 platform / excludeSessionIds 在内存中后过滤——sqlite 用 8x overscan 拉候选再过滤。如果候选 overscan 不足,在极端过滤下可能返回不满 limit 条。
  • updateMessageContent / deleteMessagesByTimestamps 的作用域:前者在含 oldText 的消息(含归档)里取最近 recentLimit(默认 100)条做文本替换;后者在 sqlite 中因单条语句的绑定参数有上限(Node 自带的 SQLite 为 32766)而分 500 条一批、以事务删除。新 provider 应注意同等的批量约束。
  • 没有 schema 校验:saveMessage 直接信任传入的 Message,不校验 role / kind 是否合法。脏数据由调用方(message-archive)负责清洗。

8. 交叉链接 ​

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布