流式回来的工具参数,什么时候才能解析?
本课一问:第 1 课看的是整条回复。可 agent 几乎都用流式接口:回复一段一段地到,工具参数也是分段来的。参数什么时候才能 JSON.parse、什么时候才能开始执行?
这一课先不碰:首 token 时间、吞吐这些指标的机制(LLM 课 C13 第 5 课 讲过,这里只用);出错、限流怎么重试(岔路 B1);账单(第 3 课)。
从已有出发
还是第 1 课那条真实回复:模型在一次回复里发了两个 read_file。这一次看它是怎么一帧一帧到的。A1 第 3 课里「服务端报 tool_calls 却没给调用」的那几轮,在这一课的时间轴上也会看到它们的样子。
本机服务的流(OpenAI Chat 兼容)每一帧是一行 data: {...},最后一帧是 data: [DONE];两家云端的流还多一行 event:,事件名与 data 里的 type 相同。录制时发现:mlx_lm.server 不增量发送工具参数:整个 <tool_call> 生成完才发一帧完整的 tool_calls(带 index);content / reasoning 按 token 增量发送。
先自己设计:收到就解析,或者一个缓冲装全部
两个最容易想到的写法:① 每收到一段参数就 JSON.parse 一次,成功了就用;② 回复里所有工具参数都往同一个字符串里追加,流结束再解析。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
改进的写法各家文档说得很一致:按调用分别累积,等这个调用的结束信号再解析。结束信号在三种线格式里各不相同——Anthropic 是这个块的 content_block_stop,Responses 是 function_call_arguments.done(或整个 output_item.done),Chat 兼容流没有逐个调用的结束事件,只能等整条回复的 finish_reason。把上面组件的策略切到「按调用分别累积」,三个数据源都能拼对。
{"path": "dateparse.py"}{"path": "test_dateparse.py"},JSON.parse 报错(本机 node 的措辞:Unexpected non-whitespace character after JSON at position 24 (line 1 column 25))——两个调用一起作废。每来一段就 parse:在本机录制上一次也没失败,因为这家服务把整个调用攒完才发一帧;换到按 Anthropic 官方文档构造的事件流上,7 个参数片段里失败 7 次,第一次就在第 3 个事件(一个空串片段)。 试三件事:① 在「并行调用 · 真实录制」上把策略切到「每来一段就 JSON.parse」:结论行说它一次也没失败——看左栏,两个调用各只有一帧,参数是整段到的;② 切到「Anthropic · 按文档构造」,同一个策略:第一个片段是空串,JSON.parse('') 就报错,之后每一段都是半截 JSON;③ 回到「共用一个缓冲」,在 Anthropic 源上拖到最后:两个 tool_use 块的参数首尾相接,和真实录制上的失败一模一样。
改进:每个调用一个缓冲,结束才解析
Anthropic 的流式文档写明:input_json_delta 是不完整的 JSON 片段,最终的 tool_use.input 才是对象;可以累积到 content_block_stop 再解析 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条官方文档直述事件模型与解析时机;它是机制说明,不是评测。文档还写明标准流式下模型每次攒够一个完整的键值再分段发出,细粒度流式(eager_input_streaming)则不缓冲、不校验,可能收到不合法的 JSON。 出处Anthropic Streaming Messages(2026-09-26 抓取) ↗需联网 。OpenAI 的 function-calling 指南同样按 output_index 累积 function_call_arguments.delta,.done 事件给出完整参数。
参数「怎么分段到」本身也是一个设计选择,四家各不相同:
| 谁 | 参数怎么到 | 什么时候能解析 | 出处 |
|---|---|---|---|
| 本机 mlx_lm.server(Chat 兼容) | 整个调用生成完,一帧给出完整参数 | 这一帧到了就行,但只有整条回复结束才知道没有别的调用了 | T5 录制(项目实测) |
| Anthropic,标准流式 | 攒够一个完整的键值,再切成几段 input_json_delta 发 | 这个块的 content_block_stop | 官方流式文档 |
| Anthropic,细粒度流式 | 边生成边发,不缓冲、不校验,可能不合法 | 同上,且解析要防御 | 官方 fine-grained tool streaming 文档 |
| OpenAI Responses | function_call_arguments.delta 逐段,.done 与 output_item.done 给完整值 | .done 或 output_item.done | 官方 function-calling 指南 |
Codex 用的是 Responses,可它根本不拼参数增量:参数增量事件被它列进了一张「认识但不处理」的表,它只收 output_item.done 里那个完整的调用项。
它还决定工具什么时候能开始执行。Codex 收到一个完整的调用项,就把工具的执行排进一个「进行中」的队列,流里后面的内容还在继续到;CoreCoder 则把整条流收完才返回,工具在那之后才开始跑(它因此可以在流中途断掉时整条重发,注释里写着「此时还什么都没执行」,llm.py:184-187)。两种写法对「中途出错怎么办」的回答完全不同。
CoreCoder 的拼装与本课项目 miniagent/stream.py 是同一个思路:按 index 各开一个槽,参数字符串往里追加,整条流结束再逐个解析。它和本组件「按调用分别累积」这条策略做的是同一件事——只有一处不同,留到岔路 B2 再看(第 248-249 行):
极小真数字:两个调用、六帧、一个时间轴
并行探针的 6 帧全在下面(毫秒是距请求发出的时刻,客户端测;耗时受本机同时运行的其他负载影响,只能粗看;token 与轮数可比):
同一个任务开思考的第一轮,706 帧:首帧 125.9 ms 就到了——可那是思考;第一段正文 6,631 ms,工具调用 6,798 ms。「首 token 时间」在 agent 里至少有三种读法,用哪一种要先说清;它们的机制(prefill 与 decode)在 LLM 课 C13 第 1 课和第 5 课。
在本机服务上,只调工具的轮次里首帧就是整个调用:T5 的 agent 运行中,第 1-6 轮首帧在 323–519 ms 之间,每一轮只有 4 帧;第 7 轮先流出一段正文,然后安静了 885.8 ms——模型在写一个工具调用,服务端解析失败把它丢了,这段时间一帧也没有。岔路 B1 用 span 瀑布把这 9 轮摊开看。
🔍 把 agent 全程 9 轮与 706 帧的思考摊开看
第 7 轮:108 帧里,正文一段段到,然后一帧空的 delta,接着 finish_reason: tool_calls——却没有任何调用。切到第 8、9 轮:只有 4 帧,首帧要等 1 秒左右。切到「开思考一轮」:706 帧,紫色的思考帧密密排到 6.6 秒,正文和工具调用挤在最后。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 流式回复里,工具参数分段到达;要在「拿到完整参数」的那一刻解析、执行,不早也不晚 |
| ② 为什么可能有效? | 共用缓冲时,两个非空 JSON 对象首尾相接一定不是合法 JSON;按调用分开累积、结束才解析,就不会拿到半截 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:JSON 文本只允许一个顶层值;{…}{…} 必然解析失败。本组件在真实录制与构造事件上各跑一遍,结果与此一致。 出处待补 |
| ③ 实验上呢? | 本机真实流:共用缓冲失败、分开累积成功、拼出的消息与录制逐字节一致;「每来一段就 parse」只在构造的云端事件流上失败 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测(T5,本机 mlx_lm.server,单一模型、单次录制)+ 按官方文档构造的事件流(非录制,切分点是构造的)。云端真实流的分段方式没有录到。 出处待补 |
演进地图:你在这里
- 起点 写死一家的线格式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 构建时现算。
一句话
先停在这里:记住三种结束信号——Anthropic 的 content_block_stop、Responses 的 .done / output_item.done、Chat 兼容流只能等 finish_reason。
再深一层:去岔路出错、限流、变慢,怎么定位?,用 span 瀑布看这 9 轮的时间花在哪,以及流断了、被限流了各家怎么重试。
下一个缺口
每轮都重发全部历史,钱花在哪?缓存能省多少? → 第 3 课
流式让回复来得早,但不让它便宜:T5 的 9 轮每一轮都把全部历史重发一遍。每一帧 usage 里那几个数字,到底怎么变成钱?
你可能会问
- 「为什么本机服务不逐字发参数?」 录制上看到的现象是:整个
<tool_call>生成完才发一帧。本课没有读 mlx_lm 的源码,只能说「录制观察」。它的后果是:本机服务的首帧时间在只调工具的轮次里等于整个调用的生成时间,岔路 B1 会用到这一点。 - 「能不能用 partial JSON 解析器,边收边显示参数?」 可以用来显示(Anthropic 文档提到了这类库);但执行必须等完整参数,否则会拿半截路径去读文件。
- 「流断在一半怎么办?」 Anthropic 文档给了续写办法,并写明工具调用块与思考块不能部分恢复;CoreCoder 在还没执行任何工具时整条重发。重试的分层在岔路 B1。