Agent 记忆架构拆解:实时存储、被动压缩与主动整理

作者:袖梨 2026-09-15

构建可持续运行的 Agent 时,真正棘手的不只是完成一轮推理,而是让不断增长的对话在有限上下文中保持连贯,同时经得起进程重启和并发消息写入。围绕这些约束,需要明确区分临时状态、持久历史与长期记忆,并为压缩、整理和会话隔离设定清晰边界。

AgentHub 的会话管理与记忆层:在有限上下文里维持短期与长期记忆

本文是 AgentHub 后端架构系列的第 4 篇,对照源码 src/session/src/memory/src/agent/auto-compact.ts 讲解。上一章 03-AgentLoop与ReAct循环 讲了一轮对话怎么跑,这一章讲对话之外的东西——会话状态怎么管、历史怎么存、记忆怎么沉淀。

引言

LLM 的上下文窗口是有限的,而对话可能无限长;一次服务重启会让所有内存状态归零,而用户的对话上下文必须还在;一个会话在跑推理时,同一会话的新消息不能并发写历史,但不同会话之间又必须互不阻塞。会话层和记忆层就是为解决这三个矛盾而存在的。

AgentHub 的方案可以概括成两句话:会话层用"纯内存 Session 对象 + JSONL 落盘的对话历史"分离状态与数据,靠会话级锁保证同一会话串行、跨会话并行;记忆层用三层加一个兜底(L1 实时落盘、L2 被动压缩、L3 主动整理、AutoCompact 闲置截断)在有限的 LLM 上下文里同时维持短期和长期记忆。多 agent 演进后,这套存储整体 per-agent 化——每个 agent 有自己的 data/<agentName>/sessions/data/<agentName>/memory/MEMORY.md,唯独用户画像 USER.md 是全局共享的。


一、会话层:状态与数据分离

会话层管的是"一次持续对话的状态",拆成三个角色:

角色职责存哪
SessionManager管 Session 对象的创建、查找、TTL 过期、锁内存(Map)
SessionHistory管对话消息的 append/load/clear/replace内存(Map)+ JSONL(磁盘)
历史持久化层(storage)纯磁盘读写(JSONL append/write/read/delete)磁盘

最关键的一个设计决策是:Session 对象只在内存里,不落盘;落盘的是对话消息(JSONL)加一份会话元数据(meta.json)

interface Session {
  key: string;           // agent:channel:chat_id,如 reviewer:wecom:zhangsan
  channel: Channel;
  chat_id: string;
  created_at: number;
  last_active: number;   // 最后活跃时间,用于 TTL
  status: "idle" | "processing" | "error";
  lock?: SessionLock;    // 持锁者信息
}

进程重启后 Session 对象全丢,但对话历史还在磁盘上。下次这个会话再来消息,getOrCreate 重建一个新 Session 对象,SessionHistory.load 从 JSONL 把历史读回来。所以"会话恢复"恢复的是历史,而不是 Session 对象本身——created_atstatuslock 都是新的。这个取舍的意图是:状态(锁、处理中标志)重启后本就该失效,而对话上下文必须持久;把"易变且重启即无效的状态"和"必须持久的数据"分开存,恢复逻辑就天然正确,不需要专门的崩溃恢复代码。

补充:会话层里有个 SessionCheckpoint 类(src/session/checkpoint.ts),设计意图是 JSONL-backed 崩溃恢复,但当前 save/restore 都是空实现(注释标 Phase 5 才做)。所以现阶段崩溃恢复完全靠"历史 JSONL 懒加载"这条路,Checkpoint 只是先占个接口位。

再补充 meta.json:每个 JSONL 旁有一份 <key>.meta.json(SessionMeta:chat_id/title/created_at/updated_at/pinned,src/storage/sessions.ts)。appendToDisk 每条消息都会写穿透更新 meta——updated_at 跟随最后一条消息,meta 不存在时自动补建,标题还是默认的"新会话"时取首条用户消息(经 stripSessionMetaPrefix 剥掉 [channel: x] 等元数据前缀)的前 40 字当标题;meta 有内存缓存,避免热路径上每条消息重复同步磁盘 I/O。所以准确说:Session 对象不落盘,落盘的是 JSONL + meta.json 两件。

二、锁:会话级的串行,跨会话的并行

锁挂在 Session 对象的 lock 字段上,不是一把全局锁:

