构建可持续运行的 Agent 时,真正棘手的不只是完成一轮推理,而是让不断增长的对话在有限上下文中保持连贯,同时经得起进程重启和并发消息写入。围绕这些约束,需要明确区分临时状态、持久历史与长期记忆,并为压缩、整理和会话隔离设定清晰边界。
本文是 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_at、status、lock 都是新的。这个取舍的意图是:状态(锁、处理中标志)重启后本就该失效,而对话上下文必须持久;把"易变且重启即无效的状态"和"必须持久的数据"分开存,恢复逻辑就天然正确,不需要专门的崩溃恢复代码。
补充:会话层里有个
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_id 和 acquired_at 两个字段。多 agent 演进后,一个锁属于哪个 agent 完全靠 session_key 的前缀(agent:channel:chat_id)承载。这么选的理由:
reviewer:wecom:zhangsan 和 default:wecom:zhangsan 本来就是不同的 key、不同的 Session、不同的锁,天然隔离,锁逻辑一个字都不用改。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。
每个 session 有两个独立定时器,管两件不同的事:
(a) TTL 定时器(会话闲置清理) ——默认 60 分钟。每次 getOrCreate 都重置(用户每发一条消息就往后推),超时后删内存里的 Session 对象,但 JSONL 历史不动。这是省内存:长期不说话的会话从内存踢掉,磁盘历史保留,下次再来还能懒加载恢复。
(b) 锁超时定时器(防堵塞) ——默认 120 秒,per-agent 可在 agent.json 里覆盖(default agent 显式配了 600,那只是它自己的配置值不是全局默认)。拿锁时启动倒计时,正常处理完 releaseLock 清掉它;如果 LLM 调用挂死(网络卡死),超时后 forceReleaseLock 强制释放锁,会话不再永久堵塞。强制释放会把 status 设成 "error"(不是 idle),标记这个会话出过问题。
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 双删。
单 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 | 否,全局共享 |
| workspace | workspace/<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 |
|---|---|---|---|---|
| L1 | Session Messages | 当前对话的原始消息 | 实时(每条消息) | 不用 |
| L2 | Consolidator | 历史摘要(压缩后的旧消息) | 被动:input_tokens 超阈值 | 用(摘要) |
| L3 | Memory 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 Consolidator | L3 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 共用一份) │
└─────────────────────────────────────────────────────────────────┘
因为这两个文件承载的东西归属不同的主体。
data/<agentName>/memory/ 拆开,每个 agent 一份。auth/USER.md,所有 agent 共享同一份,任何一次对话对用户的新认知都能让全体 agent 受益。一句话:记忆(MEMORY)是 agent 的领域产物,画像(USER)是用户的固有属性。前者随 agent 分,后者随用户走。对应到 L3 的实现,MemoryProcessor 遍历所有 active agent 时给每个各写一份 MEMORY.md,但 USER.md 只更新那一份全局的。
桌面端(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-title | LLM 生成标题 |
| GET .../welcome | LLM 生成欢迎语,缓存进 agents/<name>/welcome.json |
这套设计里值得说的取舍是:meta 与 JSONL 分离存放,而不是塞进 JSONL 头部。JSONL 是 append-only 的热路径,每条消息都写;标题、置顶这种低频改、列表页高频读的展示态信息放旁边一个小 JSON,列会话时不用逐行 parse 几十 KB 的对话历史。代价是两个文件要维护一致性,所以 appendToDisk 每条消息顺手写穿透更新 meta 的 updated_at,从源头保证两者不漂。
进程重启:内存全空(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 秒)再发一次才真正执行——避免一条手滑消息把几十秒的推理结果扔掉。
如实列几个点:
read_memory/write_memory 工具主动读写,接入程度从"写了没人读"升级为"工具可达"。只是否"用得上"取决于模型当轮是否主动调工具,自动注入这步仍然没做,长期记忆的价值没完全兑现。改进方向:让 ContextBuilder 组装 system prompt 时读入该 agent 的 MEMORY.md 和全局 USER.md(可以做成带 token 预算的截断注入)——机制和数据都已经存在,缺的只是消费端。consolidate_at_tokens 等阈值改成按当前 agent 所用模型的上下文窗口比例计算(比如窗口的 40%)——在 model 可 per-agent 的语境下尤其有意义。会话层用"纯内存 Session + JSONL 落盘历史"分离易变状态与持久数据,会话级锁让同会话串行、跨会话并行,agent 归属靠 session_key 前缀承载而非给锁加字段;记忆层三层加兜底各管一个维度——L1 实时留原文、L2 被动压活跃会话、L3 主动沉淀跨会话记忆、AutoCompact 兜底截闲置大会话;多 agent 演进后存储整体 per-agent 化,唯 USER.md 因"画像属于用户而非 agent"保持全局共享。