Appearance
🐣 小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 的 存储 JSONL JSONL + SQLite(可选) 压缩 滑动窗口+拼接 LLM 语义总结(compaction) 分叉 无 fork(试验不同方案) 搜索 无 search-backend 省的都是"高级功能"。 个人用,基础的"存盘+恢复+裁剪"够了。
本章小结
🐣 小a的第二十八课
┌──────── 上下文与会话 ────────┐ │ │ │ • 上下文会爆: │ │ 滑动窗口(留首尾+摘要) │ │ → pi compaction 极简版 │ │ │ │ • 会话持久化: │ │ JSONL(append-only) │ │ → 崩溃友好、可恢复 │ │ │ │ • 集成:加载→裁剪→存盘 │ │ │ │ • 对照 pi:省了 SQLite/ │ │ fork/search(产品级) │ └─────────────────────────────┘
关键认知:记忆管理两大问题——窗口会爆(用裁剪)、关了就忘(用存盘)。JSONL + 滑动窗口是最简方案,够个人用。
课后实验
- 实现 session.ts:照着写出加载/保存。
- 测持久化:跑一次 Agent,关掉,再开,问"我们刚才聊了啥"——看它能不能恢复。
- 触发裁剪:把 MAX_MESSAGES 设成 5,聊 10 轮,看历史怎么被裁剪。
- 找边界 bug:把
recent改回slice(-(MAX_MESSAGES - 1)),把 MAX_MESSAGES 设成 3,打印每次裁剪后的长度——验证它为什么超过 3 条。
下一章:Agent 后端齐了,给它加个能人机对话的"脸"。 → 第 29 章 · 交互层