把开源编程 Agent pi 嵌进自己的程序:三条集成路线怎么选

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

这三条路线不是同一件事的三种写法,而是三种不同的进程边界,选型的第一个问题不是「哪个功能全」,而是「你的程序和 Agent 要不要待在同一个 Node.js 进程里」。 这个问题定了,后面的类型安全、状态访问、崩溃隔离、跨语言可行性全都跟着定,几乎没有回旋余地。反过来说,如果你先从「我要流式输出」「我要自定义工具」这类需求出发去挑,很容易挑到一条要写一堆胶水才能凑合的路。

pi 是 earendil-works 的开源编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。它的 CLI 包名是 @earendil-works/pi-coding-agentpackages/coding-agent/package.jsonbin 字段把命令名注册为 piexports 里除了主入口,还单独开了一个 ./rpc-entry 子路径,这个细节本身就透露出:命令行、SDK 和 RPC 三种用法在打包阶段就被当成三个入口对待了。

站内已有两篇讲通用方法论的文章:Agent SDK 与自建框架怎么选讲的是抽象层次上的取舍,API 接入方式对比讲的是接入形态的一般分类;这一篇不重复那些判据,只做一件事——把一个真实开源项目的官方集成路线逐条拆开,让你能对着仓库文件核对每一句话。

一、三条路线的形态差异

pi 的 packages/coding-agent/docs/ 目录下有三份文档直接对应三条路线:sdk.mdrpc.mdjson.md。它们的形态是这样的:

SDK 路线是一个 npm 包。npm install @earendil-works/pi-coding-agent 之后,你在自己的 TypeScript 代码里 import createAgentSession(),Agent 跑在你的进程里,你拿到的是对象和回调,不是字符串。

RPC 路线是一个子进程。你启动 pi --mode rpc,然后往它的 stdin 写 JSON 行、从 stdout 读 JSON 行。这条路线不要求你用 TypeScript,rpc.md 里给的第一个完整客户端示例就是 Python 的 subprocess.Popen

JSON 事件流路线更简单:pi --mode json "Your prompt",跑一次,把全过程的事件按 JSON 行吐到 stdout,然后进程退出。它是单向的——你只能在启动时给一次输入,之后就只有读。

docs/usage.md 的模式表里还列了 -p / --print(打印结果后退出)和 --export <in> [out](把会话导出成 HTML)。-p--mode json 共用同一份实现,源码在 src/modes/print-mode.ts,它的 PrintModeOptionsmode 字段只有 "text""json" 两个取值,注释写得很直白:text 只输出最终回复,json 输出全部事件。

下面这张表是三条路线各自会碰到的东西:

组成部分它负责什么对应仓库位置你什么时候会碰到它
createAgentSession()造一个会话,装配模型、工具、资源加载器、会话存储packages/coding-agent/docs/sdk.md走 SDK 路线的第一行代码
AgentSession会话生命周期、消息历史、压缩、事件订阅packages/coding-agent/src/core/agent-session.tsprompt()subscribe()
AgentSessionRuntime替换当前活跃会话(新建、切换、fork、导入)packages/coding-agent/docs/sdk.md你的程序要做「开新会话」按钮时
DefaultResourceLoader发现扩展、技能、提示词模板、主题、上下文文件packages/coding-agent/docs/sdk.md想改系统提示词或注入自定义技能时
ModelRuntime模型目录与凭据解析packages/coding-agent/docs/sdk.md需要指定模型或注入 API key 时
RPC 命令与事件协议子进程的 JSON 报文契约packages/coding-agent/docs/rpc.md用非 TypeScript 语言集成时
JSONL 收发实现严格按 LF 切行的读写packages/coding-agent/src/modes/rpc/jsonl.ts自己写客户端解析时的参照
打字化 RPC 客户端现成的 TypeScript 子进程客户端packages/coding-agent/src/modes/rpc/rpc-client.ts用 TS 但仍想要进程隔离时
runPrintMode单次跑完、输出、退出packages/coding-agent/src/modes/print-mode.ts做 CI 任务或批处理脚本时
SDK 示例集从最小到全控制的 13 个可运行示例packages/coding-agent/examples/sdk/第一次接的时候照着抄

