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 时间戳,用于排序和过期判断。

但仅有 Session 还不够定位一条会话。会话不属于”某个进程”,它属于”某个项目下的某次对话”。所以存储寻址需要第二个结构——SessionKey

interface SessionKey {
  projectKey: string;   // 工作区路径的编码,标识"哪个项目"
  sessionId: string;    // 会话 id
  subpath?: string;     // 可选:子代理记录的子路径
}

projectKey 是工作区根路径的安全编码(例如把 /home/user/my-project 编码成 -home-user-my-project)。它的作用不是存数据,而是分区——同一个项目的所有会话落在同一个 projectKey 下,list() 时按项目过滤。subpath 留给第 5 篇的 SubAgent:子代理的临时记录可以挂在主会话下,但走独立子路径,不污染主对话。

这个结构揭示了一个重要事实:Session 与 Workspace 的耦合仅限于一个 projectKey 字符串。Session 不需要持有 Workspace 对象,不需要知道工作区的 env、忽略模式、运行时进程——它只需要知道”我属于哪个项目”。这也是为什么 Session 可以独立于 Workspace 实现:projectKeyprocess.cwd() 就能算出来,不必等 Workspace 抽象落地。

存储位置:全局而非项目内

一个自然的设想是把会话文件放进项目目录,比如 .agent/sessions/<id>.json。这种方案可调试性高——打开项目就能看到历史。但 Claude Code 选择了另一种做法:会话不存进项目,而存进全局目录,按 projectKey 分区。

~/.anycode/
├── projects/
│   ├── -home-user-my-project/        ← projectKey(路径编码)
│   │   ├── a1b2c3d4.jsonl            ← 一个会话一个 JSONL 文件
│   │   └── e5f6g7h8.jsonl
│   └── -home-user-other-project/
│       └── 9h0i1j2k.jsonl
└── ...

这个选择有几个理由:

项目目录的洁净。 项目目录是用户的工作空间,是 git 仓库,是团队共享的资产。把会话日志这种纯本地、纯个人的状态塞进去,即使加了 .gitignore,也会在 ls、文件搜索、IDE 文件树里制造噪音。会话属于”这台机器上的这个用户”,不属于”这个项目”——把它放在用户目录下,归属关系才对。

跨项目会话列表。 用户往往同时在多个项目上工作。全局存储让 list() 可以跨项目聚合——“我最近在哪些项目上和 Agent 聊过”成为一次查询就能回答的问题。项目内存储则做不到,因为列表前你得先知道有哪些项目目录。

与项目移动解耦(部分)。 项目目录被重命名或移动时,会话日志不会跟着消失——它还在 ~/.anycode/projects/ 下。代价是 projectKey 由原路径编码而来,移动后新路径算出的 key 变了,旧会话不会自动关联到新路径。这是一个有意识的取舍:宁可接受”移动项目要手动迁移 key”,也不把会话日志散落到各处。

与工作区状态分离。 第 12 篇会讨论,Workspace 承载的是文件、env、运行时进程这类物理状态;Session 承载的是对话历史这类逻辑状态。两类状态的生命周期不同——项目删了,物理状态没了,但你可能还想翻看之前的对话。把它们存在不同地方,生命周期才解耦。

需要强调:存储在全局目录并不意味着 Session 依赖 Workspace 对象。projectKey 只是一个字符串,从 process.cwd() 编码而来。Session 的实现完全可以先于 Workspace 落地——这正是排序上 Session 可以先做的根据。

SessionStore:以适配器隔离存储

存储方案的选择取决于系统规模。但无论选哪种,增删改查的对象始终是 Session(或更细的 SessionKey + 条目),变化的只是底层读写。因此把存储抽象成一个适配器接口,而不是让业务代码直接调 fs

interface SessionStore {
  append(key: SessionKey, entries: SessionEntry[]): Promise<void>;
  load(key: SessionKey): Promise<SessionEntry[] | null>;
  list(projectKey: string): Promise<SessionMeta[]>;
  remove(key: SessionKey): Promise<void>;
}

注意 append 而非 save。这不是命名口味,而是根本的写入模型——下一节会展开。

原型阶段的最直接实现是本地文件:

class LocalSessionStore implements SessionStore {
  constructor(private baseDir = `${os.homedir()}/.anycode/projects`) {}
 
  private file(key: SessionKey): string {
    return path.join(this.baseDir, key.projectKey, `${key.sessionId}.jsonl`);
  }
 
  async append(key: SessionKey, entries: SessionEntry[]): Promise<void> {
    const file = this.file(key);
    await fs.promises.mkdir(path.dirname(file), { recursive: true });
    const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
    await fs.promises.appendFile(file, lines, "utf-8");
  }
 
  async load(key: SessionKey): Promise<SessionEntry[] | null> {
    const file = this.file(key);
    try {
      const raw = await fs.promises.readFile(file, "utf-8");
      return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line));
    } catch {
      return null;
    }
  }
  // list / remove 同理
}

