M12. 无头 SDK:从 Agent 到 AgentSession#
把 Pi 放进自己的应用时,最容易犯的错误是只看到一个 Agent 类,然后把模型、工具、会话文件、UI 和业务状态全部塞进去。Pi 官方 SDK 的价值,恰恰在于它把这些职责拆开了。
1. 三个对象,三种责任#
Agent:负责把循环跑起来#
核心 Agent 持有当前 transcript、工具、模型流函数和事件生命周期。它知道如何 prompt、continue、steer、follow-up、abort 和等待空闲,但它本身没有会话文件持久化。
这是一种有意的低层边界:你可以用它跑一个内存里的 Agent,也可以把自己的存储和 UI 接在事件流上。
AgentSession:把产品能力装配起来#
AgentSession 在核心 Agent 之上增加会话管理、模型和 thinking level 控制、上下文压缩、扩展、工具运行时和事件订阅。它会监听 Agent 的 message_end,把 user、assistant 和 tool result 等消息交给 SessionManager 保存。
SessionManager:管理会话树和文件#
它负责 JSONL 会话文件、entry 的父子关系、当前 leaf、树遍历、标签、回退、分支和从分支创建新会话。它不应该承担模型推理,也不应该把 UI 状态塞进每一个消息节点。
可以把三者想成一条装配线:Agent 产生运行事件,AgentSession 把运行事件接上产品策略,SessionManager 把需要恢复的事实落盘。
2. 一个最小的 headless 会话#
下面的骨架展示的是 SDK 的连接方式,而不是完整服务:
import {
createAgentSession,
type AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";
import { getModel } from "@earendil-works/pi-ai/compat";
const session = await createAgentSession({
cwd: process.cwd(),
model: getModel("anthropic", "claude-sonnet-4-5"),
});
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
if (event.type === "tool_execution_end") {
console.error(`[tool] ${event.toolName}: ${event.isError ? "error" : "ok"}`);
}
});
try {
await session.prompt("Read the project structure and summarize the main risks.");
} finally {
unsubscribe();
session.dispose();
}真实项目要把 process.stdout 换成自己的事件适配器:WebSocket、HTTP 流、桌面 UI 或消息队列都可以。关键点是,业务层不需要猜测 Agent 当前在做什么,而是消费结构化的 session event。
3. 为什么不直接把 Agent 当数据库用#
持久化和运行循环是两个不同的变化轴:
- 运行循环关注“下一次模型调用应该拿到什么上下文”。
- 持久化关注“重启、回退和分叉之后,哪些事实还存在”。
如果把 append、branch、compact 和数据库事务直接写进 Loop,Loop 就无法在内存模式、测试模式或不同存储后端中复用。Pi 的做法是通过事件订阅把“消息已经完成”作为持久化时机:只有完整消息产生后,才追加到 session manager。
这也解释了为什么半截的流式文本不应直接当成一条最终消息写入业务数据库。流式 delta 是展示材料,message_end 才是可以恢复的事实。
4. 会话树不是聊天列表#
SessionManager 的 entry 通过 id 和 parentId 串成树。追加新消息只需要指向当前 leaf;回退只需要把活动 leaf 移到旧节点;从旧节点继续写,自然得到新的分支。
import { SessionManager } from "@earendil-works/pi-coding-agent";
const sm = SessionManager.open("/path/to/session.jsonl");
const path = sm.getPath();
const leaf = sm.getLeafEntry();
const children = leaf ? sm.getChildren(leaf.id) : [];
// 给某个节点打一个可检索的业务标签
if (path[0]) {
sm.appendLabelChange(path[0].id, "checkpoint");
}应用层可以把“用户接受的版本”“评审通过的节点”或“部署前快照”写成标签,而不是复制整份会话。这样,树结构提供了回溯能力,标签提供了产品语义。
5. SDK 接入时的四个边界#
模型边界
getModel() 得到的是模型对象,不是一个只有 id 的配置字典。多供应商差异由 pi-ai 处理;宿主应用只负责选择模型、认证和失败策略。
UI 边界#
subscribe() 提供事件,不规定你必须使用 TUI。浏览器前端应把事件转换为自己的消息协议,避免把 Pi 的内部事件类型直接暴露成长期公共 API。
存储边界
Session file 保存恢复所需的 Agent 历史;用户账户、业务订单、权限记录等应留在宿主数据库。不要为了“方便恢复”把所有业务状态塞进 custom message。
生命周期边界
每次创建 session 都要有明确的 dispose()、取消和超时策略。进程长时间运行时,未注销的 subscriber、未关闭的流和未回收的临时文件都会变成隐蔽泄漏。
6. 什么时候选哪一层#
| 需求 | 建议入口 |
|---|---|
| 只想验证一个循环的运行行为 | Agent |
| 要模型、工具、扩展、压缩和会话能力 | AgentSession / createAgentSession |
| 要列出、打开、分支和标记历史 | SessionManager |
| 只想直接调用一个 LLM,不需要 Agent | pi-ai |
先选最低够用的层。只做一次文本生成时,不要引入完整 session;需要可恢复的编码工作流时,也不要自己重新实现一套 JSONL 树。
7. Headless 集成的生命周期不变量#
一次 headless 运行至少要有一个创建点、一个可观察出口和一个释放点:创建 session 时确定工作目录、模型和存储;运行中通过订阅者传出结构化事件;结束时取消活动操作、注销订阅并调用 dispose()。这三个点缺一不可。只有输出而没有释放,会造成资源泄漏;只有持久化而没有事件投影,用户无法理解正在发生什么;只有核心 Agent 而没有 session 边界,则无法可靠恢复。
资料
本文依据 Pi SDK 文档、Agent 核心源码和 SessionManager 源码重写。示例中的模型名只用于说明对象形态,不构成模型推荐。