二、同进程 SDK:装配的粒度到底有多细

sdk.md 的 Quick Start 只有几行:

import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  modelRuntime,
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("What files are in the current directory?");

短是短,但这几行里已经藏了三个可替换的插槽。sessionManager 决定会话存不存盘:SessionManager.inMemory() 不落盘,SessionManager.create(cwd) 建新的持久会话,SessionManager.continueRecent(cwd) 接着最近一次,SessionManager.open(path) 打开指定文件。modelRuntime 决定模型从哪来、凭据怎么解析。第三个插槽在参数里没出现——resourceLoader,不传就用 DefaultResourceLoader 走标准发现流程。

工具这块的粒度值得单独说。内置工具一共七个:readbasheditwritegrepfindls,默认只开前四个。你可以用 tools 给白名单,用 excludeTools 在白名单之后再排除具体名字,noTools: "all" 关掉全部,noTools: "builtin" 只关内置的、保留扩展和自定义工具。文档里给了一个只读模式的写法,就是 tools: ["read", "grep", "find", "ls"]。自定义工具走 defineTool(),用 typebox 的 Type.Object 声明参数,然后塞进 customTools 数组;这里有个容易漏的约束:如果你同时传了 tools 白名单,白名单里必须把自定义工具的名字也写上,否则它不会被启用。

edit 工具的返回结构做了一个对集成方友好的区分:details.diff 是给 pi 自己的终端界面显示用的,details.patch 是标准 unified patch,明确写着给 SDK 消费者用。你要把改动落到自己的系统里,用后者。

真正容易踩的是会话替换。AgentSession 上没有「新建会话」「切换会话」这些方法,sdk.md 里写得很明确:这类 API 在 AgentSessionRuntime 上,不在 AgentSession 上。用 createAgentSessionRuntime() 造出 runtime 之后,newSession()switchSession()fork()importFromJsonl() 都会把 runtime.session 换成一个新对象。而事件订阅是绑在具体某个 AgentSession 上的,所以文档专门给了这段:

let session = runtime.session;
let unsubscribe = session.subscribe(() => {});

await runtime.newSession();

unsubscribe();
session = runtime.session;
unsubscribe = session.subscribe(() => {});

如果你用了扩展,替换之后还得对新 session 再调一次 bindExtensions(...)。这两件事忘了做,表现出来就是「新建会话之后界面不动了」,而且不报错。

模型与鉴权那层的解析顺序也是明写的:运行时覆盖(setRuntimeApiKey,不落盘)优先于 auth.json 里的存储凭据,再优先于环境变量(ANTHROPIC_API_KEYOPENAI_API_KEY 之类),最后才是 models.json 自定义 provider 的兜底解析器。ModelRuntime.create() 可以传 authPathmodelsPath 指到你自己的位置,也可以直接注入一个 pi-ai 的 CredentialStore,文档里用 InMemoryCredentialStore 举了例。这一点在你要把 pi 嵌进多租户服务时很关键——凭据不必落到用户主目录。至于海外模型服务商,官方对中国大陆存在区域限制、不支持直连,市面上有第三方中转但这里不做背书也不给具体渠道,各家规则不同且会调整,以官方最新说明为准。关于密钥怎么存怎么轮换,可以看API 密钥安全管理

三、子进程 RPC:协议契约里的几个硬约定

RPC 模式启动方式是 pi --mode rpc,常用参数包括 --provider--model--name/-n(设置会话显示名)、--no-session(不持久化)、--session-dir(自定义会话目录)。

协议分三类报文:你写进 stdin 的是命令(command),带 type: "response" 的是命令的成败回执,其余的是事件(event)。命令都可以带一个可选的 id,回执会带回同一个 id;事件通常不带 id,只有 bash_execution_update 会带上发起它的那条 bash 命令的 id

