跳到主要内容

模型接入层与方言: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 分发到具体 providerstream.ts:685 / stream.ts:929
streamDispatch一个大 switch(api) 路由到 14 种内置 APIstream.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:112 withAuth
统一事件流全 provider 归一到同一套增量事件types.ts:700 AssistantMessageEvent
方言定义每种模型家族一套「渲染 + 扫描」双向编解码dialect/factory.ts:14 DIALECT_DEFINITIONS
在带流包装把文本流里的工具调用解码成原生 toolcall 事件dialect/owned-stream.ts:52 wrapInbandToolStream

3. Provider 接入层:一套流式接口收敛 40+

本节讲没开方言时,pi-ai 怎么把几十家服务商压成一种接口。

3.1 统一出口:三类增量事件

所有 provider,不管协议多不同,最后都吐同一种 AssistantMessageEventStream。它的事件只有几类,按内容块(text / thinking / toolcall)各有 start / delta / end,外加 done / error(types.ts:700 AssistantMessageEvent):

start → (text_start · text_delta* · text_end)
| (thinking_start · thinking_delta* · thinking_end)
| (toolcall_start · toolcall_delta* · toolcall_end) ...任意交错... → done | error

上层只认这套事件,于是"换模型"对上层是零成本的——这正是整个接入层存在的意义。

3.2 分发:一个 switch 顶住 14 种 API

入口 streamSimple(stream.ts:929)先处理鉴权,再落到 stream(stream.ts:685)→ streamDispatch(stream.ts:695),核心就是 switch (model.api)(stream.ts:756),把 anthropic-messagesopenai-responsesgoogle-vertexbedrock-converse-stream… 一条条路由到各自的 streamXxx 函数。分发前还先查自定义 API 注册表(stream.ts:708),让扩展能塞进内置之外的 API 形态而不动核心。

3.3 每家一对 client + wire:自己拼协议,不背 SDK

pi-ai 一个刻意的取舍:不依赖各家官方 SDK,而是每个 provider 拆成 *-client.ts + *-wire.ts 两个文件。以 Anthropic 为例:

  • anthropic-client.ts —— 一个极小 HTTP 客户端。它自己拼请求头(buildAnthropicHeaders)、自己序列化请求体(buildParams)、自己解析 SSE 帧(iterateAnthropicEvents),自己管重试和超时。文件头注释直说:官方 SDK 真正被用到的只有"URL 拼装、鉴权头、有限重试、超时、HTTP 错误→状态码映射"这么点,索性亲手实现这一小片(providers/anthropic-client.ts:1)。
  • anthropic-wire.ts —— 手工维护的请求/响应类型,对着官方文档写,只建 pi-ai 真正读写的字段(providers/anthropic-wire.ts:1)。好处是那些 SDK 还没跟上的 beta 字段(speedcontext_managementtask_budgetthinking.display…)在这里是一等公民,不用靠 as any 强转硬塞。

为什么这么干? SDK 版本漂移、类型滞后、多带一堆用不到的依赖;pi-ai 要同时对付 40 家、还要抢先用各家 beta,自己掌握 wire 反而更稳、更薄。代价是每家协议变了要自己跟。

3.4 鉴权轮换:key 是一个可以"换号"的解析器

上层传进来的 apiKey 不一定是一个死字符串,可以是一个 resolver(auth-retry.ts:37 ApiKey = string | ApiKeyResolver)。这让 OAuth / plan 登录 / 本地凭据这些"会过期、要刷新、有多个号可轮"的场景能塞进同一条路。

轮换用一套 a/b/c 策略(auth-retry.ts:21 ApiKeyResolveContext):

初次解析 error=∅ → 用本地缓存的、没过期的 token(便宜)
遇到可重试的鉴权错误
步骤 b error≠∅, lastChance=F → 刷新同一个号(强制重新铸 token)
步骤 c error≠∅, lastChance=T → 换到兄弟号(作废当前凭据,轮下一个)

非流式调用走 withAuth(auth-retry.ts:112)/ withOAuthAccess(auth-retry.ts:202)。流式这条要难一点:响应已经在流了,不能傻傻重放——所以 streamSimple 里有一段 replay-safe 缓冲(stream.ts:940 起):在吐出任何"重放不安全"的事件前,如果撞上可重试的鉴权错误,就把已缓冲的事件丢掉、换 key 重来;一旦已经吐了真内容,就不再回退。AUTH_RETRY_STEPS = [false, true](auth-retry.ts:82)这一个常量,把流式和非流式两条路的"刷新→换号"顺序统一成同一份。


