3.6 Agent Harness、Session 与状态
本章解决什么问题:3.5 里的 Agent Loop(Agent 循环)已经能「请求模型 → 执行工具 → 再请求」地跑起来了,但它只是一个函数:跑完就忘、按不停、崩了就没、还只会往终端里
console.log。真实产品要在这个循环外面再套一圈工程设施——状态、会话(Session)、取消、错误恢复、界面解耦。这一整套设施就叫 Agent Harness(Agent 运行框架)。本章讲清它由哪几块组成、各块的边界在哪里。前置知识:3.5 Agent 与 Agent Loop(循环是什么)、2.7 事件、回调与取消(AbortController)(AbortController 与事件订阅)、2.8 Node.js 文件、路径与进程 API(读写文件)。
学习目标:读完本章后你能
- 说出一次 Agent 运行必须记住的状态有哪些,以及它们为什么放在内存对象里;
- 解释 Session 的三个能力——存盘、恢复、分叉——分别解决什么问题;
- 分清「改内存状态」和「往磁盘写」是两件事,并说出典型的落盘时机;
- 用一句话定义 Agent Harness,并画出模型层 / Agent 逻辑层 / Harness 层 / 工具系统 / Session / 界面层的分层关系;
- 在 Pi 的三个核心 package 上指认出这几层各自落在哪里。
建立直觉:引擎与整车
把 Agent Loop 想成发动机。发动机的职责非常纯粹:给它燃料(消息历史),它输出动力(模型回复与工具调用)。它不管油箱多大、不管仪表盘上显示什么、不管钥匙拧到哪一档,更不管你昨天开去了哪里。
但没人会买一台发动机开上路。你要的是整车:油箱和油量表(状态)、行车记录(Session)、刹车(取消)、故障灯与备胎(错误恢复)、方向盘和仪表盘(界面)。这些部件本身不产生动力,却决定了这台发动机能不能被人使用。
Agent Harness 就是这台整车里除发动机以外的部分。它的定义可以收得很紧:
一次运行必须记住什么:状态
先问一个具体问题:假如 Agent 正在工作,此刻要把「它现在的样子」完整描述出来,需要写下哪些东西?
- 消息历史(messages):整段对话的全部消息。它是下一次请求模型时的输入,是最核心的状态。
- 当前模型:用户可能中途换模型(比如从便宜的换成聪明的),换完之后下一轮才生效,所以「当前模型」必须是一个可读可写的字段,而不是写死在某次调用里的参数。
- 可用工具集:这一轮允许模型调用哪些工具。技能加载、扩展安装都可能改变它。
- 运行中标志(running):现在是不是正忙。没有它,用户敲第二句话时程序就会同时跑两个循环,两条时间线往同一个消息数组里写,历史立刻错乱。
- 正在流式接收的那条消息:模型的回复是一小段一小段到达的(3.3),还没结束的半成品要有地方放,界面才能实时显示。
- 正在执行的工具调用、最近一次错误:用来显示「工具运行中」的转圈动画,以及出错后的提示。
这些东西每一轮都要读、每几毫秒就要写(流式片段到达时更新特别频繁)。所以它们的家是内存里的一个普通对象——不是数据库,不是文件。把它们放进文件,意味着每来一个文字片段就要写一次盘,既慢又毫无必要。
下面是一个把状态、运行中标志和取消放在一起的最小例子。为了不依赖任何 API Key,模型是假的:
type Msg = { role: "user" | "assistant"; text: string };
/** 一次运行期间必须记住的东西,全部放在内存里 */
interface HarnessState {
messages: Msg[];
model: string;
running: boolean;
}
const state: HarnessState = { messages: [], model: "fake-model-v1", running: false };
/** 假模型:不联网、不需要任何 API Key */
function fakeReply(messages: Msg[]): Msg {
const asked = messages.filter((m) => m.role === "user").length;
return { role: "assistant", text: `这是第 ${asked} 次回答,模型是 ${state.model}` };
}
/** 可被取消的等待:signal 一旦 abort 就立刻返回 */
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(timer);
resolve();
});
});
}
/** 一次 turn:改的是内存状态,不碰磁盘 */
async function runTurn(text: string, signal: AbortSignal): Promise<void> {
if (state.running) throw new Error("busy:上一轮还没结束");
state.running = true;
try {
state.messages.push({ role: "user", text });
await sleep(10, signal); // 假装在等模型回复
if (signal.aborted) {
state.messages.push({ role: "assistant", text: "[本轮被取消]" });
return;
}
state.messages.push(fakeReply(state.messages));
} finally {
state.running = false;
}
}三个细节值得停一下。第一,running 的检查放在函数最开头、赋值紧随其后,中间不能有 await——一旦中间有 await,两次几乎同时的调用就可能双双通过检查(2.5 讲过 await 会把控制权交还给事件循环)。第二,复位写在 finally 里,这样无论正常结束、抛错还是被取消,标志都不会卡在 true——卡住的后果是整个程序永远显示「忙」,再也接受不了新输入。第三,取消不是「让函数消失」,而是让它以一种可记录的方式收尾:这里往历史里追加了一条「本轮被取消」,用户和模型下次都能看见这段历史里发生过什么。
Session:会话的持久化身份
内存对象解决了「现在」,但解决不了「昨天」。进程一退出,state.messages 就消失了。
**Session(会话)**就是给一段对话一个持久化的身份:它有 ID、有磁盘上的一份记录,并且支持三件事。
- 存盘:把消息历史(以及模型切换等关键变更)写进文件。
- 恢复:下次启动时读回来,让新进程接着上次的对话继续说,而不是从空白开始。
- 分叉(branch / fork):从历史中间某个点复制出一条新时间线,两边各自往下走、互不影响。想「回到三步之前换个思路重试」时,靠的就是它。
分叉这件事需要一点解释:为什么不直接删掉后面的消息?因为删掉就找不回来了。真实用法里,你常常想比较「按方案 A 走」和「按方案 B 走」的结果,两条线都得留着。所以 Session 的通行做法是只追加、不修改:历史里的每一条记录写下去就不再改动,分叉只是让新记录挂到旧记录的某个位置后面。
下面这段接着上一节的状态代码,用最朴素的方式实现存盘、恢复和分叉——一个 JSON 文件装一条时间线:
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = mkdtempSync(join(tmpdir(), "mini-session-"));
const sessionFile = join(dir, "session.json");
const forkFile = join(dir, "session-fork.json");
const save = (file: string, messages: Msg[]) =>
writeFileSync(file, JSON.stringify(messages, null, 2));
const load = (file: string): Msg[] => JSON.parse(readFileSync(file, "utf8")) as Msg[];把两段拼在一起跑一遍完整流程(对话两轮 → 存盘 → 模拟重启 → 恢复 → 分叉 → 取消 → 并发被拒):
const controller = new AbortController();
await runTurn("你好", controller.signal);
save(sessionFile, state.messages);
await runTurn("再来一句", controller.signal);
save(sessionFile, state.messages);
console.log("1. 内存里的消息数:", state.messages.length);
state.messages = []; // 模拟进程退出:内存全部丢失
console.log("2. 重启后内存里的消息数:", state.messages.length);
state.messages = load(sessionFile); // 从 Session 文件恢复
console.log("3. 从 Session 恢复后:", state.messages.length);
console.log(" 最后一条:", state.messages[state.messages.length - 1]?.text);
// 分叉:把前两条抄进新文件,两条时间线从此各走各的
save(forkFile, state.messages.slice(0, 2));
console.log("4. 分叉出的新 Session 消息数:", load(forkFile).length);
const aborted = new AbortController();
aborted.abort();
await runTurn("这句会被取消", aborted.signal);
console.log("5. 取消后最后一条:", state.messages[state.messages.length - 1]?.text);
console.log(" running 标志已复位:", state.running);
const busyController = new AbortController();
const first = runTurn("第一句", busyController.signal);
try {
await runTurn("插队的第二句", busyController.signal);
} catch (error) {
console.log("6. 并发调用被拒绝:", (error as Error).message);
}
await first;
console.log("7. 结束时消息数:", state.messages.length, "running =", state.running);把上面三段代码按顺序拼成一个 demo.ts,放进任意一个已经 npm install 过的实验目录(例如 2.7 的 labs/typescript-basics/10-events-abort),用 npx tsx demo.ts 运行。真实输出:
1. 内存里的消息数: 4
2. 重启后内存里的消息数: 0
3. 从 Session 恢复后: 4
最后一条: 这是第 2 次回答,模型是 fake-model-v1
4. 分叉出的新 Session 消息数: 2
5. 取消后最后一条: [本轮被取消]
running 标志已复位: false
6. 并发调用被拒绝: busy:上一轮还没结束
7. 结束时消息数: 8 running = false第 2、3 行是本章最值得盯住的两行:内存清空后消息数归零,从文件读回后又回到 4。Session 的全部意义就在这两行之间——它是内存状态在磁盘上的一个副本,让「重启」不等于「失忆」。
messages 是**当前正在用的那份**,随时被改写;Session 是**写下来的历史**,通常只追加不修改,而且还记着消息以外的东西:会话 ID、工作目录、每次模型切换、压缩摘要、分叉点。恢复会话时,程序做的事是「读历史 → 重建出一份内存状态」,而不是「直接拿文件当状态用」。 状态在内存,落盘另有其时
既然内存和磁盘是两份东西,就必然要回答一个问题:什么时候写盘?
上面的例子偷了懒——每轮结束整个数组重写一遍。对几十条消息够用,对几千条就不行了:每次都把全文重写一遍,既慢又危险(写到一半崩溃,整个文件就毁了)。真实实现通常做两件事:一是追加写,一条新消息就在文件末尾加一行,常用格式是 JSONL(JSON Lines,每行一个 JSON 对象);二是分类对待——消息这种「一旦定稿就不再变」的东西立刻落盘,而模型切换、分叉点移动这类零散改动先攒着,在一轮结束的时刻统一刷盘。
图 3.6-1 一轮对话里,内存改了很多次,磁盘只写了几次
阅读顺序:从左到右就是时间顺序。请注意 C 这一步——流式片段每几十毫秒更新一次内存,但一次盘都不写;只有到 D(消息定稿)才落盘一行。F 是另一个关键点:一轮结束是天然的「存档点」,攒着的改动在这里统一写入。Pi 的 AgentHarness 正是按这个节奏工作的,下一节有源码为证。
这个安排背后是一条通用取舍:写得越勤,崩溃时丢得越少,但平时越慢。把边界定在「消息定稿」上,是因为半成品消息本来就没有保存价值——崩溃后重放一句没说完的话没有意义。
Harness 还要管的三件事
取消。用户按下 ESC,正在等模型的网络请求、正在读大文件的工具、准备发起的下一轮,都要停下来。2.7 讲过,办法是一条 AbortSignal 贯穿全链:Harness 持有 AbortController,把 signal 传给循环,循环传给模型请求和每个工具。取消之后 Harness 还要负责收尾——把队列清空、把状态标记成空闲、告诉界面「停了」。
错误恢复。Agent 运行里的错误分两类,处理方式完全不同。一类是程序错误(代码写错、类型不对),应该直接抛出来让人修;另一类是运行中可预期的失败——网络超时、模型返回被截断、工具执行失败。第二类不能让整个程序崩溃,而要变成历史里的一条记录:工具失败就把错误信息当作工具结果交回给模型,让模型自己决定换个方式重试;模型调用失败就把这一轮标记为出错,允许用户重试或换模型。把错误编码进数据流,而不是抛出去炸掉调用栈,是 Agent Harness 的一个典型设计选择。
界面解耦。同一个 Agent 要同时服务终端界面、命令行的一次性输出、编辑器插件、网页界面。做法是 Harness 只发事件(「一条消息开始了」「有文字增量」「某个工具开始执行」),谁想显示谁就订阅。Harness 里不能出现任何 console.log 式的直接输出,否则换个界面就得改核心代码。一种看法是这让调试变麻烦了(要顺着事件找源头),代价换来的是:加一个网页界面不需要动 Harness 一行代码。
分层:六个盒子各管什么
把前面所有部件放进一张图:
图 3.6-2 Agent Harness 的六层分工
阅读顺序:先看中间的 HARNESS,它是唯一同时连着上下左右的盒子。要关注的是箭头的方向:界面与 Harness 之间是双向的(输入进来、事件出去),而 Harness 与 Session、Agent Loop 之间是单向的——下层从不主动去找上层。模型层和工具系统只被 Agent Loop 调用,它们既不知道 Session 存在,也不知道屏幕上正在显示什么。
读图要点有三个。第一,模型层最纯粹:给它消息,还你流式事件,没有任何状态。第二,Agent Loop 不碰磁盘也不碰屏幕:它只跟内存里的上下文、模型层和工具系统打交道,这正是它能被反复测试的原因(喂假模型就能跑)。第三,Session 挂在 Harness 下面而不是循环下面:循环根本不知道自己的对话被存到了哪里,是 Harness 在旁边听着事件、顺手把该记的记下来。
Pi 中哪里用到了它
Pi 把这张图切成了三个 package(4.3 有完整的包地图),每个 package 恰好对应图里的一到两层。
模型层 = pi-ai。它的包描述把职责写得很清楚:统一的 LLM(大语言模型,Large Language Model)API,带自动的模型发现与 Provider(模型服务提供方)配置(源码事实)。
@earendil-works/pi-aiAgent 逻辑层 + Harness 层 = pi-agent-core。这个包的入口文件同时导出了三样东西:有状态的 Agent 类、低层的 agent loop 函数、以及 AgentHarness(源码事实)。
harness/agent-harness.tsAgentHarness 类的字段清单几乎是本章内容的逐条对照——会话、相位(当前处于空闲还是某种运行中)、取消用的 AbortController、攒着待写盘的会话变更、当前模型与思考等级、系统提示词、三个消息队列、事件处理器表(源码事实):
AgentHarness落盘时机也和图 3.6-2b 完全对上:消息定稿(message_end 事件)时立刻写进 Session,一轮结束(turn_end)时把攒着的改动统一刷盘并广播一个「存档点」事件,整段运行结束(agent_end)时再刷一次并把相位复位成空闲(源码事实):
handleAgentEvent产品层 = pi-coding-agent。pi 这个命令本体在这里:包描述写明它是带读文件、bash、编辑、写文件等工具与会话管理的编码 Agent 命令行程序(源码事实)。
@earendil-works/pi-coding-agent从源码结构看,这三个包的依赖方向是单向的:pi-coding-agent → pi-agent-core → pi-ai。换句话说,下层永远不知道上层的存在——这正是图 3.6-2 里箭头方向的工程体现。Session 的具体格式、树结构与分叉实现留到 5.5 与 6.5,取消与错误处理的完整链路在 5.6,自己动手实现这一层则在 8.4。
实践任务
labs/agent-concepts/04-agent-loop目标:本章没有新实验。请回到 3.5 的实验目录 labs/agent-concepts/04-agent-loop,用大约 10 行代码给它加上最朴素的 Session:把 messages 数组存成 JSON 文件,重启后读回来接着聊。
步骤:打开该实验里持有消息历史的那个文件(通常是 src/main.ts),做两处改动。
① 在文件顶部加上导入,并在创建 messages 之后立刻尝试恢复:
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const SESSION_FILE = "session.json";
if (existsSync(SESSION_FILE)) {
messages = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
console.log(`已恢复 ${messages.length} 条历史消息`);
}如果原来的 messages 是用 const 声明的,改成 let;如果它是某个状态对象的字段,就写成 state.messages = ...。
② 在循环跑完、程序退出之前,把整个数组写回去:
writeFileSync(SESSION_FILE, JSON.stringify(messages, null, 2));
console.log(`本次结束,共 ${messages.length} 条消息`);验证方式:连续运行两次 npm start。第一次不该出现「已恢复」那行;第二次应当先打印「已恢复 N 条历史消息」,且结束时的消息数大于第一次。再看一眼 session.json,应当是一个 JSON 数组,里面按顺序排着 user 与 assistant 消息。
预期现象(把上面两段接到一个最小循环上实测得到的真实输出):
本次结束,共 2 条消息
--- 第二次运行(模拟重启) ---
已恢复 2 条历史消息
本次结束,共 4 条消息如何判断成功:删掉 session.json 再跑,程序回到「第一次运行」的样子;不删则一直累积。并且你能说出这 10 行相比真实实现缺了什么。
常见错误:① 忘了 existsSync 判断,首次运行直接读不存在的文件而崩溃;② 把写盘放在循环内部每次模型调用后,导致半成品消息也被写进去;③ 只写不读,文件是有了但重启还是从零开始;④ 消息里含有不能被 JSON.stringify 处理的值(例如函数),存进去再读回来就变了形——这也是真实实现要精心设计存储格式的原因之一。
进阶思考:把 session.json 复制一份成 session-fork.json,删掉后面几条消息再跑——你就手工做了一次「分叉」。再想想:如果程序在写文件写到一半时被杀掉会发生什么?为什么「每次只在文件末尾追加一行」能减轻这个问题?
更多实践入口见实践任务索引。
本章小结
- 状态是一次运行必须记住的东西:消息历史、当前模型、工具集、运行中标志、流式半成品、最近错误。它们住在内存对象里,因为读写太频繁。
- 运行中标志要在函数最开头同步检查并置位、在
finally里复位,否则会出现两个循环同时改一份历史,或者程序永远显示「忙」。 - Session 是会话的持久化身份,提供存盘、恢复、分叉三件事;它通常只追加不修改,所以历史永远可以回溯。
- 改内存和写磁盘是两件事:流式片段只改内存,消息定稿才落盘,一轮结束是天然的存档点。
- Agent Harness = 围绕 Agent Loop 的这一整套设施:状态 + Session + 取消 + 错误恢复 + 事件分发。发动机之外的整车。
- 分层对应到 Pi:
pi-ai是模型层,pi-agent-core同时提供 Agent 逻辑层与 Harness 层,pi-coding-agent是产品层(界面与具体工具)。 - 关键术语:Agent Harness(Agent 运行框架)、Session(会话)、状态(State)、分叉(branch / fork)、存档点(save point)、JSONL。
- 关键源码索引:
packages/agent/src/index.ts:1-6(三层同包)、packages/agent/src/harness/agent-harness.ts:171-197(Harness 持有的状态)、packages/agent/src/harness/agent-harness.ts:538-565(落盘时机)。 - 自测问题:
- 为什么「正在流式接收的半成品消息」不需要写进 Session 文件?
- 如果去掉「运行中标志」,用户连按两次回车会发生什么?请具体描述消息历史会变成什么样。
- Session 的「分叉」为什么用复制而不是删掉后面的消息?
- Agent Loop 里为什么不应该出现
console.log?应该换成什么?
- 下一章:3.7 Compaction、Extension 与 Skill——对话变长后上下文会超出模型能接受的容量,上下文压缩(Context Compaction)负责把旧消息压成摘要;扩展(Extension)与技能(Skill)则决定这套设施还能被外部塞进哪些能力。