最近在认真看 Pi SDK,想把它真正嵌进自己的程序里。光读官方示例容易囫囵吞枣,所以我从最小的命令行调用开始自己写,打算一步步把它长成支持多会话、SSE、工具策略和持久化的 Web Agent。这篇记第一段做的事:最小会话与模型运行时。
目标看起来很轻——用 OpenAI Chat Completions 兼容服务跑通一个内存会话,订阅文本增量,dispose() 收尾。但真正动手会发现几个容易糊过去的概念得先摆到台面上:ModelRuntime、AgentSession、SessionManager 的边界;「模型已注册」和「模型已有有效认证」是两回事;以及为什么不该直接用无参数的 createAgentSession()。
心智模型:SDK 不是黑盒 run()
先看官方最小调用长什么样:
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?");这段代码已经体现了 SDK 的形状:它没有提供一个吞进 prompt、吐出答案的黑盒 run(),而是把编码 Agent 拆成一组可以独立替换的部件。这一篇只碰到三个:
| 部件 | 这一篇的职责 |
|---|---|
ModelRuntime | 模型注册表、凭证解析、可用性检查 |
AgentSession | 一次会话的 prompt、状态、事件流 |
SessionManager | 会话树持久化与恢复(这里用纯内存) |
往后还会陆续接上 SettingsManager(重试、压缩等设置)、ResourceLoader(system prompt、扩展、Skills)和 AgentSessionRuntime(替换活动会话)。这些部件每一个都有 inMemory() 变体,单元测试可以在无文件系统、无凭证的环境里构造完整 Agent 会话——这也是为什么从第一步就敢跑真模型,部件都能隔离,不会污染主机。
ModelRuntime:注册 ≠ 有认证
这里有个容易踩的点:ModelRuntime 上两个看起来像、其实不同的查询。
getModel(providerId, modelId) 只查注册表,不验证认证是否可用;getAvailable() 只返回具备有效认证的模型。一个模型可以「注册成功」但同时「没有有效认证」——比如 models.json 里写了它的 id,但对应的 API key 没配。这两个状态在跑真请求之前就得分开判断,否则你会把「key 没配」错报成「模型不存在」。
我的 smoke.ts 里这一段就是把这个区别打出来:
const model = modelRuntime.getModel(providerId, modelId);
if (!model) throw new Error(`Model not registered: ${providerId}/${modelId}`);
console.log(`Registered: ${model.provider}/${model.id}`);
const available = await modelRuntime.getAvailable();
console.log(
"Available: ",
available.length ? available.map((m) => `${m.provider}/${m.id}`).join(", ") : "(empty)",
);
const isAvailable = available.some((m) => m.id === modelId);
console.log(`Configured model is available: ${isAvailable}`);isAvailable 才是后面能不能真跑的判据。我管这一步叫「启动诊断」:先看清模型在不在注册表、有没有认证,再去建会话,别把问题留到第一次请求时报一个看不懂的错。
最小会话的固定顺序
确认模型可用之后,交互的顺序是固定的:构造 runtime → 创建 session → subscribe → prompt → dispose。prompt() 等待整个 Agent run 结束,订阅负责实时展示。
const { session } = await createAgentSession({
model,
modelRuntime,
thinkingLevel: "off",
tools: ["read"],
sessionManager: SessionManager.inMemory(),
});
try {
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("Say hello in one sentence.");
} finally {
session.dispose();
}两个地方容易漏。
一是 dispose() 放在 finally 里。它释放订阅、模型连接等资源,保证进程能干净退出。Agent run 中途抛错也不能漏掉它,否则进程可能挂住。
二是订阅要「收敛」。我第一版先把所有事件类型都打一行标记,用来观察生命周期:message_start / message_update(里面再分 text_delta、thinking_delta)/ message_end / agent_end。看清有哪些事件之后,再收敛成只把 text_delta 实时写到 stdout。这样输出是增量流,不是等整轮结束一次性打印——后者看不出 SDK 在流式这件事上给了你什么。
安全基线:为什么第一步就要「别用默认」
先问一个该想清楚的问题:为什么不能直接用无参数的 createAgentSession()。无参调用会落到真实的 ~/.pi/agent:用持久化 session、加载默认工具集、读取本机凭证、还会沿 cwd 向上发现项目级扩展。第一个实验就继承一整套主机的状态和信任边界,不可复现,也不安全。
所以我的代码做了三件隔离:
专用 agentDir。 在项目里建 .data/pi-agent,权限 0o700,把 models.json、auth.json 都关在里面,和真实 ~/.pi/agent 彻底隔开。
key 用 $ENV_VAR 引用,不落盘也不进 argv。 models.json 里的 apiKey 写成 "$COURSE_OPENAI_API_KEY",由 ModelRuntime 解析时替换成环境变量的值。代码里显式不读取这个变量的值,避免 key 进入进程内存的显式变量:
const modelsPath = path.join(agentDir, "models.json");
await writeFile(
modelsPath,
JSON.stringify({
providers: {
[providerId]: {
baseUrl,
api: "openai-completions",
apiKey: "$COURSE_OPENAI_API_KEY",
compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
models: [
{ id: modelId, name: `${modelId} (course endpoint)`, reasoning: false, input: ["text"] },
],
},
},
}, null, 2) + "\n",
{ mode: 0o600 },
);只读默认 + 内存会话。 tools: ["read"] 只启用只读工具,SessionManager.inMemory() 显式避开本机会话树。本地 Ollama 这种不需要 key 的服务也得给一个占位值,因为 Pi 只把已配置认证的模型视为可用——这条在这一步就能验证到。
进阶:--model provider/id 与三道错误闸门
跑通最小会话之后,我想再加一个 --model provider/id 参数,并刻意给出三类不同的错误:格式非法、模型未注册、模型缺少认证。key 不能进命令行,它会出现在 shell history、ps、argv 里,三类错误信息也都不能打印 key 值。
我把校验做成三道闸门,依次判断、各自给一个退出码:
function parseModelArg(argv: string[]): ModelArg {
const idx = argv.indexOf("--model");
if (idx === -1) fail("Missing required argument: --model provider/id", 2);
const raw = argv[idx + 1];
if (!raw) fail("--model requires a value in the form provider/id", 2);
// 格式校验:恰好一个 '/',两侧都非空。多一个 '/' 也算非法。
const slashCount = raw.split("/").length - 1;
if (slashCount !== 1) fail(`Invalid model format: ${JSON.stringify(raw)} (expected "provider/id")`, 3);
const [providerId] = raw.split("/");
if (!providerId) fail(`Invalid model format: ${JSON.stringify(raw)} (provider and id must be non-empty)`, 3);
return { providerId, modelId: raw.slice(providerId.length + 1) };
}闸门一是格式:--model 必须是 provider/id,恰好一个 /,两侧非空。foo、foo/bar/baz 都在这里挡掉(退出码 3)。
闸门二是模型未注册。getModel() 只查注册表、不验证认证,正好用来做这一道:
const model = modelRuntime.getModel(providerId, modelId);
if (!model) fail(`Model not registered: ${providerId}/${modelId}`, 4);闸门三是模型缺少认证。模型已注册,但 provider 没有有效凭证,用 checkAuth() 单独判:
const auth = await modelRuntime.checkAuth(providerId);
if (!auth) fail(`Model has no valid auth: ${providerId}/${modelId} (set the provider's API key in the environment)`, 5);三道闸门都通过,才进入实跑。这里有个设计上的小取舍:models.json 里固定写配置 provider 的 baseUrl 和模型,但 --model 允许指向任意 provider/id(包括内置 provider,比如 anthropic/claude-...)。这样「模型未注册」和「缺少认证」才能被真实地触发出来——只允许配置的那个 provider 的话,闸门二永远走不到。
把 key 当命令行参数,是这类工具最常见的泄漏点。三道闸门把「你给的东西格式对不对」「这个东西我认不认识」「它能不能真的用」拆开,每一步的报错都和 key 无关,key 始终只通过 models.json 里的 $COURSE_OPENAI_API_KEY 间接引用。
跑起来能看到什么
smoke.ts 末尾留了一段诊断,把 session.state.messages 每条消息的 role、stopReason、内容块和工具调用都打出来。这是为了解释两件事:为什么有时候看不到 text_delta,为什么一次 prompt 之后会出现两段 message。
for (const [i, msg] of session.state.messages.entries()) {
const role = msg.role ?? "(unknown)";
const stop = "stopReason" in msg ? msg.stopReason : "";
const blocks = Array.isArray(msg.content)
? msg.content.map((b) => `${b.type}:${JSON.stringify(b).slice(0, 160)}`)
: [typeof msg.content === "string" ? `text:${msg.content.slice(0, 160)}` : `(no blocks)`];
const tools = msg.toolCalls?.length ? msg.toolCalls.map((t) => t.name).join(",") : "";
console.log(`[${i}] role=${role} stop=${stop ?? ""} tools=${tools}`);
for (const b of blocks) console.log(` - ${b}`);
}把 .env 里的 key 临时清空再跑一次正确的 model,对照「启动诊断」和「模型请求错误」的区别,正好是上面「注册 ≠ 有认证」的现场版:注册检查照过,getAvailable() 变空,会话阶段被跳过,错误信息里不会出现 key。
小结
这一段真正立住的不是「跑通了一个 hello」,而是三件事:部件边界(ModelRuntime / AgentSession / SessionManager 各管什么)、注册与认证的区别(getModel vs getAvailable / checkAuth)、一条安全基线(专用 agentDir、$ENV_VAR 引用 key、只读默认、内存会话)。后续在这之上加 SSE、工具与安全边界、资源加载、扩展、持久化和多会话 Web API,但底子是这一段打的。
代码放在本地仓库里,两个文件是 smoke.ts(最小会话 + 启动诊断)和 homework.ts(--model 参数 + 三道错误闸门)。下一篇记事件流与 SSE。
参考
代码固定使用 @earendil-works/[email protected],避免上游快速变化破坏可复现性。