list 是关键的性能敏感操作:它只读取每个文件的元数据(id、title、时间戳、messages 长度),不加载完整 messages 数组。当 projectKey 目录下有数百个会话时,全量加载会消耗大量内存和 I/O 时间。实现上可以把元数据写在 JSONL 的首行(一个 meta 条目),list 时只读首行;或者维护一个轻量索引文件。具体策略看规模,但”不全量加载”是底线。

当系统需要支持多用户、并发读写、或按元数据字段频繁查询时,数据库方案替代文件方案——只需写一个 SqlSessionStore 实现同一接口,业务代码一行不改。Claude Code 走得更远:本地写一份,再异步镜像到外部后端(S3、Redis、PostgreSQL),双写架构保证本地是 source of truth,外部镜像失败也不丢数据。这种双写对单机学习项目是过度设计,但接口留好了扩展位——这就是适配器的价值。

流式持久化:append 而非全量重写

早期实现里,save(session) 把整个 Session 对象 JSON.stringify 后全量覆盖写盘。这种写法简单,但有两个问题:

粒度太粗。 一次对话可能跑几十轮、产生上百条 message。全量重写意味着每存一次都要把全部历史序列化一遍——第 N 轮保存时重写前 N-1 轮的内容。对话越长,单次写入越慢,而其中绝大部分是重复 I/O。

崩溃窗口大。 全量重写是非原子的:写到一半进程被 kill,文件就处于半截状态,下次 load 直接解析失败,整个会话报废。

append 模型解决这两点。每条 message 作为一个 JSONL 行追加到文件末尾,不触碰已有内容:

{"role":"system","content":"..."}
{"role":"user","content":"修一下登录 bug"}
{"role":"assistant","tool_calls":[...]}
{"role":"tool","tool_call_id":"...","content":"..."}
{"role":"assistant","content":"已修复..."}
↑ 每轮新产生的条目只往后追加,旧条目不动

写入是 append-only:要么整行写进去,要么没写,不存在”半行”。进程在任何时刻崩溃(OOM、断电、kill -9),最多丢失最后一条未 flush 的记录,前面的历史完整。配合每轮保存的策略,崩溃损失被压到”当前这一轮”以内。

load 时按行解析、按序拼接,恢复出完整的 messages 数组。JSONL 天然是流式日志——这也是为什么 Claude Code 选 JSONL 而非单个 JSON 文件:它和”持续追加”的写入模型是匹配的。

与 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(
  key: SessionKey,
  store: SessionStore,
  userInput?: string
) {
  const entries = (await store.load(key)) ?? [];
  const session = entriesToSession(entries);
 
  if (userInput) {
    session.messages.push({ role: "user", content: userInput });
  }
 
  // 记录本轮新产生的条目,循环结束后一次性追加
  const before = session.messages.length;
  session.messages = await agentLoop(session.messages);
  const newEntries = session.messages.slice(before).map(toEntry);
 
  await store.append(key, newEntries);
  return session;
}

上面是每轮粒度的 append。要更细,可以让 Loop 在每次 push 后回调通知包装层立即 append——达到每条 message 落盘。代价是 Loop 要多一个回调钩子,破坏”接口零变化”。学习项目里每轮粒度已经够:崩溃最多丢一轮,I/O 也不重。

数据流是单向的:load → agentLoop(messages) → append(新增条目)。Loop 不知道 Session 的存在——它只看到一个 ChatMessage[] 参数。这样设计的结果是:Session 的存储实现(本地文件、数据库、远程服务)可以在不修改 Loop 代码的情况下替换;Loop 的单元测试不需要模拟 Session 层,直接传入 messages 数组即可。

两个工程上的决策点:

保存时机。 推荐每轮循环后立即 append。有人会担心磁盘 I/O 频率——但 JSONL 追加只写新增的几行,开销在毫秒级,用户感知不到。换来的是:进程在任何时刻崩溃,最多丢失当前轮次的数据。

恢复入口。 runSession 要求调用方提供 SessionKey。这个 key 的来源有两种模式:启动时自动恢复最近一次活跃的 Session(类似 Claude Code 的 --continue 行为),或列出当前项目下所有会话让用户选择(类似 ChatGPT 的侧边栏、Claude Code 的 --resume)。前者适合命令行工具,后者适合 GUI 应用。两种模式不互斥,可以在自动恢复的同时提供”切换到其他会话”的能力。

标题生成

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"

标题生成后作为一条 meta 条目 append 到会话文件(或更新首行索引),不重写历史。用户之后可以手动修改,但自动生成将默认体验从”不可用”提升到了”基本可用”。

分支与恢复

会话一旦可持久化,就衍生出两种派生操作。

