mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
4920 字
13 分钟
拆解 @earendil-works/pi-ai:统一 40 家 LLM 的三层设计
2026-08-22

写 agent(能自己调工具干活的 AI 程序)绕不开模型层:OpenAI 一套 SDK(官方提供的调用工具包)、Anthropic 一套、Google 又一套,鉴权方式、流式事件、工具调用格式、思考参数全不一样。想中途换个模型,基本等于重写一遍接入层。

@earendil-works/pi-aipi coding agent 的模型层(monorepo 里的 packages/ai),把这堆差异收敛成一个接口:40 家 provider 共用一个 Context、一套流式事件、一份用量和成本统计。本文先用一个不需要 API key 的例子把它跑起来,再回头拆设计。只想用起来,读完前 3 节就够;想知道为什么这样设计,继续往后。

两个小术语

provider:一家模型厂商的接入配置(密钥、模型列表、请求行为),比如 anthropic、openai。

Context:一次对话的全部上下文——system prompt、消息列表、工具定义,就是一个普通对象。

earendil-works
/
pi
Waiting for api.github.com...
00K
0K
0K
Waiting...

关于 pi 这个 agent 本身的设计哲学,之前写过:

Pi 和 DeepSeek Harness 简要对比
模型和真实世界之间那一层该长成什么形状:Pi 把 loop 当产品,DSH 把可替换做成内核原语
2026-08-21开发#Pi#DeepSeek#Harness

先明确这个包是什么。当前版本 0.84.2,作者 Mario Zechner(badlogic),MIT,纯 ESM,要求 Node ≥ 22.19.0,浏览器也能跑(Bedrock 和 OAuth 登录除外)。

关键数字(0.84.2 实测):

