SDK 与 RPC:选择应用集成入口
按宿主生命周期、语言栈和故障隔离需求,在进程内 SDK 与跨进程 RPC 之间做一次有依据的选择。
SDK 与 RPC:选择应用集成入口
要把智能体跑进你的应用里,先要回答一个具体问题:它跟你住在同一个进程,还是另一个进程?
Pi 给了两条路。SDK 把智能体嵌进你的 Node.js 或 Bun 进程,你可以直接调方法、直接收事件。RPC 把 Pi 变成一个长驻子进程,你通过 stdin/stdout 上的 JSON 记录跟它说话。
这两条路不是"高级/低级"的关系,而是进程边界在哪儿的关系。选错的代价不是写法麻烦,而是故障隔离、生命周期和语言栈全部要自己重新发明一遍。
读完这篇你能对着决策表说出"我这次该选哪条路",并掌握两条路线各自完整的最小客户端和释放路径。
需要的前置只有一条:会用 Node.js 或 TypeScript,并且知道 Pi 是一个跑在终端里的命令行程序。
先看清 Pi 有几种运行形态
官方把 Pi 的运行形态分成四类 CLI 模式加一类 SDK:
注意 SDK 不在这个表的"CLI 模式"里——它不是一个模式,是把智能体直接放进你的进程。
另外两个容易混淆的点:
- Print 模式不暴露中间事件。它跑完所有 prompt,把最终助手文本写到 stdout 就退出。响应以
error或aborted结束会产生非零退出码;撞到输出 token 上限时仍会打印部分文本,同时写警告到 stderr,退出码非零。 - JSON 模式不接受后续命令。所有 prompt 在进程启动时一次性给完,事件流完就退出。它和 RPC 共用同一套事件形状,区别是 JSON 模式多一条会话头记录,RPC 没有。
- RPC 模式拒绝
@file形式的 prompt 参数。源码里明确检查:一旦在 RPC 模式下发现@file参数,打印错误并以退出码 1 退出。prompt 要通过prompt命令发送。
SDK 路线:直接调方法
SDK 的入口是 createAgentSession()。它返回一个 AgentSession,这个对象持有一整段对话、它的模型和工具、排队消息、压缩状态,以及扩展运行时。
// sdk-run.ts
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
// 订阅必须早于 prompt:否则快速完成的运行可能在你订阅之前就把事件发完了
const unsubscribe = session.subscribe((event) => {
// 只取 text_delta:thinking 和 toolcall 是另两类增量,混在一起会拼出乱码
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
await session.prompt("这个仓库的测试怎么跑?");
console.log(session.getLastAssistantText());
} finally {
// 释放顺序:先退订再 dispose,避免释放之后再收到残留回调
unsubscribe();
session.dispose();
}订阅要早于 prompt
session.subscribe() 返回一个取消订阅函数。必须在 prompt 之前订阅——否则快速完成的运行可能在你订阅之前就已经把事件发完了。
订阅顺序有意义:message_end 里的消息是权威的最终消息;agent_end 标记的是一次底层运行结束,但自动重试或排队的工作还可能跟在后面。要确认 Pi 不会自动继续,用 agent_settled。
运行中发消息必须指定去向
会话正在流式输出时调用 prompt(),你必须明确这条消息是打断当前运行还是等它跑完。不指定就抛错,而不是替你猜:
// 插到当前助手轮次之后
await session.steer("先看 README.md");
// 等智能体把手上的活做完
await session.followUp("做完之后再总结一下改动");steer() 和 followUp() 直接暴露这两种行为,返回值是 "queued"(消息已排队,包括被扩展转换之后)或 "handled"(被扩展消费掉了)。
取消与等待
// 停掉当前操作,并等待会话进入空闲
await session.abort();
// 只等待,不取消
await session.waitForIdle();abort() 会停掉当前运行并等到会话空闲。CLI 里的 abort() 还会把排队消息退回编辑器——SDK 层面没有编辑器,排队消息仍留在会话里。
替换会话要重新订阅
AgentSessionRuntime 提供 newSession()、switchSession()、fork()、importFromJsonl()。每次操作都会替换当前 AgentSession,并为目标工作目录重建服务。
关键约束:替换之后,旧会话上的订阅属于旧对象,必须重新绑定。
// runtime.ts
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
// 运行时工厂:每建一个会话都要为目标 cwd 重建服务,扩展工厂也要一起传进去
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
// 当前订阅的退订函数。运行时替换会话后旧订阅属于旧对象,必须重新绑定
let unsubscribe: (() => void) | undefined;
async function bindSession() {
// 先退订旧的:否则同一个回调里会同时收到新旧两个会话的事件
unsubscribe?.();
const session = runtime.session;
// SDK 不自动 bindExtensions:MCP 扩展在 session_start 才连服务器,这里必须手动触发
await session.bindExtensions({});
unsubscribe = session.subscribe((event) => {
if (event.type === "queue_update") {
console.log("排队中:", event.steering.length + event.followUp.length);
}
});
return session;
}
let session = await bindSession();
try {
await runtime.newSession();
session = await bindSession(); // 重新绑定,否则收不到新会话的事件
await session.prompt("新会话里的一句问候");
} finally {
unsubscribe?.();
await runtime.dispose();
}RPC 路线:跨进程说话
RPC 模式把 Pi 变成一个长驻子进程,通过 stdin 上的 JSON 命令和 stdout 上的 JSON 响应与事件驱动。
# 启动 RPC 模式的 Pi 子进程:之后从 stdin 发命令,从 stdout 读 JSONL 响应与事件
# --no-session 用内存会话,不落盘;不加它会把会话写进 ~/.pi/agent/sessions/
pi --mode rpc --no-session--no-session 用内存会话,不落盘。常规 CLI 选项仍然用来选工作目录、模型、工具、资源和会话行为,常用的是 --provider、--model、--name、--no-session、--session-dir。
四类记录
关联命令与响应
每条命令都可以带一个可选的字符串 id,匹配的响应会原样带回它:
{"id":"req-1","type":"get_state"}
{"id":"req-1","type":"response","command":"get_state","success":true,"data":{"...":"..."}}命令处理是异步的,所以必须按 ID 关联,不能按响应顺序。 同时有多个命令在飞的时候,ID 是唯一可靠的办法。
会话事件一般没有命令 ID,因为它们描述的是会话活动而不是某一次请求。唯一的例外是 bash_execution_update:当发起它的 bash 命令带 ID 时,输出事件会重复这个 ID。
分帧规则
RPC 用严格的 JSONL 分帧。一条记录一个完整 JSON 对象,用 LF(\n)结尾。 读 stdout 时按字节或 UTF-8 流读取,只在 LF 上切分;前面有可选的回车符要剥掉,以便接受 CRLF 输入。
这里有一个真实的坑:不要用把 Unicode 行分隔符(U+2028)和段分隔符(U+2029)当作记录边界的通用行读取器。Node.js 的 readline 会这么做,而这两个字符在 JSON 字符串里是合法的。Pi 自己的实现也刻意避开了 readline,源码注释写明了这个原因。
还有两条纪律:
- 持续读 stdout。 Pi 遵守 stdout 的背压,但停止读取的客户端会把进程卡住。
- stdout 是协议专用通道。 诊断信息和应用日志一律写 stderr。
一个完整的 RPC 客户端
用 Node.js 写,手动按 LF 分帧:
// rpc-run.ts
import { spawn } from "node:child_process";
const child = spawn("pi", ["--mode", "rpc", "--no-session"], {
stdio: ["pipe", "pipe", "pipe"],
});
// 诊断信息走 stderr,不污染 stdout 的协议流
child.stderr.pipe(process.stderr);
type JsonRecord = Record<string, any>;
let buffer = "";
let settled: (() => void) | undefined;
// 手动按 LF 分帧:readline 会把 U+2028 / U+2029 也当边界,而它们在 JSON 字符串里是合法的
child.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
let index: number;
while ((index = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, index).replace(/\r$/, "");
buffer = buffer.slice(index + 1);
if (line.length === 0) continue;
handleRecord(JSON.parse(line) as JsonRecord);
}
});
function send(command: JsonRecord) {
child.stdin.write(`${JSON.stringify(command)}\n`);
}
function handleRecord(record: JsonRecord) {
if (record.type === "response") {
console.error(`[响应] ${record.command} success=${record.success}`);
return;
}
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta") {
process.stdout.write(record.assistantMessageEvent.delta);
return;
}
if (record.type === "tool_execution_start") {
console.error(`[工具] ${record.toolName}`);
return;
}
if (record.type === "agent_settled") {
settled?.();
}
}
try {
// 先装好等待,再发命令:否则快速完成时可能漏掉 agent_settled
const done = new Promise<void>((resolve) => {
settled = resolve;
});
send({ id: "req-1", type: "prompt", message: "这个仓库的测试怎么跑?" });
// 等 agent_settled 再收尾:agent_end 之后还可能有重试或排队工作
await done;
} finally {
// 关闭 stdin 请求有序退出:Pi 会先释放活动运行时再退出
child.stdin.end();
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
}这段代码有两个刻意为之的地方:先装等待再发命令,以及手写 LF 分帧而不是用 readline。
TypeScript 客户端:RpcClient
如果你的宿主是 Node.js 或 TypeScript,官方导出了 RpcClient:它启动 Pi 子进程、关联响应、暴露有类型的命令方法、把事件送给监听器。
import { RpcClient } from "@earendil-works/pi-coding-agent";
// cliPath 指向一个可运行的 Pi CLI;默认是 "dist/cli.js"
const client = new RpcClient({
cliPath: "/opt/pi/dist/cli.js",
args: ["--no-session"],
});
// 先装监听器再发 prompt:事件在 start() 之后就开始流了
const unsubscribe = client.onEvent((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
// start() 才真正拉起 Pi 子进程,之后才有事件
await client.start();
// promptAndWait 内部自己保证"先装监听器再发 prompt",不会漏掉快速完成
await client.promptAndWait("这个仓库的测试怎么跑?");
} finally {
unsubscribe();
// stop() 会关闭子进程 stdin,让 Pi 走有序退出路径
await client.stop();
}RpcClient.promptAndWait() 内部会先装事件监听器再发 prompt,避免和快速完成竞态。如果你用分开的方法调用,先装监听器,再调 prompt(),并且只在有运行在进行时调 waitForIdle()。
客户端需要一条可运行的 Pi CLI 路径。RpcClient 默认用 dist/cli.js,所以从源码跑要先构建这个包。
决策表:SDK 还是 RPC
两条路线都能做的事是同一件:跑 prompt、拿流式文本、取消、查状态。区别在于你能碰到多少内部能力,以及故障被隔离在哪一层。
如果你同时满足"Node.js 宿主"和"需要进程隔离",两个答案都成立。倾向 SDK 的理由是:少一层序列化、少一个进程要监控。倾向 RPC 的理由是:把不受信任的执行放进一个可以整体 kill 掉的进程里。
两条路线的资源释放差异
两个容易漏的点:
- RPC 关闭 stdin 是"请求"有序退出,不是保证。 官方文档明确说客户端仍然要处理进程信号和意外退出。
- SDK 的
session.dispose()不会静默吞掉正在跑的模型请求——它会中止进行中的工作。但你如果在dispose()之前没有解绑订阅,监听器会被移除而不是被调用。
三条纪律
无论选哪条路:
- 控制输出和普通日志分开。 SDK 里用 stdout 写协议内容、stderr 写日志;RPC 里 stdout 是协议专用通道,诊断走 stderr。把两者混写,客户端就再也解析不了流了。
- 订阅早于动作。 SDK 在
prompt()前订阅,RPC 在发命令前装监听器。 - 释放路径和创建路径对称。 创建了会话就要有对应的释放;创建了子进程就要有关闭 stdin 和处理退出的代码。
你现在能做什么
- 用 SDK 跑完一次 prompt,订阅事件拿到流式文本,并在结束时解绑订阅、释放会话。
- 用 RPC 跑完一次 prompt,按
id关联命令与响应,正确处理命令、响应、事件三者的关系。 - 手写一个 LF 分帧的 stdout 读取器,并验证它不会在 JSON 字符串里的
U+2028/U+2029上误切分。 - 在 SDK 里调用
runtime.newSession()后重新绑定订阅,确认新会话的事件能收到。 - 对着自己的应用条件查一遍决策表,写下选 SDK 还是 RPC 以及理由。