4. 方言层:in-band 工具调用(核心)

这一节是本章的重头。方言(dialect)= 某个模型家族在纯文本里表达"工具调用 / 工具结果 / 思考"的一套约定标记,pi-ai 为它提供双向编解码

4.1 一个开关决定走不走方言

上层可以显式指定 dialect,或用环境变量 PI_DIALECT 强开(agent-loop.ts:1145 ownedDialect;agent-loop.ts:126 resolveOwnedDialectFromEnv)。这个模式源码里叫 owned(自持):工具调用的主导权从服务商手里拿回来,由 pi-ai 自己在文本层面处理。

不开 owned 时,渲染示例用的方言仍按模型自动挑(preferredDialect,catalog 侧 identity/dialect.ts:18)——即便走原生工具,给模型看的示例也得用它母语的方言,不然模型学不会。

4.2 一套接口:DialectDefinition(渲染 + 扫描)

每种方言实现同一个接口 DialectDefinition(dialect/types.ts:33),两半对称:

  • 渲染(编码,发送侧):renderAssistantToolCalls / renderToolResults / renderThinking / renderTranscript——把结构化的调用/结果/思考写成该方言的文本
  • 扫描(解码,接收侧):createScanner() 返回一个 InbandScanner,把模型吐的文本流解回结构化事件。

工厂 DIALECT_DEFINITIONS(dialect/factory.ts:14)登记了 11 种方言。它们的编码风格差异很大:

方言工具调用长什么样(骨架)家族
anthropic / xml<function_calls><invoke name=..><parameter name=..>Claude、通用回退
glm<tool_call>name + 一对对 <arg_key>/<arg_value>GLM
deepseek<|tool▁calls▁begin|>…name+分隔符+JSON(全角 / token)DeepSeek
kimi`<tool_calls_section_begin
harmony`<start
hermes / qwen3 / minimaxChatML 风 <tool_call> + JSON各开源家族
gemini / gemma```tool_code 围栏 / `<channel>` 通道

xml兜底方言(catalog FALLBACK_DIALECT),未知模型就用它;它内部其实复用 anthropic 的扫描器,或按 xmlTagset 切到 DeepSeek 的 DSML 标签(dialect/xml.ts:26)。

方言的 prompt 用同一个模板拼:renderInbandToolPrompt(dialect/catalog.ts:24)把工具目录(renderToolCatalog,每个工具一行 JSON,dialect/catalog.ts:9)填进 {{TOOLS}},把该方言的格式说明填进 {{DIALECT}}(模板见 dialect/prompt-template.md)。

4.3 发送侧:把工具"藏"进对话

开了 owned,agent-loop.ts 里做三件事(agent-loop.ts:1175 起):

1. systemPrompt 末尾追加 renderInbandToolPrompt(tools, dialect) → 工具目录变成文本
2. messages 过一遍 encodeInbandToolHistory(...) → 历史里的旧工具调用/结果改写成方言文本
3. tools = undefined → 请求里【不带】任何原生工具

第 3 步是关键:告诉服务商"这次没有工具",于是也就不存在 tool_choice,免得报错(agent-loop.ts:1225)。

第 2 步 encodeInbandToolHistory(dialect/history.ts:13)把过去回合里结构化的 toolCall / toolResult 消息,回写成该方言的文本——因为这次请求里模型只认文本形态的工具历史。它会把助手消息里的调用块渲染进正文(encodeAssistantMessage,dialect/history.ts:42),把连续的工具结果合并成一条 user 消息(encodeToolResults,dialect/history.ts:58)。

4.4 接收侧:流式扫描器 InbandScanner

模型吐的是,一次来几个字符,工具标记可能被切成两半。扫描器接口只有两个方法(dialect/types.ts:15):feed(text) 喂增量、flush() 收尾,各自返回一批 InbandScanEvent(text / thinkingXxx / toolStart / toolArgDelta / toolEnd,dialect/types.ts:6)。

难点是"半个标记"。 收到 <functio 时不能当正文吐出去,也不能确定它就是工具调用——得先攥住(hold back),等后续字符到齐再判断。anthropic 扫描器用一个状态机干这事:outside / section / invoke / parameter / thinking 五态(dialect/anthropic.ts:64),#peekTag 在标记没闭合、但可能是合法前缀时返回 "partial" 攥住不动(dialect/anthropic.ts:453)。harmony 是 token 流,用 partialSuffixOverlapAny 算"当前 buffer 末尾有多长可能是某个控制 token 的开头",把这段留住(dialect/harmony.ts:71;工具函数 dialect/coercion.ts:122)。

一段直觉版,演示"攥住半个标记":

// 示意,非源码:增量扫描的核心两难
feed(chunk) {
buffer += chunk;
const hit = buffer.indexOf("<invoke"); // 找到完整开标记?
if (hit === -1) {
const keep = maybeTagPrefix(buffer); // buffer 末尾像不像标记开头?
emitText(buffer.slice(0, buffer.length - keep));
buffer = buffer.slice(buffer.length - keep); // 攥住可能是半个标记的尾巴
return;
}
// …完整标记到了,切状态解析参数
}
// 重点看:没凑齐前绝不把可能属于标记的字符当正文吐出去

参数类型的坑:coercion。 方言文本里的参数值是裸文本,{"a":1} 到底是字符串还是 JSON?扫描器按工具 schema 判断:只有 schema 声明成纯 string 的参数才读原文,其余尝试 JSON 解析(带修复)(dialect/coercion.ts:43 isStringOnlySchemadialect/coercion.ts:28 buildStringArgsResolver;anthropic 侧 #coerceParameterValue,dialect/anthropic.ts:403)。每个解出来的调用还得凭空造个 id(mintToolCallId,dialect/coercion.ts:109,前缀 ptc_),因为服务商没给。

4.5 wrapInbandToolStream:把文本还原成原生事件

最后一步:扫描器吐的是 dialect 事件,但上层只认 AssistantMessageEventwrapInbandToolStream(dialect/owned-stream.ts:52)包住内层 provider 流,内部用 InbandStreamProjector(dialect/owned-stream.ts:123)把每个 text_delta 喂给扫描器,再把解出来的 toolStart/toolEnd 投影成 toolcall_start/toolcall_end 事件——上层于是完全分不出这是方言解码的还是原生的。

这里有两个精巧处:

(1) native 与 inband 双通道去重。 有些模型经网关(如 Gemini 走 OpenRouter)即便 owned 模式没发 tools,仍会同时回原生 functionCall。projector 用 #toolChannel("native" | "inband")记住这一回合的工具先从哪条通道来,另一条就丢掉,避免同一个调用被派发两次(dialect/owned-stream.ts:175 nativeToolStartdialect/owned-stream.ts:366 #beginTool)。

(2) 伪造工具结果 → 立即中止。 模型有时会"入戏太深",自己把工具结果也编出来往下写(比如接着打出 <function_results>)。每种方言登记了它的"结果开始 token"(RESPONSE_OPEN_TOKENS,dialect/owned-stream.ts:13);InbandStreamProjector.text() 一旦在文本里撞见这个 token,就停住扫描并触发 abort(dialect/owned-stream.ts:226),让服务商别再为这段幻觉烧 token。上层可选择"硬中止"或"排空丢弃"(agent-loop.ts:1292 传入 abortOnFabricatedToolResult)。


5. 巧妙之处(可借鉴的技术)

① 一份 render 三处复用。 同一套 renderToolCall 既用来编码历史、又用来在系统 prompt 里生成 <examples> 示例(dialect/examples.ts:7 renderToolExamples),还用来生成人读的工具清单(dialect/inventory.ts:16 renderToolInventory)。发出去的示例和历史用同一种方言语法,模型看到的永远自洽。

② 思考泄漏自愈(thinking healing)。 有的模型把思考(reasoning)漏进了正文通道。ThinkingInbandScanner(dialect/thinking.ts:28)内建了所有方言的思考定界符(<think>```thinking、harmony 的 <|channel|>analysisdialect/thinking.ts:17),不管哪种漏法都能把它从正文里捞回成 thinking 事件。

③ 跨模型思考降级(demotion)。 把 A 模型的思考塞进 B 模型的历史时,直接原样重放常被 B 静默丢弃。renderDemotedThinking(dialect/demotion.ts:25)会用目标模型自己的思考定界符包一层,让它读起来像 B 的母语思考;harmony/gemma 因为其定界符是聊天模板控制 token、不能出现在结构化消息里,退回朴素 <think>