第一个硬约定是分帧。 rpc.md 的 Framing 一节说得很重:只用 LF(\n)作记录分隔符,客户端要按 \n 切,输入里如果有 \r\n 就把尾部的 \r 剥掉。然后是一句点名批评:Node 的 readline 对本协议不合规,因为它还会在 U+2028U+2029 上切分,而这两个字符在 JSON 字符串内部是合法的。仓库里 src/modes/rpc/jsonl.ts 导出的 attachJsonlLineReader 就是按这个约定实现的,rpc.md 末尾那段 Node 示例也是手写缓冲区找 \n,不用 readline。这是那种平时跑一百次都不出事、某天模型输出里带了一个特殊字符就整条流错位的坑。

第二个硬约定是「接受」不等于「完成」。 prompt 命令的回执在 prompt 被接受、入队或处理之后就发出来了,事件继续异步流。success: true 只表示接受,success: false 表示在接受前被拒;接受之后的失败,走的是正常事件与消息流,不会为同一个请求 id 再发第二条 response。SDK 那边有一个对应物,PromptOptions 里的 preflightResult(success) 回调,语义完全一致。

第三个是 agent_end 和 agent_settled 的区别。 事件表里写得清楚:agent_end 是「一次低层 agent run 完成」,后面还可能跟着重试、压缩或队列里的续跑;agent_settled 才是「整个 run 彻底安定,不会再有自动重试、压缩重试或排队消息」。如果你的程序是靠这个信号去点亮「完成」状态、去释放锁、去发通知,那盯错事件的结果就是状态反复横跳。顺带一提,rpc.md 里那个 Python 示例是拿 agent_end 跳出循环的,那是最小演示,不是生产写法。

命令面比想象中宽。除了 promptsteerfollow_upabort,还有状态类的 get_stateget_messagesget_session_stats;模型类的 set_modelcycle_modelget_available_models;思考档位的 set_thinking_levelcycle_thinking_levelget_available_thinking_levels;队列策略的 set_steering_modeset_follow_up_mode(都支持 "all""one-at-a-time",默认后者);压缩与重试的 compactset_auto_compactionset_auto_retryabort_retry;会话树的 switch_sessionforkcloneget_fork_messagesget_entriesget_tree;以及 bashabort_bashexport_htmlset_session_nameget_commands

其中 bash 命令的语义要看清楚:它立刻执行并返回结果,同时在 agent 的消息状态里生成一条 BashExecutionMessage,但这条输出要等到下一次 prompt 才会进入模型上下文。文档明确说了两点推论:一是 bash 输出在下一次 prompt 时才进上下文而不是立刻;二是你可以在一次 prompt 前连续跑多条 bash,输出会一起带进去。想不清楚这一点,很容易写出「让模型看一眼命令结果」却发现它没看到的调用序列。

get_entries 值得单独夸一句设计:会话是一棵 append-only 的树,entry 的 id 是稳定的,所以 entry id 天然就是一个持久游标——把你见过的最后一个 id 当 since 传回去,就只拿之后的新条目,跨客户端重启也成立。响应里还有 leafId,一次往返就能判断活跃分支有没有移动。它和 get_messages 的区别是包含压缩前的历史和被放弃的分支。做审计、做回放、做断线续传的,用它。这块跟Agent 复现与回放讲的思路是一致的。

还有一层容易被忽略的:扩展 UI 子协议。扩展调 ctx.ui.select() / confirm() / input() / editor() 时,RPC 模式会往 stdout 发 extension_ui_request 并阻塞,等你从 stdin 回一条 id 匹配的 extension_ui_response;而 notifysetStatussetWidgetsetTitleset_editor_text 是发完就走,不等回复。对话类请求如果带了 timeout 字段,超时后 agent 侧会自己用默认值兜底,客户端不用管计时。这意味着:你只要打算在 RPC 下跑任何会问用户话的扩展,就必须实现这个子协议,否则那条扩展会一直卡在那里等。

四、单次 JSON 事件流:最短的那条路

json.md 全文很短,因为这条路线本身就没什么可配的。pi --mode json "Your prompt" 跑一次,stdout 的第一行是会话头:

{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}