维度数字
内置 provider40 个
API 实现(线协议)10 个
生成目录里的模型1267 个
直接依赖的官方 SDK4 个(@anthropic-ai/sdkopenai@google/genai@aws-sdk/client-bedrock-runtime
API 实现是什么

指「实际跟厂商服务器通话的那段协议代码」。40 家厂商只对应 10 种实现,是因为很多厂商都兼容 OpenAI 的接口格式,协议可以复用。

40 家 provider 全部复用这 10 个 API 实现,整个包只依赖 4 个官方 SDK。这是第一个设计判断:厂商很多,协议很少。xAI、Groq、Cerebras、DeepSeek、OpenRouter 全都讲 OpenAI Chat Completions,差异只在字段细节。

第二个判断写在 README 开头:只收录支持工具调用的模型。pi-ai 是给 agent 用的,工具调用是 agentic 工作流的前提,不能调工具的模型直接不进目录。

它要抹平哪些差异#

这一节看厂商之间具体差在哪——后面所有设计都在回应这四条:

  • 鉴权方式:最简单的是填一个 API key;订阅制产品走 OAuth(浏览器跳转登录,用订阅额度,比如 Claude Pro/Max、ChatGPT Plus/Pro、GitHub Copilot);云上服务用环境凭证链(AWS profile、gcloud ADC)。一个库要同时装下这三种。
  • thinking 参数:Anthropic 收 thinking.budget_tokens,OpenAI 收 reasoning_effort,DeepSeek 收 thinking.type,Qwen 收 enable_thinking——同一件事,四种参数名。
  • 工具调用 ID 格式:OpenAI Responses 生成的 ID 有 450+ 字符还带 |,Anthropic 要求匹配 ^[a-zA-Z0-9_-]+$ 且不超过 64 字符。A 家产生的历史消息直接喂给 B 家,会被接口拒绝。
  • 流式事件的粒度和命名:每家的事件结构都不同,消费端原本要各写一套解析。
thinking 是什么

模型正式回答前的内部推理过程。有的厂商把它单独返回、可以回看,有的不暴露。各家开启它的参数格式完全不同,是统一接口最难抹平的部分之一。

先跑起来:不用 API key 的 agent 循环#

这一节不调任何真实模型,把 pi-ai 的完整交互流程跑一遍,建立直觉。

pi-ai 内置了一个假 provider:fauxProvider。它按你预先排好的「剧本」(一串写死的回复)回应请求,所以不需要任何密钥。先明确什么叫 agent 循环:模型回复 → 模型要求调用工具 → 你执行工具、把结果喂回去 → 模型继续,如此来回。

下面这段代码我在本机实际跑过(Node 24 + pi-ai 0.84.2),走完了「思考 → 调工具 → 收结果 → 继续回答」的完整循环:

demo.mjs
import {
createModels,
fauxProvider,
fauxAssistantMessage,
fauxThinking,
fauxText,
fauxToolCall,
} from '@earendil-works/pi-ai';
const faux = fauxProvider({ tokensPerSecond: 200 });
const models = createModels();
models.setProvider(faux.provider);
const model = faux.getModel();
const context = {
systemPrompt: 'You are helpful.',
messages: [{ role: 'user', content: 'echo hi then summarize', timestamp: Date.now() }],
};
// 第一轮排一个「思考 + 工具调用」的剧本
faux.setResponses([
fauxAssistantMessage(
[fauxThinking('Need to call echo first.'), fauxToolCall('echo', { text: 'hi' })],
{ stopReason: 'toolUse' },
),
]);
const s = models.stream(model, context);
for await (const event of s) {
if (event.type === 'thinking_delta') console.log('[think]', event.delta);
if (event.type === 'toolcall_end') console.log('[toolcall]', event.toolCall.name, event.toolCall.arguments);
if (event.type === 'done') console.log('[done]', event.reason);
}
const msg = await s.result();
console.log('usage:', msg.usage.input, 'in /', msg.usage.output, 'out');
// 执行工具,把结果塞回 context,再排第二轮
context.messages.push(msg);
context.messages.push({
role: 'toolResult',
toolCallId: msg.content.find((b) => b.type === 'toolCall').id,
toolName: 'echo',
content: [{ type: 'text', text: 'hi' }],
isError: false,
timestamp: Date.now(),
});
faux.setResponses([fauxAssistantMessage([fauxText('The echo said hi.')])]);
const next = await models.complete(model, context);
console.log('final:', next.content.find((b) => b.type === 'text').text);
console.log('context bytes:', JSON.stringify(context).length);

真实输出:

[think] Need to call echo fi
[think] rst.
[toolcall] echo { text: 'hi' }
[done] toolUse
usage: 13 in / 11 out
final: The echo said hi.
context bytes: 753

注意 thinking 的增量是半个词半个词到的(fi / rst.)——流式消费代码必须按增量拼接,不能假设语义完整的 chunk。两个实现细节:faux 的 usage 按 4 字符 ≈ 1 token 估算;剧本队列掏空后,再请求会收到 errorMessage: "No more faux responses queued" 的错误消息,同样不 throw。

pi-ai 的全部交互模型就是这样:把消息推进 context,从流里收事件。写 agent 的测试时 faux provider 价值很大——工具循环、中断恢复、跨模型切换的断言都可以做成确定性的。下一节把假模型换成真的。

接入真实模型的最小骨架#

换回真实模型,只要改两样东西:注册方式、密钥。先装包:

npm install @earendil-works/pi-ai

最省事的全量版:

import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const models = builtinModels(); // 40 家全注册;在意包体积就改用单家工厂
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
const context: Context = {
systemPrompt: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'What time is it?', timestamp: Date.now() }],
tools: [/* TypeBox 工具 */],
};
// ANTHROPIC_API_KEY 从环境自动解析;显式 options.apiKey 永远优先
const s = models.stream(model, context);
for await (const event of s) {
if (event.type === 'text_delta') process.stdout.write(event.delta);
if (event.type === 'toolcall_end') {
// 执行工具后往 context.messages 推 toolResult,再 complete 一轮
}
}
context.messages.push(await s.result());

tools 数组留空也能跑;工具怎么定义,见后面「工具、思考等级与 Simple API」一节。只关心包体积的单家版:createModels() + anthropicProvider()(来自 @earendil-works/pi-ai/providers/anthropic)+ models.setProvider(...)

