docker compose up 起来的是两个服务,不是一个

2026-08-09

speech-to-speech 是 Hugging Face 的开源语音代理流水线,仓库描述原文是 “Build local voice agents with open-source models”,2026-08-09 这天的 star 数是 11893(star 数只是个时间点快照,跟好不好用没有关系,这里只作为版本坐标)。它的 README 里 Docker 那一节短得几乎会被跳过:装好 NVIDIA Container Toolkit,然后 docker compose up

问题就出在这三行字后面那句话上。README 说的是,compose 文件会启动一个跑 Gemma 4 的 llama.cpp 服务实时服务,暴露端口 80808765

也就是说,你敲下 docker compose up 之后,桌面上不是一个服务,是两个。很多人第一次起完之后去 8080 找页面、或者只盯着 8765 看,都是因为没把这个拓扑先想清楚。

这两个端口分别是谁

先把端口和仓库里的其它说法对上号。下面这张表只列跟本篇直接相关的两行,参数的完整清单在别处讲:

端口谁在听在仓库其它地方长什么样
8080llama.cpp 提供的 OpenAI 兼容 LLM 服务README 的服务商对照表里,llama.cpp 那一行的 --responses_api_base_url 就是 http://127.0.0.1:8080/v1--responses_api_api_key 填空字符串
8765speech-to-speech 的 Realtime 服务realtime_server_arguments.py--port 默认就是 8765;默认服务地址是 ws://localhost:8765/v1/realtime

对上号之后,这个 compose 的形状就一目了然了:它是 README「Fully Local」那一节两个终端的容器版本。那一节让你在终端 1 起 llama-server,在终端 2 起 speech-to-speech serve 并把 --responses_api_base_url 指向 http://127.0.0.1:8080/v1。compose 把这两步打成了一个编排单元。

这层对应关系是我们把 README 两节内容对照读出来的推断,不是 compose 文件的内容——我们只读了 README 里那句话,没有读过 docker-compose.yml 的正文,所以它具体给实时服务传了哪些参数、用的是哪个 Gemma 4 的 GGUF 检查点,本文一律不写。README 在这里只说了 “Gemma 4”,没有点名检查点。(仓库 Fully Local 那一节出现过 ggml-org/gemma-4-E4B-it-GGUF,但那是另一节的命令行示例,不能直接当成 compose 的内容。)

命令怎么写

Docker 这条路要先有源码,因为 compose 文件在仓库里:

git clone https://github.com/huggingface/speech-to-speech.git
cd speech-to-speech
docker compose up

前两行来自 README 的「从源码安装」一节,第三行来自 Docker 一节。每一行的作用:

  • git clone:Docker 路线跟 pip install speech-to-speech 不是一回事。pip 装的是包,拿不到 docker-compose.ymlDockerfileDockerfile.arm64 这些编排文件,它们在仓库里。
  • cddocker compose 默认在当前目录找 compose 文件。
  • docker compose up:不加 -d,日志直接打在前台。第一次起的时候强烈建议这样,因为两个服务的启动日志混在一起才看得出是哪一半没起来。

README 在 Docker 一节写明的硬前置是 NVIDIA Container Toolkit,原文给的链接指向 NVIDIA 官方安装指南。它不装,容器拿不到 GPU。

以上为按官方文档段落组合的示例,未逐项实测,以官方文档与 --help 的实际输出为准。

起来之后能拿到什么

这里只写有依据的部分,我们没有运行过这套服务,所以不描述任何界面、日志行、启动耗时

  • 8765 上是一个 Realtime 服务,同一个 FastAPI / uvicorn 进程上暴露 WebSocket 与 WebRTC 两种传输,WebSocket 端点路径是 /v1/realtime
  • 8080 上是一个 OpenAI 兼容的 LLM 端点,按 README 的对照表,它的 base URL 形态是 http://127.0.0.1:8080/v1
  • 默认不会多出一个「LLM 代理」端点。--enable_llm_proxy 在模块级参数里默认是 False。要开的话看下一节的安全说明。
  • WebRTC 那条路要求装 speech-to-speech[webrtc] extra,走的是 POST /v1/realtime/calls,Content-Type 是 application/sdp。容器里装没装这个 extra,取决于镜像怎么构建的——我们没读过 Dockerfile 正文,不下结论。

注意后两条说的是 CLI 与源码层面的默认行为,不等于 compose 起出来的实例就一定是这个配置,因为 compose 完全可以在命令里覆盖它们。

怎么验收

第一处:8765 的协议层通不通。 仓库自带一个麦克风客户端,README 给的连法是:

speech-to-speech talk --url ws://127.0.0.1:8765/v1/realtime

如果你不想动麦克风,只想确认协议层握上了,README 里那段 Python 示例更适合做冒烟检查——它用官方 OpenAI SDK 连上去,把收到的事件类型逐条打出来:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8765/v1",
    websocket_base_url="ws://localhost:8765/v1",
    api_key="not-needed",
)

