pi-ai 统一多模型 API

pi-ai 是一个统一的 LLM 接口层,用同一套 Context、消息和事件模型对接 OpenAI、Anthropic、Google、DeepSeek、Mistral、xAI、Groq、OpenRouter 等数十家提供方,以及任何兼容 …

预计阅读
17分钟
全文字数
2,330字
资料截至
2026-10-09
Agent SDK · pi4.2
版本与时效声明
  • 本文基于 @earendil-works/pi-ai 1.1.0(2026-10-07 发布,源码 commit 6fb2e78)。
  • 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. pi-ai 是一个统一的 LLM 接口层,用同一套 Context、消息和事件模型对接 OpenAI、Anthropic、Google、DeepSeek、Mistral、xAI、Groq、OpenRouter 等数十家提供方,以及任何兼容 OpenAI 的端点1。
  2. 三个核心设计:Context 是可以序列化的纯数据;流式事件按内容块类型统一;可以在对话中途切换模型1。
  3. 它只收录支持工具调用的模型,因为工具调用是 agent 的刚需1。
  4. 自带 faux provider,可以用脚本化的模型响应来测试 agent,不需要 API Key1。

1. 为什么需要一个统一层#

如果直接使用各家的官方 SDK,你会遇到以下差异(详见 对照表):

差异点OpenAI ResponsesAnthropic Messages其他厂商
消息结构一组 item一组 content block各不相同
工具调用的参数JSON 字符串已解析的对象各不相同
推理内容reasoning item,可能是加密的thinking block,带 signature有的厂商根本没有
流式事件response.*message_*、content_block_*各不相同
状态可以存储在服务端无状态通常无状态

pi-ai 的做法是:在你的代码和厂商的 API 之间,加一层统一的数据模型。

你的代码

Context / Message / Tool

Models 集合

anthropicProvider()

openaiProvider()

deepseekProvider()

……数十家提供方

anthropic-messages 协议实现

openai-responses 协议实现

openai-completions 协议实现

Anthropic API

OpenAI API

DeepSeek API

你的代码

Context / Message / Tool

Models 集合

anthropicProvider()

openaiProvider()

deepseekProvider()

……数十家提供方

anthropic-messages 协议实现

openai-responses 协议实现

openai-completions 协议实现

Anthropic API

OpenAI API

DeepSeek 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
序列化后消息数: 4

faux 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、已存储的凭据,或 OAuth
models.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。

两个容易踩的坑
  1. 不同内容块的事件可能交错出现,比如 text_delta 和 toolcall_delta 穿插到达。消费方必须用 contentIndex 来区分属于哪个内容块1。
  2. 流一旦返回,后续的请求失败不会抛出异常,而是发出一个 error 事件,最终消息的 stopReason 为 error 或 aborted1。

正常的事件顺序是 start → 若干更新事件 → done;如果在生成过程中失败,顺序是 start → 若干更新事件 → error1。


5. 消息模型#

Context

+systemPrompt

+messages

+tools

UserMessage

+role = user

+content

+timestamp

AssistantMessage

+role = assistant

+content

+stopReason

+usage

+provider

+model

ToolResultMessage

+role = toolResult

+toolCallId

+toolName

+content

+isError

SystemMessage

+role = system

+content

+sections

+toolsAdded

+toolsRemoved

Context

+systemPrompt

+messages

+tools

UserMessage

+role = user

+content

+timestamp

AssistantMessage

+role = assistant

+content

+stopReason

+usage

+provider

+model

ToolResultMessage

+role = toolResult

+toolCallId

+toolName

+content

+isError

SystemMessage

+role = system

+content

+sections

+toolsAdded

+toolsRemoved

  • 工具结果是一条独立的 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 运行时。

相关笔记#

参考资料#

注释与出处#

  1. 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 等节;commit 6fb2e78),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

  2. 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/ ↩

输入关键词开始搜索。多个关键词用空格分隔。