接本地推理服务不用写协议代码,声明一个自定义 provider 就行:

import { createModels, createProvider, type Model } from '@earendil-works/pi-ai';
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
const ollamaModel: Model<'openai-completions'> = {
id: 'llama-3.1-8b',
name: 'Llama 3.1 8B (Ollama)',
api: 'openai-completions',
provider: 'ollama',
baseUrl: 'http://localhost:11434/v1',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
// Ollama 这类服务不认 developer role 和 reasoning_effort
compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
};
const ollama = createProvider({
id: 'ollama',
// 免密钥服务也要声明 auth:resolve 返回空配置即视为已配置
auth: { apiKey: { name: 'Ollama', resolve: async () => ({ auth: {} }) } },
models: [ollamaModel],
api: openAICompletionsApi(),
});
const models = createModels();
models.setProvider(ollama);

compat 挂在模型上(Model.compat),按模型所属 API 分型。OpenAI 兼容层的开关最多,有 24 个:maxTokensField 选字段名、requiresThinkingAsText 把 thinking 降级成文本、thinkingFormat 在 11 种思考参数格式之间切换(openai、openrouter、deepseek、together、zai、qwen、chat-template 等)。Anthropic 和 Bedrock 也各有小开关组——比如生成目录里 Anthropic 模型自带的 compat: { supportsStrictTools: true }(见「目录是生成的,SDK 是懒加载的」一节)。

不设置时,按 provider ID 和 baseUrl 对十来家已知厂商探测(xAI、Cerebras、DeepSeek、Together、Moonshot、OpenRouter、Cloudflare 等);部分设置时,没指定的字段沿用探测结果或固定默认值。所以接自建网关、LiteLLM、vLLM 这类端点,只需覆盖实际有差异的几项。

每次请求回来的 usage.cost 已经按目录里的定价算好了钱,输入、输出、cache 读写、总计分开统计。配合流式选项里的 telemetryContext 可以接 pi 自己的 telemetry 包——那包的设计之前也拆过:

讲解 @earendil-works/pi-telemetry
读 earendil-works/pi 的 packages/telemetry:它为什么没有 exporter,startSpan 怎么结算,schema 为什么只在类型里生效
2026-08-21开发#Pi#Telemetry#TypeScript

三层结构:Provider、Models、API 实现#

用法看完了,回头看设计:pi-ai 怎么用三层结构切开前面那组差异。

graph TD APP[你的代码] --> M["Models 集合<br/>路由 + auth 解析"] M --> P1["Provider: anthropic"] M --> P2["Provider: github-copilot"] M --> P3["Provider: ollama(自建)"] P1 --> A1["anthropic-messages"] P2 --> A1 P2 --> A2["openai-responses"] P2 --> A3["openai-completions"] P3 --> A3 A1 --> S1["@anthropic-ai/sdk<br/>首次请求才加载"] A2 --> S2["openai SDK<br/>首次请求才加载"] A3 --> S2

Provider 是运行时单元。它拥有三样东西:模型目录、鉴权逻辑、stream 行为。接口的核心部分:

export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly baseUrl?: string;
// 必需:哪怕是免密钥的本地服务也要声明 auth 语义
readonly auth: ProviderAuth;
// 同步返回当前已知模型列表,不许抛异常
getModels(): readonly Model<TApi>[];
// 动态 provider 才有:拉最新模型列表
refreshModels?(context: RefreshModelsContext): Promise<void>;
stream<T extends TApi>(model: Model<T>, context: Context, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}

Models 是集合。注册若干 provider,负责同步查模型、解析鉴权、把请求路由给拥有该模型的 provider。complete() 没有任何独立逻辑,就是 stream().result()

async complete(model, context, options) {
return this.stream(model, context, options).result();
}

API 实现是线协议。10 个:anthropic-messagesopenai-responsesopenai-completionsopenai-codex-responsesazure-openai-responsesgoogle-generative-aigoogle-vertexmistral-conversationsbedrock-converse-streampi-messages。混合 API 的 provider 按 model.api 逐模型分发——GitHub Copilot 一家就同时挂三种协议(anthropic-messages 10 个模型、openai-responses 14 个、openai-completions 8 个),OpenCode Zen 同理。

