同一个功能,云端是协议原生,本地靠正则抠
给语音代理加工具(function calling)这件事,在 speech-to-speech 里有一个很值得看的细节:同一个功能,两个 LLM 后端的实现方式完全不是一回事。
src/speech_to_speech/api/openai_realtime/README.md 的 Tool Calling Design 一节把这件事写得很直白:工具调用按 LLM 后端走两条截然不同的路径,但两条路最终汇合到同一套面向客户端的线上协议。这句话的两半都重要——前半决定了你选后端时会遇到什么麻烦,后半决定了你的客户端代码要不要跟着改。
下面按机制拆开讲,最后给一段可以照着走的选型判断。
路径 A:本地 LLM,靠提示词约定加正则
走 LanguageModelHandler(也就是 --llm_backend transformers 或 --llm_backend mlx-lm)时,模型本身并没有「工具调用」这个协议层能力,整条链路是项目自己在外面搭出来的。README 给的步骤是这样:
session.update里定义的 tools 被转成FunctionTool对象;- 每个工具的 JSON Schema
parameters,通过signature_from_schema变成 Python 的inspect.Signature; to_code_prompt()把它渲染成人类可读的def name(...): """docstring"""块;- 这些工具签名通过 Jinja2 模板(
tool_prompt.py)注入系统提示词,指示模型把工具调用包在<code>...</code>分隔符里:
<code>
function_name(arg_name_1=value1, arg_name_2='string_value')
</code>
- 生成完成之后,
_extract_tools用正则去找<code>块,再由extract_function_calls_from_text解析每一个name(kwargs)调用,并对照已注册的工具做校验;校验通过的调用变成带自动生成call_id的ResponseFunctionToolCall字典。
对应的代码落在 src/speech_to_speech/LLM/tool_call/ 下,四个文件各司其职:function_tool.py(工具对象)、signature_from_schema.py(Schema 转签名)、tool_prompt.py(提示词模板)、function_call.py(解析与构造调用)。
值得停一下的是这条链路的性质:它把一个结构化协议问题降级成了文本约定问题。模型看到的不是 tools 字段,而是一段写着函数签名的系统提示词;框架拿到的也不是结构化对象,而是一段要用正则去切的文本。中间任何一环——模型没按格式输出、参数名拼错、写了个没注册过的函数——都只能在 _extract_tools 之后靠那次「对照已注册工具」的校验兜住。
需要说清楚的是:README 没有给任何关于这条路径成功率的数据,我们也没有跑过一次生成,所以这里不能推出「本地模型工具调用不可靠」这种结论。能确定的只有实现机制本身:它依赖提示词遵循度,而不依赖 API 契约。
顺带一个有关的参数事实(来自 arguments_classes/language_model_arguments.py):本地后端默认 --llm_gen_temperature 0.0、--llm_gen_do_sample False,也就是确定性贪心解码。这个默认值和 <code> 块格式稳不稳之间有没有关系,README 没给任何说明,我们不替它下结论,只把这条默认值摆在这里,方便你调参时心里有数。
路径 B:OpenAI API,工具是协议里自带的
走 OpenAI API 这一侧(README 点名的是 ResponsesApiModelHandler,对应默认的 --llm_backend responses-api;--llm_backend chat-completions 同属 API 侧,与它共用同一组 --responses_api_* 连接参数)时,事情简单得多:tools 作为 tools= 参数原生传给 client.responses.create,API 直接返回结构化的 function_call 项。
没有提示词工程,没有 <code> 分隔符,没有正则。README 还提到这一侧支持 response.create 传来的按响应 tool_choice 覆盖——也就是可以在单次响应上临时改变工具选择策略。
这就是「协议原生」和「模拟实现」的差别。同一段业务需求(让模型调一个查天气的函数),在云端是一个字段,在本地是一整套提示词模板加解析器。写自己的 Agent 框架时,这组对比比任何架构图都说明问题。
两条路在哪里汇合
关键在于:这个差异不会漏到客户端。
两个 handler 都产出同样的 (text, language_code, tools) 三元组。LMOutputProcessor 把干净文本转给 TTS,把 {"type": "assistant_text", "text": ..., "tools": [...]} 放进 text_output_queue。路由器的 _send_loop 再把它们翻译成线上事件:
| 输出内容 | 客户端收到的事件 |
|---|---|
| 每个文本块 | 一个 response.output_audio_transcript.delta |
| 文本结束 | 一个终态 response.output_audio_transcript.done |
| 每个工具调用 | 一个 response.function_call_arguments.done |
所以从客户端视角看,本地模型和 OpenAI 后端发出来的工具调用长得一模一样。这条对选型是有直接意义的:你可以先按部署约束(能不能联网、有没有卡、要不要数据不出内网)选后端,而不必担心换后端要重写客户端的工具处理逻辑。
工具结果怎么回流
README 把回流写成了四步,注意第 2 步和第 4 步:
- 客户端执行工具,发
conversation.item.create,type: "function_call_output",output: "<result>"; RealtimeService把工具输出追加进聊天上下文,并发出conversation.item.created——这一步不触发生成;- 如果工具结果需要说给用户听(摄像头、搜索、数据类结果),客户端再发一次
response.create才会触发后续生成; - 对于「发射后不管」的动作类工具(跳舞、表情、转头、停止、待机等),客户端可以在收到
conversation.item.created之后就停下;助手在发起工具调用之前应该已经说过一句自然的引导语了。
第 2 步是最容易踩的:不少人会默认「我把结果塞回去了,模型自然会接着说」——不会。要说话就得自己再发 response.create。
第 4 步则透露了这套设计原本的使用场景(README 举的是 Reachy Mini 一类机器人设备,这是文档里的场景,不是我们验证过的产品行为),同时给出了一个很实用的语音代理设计范式:先说话,再动作,别让用户干等。放到非机器人的场景同样成立——查数据库前先说「我查一下」,比查完再开口体验完全不同。
工具调用和打断的交界处
工具调用的输出和普通文本一样要过 _send_loop,所以绕不开打断(barge-in)这套机制。这里只讲和工具调用直接相关的部分。
打断由 VAD、_send_loop 与 LLM/TTS handler 通过一个共享的 CancelScope 对象协作完成(cancel_scope.py)。它管两样东西:世代计数器 cancel_scope.generation,以及丢弃标志 cancel_scope.discarding。流水线线程在每个响应开始时捕获当前世代,在每个流式 token 上检查 cancel_scope.is_stale(gen);调 cancel() 时世代递增,所有更早的世代立刻过期。README 特意强调了一句:no timing games required——不需要玩时序把戏。
和工具调用有关的是这一条:流水线输出是世代标记的,AudioOutput 块与 AssistantTextEvent 都带一个 cancel_generation 字段,由产出它的 handler 盖章;_generation_is_discardable 在两种情况下丢弃一项——它的世代已过期,或者 discarding 置位且该项不属于当前世代。
README 点名带 cancel_generation 的是这两类输出,没有点名工具调用项,我们也没读到相反的说法,所以这里不做任何推断。但有一条工程提醒是站得住的(这是我们的设计建议,不是 README 的内容):客户端侧的工具执行最好做成幂等的,尤其是「发射后不管」的动作类工具——用户抢话、response.cancel(会触发 finish_response(status="cancelled", reason="client_cancelled"))、以及回合检测导致的取消(status="cancelled"、reason="turn_detected")都可能发生在你已经开始执行工具之后。
还有一个默认值别记反:打断门控由 turn_detection.interrupt_response 控制(经 RuntimeConfig.interrupt_response_enabled 读取),默认 true。关掉之后,响应期间的用户语音仍会被转写,但响应会继续播放。
选后端:一条可以照着走的判断路径
把上面的机制换成决策,顺序建议是这样:
第一问:数据能不能出内网、要不要断网运行? 必须全本地就走本地路径。README 给的最省事做法是把 LLM 放到独立的 llama.cpp 进程,speech-to-speech 通过 OpenAI 兼容接口指过去:
# 终端 1:llama.cpp 提供 Gemma 4
llama-server -hf ggml-org/gemma-4-E4B-it-GGUF -np 2 -c 65536 -fa on --swa-full
# 终端 2:speech-to-speech 指向这个本地服务
speech-to-speech serve \
--stt parakeet-tdt \
--llm_backend responses-api \
--tts qwen3 \
--model_name "ggml-org/gemma-4-E4B-it-GGUF" \
--responses_api_base_url "http://127.0.0.1:8080/v1" \
--responses_api_api_key "" \
--responses_api_stream \
--enable_live_transcription
注意这条路走的仍是路径 B(responses-api 打的是 OpenAI 兼容的 /v1/responses),只是对端换成了你自己机器上的 8080。真正走路径 A 的是进程内本地后端:Apple Silicon 用 --llm_backend mlx-lm,CUDA / CPU 用 --llm_backend transformers。这两者在工具调用上的实现完全不同,别混。
另外,离线运行时如果不加本地 base URL 覆盖,默认的 responses-api 后端会去调远程服务,断网就断了。
第二问:你打的服务商,Responses 那条路的流式工具调用稳不稳?
README 给了两个具体理由建议改用 chat-completions:一是服务商在 Responses 路径上忽略 chat_template_kwargs.enable_thinking,需要一个 reasoning_effort 旋钮来抑制推理(对应 --responses_api_reasoning_effort none);二是该服务的 Responses 流式工具调用路径不可靠,而它的 Chat Completions 工具调用流式是稳的,README 点名这在某些 vLLM 构建上有用,并给出 issue 链接 #312(那条 issue 的正文我们没读过,不复述它的结论)。
第二条正是本篇的落点:工具调用出问题时,先怀疑的不该是模型,而是你走的是哪条 API 路径。换 --llm_backend chat-completions 是一个成本极低的验证动作。
第三问:要不要跳过 STT,直接把音频喂给模型?
要的话,responses-api 不支持直接音频模式,必须用 chat-completions,而且必须显式把 --model_name 设成接受音频输入的模型——默认的 gpt-5.4-mini 接受文本与图像,不接受音频。
第四问:说中文吗? 这条和工具调用间接相关——工具参数是从用户的话里来的。默认 STT(Parakeet TDT)只覆盖 25 种欧洲语言,做中文语音代理必须换 STT(Whisper 系或 Paraformer)。转写都不对,工具参数自然不会对。
最后一条不属于选型但属于部署:--num_pipelines 默认是 1,也就是默认只承载一个并发会话;服务端默认 --host 127.0.0.1、--port 8765。做多用户之前先看这两处。
顺带把安全边界说清楚:serve 默认只绑环回,要让别的机器连进来必须显式传 --host 0.0.0.0,而 --host 的帮助文本自己写明了这样暴露出去的是一个 unauthenticated API——不认证、不限流,工具调用的入口自然也跟着一起暴露。所以这类服务只在受信网络内或放在网关后面启用,别直接挂公网。至于具体用反向代理还是防火墙,那是通用运维做法,不是这个项目文档的内容,请结合自己的环境评估。
以上命令均按官方参数语义组合与引用,未逐项实测,以官方文档与 speech-to-speech serve -h 的实际输出为准。
一句话总结这篇的判断依据
如果你的工具调用在本地后端上表现和云端不一致,不要先去调提示词——先确认自己在哪条路径上:路径 A 是「提示词约定 + 正则解析」,任何格式偏差都会在解析这一环被拦掉;路径 B 是「API 契约」,问题更可能出在服务商对 Responses 流式工具调用的实现上。两条路的排查方向完全相反,认错了路,调再久也是白调。
延伸阅读
本文依据 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 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。