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[] 类型完全一致。createdAtupdatedAt 是 Unix 时间戳,用于排序和过期判断。

存储方案的选择取决于系统规模。在原型和单用户场景下,文件系统是最直接的方案:

sessions/
  a1b2c3d4.json
  e5f6g7h8.json
  ...

每个 Session 对应一个 JSON 文件,文件名是 Session id。JSON.stringifyJSON.parse 完成序列化与反序列化——ChatMessage 结构天然是 JSON 兼容的,不需要额外的转换层。

当系统需要支持多用户、并发读写、或按元数据字段频繁查询时,数据库方案替代文件方案。核心逻辑不变——增删改查的对象始终是 Session 接口,变化的只是 saveload 的实现细节。

基本操作

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 篇文章的能力整合成一个可运行的产品,依赖的就是这一层。


下一篇12. Workspace 设计 - 基于工作目录的环境隔离与安全沙箱