模型接入:换模型、控成本怎么办?
本章一问:换模型、控成本怎么办?
主线定位
累计数据流里,本章点亮「模型接入与计费」:循环里每一次「请求模型」都从这里出去、从这里回来。
本章问题链
每一课只回答一个问题;上一课的失败,逼出下一课的问题。
| 课 | 这一课的问题 | 朴素方案在哪个真数字上失败 | 一句话 |
|---|---|---|---|
| 第 1 课 | 同一段带工具调用的对话,发给不同厂商长得一样吗? | 写死 Chat 格式只改 base URL:同一段对话 9 个语义位置,换成 Anthropic Messages 有 9 个换了路径或类型,参数从字符串变成对象 | 换 provider 换的是整套线格式:要么加一层抽象替每家补差异,要么只接一家、用满它 |
| 岔路 | 怎么让模型的输出一定是合法 JSON? | 靠服务端解析:本课项目 26 次运行里,整条调用被服务端丢掉 7 次,T5 又 3 次;CoreCoder 则把坏 JSON 静默变成空参数 | 一个机制管模型「说什么」,一个管它「怎么调用」;约束采样之外的例外,harness 仍要兜 |
| 第 2 课 | 流式回来的工具参数,什么时候才能解析? | 两个并行调用共用一个缓冲:{"path": "dateparse.py"}{"path": "test_dateparse.py"} 不是合法 JSON(真实录制) | 流式工具参数按调用分别累积,收到结束信号才解析 |
| 岔路 | 出错、限流、变慢,怎么定位? | 只看总耗时:T5 的 9 轮里,第 8、9 轮首帧要等 1,029 / 978 ms,是其他只调工具的轮的两倍多,原因藏在帧里 | 先按 span 拆开时间再找慢在哪;重试要分层,限流不在传输层硬重试 |
| 第 3 课 | 每轮都重发全部历史,钱花在哪?缓存能省多少? | CoreCoder 按全价估账:T5 在 Claude Sonnet 5 价目下是按四段算的 2.64 倍 | 缓存命中率就是输入侧的钱:前缀一变就归零;开思考时,大头在输出 |
| 实验课 | 线缆与账单:真实帧回放、同一次运行换价目重算 | — | 线格式、帧、账单三样都要能从真实记录里复算出来 |
演进地图
本章的「现代做法」是被一连串真实数字逼出来的。每一课都会用这张图标出「你在这里」。
- 起点 写死一家的线格式CoreCoder(OpenAI 兼容 + litellm 兜底)、本课项目 miniagent(标准库 urllib)解决了最短的路:CoreCoder 的
llm.py净 259 行,换 provider 只改 base URL 与 key。卡在同一段带工具调用的对话,换成 Anthropic Messages,9 个语义位置里 9 个换了路径或类型(参数从字符串变成对象,5 条消息变 3 条);Codex 干脆不认 Chat:配置wire_api = "chat"直接报错。 - 第一步 加一层抽象,或只留一种用满它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包打了补丁;而且抽象挡不住流式与缓存的细节。 - 第二步 流式:按调用分别累积,结束才解析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 倍。
- 第三步 缓存感知的账单 + 稳定前缀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 构建时现算。
贯穿任务:修好 test_parse_date
全课的贯穿任务是项目 04-实践项目/tasks/parse_date:让失败的 test_parse_date 通过,测试文件不许改。这一章给它的 trace 加上线缆层:每一轮请求在网络上的样子、回复的每一帧什么时候到、这一轮按价目算多少钱。
本章用到的真实数据(项目线的 T5,本机 Apple M5 Max, 128 GB,Qwen/Qwen3-8B-MLX-4bit,mlx_lm.server on 127.0.0.1:8080 (OpenAI-compatible /v1/chat/completions)):
- agent 全程流式:同一个任务、关闭思考、seed 1,9 轮,每一帧的原始
data:行与到达时刻都录了下来;其中 3 轮服务端报finish_reason: tool_calls却一个调用也没给(A1 第 3 课见过的「调用被丢弃」),以dropped_tool_call停,验收没过。 - 并行工具调用探针:让模型一次回复同时读两个文件,6 帧;同一请求再发一次非流式,流式拼出的消息与之逐字节一致,只差服务端随机生成的调用 id。
- 思考模式探针:任务的第一轮请求,开思考,706 帧;首帧 125.9 ms,第一段正文 6,631 ms。
- 录制时的一个发现:mlx_lm.server 不增量发送工具参数:整个 <tool_call> 生成完才发一帧完整的 tool_calls(带 index);content / reasoning 按 token 增量发送。所以「参数逐字流来、中途解析会失败」这件事,在本机录制上看不到,要到按官方文档构造的事件流上才看得到。
- 账单用的是项目里六次真实运行的逐轮 usage(T5、开思考与关思考、分层压缩、T10 两天会话的冻结组与刷新组),价格取两家官方价格页 2026-09-26 的快照。
这一章没有云端 provider 的真实录制。规则禁止经手任何 API key,所以设计卡里「同一任务经 Anthropic Messages 与 OpenAI Responses 各跑一次」没有做。页面上这两家的请求与事件流,都是把本机那条真实回复按官方文档的格式转换或构造出来的,一律标「按文档构造,非真实录制」;项目的 Anthropic 拼装器也只对照过这种构造的事件,不是录制。本机服务的帧是真实录制,两者在组件里用实线 / 虚线按钮分开。
三层交互
| 层 | 本章里的组件 | 在哪一课 |
|---|---|---|
| 概念动画 | 演进地图(每一步卡在哪个真数字上);流式拆帧器的「按真实节奏播放」,帧落在时间轴上 | 每一课、第 2 课 |
| 算法模拟器(真算法、小数据) | 三种线格式的现场转换(JS 与 python 两份独立实现逐字节一致);三种拼装策略跑同一条流;Pi 的 calculateCost 与 CoreCoder 的 estimated_cost 移植进浏览器,插入「换模型 / 空闲 6 分钟 / system 放时间戳」按官方规则重算;五种实现对同一段参数文字的处理 | 第 1、2、3 课,岔路 B2 |
| 真实数据 | T5 的全部真实帧(并行探针、706 帧思考、agent 9 轮)逐帧回放并与录制的完整消息比对;六次真实运行的逐轮账单;由 trace 现算的 span 瀑布 | 第 2、3 课,岔路 B1,实验课 |
每个移植都配了自检,组件底部显示结果:拼装器与录制消息逐字节比对;账单在 276 个用例上与 bun 跑的 Pi 原文、python 跑的项目 cost.py 和 CoreCoder 原文逐项比对(误差都小于 1e-9)。生成脚本:src/data/precomputed/gen_a08_model_access.py(+ .ts)与 gen_a08_prices.py;组件纯函数的 node 单测:test_a08_pure.mjs。
项目推进:里程碑 M8 · provider 抽象 + 流式 + 成本账
对应 04-实践项目/MILESTONES.md 的「A8 · 模型接入与工程化」。已有:标准库写的 OpenAI 兼容客户端(miniagent/llm.py)、两套拼装器(stream.py)、按 usage 算成本(cost.py)、429 不在传输层重试。代码 AI 来写,决策你来做。
- 直连 SDK、第三方抽象(litellm / Vercel AI SDK),还是自建最薄一层?第 1 课的对照:CoreCoder 直连 + litellm 兜底,Pi 自建 10 种协议 × 40 个 provider,OpenCode 用 AI SDK 但要维护 1,909 行的逐家改写,Codex 只留一种。本项目现在:标准库自建,两家线格式各一个拼装器。写进
DECISIONS.md,理由要引用第 1 课的三栏对照。 - 事件处理:穷举还是兜底?Anthropic 文档要求「优雅地处理未知事件」;Codex 把认识但不用的事件显式列成一张忽略表,真不认识的记一条 debug 日志(第 2 课)。本项目:已知类型逐一处理,
ping忽略,error抛异常。 - 开不开缓存,断点放哪?第 3 课的账单:前缀够不到最小可缓存长度时根本不缓存;system 里放会变的内容,每轮都得重写。本地服务自动做前缀缓存,M6 的「冻结」布局决定命中多少。
- 限流谁来重试?Codex 的传输层
retry_429: false,CoreCoder 在传输层按 2 的幂重试 429(岔路 B1)。本项目:429 立刻抛给上层。
验收标准(对应大纲 M8 ①-⑥;括号里是 2026-09-26 在项目里核对的现状):
- ① 同一任务在 2 个 provider 上跑通(未做:规则禁止接需要 API key 的云端 provider。可做的替代:把本机服务换成另一个 OpenAI 兼容的本地后端再跑一次,比较 usage 字段与帧的差异)。
- ② 成本由 usage 与价格快照算出,与手算误差 < 1e-9(
tests.test_stream.Cost已有;本章实验课把同一算法接上真实价目,误差见组件自检)。 - ③ 流式 tool call 拼装与录制的完整消息字节一致(T5 并行探针:与录制的拼装结果一致,与同 seed 非流式回复除调用 id 外一致;本章浏览器里重跑一遍,结果在第 2 课组件底部)。
- ④ 并行工具调用拼装(
test_parallel_tool_calls_keep_their_index_order)。 - ⑤ 429 不在传输层立即重试(
test_429_is_not_retried_in_the_transport,流式与非流式各 1 次请求)。 - ⑥ 导出 T5(
src/data/project/agent-t5-sse.json)。 - 本章建议补的两条:(a)拼装器对「服务端报 tool_calls 却没给调用」的轮次要有测试钉住行为(T5 第 7-9 轮是真实样本);(b)
cost.py目前只认 5 分钟写入倍率,1 小时写入与按模型的命中例外(Opus 5.5 0.05×、Fable 5.1 0.025×)要么支持、要么在报告里注明。
坑与带走
本章拦截的误解| 误解 | 纠正 | 在哪一课 |
|---|---|---|
| OpenAI 兼容就是一样的 | 同一个「兼容」接口也有方言:本地服务要 chat_template_kwargs,思考字段叫 reasoning 还是 reasoning_content 各家不同,OpenAI 新模型要把 max_tokens 改名 | 第 1 课 |
| Codex 只留 Responses = OpenAI 废弃了 Chat Completions | 那是 Codex 自己的选择(维护者公告 #7782);Chat Completions 仍在 OpenAI 平台上 | 第 1 课 |
服务端给了 tool_calls,JSON 就一定合法 | 本机服务遇到坏 JSON 直接丢掉整条调用;CoreCoder 把它静默变成空参数 | 岔路 B2 |
| 流式只是 UI 效果 | 它决定工具什么时候能开始执行:Codex 收到一个完整的调用项就把工具排进执行队列,流还没结束 | 第 2 课 |
| 首 token 时间就是模型开始干活的时刻 | 本机服务把工具调用攒完整才发:只调工具的一轮,首帧到达时整个调用已经生成完 | 第 2 课、岔路 B1 |
| 大厂代码处处优雅 | Codex 的 Retry-After 是用正则从错误文字里刮出来的 | 岔路 B1 |
| 缓存改变了模型的输出 | 缓存复用的是同一段前缀的计算结果,请求一字不差;变的只是价格与速度 | 第 3 课 |
| 编码 agent 的钱都花在重发的上下文上 | 看你的运行:关思考时输入占大头,开思考时本课的运行里输出占 94% | 第 3 课 |
input_tokens 各家一个意思 | Anthropic 不含缓存,OpenAI 与 OTel 约定含缓存;读错一处就重复计费 | 第 1、3 课 |
| 症状 | 后果 | 怎么办 |
|---|---|---|
| 写死一种线格式 | 换 provider 要大改 | 加抽象层,或明确「只用一家、用满它」 |
| 中途解析 partial JSON | 抛错或拿到半截参数 | 按调用累积到结束信号(或像 Codex 只收完整项) |
| 所有调用共用一个缓冲 | 并行工具调用拼成非法 JSON | 每个 index 一个缓冲 |
_ => {} 吞掉未知事件 | 协议新增事件被静默丢弃 | 认识的逐一处理,认识但不用的列表忽略,不认识的至少记日志 |
坏 JSON 静默变成 {} | 工具以空参数执行,模型不知道自己错了 | 把解析错误作为工具结果回给模型 |
| 前缀里有易变内容 | 缓存每轮失效、每轮付写入价 | 稳定前缀,易变内容后置或放进消息 |
| 账单按 prompt 全价估 | T5 上高估 2.64 倍 | 按 usage 四段算,注意 input_tokens 的口径 |
| 429 立即重试 | 加剧限流、烧配额 | 交给上层按重置时间处理 |
带走这些句子(各课「一句话」):
- 换 provider 换的是整套线格式:要么加一层抽象替每家补差异,要么只接一家、用满它。
- 一个机制管模型「说什么」,一个管它「怎么调用」;约束采样之外的例外,harness 仍要兜。
- 流式工具参数按调用分别累积,收到结束信号才解析。
- 先按 span 拆开时间再找慢在哪;重试要分层,限流不在传输层硬重试。
- 缓存命中率就是输入侧的钱:前缀一变就归零;开思考时,大头在输出。
深入这一章
按你愿意花的时间选。每条都在 2026-09-26 实际打开核对过;站外链接需联网。视频缺口:缓存、计费、流式拆帧都没有找到专门讲机制的视频,用官方文档里的图与 Sam Rose 的交互长文代替。
🎮 想更直观 10–30 分钟
🔬 想吃透原理 1–3 小时
📄 想读原文 按需
我替你排除了什么:litellm 延迟「2s → 4.5s」一类的具体倍数(找不到可引用的测量)· 「OpenAI 缓存折扣从 50% 提到 90%」(历史变化未核实)· Braintrust「核心闭源」的竞品说法(未核实,且利益相关)· 需要登录才能看的 SaaS 追踪平台(Langfuse 云端控制台、OpenAI Traces dashboard;Langfuse 本身 MIT 可自托管,岔路 B1 提到但不列为资料)· AgentPrism 组件库(交互做得好,但本章的瀑布图已用真实 trace 实现,不再引入 React 依赖)· 任何「成本大头一定是上下文」的二手结论(第 3 课用自己的运行现算,结论随负载变)。