acquireLock(key, message_id) {
  const session = this.sessions.get(key);       // 按 key 找 session
  if (!session || session.lock) return false;    // 已有锁 -> 失败
  session.lock = { message_id, acquired_at: Date.now() };
  ...
}
SessionManager.sessions (Map)
  ├─ "reviewer:wecom:zhangsan"   -> Session { lock: {message_id: A} }   ← A 持锁中
  ├─ "reviewer:wecom:lisi"       -> Session { lock: undefined }          ← 空闲
  ├─ "reviewer:feishu:group_1"   -> Session { lock: {message_id: C} }   ← C 持锁中
  └─ "default:cli:direct"        -> Session { lock: undefined }          ← 空闲

串行是"会话内"的,并行是"跨会话"的:同一会话的两条消息排队(先来的持锁,后来的进 Mid-turn Queue 等),不同会话完全并行。100 个用户同时聊天就有可能 100 个 Runner 同时在跑,互不阻塞。锁粒度选会话级而非全局,是因为锁的目的就是"防止同一会话并发搞乱历史",锁的归属跟会话生命周期一致——会话对象 TTL 过期销毁,锁也就没了,不会泄漏。

为什么锁结构里没有 agent 字段? SessionLock 只有 message_idacquired_at 两个字段。多 agent 演进后,一个锁属于哪个 agent 完全靠 session_key 的前缀(agent:channel:chat_id)承载。这么选的理由:

  1. 单一事实源。会话属于哪个 agent 只有一处记录(key 前缀),不会出现"key 说是 agent A、lock 字段说是 agent B"这种自相矛盾的状态。加了字段就要保证两处永远同步,平白多一类 bug。
  2. 锁的语义本就不关心 agent。锁要防的是"同一会话并发写坏历史",判断"是不是同一会话"用完整 key 比对就够了——reviewer:wecom:zhangsandefault:wecom:zhangsan 本来就是不同的 key、不同的 Session、不同的锁,天然隔离,锁逻辑一个字都不用改。agent 维度对锁来说是"免费"就有的。
  3. 改动面最小。整个 SessionManager/SessionHistory 的代码在演进里几乎没动,只是 key 的构造多拼了个前缀。把 agent 塞进 key 而非塞进结构体,让多 agent 化对会话层近乎透明。

反过来,如果哪天真需要"按 agent 批量操作锁/会话"(比如归档某 agent 时清它所有会话),靠 key 前缀 startsWith 过滤也够用。所以加字段是有成本没收益的选择。这条原则后来还延续到了消息结构:InboundMessage 新增的都是 cred_id(同机器人分流)、model_hint/provider_hint(单轮模型覆盖)这类入站路由/行为字段,OutboundMessage 新增 event? 承载流式事件,都不是 agent 归属字段——归属仍只有 key 前缀一处。

dispatch 层的两个相关演进值得知道:流式输出——agent turn 的最终回复不再走 content reply,而是由 StreamSink 以 done 事件携带 full_text 推送,HTTP 渠道支持 ?stream=1 / Accept: text/event-stream 的 SSE 流式;单轮模型覆盖——消息带 model_hint/provider_hint 时,临时构造一个 AgentLoop 只覆盖本轮请求,不改 agent 定义、不与并发请求互斥,配错则降级回 agent 默认模型并 warn。

三、SessionManager 的两套定时器

每个 session 有两个独立定时器,管两件不同的事:

(a) TTL 定时器(会话闲置清理) ——默认 60 分钟。每次 getOrCreate重置(用户每发一条消息就往后推),超时后删内存里的 Session 对象,但 JSONL 历史不动。这是省内存:长期不说话的会话从内存踢掉,磁盘历史保留,下次再来还能懒加载恢复。

(b) 锁超时定时器(防堵塞) ——默认 120 秒,per-agent 可在 agent.json 里覆盖(default agent 显式配了 600,那只是它自己的配置值不是全局默认)。拿锁时启动倒计时,正常处理完 releaseLock 清掉它;如果 LLM 调用挂死(网络卡死),超时后 forceReleaseLock 强制释放锁,会话不再永久堵塞。强制释放会把 status 设成 "error"(不是 idle),标记这个会话出过问题。

四、SessionHistory:write-through cache + 懒加载