分层的收益在组合性:新增一家 OpenAI 兼容厂商,不用写任何协议代码,给一个模型列表加 baseUrl 就完事——上一节 Ollama 的例子已经演示过。

错误不抛出:一套事件协议#

这一节回答:40 家厂商各不相同的流式回复,怎么统一成一套事件。

所有 provider 的所有流,都收敛到 12 种事件:

事件含义
start流开始,带初始 partial 消息
text_start / text_delta / text_end文本块的始 / 增量 / 终
thinking_start / thinking_delta / thinking_endthinking 块的始 / 增量 / 终
toolcall_start / toolcall_delta / toolcall_end工具调用的始 / 参数增量 / 终
done成功结束,reasonstop / length / toolUse / deferred(转后台异步)
error失败或中止,携带部分内容的消息

最有特色的设计是请求失败不 throw。网络错误、鉴权失败、abort、工具参数校验失败,全部走 error 事件;最终消息(await s.result())里带着 stopReason: "error"errorMessage、已收到的部分内容和部分 token 用量。调用方不需要 try/catch 包流,统一在事件循环里处理。

流的实现也朴素——一个 queue 加一组等待中的 resolver,done / error 事件结算 result() 的 promise:

// dist/utils/event-stream.js,节选
push(event) {
if (this.done) return;
if (this.isComplete(event)) { // done 或 error
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
const waiter = this.waiting.shift();
if (waiter) waiter({ value: event, done: false });
else this.queue.push(event);
}

两个使用上的坑,README 自己都标了:

  1. 不同内容块的事件不保证连续。一个上游 chunk 可能同时带文本和工具调用的增量,事件会交错。必须用 contentIndex 关联增量和块,不能假设一个块的 *_start/*_delta/*_end 不被别人打断。
  2. toolcall_delta 里的 arguments 是尽力解析的半成品。库里用 partial-json 包边收边解析,字段可能缺、字符串可能半截。做渐进 UI 可以,执行业务逻辑必须等 toolcall_end,再过一遍 validateToolCall

Context 是纯数据,可以跨厂商交接#

这一节回答:会话中途换一家厂商,上下文怎么不丢。

Context 只有三个字段:systemPromptmessagestools。全部可 JSON 序列化,持久化会话就是 JSON.stringify,换机器、换进程、换模型都行。

「换模型」是重点。凡是要把 Context 翻译成厂商格式的 API 实现——anthropic-messages、openai-completions、openai-responses 系、google 系、mistral、bedrock——都会先过一遍 transformMessagesdist/api/transform-messages.js,186 行,是全文最值得读的函数)。唯一的例外是 pi-messages:它本身就是 pi 的协议,Context 原样 POST 给后端,翻译是服务端的事。0.84.2 里 transformMessages 按顺序做这些事:

  1. null 内容归一化成空数组,兜住手搓的历史消息
  2. 目标模型不支持视觉时,把图片降级成占位文本 (image omitted: model does not support images)
  3. 跨模型的 thinking 块转成普通文本块(同模型则保留带签名的原始块,供回放;redacted 的加密 thinking 直接丢弃)。注意 README 说会加 <thinking> 标签,0.84.2 的代码已经不加标签了,就是纯文本
  4. 跨模型的 tool call 剥掉 thoughtSignature,并按目标 API 归一化 ID。给 Anthropic 的归一化就一行:id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
  5. 整条跳过 stopReasonerror / aborted 的 assistant 消息——残缺的回合不回放,让模型从上一次有效状态重来,避免 OpenAI 的 “reasoning without following item” 这类报错
  6. 给没有结果的孤儿 tool call 补合成的错误结果("No result provided"),满足各家「tool call 必须有结果」的协议要求

效果是:Claude 思考到一半切 GPT-5 再切 Gemini,工具调用链不断,上下文不丢。这也回答了「为什么 ModelContext 必须是纯数据」——只有纯数据才能在 provider 之间自由流动。

目录是生成的,SDK 是懒加载的#

这一节回答:1267 个模型的元数据和 4 个重型 SDK,怎么不拖慢你的构建。

模型元数据不是手维护的。构建期脚本(官方仓库 scripts/generate-models.ts,约 3000 行)以 models.dev 为主数据源,NVIDIA NIM、OpenRouter、Vercel AI Gateway 等几家直接拉各自的 /models 接口,再叠加一批手工修正;产物 hydrate 成 providers/data/<id>.json,再由 <id>.models.ts 从 JSON 的 key 导出字面量类型——所以 getBuiltinModel('openai', 'gpt-4o-mini') 有完整的自动补全和返回类型。Model 是纯数据(id、定价、contextWindow、是否支持图像/推理),没有方法,JSON.stringify 直接序列化。

一条真实记录长这样(anthropic.json 里的 Claude Haiku 4.5):

"claude-haiku-4-5": {
"id": "claude-haiku-4-5",
"name": "Claude Haiku 4.5 (latest)",
"api": "anthropic-messages",
"provider": "anthropic",
"baseUrl": "https://api.anthropic.com",
"reasoning": true,
"input": ["text", "image"],
"cost": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25 },
"contextWindow": 200000,
"maxTokens": 64000,
"compat": { "supportsStrictTools": true }
}

SDK 加载走的是 lazyApi 包装。provider 工厂 import 的不是 @anthropic-ai/sdk,而是一个 .lazy 模块;首次请求到达时才动态 import 真 SDK。核心代码很短:

// dist/api/lazy.js,节选
export function lazyApi(load, capabilities) {
const api = {
stream: (model, context, options) =>
lazyStream(model, async () => (await load()).stream(model, context, options)),
streamSimple: (model, context, options) =>
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
};
return api;
}

lazyStream 同步返回一个事件流,把异步的模块加载藏在流后面——加载失败也不会 throw,而是终止流并推一个 error 事件,和前面「错误不抛出」一节的约定一致。

配合打包器代码分割,tree shaking 规则很干净:

  • 主入口 @earendil-works/pi-ai 不含任何内置目录和 SDK
  • providers/<p> 只拉这一家的目录和 lazy 包装
  • providers/all 是全量重入口,只在真要 40 家全注册时用

鉴权属于 provider#

这一节回答:三种鉴权方式(key、OAuth、云凭证链)怎么收进一个接口。

鉴权不是全局注册表,而是每个 provider 自己的 auth 字段。以 Anthropic 为例,解析顺序直接从源码能看到:

// dist/providers/anthropic.js,节选
resolve: async ({ ctx, credential, signal }) => {
// 1. 存过的 credential 永远优先
if (credential?.key) {
return { auth: { apiKey: credential.key }, source: "stored credential" };
}
// 2. ANTHROPIC_AUTH_TOKEN 走 Bearer 头
const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV);
if (authToken) {
return { auth: { headers: { Authorization: `Bearer ${authToken}` } }, source: ANTHROPIC_AUTH_TOKEN_ENV };
}
// 3. 再依次看 ANTHROPIC_OAUTH_TOKEN、ANTHROPIC_API_KEY
// ...
return undefined; // 未配置
}

持久化凭证走 CredentialStore,契约刻意做得很小:readlist(只返回 { providerId, type } 元数据,不碰 secret)、modify(唯一写路径,串行化的 read-modify-write)、delete。两个值得抄的决策:

  • OAuth 刷新在 modify 里跑。token 刷新持有存储锁,并发请求、甚至多进程不会重复刷新同一个轮换过的 token。
  • 存过的 credential 拥有它的 provider。只要存了,环境变量就不再被咨询;刷新失败也不会静默回落到 env key。避免「线上偷偷用了开发机的 key」这类事故。

不发请求也能检查配置状态:await models.getAuth(model) 返回 headers 和来源标签("ANTHROPIC_API_KEY""OAuth""stored credential"),做状态栏 UI 直接够用。

请求头的合并顺序也是固定的,后者覆盖前者:

provider auth → model.headers → options.headers → transformHeaders → 发给 provider

订阅制 provider(Anthropic、OpenAI Codex、GitHub Copilot)的 OAuth 登录内置了完整流程,命令行直接可用,凭证写到当前目录的 auth.json

npx @earendil-works/pi-ai login anthropic

工具、思考等级与 Simple API#

这一节回答:四家厂商四种 thinking 参数,怎么变成一个选项。

工具用 TypeBox 定义,schema 本身就是可序列化的 JSON:

import { Type, StringEnum, type Tool } from '@earendil-works/pi-ai';
const weatherTool: Tool = {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: Type.Object({
location: Type.String({ description: 'City name or coordinates' }),
// 兼容 Google:不要用 Type.Enum,它生成的 anyOf/const 模式 Google 不收
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
})
};

流式 API 有两层,对应两种心智负担:

  • stream / complete:吃所属 API 的完整选项(Anthropic 的 thinkingBudgetTokens、OpenAI 的 reasoningEffort),动态查出来的模型用 hasApi(model, 'anthropic-messages') 收窄后获得完整类型
  • streamSimple / completeSimple:统一六档 reasoning: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max',由库里映射成各家参数

token 系 provider 的默认思考预算在 simple-options.js 里:

reasoning 档默认 thinking 预算
minimal1024
low2048
medium8192
high16384

xhigh / max 是模型级 opt-in(由模型的 thinkingLevelMap 声明,null 表示该档不支持),预算计算时按 high 处理。模型级的 thinkingBudgets 选项可以整体覆盖这张表。

同一文件里还有个安静的保护:maxTokens 会被钳到 contextWindow − 上下文估算 tokens − 4096 安全边距内,防止把上下文撑爆。

深入:精读 openai-completions(可跳过)#

这一节是源码精读,不影响使用,可以跳过。前面 demo 和真实模型骨架用的 stream / complete,底层走的就是这类 API 实现。

transformMessages 只是预处理。真正把 Context 变成厂商 wire 格式的代码,住在每个 API 实现里。以 openai-completions 为例——源码在仓库的 packages/ai/src/api/openai-completions.ts(npm 包里只有编译产物 dist/),约 1670 行。读这类实现带三个问题就够了:输入怎么转、输出怎么转、厂商差异怎么抹平。

先看主干。stream 函数同步返回一个事件流,所有工作塞进 async IIFE,try/catch 兜住全部异常转成 error 事件——「错误不抛出」就是在这里落地的:

// src/api/openai-completions.ts,骨架精简
export const stream = (model, context, options) => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = { role: "assistant", content: [], stopReason: "pending", /* ... */ };
try {
// 建 client、buildParams、发请求
for await (const chunk of openaiStream) { /* 逐 chunk 翻译成事件 */ }
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};