之后是事件流,agent_start / turn_start / message_start / message_update / message_end / turn_end / agent_end 依次出现。文档给的用法示例是直接管到 jq:

pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'

事件类型定义在 AgentSessionEvent 上,它由基础的 AgentEvent(agent、turn、message、tool 四组生命周期)再并上会话层的几种:queue_updatecompaction_start / compaction_endauto_retry_start / auto_retry_end、以及三个 summarization_retry_*json.md 特意点了一句:queue_update 每次变化都会把完整的待发 steering 和 follow-up 队列吐出来,compaction_start / compaction_end 手动和自动压缩都覆盖。

打印模式还有一个很实用的小设计:docs/usage.md 里写了,print 模式会读管道进来的 stdin 并把它合并进初始 prompt,也就是 cat README.md | pi -p "Summarize this text" 这种写法直接成立。做 CI 检查、做批量脚本,这条路线的接入成本几乎是零。

五、边界与代价:它明确不管的那些事

docs/usage.md 的 Design Principles 一节把话说在了明处:pi 把核心做小,工作流相关的东西推到扩展、技能、提示词模板和包里。它有意不内置 MCP、不内置下级 agent 编排(sub-agents)、不内置权限确认弹窗、不内置 plan mode、不内置待办列表、不内置后台 bash;文档给的替代方案是你自己写扩展或装包,或者用容器、tmux 这类外部工具。

对集成方来说,这句话要翻译成三条实际代价:

其一,权限与审批要你自己兜。 没有内置的权限弹窗,意味着「这条 bash 该不该跑」这个判断不在 pi 的默认路径里。RPC 模式下扩展可以用 select 对话框来问,但那是你写的扩展加你实现的子协议共同凑出来的,不是开箱能力。这一层怎么设计,参考Agent 最小权限设计

其二,RPC 模式下一部分终端能力是降级的。 rpc.md 列了一张清单:custom() 返回 undefinedsetWorkingMessage()setWorkingIndicator()setFooter()setHeader()setEditorComponent()setToolsExpanded() 都是空操作;getEditorText() 返回空串;getToolsExpanded() 返回 falsepasteToEditor() 降级成 setEditorText()getAllThemes() 返回空数组;getTheme() 返回 undefinedsetTheme() 直接返回失败。同时 ctx.mode"rpc"ctx.hasUItrue——因为对话方法确实能用。所以想判断「是不是真终端」,文档给的判据是 ctx.mode === "tui",不是看 hasUI。这个反直觉的点,不看文档基本猜不到。

其三,有些数字是估算,不是权威值。 compact 返回的 estimatedTokensAfter 文档写明是压缩后重建消息上下文的启发式估计,不是服务商精确计数。get_session_stats 里的 contextUsage 在没有模型或拿不到上下文信息时会整个缺失;即便存在,压缩刚结束、还没有新的助手响应回填 usage 之前,contextUsage.tokenspercent 都是 null。你要拿这些数做计费、做告警阈值,得先接受它们会短暂为空。

还有两条更偏架构的边界。SDK 路线里事件订阅绑死在具体 AgentSession 实例上,会话一换就得重订,这在前面已经说过;SettingsManager 的写入是异步排队的,同步 setter 之后要 await settingsManager.flush() 才有持久化保证,而且它不打印设置 I/O 错误,要你自己 drainErrors() 拿出来上报。这两件事都是那种「不知道就一定会碰到一次」的类型。

至于路线本身怎么选,sdk.md 给了两组判据,可以直接照抄:想要类型安全、和 Agent 同进程、需要直接访问 agent 状态、想用代码定制工具与扩展,用 SDK;从别的语言集成、想要进程隔离、要做语言无关的客户端,用 RPC。加一条我的补充:如果你的场景是「跑一次、拿结果、结束」,别急着上前两条,先试 --mode json-p

六、上手清单:每条都对应一个真会踩的坑