分支(fork)。 用户想从某个中间点另起一条思路,但又不想丢失原会话——这是 /branch 的场景。实现上:复制原会话的所有条目到一个新 sessionId 下,从那个点开始追加新对话。原会话不动,新会话继承全部前情。这本质是一次 load → copy → append-to-new-key,存储层用 append(newKey, oldEntries) 就能完成。

恢复(resume)。 --resume <id> 加载指定会话继续;--continue 不带 id,恢复当前项目下 updatedAt 最近的那条。两者都走 runSessionload 路径,区别只是 key 的来源——前者显式指定,后者从 list(projectKey) 取排序后第一个。

原会话 a1b2c3d4
  ├── 第 1-10 轮
  ├── 分支点 ── fork ──→ 新会话 f7e8d9c0(继承 1-10 轮的副本)
  │                     ├── 第 11 轮(新思路)
  │                     └── ...
  └── 第 11 轮(原思路继续)

分支是廉价的——文件复制,不涉及运行时状态。这也反过来说明为什么 Session 要和 Workspace 的运行时状态(进程、端口)分开存:分支一个会话不该分支出一个 dev server。

会话的生命周期管理

Session 一旦可以被持久化,就会累积。一个活跃的 Agent 工具在使用数周后可能产生几十甚至上百个 Session。其中大部分是一次性的调试对话,任务完成后不再需要。如果不加管理,list() 的输出将失去参考价值。

管理策略分为三个层次:

排序。 list() 默认按 updatedAt 倒序排列。最近活跃的 Session 排在前面,符合用户的使用模式——用户大概率想继续最近的对话,而非三周前的某次调试。

归档。 对于不再活跃但有一定保留价值的 Session,移动到 archive/ 子目录(或打个 archived 标记)。归档的 Session 从主列表中移除,但仍可通过完整路径访问。这个操作可以由用户手动触发,也可以基于”超过 N 天未活跃”的规则自动执行。

过期清理。 本地会话日志由 cleanupPeriodDays 控制(Claude Code 默认 30 天),超期自动删除。对于个人 Agent 工具而言,物理删除是合理的——没有刻意保留的东西,丢了就丢了,这与用户的心智模型一致。如果用了外部 SessionStore 镜像,则由后端(如 S3 生命周期策略)负责清理,本地策略与外部策略独立。

与前文的连接

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。SessionKeysubpath 字段正是为这种场景预留:子代理的临时记录可挂在主会话 key 下,走独立子路径,不污染主对话。

多 Agent 编排(第 6 篇)。 第 6 篇的 Team 是一组 Agent 实例的集合。引入 Session 之前,Team 的 messages 是临时的。引入 Session 后,存在两种模式:共享 Session——Team 中的所有 Agent 操作同一个 messages 数组,完成后统一持久化;独立 Session——每个 Agent 维护自己的 Session,适用于长期运行的专家 Agent 需要在多次 Team 调用间保持自身状态。工具型 Agent 用共享模式,专家型 Agent 用独立模式。

设计决策的论证

本地 JSONL vs 数据库

文件方案的优势是零依赖和可调试性。一个 Session 对应一个 JSONL 文件,用户可以直接用任何文本编辑器打开、阅读、甚至手动追加。这种透明性在原型和单用户场景中很实用:当 Agent 行为异常时,直接检查 Session 文件比查询数据库快得多。

文件方案的瓶颈出现在两个场景:当 list() 需要遍历数百个文件时,即使只提取元数据,I/O 开销也线性增长;当多个进程需要同时读写同一个 Session 时,文件锁的粒度控制远不如数据库的事务机制。

数据库方案的引入时机不是”用户量大了”,而是 list() 的延迟超出了交互可接受的阈值——通常以 200ms 为界。在达到这个阈值之前,文件方案足够。这个标准可度量,不依赖对规模大小的模糊感觉。而到那时,因为有 SessionStore 适配器在,切换数据库只是换一个实现类,业务层不动。

每条 append vs 每轮 append vs 全量重写

写入粒度有三档。全量重写每次序列化整个 Session,最慢且崩溃窗口最大,已在上文否定。剩下的选择是每轮 append(每完成一轮 LLM 调用 + 工具执行,把这轮的新条目追加一次)还是每条 append(每产生一条 message 立即追加)。

每条 append 的崩溃损失最小——理论上最多丢最后一条未 flush 的记录。但它的代价是 Loop 要暴露一个回调钩子(“我刚刚 push 了一条消息,请持久化”),破坏了”Loop 接口零变化”的原则,也让 Loop 的单元测试必须模拟存储层。

每轮 append 是工程上的甜点:Loop 仍然只接收和返回 ChatMessage[],包装层在每轮结束后把这轮的新条目一次性追加。崩溃损失是”当前这一轮”——对一个跑了几十轮的任务来说,丢一轮是小事,丢十几轮才需要用户手动复盘。I/O 频率从”每条一次”降到”每轮一次”,而每轮追加的也只是几行 JSON,开销可忽略。学习项目选这一档,性价比最高。

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 - Agent 的活动范围设计