outputpending 空壳开始,在循环里被逐渐填满;每个事件携带的 partial 都是它。

输入转换:Context → messages 数组#

convertMessages() 先调 transformMessages(见「Context 是纯数据」一节)做归一化,然后逐条翻译。几个有代表性的决策:

systemPrompt 的 role 看模型是否推理

if (context.systemPrompt) {
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
const role = useDeveloperRole ? "developer" : "system";
params.push({ role, content: sanitizeSurrogates(context.systemPrompt) });
}

assistant 的 content 坚持发纯字符串,注释里记录了原因——发 {type:"text"} 数组是非标准的,NVIDIA NIM 上的 DeepSeek V3.2 会照抄这个结构,产出 [{'type':'text','text':'[{...}]'}] 这样的递归嵌套:

// Always send assistant content as a plain string (OpenAI Chat Completions
// API standard format). Sending as an array of {type:"text", text:"..."}
// objects is non-standard and causes some models (e.g. DeepSeek V3.2 via
// NVIDIA NIM) to mirror the content-block structure literally in their output

toolResult 变成 role: "tool" 消息:只有图片没有文本时占位 "(see attached image)";图片块抽出来,另外拼一条带 image_url 的 user 消息跟在后面。内部的 normalizeToolCallId 还要处理 OpenAI Responses 的 call_id|item_id 双段 ID(400+ 字符):拆出两段、清洗非法字符、截到 Chat Completions 的 40 字符上限,超长就用 8 位 hash 兜底。

