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;
  • 多家不支持给系统提示词用的 developer role;
  • Grok 不喜欢 reasoning_effort;
  • reasoning 内容有的放 reasoning_content,有的放 reasoning,格式五花八门。

pi-ai 的对策是 compat 标志集(见 packages/ai/src/api/openai-completions.ts):supportsDeveloperRolemaxTokensFieldthinkingFormat……按 baseUrl 自动嗅探已知 provider,未知端点可以手工指定。这个设计很实在:不为"理论上正确的抽象"辩护,只为"实际能跑通"负责

决策二:统一的事件流,而不是统一的返回值

pi-ai 的调用接口是流式的:models.stream(model, context) 返回一个事件流,消费方式对所有 provider 一致:

事件含义
start流开始,给出初始的 assistant 消息结构
text_start / text_delta / text_end文本块的流式输出
thinking_start / thinking_delta / thinking_end思考内容的流式输出
toolcall_start / toolcall_delta / toolcall_end工具调用的流式输出
done完成,带 stopReason(stop/length/toolUse)
error出错或被中止(error/aborted)

两个值得细品的点:

工具调用的参数是"流着来"的。每个 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 截断,你才真正拥有这一层。

给造 agent 的你的启示

统一 LLM 层的价值不在"省得写 fetch",而在:一处接入、处处可换(provider/模型都是运行期选择)、事件语义一致、abort 与部分结果可用。如果你只能给自己的 agent 投资一层抽象,投这一层。

动手验证:实战篇第 08 章builtinModels() 三行接入 30+ provider。下一篇:pi-agent-core:agent loop →