11. Session 设计 - 基于会话的上下文管理
问题的提出
回顾第 1 篇的 Agent Loop 实现:agentLoop 接收用户输入,初始化 messages 数组,进入循环,最终返回完整的对话历史。进程退出后,messages 随内存释放而消失。
启动 → [system, user] → Loop → [system, user, assistant, tool, ...] → 返回 → 进程退出 → 全部丢失这是一个无状态的函数。对于一次性任务——“列出当前目录的文件”、“创建一个 HelloWorld.txt”——这种行为是可接受的。任务完成,输出结果,不需要回顾。
但一旦 Agent 的交互模式从”单次命令”迁移到”持续对话”,无状态性就构成了根本缺陷。用户今天修改了某个模块的配置,明天需要 Agent 基于那个修改继续调试。没有持久化的 Agent 每次启动都从零开始,要求用户重新陈述全部背景。这种体验不构成产品。
结论很明确:Agent 需要在多次进程运行之间保持对话状态的连续性。Session 机制解决的就是这个问题。
三层上下文模型
在讨论实现之前,需要先界定几个概念。Agent 系统的上下文信息分布在三个不同的时间尺度上,混淆这三个层次会导致设计上的歧义。
Messages(短期上下文) 是当前进程内的对话数组。它随着 Agent Loop 的每一轮迭代增长,包含 system prompt、用户输入、assistant 的 tool_call 以及工具返回结果。Messages 的生命周期与单次进程运行绑定。第 7 篇引入了压缩机制来抑制 Messages 的无限膨胀,但压缩不解决持久化问题——进程退出后,压缩后的 Messages 同样消失。
Session(中期上下文) 是 Messages 的持久化容器。一次对话构成一个 Session,它将 Messages 从内存序列化到存储介质,使得下一次进程启动时可以恢复。
Memory / Knowledge(长期上下文) 是从多个 Session 中提取的、经过归纳的经验。第 2 篇的滑动窗口 Memory 和第 10 篇的 Markdown KB 都属于这一层。它们不存储原始对话,而是存储提炼后的结论——规则、避坑记录、设计决策。
┌──────────────────────────────────────────────┐
│ Memory / Knowledge │ 长期:跨会话
│ (第2篇 Memory、第10篇 Markdown KB) │ 提取精华,结构化存储
├──────────────────────────────────────────────┤
│ Session │ 中期:跨运行
│ messages 数组 + 元数据 + 持久化机制 │ 同一话题的完整对话历史
├──────────────────────────────────────────────┤
│ Messages │ 短期:进程内
│ 当前运行的对话数组,压缩机制(第7篇) │ 每轮循环都在变化
└──────────────────────────────────────────────┘三层之间存在明确的数据流向。Session 结束时,关键信息被提取并注入 Memory 层。新 Session 启动时,Memory 层的内容通过 System Prompt 注入当前 Messages。Messages 在运行中不断增长,触发压缩后将精华保留、细节丢弃——但压缩后的 Messages 依然属于 Session 层,需要在进程退出前持久化。
Session 的数据结构
Session 的核心是一个携带元数据的 Messages 容器。用 TypeScript 描述其最小接口:
interface Session {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}id 是唯一标识,使用 UUID 或类似的冲突概率可忽略的生成算法。title 用于人类识别——在会话列表中,标题是用户判断”这是哪次对话”的首要依据。messages 是完整的对话历史数组,与 Agent Loop 中使用的 ChatMessage[] 类型完全一致。createdAt 和 updatedAt 是 Unix 时间戳,用于排序和过期判断。
存储方案的选择取决于系统规模。在原型和单用户场景下,文件系统是最直接的方案:
sessions/
a1b2c3d4.json
e5f6g7h8.json
...每个 Session 对应一个 JSON 文件,文件名是 Session id。JSON.stringify 和 JSON.parse 完成序列化与反序列化——ChatMessage 结构天然是 JSON 兼容的,不需要额外的转换层。
当系统需要支持多用户、并发读写、或按元数据字段频繁查询时,数据库方案替代文件方案。核心逻辑不变——增删改查的对象始终是 Session 接口,变化的只是 save 和 load 的实现细节。
基本操作
Session 的 CRUD 接口是自解释的:
function create(title?: string): Session {
return {
id: generateId(),
title: title || "New Session",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
function save(session: Session): void {
const filePath = `sessions/${session.id}.json`;
session.updatedAt = Date.now();
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
}
function load(id: string): Session {
const filePath = `sessions/${id}.json`;
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as Session;
}
function list(): SessionMeta[] {
const files = fs.readdirSync("sessions/");
return files
.filter(f => f.endsWith(".json"))
.map(f => {
const raw = fs.readFileSync(`sessions/${f}`, "utf-8");
const { id, title, createdAt, updatedAt, messages } = JSON.parse(raw);
return { id, title, createdAt, updatedAt, messageCount: messages.length };
})
.sort((a, b) => b.updatedAt - a.createdAt);
}
function remove(id: string): void {
fs.unlinkSync(`sessions/${id}.json`);
}create 生成一个新的 Session 对象,不做持久化——首次 save 时才写入磁盘。save 是全量覆盖写,每次将整个 Session 对象序列化。load 从磁盘反序列化,恢复完整的 Session 对象。list 是关键的性能敏感操作:它只读取元数据字段(id、title、时间戳、messages 长度),不加载完整的 messages 数组。当 sessions 目录下有数百个文件时,全量加载会消耗大量内存和 I/O 时间。remove 是物理删除——对于个人 Agent 工具而言,软删除增加复杂度而收益有限。
与 Agent Loop 的集成
Session 与 Agent Loop 的关系遵循一条设计原则:Agent Loop 的接口不发生任何变化。 Loop 仍然接收 ChatMessage[],返回 ChatMessage[]。Session 是 Loop 外部的包装层,负责启动前加载和结束后持久化。
// Agent Loop,与第 1 篇完全一致
async function agentLoop(
messages: ChatMessage[],
maxIterations = 20
): Promise<ChatMessage[]> {
for (let i = 0; i < maxIterations; i++) {
const msg = await callLLM(messages);
messages.push(msg);
if (!msg?.tool_calls?.length) break;
for (const toolCall of msg.tool_calls) {
const { name, arguments: rawArgs } = toolCall.function;
const output = await toolsMap[name](JSON.parse(rawArgs || "{}"));
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}
}
return messages;
}
// Session 包装层
async function runSession(sessionId: string, userInput?: string) {
const session = load(sessionId);
if (userInput) {
session.messages.push({ role: "user", content: userInput });
}
session.messages = await agentLoop(session.messages);
save(session);
return session;
}数据流是单向的:load → agentLoop(messages) → save。Loop 不知道 Session 的存在——它只看到一个 ChatMessage[] 参数。这样设计的结果是:Session 的存储实现(文件、数据库、远程服务)可以在不修改 Loop 代码的情况下替换;Loop 的单元测试不需要模拟 Session 层,直接传入 messages 数组即可。
两个工程上的决策点:
保存时机。 推荐每轮循环后立即保存。有人会担心磁盘 I/O 频率——但 Session 文件是纯文本 JSON,最大不过 1MB 左右,写盘开销可以忽略。换来的是:进程在任何时刻崩溃(OOM、断电、kill -9),最多丢失当前轮次的数据。
恢复入口。 runSession 要求调用方提供 sessionId。这个 id 的来源有两种模式:启动时自动恢复最近一次活跃的 Session(类似 Claude Code 的 resume 行为),或列出所有会话让用户选择(类似 ChatGPT 的侧边栏)。前者适合命令行工具,后者适合 GUI 应用。两种模式不互斥,可以在自动恢复的同时提供”切换到其他会话”的能力。
Session 的标题生成
Session 的标题是人识别对话的关键。"New Session 37" 这种默认命名在会话数量超过十个后完全失效。标题需要由 Agent 在首次交互时自动生成。
async function generateTitle(messages: ChatMessage[]): Promise<string> {
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
const resp = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Generate a concise title (max 10 words) for this conversation.
Output only the title text, no quotes or formatting.`,
},
...messages.filter(m => m.role === "user" || m.role === "assistant"),
],
temperature: 0.3,
});
return resp.choices[0]?.message?.content?.trim() || "Untitled";
}这个函数在 Session 首次完成 Agent Loop 后调用。使用轻量模型(gpt-4o-mini)控制成本,temperature 设低以保证输出的稳定性。只传入 user 和 assistant 消息,过滤掉 tool 消息——标题不需要知道每个工具调用的具体参数和返回值。如果生成失败,回落为带时间戳的默认标题,如 "Session 2026-06-23 14:30"。
标题生成后写入 session.title 并立即保存。用户之后可以手动修改,但自动生成将默认体验从”不可用”提升到了”基本可用”。
会话的生命周期管理
Session 一旦可以被持久化,就会累积。一个活跃的 Agent 工具在使用数周后可能产生几十甚至上百个 Session。其中大部分是一次性的调试对话,任务完成后不再需要。如果不加管理,list() 的输出将失去参考价值。
管理策略分为三个层次:
排序。 list() 默认按 updatedAt 倒序排列。最近活跃的 Session 排在前面,符合用户的使用模式——用户大概率想继续最近的对话,而非三周前的某次调试。
归档。 对于不再活跃但有一定保留价值的 Session,移动到 sessions/archive/ 子目录。归档的 Session 从主列表中移除,但仍可通过完整路径访问。这个操作可以由用户手动触发,也可以基于”超过 N 天未活跃”的规则自动执行。
删除。 对于确认无价值的 Session——一次性命令、失败的尝试、测试对话——直接物理删除。remove() 函数完成这一操作。删除是不可逆的,但考虑到 Session 内容本身在产生时就没有被用户显式标记为”需要保留”,这种不可逆性是合理的——它与用户的心智模型一致:没有刻意保存的东西,丢了就丢了。
与前文的连接
Session 不引入新的 AI 能力。它把前 10 篇文章构建的组件连接为一个可持久化的系统。
Agent Loop(第 1 篇)。 Loop 操作的不再是临时初始化的 messages 数组,而是从 Session 中加载的持久化数据。Loop 的逻辑完全不变——变化仅发生在数据来源这一层。
Memory(第 2 篇)。 第 2 篇的 saveMemory 在每次 Agent 任务完成后追加记录。引入 Session 后,saveMemory 的调用时机从”Loop 结束时”精确定义为”Session 保存时”——每次 Session 持久化,同步触发记忆提取。Session 提供了明确的边界事件,记忆的写入时机从隐式变为显式。
上下文压缩(第 7 篇)。 压缩机制在 Loop 内部运行,压缩后的 Messages 是 Session 持久化的输入。两者是流水线关系:Loop 运行 → Messages 达到阈值 → 压缩 → 继续 Loop → 最终 Messages 写入 Session。压缩和 Session 各自独立运行,互不感知对方的存在。
SubAgent(第 5 篇)。 SubAgent 在生成时接收独立的任务字符串,内部维护独立的 Messages 数组。SubAgent 不访问主 Agent 的 Session——这一隔离是有意为之。SubAgent 是临时工,任务完成后其 Messages 随函数返回而销毁,不产生持久化的 Session。如果某个 SubAgent 的执行结果对后续任务有参考价值,主 Agent 自行决定将什么内容写入自己的 Session。
多 Agent 编排(第 6 篇)。 第 6 篇的 Team 是一组 Agent 实例的集合。引入 Session 之前,Team 的 messages 是临时的。引入 Session 后,存在两种模式:共享 Session——Team 中的所有 Agent 操作同一个 messages 数组,完成后统一持久化;独立 Session——每个 Agent 维护自己的 Session,适用于长期运行的专家 Agent 需要在多次 Team 调用间保持自身状态。工具型 Agent 用共享模式,专家型 Agent 用独立模式。
设计决策的论证
文件存储 vs 数据库存储
文件方案的优势是零依赖和可调试性。一个 Session 对应一个 JSON 文件,用户可以直接用任何文本编辑器打开、阅读、修改。这种透明性在原型和单用户场景中很实用:当 Agent 行为异常时,直接检查 Session 文件比查询数据库快得多。
文件方案的瓶颈出现在两个场景:当 list() 需要遍历数百个文件时,每次调用都需读取并解析每个文件的 JSON(即使只提取元数据),I/O 开销线性增长;当多个进程需要同时读写同一个 Session 时,文件锁的粒度控制远不如数据库的事务机制。
数据库方案的引入时机不是”用户量大了”,而是 list() 的延迟超出了交互可接受的阈值——通常以 200ms 为界。在达到这个阈值之前,文件方案足够。这个标准可度量,不依赖对规模大小的模糊感觉。
每轮保存 vs 延迟批量保存
每轮保存的策略是:Agent Loop 每完成一轮迭代(LLM 调用 + 工具执行),立即将当前 messages 写入磁盘。延迟批量保存的策略是:Loop 运行期间仅更新内存,在正常结束或退出信号触发时统一写盘。
延迟保存的支持者通常以 I/O 开销为理由。但这个理由在 Session 的场景下不成立:Session 文件是纯文本 JSON,一次对话的完整记录很少超过 1MB。这个体量下,每轮的 writeFileSync 开销在毫秒级,用户完全感知不到。而节省下来的 I/O 换来的是一个真实的代价:进程一旦崩溃(不仅仅是 kill -9,还包括未捕获的异常、依赖服务超时导致的强制退出),未保存的轮次全部丢失。
每轮保存的优势反过来看就是:崩溃时最多丢失当前这一轮。对一个跑了几十轮的任务来说,丢一轮是小事,丢十几轮可能需要用户手动复盘。因此推荐每轮保存。
Session 粒度的确定
一个 Session 对应一次对话。但”一次对话”的边界在哪里?是一次任务(修一个 bug),一个主题(性能优化),还是一个时间段(今天下午的工作)?
这个问题没有技术上的唯一正确答案。但设计上有一个原则:Agent 不应替用户决定对话的边界。
用户开始新话题时创建新 Session;用户在同一话题下继续时复用已有 Session。Agent 提供创建、切换、归档的机制,但不做自动分割。自动分割的风险在于误判——Agent 判定”这应该是一个新话题”而用户认为还没有结束。这种误判造成的体验损害远大于让用户多点击一次”新会话”按钮。在会话管理上,保守的自动化优于激进的自动化。
工程意义
Session 机制没有引入任何新的 AI 能力。它不改进 LLM 的推理质量,不扩展工具调用的范围,不优化 token 消耗。它解决的问题纯粹属于软件工程范畴:如何将进程内的高频变化数据,可靠地转换为进程外的持久化状态。
但这一层持久化,让 Agent 系统从一次性脚本变成了可持续使用的产品。ChatGPT 的对话列表、Claude Code 的会话恢复、Cursor 的 Chat 历史——底层都是 Session 机制的不同变体。它们的共同点是:用户在多次打开应用之间,体验到的是一个记得之前聊过什么的 Agent,而不是一个每次都需要重新自我介绍的工具。
在架构上,Session 是一个承上启下的中间层。对下,管理 Messages 的持久化;对上,为 Memory 和 Knowledge 层提供结构化的数据源。Session 本身不复杂——代码量远少于压缩(第 7 篇)或 RAG(第 9 篇)。但把前 10 篇文章的能力整合成一个可运行的产品,依赖的就是这一层。