pi-ai:统一 LLM 层
agent 的一切始于对模型的调用,而"调模型"这件事在 2025 年出奇地脏:provider 几十家、API 协议好几套、每家的流式格式、工具调用、思考(thinking)输出、token 上报方式都不一样。pi-ai 是 pi 对这片泥沼的回答:一个只依赖 provider 官方 SDK 的统一 LLM 层。本篇讲它的三个核心设计决策。
决策一:世界只有四种 API
pi-ai 支持的 provider 有 30 多家,但作者穿透表象发现:真正需要实现的 wire 协议只有四种——OpenAI Completions、OpenAI Responses、Anthropic Messages、Google Generative AI(见 packages/ai/src/api/)。其余绝大多数 provider(xAI、Groq、Cerebras、DeepSeek、OpenRouter、Mistral……)都讲其中某一种,主要是 OpenAI Completions 的各种"方言"。
麻烦正在方言上。openai-completions.ts 里的兼容清单随手摘录:
- Cerebras、xAI、Mistral 等不喜欢
store字段; - Mistral 等用
max_tokens而不是max_completion_tokens; - 多家不支持给系统提示词用的
developerrole; - Grok 不喜欢
reasoning_effort; - reasoning 内容有的放
reasoning_content,有的放reasoning,格式五花八门。
pi-ai 的对策是 compat 标志集(见 packages/ai/src/api/openai-completions.ts):supportsDeveloperRole、maxTokensField、thinkingFormat……按 baseUrl 自动嗅探已知 provider,未知端点可以手工指定。这个设计很实在:不为"理论上正确的抽象"辩护,只为"实际能跑通"负责。
决策二:统一的事件流,而不是统一的返回值
pi-ai 的调用接口是流式的:models.stream(model, context) 返回一个事件流,消费方式对所有 provider 一致:
两个值得细品的点:
工具调用的参数是"流着来"的。每个 toolcall_delta 只带一小段 JSON 字符串,pi-ai 在流式过程中就对半拉的 JSON 做渐进解析——UI 因此能在参数还没传完时就实时预览(比如 diff 一边生成一边渲染)。你在实战篇第 04 章手写过的"按 index 归并、拼字符串、最后 parse"是它的简化版。
错误不抛出,而是成为消息。请求失败(包括被 abort)不 throw,而是以 error 事件收尾,最终的 AssistantMessage 带着 stopReason: "error" | "aborted" 和已收到的部分内容。中止的请求其部分内容可以保留进上下文,下次接着用——abort 在 pi-ai 里是与"正常完成"平级的一等结局,因为作者认为"一个不能中止、中止即丢失的 LLM 库,不配进入生产系统"。
决策三:上下文是数据,可以在 provider 之间搬运
pi-ai 的 Context { systemPrompt, messages, tools } 是纯数据,可以 JSON.stringify 序列化、存盘、传输,并且——这是它被设计之初就瞄准的能力——跨 provider 交接(handoff):上午用 Claude 干的活,下午可以换 GPT 接着干。
交接当然是 best-effort:Anthropic 的 thinking 轨迹换成 OpenAI 能懂的形式(包成 <thinking> 标签的文本块)、各家插在流里的签名 blob 要在重放时保留或转换。作者的态度一如既往:抽象可以漏,但要漏得明确、可用。
与此配套的两个实用设计:
- 结构化工具结果:工具的返回值分两层——
content(给 LLM 的文本/图片)和details(给 UI 的结构化数据)。UI 不再需要解析文本输出重建结构(见packages/ai/src/types.ts)。 - token/成本统计是 best-effort:各家上报 token 的时机混乱(有的在流头、有的在流尾、中止时有的干脆不上报),缓存读写的计量更是西部荒野。pi-ai 如实统计、如实标注,不为"精确到个位"的假象负责。
为什么不用 Vercel AI SDK
作者被问过无数次,答案写在手记里:直接基于 provider 官方 SDK 自建,换来完全的控制权和更小的表面积——统一的流式事件、统一的 abort、统一的 handoff 语义,都按自己的需求精确裁剪。这也是本教程裸写阶段同样不装 SDK 的原因:只有亲手处理过 SSE 分片、tool_calls 累积、length 截断,你才真正拥有这一层。
统一 LLM 层的价值不在"省得写 fetch",而在:一处接入、处处可换(provider/模型都是运行期选择)、事件语义一致、abort 与部分结果可用。如果你只能给自己的 agent 投资一层抽象,投这一层。
动手验证:实战篇第 08 章用 builtinModels() 三行接入 30+ provider。下一篇:pi-agent-core:agent loop →