④ Harmony 泄漏检测与恢复(信号融合)。 GPT-5 有时把 harmony 协议头(<|channel|>to=functions.…)漏进正常输出,污染工具参数。detectHarmonyLeak(utils/harmony-leak.ts:147)用多信号融合判定:控制 token H、marker M 加上 channel 词邻接 C / 乱码 token G / 脚本突变 S / 级联 B / 假结果 R / 越界 T 等共信号——单独一个 M 不触发(文档和测试自己就带这个 marker,得防误伤)。命中后对可恢复的工具(如 edit 的 hashline 输入)做截断+续写哨兵(recoverHarmonyToolCall,utils/harmony-leak.ts:256;注册表 utils/harmony-leak.ts:55),其余走中止重试。默认对整个 openai-codex 家族开着,免得未来新模型悄悄绕过(utils/harmony-leak.ts:112)。

⑤ 手写 wire 换来 beta 先发权。 不背 SDK(§3.3),让 pi-ai 能第一时间用上各家还没进 SDK 的 beta 字段。


6. 边界与局限

  • in-band 是有代价的:工具目录占 prompt token;模型可能把调用打错(少个闭标签、参数不是合法 JSON),扫描器只能尽力修复(parseJsonWithRepair),修不动就退回原文。原生结构化工具没这些烦恼。
  • 方言得跟模型家族对上:preferredDialect 按模型 id 的 family token 猜(identity/dialect.ts:18),猜错(或用了没登记的家族)就退 xml 兜底,效果打折。
  • 参数类型判定依赖 schema:没有 schema 或 schema 不准时,字符串/JSON 的判断会错(dialect/coercion.ts 尽量保守,但非万能)。
  • 手写 wire 要人肉跟协议:服务商改了字段,得手动更新对应 *-wire.ts;这是"不背 SDK"的代价。
  • 双通道去重是启发式:靠"先到先得"锁通道(§4.5),罕见时序下仍可能错判。

7. 横向对比

  • 上层怎么用这条流、怎么组织回合:01-agent-loop
  • 方言解出来的工具调用最终派发到的工具宇宙:03-tool-surface;其中 edit 工具的 hashline 输入正是 harmony 恢复能"截断续写"的那种 DSL:04-hashline-edit
  • 长程上下文里思考/历史的改写与转向,和本章的 demotion / 历史编码相邻:06-context-and-steering
  • 总览与阅读地图:index

8. 代码地图(导航索引)

主题文件路径符号名
统一入口 / 鉴权轮换外壳packages/ai/src/stream.tsstreamSimple
按 API 分发packages/ai/src/stream.tsstream / streamDispatch
自定义 API 注册packages/ai/src/api-registry.tsregisterCustomApi / BUILTIN_API_IDS
统一事件类型packages/ai/src/types.tsAssistantMessageEvent / Context
手写 HTTP 客户端packages/ai/src/providers/anthropic-client.tsAnthropicMessages / iterateAnthropicEvents
手写 wire 类型packages/ai/src/providers/anthropic-wire.tsMessageCreateParamsStreaming
鉴权 a/b/c 策略packages/ai/src/auth-retry.tswithAuth / withOAuthAccess / AUTH_RETRY_STEPS
方言定义接口packages/ai/src/dialect/types.tsDialectDefinition / InbandScanner / InbandScanEvent
方言工厂packages/ai/src/dialect/factory.tsDIALECT_DEFINITIONS / createInbandScanner
方言选择(按模型)packages/catalog/src/identity/dialect.tspreferredDialect / FALLBACK_DIALECT
prompt 注入packages/ai/src/dialect/catalog.tsrenderInbandToolPrompt / renderToolCatalog
历史改写packages/ai/src/dialect/history.tsencodeInbandToolHistory
anthropic 扫描器(状态机)packages/ai/src/dialect/anthropic.tsAnthropicInbandScanner
harmony 扫描器(token 机)packages/ai/src/dialect/harmony.tsHarmonyInbandScanner
参数类型/JSON coercionpackages/ai/src/dialect/coercion.tsisStringOnlySchema / buildStringArgsResolver / mintToolCallId
文本流→原生事件投影packages/ai/src/dialect/owned-stream.tswrapInbandToolStream / InbandStreamProjector / RESPONSE_OPEN_TOKENS
思考泄漏自愈packages/ai/src/dialect/thinking.tsThinkingInbandScanner
跨模型思考降级packages/ai/src/dialect/demotion.tsrenderDemotedThinking
Harmony 泄漏检测/恢复packages/ai/src/utils/harmony-leak.tsdetectHarmonyLeak / recoverHarmonyToolCall
owned 开关接线packages/agent/src/agent-loop.tsresolveOwnedDialectFromEnv / wrapInbandToolStream(调用点)