同一个功能,云端是协议原生,本地靠正则抠

2026-08-09

给语音代理加工具(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 给的步骤是这样:

  1. session.update 里定义的 tools 被转成 FunctionTool 对象;
  2. 每个工具的 JSON Schema parameters,通过 signature_from_schema 变成 Python 的 inspect.Signature
  3. to_code_prompt() 把它渲染成人类可读的 def name(...): """docstring""" 块;
  4. 这些工具签名通过 Jinja2 模板(tool_prompt.py注入系统提示词,指示模型把工具调用包在 <code>...</code> 分隔符里:
<code>
function_name(arg_name_1=value1, arg_name_2='string_value')
</code>
  1. 生成完成之后,_extract_tools正则去找 <code> 块,再由 extract_function_calls_from_text 解析每一个 name(kwargs) 调用,并对照已注册的工具做校验;校验通过的调用变成带自动生成 call_idResponseFunctionToolCall 字典。

对应的代码落在 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 步:

  1. 客户端执行工具,发 conversation.item.createtype: "function_call_output"output: "<result>"
  2. RealtimeService 把工具输出追加进聊天上下文,并发出 conversation.item.created——这一步不触发生成
  3. 如果工具结果需要说给用户听(摄像头、搜索、数据类结果),客户端再发一次 response.create 才会触发后续生成;
  4. 对于「发射后不管」的动作类工具(跳舞、表情、转头、停止、待机等),客户端可以在收到 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

注意这条路走的仍是路径 Bresponses-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 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。