从0打造编码Agent 阅读 + 实验 ≈ 30 分钟 页内实验 ×2
A8 模型接入:换模型、控成本怎么办? 第 1 课

同一段带工具调用的对话,发给不同厂商长得一样吗?

三种线格式
把本课项目发给本机服务的一段真实对话(系统提示、任务、模型的两个并行 read_file、两条工具结果)现场转换成 Anthropic Messages 和 OpenAI Responses:9 个语义位置全部换了路径,调用参数一处是字符串、一处是对象,5 条消息变成 3 条或 6 个 item。只改 base URL 行不通;各家的做法分成三条路——直连一家、加一层抽象替每家补差异、只留一种用满它——pin 源码里都能看到各自付出的代价。
❓

本课一问:A1 第 1 课拆开过一次工具调用:模型写一条 tool_calls,harness 回填一条 role: tool。那是 OpenAI Chat Completions 的写法。同一段带工具调用的对话,发给 Anthropic、发给 OpenAI 的 Responses,长得一样吗?

这一课先不碰:回复怎么一帧一帧流回来(第 2 课);usage 怎么变成钱(第 3 课);怎么保证参数是合法 JSON(岔路 B2)。

从已有出发

A1 第 1 课的 Dig 里留过一句:「harness 对接哪一家,就说哪一家的格式——这是 A8『换模型』要处理的第一件事。」A2 的岔路 B1 又看过这一来一回在网络上的样子:每轮请求的开头原封不动是上一轮的全部内容。这一课不重讲往返本身,只问一件事:换一家,这段 JSON 要改多少?

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

用的是贯穿任务的一段真实对话(项目 T5 的并行调用探针,本机 Qwen/Qwen3-8B-MLX-4bit):系统提示是 miniagent 的 v2 工作规则(895 字符),任务是「Read dateparse.py and test_dateparse.py. Call read_file for BOTH files in the same reply (two tool calls at once), then stop.」;模型在同一条回复里发了 2 个 read_file,harness 把两个文件的内容回填回去(本页的工具结果由生成脚本调用项目的 Toolbox.read_file 在任务仓原文件上现跑得到)。

先自己设计:写死 Chat 格式,换 provider 只改 base URL

本课项目 miniagent 和 CoreCoder 都走 OpenAI 兼容的 Chat Completions。最省事的「换模型」:请求体一个字不改,把地址换成另一家的。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案写死 Chat Completions 格式,换 provider 只改 base URL
同一段带工具调用的对话,三种线格式

左栏:项目录制(T5 并行探针,Qwen/Qwen3-8B-MLX-4bit · mlx_lm.server 0.31.3 · 2026-09-26),工具结果由项目 Toolbox 现跑。中、右栏:按官方文档格式在浏览器里现场转换(文档抓取 2026-09-26),不是真实请求。

🎯

试三件事:① 看「调用参数」一行:Chat 与 Responses 里是字符串(JSON 编码后的文本),Anthropic 里是对象——同一个 {"path": "dateparse.py"},换一家就要多一次或少一次 JSON.parse;② 切到「原始请求」:在中栏找两条 tool_result,它们在同一条 role: user 消息里;再在右栏找 function_call_output,它们是和 function_call 并列的顶层 item;③ 切到「回复与 usage」:左栏是真实回复,finish_reason 是 tool_calls;中栏换算成 Anthropic 口径后是 stop_reason: tool_use,右栏没有停止原因,只有 output 里各 item 的类型。

⚠
常见误解:「OpenAI 兼容,就是一样的。」

同一个「兼容」接口也有方言。本课项目发给本机服务的请求里有 chat_template_kwargs(Qwen 聊天模板的开关,只有这家本地服务认);本机服务把思考放在 delta.reasoning,DeepSeek 一类放在 reasoning_content,CoreCoder 两个都读(llm.py:217-218);OpenAI 自家的新模型要把 max_tokens 改名成 max_completion_tokens,CoreCoder 专门写了一个函数,在收到 400 后按报错文字改参数重发:

CoreCoder v0.7.0:同一个「OpenAI 兼容」接口里的方言 CoreCoder@v0.7.0:corecoder/llm.py:85-101需联网 MIT
85def _adapt_rejected_param(params: dict, exc: BadRequestError) -> bool:
86 """Translate or drop one parameter a provider rejected with a 400.
87
88 Newer OpenAI models (gpt-5, o-series) accept only ``max_completion_tokens``
89 and the default temperature; the 400 message quotes the offender
90 (``'max_tokens'``). Quoted matching is load-bearing: the rejection text
91 for ``max_completion_tokens`` must not re-trigger the translation.
92 Returns False when nothing recognizable was rejected.
93 """
94 message = str(exc).lower()
95 if "'max_tokens'" in message and "max_tokens" in params:
96 params["max_completion_tokens"] = params.pop("max_tokens")
97 return True
98 if "'temperature'" in message and "temperature" in params:
99 params.pop("temperature")
100 return True
101 return False
👆 带下划线的行可以点:点哪行,讲哪行。