append(session_key, role, content) {
  // 1. 内存没有就从磁盘懒加载
  if (!this.store.has(session_key)) {
    const fromDisk = loadFromDisk(session_key);
    if (fromDisk.length > 0) this.store.set(session_key, fromDisk);
  }
  // 2. 内存 push
  const history = this.store.get(session_key) ?? [];
  history.push({ role, content, timestamp: Date.now() });
  // 3. 磁盘 append
  appendToDisk(session_key, msg);
}

三个设计要点:懒加载(启动不预读,谁访问谁加载,重启后内存空);内存+磁盘双写(读走内存快、磁盘做持久化兜底,经典 write-through);四个方法对应四种场景——

方法用途磁盘操作
append追加一条消息(GatewayCore 写 user/assistant)append(追加一行,快)
load读历史(Loop 读给 LLM)不写
clear清空(/restart 命令)delete(只删 JSONL,meta 保留)
replace整体替换(Consolidator 压缩后重写)write(覆盖整个文件)

用 JSONL(每行一个 JSON)而不是一个大 JSON 数组,核心原因是 append 高效:大数组每次追加都要读全文、parse、push、stringify、写回;JSONL 直接往文件末尾追加一行。对"每条消息都要落盘"的场景,JSONL 是最优解。而 replace 之所以单独存在,就是为了 L2 压缩后必须整体重写这一个场景。

注意 clear 只删 JSONL(deleteDisk),会话 meta 保留——所以 /restart 后会话仍挂在桌面端列表里、标题不变;彻底删除要显式调 deleteMeta,Admin 的 DELETE /api/agents/:name/sessions/:chatId 就是 clear + deleteMeta 双删。

五、per-agent 存储布局

单 agent 时代,历史文件在 <workspace>/sessions/<channel>_<chat_id>.jsonl,MEMORY.md/USER.md 都在 workspace/ 下,全局各一份。多 agent 演进后,存储整体按 agent 拆开:

数据路径per-agent?
会话历史data/<agentName>/sessions/<key>.jsonl
会话 meta(标题/置顶/时间戳)data/<agentName>/sessions/<key>.meta.json
MEMORY.md(L3 全局记忆)data/<agentName>/memory/MEMORY.md
USER.md(用户画像)auth/USER.md否,全局共享
workspaceworkspace/<agentName>/(全部 agent 统一嵌套,含 default)

session_key 也从 channel:chat_id 变成 agent:channel:chat_id,文件名里冒号替换成下划线落盘。key 由统一工厂 sessionKey() 构造,是唯一构造点(chokepoint),在构造时做路径穿越校验(src/session/key.ts:拒绝 /、``、..、盘符,agentName 段还拒绝 :);落盘拼路径处还有二道防线 assertSafeKey(src/storage/sessions.ts)——defense in depth。key 会被直接拼进磁盘路径,所以 agentName/chatId 里混入路径语义字符就构成穿越,在唯一构造点上统一拦住,入口再多(URL 路径 / query / JSON body / 双重编码)也汇到这一处。

workspace 布局则从"default 不带后缀、其他 agent workspace_<name>"统一成了嵌套的 workspace/<agentName>/,存量目录靠启动时的一次性迁移 migrateWorkspaceLayout() 收编(散落内容搬入 workspace/default/、旧 workspace_<name>/ 搬入嵌套结构、agent.json 的 system_allowed_paths 前缀改写)。这条 per-agent 化的主线贯穿整个记忆层:L2 Consolidator 是 per-agent 的(压缩阈值可 def.memory?.consolidate_at_tokens 覆盖,缺省继承全局),L3 MemoryProcessor 则遍历所有 active agent,给每个 agent 各整理一份自己的 MEMORY.md,但只更新一份全局共享的 USER.md。

为什么 MEMORY.md per-agent 而 USER.md 全局,是这一层最值得讲的设计对比,下一节讲完机制后展开。

六、记忆层:三层 + 一个兜底

记忆层解决的核心矛盾是:LLM 上下文有上限,但对话可能无限长,如何让 Agent 既有短期记忆(当前对话)又有长期记忆(跨会话)?

答案是四个机制协作。它们不是随便堆出来的,而是对应三个正交的问题:当前对话完整可回溯(L1)、活跃会话越聊越长单轮 token 撑不住(L2)、跨会话的长期记忆(L3),再加一个 L2 覆盖不到的盲区(AutoCompact):

名字存什么触发条件用不用 LLM
L1Session Messages当前对话的原始消息实时(每条消息)不用
L2Consolidator历史摘要(压缩后的旧消息)被动:input_tokens 超阈值用(摘要)
L3Memory Processor全局记忆 + 用户画像主动:定时(默认 120 分钟)用(提炼)
兜底AutoCompact截断闲置大会话定时扫闲置会话(每 10 分钟)不用(纯截断)

