适用场景
这套方案适合想把语音助手跑在自己机器上的团队:用户说话进来,模型边听边决定要不要调用外部接口(查天气、查订单、查日程),再把结果用语音回出来。典型用途是客服前置分流、会议/工单语音录入、车内或智能硬件的语音入口。相比直接买云端语音 API,自建的好处是数据不出内网、链路每一段都能改、延迟可以自己压;代价是要自己处理显存、并发和打断逻辑。
环境与前置条件
操作系统建议 Linux(Ubuntu 22.04 及以上一类的主流发行版),Windows 走 WSL2 也能跑通,但生产部署优先 Linux。以下命令以 bash 为准。
硬件方面先算一笔账:27B 量级的模型,FP16/BF16 权重本身就在 50GB 以上,加上 AI 词典:KV Cache">KV Cache 和音频编码器部分,单卡 80GB 是比较稳的起点;如果是两张 48GB 卡做张量并行,或者用 FP8/AWQ 这类量化权重把显存压到 30GB 上下,也能跑,但要注意量化版本的质量以官方模型卡说明为准。磁盘预留 100GB 以上放权重、临时音频和日志。
软件侧需要:
- NVIDIA 驱动与 CUDA 运行库,版本要和后续安装的推理框架匹配,具体对应关系以推理框架官方文档当前版本为准;
- Python 3.10 及以上,建议用 venv 或 conda 隔离;
- ffmpeg,用来把各种格式的录音统一成 16kHz 单声道;
- 能访问模型权重下载源的网络,直连不畅时用镜像站。
先做一轮体检:
```bash
nvidia-smi # 看驱动版本、显存容量、当前占用
python3 -V # 确认 3.10+
df -h /data # 确认磁盘余量
free -g # 内存建议不低于 64GB,加载权重时会用到
ffmpeg -version | head -n 1
```
nvidia-smi 能正常打印出 GPU 型号和显存,就算这步过了。
分步骤部署
第 1 步:建一个干净的 Python 环境
不要装到系统 Python 里,后面升级推理框架会很难受。
```bash
cd /data
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip wheel setuptools
```
激活后 which python 应指向 /data/.venv/bin/python。
第 2 步:安装推理与服务依赖
```bash
pip install -U vllm transformers accelerate
pip install -U fastapi "uvicorn[standard]" httpx soundfile librosa numpy python-multipart
```
这里装两类东西:一类是模型推理框架(vLLM、transformers 等),一类是后面对外的 HTTP 网关。版本号一律以各自官方文档当前版本为准,不要照抄别人博客里的固定版本。安装完成后 python -c "import vllm, transformers; print('ok')" 能打印 ok 即可。
如果官方模型卡推荐的是别的推理后端(有些语音模型需要用作者提供的推理脚本或专门的音频服务框架),以官方说明为准,后面的启动参数只做参考。
第 3 步:下载模型权重
先把权重拉到本地,避免每次启动都联网。
```bash
pip install -U "huggingface_hub[cli]"
直连不畅时再打开下面这行,走镜像
export HF_ENDPOINT=https://hf-mirror.com
hf download <组织名>/<模型仓库名> --local-dir /data/models/apriel-voice
```
<组织名>/<模型仓库名> 请从官方模型卡页面复制实际值。下载成功的标志是目录里出现 config.json、分词器文件、权重分片和音频编码器相关文件,用 du -sh /data/models/apriel-voice 核对体积和模型卡标注的参数量级是否对得上。
第 4 步:启动模型服务
如果推理框架支持这个模型架构,可以直接用 OpenAI 兼容接口起服务,这是最省事的一条路:
```bash
vllm serve /data/models/apriel-voice \
--served-model-name apriel-voice \
--host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
几个参数的含义:--tensor-parallel-size 是卡数,单卡写 1;--max-model-len 是上下文长度,越长占显存越多;--gpu-memory-utilization 控制框架愿意占用的显存比例;后两行是打开自动工具调用并指定解析器,解析器名称必须和模型训练时用的格式匹配,具体取值看模型卡,填错的表现是模型明明该调工具却只回一段纯文本。
启动过程中会看到加载权重、编译计算图的日志。出现类似 Uvicorn running on http://0.0.0.0:8000 或 Application startup complete 的行,并且进程没有退出,就算起来了。首次启动慢是正常的,图形编译要花几分钟。
第 5 步:搭一个语音网关
模型服务本身处理文本/音频张量,真实业务里还需要一层网关负责:收音频、转写、带工具描述请求模型、执行工具、把结果合成语音返回。先写工具注册表和大模型调用循环:
```python
gateway.py
import base64, json, time
import httpx
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import Response
LLM_BASE = "http://127.0.0.1:8000/v1"
MODEL = "apriel-voice"
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气,用户问天气、气温、下雨时调用",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名,例如 杭州"}},
"required": ["city"],
},
},
}]
def get_weather(city: str) -> dict:
换成你自己的数据源,这里返回占位数据
return {"city": city, "temp_c": 21, "desc": "多云"}
REGISTRY = {"get_weather": get_weather}
SYSTEM = "你是一个语音助手。回答要短、口语化,不超过两句话。需要实时信息时调用工具,不要凭空猜测。"
async def run_turn(user_text: str):
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_text},
]
async with httpx.AsyncClient(timeout=60) as client:
for _ in range(3): # 最多三轮工具往返,防止死循环
r = await client.post(f"{LLM_BASE}/chat/completions", json={
"model": MODEL,
"messages": messages,
"tools": TOOLS,
"tool_choice": "auto",
"temperature": 0.2,
"max_tokens": 512,
})
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
messages.append(msg)
if not msg.get("tool_calls"):
return msg.get("content") or ""
for call in msg["tool_calls"]:
name = call["function"]["name"]
args = json.loads(call["function"]["arguments"] or "{}")
result = REGISTRYname
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": json.dumps(result, ensure_ascii=False),
})
return "这个问题我暂时处理不了。"
```
这段的关键点:tools 里每个函数的 description 要写清楚"什么情况下该调用",模型靠它做决策;工具结果必须用 role: "tool" 配 tool_call_id 回填,不能直接塞进 user 消息,否则多轮之后模型会对不上号。
第 6 步:接上语音输入输出
语音有两种接法。如果模型卡明确写了支持音频输入输出,就直接把音频交给模型,走同一条 chat/completions;如果不支持或者效果不稳,就用"ASR → 大模型 → TTS"的级联方案,工程上更好调。
```python
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import Response
import base64, time
app = FastAPI()
async def transcribe(wav_bytes: bytes) -> str:
"""音频转文字:支持音频输入的模型可直接走多模态消息,
否则换成本地 ASR 引擎。下面的消息体结构以框架文档为准。"""
b64 = base64.b64encode(wav_bytes).decode()
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(f"{LLM_BASE}/chat/completions", json={
"model": MODEL,
"messages": [{"role": "user", "content": [
{"type": "text", "text": "逐字转写这段语音,只输出文字。"},
{"type": "input_audio", "input_audio": {"data": b64, "format": "wav"}},
]}],
"max_tokens": 256,
})
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
async def synthesize(text: str) -> bytes:
"""文字转语音:接你选定的开源 TTS 引擎,返回 wav 字节。"""
...
@app.post("/voice")
async def voice(file: UploadFile = File(...)):
t0 = time.perf_counter()
audio = await file.read()
text = await transcribe(audio)
t1 = time.perf_counter()
reply = await run_turn(text)
t2 = time.perf_counter()
wav = await synthesize(reply)
t3 = time.perf_counter()
print(f"asr={t1-t0:.2f}s llm={t2-t1:.2f}s tts={t3-t2:.2f}s total={t3-t0:.2f}s")
return Response(content=wav, media_type="audio/wav")
```
音频格式务必统一。客户端上传的录音先过一遍 ffmpeg:
```bash
ffmpeg -y -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le test.wav
```
16kHz 单声道是绝大多数语音模型的输入惯例,采样率不对的典型表现是识别出乱码或全是噪声。
启动网关:
```bash
uvicorn gateway:app --host 0.0.0.0 --port 8080
```
第 7 步:加流式与打断
真正当助手用,必须能被打断。做法是把链路拆成三段流式:ASR 边收边出文字,大模型开 stream: true 拿首 token,TTS 按句合成边下发。同时跑一个 VAD(语音活动检测)盯麦克风,检测到用户重新说话就取消当前请求。取消的方式是客户端断开 HTTP 连接,网关侧捕获断开异常并停止后续合成,不要让模型继续算完。
流式下有一个顺序问题:如果这一轮要调工具,语音先别急着播,等工具结果回来再合成,否则用户会听到一句"我查一下"然后长时间静音。可以在 tool_calls 非空时先把"我查一下"这类过渡语播出去,用这段时间掩盖工具往返延迟,体验差别很明显。
第 8 步:测延迟
不要照抄网上的数字,在你自己的机器上跑。压测脚本核心就是把每一步打点,重复几十次取分位数:
```python
import statistics, time, httpx
def bench(n=30):
rows = []
for _ in range(n):
t0 = time.perf_counter()
换成你网关的真实请求
text = httpx.post("http://127.0.0.1:8080/asr-only",
files={"file": open("test.wav", "rb")}).text
t1 = time.perf_counter()
httpx.post("http://127.0.0.1:8000/v1/chat/completions", json={
"model": "apriel-voice",
"messages": [{"role": "user", "content": text}],
"max_tokens": 128,
})
t2 = time.perf_counter()
rows.append((t1 - t0, t2 - t1, t2 - t0))
for i, name in enumerate(["asr", "llm", "e2e"]):
vals = sorted(r[i] for r in rows)
p50 = statistics.median(vals)
p95 = vals[int(len(vals) * 0.95) - 1]
print(f"{name}: p50={p50*1000:.0f}ms p95={p95*1000:.0f}ms")
if __name__ == "__main__":
bench()
```
重点看 p95 而不是 p50,语音场景里用户对偶发的两秒卡顿特别敏感。如果 p95 明显高于 p50,一般是显存换页、并发排队或者工具接口本身抖动,先把工具那一段的超时和重试做掉。
验证部署是否成功
第一步,确认模型服务在线:
```bash
curl -s http://127.0.0.1:8000/v1/models | python -m json.tool
```
预期返回一个 JSON,data 数组里有 "id": "apriel-voice"。
第二步,确认纯文本推理通:
```bash
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"apriel-voice","messages":[{"role":"user","content":"用一句话介绍你自己"}],"max_tokens":128}'
```
预期 choices[0].message.content 是一段通顺中文,不是乱码、不是空字符串、不是重复同一句。
第三步,确认工具调用生效:
```bash
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"apriel-voice","messages":[{"role":"user","content":"杭州现在多少度"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"查询城市实时天气","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],"tool_choice":"auto"}'
```
预期返回的 message 里出现 tool_calls,function.name 是 get_weather,arguments 里带着 {"city": "杭州"}。如果这里返回的是一段普通文字,说明工具没接上,先排查解析器配置。
第四步,端到端:
```bash
curl -s -X POST http://127.0.0.1:8080/voice -F "file=@test.wav" -o reply.wav
ffprobe reply.wav # 确认是有效音频
```
预期拿到一个能正常播放的 wav,内容是回答了 test.wav 里问的问题;同时网关日志里能看到 asr= / llm= / tts= / total= 四个耗时数字。
常见报错与解决
torch.OutOfMemoryError: CUDA out of memory → 显存不够,多半是上下文长度给太宽或者并发太高 → 降 --max-model-len 到 8192、--gpu-memory-utilization 到 0.85,还不行就换量化权重或加卡改 --tensor-parallel-size。
ValueError: The model ... is not supported 或 Unsupported architecture → 推理框架版本还没适配这个模型结构 → pip install -U vllm transformers 升级到最新,仍不行就改用官方模型卡推荐的推理后端或作者提供的推理脚本。
OSError: We couldn't connect to 'https://huggingface.co' 或返回 401 → 网络不通或没登录 → 直连不畅时 export HF_ENDPOINT=https://hf-mirror.com;需要授权的仓库先 hf auth login 填 token 再重新下载。
httpx.ConnectError: [Errno 111] Connection refused → 模型服务没起来或端口不对 → ss -lntp | grep 8000 看端口监听情况,curl -s localhost:8000/v1/models 复测,端口被占用就换 --port。
模型该调工具却只回文字,tool_calls 为空 → 没开自动工具调用,或解析器与模型训练格式不匹配 → 启动参数加上 --enable-auto-tool-choice 并按模型卡选对 --tool-call-parser;同时检查工具的 description 是否写得太含糊。
转写结果是乱码或整段噪声 → 音频采样率、声道数或编码格式不匹配 → ffmpeg -y -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le out.wav 统一格式后再送进去。
后续维护
备份:真正需要备份的不是权重本身(可以重新下载),而是 gateway.py、工具 schema、提示词、启动脚本和推理框架的版本锁定文件。这些放 git 里,每次改动留记录。权重建一份校验清单(文件清单加 sha256),换机器时核对一遍,避免下到残缺分片。
升级:推理框架和模型权重分开升级,一次只动一个。先在备用端口起一份新版本,把固定的回归用例跑一遍(纯文本问答、单工具调用、多轮工具、音频转写、打断取消各来几条),对比延迟和成功率都正常再切流量。不要把 torch、vllm、transformers 一口气全升到最新。
日志:网关输出结构化 JSON,每条至少记 request_id、用户文本长度、asr_ms、llm_first_token_ms、llm_total_ms、tool_name、tool_ms、tts_first_frame_ms、是否被打断。排查慢请求时按 request_id 一串就清楚卡在哪一段。
监控:至少盯四个指标——请求 QPS、端到端 p95 延迟、GPU 显存占用与利用率、工具调用失败率。前三项决定容量,最后一项决定体验。接 Prometheus 加 Grafana 是常规做法,从日志里解析也行,别等用户投诉才发现成功率掉了。
合规:录音涉及个人信息,上线前想清楚留存策略——默认不落盘、必要时脱敏、明确告知用户正在录音。这块不做好,技术上跑通也不能上线。