改进:三条路,各付各的代价

直连一家(+ 兜底)自建一层抽象用第三方 SDK 抽象只留一种、用满它
代表(pin)CoreCoder v0.7.0Pi 31b513e3 的 pi-aiOpenCode v1.18.32(Vercel AI SDK)Codex 8cabf5a
怎么对付三种线格式默认只说 OpenAI 兼容;不兼容的交给 litellm(CORECODER_PROVIDER=litellm)「协议」与「provider」拆成两维:10 种协议、40 个内置 provider,provider 只声明自己用哪种协议每家一个 @ai-sdk/* 包(19 个依赖),统一成 SDK 的消息与事件WireApi 只剩 Responses 一个变体,本地的 Ollama、LM Studio 也按 Responses 接
代价(pin 里数得出来)llm.py 净 259 行,但方言靠报错后再改(上面那段)协议实现与模型目录都要自己维护逐 provider 改写消息的 transform.ts 1,909 行;给 8 个 @ai-sdk 包打了补丁;同时在试验去 SDK 化的 packages/llm(8 个协议文件)放弃了所有只说 Chat 的服务;配置 wire_api = "chat" 直接报错
换来什么最短的路能用满每家的特性,又不被 SDK 绑住一次接入几十家能用满一家:流里边收边执行工具、按会话设缓存键(第 2、3 课)

三条路的理由,只有 Codex 在仓库里留了说明;CoreCoder 与 Pi 的仓库里都没有找到「为什么直连 / 为什么自建」的文字 。下面分别看三处源码。

Codex:WireApi 只剩一个变体,配置 chat 直接报错 codex@8cabf5a:codex-rs/model-provider-info/src/lib.rs:49-83需联网 Apache-2.0
49const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782";
50pub const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
51pub const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782";
52
53/// Wire protocol that the provider speaks.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
55#[serde(rename_all = "lowercase")]
56pub enum WireApi {
57 /// The Responses API exposed by OpenAI at `/v1/responses`.
58 #[default]
59 Responses,
60}
61
62impl fmt::Display for WireApi {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 let value = match self {
65 Self::Responses => "responses",
66 };
67 f.write_str(value)
68 }
69}
70
71impl<'de> Deserialize<'de> for WireApi {
72 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
73 where
74 D: serde::Deserializer<'de>,
75 {
76 let value = String::deserialize(deserializer)?;
77 match value.as_str() {
78 "responses" => Ok(Self::Responses),
79 "chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)),
80 _ => Err(serde::de::Error::unknown_variant(&value, &["responses"])),
81 }
82 }
83}
👆 带下划线的行可以点:点哪行,讲哪行。
Pi:协议是一个维度(10 种),provider 是另一个维度 pi@31b513e3:packages/ai/src/types.ts:17-29需联网 MIT
17export type KnownApi =
18 | "openai-completions"
19 | "mistral-conversations"
20 | "openai-responses"
21 | "azure-openai-responses"
22 | "openai-codex-responses"
23 | "anthropic-messages"
24 | "bedrock-converse-stream"
25 | "google-generative-ai"
26 | "google-vertex"
27 | "pi-messages";
28
29export type Api = KnownApi | (string & {});
👆 带下划线的行可以点:点哪行,讲哪行。
OpenCode:抽象层替每家补差异(transform.ts 的一小段) opencode@v1.18.32:packages/opencode/src/provider/transform.ts:168-196需联网 MIT
168 // Anthropic rejects messages with empty content - filter out empty string messages
169 // and remove empty text/reasoning parts from array content
170 if (model.api.npm === "@ai-sdk/anthropic") {
171 msgs = msgs
172 .map((msg) => {
173 if (typeof msg.content === "string") {
174 if (msg.content === "") return undefined
175 return msg
176 }
177 if (!Array.isArray(msg.content)) return msg
178 const filtered = msg.content.filter((part) => {
179 if (part.type === "text") {
180 return part.text !== ""
181 }
182 if (part.type === "reasoning") {
183 return (
184 part.text.trim().length > 0 ||
185 part.providerOptions?.anthropic?.signature != null ||
186 part.providerOptions?.anthropic?.redactedData != null
187 )
188 }
189 return true
190 })
191 if (filtered.length === 0) return undefined
192 return { ...msg, content: filtered }
193 })
194 .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
195 }
196
👆 带下划线的行可以点:点哪行,讲哪行。
⚠
常见误解:「Codex 只留 Responses,说明 OpenAI 废弃了 Chat Completions。」

错误外推。公告 #7782(维护者 2025-12-09 发)讲的是 Codex 自己停止支持 chat/completions:理由是那套 API 生于 GPT-3.5 时代,不是为今天的 agent 场景设计的,维护它带来复杂度和回归;Chat Completions 仍在 OpenAI 平台上。第 1 课能下的结论只是「Codex 选择只押一种线格式」。

极小真数字:同一份 usage,三种写法

线格式不只在请求里不同,回复里的用量字段也不同——而且差的是含义,不只是名字。上面那条真实回复,本机服务报的是:

T5 并行探针这一轮的输入 token:929,其中命中缓存 890(服务端 usage 原样)
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
同一个名字 input_tokens,在 Anthropic 里是 39,在 Responses 与 OTel 里是 929。第 3 课会看到:读错这一处,账单就把命中的 890 个 token 多算一遍。OpenCode 在 session.ts 里专门写了注释提醒这件事(AI SDK v6 把各家的 inputTokens 统一成含缓存,算钱前要减掉)。
🔍 看同一份回复在三种格式里的样子
这一轮的回复与 usage,三种写法

左栏:项目录制(T5 并行探针,Qwen/Qwen3-8B-MLX-4bit · mlx_lm.server 0.31.3 · 2026-09-26),工具结果由项目 Toolbox 现跑。中、右栏:按官方文档格式在浏览器里现场转换(文档抓取 2026-09-26),不是真实请求。

证据三问

问本课的回答
① 它解决什么问题?让同一个 harness 能换到另一家模型,而不重写循环
② 为什么会不同?三家的请求 schema 各自定义:同一段对话按文档转换,9 个语义位置里 9 个路径不同,由转换规则直接决定
③ 各家怎么选?Codex 只留一种(维护者公告给了理由);Pi 自建、OpenCode 用 SDK,代价在 pin 里数得出来,理由没写

演进地图:你在这里

模型接入层的演进:每一步都被上一步的真数字逼出来
  1. CoreCoder(OpenAI 兼容 + litellm 兜底)、本课项目 miniagent(标准库 urllib)
    解决了最短的路:CoreCoder 的 llm.py 净 259 行,换 provider 只改 base URL 与 key。
    卡在同一段带工具调用的对话,换成 Anthropic Messages,9 个语义位置里 9 个换了路径或类型(参数从字符串变成对象,5 条消息变 3 条);Codex 干脆不认 Chat:配置 wire_api = "chat" 直接报错。
  2. Pi 自建 pi-ai · OpenCode 用 Vercel AI SDK · Codex 只留 Responses
    解决了Pi 把 10 种协议与 40 个 provider 做成正交的两维;OpenCode 挂了 19 个 @ai-sdk/* 依赖;Codex 的 WireApi 只剩 1 个变体。
    卡在抽象层得替每一家补差异:OpenCode 逐 provider 改写消息的 transform.ts 有 1,909 行,还给 8 个 @ai-sdk 包打了补丁;而且抽象挡不住流式与缓存的细节。
  3. Anthropic 流式文档 · OpenAI function-calling 流式指南 · Codex 只收 output_item.done
    解决了开思考的一轮,第一帧 125.9 ms 就到;整包等回复的话,正文要等到 6,631 ms、工具调用 6,798 ms。
    卡在流式只让回复来得早,不让它便宜:T5 的 9 轮每轮整份重发,输入累计 15,921 token,是最后一轮 2,389 的 6.7 倍。
  4. Anthropic / OpenAI prompt caching · Codex prompt_cache_key · OpenCode applyCaching
    解决了按 usage 四段计价:T5 在 Claude Sonnet 5 价目下 $0.0140;CoreCoder 按全价估 $0.0371,是它的 2.64 倍。
    卡在缓存只省输入:开思考的 pd-v2-unlimited-s3 账单里输出占 94%;前缀里一处变、后面全失效——T10 刷新组第 7 轮 system 重建,命中从 2,072 掉到 294。(→ A9:能跑、能算账了,怎么知道它好不好?)

数字口径:pin 源码计数(Codex 8cabf5a、OpenCode v1.18.32、Pi 31b513e3、CoreCoder v0.7.0)、项目录制 T5 / T10 与逐轮 usage(本机 Qwen3-8B,token 按它的 tokenizer 数)、官方价格页快照 2026-09-26;由 a08-model-access.json 构建时现算。

一句话

📌
换 provider 换的是整套线格式:要么加一层抽象替每家补差异,要么只接一家、用满它。
🚦

先停在这里:记住三件事——系统提示放哪、调用参数是字符串还是对象、工具结果是独立消息还是塞进 user 消息。
再深一层:去岔路怎么让模型的输出一定是合法 JSON:参数是一段模型生成的文字,各家怎么保证它能被解析。

下一个缺口

回复是一点点流回来的,工具参数什么时候才能解析? → 第 2 课:流式回来的工具参数,什么时候才能解析?

这一课看的都是「整条回复」。可 agent 用的几乎都是流式接口:参数是分几段到的,三家分段的方式又各不相同。

你可能会问