青蛙小白
博客 / 2026/08

Pi SDK 学习笔记(一):最小会话与模型运行时

2026/08/02 · — 字 · 阅读约 — 分钟 ·
目录

最近在认真看 Pi SDK,想把它真正嵌进自己的程序里。光读官方示例容易囫囵吞枣,所以我从最小的命令行调用开始自己写,打算一步步把它长成支持多会话、SSE、工具策略和持久化的 Web Agent。这篇记第一段做的事:最小会话与模型运行时。

目标看起来很轻——用 OpenAI Chat Completions 兼容服务跑通一个内存会话,订阅文本增量,dispose() 收尾。但真正动手会发现几个容易糊过去的概念得先摆到台面上:ModelRuntimeAgentSessionSessionManager 的边界;「模型已注册」和「模型已有有效认证」是两回事;以及为什么不该直接用无参数的 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_deltathinking_delta)/ message_end / agent_end。看清有哪些事件之后,再收敛成只把 text_delta 实时写到 stdout。这样输出是增量流,不是等整轮结束一次性打印——后者看不出 SDK 在流式这件事上给了你什么。

安全基线:为什么第一步就要「别用默认」

先问一个该想清楚的问题:为什么不能直接用无参数的 createAgentSession()。无参调用会落到真实的 ~/.pi/agent:用持久化 session、加载默认工具集、读取本机凭证、还会沿 cwd 向上发现项目级扩展。第一个实验就继承一整套主机的状态和信任边界,不可复现,也不安全。

所以我的代码做了三件隔离:

专用 agentDir。 在项目里建 .data/pi-agent,权限 0o700,把 models.jsonauth.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、psargv 里,三类错误信息也都不能打印 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,恰好一个 /,两侧非空。foofoo/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 每条消息的 rolestopReason、内容块和工具调用都打出来。这是为了解释两件事:为什么有时候看不到 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],避免上游快速变化破坏可复现性。

评论