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

怎么让模型的输出一定是合法 JSON?

让输出一定是合法 JSON
工具参数是模型生成的一段文字。本课项目的原生工具调用里,服务端解析器 7 次遇到坏 JSON,每次都把整条调用丢掉;换成「提示词里约定 JSON、自己解析」的两次运行没有一次解析失败,却有 6 轮写满上限被截断。同一段坏参数交给五种实现:本机服务丢弃、CoreCoder 静默变成空参数、OpenCode 改写成一个报错工具、本课项目回报错误让模型重发、服务端按 schema 约束采样则根本生成不出来。约束采样有两个互补的开关:一个约束整条回复,一个约束工具调用。
❓

本课一问:工具参数、结构化的最终答案,都是模型生成的一段文字。怎么保证它一定能被 JSON.parse、一定符合 schema?

这一课先不碰:参数分段流回来时什么时候解析(第 2 课);约束解码在语法层面怎么实现(A2 第 3 课 讲过 Codex 的 Lark 语法)。

从已有出发

A1 第 1 课留过一句话:「协议并没有让输出变得一定合法……两种方式的详细对照在 A8。」这里兑现。A1 第 3 课的「服务端丢弃调用」、A2 第 3 课的约束解码,是同一个问题的两头。

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

先自己设计:交给服务端解析,或者自己约定格式

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案交给服务端的原生解析;或在提示词里约定 JSON、自己解析

两组真实运行并排:左边关闭思考的原生工具调用,第 7 轮起连着三次「调用被丢弃」;右边提示词约定 JSON 的运行,解析都成功,但第 3 轮写满上限被截断。

原生工具调用(关思考)vs 提示词约定 JSON(开思考),同一任务、seed 1 请求模型回复工具调用工具结果harness 注入

数据:本课项目 miniagent 的真实运行(src/data/project/agent-traces.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。

两组运行差的不只是调用方式:右边开着思考,左边关着。这不是受控对照,只能说明「两种方式都会出问题,出的问题不一样」,不能比较谁更稳。

同一段参数,五种实现

同一段参数文字,五种实现各会怎样
① 模型写出的参数(可以直接改)
② 这个工具的 JSON Schema(本课项目真实定义)
 
③ 交给五种实现
🎯

试三件事:① 默认预设是「正则里的反斜杠没转义」:这是项目 README 记下的服务端丢弃原因(模型在改 _DAY_FIRST 的正则时写出 \d,JSON 里这不是合法转义),五行里哪几行是「静默出错」?② 换到「缺 required 的 old_string」:JSON 合法、schema 不合法——本机服务照单全收,本课项目在执行前拦下;③ 换到「多一个 schema 里没有的字段」:这份 schema 没写 additionalProperties: false,校验不拦;strict 模式要求必须写它。

CoreCoder 的那一行来自这段源码——第 2 课看过它按 index 分槽拼参数,问题在最后几行:

CoreCoder v0.7.0 _drain:坏 JSON 变成空参数 CoreCoder@v0.7.0:corecoder/llm.py:242-250需联网 MIT
242 # parse accumulated tool calls
243 parsed: list[ToolCall] = []
244 for idx in sorted(tc_map):
245 raw = tc_map[idx]
246 try:
247 args = json.loads(raw["args"])
248 except (json.JSONDecodeError, KeyError):
249 args = {}
250 parsed.append(ToolCall(id=raw["id"], name=raw["name"], arguments=args))
👆 带下划线的行可以点:点哪行,讲哪行。

OpenCode 把这件事交给 AI SDK 的修复钩子:工具名大小写不对就改名;修不了的,改写成一个叫 invalid 的工具,把原工具名和错误信息写进参数,模型下一轮看得到:

OpenCode:修不了的调用改写成 invalid 工具(session/llm.ts) opencode@v1.18.32:packages/opencode/src/session/llm.ts:296-312需联网 MIT
296 async experimental_repairToolCall(failed) {
297 const lower = failed.toolCall.toolName.toLowerCase()
298 if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
299 return {
300 ...failed.toolCall,
301 toolName: lower,
302 }
303 }
304 return {
305 ...failed.toolCall,
306 input: JSON.stringify({
307 tool: failed.toolCall.toolName,
308 error: failed.error.message,
309 }),
310 toolName: "invalid",
311 }
312 },
👆 带下划线的行可以点:点哪行,讲哪行。

改进:约束采样的两个开关

Anthropic 的结构化输出文档把它分成两个互补的机制 :

JSON 输出:output_config.format严格工具:工具定义上的 strict: true
约束什么模型这一次回复的正文:必须是符合给定 schema 的 JSON模型调用工具时:工具名必须在清单里,参数必须符合该工具的 schema
用在 agent 的哪里最后交结果、子 agent 交回结构化摘要、评测时让判官输出分数每一轮的工具调用——本课一直在处理的那段参数文字
怎么做到按 schema 编译出语法,采样时逐 token 约束(约束解码,A2 第 3 课)同上
文档写明的例外拒答(stop_reason: refusal)、写到 max_tokens 被截断时可能不合 schema;字符串 enum 的大小写不保证同左
代价首次用某个 schema 要编译语法(缓存 24 小时);会多注入一段系统提示,改动它会让 prompt 缓存失效schema 只支持 JSON Schema 的一个子集;对象必须写 additionalProperties: false
⚠
常见误解:「开一个结构化输出就够了。」

两个开关管的不是一件事:一个管模型说什么(最终回复的形状),一个管模型怎么调用(工具名与参数)。只开 JSON 输出,工具调用的参数照样可能坏;只开严格工具,最后交回来的总结照样是自由文本。而且两者都有文档写明的例外——截断、拒答——所以 harness 端的「解析失败就回报错误」仍然不能省。

证据三问

问本课的回答
① 它解决什么问题?模型生成的参数或答案能被程序可靠地解析、符合约定的结构
② 为什么可能有效?采样时按 schema 编译的语法约束,每一步只允许合法的下一个 token,生成出来的文字必然合法(截断、拒答除外)
③ 实验上呢?本课项目没有接任何带约束采样的服务;真实运行只说明了两种不约束的方式各有各的失败(服务端丢弃 7 次 / 截断 6 轮)

演进地图:你在这里

这一课挂在演进图「加一层抽象」那一步旁边:它处理的是各家线格式里同一个位置——调用参数那段文字。

模型接入层的演进:每一步都被上一步的真数字逼出来
  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 构建时现算。

一句话

📌
一个机制管模型「说什么」,一个管它「怎么调用」;约束采样之外的例外,harness 仍要兜。
🚦

先停在这里:回主线第 2 课,看参数分段流回来时的解析时机。
再深一层:把 miniagent 的工具 schema 都补上 additionalProperties: false,再跑一遍 tests.test_tools:哪些现有用例依赖了「多传字段也不报错」?

下一个缺口

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

你可能会问