所有文本过一遍 sanitizeSurrogates 清掉孤立的 Unicode 代理项——这类字符会让一些 API 直接报错。

输出转换:SSE chunk → 事件#

SSE 是什么

Server-Sent Events:服务器把回复切成一连串小数据块(chunk)边生成边推送,客户端逐块接收——这就是「流式输出」的实现方式。

SDK 的流式响应在 for await (const chunk of openaiStream) 里逐块翻译:

  • chunk.usage → 更新 token 统计(Moonshot 把 usage 塞在 choice.usage 里,有兜底)
  • choice.finish_reasonmapStopReason() 映射成统一的 stopReason
  • delta.content → 追加到当前 text 块,推 text_delta
  • thinking 没有标准字段,靠探测:["reasoning_content", "reasoning", "reasoning_text"] 取第一个非空的推 thinking_delta——注释注明 chutes.ai 同时返回前两个字段且内容相同,必须去重
  • delta.tool_calls → 累积参数字符串,每收一段就 parseStreamingJson 尽力解析一次半截 JSON,推 toolcall_delta
if (toolCall.function?.arguments) {
delta = toolCall.function.arguments;
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
block.arguments = parseStreamingJson(block.partialArgs);
}

这就是「toolcall_delta 里的 arguments 是半成品」的来源。流结束后统一补 *_end 事件;厂商没发 finish_reasoncompat.supportsFinishReason 为 false 时,按「内容里有没有 toolCall」推断 toolUse 还是 stop;该有而没有,直接抛错走 error 事件。

