到目前为止,Agent 只能在一次请求里工作。JSONL session store 让它拥有可恢复的历史。
对应文件:
examples/teaching-agent/src/server/agent/sessionStore.ts
src/server/agent/sessionStore.ts
.teaching-agent/session.jsonl # 运行后生成,不提交
这一步开始从“内存上下文”进入“可恢复上下文”。注意 .teaching-agent/ 是运行产物,应该放进 .gitignore。
Agent 每次请求模型都需要上下文。上下文不能只存在 React state 里,也不能只存在某次 HTTP 请求里。它要能:
教学版 session 文件在:
examples/teaching-agent/.teaching-agent/session.jsonl
每条消息 append 一行:
{"type":"message","id":"entry_1","parentId":null,"timestamp":"...","message":{"role":"user","content":[{"type":"text","text":"列出工作区文件"}]}}
append-only 的好处是简单、稳定、崩溃友好。真实 Pi 也是这种思路。
不要一次写完整 store。按下面顺序实现,每一步都可以手动验证:
1. constructor(filePath, cwd):如果文件不存在,写 session header
2. appendMessage(message):生成 entry_N,parentId 指向当前 leaf
3. pathToLeaf():从 leaf 沿 parentId 回溯
4. buildContext():把当前 leaf 路径转成 AgentMessage[]
5. reset():删除文件并重写 header
6. compactIfNeeded():最后再补
关键 diff:
+ private leafId: string | null = null;
+ private byId = new Map<string, SessionEntry>();
+
+ async appendMessage(message: AgentMessage): Promise<string> {
+ const id = this.nextId();
+ const entry = { type: "message", id, parentId: this.leafId, timestamp: new Date().toISOString(), message };
+ await this.appendEntry(entry);
+ this.leafId = id;
+ return id;
+ }
async appendMessage(message: AgentMessage): Promise<string> {
const id = this.nextId();
const entry = {
type: "message",
id,
parentId: this.leafId,
timestamp: new Date().toISOString(),
message,
};
await this.appendEntry(entry);
this.leafId = id;
return id;
}
leafId 指向当前分支的末端。每次 append,新的 entry 的 parentId 指向旧 leaf,然后 leaf 前移。
flowchart LR A["entry_1 user"] --> B["entry_2 assistant"] B --> C["entry_3 toolResult"] C --> D["entry_4 assistant"]
private pathToLeaf(): SessionEntry[] {
if (!this.leafId) return [];
const path: SessionEntry[] = [];
let current = this.byId.get(this.leafId);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}
完整仓库现在已经把这个结构做成了最小 UI:JsonlSessionStore.switchLeaf(leafId) 会把当前 leaf 指向已有 entry,POST /api/branch 调用它,前端 Session Tree 再把 id / parentId 可视化。
如果你从空目录跟做,可以先实现 append 和 buildContext(),确认主链路能跑;等 Step 6 前端完成后,再回来补 branch endpoint 和树形 UI。
async compactIfNeeded(maxApproxTokens: number, keepRecentMessages: number) {
const currentContext = this.buildContext();
const tokensBefore = estimateTokens(currentContext);
if (tokensBefore <= maxApproxTokens) return undefined;
const kept = messageEntries.slice(-keepRecentMessages);
const summarized = messageEntries.slice(0, -keepRecentMessages);
const summary = summarizeEntries(summarized);
return appendCompactionEntry(summary, kept[0].id, tokensBefore);
}
这不追求摘要质量,只保留结构:
| 字段 | 作用 |
|---|---|
summary |
老上下文摘要 |
firstKeptEntryId |
从哪条消息开始保留原文 |
tokensBefore |
压缩前的近似 token |
启动项目,连续发送几次:
列出工作区文件
读取 README.md
写一条笔记
然后查看:
cat examples/teaching-agent/.teaching-agent/session.jsonl
你应该能看到 session、message,在上下文足够长时还能看到 compaction。
如果你是从空目录跟做,先用这个最小检查:
const store = new JsonlSessionStore(".teaching-agent/session.jsonl", process.cwd());
await store.appendMessage(createUserMessage("hello"));
console.log(store.buildContext().length);
预期输出:
1
| 错误 | 后果 |
|---|---|
| 每次保存整个 JSON 数组 | 长会话写入成本越来越高 |
| 恢复时不重建 byId | 无法从 leaf 回溯上下文 |
| 压缩时删除旧行 | 丢失审计和重建能力 |
| 把 compaction 当 assistant message 保存 | 类型语义混乱,后续无法区分摘要来源 |
把 compactIfNeeded(1200, 8) 中的 1200 改成 300,更容易触发压缩。观察前端 Event Timeline 里是否出现 compaction。
git add src/server/agent/sessionStore.ts .gitignore
git commit -m "step 4: add jsonl session store"