GLM 流式输出下的工具调用怎么拼装:分片合并与踩坑清单
数据截至 2026-08,价格与限额以各官网为准。本文只讲计费与接入机制,不列具体价格数字。
一句话说完:GLM 的流式工具调用要同时打开 stream 和 tool_stream 两个开关,打开之后函数参数会被拆成很多片送过来,官方文档原文写的是「在不进行缓冲或 JSON 验证的情况下流式传输工具使用参数」——意思是你收到的每一片 arguments 都不保证是合法 JSON,中途做 json.loads 必然炸。正确的拼装方式是:以 tool_call.index 作为归并键建一张表,首次出现该 index 时把整个 tool_call 对象存下来(里面带着 id 和 function.name),之后的分片只往 function.arguments 后面做字符串追加,等整个流走完再统一解析。绝大多数「参数解析失败」「函数名是空的」「tool_call_id 找不到」的问题,根源都在这一步的写法上,而不在模型本身。
先分清两个开关:stream 和 tool_stream 不是一回事
普通流式输出只需要 stream=True。官方《流式消息》一页把它写得很直白:stream=True 启用流式输出,必须设置为 True,响应采用服务器发送事件(Server-Sent Events,SSE)格式。
但工具调用的流式是另一个开关。官方《工具流式输出》一页列的核心参数是三个:stream=True 启用流式输出、tool_stream=True 启用工具调用流式输出、model 用支持工具调用的模型。这里有个很容易被跳过的细节——这两个开关是分开的,tool_stream 不会跟着 stream 一起打开。API 参考里 tool_stream 字段的说明是「是否开启流式响应 Function Calls」,默认值 false;《迁移到 GLM 新模型》一页在讲 GLM-5.3 时也写着「默认 False 关闭,需同时打开」stream=True 与 tool_stream=True。所以第一次接这个功能只写了 stream=True,工具调用参数的流式构建这条路根本就没打开,这时候先别怀疑模型不会调工具,回头看看请求体里是不是少了半个开关。至于不开 tool_stream 的时候 delta.tool_calls 具体会是什么形态,官方文档里没有找到相关说明,别照着猜出来的表现去写判断条件。
关于支持范围,有个坑得先说清楚:同一批官方文档里,两处的口径不一样。《工具流式输出》功能特性那一句写的是「工具调用在最新 GLM-5.2、GLM-5.1、GLM-5、GLM-4.7 模型中现在支持开启响应的流式输出」;而 API 参考里 tool_stream 字段的描述写的是「仅限 GLM-5.3 GLM-5.2 GLM-5.1 GLM-5 GLM-5-Turbo GLM-4.7 GLM-4.6 系列支持此参数」,比前者多出 GLM-5.3、GLM-5-Turbo、GLM-4.6 三个系列。哪一份是当前口径,文档本身没有交代。所以别拿指南页那一句去反推「某某模型不支持」——判断某个模型能不能传这个参数,以 API 参考里该字段说明的当前版本为准;更稳的做法是压根别把支持列表硬编码进业务判断,模型支持矩阵是会随版本更新的。
至于要不要开这个开关:官方给的理由是减少调用延迟、提供更好的用户体验,典型场景是智能客服里实时显示查询进度,以及代码助手里显示工具调用链。如果你的产品界面根本不展示中间过程,收到完整结果再渲染,那非流式的工具调用写起来简单得多,没必要给自己找拼装的麻烦。
delta 里有三个字段,它们各走各的
流式响应中的 delta 对象,官方文档列了三个字段:
reasoning_content:模型推理过程的文本内容content:模型回答的文本内容tool_calls:工具调用信息,包含函数名和参数
这三个是三条独立的流,不要拿一个缓冲区去接。官方示例里也是分别用 reasoning_content、content、final_tool_calls 三个变量去收的;其中前两个各自还带了一个「是否已开始输出」的标志位(reasoning_started、content_started),用来控制界面上什么时候打印分段标题,而 final_tool_calls 是一张以 index 为键的 dict,不需要这个标志。
实践里最常见的错法是把 reasoning_content 和 content 拼进同一个字符串,然后整段展示给用户。思考过程和最终回答混在一起,用户看到的就是一坨自言自语。它们分开给你,就是让你分开渲染的——思考过程可以默认收起、可以灰字、可以做成一个「正在思考」的浮层,回答内容才是主体。
另外注意官方示例读这两个字段时用的是 hasattr(delta, 'reasoning_content') and delta.reasoning_content 这种双重判断:先判属性在不在,再判值是不是空。文档没有解释为什么要写成这样,但既然官方示例对 reasoning_content 和 content 都套了同一层防御,照抄它比直接裸取省事——Python 这一层的道理是明确的:属性不存在时裸取会抛 AttributeError,值是 None 或空串时不判空,后面那句字符串拼接和逐片打印都得多写一层保护。顺带一提,官方示例控制标志位用的是 delta.reasoning_content.strip(),比外层的判空更严一档,图的是不要因为一个纯空白的分片就把分段标题提前打出来。
拼装的核心:按 index 归并,收完再解析
官方示例里那段归并逻辑,是整页文档最值钱的部分:
final_tool_calls = {}
for chunk in response:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.tool_calls:
for tool_call in delta.tool_calls:
index = tool_call.index
if index not in final_tool_calls:
# 首次出现这个 index:整个对象存下来
final_tool_calls[index] = tool_call
final_tool_calls[index].function.arguments = tool_call.function.arguments
else:
# 已经见过:只追加参数片段
final_tool_calls[index].function.arguments += tool_call.function.arguments
三个要点,逐个说。
第一,归并键是 index,不是函数名。 官方示例用 index 作 dict 的键、而不是顺序往一个缓冲区里追加,这个写法本身就说明同一次响应里可能存在不止一个工具调用——《工具调用》一页的非流式示例也是 for tool_call in message.tool_calls 这样按列表遍历的,而不是只取第一个。至于多个调用的分片按什么顺序送达,官方文档里没有找到相关说明;好在只要老老实实按 index 归并,送达顺序怎么排都不影响结果,这也正是这个写法的价值所在。反过来用函数名当 key 就不行了:模型如果在同一轮里调用同一个函数两次(比如查两个城市的天气),两次调用会共用一个 key,参数被追加成一坨。
第二,首片和后续片的角色不一样。 照官方示例的写法,首次遇到某个 index 时是把整个 tool_call 对象存进表里的,id 和 function.name 都在这一步落下来;之后的分片只贡献 function.arguments 的字符串追加。所以你的数据结构里不能只留一个 arguments 字符串——id 丢了,后面回填结果那一步会卡死(下一节讲为什么)。
第三,绝对不要在循环里 json.loads。 官方对这个特性的描述是「在不进行缓冲或 JSON 验证的情况下流式传输工具使用参数」,这句话就是在告诉你:分片是按流吐的,不保证任何一片自身是完整合法的 JSON。你可能收到 {"loca、tion": "北、京"} 这样的碎片。想着「先试着解析一下,解析成功就说明收完了」的写法,在参数值里出现 } 或者恰好前半段能构成合法 JSON 的时候会给出一个错误的早停。只在整个流结束后解析一次。
对应地,也别在循环里做参数校验、别在循环里触发函数执行。工具的真正执行必须发生在流走完之后。
拼完之后:怎么把结果送回模型
拼装不是终点。工具调用的完整回合是「模型请求调用 → 你执行 → 结果送回模型 → 模型给最终回答」,第三步用的字段在官方《工具调用》一页有明确说明。
响应里的关键字段是:tool_calls 包含模型决定调用的函数信息,function.name 是被调用的函数名称,function.arguments 是函数调用参数且是 JSON 格式的字符串(所以要 json.loads 一次才是对象),id 是工具调用的唯一标识符。
回填时构造的消息长这样:
messages.append({
"role": "tool",
"content": json.dumps(result, ensure_ascii=False),
"tool_call_id": tool_call.id
})
tool_call_id 就是上面那个 id。这也是为什么流式拼装时不能只留 arguments——id 在首片里,你把它丢了,这一步就没法配对了。同一轮里有多个工具调用时,每个调用都要有自己的一条 role: "tool" 消息,一一对应——官方示例那个 for tool_call in message.tool_calls 循环,每转一圈就 append 一条,就是这个意思。
还有一个顺序问题:在把工具结果 append 进去之前,要先把模型那条带 tool_calls 的助手消息 append 进对话历史(官方示例里是 messages.append(message.model_dump()))。少了这一步,对话历史里就只有工具结果、没有工具请求,上下文是断的。
顺带提一个设计约束:官方文档写 tool_choice 默认且仅支持 auto。也就是说你没有办法强制模型必须调用某个指定函数。如果你的业务流程设计成「这一步一定要走某个工具」,那就不能靠 tool_choice 兜底,得在提示词层面引导,并且在代码里对「模型这一轮没调工具」的情况做兜底分支——官方示例最后那个 else: print(message.content) 分支就是干这个的,别删。
裸 HTTP 解析 SSE 时还要多处理几件事
用 SDK(zai-sdk 里的 ZhipuAiClient)的话,SSE 分帧是它替你做的。但很多团队的网关层是自己拿 HTTP 客户端裸接的,那就得自己处理这几件事,官方《流式消息》一页给的响应示例把边界都摆出来了:
- 每一行是
data:开头的 JSON,最后一行是data: [DONE]。[DONE]不是 JSON,直接丢给解析器会抛异常,要单独判掉。 finish_reason只在最后一个 chunk 中出现,前面的 chunk 里它是null。所以「拿到 finish_reason 就结束循环」这个判断本身是可以的,但别指望每片都有。usage(令牌使用统计)同样只在最后一个 chunk 中出现。想统计这次调用的 token 消耗,必须留到最后一片去读,中途每片累加是读不到东西的。官方响应示例里最后那片的usage结构中还带了prompt_tokens_details这一层,缓存命中相关的统计在里面。具体的计费口径和单价请以官方定价页为准,这一页文档只给字段不给价钱。chunk.choices可能是空的。官方示例第一行就是if not chunk.choices: continue,说明存在没有 choices 的分片,直接下标取[0]会越界。
代理层还有一个常被忽略的点:SSE 依赖连接保持和逐块下发,中间任何一层做了响应缓冲,流式就退化成了「等很久然后一次性全给」。这类问题在浏览器端表现为「开了流式但还是一次性出结果」,排查方向是网关而不是 API。
一份排查顺序
工具调用在流式下出问题时,按这个顺序看,能省很多时间:
- 收不到分片形式的工具调用 → 先确认
tool_stream=True有没有真的进请求体(它默认false,不会跟着stream一起开),再对着 API 参考里tool_stream字段的说明确认你用的模型在支持范围内——别用指南页那份更窄的列表去判。 - 参数 JSON 解析失败 → 看是不是在循环里解析了;把拼装好的完整
arguments字符串原样打出来,一眼就能看出是被截断还是被串了。 - 函数名为空或
id为None→ 归并逻辑把首片的整体对象丢了,只留了arguments。 - 两次同名调用参数糊在一起 → 归并键用了函数名而不是
index。 - 回填后模型答非所问 → 检查带
tool_calls的助手消息有没有先 append,以及tool_call_id是否一一对应。 - token 统计永远是零 →
usage只在最后一个 chunk,读的位置不对。 - 流中途断掉 → 那是另一类问题,和拼装无关,处理思路见流式输出中断怎么续传。
最后一个建议:把拼装逻辑单独抽成一个函数,输入是 chunk 序列,输出是 list[tool_call],然后给它写单元测试。 测试数据不需要真的调 API,手工造几组分片就够——把一个 arguments 故意切在字符串中间、造两个同名不同 index 的调用、造一个 choices 为空的片。这类逻辑一旦写错,表现是间歇性的、跟模型输出内容强相关的偶发失败,线上抓起来非常难受,但用离线的假分片测起来只要几分钟。
相关内容可以接着看GLM API 报错怎么排查,以及流式场景下另一类常见现象流式输出顺序错乱的成因。