L1 就是 SessionHistory,完整原始对话,越聊越长撑不住,所以需要压缩。

L2 Consolidator(被动压缩,会话内) :Loop 跑完一轮后检查 input_tokens,>= consolidate_at_tokens(默认 80000)才触发。压缩逻辑是保留最近 20 条不动、把更早的旧消息交给 LLM 摘要成一条 role: "system" 的消息,再用 history.replace 整体重写 JSONL:

const KEEP_TAIL = 20;
if (messages.length <= KEEP_TAIL) return;            // 不足 20 条,没得压
const toSummarize = messages.slice(0, -KEEP_TAIL);   // 旧的
const toKeep = messages.slice(-KEEP_TAIL);           // 最近 20 条
const summary = await this.summarize(toSummarize);
const summaryMsg = { role: "system", content: `[历史摘要] ${summary}`, ... };
history.replace(session_key, [summaryMsg, ...toKeep]);

摘要用 role: "system" 是有讲究的:Loop 组装 system prompt 时会把历史里所有 role: "system" 的消息折叠进 system prompt(因为 Anthropic API 不接受 system 作为消息 role)。所以摘要不是普通对话消息,而是变成了系统上下文的一部分。此外 L2 的压缩成果是跨轮生效的——触发那轮 LLM 已经调用完了享受不到,是给下一轮省 token,"前人栽树后人乘凉"。

L3 Memory Processor(主动整理,跨会话) :setInterval 每 120 分钟主动跑一次,不管 token 超不超。它遍历所有 active agent,扫每个 agent 最近的会话消息(最近 10 个会话文件 × 每个文件最近 30 条,每条内容截 200 字符),喂给 LLM 提炼两类东西——MEMORY.md(跨会话的重要事项、结论、待办)和 USER.md(用户偏好、习惯、角色、背景),两路并行更新。

需要如实说明:L3 产物 MEMORY.md/USER.md 目前仍是"L3 在写,但 ContextBuilder 不自动把它们读进 system prompt"。不过现在 LLM 可以通过平台级内置工具 read_memory/write_memory 主动读写 data/<agent>/memory/MEMORY.md,这两个工具被列为平台级(PLATFORM_TOOLS,与 manage_cron_jobs 并列)、不受 per-agent 白名单裁剪、所有 agent 恒可用。所以接入程度从早先的"写了没人读"升级为"不自动注入、但工具可达"——比半成品进了一步,离"自动注入"仍差一步。

AutoCompact(兜底截断,闲置大会话) :后台每 10 分钟遍历 data/ 下每个 agent 的 sessions 目录,按文件 mtime 判断闲置——闲置超 10 分钟且消息超 100 条的 JSONL,直接截断到最近 20 条,不用 LLM,纯截断丢旧消息。它补的是 L2 的盲区:L2 只在会话活跃(Loop 跑完)时才触发,一个聊了 200 条然后用户就再没回来的会话,L2 永远不会触发,200 条一直占着。AutoCompact 低成本兜底这种长期闲置的大会话。代价是旧消息直接丢了没摘要,但用户走了 10 分钟没回来,旧细节大概率不重要——这是"省存储 vs 保信息"里选了省存储。

为什么要分三层,而不是一个机制包办

三者作用域不同(单条 / 单会话 / 跨会话)、时间尺度不同(实时 / 本轮 / 每 2 小时)、成本不同(不用 LLM / 用 LLM / 用 LLM),硬塞进一个机制会互相打架:你不可能用"每轮压缩"去做跨会话记忆(会话结束就没了),也不可能用"定时整理"去救一个正在爆上下文的活跃会话(等不及)。分三层本质是让每个机制只管一个维度,触发条件各自最优。

L2 和 L3 都"用 LLM 处理历史",但目标和触发完全相反:

维度L2 ConsolidatorL3 Memory Processor
作用域单个会话内跨所有 active agent 的所有会话
触发被动:某轮 input_tokens ≥ 80000主动:定时 setInterval(120 分钟)
目的省当前会话的上下文 token沉淀跨会话长期记忆
产物JSONL 里的 system 摘要(喂回同会话)独立文件 MEMORY.md / USER.md
保留粒度摘要 + 最近 20 条原文提炼后的要点,不保留原文

