跳到主内容
创建时间:最后更新:作者:白川

SDK 与 RPC:选择应用集成入口

按宿主生命周期、语言栈和故障隔离需求,在进程内 SDK 与跨进程 RPC 之间做一次有依据的选择。

SDK 与 RPC:选择应用集成入口

要把智能体跑进你的应用里,先要回答一个具体问题:它跟你住在同一个进程,还是另一个进程?

Pi 给了两条路。SDK 把智能体嵌进你的 Node.js 或 Bun 进程,你可以直接调方法、直接收事件。RPC 把 Pi 变成一个长驻子进程,你通过 stdin/stdout 上的 JSON 记录跟它说话。

这两条路不是"高级/低级"的关系,而是进程边界在哪儿的关系。选错的代价不是写法麻烦,而是故障隔离、生命周期和语言栈全部要自己重新发明一遍。

读完这篇你能对着决策表说出"我这次该选哪条路",并掌握两条路线各自完整的最小客户端和释放路径。

需要的前置只有一条:会用 Node.js 或 TypeScript,并且知道 Pi 是一个跑在终端里的命令行程序。

先看清 Pi 有几种运行形态

官方把 Pi 的运行形态分成四类 CLI 模式加一类 SDK:

形态接口生命周期适用场景
Interactive
终端 UI
到用户退出为止
人在终端里直接用 Pi
Print
stdout 上的最终文本
一次调用
脚本只要最终回复
JSON
stdout 上的 JSONL 事件
一次调用
进程需要结构化的进度
RPC
stdin 命令 + stdout 响应与事件
长驻
进程需要双向控制
SDK
进程内 TypeScript 方法与事件
由宿主决定
Node.js / Bun 宿主,要完整 API

注意 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,这个对象持有一整段对话、它的模型和工具、排队消息、压缩状态,以及扩展运行时。

typescript
// 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(),你必须明确这条消息是打断当前运行还是等它跑完。不指定就抛错,而不是替你猜:

typescript
// 插到当前助手轮次之后
await session.steer("先看 README.md");

// 等智能体把手上的活做完
await session.followUp("做完之后再总结一下改动");

steer() 和 followUp() 直接暴露这两种行为,返回值是 "queued"(消息已排队,包括被扩展转换之后)或 "handled"(被扩展消费掉了)。

取消与等待

typescript
// 停掉当前操作,并等待会话进入空闲
await session.abort();

// 只等待,不取消
await session.waitForIdle();

abort() 会停掉当前运行并等到会话空闲。CLI 里的 abort() 还会把排队消息退回编辑器——SDK 层面没有编辑器,排队消息仍留在会话里。

替换会话要重新订阅

AgentSessionRuntime 提供 newSession()、switchSession()、fork()、importFromJsonl()。每次操作都会替换当前 AgentSession,并为目标工作目录重建服务。

关键约束:替换之后,旧会话上的订阅属于旧对象,必须重新绑定。

typescript
// 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 响应与事件驱动。

bash
# 启动 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。

四类记录

方向记录用途
stdin
Command
让 Pi 提示、检查状态、改配置、管会话
stdout
response
报告这条命令成没成功,返回命令数据
stdout
Session event
流式输出运行、消息、工具、队列、压缩、重试活动
双向
Extension UI record
在 Pi 和客户端之间转发支持的扩展交互

关联命令与响应

每条命令都可以带一个可选的字符串 id,匹配的响应会原样带回它:

