工具结果回流四步:为什么第 2 步不触发生成
写语音代理客户端的人,十有八九会在同一个地方卡一次:模型发起了工具调用,你老老实实把工具跑完,把结果发回去,服务端也确认收到了,然后——没有然后了。助手不说话,音频通道安安静静,日志里也不报错。
这不是 bug,是 speech-to-speech 的设计。它把”把结果放进上下文”和”让模型开口”拆成了两件事,而绝大多数人默认它们是一件事。
官方文档给的四步,原样摆在这里
speech-to-speech 仓库里 src/speech_to_speech/api/openai_realtime/README.md 的 Tool Calling Design 一节,把工具结果回流写成了四步:
- 客户端执行工具,发
conversation.item.create,type为"function_call_output",结果放在output字段里; RealtimeService把工具输出追加进聊天上下文,并发出conversation.item.created;这一步不触发生成;- 如果工具结果需要说给用户听(摄像头画面、搜索结果、数据类结果),客户端再发一次
response.create来触发后续生成; - 对于”发射后不管”的机器人动作类工具(跳舞、表情、转头、停止、待机等),客户端可以在收到
conversation.item.created之后就停下;助手在发起工具调用之前应该已经说过一句自然的引导语了。
四步里真正有信息量的是第 2 步和第 4 步。第 2 步定了规矩,第 4 步解释了为什么要定这个规矩。
第 2 步为什么被设计成静默
先看一个容易被忽略的细节:conversation.item.create 这个事件并不是工具专用的。在客户端到服务端的五个事件里,它的职责被描述为”把 input_text 或 function_call_output 注入 LLM 上下文,不触发生成”。也就是说,你用文字给模型塞一句话,和你把工具结果塞回去,走的是同一个入口、同一套语义——注入,仅此而已。
这一条设计带来三个直接后果,也是判断依据:
第一,多个工具结果可以聚齐再生成。 如果注入即生成,模型一次发起三个工具调用、你分三次把结果发回去,就会触发三轮生成,前两轮都是拿着残缺上下文说话。注入与生成解耦之后,你可以连发三个 conversation.item.create,最后补一个 response.create,模型看到的是完整的三份结果。
第二,触发权留在了客户端。 什么时候该开口、这一轮用什么 instructions、这一轮允许模型用哪些工具,都由 response.create 决定——它支持按响应覆盖 instructions 与 tool_choice。如果注入即生成,这些按轮次的控制点就没地方挂了。
第三,动作类工具根本不需要生成。 这就是第 4 步说的场景。文档举的是 Reachy Mini 这类机器人设备:模型调一个”转头”或者”跳个舞”的工具,机器人转了、跳了,这件事本身就是给用户的反馈,不需要助手再念一遍”我已经转头了”。如果注入即生成,每个动作后面都会强行跟一段废话。
第三点顺带给出了一个很实用的语音代理设计范式,文档自己也点了:助手应该在发起工具调用之前就说过引导语。也就是”先说话再动作,别让用户干等”。你要是把话留到工具跑完再说,用户面对的就是一段莫名其妙的静音。这个顺序不是服务端替你排的,是你写提示词和工具描述时要自己安排的。
决策路径:这次要不要补第 3 步
真正要做的判断只有一个——这次工具调用的结果,是不是需要用嘴说出来。
- 结果是给用户看的信息(搜索到了什么、摄像头里有什么、查到的数据是多少),那必须补
response.create。不补,用户等到的就是静音。 - 结果是一个已经完成的物理动作或状态切换(转头、停止、待机),并且你在调用之前已经让助手说过引导语了,那就在
conversation.item.created之后收手。 - 结果是失败或异常,你希望助手把失败告诉用户——那也算”需要说给用户听”,同样要补
response.create。文档没有给失败结果的专门约定,你自己在output里怎么描述失败,模型就怎么理解,这部分完全是你的提示词工程责任。
有一个反直觉的地方值得单独提醒:第 4 步的”可以停下”是允许,不是要求。文档没有说动作类工具补发 response.create 会出错,它给的是一条省一轮生成的路子。所以拿不准的时候,补一发比漏一发安全。
怎么从事件流上判断自己卡在哪一步
这套协议的调试方式很朴素:把服务端发回来的事件按顺序打出来,对着看。
| 你看到的事件 | 说明你走到了哪 |
|---|---|
response.function_call_arguments.done | 服务端把工具调用交给你了,带 call_id、name 和 JSON arguments |
conversation.item.created | 你的 conversation.item.create 被接住了,上下文已追加,到此为止不会有生成 |
response.created | 生成真的开始了 |
response.output_audio_transcript.delta | 助手转写在增量输出 |
response.done | 这一轮结束 |
这里有一处时机差异会直接坑到 UI:response.created 不是在请求发起时发的,是在第一个出站音频块出来时才发。也就是说,你发完 response.create 之后有一段没有任何事件的空窗,这段空窗是正常的,不代表你的 response.create 被吞了。想在界面上做”正在思考”的状态,不能等 response.created 才点亮,否则用户会先看到一段什么都没有的空白。
还有一处是助手转写:实时渲染要挂在 response.output_audio_transcript.delta 上,把 response.output_audio_transcript.done 只当作定稿。文档明确说过,把 delta 值拼接起来能复现终态的 done.transcript;一个产生了转写文本的响应恰好发一次转写 done。老客户端如果还在消费块级 done 做实时渲染,这是必须改的。
所以判断顺序是:先确认有没有 conversation.item.created——没有就是注入这一步出了问题;有了但一直没有后续,先回头查你到底发没发 response.create;发了但迟迟没有 response.created,那就等一等音频块,别急着重发。
别在这个时候重复发 response.create
补第 3 步的时候有个坑:如果这一轮已经有活跃响应了,你再发一个 response.create,服务端的已知错误码里有一个就叫 conversation_already_has_active_response。这个错误名字已经把问题说完了——不是网络问题,是你自己的状态机认为”该说话了”,而服务端认为”正在说着呢”。
这条也顺带回答了另一个常见疑问:为什么不干脆在超时后无脑重发一次 response.create 兜底。因为服务端对”有没有活跃响应”是有状态的,无脑重发换来的是一个 error 事件,而不是第二次机会。
顺带说一句相关的机制:response.create 在打断状态机里还有一层身份。speech-to-speech 用一个共享的 CancelScope 对象管打断,其中的丢弃标志 cancel_scope.discarding 有三个清除点,一是世代匹配的 response_done(generation),二是显式 response.create 触发的 new_response(),三是会话认领/释放时的 reset()。文档只列到这三条,各条在工具回流场景里分别会不会被走到,它没有展开讲,我们也不替它推。知道 response.create 不只是”让它说话”这一个作用就够了,出了怪问题时记得往这条线上想。
两种 LLM 后端,回流代码不用改
最后补一个能省事的判断。speech-to-speech 的工具调用在服务端内部走两条截然不同的路径:
- 本地 LLM(
LanguageModelHandler,transformers / mlx-lm):session.update里定义的 tools 被转成FunctionTool对象,JSON Schema 的parameters经signature_from_schema变成 Python 的inspect.Signature,再由to_code_prompt()渲染成人类可读的def name(...)块,通过 Jinja2 模板注入系统提示词,要求模型把调用包在<code>...</code>里;生成之后_extract_tools用正则找<code>块,extract_function_calls_from_text解析每个name(kwargs)调用并对照已注册工具校验,合法调用变成带自动生成call_id的ResponseFunctionToolCall字典。 - OpenAI API(
ResponsesApiModelHandler):tools 作为tools=参数原生传给client.responses.create,API 直接返回结构化的function_call项,不需要提示词工程,也不需要正则解析。
同一个功能,云端 API 是协议原生能力,本地模型要靠提示词约定加正则抠出来——这大概是”本地模型和 API 模型的真实差距”最直观的一个例子。但别顺手推出”本地模型工具调用不可靠”这种结论,文档没有给任何成功率数据,我们也没有跑过。
对客户端来说,重点在于:两条路径最终汇合到同一套面向客户端的线上协议。两个 handler 都产出 (text, language_code, tools) 三元组,LMOutputProcessor 把干净文本转给 TTS、把 assistant_text 与工具调用字典放进 text_output_queue,路由器的 _send_loop 再翻译成协议事件。所以你换后端时,上面这套”收 response.function_call_arguments.done、发 conversation.item.create、按需补 response.create”的回流代码是不用动的。
什么情况下不是这个原因
如果你压根没收到 response.function_call_arguments.done,那问题在更前面——工具根本没被调起来,跟回流这四步无关,该回去查 session.update 里的 tools 定义和提示词。如果 conversation.item.created 也没回来,那是注入这一步没成功,看有没有伴随的 error 事件和错误码,别急着往生成触发上想。只有”工具调用收到了、结果发回去了、conversation.item.created 也回来了,就是不出声”这一种情况,才是本文说的第 2 步不触发生成。
另外,本文只依据仓库内的 Realtime Engine 架构文档整理。conversation.item.create 这个事件除了 type 与 output 之外的完整字段结构,文档里没有逐字段列全,请以官方文档和你实际连上去看到的报文为准。
延伸阅读
本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。