- 本文基于
@earendil-works/pi-ai1.1.0(2026-10-07 发布,源码 commit6fb2e78)。 - pi-ai 的 API 经历过一次重构:早期的全局写法
getModel()/stream()改成了现在的Models集合(createModels()加上各提供方的工厂函数)。旧写法仍然可以通过@earendil-works/pi-ai/compat入口使用,但官方说明将在未来版本中移除1。网上较早的教程大多用的是旧写法,阅读时要注意。 - 文中标注为“✅ 已实际运行”的代码,笔者用 TypeScript 7.0.2 的 strict 模式做过类型检查,并且借助 faux provider(模拟模型)在本地实际跑通;其余代码只做了类型检查。
- 资料截至 2026-10-09,本文不随官方同步更新,请以 pi-ai README 为准。
1. 为什么需要一个统一层#
如果直接使用各家的官方 SDK,你会遇到以下差异(详见 对照表):
| 差异点 | OpenAI Responses | Anthropic Messages | 其他厂商 |
|---|---|---|---|
| 消息结构 | 一组 item | 一组 content block | 各不相同 |
| 工具调用的参数 | JSON 字符串 | 已解析的对象 | 各不相同 |
| 推理内容 | reasoning item,可能是加密的 | thinking block,带 signature | 有的厂商根本没有 |
| 流式事件 | response.* | message_*、content_block_* | 各不相同 |
| 状态 | 可以存储在服务端 | 无状态 | 通常无状态 |
pi-ai 的做法是:在你的代码和厂商的 API 之间,加一层统一的数据模型。
提供方(provider)是运行时单位,每个提供方拥有自己的模型目录、鉴权方式和流式行为。多个提供方会共用底层的协议实现:Anthropic 的模型走 anthropic-messages,OpenAI 走 openai-responses,xAI、Groq、Cerebras、OpenRouter 等大多数提供方共用 openai-completions1。
2. 快速上手(✅ 已实际运行)#
下面这段代码用 faux provider 代替真实模型,所以没有 API Key 也能运行,适合用来理解数据流:
import { createModels, fauxAssistantMessage, fauxProvider, fauxText, fauxThinking, fauxToolCall, Type, type Context, type Tool,} from "@earendil-works/pi-ai";
const faux = fauxProvider();const models = createModels();models.setProvider(faux.provider);const model = faux.getModel();
const tools: Tool[] = [ { name: "get_time", description: "获取当前时间", parameters: Type.Object({ timezone: Type.Optional(Type.String({ description: "时区,如 Asia/Shanghai" })) }), },];
// Context 是纯数据:系统提示词、消息、工具,可以直接用 JSON 序列化const context: Context = { systemPrompt: "你是一个简洁的助手。", messages: [{ role: "user", content: "现在几点?", timestamp: Date.now() }], tools,};
faux.setResponses([ fauxAssistantMessage([fauxThinking("需要调用 get_time。"), fauxToolCall("get_time", { timezone: "Asia/Shanghai" })], { stopReason: "toolUse", }), fauxAssistantMessage([fauxText("现在是北京时间下午 3 点。")]),]);
// 第 1 次请求:流式消费统一事件const s = models.stream(model, context);for await (const event of s) { if (event.type === "thinking_delta") process.stdout.write(`[thinking] ${event.delta}\n`); if (event.type === "toolcall_end") console.log("toolcall_end:", event.toolCall.name, event.toolCall.arguments); if (event.type === "done") console.log("done:", event.reason);}const first = await s.result();context.messages.push(first);
// 执行工具,把结果作为 toolResult 消息追加for (const call of first.content.filter((b) => b.type === "toolCall")) { context.messages.push({ role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text: "15:00" }], isError: false, timestamp: Date.now(), });}
// 第 2 次请求:非流式const second = await models.complete(model, context);context.messages.push(second);console.log("回答:", second.content.filter((b) => b.type === "text").map((b) => b.text).join(""));console.log("usage:", second.usage.input, "in /", second.usage.output, "out");
// Context 可以序列化保存,之后换任意模型继续const restored: Context = JSON.parse(JSON.stringify(context));console.log("序列化后消息数:", restored.messages.length);在本地实际运行的输出如下:
[thinking] 需要调用 get_time。toolcall_end: get_time { timezone: 'Asia/Shanghai' }done: toolUse回答: 现在是北京时间下午 3 点。usage: 70 in / 4 out序列化后消息数: 4faux provider 的 usage 是估算值,大约每 4 个字符算 1 个 token1。
3. 接入真实模型#
import { createModels, type Context } from "@earendil-works/pi-ai";import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
// 按需注册提供方:每个提供方都是一个独立的子路径,打包时只会引入用到的那些const models = createModels();models.setProvider(anthropicProvider()); // 鉴权:环境变量 ANTHROPIC_API_KEY、已存储的凭据,或 OAuthmodels.setProvider(openaiProvider()); // 鉴权:OPENAI_API_KEY 等
const claude = models.getModel("anthropic", "claude-opus-5-5");if (!claude) throw new Error("模型不在目录里");
const context: Context = { systemPrompt: "你是一个资深后端工程师。", messages: [{ role: "user", content: "解释一下什么是幂等性。", timestamp: Date.now() }],};
// completeSimple 加上统一的 reasoning 档位,各家的推理参数由 pi-ai 负责翻译const reply = await models.completeSimple(claude, context, { reasoning: "medium" });for (const block of reply.content) { if (block.type === "thinking") console.log("[思考]", block.thinking); if (block.type === "text") console.log(block.text);}console.log(`费用:$${reply.usage.cost.total.toFixed(4)}`);| API | 作用 |
|---|---|
createModels() + setProvider() | 按需组装一个提供方集合,利于 tree shaking |
builtinModels()(从 providers/all 导入) | 一次注册全部内置提供方,代价是体积较大1 |
models.getModel(provider, id) | 同步查询模型目录 |
stream() / complete() | 流式或一次性请求,可以传入各提供方专属的参数 |
streamSimple() / completeSimple() | 使用统一的 reasoning 档位:minimal、low、medium、high,以及部分模型支持的 xhigh、max1 |
models.getAuth(provider) | 在不发请求的情况下查看鉴权是怎么解析的 |
鉴权:每个提供方各自负责鉴权,包括 API Key 的解析、OAuth 登录和刷新。请求时显式传入的 apiKey 优先级最高1。部分提供方支持用订阅账号登录(OAuth),例如 GitHub Copilot1。
4. 统一的流式事件#
| 事件 | 含义 | 关键字段 |
|---|---|---|
start | 流开始 | partial:最初的 assistant 消息结构 |
text_start / text_delta / text_end | 文本块 | contentIndex、delta |
thinking_start / thinking_delta / thinking_end | 推理块 | contentIndex、delta |
toolcall_start / toolcall_delta / toolcall_end | 工具调用 | toolCall:完整的调用,注意此时还没有做 schema 校验 |
done | 正常结束 | reason:stop、length 或 toolUse |
error | 出错或被中止 | reason:error 或 aborted;error 字段是带有部分内容的消息 |
以上取自 pi-ai README1。
正常的事件顺序是 start → 若干更新事件 → done;如果在生成过程中失败,顺序是 start → 若干更新事件 → error1。
5. 消息模型#
- 工具结果是一条独立的
toolResult消息,content里可以同时放文本和图片1。 - 对话中途的 system 消息:可以通过
sections(按名字替换某一段提示词)和toolsAdded/toolsRemoved(增减工具)修改提示词和工具集,而不必改写之前的历史。对于支持这种方式的模型,pi-ai 会把这些消息原样发送,这样提示词缓存的前缀就不会失效;对于不支持的模型,pi-ai 会把它们折叠成开头的一条 system 消息1。
stopReason#
| 值 | 含义 |
|---|---|
stop | 本轮最后一条消息 |
length | 达到了最大 token 数 |
toolUse | 模型在调用工具,等待工具结果 |
error | 生成过程中出错 |
aborted | 被 abort signal 取消 |
以上取自 pi-ai README1。
6. 工具:TypeBox Schema#
import { StringEnum, Type, type Tool } from "@earendil-works/pi-ai";
const weatherTool: Tool = { name: "get_weather", description: "查询某地当前天气", parameters: Type.Object({ location: Type.String({ description: "城市名或坐标" }), units: StringEnum(["celsius", "fahrenheit"], { default: "celsius" }), // 兼容 Google,见下方说明 }),};
// 可选:让提供方在服务端强制参数符合 schema。prefer 表示尽量强制,不支持时退回普通工具调用const strictTool: Tool = { name: "edit_file", description: "编辑文件", parameters: Type.Object({ path: Type.String(), content: Type.String() }, { additionalProperties: false }), constrainedSampling: { type: "json_schema", strict: "prefer" },};
console.log(weatherTool.name, strictTool.name);- pi-ai 使用 TypeBox 定义参数。TypeBox schema 本身就是普通的 JSON,所以可以序列化,便于在分布式系统中传递;同时它也能用来在运行时校验参数1。
- 定义枚举时要用
StringEnum,而不是Type.Enum。后者会生成anyOf/const结构,Google 的 API 不支持1。 constrainedSampling.strict有两个取值:prefer表示提供方支持时就在服务端强制,不支持时退回普通工具调用;require表示不支持就直接报错1。
7. 杀手级特性:对话中途切换提供方#
import { createModels, type Context } from "@earendil-works/pi-ai";import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek";import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
const models = createModels();models.setProvider(anthropicProvider());models.setProvider(openaiProvider());models.setProvider(deepseekProvider());
const context: Context = { messages: [] };
// 先用 Claude 推理const claude = models.getModel("anthropic", "claude-opus-5-5")!;context.messages.push({ role: "user", content: "25 * 18 等于多少?", timestamp: Date.now() });context.messages.push(await models.completeSimple(claude, context, { reasoning: "medium" }));
// 换成 GPT 来复核:它会看到 Claude 的 thinking,被转换成带 <thinking> 标签的文本const gpt = models.getModel("openai", "gpt-5.6-sol")!;context.messages.push({ role: "user", content: "这个计算对吗?", timestamp: Date.now() });context.messages.push(await models.complete(gpt, context));
// 再换成 DeepSeek 总结const ds = models.getModel("deepseek", "deepseek-v4-pro")!;context.messages.push({ role: "user", content: "用一句话总结上面的对话。", timestamp: Date.now() });const summary = await models.complete(ds, context);console.log(summary.content);跨提供方时的转换规则1:
| 消息 | 怎么处理 |
|---|---|
| user 消息、tool result 消息 | 原样传递 |
| 同一提供方或同一 API 产生的 assistant 消息 | 原样保留 |
| 其他提供方产生的 assistant 消息 | thinking 块转换成带 <thinking> 标签的文本 |
| 工具调用、普通文本 | 原样保留 |
- 先用一个快速模型起步,遇到复杂推理时切换到更强的模型;
- 某个厂商故障时切换到另一家,对话照常继续;
- 用不同厂商的模型交叉评审,即一个模型产出,另一个模型复核。
Mario 在博文里坦言,这种转换是“尽力而为”的,但实际效果相当不错2。
8. 其他实用能力#
| 能力 | 说明 |
|---|---|
| 中止请求 | 传入 AbortSignal。被中止的消息 stopReason === "aborted",中止前已经收到的部分内容会保留下来,之后可以接着继续1 |
| Faux provider | 用脚本化的响应做测试:响应按请求顺序依次消费;队列空了会返回错误消息;可以模拟 prompt 缓存;tokensPerSecond 可以模拟真实的输出速度1 |
| 浏览器支持 | 核心入口和各提供方工厂都没有副作用,可以正常打包;浏览器里没有环境变量,需要显式传入 key,或者注入一个 CredentialStore1 |
| 自定义提供方 | 用 createProvider({ id, auth, models, api }) 定义;兼容 OpenAI 的端点(Ollama、vLLM、LM Studio 等)可以直接复用现有的协议实现1 |
| 图像生成与分类器 | 除了对话模型,也支持图像生成模型和分类模型,比如 OpenAI Decisions1 |
| 调试 | 可以查看发给提供方的原始 payload,以及提供方返回的原始流事件1 |
9. 与直接使用官方 SDK 的取舍#
| 直接用 openai / anthropic SDK | 用 pi-ai | |
|---|---|---|
| 新功能可用的时间 | 第一时间可用 | 需要等 pi-ai 适配,提供方专属的参数可以通过 stream() 的 options 传入 |
| 多厂商 | 每家写一套代码 | 一套代码 |
| 切换模型 | 需要自己转换历史 | 内置转换 |
| 托管能力(服务端状态、托管工具、托管 agent) | 完整可用 | 主要面向“客户端循环”的用法 |
| 类型 | 各家各自一套 | 统一的一套 |
这张表是笔者的分析。
笔者的建议:
- 如果项目确定只用一家,并且需要用到该厂商的托管能力(Conversations、Managed Agents、托管 MCP 等),优先用官方 SDK;
- 如果项目需要多厂商、需要完全掌控上下文,或者在做自己的 agent 框架,pi-ai 是很好的底座。DSH 的
dsh-llm-pi-ai适配器就复用了它,见 5.1 DeepSeek Harness 全景。
小结#
- pi-ai 是一个多厂商的 L1 层,核心在于三点:统一的 Context、统一的事件、可以切换模型。
- 记住两个约定:消费流式事件时按
contentIndex归属;出错不抛异常,而是看stopReason。 - 下一步:在 pi-ai 之上加一个循环,就是 4.3 pi-agent-core 最小 Agent 运行时。
相关笔记#
- 4.1 pi 全景与设计哲学 · 4.3 pi-agent-core 最小 Agent 运行时
- 对照:2.2 OpenAI 客户端 SDK 与 Responses API · 3.2 Anthropic 客户端 SDK 与 Messages API
参考资料#
注释与出处#
-
earendil-works/pi,
packages/ai/README.md(Supported Providers、Quick Start、Providers and Models、Auth、Tools、Complete Event Reference、Thinking/Reasoning、Stop Reasons、Error Handling、Faux Provider for Tests、Cross-Provider Handoffs、System Messages、Context Serialization、Migrating from the Old Global API 等节;commit6fb2e78),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/ai/README.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 -
Mario Zechner,What I learned building an opinionated and minimal coding agent(2025-11-30),https://mariozechner.at/posts/2025-11-30-pi-coding-agent/ ↩