readline 读 RPC 输出。 会踩是因为它看起来完全对,一般输入下也确实对;出问题只在模型输出恰好含 U+2028 / U+2029 时,而那时你已经上线了。避法:按仓库 src/modes/rpc/jsonl.ts 的做法自己维护缓冲区、只按 \n 切、剥掉行尾的 \r。TypeScript 项目可以直接参照 src/modes/rpc/rpc-client.ts

promptsuccess: true 当成任务完成。 会踩是因为这个字段名字太像终态。它只表示「被接受/入队/已处理」。避法:完成状态盯 agent_settled;如果你确实需要区分单次 run 结束,才用 agent_end,并检查它的 willRetry

流式中直接再发 prompt。 会踩是因为交互式界面里用户就是会连着敲。SDK 会抛错,RPC 会返回错误响应。避法:显式带上 streamingBehavior"steer" 表示当前助手轮把工具调用跑完、下一次模型调用之前插进去,"followUp" 表示等 agent 完全停下再发;或者直接调 steer() / followUp()。另外,扩展命令(/xxx 形式)是例外——它们即使在流式中也立刻执行,但反过来说,steer()followUp() 不接受扩展命令,只接受会被展开的提示词模板。

新建/切换会话之后界面不更新。 会踩是因为 runtime.newSession() 之类不报错,只是悄悄换掉了 runtime.session。避法:把「取消订阅 → 取回新 session → 重新订阅 →(有扩展的话)重新 bindExtensions」封成一个函数,所有替换操作都走它。

自定义工具没生效。 会踩是因为你传了 customTools,也传了 tools 白名单,但白名单里只写了内置工具名。避法:白名单是对所有来源统一生效的,自定义工具和扩展工具的名字都得列进去,例如 tools: ["read", "bash", "my_tool"]

在 RPC 里让模型「看一眼」bash 结果。 会踩是因为 bash 命令立刻返回了输出,人很自然以为模型也看到了。实际要等下一次 prompt 才会被转成用户消息带进上下文。避法:把 bash 当成「预置上下文」的手段,先跑若干条,再发 prompt;需要模型自己决定跑什么命令,那就让它调 bash 工具,不是你发 bash 命令。

在服务里直接用默认凭据路径。 会踩是因为默认会去 ~/.pi/agent/auth.json,本机开发一切正常,上了多租户服务就串了。避法:ModelRuntime.create({ authPath, modelsPath }) 指到隔离位置,或者注入自己的 CredentialStore;临时密钥用 setRuntimeApiKey(),它不落盘。

忘了资源发现是跟着 cwd 走的。 会踩是因为你的服务进程的工作目录未必是用户项目目录。DefaultResourceLoader 会按 cwd.pi/extensions/.pi/skills/.agents/skills/(向上找到 git 仓库根)、.pi/prompts/,以及从 cwd 逐级向上查找的 AGENTS.md。避法:显式传 cwd,并且注意——一旦你传了自定义 ResourceLoadercwdagentDir 就不再控制资源发现了,只还影响会话命名和工具路径解析。

收束

真要落地,按这个顺序自检一遍:进程边界定了没(同进程还是子进程);完成信号盯的是不是 agent_settled;分帧是不是只按 LF 切;会话替换后有没有重订阅;工具白名单有没有漏掉自定义工具;凭据和 cwd 有没有指到隔离位置;权限确认这层由谁兜。

接下来读哪个文件,取决于你选了哪条路。走 SDK 的,先把 packages/coding-agent/examples/sdk/ 下的 01-minimal.ts 跑通,再直接跳到 13-session-runtime.ts 看会话替换是怎么组织的,中间那几个按需查。走 RPC 的,packages/coding-agent/src/modes/rpc/rpc-types.ts 是命令、响应和扩展 UI 报文的类型真源(事件类型不在这里,AgentSessionEvent 定义在 src/core/agent-session.ts,它并进来的基础事件 AgentEventpackages/agent/src/types.ts),rpc-client.ts 是一份可以照抄的客户端实现。只是想跑批处理的,docs/json.md 那一页看完就够了。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的扩展、技能、提示词模板与包该怎么选开源编程 Agent pi 的会话怎么存、怎么续、怎么翻回去看

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。