数据截至 (上游 commit 565d53515b54)
模型接入层与方言:in-band 工具调用(pi-ai)
30 秒导读:
@oh-my-pi/pi-ai干两件事。第一,把 40 多个大模型服务商(Anthropic / OpenAI / Google / 各种网关…)各不相同的 HTTP 协议,收敛成同一条流式事件流——上层 agent 循环(见 01-agent-loop)永远只面对一种AssistantMessageEventStream。第二,更巧的是方言层(dialect):当一个模型原生的工具调用不好用、甚至根本不支持时,pi-ai 干脆不告诉服务商"这有工具",而是把工具目录写进 prompt 文本,再从模型吐出的纯文本流里把工具调用解码出来,伪装成原生调用交回上层。这叫 in-band(带内)工具调用。
本章只讲"模型进来、文本出去"这一层。回合逻辑(什么时候该调工具、结果怎么塞回去)是 01-agent-loop;工具本身长什么样是 03-tool-surface。
1. 这是什么(零基础也能懂)
一句话定义
pi-ai 是 oh-my-pi 的模型接入层:上层给它一段对话 + 一堆工具,它负责联网、鉴权、把请求翻成某个服务商的方言、再把返回的流翻回统一格式。
它要解决的两个真问题
问题一:服务商太多,协议全不一样。 同样是"发一段对话、流式收回答",Anthropic 用 Messages API,OpenAI 有 Completions 和 Responses 两套,Google 有 Generative-AI / Gemini-CLI / Vertex 三套,还有 OpenRouter、Bedrock、Cursor、Copilot、GitLab Duo… 内置就 14 种 API 形态(api-registry.ts:19 BUILTIN_API_IDS),背后是 40+ 家 provider。上层 agent 循环不可能为每一家写一遍。
问题二(更棘手):很多模型的"原生工具调用"要么不存在、要么不好使。 有的开源模型压根没在 tool-call 上训练;有的经某个网关中转后,原生工具调用被吞掉或改形;有的模型(如 GLM、Kimi、DeepSeek)其实是被训练成在文本里用一套特定标记来喊工具的,你走服务商的"结构化工具"通道反而绕远。
直觉:什么是 in-band(带内)工具调用
先看两种做法的对比:
| 原生 / 结构化工具调用 | in-band(带内)工具调用 | |
|---|---|---|
| 工具目录发给谁 | 放进请求的 tools 字段,服务商负责 | 写进 system prompt 的纯文本 |
| 模型怎么喊工具 | 服务商用专门的结构化通 道回传 | 模型在正文里打出一段约定标记 |
| pi-ai 怎么拿到 | 直接读结构化字段 | 用扫描器从文本流里解析出来 |
| 谁能用 | 只有支持 tool-call 的模型 | 任何能生成文本的模型都能用 |
"带内"就是:工具调用不走单独的信道,而是混在模型的正常文本输出里面。pi-ai 发的时候把工具编码进 prompt,收的时候从文本流里解码回来。对上层 agent 来说,两种做法看起来完全一样——都是收到一个 toolCall 事件。
用起来什么样
上层永远只调一个入口 streamSimple(model, context, options),拿回一个可以 for await 的事件流:
// 示意,非源码:上层 agent 眼里的世界永远是这一种
const stream = streamSimple(model, { systemPrompt, messages, tools }, opts);
for await (const ev of stream) {
if (ev.type === "text_delta") process.stdout.write(ev.delta); // 正文增量
if (ev.type === "toolcall_end") dispatch(ev.toolCall); // 一个工具调用凑齐了
}
// model 是 Anthropic 还是某个开源模型、工具是原生还是方言解码出来的,这里都看不出来
到底走原生还是走方言,由一个开关决定,下面第 4 节讲。
2. 顶层全景(它大概怎么转)
pi-ai 有两层收敛,一层套一层:
上层 agent 循环(第1章)
│ context = { systemPrompt, messages, tools }
▼
┌─────────────────────────────────────────────┐
│ 方言层(owned / in-band)—— 可选,默认关 │ ← 本章重点
│ 开:把 tools 写进 prompt、历史改写、tools=∅ │
└─────────────────────────────────────────────┘
│
▼
streamSimple ──► stream ──► streamDispatch:switch(model.api)
│ (鉴权轮换在这一层之外包一圈)
┌───────────────┬───────┴────────┬────────────────┐
▼ ▼ ▼ ▼
anthropic- openai- google-* 40+ 家
messages responses/… 3 套 各自 client+wire
│ │ │ │
└───────────────┴────────┬───────┴────────────────┘
▼
统一出口:AssistantMessageEventStream
(text/thinking/toolcall 三类增量事件)
│
▼
方言层收尾:wrapInbandToolStream 从文本流里
解码工具调用,再伪装成原生 toolcall 事件回传
怎么读这张图: 从上往下是一次请求。中间那个"方言层"是可选的夹层——不开时请求直穿到 provider 走原生工具;开时它在进的方向改写请求(把工具变成 prompt 文本),在出的方向把文本流里的工具调用捞回来。无论走哪条路,最后交给上层的都是同一种事件流。
各部件职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
stream / streamSimple | 统一入口,按 model.api 分发到具体 provider | stream.ts:685 / stream.ts:929 |
streamDispatch | 一个大 switch(api) 路由到 14 种内置 API | stream.ts:695 |
| 自定义 API 注册表 | 扩展可注册新 API 形态(如 vertex-claude-api) | api-registry.ts:72 registerCustomApi |
| client + wire 对 | 每家一对:client 管 HTTP/重试,wire 管请求体/响应类型 | providers/anthropic-client.ts / anthropic-wire.ts |
| 鉴权轮换 | API key 解析器 + a/b/c 重试策略(刷新→换号) | auth-retry.ts:231 withAuth |
| 统一事件流 | 全 provider 归一到同一套增量事件 | types.ts:700 AssistantMessageEvent |
| 方言定义 | 每种模型家族一套「渲染 + 扫描」双向编解码 | dialect/factory.ts:14 DIALECT_DEFINITIONS |
| 在带流包装 | 把文本流里的工具调用解码成原生 toolcall 事件 | dialect/owned-stream.ts:90 wrapInbandToolStream |
3. Provider 接入层:一套流式接口收敛 40+
本节讲没开方言时,pi-ai 怎么把几十家服务商压成一种接口。