兼容性处理:探测加覆盖#

OpenAICompletionsCompat 的 24 个开关在「接入真实模型」一节列过,这里看它们在代码里怎么生效。getCompat() 就一件事——探测结果兜底,模型上的显式设置逐字段覆盖:

function getCompat(model) {
const detected = detectCompat(model); // 按 provider ID + baseUrl 匹配十来家已知厂商
if (!model.compat) return detected;
return {
supportsStore: model.compat.supportsStore ?? detected.supportsStore,
supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
// ...24 个字段全是同一个模式
};
}

开关的消费点散布在转换函数里:compat.supportsDeveloperRole 决定 systemPrompt 的 role,compat.requiresToolResultName 决定 tool 消息带不带 namecompat.requiresAssistantAfterToolResult 决定要不要在 toolResult 和 user 消息之间插一条合成的 assistant 消息。每加一个厂商,理想情况下只是多一个探测分支或一组开关值,不动主逻辑——这是这个文件能被 27 家厂商复用的原因。

什么时候用它,什么时候不用#

适合的场景:写 agent 或任何多轮工具循环;需要在多家模型间切换、对比、兜底;需要统一的 token / 成本统计;测试想要确定性(faux provider);想在浏览器里直连模型(Bedrock 和 OAuth 登录除外,这两样是 Node only)。

不适合的场景:

  • 只要对一家厂商发一次 completion——官方 SDK 更直接,这个库的价值在多模型和 agent 循环
  • 要 embedding、语音、视频——目录只收工具调用模型,这些模态不在范围内
  • 要图像生成——有独立的 ImagesModels API,但 0.84.2 只有 OpenRouter 一家接入
  • 老代码的全局 API(getModel()stream() 直调)已冻结在 @earendil-works/pi-ai/compat,未来版本会删,新项目直接用 createModels() + provider 工厂

回到开头的问题:统一 40 家 LLM 靠的不是一个巨大的适配层,而是明确一个事实「厂商很多、协议很少」,然后把鉴权、目录、流行为收进 Provider,把路由和凭证解析收进 Models,把 10 条线协议做成可懒加载、可单测的实现。每层都可以单独替换——这也是 pi 全家桶一贯的形状。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

拆解 @earendil-works/pi-ai:统一 40 家 LLM 的三层设计
https://l1ngg.info/posts/tech/pi-ai/
作者
L1ngg
发布于
2026-08-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录