触发方式的差异是刻意的。L2 被动是因为压缩本身要花一次 LLM 调用,token 没超没必要浪费成本,所以事件驱动、超阈值才压。L3 主动是因为跨会话记忆的价值不体现在某一轮 token 上,而是"时不时把散落在各会话的信息归拢一次",跟当前有没有对话、token 高不高无关,所以定时跑最合适。一个是"哪里烧起来救哪里",一个是"定期打扫卫生"。

记忆层全景图

┌─────────────────────────────────────────────────────────────────┐
│                        单个会话生命周期(per-agent)               │
│  每条消息 ──▶ L1 实时落 JSONL(data/<agent>/sessions/)           │
│                  │                                              │
│                  │  会话活跃中,某轮 input_tokens ≥ 80000         │
│                  ▼                                              │
│              L2 被动:旧消息 LLM 摘要→role:system,               │
│                      保留最近 20 条,replace 重写 JSONL          │
│                  │  (摘要跨轮生效,下一轮进 system prompt)        │
│                  │  用户走了,会话闲置                            │
│                  ▼                                              │
│              AutoCompact 兜底(每 10 分钟扫):                    │
│              闲置 >10 分钟 且 >100 条 → 纯截断到最近 20 条        │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                        跨会话(L3,遍历所有 active agent)          │
│  每 120 分钟主动触发:扫每个 agent 最近消息 → LLM 提炼             │
│  → 写 data/<agent>/memory/MEMORY.md(per-agent 全局记忆)         │
│  → 写 auth/USER.md(全局共享用户画像,所有 agent 共用一份)         │
└─────────────────────────────────────────────────────────────────┘

为什么 MEMORY.md per-agent,USER.md 却全局

因为这两个文件承载的东西归属不同的主体。

  • MEMORY.md 是"某个 agent 的工作记忆" ——一个业务审核 agent 记的是审核领域的事项、结论、待办,一个周报 agent 记的是周报相关的上下文。这些是agent 各自领域内的,混在一起既没意义又会互相污染(审核 agent 不该在上下文里看到周报 agent 的记忆)。所以 MEMORY.md 按 data/<agentName>/memory/ 拆开,每个 agent 一份。
  • USER.md 是"用户画像" ——记的是用户是谁、什么角色、什么偏好、什么习惯。而所有 agent 服务的是同一个用户,这个人不会因为在跟哪个 agent 说话就变成另一个人。如果 USER.md 也 per-agent,就会出现"审核 agent 认识的张三"和"周报 agent 认识的张三"是两份割裂画像,用户在一个 agent 那里表达过的偏好,换个 agent 又得重新说一遍。所以 USER.md 放在全局 auth/USER.md,所有 agent 共享同一份,任何一次对话对用户的新认知都能让全体 agent 受益。

一句话:记忆(MEMORY)是 agent 的领域产物,画像(USER)是用户的固有属性。前者随 agent 分,后者随用户走。对应到 L3 的实现,MemoryProcessor 遍历所有 active agent 时给每个各写一份 MEMORY.md,但 USER.md 只更新那一份全局的。

七、会话 meta 与桌面端多会话管理

桌面端(http 渠道)一个用户可以同时挂多个会话,支撑这套多会话 UI 的就是每个 JSONL 旁的 meta.json。listSessionMetas(agentName) 列出某 agent 的全部持久化会话——只列 http 渠道(wecom/feishu/cli 虽然 append 时也写 meta,但不在桌面端列表展示),按 pinned 优先、updated_at 倒序排。旧版只有 JSONL 没有 meta 的存量会话,在列表时现场合成 meta 落盘(migrateLegacySession:取首条用户消息做标题、首尾消息时间戳做 created_at/updated_at),保证升级后老会话不丢。

配套一组 Admin 会话管理 API(src/channels/http/admin.ts):

API作用
GET /api/agents/:name/sessions列出会话(走 listSessionMetas)
POST /api/agents/:name/sessions新建会话
DELETE .../sessions/:chatId彻底删除:clear(删 JSONL)+ deleteMeta 双删
PATCH .../sessions/:chatId改标题
POST .../sessions/:chatId/pin置顶 / 取消置顶
POST .../sessions/:chatId/auto-titleLLM 生成标题
GET .../welcomeLLM 生成欢迎语,缓存进 agents/<name>/welcome.json