with client.realtime.connect(model="local") as conn:
    conn.send(
        {
            "type": "session.update",
            "session": {
                "type": "realtime",
                "instructions": "You are a helpful assistant.",
                "audio": {
                    "input": {
                        "turn_detection": {
                            "type": "server_vad",
                            "interrupt_response": True,
                        }
                    }
                },
            },
        }
    )

    for event in conn:
        print(event.type)

按协议文档,连接建立时服务端会发 session.created,一次成功的 session.update 会被 session.updated 确认。这两个事件名能打出来,8765 这一半就算走通了。那个 api_key="not-needed" 不是笔误——服务端自身不做认证,下一节会讲这件事的分量。

第二处:8080 那一半。 它就是个普通的 OpenAI 兼容端点,按 README 对照表里的形态(base URL http://127.0.0.1:8080/v1,API key 传空字符串)当作上游访问即可。具体怎么探活以 llama.cpp 官方文档为准,我们手上没有 llama.cpp 的一手资料,不编 curl 命令。

第三处:分清是哪一半没起来。 这是把两个服务当成一个时最常见的误判。8765 连得上但对话没有反应,跟 8765 根本连不上,是完全不同的两类问题——前者要往 8080 和上游模型那边查,后者才是实时服务本身的问题。前台跑 docker compose up 的价值就在这里。

最容易在哪一步出错

并发数默认是 1。 --num_pipelines 默认值是 1,help 原文说得很直接:一个 uvicorn 服务监听 --port,把每个进来的客户端路由到下一个空闲的流水线,每条流水线有自己的 VAD / STT / LM / TTS handler 和对话状态;最大并发 WebSocket 会话数等于 num_pipelines,再来的连接会被拒绝。协议错误码里也有对应的 session_limit_reached

这条在容器化之后杀伤力更大:你把服务丢给同事试用,第二个人连上来就被拒,日志又在容器里,很容易被误判成「服务挂了」。做多人部署,这是第一个要动的参数。

绑定地址默认是环回。 --host 默认 127.0.0.1,help 原文写的是:显式传 0.0.0.0 才能把这个未认证的 API 暴露到网络上。如果你要照着官方 compose 改自己的编排,这是第一个坑——容器内绑在环回上,宿主机是访问不到的(这是容器网络的通用常识,不是本项目文档内容)。

而一旦你真的传了 --host 0.0.0.0,或者打开了 --enable_llm_proxy,README 的安全声明必须原样带上:**服务端自己不做任何认证,也不做任何限流。**只在受信网络上启用,或者部署在一个自己掌管访问控制的网关后面。README 举的例子是让计算副本充当这样的网关,只对用 token 创建了会话的客户端开放这些路径、按 token 校验 key、按用户限流。本文不给「这样配就安全了」的结论,也不给具体网关方案。

中文场景要换 STT。 默认 STT 是 Parakeet TDT,README 的多语言表里它覆盖的是 25 种欧洲语言。要做中文语音代理必须换成 Whisper 系或 Paraformer,Paraformer 还要装 paraformer extra。容器化之后换 STT 意味着改编排文件和镜像依赖,不是改一下本机命令行那么轻。

写错的后端参数不会报错。 README 明说,CLI 只为选中的后端构造配置,未激活后端的已知选项仍然被接受,但会带警告地忽略,JSON 配置同理。在容器里日志刷得飞快,这条警告非常容易被冲走,然后你就会对着一个「参数明明写了却没生效」的服务发呆。

模型缓存。 README 的离线一节要求:断网前先在联网状态下用完全相同的配置启动一次,让 STT、LLM、TTS、Silero VAD、NLTK 和 Smart Turn 需要的资源都缓存好。容器每次重建是否还留着这些缓存,取决于你怎么挂载卷(同样是通用容器实践,不是本项目文档内容)。

什么情况别走这条路

  • 手上不是 NVIDIA 卡,或者不打算装 NVIDIA Container Toolkit。README 的 Docker 前置就是它。仓库另有 Dockerfile.arm64,但我们没读过它的内容,不替它下结论。
  • Apple Silicon。README 给 macOS 的路子是本机安装加 --mac-optimal-settings 预设(STT 用 Parakeet TDT、LLM 用 MLX LM、TTS 用 Qwen3-TTS 走 mlx-audio 并默认取 6bit 变体),跟 NVIDIA Container Toolkit 这条容器路线不是同一条道。
  • 只想快速试一下、不打算跑本地 LLM。那 8080 那一半就是多余的:直接 pip install speech-to-speechexport OPENAI_API_KEY=... 之后 speech-to-speech serve,LLM 走托管服务商即可。顺带说一句,这个开箱默认值本身就挺反直觉——项目名叫 “local voice agents”,但默认配置里只有 STT 和 TTS 在本地,LLM 默认打的是 OpenAI 的 gpt-5.4-mini。docker compose 这条路的真正价值,恰恰是它把 LLM 那一环也搬到了本地。
  • 要给多人用。先把 --num_pipelines 想明白再谈部署,否则第二个用户就是你的第一个故障单。

延伸阅读


本文依据 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?报名体系课或加入会员,照着学、照着用。