json
{"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 分帧:

typescript
// 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 子进程、关联响应、暴露有类型的命令方法、把事件送给监听器。

typescript
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

你的条件选为什么
宿主是 Node.js 或 Bun
SDK
直接调方法、拿强类型事件,没有序列化损耗
需要完整的会话/扩展/资源 API
SDK
RPC 只暴露命令清单里那些能力
宿主是 Python、Go、Rust 等
RPC
语言无关,只依赖 JSON 协议
要让智能体跑在独立进程里
RPC
崩溃只影响子进程
需要隔离文件系统权限或资源占用
RPC
进程级隔离比同进程隔离可靠
要做 IDE 或自定义界面
RPC
双向命令 + 事件流正是这个场景
只需要一次性的最终文本
Print 模式
不用维护长驻进程
只需要一次性运行的结构化进度
JSON 模式
一次调用,事件流完就退出

两条路线都能做的事是同一件:跑 prompt、拿流式文本、取消、查状态。区别在于你能碰到多少内部能力,以及故障被隔离在哪一层。

如果你同时满足"Node.js 宿主"和"需要进程隔离",两个答案都成立。倾向 SDK 的理由是:少一层序列化、少一个进程要监控。倾向 RPC 的理由是:把不受信任的执行放进一个可以整体 kill 掉的进程里。

两条路线的资源释放差异

维度SDKRPC
释放入口
session.dispose() / runtime.dispose()
关闭子进程 stdin
dispose() 做什么
中止进行中的工作、失效扩展上下文、与 agent 断开、移除事件监听器
Pi 释放活动运行时后退出
取消当前运行
await session.abort()
发送 abort 命令,它会等会话空闲后响应
清空排队消息
队列随会话存在
clear_queue 命令,返回被清掉的文本
强制终止
宿主自行决定
客户端还要处理信号和意外退出
失败可见性
同一进程,异常直接抛给你
子进程启动失败、意外退出、stderr 诊断都要自己处理

两个容易漏的点:

  • RPC 关闭 stdin 是"请求"有序退出,不是保证。 官方文档明确说客户端仍然要处理进程信号和意外退出。
  • SDK 的 session.dispose() 不会静默吞掉正在跑的模型请求——它会中止进行中的工作。但你如果在 dispose() 之前没有解绑订阅,监听器会被移除而不是被调用。

三条纪律

无论选哪条路:

  1. 控制输出和普通日志分开。 SDK 里用 stdout 写协议内容、stderr 写日志;RPC 里 stdout 是协议专用通道,诊断走 stderr。把两者混写,客户端就再也解析不了流了。
  2. 订阅早于动作。 SDK 在 prompt() 前订阅,RPC 在发命令前装监听器。
  3. 释放路径和创建路径对称。 创建了会话就要有对应的释放;创建了子进程就要有关闭 stdin 和处理退出的代码。

你现在能做什么

  • 用 SDK 跑完一次 prompt,订阅事件拿到流式文本,并在结束时解绑订阅、释放会话。
  • 用 RPC 跑完一次 prompt,按 id 关联命令与响应,正确处理命令、响应、事件三者的关系。
  • 手写一个 LF 分帧的 stdout 读取器,并验证它不会在 JSON 字符串里的 U+2028 / U+2029 上误切分。
  • 在 SDK 里调用 runtime.newSession() 后重新绑定订阅,确认新会话的事件能收到。
  • 对着自己的应用条件查一遍决策表,写下选 SDK 还是 RPC 以及理由。

排障速查

现象检查点处理
RPC 收不到任何响应
分帧
是否只在 LF 上切分?是否剥掉了 \r?
RPC 流卡住不再产出
是否持续读 stdout
停止读会阻塞 Pi;改用持续消费
readline 解析出坏 JSON
Unicode 分隔符
readline 会把 U+2028/U+2029 当边界,换成手写 LF 分帧
RPC 响应和命令对不上
关联方式
按 id 关联,不要按到达顺序
换了会话后收不到事件
重新订阅
运行时替换会话后必须重新 bindSession()
prompt() 抛"必须指定 streamingBehavior"
是否在流式输出中
改用 steer() / followUp(),或在 prompt() 里传 streamingBehavior
SDK 里事件漏了一半
订阅时机
订阅要在 prompt() 之前
RpcClient 报找不到 CLI
cliPath
指向构建产物 dist/cli.js;从源码跑要先构建
子进程意外退出
stderr
诊断信息在 stderr,不要当协议数据解析
RPC 启动就报错退出
@file 参数
RPC 模式不接受 @file 形式的 prompt 参数

参考资料