Go SDK · v0.3.8
MOSS-Live Voice Agent Runtime
实时全双工语音 Agent 的编排层:判停、打断裁决、叫停、抢跑、出站配速、 上下文历史,加 DashScope 三件(ASR / LLM / TTS)与 WebSocket 传输。 交付物是 Go SDK —— 嵌进你自己的服务,代码在你的仓库、走你的发布流程。
概览与分层
Runtime 负责一台语音 Agent 的全部编排。你提供上游凭据和人设,它负责 「什么时候算用户说完了」「这声音是打断、附和、回声还是叫停」「她该不该闭嘴」 这一类判断,以及把音频按实时速度放出去。
终端 你的服务(嵌 SDK)
│ ① POST /v1/sessions ←── 你的后端签发(人设 / 模型 / 音色 / 工具白名单)
│ ← {session_id, token, expires_at}
│ ② WS /v1/realtime?token=…
│ 上行:16k s16le 单声道 PCM(二进制帧)
│ 下行:24k s16le PCM(二进制帧)+ JSON 事件(文本帧)
│ ③ DELETE /v1/sessions/{id}(或 TTL 15min 自然过期)| 包 | 职责 | 你会碰吗 |
|---|---|---|
agent | 装配层:agent.New(cfg).Serve(":8080") 一句起完整服务 | 主要入口 |
engine | 纯逻辑状态机:相位、判停窗、打断裁决。无 goroutine、无时钟 | 只读;行为疑问看它的注释 |
voice | VAD 与语义判停的注入口(Prober / EndOfTurn) | 换模型时 |
provider/dashscope | ASR / LLM / TTS 三件上游 | 换供应商时 |
provider/silero | Silero VAD(-tags silero) | 构建时 |
provider/smartturn | Smart Turn v3 语义判停(-tags smartturn) | 可选 |
transport/ws | WebSocket 传输:连接上限、读超时、来源校验 | 一般不碰 |
mcptool | MCP 工具箱适配 | 接工具时 |
examples/musicbox | 曲库工具的参照实现(抄走改,别当依赖) | 做点播时 |
安装
go get —— 写完代码要 go mod tidy
go get <module>@版本 不扫你的 import:模块会被记成
// indirect,传递依赖的 go.sum 条目一条都不写。紧接着
go build 会吐十几行 missing go.sum entry,
而错误里没有任何一句提示该跑 go mod tidy。
实测(v0.3.0,空白工程):go get …@v0.3.0 → 8 条
missing go.sum entry;改跑 go mod tidy → 一次通过。
踩到时最像「这个私有仓库配坏了」,GOPRIVATE、凭据、代理会被挨个怀疑一遍,
实际上跟它们都没关系。
模块路径与仓库路径一致:
codeup.aliyun.com/mosi/voice-agent-sdk
这是私有仓库,公共 proxy 上没有,使用方要先配两件事:
# 1) 跳过公共 proxy 和校验和库 go env -w GOPRIVATE=codeup.aliyun.com/mosi/* # 2) 凭据(二选一) git config --global url."git@codeup.aliyun.com:".insteadOf "https://codeup.aliyun.com/" # 或写 ~/.netrc: machine codeup.aliyun.com login <账号> password <个人访问令牌> go get codeup.aliyun.com/mosi/voice-agent-sdk@v0.2.1
go install 拿不到能直接跑的二进制
cmd/server 依赖 CGO + libonnxruntime。不带 -tags silero 装得上但启动即报缺 VAD;带标签则需要使用方先装好 onnxruntime。
交付姿势是 go get 当依赖 + 自己写 main.go,发二进制请用预编译产物或基础镜像。
上面这套在干净模块里跑通过(go get + go build 到 v0.2.1)。
⚠️ internal/ 下的包外部引不了 —— 需要什么请从 agent 或顶层包暴露。
codeup 的 ?go-get=1 只对「组织/仓库」两级返回 go-import meta。
放在子组下(mosi/<子组>/voice-agent-sdk)的话 Go 解析不到仓库边界,
会退化成去找 mosi/<子组>.git 然后报 repository not found。
快速开始:从零跑通一次对话
交付物是 SDK,不是服务。最短路径是嵌进你自己的 main.go:
package main import ( "log" "os" "codeup.aliyun.com/mosi/voice-agent-sdk/agent" ) func main() { a, err := agent.New(agent.Config{ Dashscope: agent.Dashscope{ APIKey: os.Getenv("DASHSCOPE_API_KEY"), }, VADModel: "/opt/models/silero_vad.onnx", Persona: "你是四月,说话简短自然,别念清单。", Events: true, // 把判定过程下发给终端(调试/埋点用) }) if err != nil { log.Fatal(err) } log.Fatal(a.Serve(":8080")) }
跑起来之后有六个口子:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /v1/sessions | 签发会话 token(带人设/模型/音色/工具白名单) |
| DELETE | /v1/sessions/{id} | 主动撤销 |
| WS | /v1/realtime | 实时音频与事件 |
| GET | /healthz | 存活探针,恒返回 ok |
| GET | /version | 构建的提交号与时间 —— 验证部署换没换成功靠它 |
| GET | /dev | 调试台单页(默认关,见 DevPage) |
/version 是验证部署换没换成功的唯一手段 ——/healthz 新旧二进制答得一模一样。详见 部署要点。
构建与 CGO
VAD 和语义判停都是 ONNX 模型,走 CGO 链接 libonnxruntime。
两个能力各有一个构建标签,不带标签也能编译(那时必须自己注入 Prober)。
# 只要 VAD(最常见) CGO_CFLAGS=-I/opt/onnxruntime/include CGO_LDFLAGS=-L/opt/onnxruntime/lib \ go build -tags silero -o va-server ./cmd/server # 连语义判停一起(可选,见「语义判停」一节) CGO_CFLAGS=-I/opt/onnxruntime/include CGO_LDFLAGS=-L/opt/onnxruntime/lib \ go build -tags smartturn,silero -o va-server ./cmd/server # 运行时 export LD_LIBRARY_PATH=/opt/onnxruntime/lib
只开一个标签编译通过不代表两个一起开也通过(两个 provider 各带一份 vendored ONNX 胶水,符号撞车只在同时链接时暴露)。CI 里请直接用生产那条命令。
判停:什么时候算说完了
判停是这个产品手感的地基:窗短了在句中停顿处抢答半句,窗长了每一轮都慢。 Runtime 的判据是静音时长,而窗口长度按「这句听起来说完了没有」动态调制。
| 档 | 窗口 | 什么时候用 |
|---|---|---|
| Fast | 220ms | 听起来说完了(句末语气词、疑问收尾) |
| 默认 | 300ms | 没有文本证据 —— 也是不启用任何语义判据时的行为 |
| Slow | 700ms | 明显没说完(尾巴是结构助词、连词、介词) |
300ms 那个数不是拍的:它来自 756 条真实录音的扫窗,是「每轮省 250ms 只多付 2.4 个百分点判早」的拐点。没有同等规模的语料就别动它。
判早时前半句会攒进 pending、这一轮结束时并回去,用户听不出来。Runtime 为这种轮次记一条 判停判早·定稿是拼起来的,带拼了几段 —— 判早率靠它统计。
打断与叫停
她在说话时用户开口,Runtime 立刻把音量压到 30%(不是硬停), 然后等文本证据来裁决。四种裁定,含义各不相同:
verdict | 含义 | 动作 |
|---|---|---|
real | 实词定稿到了,用户真要插话 | 作废本轮,那句话立即成为新一轮 |
backchannel | 「嗯」一声,听着呢 | 不作废,恢复音量继续说 |
echo | AEC 残留:电平低到不可能是前景用户,或转写是她刚说的那句的连续片段 | 同上 |
false | 全程一个字都没转出来,干等满兜底窗 | 同上;这是纯误判,比值要盯 |
压低音量之后如果用户持续出声超过 700ms,不等文本直接升级为全停 —— 这是「叫不停」的物理保证,不依赖听写质量。
判成回声 = 不打断。只比字集重合会误判:比对基准是她这一轮说过的全部文本,说得越久字池越大,用户任何一句话都可能「字都说过」。
所以加第二道:转写里要有一段连续的字也连续出现在她话里(≥40% 且 ≥3 字)。
第三类输入:叫停
「停」「别说了」「行了行了别说了」这类不是附和、也不该当成实词:
| 当成 | 结果 |
|---|---|
| 附和 | 不作废 —— 她接着说,用户体感是「我让她停也不停」 |
| 实词 | 作废本轮 + 拿这句去生成 —— 她停一下,然后回你一句「好的」 |
| 叫停(现在) | 作废输出、掐掉生成、不开新一轮,回到待命 |
partial 一命中就停,不等定稿(等定稿要多半秒)。代价是 partial 可能被 ASR 改口 —— 那时她已经停了,改口后的实词定稿照常成一轮。
判据分两档,理由是「快而糙的判据放可撤销的动作上,慢而准的放不可撤销的动作上」:
| 时机 | 判据 | 它决定 |
|---|---|---|
| partial | 整句精确匹配 | 闭嘴 —— 可撤销,停错了用户再说一句就回来 |
| 定稿 | 整句组合判据 | 回不回话 —— 开口收不回来,且这一档不吃延迟预算 |
组合判据把整句切成「反义 / 原子 / 虚词」三种词:有一段三者都不是(= 有实词) 就走实词路。所以:
行了行了别说了 → 叫停(原子+原子+原子) 好了我知道了你别说了 → 叫停 你先停一下 → 叫停(「你」「先」是虚词) 停一下我想问个事 → 实词路,她该回答 你别停下来啊 → 反义,用户在喊接着说 好了吗 / 行了吗 → 疑问,用户在等回答 还行吧 / 还不错 → 评价,不是叫停
叫停词表内置在 SDK 里,不开放配置 —— 收宽一个字就会把真话判成叫停。要改请提 issue 并带上录音。
语义判停(可选模型)
内置的规则判据只抓得住「尾巴是虚词」那一类。抓不住的是这种:
用户说:给我查一下 …(换气)… 上海的天气
↑ 语法上完整、尾巴是实词,只有模型知道后面还有宾语所以留了一个和 VAD 完全对称的注入口。装 Smart Turn v3 那一类 (Whisper-tiny + 线性头,本地跑,一轮一次推理)不用动状态机:
type EndOfTurn interface { // 给最近 8 秒的 16k 单声道 s16le PCM,返回「说完了」的概率 [0,1]。 Predict(pcm []byte) (float32, error) }
| 怎么启用 | 配置 |
|---|---|
| 用内置的 Smart Turn v3 | 构建加 -tags smartturn,配 SmartTurnModel / SMART_TURN_MODEL |
| 自己接别的模型 | 实现 voice.EndOfTurn 塞进 Config.EndOfTurn(优先于前者) |
| 不启用 | 两个都不配 —— 退回规则判据,零成本、零内存 |
实测代价(一台 Xeon Platinum)
| 项 | 耗时 | 说明 |
|---|---|---|
| log-mel 前端 | 14.7ms | Whisper 谱,纯 Go,零分配 |
| ONNX 推理(1 线程) | 92.9ms | |
| ONNX 推理(2 线程,默认) | 54.0ms | 合计约 68.7ms 一次 |
| ONNX 推理(4 线程) | 41.5ms | 不取:200 会话 × 4 线程会把省下的吃回去 |
| 每会话内存 | +256KB | 8 秒滚动音频缓冲;不启用时一个字节都不分配 |
68.7ms 而主循环 20ms 一帧,同步跑会让出站配速卡住。Runtime 在静音 100ms 时异步发问,答案 ~169ms 就位,正好赶上 220ms 的 fast 窗。改推理耗时必须连着改触发时机(有测试守着)。
SDK 侧只验证了管道是通的(与 Python 参考实现逐点对拍,log-mel 偏差 < 1e-6),没有验证中文判断质量。
建议:先收几天 判停判早 日志,判早率高才值得开 —— 那批日志同时也是评估模型的语料。
抢跑
收口锚在「拿到 ASR 定稿」那一刻,而定稿比请求晚 130~220ms。抢跑就是抢这一段: 请求定稿的同时拿最后一个 partial 提前起生成,定稿到了对得上就认领、对不上就整个丢掉重跑。
三条铁律,每条都对应一种真实的坏法:
| 铁律 | 不这么做会怎样 |
|---|---|
| 只在收口相起跑 | 音频被推进上一轮还没播完的队列,账和音频一起乱 |
| 只提前算,不提前出声 | 抢错的那句已经出了声 = 客户端替服务端做了判停 |
| 认领时才进历史 | 历史里出现用户根本没说过的半句话,下一轮她会引用它 |
认领的判据是「同一句」,但容忍一种差别:定稿只在尾巴多了纯语气词。 「我讲故事」→「我讲故事吧」对模型是同一个请求,因为差一个字丢掉整份产物不值。 差实字一律重跑 ——「晚上」和「晚上好」是两个意思。
另外三处止损:用户重新开口时当场掐掉(那一刻就能确定必然落空); 非实词(附和/叫停)不起跑;攒够 5 秒音频还没等到定稿就丢掉。
抢跑有【上下界】,不是只有下界
只在 ASR 假设「刚稳下来」时起跑:100ms ≤ stable ≤ 480ms。
上界是数据推翻设计假设推出来的。真机 27 条带 stable_ms 的记录:
命中(14) 128 128 128 128 160 192 224 256 352 352 352 384 416 448 落空(13) 416 512 512 512 544 608 640 960 1056 1120 1536 1696 1888
越「稳」越落空。看原文就明白:spec=我抽 / final=我从一数到一百
(stable=960)——「我抽」躺了 960ms 不是因为它对,是因为 ASR 那一刻没吐新东西。
判据要的是刚刚稳下来的假设,不是很久没动的假设。
加上界之后真机命中率从 60% 升到 83%。
agent.Config 全字段
零值即默认。除 Dashscope.APIKey 和 VAD 二选一外,其余都可省。
上游三件
| 字段 | 默认 | 说明 |
|---|---|---|
Dashscope.APIKey | — | 必填 |
Dashscope.HTTP / .WS | 官方端点 | 换区域、走代理用 |
Dashscope.LLMModel | qwen3.7-flash | |
Dashscope.MaxTokens | 200 | 口语回复的长度硬闸;失控的长回复既费钱又抬打断率 |
Dashscope.Temperature | 上游默认 | |
Dashscope.ASRModel | qwen3-asr-flash-realtime | |
Dashscope.ASRLang | 自动判定 | 建议显式配 zh:孤立单字是语种判定最不稳的输入 |
Dashscope.TTSModel | qwen3-tts-flash-realtime | cosyvoice-* 自动换协议 |
Dashscope.Voice | longxing_v3 | |
Dashscope.TTSInstructions | — | 语气指令,仅 qwen3-tts 系 |
Dashscope.TTSRate / .TTSPitch | — | 语速 / 音高 |
Dashscope.TTSSSML | false | 仅 cosyvoice-* |
核心装配
| 字段 | 默认 | 说明 |
|---|---|---|
VADModel | — | silero_vad.onnx 路径(需 -tags silero) |
Prober | — | 自己注入 VAD;与 VADModel 至少给一个 |
EndOfTurn | — | 语义判停模型;不配退回规则判据 |
SmartTurnModel | — | Smart Turn v3 路径(需 -tags smartturn) |
Persona | — | 系统提示词 |
TimeZone | Asia/Shanghai | 她说「现在几点」用的时区 |
MaxHistory | 20 | 带给 LLM 的历史条数(用户+agent 合计) |
SkillsDir | — | 技能预设目录;加载失败在启动时炸 |
Precache | false | 寒暄直出:命中整段跳过 LLM+TTS(首音 ~160ms vs 全链 ~870ms) |
Toolbox | — | 业务工具 |
承载面与安全
| 字段 | 默认 | 说明 |
|---|---|---|
MaxConns | 200 | 同时在跑的实时连接上限。每条吃一份 ASR/TTS 上游 + 一份 ONNX 会话,按机型和上游配额调 |
MaxSessions | 10000 | 存活 token 上限 |
ReadTimeout | 90s | 实时连接读超时(最长静默存活) |
RequireSession | false | 公网必开:实时口必须带有效 token |
AdminToken | — | 保护签发口;空 = 不鉴权(启动告警,只许内网) |
AllowOrigins | 同源 | 浏览器来源白名单;"*" 全放 |
观测与调试
| 字段 | 默认 | 说明 |
|---|---|---|
Events | true | 把判定过程下发客户端 |
OnEvent | — | 服务端收一份事件流(内容日志、自定义埋点) |
LogText | false | 允许日志携带用户原话;默认只给字数 |
DevPage | false | 挂 /dev 调试台 |
TracerProvider | — | 非空 = 开 OTel |
Logger | slog.Default() |
/dev 开着等于给任何人一个能说话的入口(烧上游配额)。公网要开就同时配
RequireSession,并用 /dev?token=<签发的token> 打开 —— 页面会把它透传给实时口。
RequireSession 挡的是「连接」不是「页面」:要连页面都不给看,得在网关挡 /dev。
环境变量(官方启动器)
cmd/server 是全 env 配置的最小启动器。正式接入建议嵌 agent.New,
但这张表也是「Config 有哪些旋钮」的速查。
# 必填 DASHSCOPE_API_KEY=sk-… SILERO_VAD=/opt/models/silero_vad.onnx # 上游端点与调音 DASHSCOPE_HTTP= DASHSCOPE_WS= LLM_MODEL= LLM_MAX_TOKENS= LLM_TEMPERATURE= ASR_MODEL= ASR_LANG=zh TTS_MODEL= TTS_VOICE= TTS_INSTRUCTIONS= TTS_RATE= TTS_PITCH= TTS_SSML=1 # 人设与对话 PERSONA= TIMEZONE=Asia/Shanghai MAX_HISTORY=20 SKILLS_DIR= PRECACHE=1 SMART_TURN_MODEL= # 语义判停(需 -tags smartturn) # 搜索与工具 SEARCH=1 SEARCH_SITES=a.com,b.com SEARCH_DAYS=7 SONG_DIR= MUSIC_DIR= # MCP 业务工具(不配 = 完全不接 MCP) MCP_ENDPOINT=https://tools.you.com/mcp MCP_ALLOW=get_order,refund # 必填,默认拒绝 MCP_HEADERS=Authorization=Bearer xxx MCP_TIMEOUT=5s # MCP 放音频(配了 MEDIA_HOSTS 才开) MEDIA_HOSTS=cdn.you.com # 空 = 媒体能力整个关闭 MEDIA_MAX_BYTES=16777216 MEDIA_CACHE_BYTES=67108864 MEDIA_ALLOW_PRIVATE= MEDIA_CACHE_IGNORE_QUERY= # 承载面与安全 ADDR=:8080 MAX_CONNS=200 MAX_SESSIONS=10000 READ_TIMEOUT=90s REQUIRE_SESSION=1 ADMIN_TOKEN= ALLOW_ORIGINS=https://your.app # 观测 EVENTS=1 LOG_TEXT= DEV_PAGE= OTLP_ENDPOINT=http://tempo:4318 OTEL_SERVICE_NAME=voice-agent
MAX_CONNS=2OO(字母 O)这类会直接报错退出。吞成 0 等于「用默认值」,
而那和「配了但没生效」长得一模一样。
联网搜索
Search.Builtin 打开之后一直能搜,配不配工具箱都一样。
web_search
有工具箱时 SDK 会自动加一个叫 web_search 的工具。你再塞一个同名的进去,会被静默丢掉(合并时重名先注册的赢,而 SDK 那个在前)—— 两边行为不一致时查起来毫无线索。需要改搜索行为请用 Search.Sites / Search.Days,或用会话级 tools 白名单把它关掉。
上游的 enable_search 确实会把 function calling 压死,但这件事 SDK 自己解掉了:
| 情形 | 走法 | 代价 |
|---|---|---|
| 没有工具箱 | 主链直接开内置搜索 | 少一趟 LLM,快 150~350ms |
| 有工具箱 | 搜索包成 web_search 工具,主链保住 function calling | 这类轮次多一趟 LLM(实测子请求 ~1.1s) |
Search.Sites(域名白名单,最多 25 个)和 Search.Days(7/30/180/365)
在两条路上都生效。子请求会自动带上当前日期 —— 「今天天气」这类问题需要它。
鉴权:两把钥匙
| 钥匙 | 护的口 | 不配的后果 |
|---|---|---|
AdminToken | POST /v1/sessions(Authorization: Bearer …) | 任何人都能签发会话 —— 只许内网 |
| 会话 token | WS /v1/realtime?token=… | 不开 RequireSession 时任何人都能连、都能说话 |
会话 token 15 分钟过期,有效期内可重复使用(断线重连是常态,一次性 token 会让每次重连都要重新签发)。安全性靠短 TTL 兜。
技能 Skills(YAML)
SkillsDir 指向一个目录,里面每个 *.yaml 是一个命名预设。
签发时用 {"skill":"<文件名>"} 选用。
persona: 你是护理助手,说话慢一点,多确认。 llm_model: qwen3.7-flash tts_voice: longxing_v3 max_history: 12 timezone: Asia/Shanghai search: false tools: [check_schedule]
预设只填空位:请求里显式给出的字段永远赢。未知技能名在签发时就报错,
不会静默退回默认人设。目录加载失败在 agent.New 时炸 ——
技能文件是交付物,坏了要在启动时看见。
工具箱与 MCP
type Toolbox interface { // Specs 是这一轮要交给模型的全部工具说明(允许每轮不同)。 Specs() []ToolSpec // Invoke 执行一次调用,返回给模型看的结果文本。 Invoke(ctx context.Context, call ToolCall) string }
Invoke 没有 error 返回,这是刻意的:工具失败是常事(网络、限流、参数不对),
模型看到失败原因能改口重试或换个说法;中断这一轮用户听到的是她突然哑了。
MCP 用 mcptool.New 适配;会话级 tools 白名单只能收窄不能放宽,
且 Specs 和 Invoke 两头都拦 —— 只滤 Specs 的话,模型幻觉出一个未放行的
工具名照样能执行。
工具轮语义
最多三趟 LLM,也就是两轮工具 + 一趟收口。最后一趟不给工具: 给了模型可能又要调,而那之后没有下一趟去问它「拿结果说话」—— 工具白执行一遍,用户听到这一轮突然没声了。
语音场景每多一次模型往返都是几百毫秒,这个数别随手往上加。
曲库播放(媒体注入)
SDK 只提供媒体注入原语,不知道「曲库」这个概念 —— 有几个库、怎么选曲、
索引什么格式,是宿主的业务(版权、命名、匹配口径每家都不同)。
examples/musicbox 是参照实现,抄走改,别当依赖。
ctl, ok := voiceagent.MediaFromContext(ctx) if !ok { return "播放功能当前不可用。" } ctl.PlayPCM(voiceagent.KindSong, id, title, f) // f 的所有权移交 SDK
曲库形态:一个目录 + index.json + 24k s16le 单声道裸 PCM。
转码在入库时做一次:ffmpeg -i in.mp3 -ar 24000 -ac 1 -f s16le out.pcm。
SDK 里没有解码器 —— 格式错误应该在入库时炸,而不是播放时出一阵噪声。
播放语义:报幕后接播、非附和文本立停、附和/跟唱不切歌。 媒体走同一个出站队列,所以配速与打断机器全部复用。
描述里同时有「曲名表」和「报幕例句」的话,模型能拼出一句看着正确的回复而一次工具都不调。报幕底稿应该由工具结果给 —— 那句话只有真调过工具才拿得到。
打断是两级的
放音频期间 SDK 把 ASR 闸关上(和她自己说话时一样)—— 否则在没有 AEC 的终端上, 她的歌声回进麦克风会被转成文本,走进停播判据、把自己停掉。打断能力由两级顶上:
| 触发 | 动作 | 可逆 |
|---|---|---|
| VAD 认出开口 | 音量压到三成,开麦补灌预滚动,等定稿裁决 | 是 |
| ASR 定稿是实词/叫停 | 真停播 | 否 |
| 定稿是附和 / 静音 1.2s 没等到内容 | 音量还原,继续放 | 是 |
快而糙的判据只配放在可逆动作上。此前 partial 一来就停, 现场是一个 2~3 字的片段(「应该」)把整首歌不可逆地掐掉。
播放要等本轮出站全部排空,报幕多长音乐就晚多久(实测曾达 6~8 秒)。 这条卡在代码里,不靠 prompt —— 措辞约束不住模型行为。
让 MCP 工具放音频(交付方不写 Go)
工具结果里除了 text,再带一块 resource_link 或内联 audio,
SDK 取回来播。配了 MEDIA_HOSTS 才开。
{"content": [
{"type": "text", "text": "已开始唱《星夜》。回一句自然的话就行,别念歌词。"},
{"type": "resource_link", "uri": "https://cdn.you.com/starnight.wav",
"mimeType": "audio/wav", "title": "星夜"}
]}text 那块给模型当报幕底稿,resource_link 那块 SDK 拿去播。
stop_playing 由 SDK 内置,不用你实现 —— 它是唯一绝不能失败的工具,
而实现纯粹是本地的。想标成「她自己唱的」,在那块内容的 _meta 里写
{"voice-agent/kind": "song"},默认是 music。
裸流里没有采样率。猜错不报错,只会让她的声音变调(44.1k 当 24k 播)、
快一倍(双声道)或变噪声(24bit),而服务端一片正常。
mp3/opus 请入库时转好:ffmpeg -i in.mp3 -ar 24000 -ac 1 -c:a pcm_s16le out.wav
一个被攻破或只是写错的 MCP server,就能让语音服务器代它去请求任意地址,
而它多半在 VPC 里。所以:主机白名单(MEDIA_HOSTS,默认拒绝)
+ 出网地址检查(解析之后、连接之前,DNS 指到哪都绕不过)。
MEDIA_ALLOW_PRIVATE=1 会关掉第二道。同机部署时是必要的,
但这时务必把 MEDIA_HOSTS 收到最小(如只写 127.0.0.1),
靠第一道挡住 100.100.100.200(阿里云元数据,能拿 RAM 临时凭据)。
⚠️ 白名单只比主机名不比端口 —— 放行 127.0.0.1 等于放行本机所有端口。
音频缓存
取回的音频按 URL 做 LRU 缓存,默认 64MiB(MEDIA_CACHE_BYTES=0 关闭)。
小曲库翻来覆去就那几首,而音频是下载完才播的 —— 重复下载浪费的正是用户在等的时间。
预签名的签名和过期都在 query 里,每次都不同,不开的话永远命中不了。
但 query 里若有 ?v=2 ?quality=high 这类标识内容的参数,
一开就是灾难:两份音频撞成一个键,用户点 A 听到 B,而且随机(谁先进缓存谁赢)。
no-store / private / no-cache 都尊重。
private 也拦,是因为我们是共享缓存 —— 所有会话共用一份,
放行的话按用户签发的私有音频会被发给别的用户。
自定义 ASR:Commit 的契约
换识别上游要实现 voiceagent.ASR。其中 Commit 有一条
不能想当然的约定:
type ASR interface { Feed(pcm []byte) // Commit 声明当前缓冲为一整句,触发定稿识别。 // 返回【这次是不是真的发出去了】——缓冲里没有新音频时可以跳过, // 那时返回 false。 Commit() bool Events() <-chan ASREvent }
Runtime 据此推理「下一份定稿是我催出来的残渣,该丢」。跳过了却返回 true,被丢掉的就是用户的下一句真话 —— 定稿进不了状态机,也就没有作废动作,她停不下来。
为什么允许跳过:某些上游对空缓冲的 commit 会回一帧 error,而 Runtime 是 刻意多催的(少催一次的代价是用户整句话没人听)。跳过让多出来的那几次 变成免费空操作。
voiceagent.ASR 接口的 Commit()
从无返回值改成返回 bool。只影响自己实现了 ASR provider 的
交付方(用内置 DashScope 的不受影响),改动是加一个返回值,就是上面这个返回值。
会话签发 /v1/sessions
/v1/sessionscurl -X POST https://your.host/v1/sessions \ -H 'Authorization: Bearer $ADMIN_TOKEN' \ -H 'Content-Type: application/json' \ -d '{"persona":"你是四月…","voice":"longxing_v3","tools":["sing_song"]}' # ← {"session_id":"…","token":"…","expires_at":"2026-08-06T04:15:00Z"}
可配面(零值 = 沿用进程默认):
| 字段 | 说明 |
|---|---|
persona | 覆盖系统提示词 |
llm_model / asr_model / tts_model / voice | 覆盖对应上游 |
search | 显式开关联网搜索(指针语义:不给 = 用默认,给 false = 明确关掉) |
max_history / timezone | 覆盖上下文轮数 / 时区 |
skill | 选一个预置技能(见「技能」一节) |
tools | 本会话工具白名单,只能收窄 |
实时连接与音频
/v1/realtime?token=…| 方向 | 格式 |
|---|---|
| 上行 | 16k s16le 单声道 PCM,二进制帧。分片大小随意(SDK 自己攒窗) |
| 下行(音频) | 24k s16le 单声道 PCM,二进制帧,20ms 一帧 |
| 下行(事件) | JSON,文本帧 |
上行 16k(识别模型钉死),下行 24k(合成质量)。浏览器端要两个 AudioContext —— 共用一个的表现是她的声音变调。
服务端停了,客户端手里压着的还会继续播一两百毫秒。这是终端侧唯一必须实现的事件。
这条通道只出不进:上行只有音频,文本帧一律忽略。判定权全在服务端 —— 加一个「客户端说我说完了」的口子,等于把判停权分一半出去。
浏览器客户端:一条必须绕的坑
Chromium 的回声消除不处理 Web Audio API 的输出 —— 官方原话是
"does not apply echo cancellation to any remote streams, including
WebAudio Api streams"。而实时语音客户端的回放基本都是 WebAudio
(AudioWorkletNode → destination),所以 agent 自己的声音是
100% 未消除地回进麦克风的。到 2026 年浏览器仍然没有原生 API 做这件事
(Chromium issue 687574 / 40252911)。
后果不是「音质差一点」,是这条链路的判据全废:智能音箱实测的 NER(近端语音/回放回声比)普遍在 -10dB 以下,而 ASR 要工作需要 >0dB。她一出声,用户说什么都转不准;她的声音还会顶起 VAD, 服务端以为用户一直在说话。
唯一的解法是 RTCPeerConnection 本地环回:把回放塞进一条环回的
peer connection 再用 <audio> 播,浏览器就把它当
「远端参与者音频」而愿意消除。
const pc1 = new RTCPeerConnection(), pc2 = new RTCPeerConnection();
pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);
pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);
pc2.ontrack = e => { audioEl.srcObject = e.streams[0]; }; // AEC 从这里看见它
const dest = playCtx.createMediaStreamDestination();
playNode.connect(dest); // 不要 connect(playCtx.destination)
pc1.addTrack(dest.stream.getAudioTracks()[0], dest.stream);
// 再走 offer/answer,等 ontrack 到了才开始播| 容易漏的 | 漏了会怎样 |
|---|---|
| ICE 候选必须双向交换 | 协商永远不完成 —— 一点声音都没有,且零报错 |
用 addTrack 不能用 addStream | 后者已废弃,协商时报错 |
| 结束时关掉两个 PeerConnection | 每次进出留一对,泄漏几轮后「越用越卡」而页面看着正常 |
接环回前:抢跑命中率 17%,落空的原文是转错字
(继续记↔继续继续、那挺↔应该挺快)。
接环回后:命中率 60%,落空全部变成前缀没长完
(不是你完↔不是你完整的说出来)。
「听错」和「没听完」是两回事 —— 前者是回声污染,后者只是抢跑抢早了。 这个差别就是 AEC 有没有生效的判据。
下行事件参考
所有事件都有 t(类型)和 at(毫秒,流内时钟)。
未知的 t 请忽略 —— 加事件是兼容的加法。
t | 字段 | 含义 / 终端该做什么 |
|---|---|---|
partial | text | 识别中间结果。显示成灰字,会被改写 |
heard | text | 定稿。这句进了历史 |
say | text | 她说的一句(在这句的第一片音频之前发) |
sources | sources[] | 联网搜索的来源 |
flush | — | 立刻清空播放队列 |
turn | outcome asr llm_first tts_first first_byte spoken | 一轮的账,全部毫秒 |
bargein | verdict stop text | 一次挂起的裁定 |
spec | verdict(hit/miss) | 抢跑命中/落空 |
reopen | gap | 收口之后用户又开口了 |
music | state kind id text reason | 曲库开播/停播 |
error | etype code text | 见「错误码」 |
轮次报表(t=turn)
| 字段 | 量的是 |
|---|---|
outcome | completed / interrupted / empty / error |
asr | 用户开口 → 定稿 |
llm_first | 定稿 → 第一句文本 |
tts_first | 第一句文本 → 第一片音频 |
first_byte | 定稿 → 第一片音频 |
spoken | 实际放行的音频时长 |
缺一端的段不报(省略而不是 0):报一个 0 会让分位统计以为「这一步快得飞起」,
而真相是它没发生过。打断起头的那一轮没有 asr(我们不知道用户何时开口),
但 llm_first / first_byte 照常有。
停播理由(t=music, state=stopped)
reason | 含义 |
|---|---|
done | 正常放完 |
user | 被用户语音打断 |
tool | 工具主动停 |
replaced | 被下一首顶掉 |
error | 读文件失败(截断、挂载掉线、权限)—— 服务端日志有原始错误 |
不是做裁定时看到的那一句。ASR 的 partial 是逐字长出来的前缀,裁定可能发生在 「行」那一刻而报出来的是「行吧行吧」。拿它排障之前先想清楚这一条。
错误码
etype | code | 怎么处理 |
|---|---|---|
invalid_request | invalid_token | token 无效或过期 → 重新签发再连 |
invalid_request | bad_request | 请求体解析不了 |
server_error | session_setup | 会话装配失败(VAD/上游建不起来)→ 报障 |
server_error | upstream_asr | 识别上游断流 → 可重连 |
server_error | upstream_tts | 合成上游建连失败 → 查音色/配额 |
server_error | generation | 生成链路中途出错 |
server_error | too_many_sessions | 签发数或连接数超限 → 退避重试 |
server_error | internal | 兜底,细节在服务端日志 |
握手阶段被拒(token 错、连接满)会先发一条 error 事件再关连接 ——
只关的话客户端拿到的是一个 1006,三种原因长得一模一样。
可观测:日志与 OTel
OTel
配 TracerProvider(或 OTLP_ENDPOINT)就开:每轮一条 trace,
turn 下挂 asr / generation(含 llm_first / tts_first)/ speak,
打断是独立的短 span,靠 session.id 归到一起。回填式 —— 主循环零埋点开销。
# base URL 就行,SDK 会补 /v1/traces OTLP_ENDPOINT=http://tempo:4318 # 已经指到信号上的、或带网关前缀的,原样不动 OTLP_ENDPOINT=http://gw/otlp OTEL_SERVICE_NAME=voice-agent-preview # 不配默认 voice-agent
能进观测的只有我们自己生成的随机 id(session.id / issued_session_id)。
token 是凭据,任何时候都不许进 span 属性;用户说的话更不许。
该盯的日志行
| 日志 | 它告诉你什么 |
|---|---|
判停判早·定稿是拼起来的 | 判早率 —— 决定要不要上语义判停模型 |
抢跑命中 / 抢跑落空 | 抢跑值不值;stable_ms 用来调门槛 |
工具调用 | 模型调了哪个工具、参数、耗时、结果 |
媒体·开播 / 媒体·停播 | 点播全链路;reason=error 带原始错误 |
叫停 | 叫停命中了哪一句 |
连接数超限 | MaxConns 该调了 |
VAD 推理死亡 | 该会话从此听不到用户 —— 静默死亡的唯一现场证据 |
按这三行顺序看,一次定位:
① 没有 工具调用 → 模型压根没调,问题在提示词/工具描述
② 有 工具调用、没有 曲库命中 → 匹配没中,看 query 长什么样
③ 有 曲库命中、没有 媒体·开播 → SDK 侧的问题,提 issue
部署要点
模型文件清单
| 文件 | 大小 | 必需? |
|---|---|---|
silero_vad.onnx | ~2.2MB | 是(除非自己注入 Prober) |
smart-turn-v3.*-cpu.onnx | ~8.3MB | 否 —— 语义判停,不配就用规则 |
libonnxruntime.so | ~14.6MB | 是(CGO 链接,v1.18.x 验证过) |
换二进制之后必须验这三样
# ① 服务活着 curl -s https://your.host/healthz # → ok # ② 跑的确实是新二进制(healthz 分辨不出新旧) curl -s https://your.host/version # → <提交号> <构建时间> # ③ 进程加载的就是刚换上去的那个文件 sha256sum /proc/$(pgrep -f your-server)/exe
老进程没杀掉 → 新进程端口被占起不来 → /healthz 照样 200(老进程在答)。所以第 ②③ 步不能省。
换二进制前请先用同一套 env、另一个端口试起一次。
承载面
每条实时连接吃:一份 ASR 上游 + 一份 TTS 上游 + 一份 ONNX VAD 会话
(开语义判停再加一份模型会话 + 256KB 缓冲)。MaxConns 默认 200,
按你的上游配额调,不是按内存调。
关停用 SIGTERM,别用 -9:优雅关闭那条路上会把攒着的 OTel span 刷出去,
而丢 trace 这件事没有任何报错。
容量:实测,不是估的
| 每条会话 | 用量 | 200 条 |
|---|---|---|
| ONNX(Silero VAD)内存 | 10.5 MB | 2.1 GB |
| CPU | 0.6% 单核 | 1.2 核 |
| 编排热路径 | 816 ns/帧、0 分配 | — |
MaxConns 默认 200 就是按这个来的。线程不涨(intra/inter 都是 1,
50 个 VAD 只占 6 个 OS 线程)。
每条会话要一条 ASR WS + 一条 TTS WS,上游的并发配额是多少要问你的 provider。SDK 这一层 200 条只吃 2.1GB/1.2 核,不是瓶颈。
Silero 的 state/ctx 本来就在 Go 侧,OrtSession 是无状态的,理论上能全进程 共享一个,内存从 2.1GB 掉到 10MB。实测否掉:8 路并发下共享比各自一个慢 2.08 倍(100µs vs 48µs/次)。省的是不缺的内存,付的是缺的延迟。
三条运行时行为(会影响客户端怎么写)
| 行为 | 你要做什么 |
|---|---|
| 下行队列满 = 主动断开,不丢帧。队列深 128 帧(≈2.5 秒) | 客户端要能处理「服务端主动断开」并重连。满了说明已经跟不上 48KB/s, 丢几帧救不回来,明确的重连信号更好 |
| ASR 上游断了会自动重连(4 次指数退避约 3 秒) | 不用管。⚠️ 重连期间用户说的话会丢,日志里是 asr 已重连;
拨不通才收口成「本会话即将结束」 |
| 容量类配置给负数会启动失败 | 留 0 才是「用默认值」。MAX_HISTORY=-1 以前能启动,然后每条
通话刚接上就断,而 /healthz 一直 200 |
不可配面
下面这些不开放配置,问了也不给。它们每一个都是实测选出来的, 而改错了的表现是「体验变差但不报错」:
| 东西 | 值 | 为什么钉死 |
|---|---|---|
| 判停窗 | 300ms(默认档) | 756 条真实录音扫窗的拐点 |
| duck 音量 | 30%,40ms 斜坡 | 让位要能听出来又不能像卡顿 |
| 升级为全停 | 持续出声 700ms | 「连喊必停」的物理保证 |
| 叫停/附和词表 | 内置 | 收宽一个字 = 真话被当成叫停、她当场闭嘴 |
| 出站配速 | 实时 + 60ms 提前量 | 不配速则打断停不住 |
| 上行/下行采样率 | 16k / 24k | 识别模型钉死 / 合成质量 |
要改请提 issue 并带上真机录音。调用方没有我们的语料和埋点,凭感觉调只会退化。
生产就绪清单
RequireSession=1且AdminToken已配 —— 公网两把钥匙都要有DEV_PAGE关掉,或在网关挡/devLOG_TEXT关掉 —— 它会把用户原话写进日志ALLOW_ORIGINS收窄 到你的域名MAX_CONNS按上游配额调 —— 默认 200 可能远超你的额度ASR_LANG=zh—— 孤立单字的识别会稳一截OTLP_ENDPOINT+OTEL_SERVICE_NAME—— 不配就没有 trace- 验过
/version与进程 exe 的 sha - 两个构建标签一起验过(如果启用了语义判停)
- 回退路径演练过 —— 留一份上一版二进制
MOSS-Live Voice Agent Runtime · 本页由 SDK 仓库当前代码核对生成 · 有出入以代码为准,并请提 issue