这套设计里值得说的取舍是:meta 与 JSONL 分离存放,而不是塞进 JSONL 头部。JSONL 是 append-only 的热路径,每条消息都写;标题、置顶这种低频改、列表页高频读的展示态信息放旁边一个小 JSON,列会话时不用逐行 parse 几十 KB 的对话历史。代价是两个文件要维护一致性,所以 appendToDisk 每条消息顺手写穿透更新 meta 的 updated_at,从源头保证两者不漂。

八、进程重启与 /restart:两条"清零"路径的差别

进程重启:内存全空(Session 对象、SessionHistory.store 都没了),磁盘 JSONL 原样保留。用户再发消息时 getOrCreate 重建一个新 Session(新 created_at、idle、无锁),SessionHistory.load 懒加载从 JSONL 读回历史。所以恢复的是历史,不是 Session 对象。锁状态丢了没关系——重启本就意味着原持锁者的处理已经中断,不该再有"持锁者"。L2 摘要因为存在 JSONL 里(role: "system" 那行),重启后照样被折叠进 system prompt,压缩效果不丢。

/restart 命令(priority 级,绕过锁立即执行):abortController.abort() 取消正在跑的 LLM 调用 + sessionHistory.clear() 清内存 store 并删 JSONL 文件。等于把当前这一个会话清成全新的——没历史、没摘要、L2 成果一并清掉。但它只清当前会话,不影响其他会话,也不动 L3 的 MEMORY.md/USER.md(那是跨会话全局记忆,独立文件)。另外一个细节:clear 只删 JSONL,会话 meta(标题/时间戳/置顶)保留,所以 /restart 之后这个会话仍挂在桌面端列表里、标题不变,只是内容清空了。

/restart 还有个防误触设计:二次确认窗口。正在处理请求时发 /restart 会中断并丢弃进行中的结果,所以首个 /restart 只回复提示"如确认,请在 30 秒内再次发送",窗口期内(30 秒)再发一次才真正执行——避免一条手滑消息把几十秒的推理结果扔掉。

九、当前的局限与可能的改进

如实列几个点:

  1. L3 产物仍不自动进 system prompt。MemoryProcessor 定时在写 MEMORY.md/USER.md,ContextBuilder 仍不自动注入;现在 LLM 可以通过平台级 read_memory/write_memory 工具主动读写,接入程度从"写了没人读"升级为"工具可达"。只是否"用得上"取决于模型当轮是否主动调工具,自动注入这步仍然没做,长期记忆的价值没完全兑现。改进方向:让 ContextBuilder 组装 system prompt 时读入该 agent 的 MEMORY.md 和全局 USER.md(可以做成带 token 预算的截断注入)——机制和数据都已经存在,缺的只是消费端。
  2. SessionCheckpoint 是空壳。崩溃恢复完全依赖 JSONL 懒加载,Checkpoint 类的 save/restore 都是 no-op。这对"恢复历史"够用,但如果将来想恢复更细的会话状态(比如中断点、处理进度),现在没有支撑。
  3. AutoCompact 无差别丢旧消息。对闲置大会话纯截断不摘要,如果一个长对话里前半段有重要信息、用户只是暂时离开又回来,那部分细节就永久丢了。它赌的是"闲置久 = 旧信息不重要",这个假设不总成立。改进方向:截断前用一次 LLM(或复用 L2 的摘要逻辑)把要丢的旧消息压成一条摘要再截,把"纯丢弃"升级成"降级为摘要"。
  4. 两套压缩阈值是静态的(80000 token、100 条、20 条 KEEP_TAIL 都写死或走配置),没有按模型上下文窗口大小自适应。换个上下文窗口大得多的模型,80000 的阈值可能过于保守;换个小窗口模型又可能不够激进。改进方向:把 consolidate_at_tokens 等阈值改成按当前 agent 所用模型的上下文窗口比例计算(比如窗口的 40%)——在 model 可 per-agent 的语境下尤其有意义。

小结

会话层用"纯内存 Session + JSONL 落盘历史"分离易变状态与持久数据,会话级锁让同会话串行、跨会话并行,agent 归属靠 session_key 前缀承载而非给锁加字段;记忆层三层加兜底各管一个维度——L1 实时留原文、L2 被动压活跃会话、L3 主动沉淀跨会话记忆、AutoCompact 兜底截闲置大会话;多 agent 演进后存储整体 per-agent 化,唯 USER.md 因"画像属于用户而非 agent"保持全局共享。

相关文章

精彩推荐