Skip to content

🐣 小a的记性难题

Agent 会干活了,但两个问题冒出来:一是聊久了历史越来越长,迟早撑爆上下文窗口(第 1 章的伏笔);二是关了程序再开,之前聊的都没了。这就是『上下文管理』和『会话持久化』,我们用最简单的办法解决。


28.1 问题一:上下文会爆

每次循环,history 多几条。窗口迟早满(第 18 章讲过 pi 用 compaction 解决)。

我们的简化方案:滑动窗口

typescript
// memory.ts
const MAX_MESSAGES = 50;  // 保留最近 50 条

export function trimHistory(history: Message[]): Message[] {
  if (history.length <= MAX_MESSAGES) return history;
  // 保留第一条(user 的最初需求)+ 最近的
  const first = history[0];
  // 注意:first + summary + recent 三项之和必须 ≤ MAX_MESSAGES,
  // 所以 recent 只能取最近 (MAX_MESSAGES - 2) 条
  const recent = history.slice(-(MAX_MESSAGES - 2));
  // 中间的总结成一条摘要
  const summary = summarize(history.slice(1, -MAX_MESSAGES + 2));
  return [first, { role: "user", content: `[之前的对话摘要] ${summary}` }, ...recent];
}

function summarize(oldMessages: Message[]): string {
  // 简化版:直接拼接(生产环境应调 LLM 总结)
  return oldMessages
    .map(m => `${m.role}: ${m.content.slice(0, 100)}`)
    .join(" | ");
}

🧙 滑动窗口的思路:

  • 保留第一条(最初的需求,不能丢)
  • 保留最近 N 条(当前上下文)
  • 中间的总结成摘要(省空间但保信息)

这是 pi compaction 的极简版。 pi 会调 LLM 做真正的语义总结,我们简化成"拼接截断"。够用,但不智能——长大的方向见第 31 章。

⚠️ 一个真实的边界 bug:初稿里 recent 取的是最近 MAX_MESSAGES - 1 条,加上 first 和 summary 就变成了 51 条——超了 1 条。这类"长度算错 1"的 bug 在上下文裁剪里非常常见,而且难发现(超 1 条通常不报错,只是悄悄超预算)。写裁剪逻辑时,先数清楚每一项占几条。


28.2 问题二:关了就忘

Agent 的 history 在内存里,程序退出就没了。要持久化

我们的方案:JSONL 存盘

typescript
// session.ts
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "fs";

const SESSION_FILE = ".my-agent-session.jsonl";

// 加载会话
export function loadSession(): Message[] {
  if (!existsSync(SESSION_FILE)) return [];
  return readFileSync(SESSION_FILE, "utf-8")
    .split("\n")
    .filter(line => line.trim())
    .map(line => JSON.parse(line));
}

// 保存一条消息(append,不全量重写)
export function saveMessage(msg: Message): void {
  appendFileSync(SESSION_FILE, JSON.stringify(msg) + "\n");
}

🧙 为什么用 JSONL(每行一条 JSON)?

  • append-only:加一条只追加一行,不用重写整个文件(快、安全)
  • 崩溃友好:即使中途崩,已写入的行不损坏(不像整个 JSON 坏了全完)
  • 可读:每行一条,能 cat 看、能 grep

这正是 pi 用的格式(第 18 章的 jsonl-store.ts)。简单、健壮。


28.3 集成进 Agent

修改 runAgent,让它用持久化:

typescript
// agent.ts(修改)
import { loadSession, saveMessage } from "./session.ts";
import { trimHistory } from "./memory.ts";

export async function runAgent(config, userMessage) {
  const history = loadSession();          // 启动时加载
  history.push({ role: "user", content: userMessage });
  saveMessage({ role: "user", content: userMessage });  // 存盘

  for (let turn = 0; turn < config.maxTurns; turn++) {
    const trimmed = trimHistory(history); // 每轮裁剪(防窗口爆)
    // ... 用 trimmed 喂模型
    // 每条 assistant/tool 消息都 saveMessage
  }
}

🧙 现在 Agent 有记忆了:

  • 启动加载历史 → 知道之前聊了啥
  • 每条消息存盘 → 关了再开能恢复
  • 每轮裁剪 → 不会撑爆窗口

这就是 pi 的 session 体系的极简版。 pi 多了 fork(分叉)、search(搜索)、SQLite 后端——那些是产品级需求,个人用 JSONL 够了。


28.4 对照 pi:我们省了什么

🧙 我们 vs pi 的会话管理:

我们的pi 的
存储JSONLJSONL + SQLite(可选)
压缩滑动窗口+拼接LLM 语义总结(compaction)
分叉fork(试验不同方案)
搜索search-backend

省的都是"高级功能"。 个人用,基础的"存盘+恢复+裁剪"够了。


本章小结

🐣 小a的第二十八课

┌──────── 上下文与会话 ────────┐
│                             │
│  • 上下文会爆:              │
│    滑动窗口(留首尾+摘要)   │
│    → pi compaction 极简版    │
│                             │
│  • 会话持久化:              │
│    JSONL(append-only)      │
│    → 崩溃友好、可恢复        │
│                             │
│  • 集成:加载→裁剪→存盘      │
│                             │
│  • 对照 pi:省了 SQLite/    │
│    fork/search(产品级)    │
└─────────────────────────────┘

关键认知:记忆管理两大问题——窗口会爆(用裁剪)、关了就忘(用存盘)。JSONL + 滑动窗口是最简方案,够个人用。


课后实验

  1. 实现 session.ts:照着写出加载/保存。
  2. 测持久化:跑一次 Agent,关掉,再开,问"我们刚才聊了啥"——看它能不能恢复。
  3. 触发裁剪:把 MAX_MESSAGES 设成 5,聊 10 轮,看历史怎么被裁剪。
  4. 找边界 bug:把 recent 改回 slice(-(MAX_MESSAGES - 1)),把 MAX_MESSAGES 设成 3,打印每次裁剪后的长度——验证它为什么超过 3 条。

下一章:Agent 后端齐了,给它加个能人机对话的"脸"。 → 第 29 章 · 交互层