源码仓库 ↗

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、无时钟只读;行为疑问看它的注释
voiceVAD 与语义判停的注入口(Prober / EndOfTurn)换模型时
provider/dashscopeASR / LLM / TTS 三件上游换供应商时
provider/sileroSilero VAD(-tags silero)构建时
provider/smartturnSmart Turn v3 语义判停(-tags smartturn)可选
transport/wsWebSocket 传输:连接上限、读超时、来源校验一般不碰
mcptoolMCP 工具箱适配接工具时
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、凭据、代理会被挨个怀疑一遍, 实际上跟它们都没关系。

模块路径与仓库路径一致:

module
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:

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 的判据是静音时长,而窗口长度按「这句听起来说完了没有」动态调制。

档窗口什么时候用
Fast220ms听起来说完了(句末语气词、疑问收尾)
默认300ms没有文本证据 —— 也是不启用任何语义判据时的行为
Slow700ms明显没说完(尾巴是结构助词、连词、介词)

300ms 那个数不是拍的:它来自 756 条真实录音的扫窗,是「每轮省 250ms 只多付 2.4 个百分点判早」的拐点。没有同等规模的语料就别动它。

判早不会报错,但数得出来

判早时前半句会攒进 pending、这一轮结束时并回去,用户听不出来。Runtime 为这种轮次记一条 判停判早·定稿是拼起来的,带拼了几段 —— 判早率靠它统计。

打断与叫停

她在说话时用户开口,Runtime 立刻把音量压到 30%(不是硬停), 然后等文本证据来裁决。四种裁定,含义各不相同:

verdict含义动作
real实词定稿到了,用户真要插话作废本轮,那句话立即成为新一轮
backchannel「嗯」一声,听着呢不作废,恢复音量继续说
echoAEC 残留:电平低到不可能是前景用户,或转写是她刚说的那句的连续片段同上
false全程一个字都没转出来,干等满兜底窗同上;这是纯误判,比值要盯

压低音量之后如果用户持续出声超过 700ms,不等文本直接升级为全停 —— 这是「叫不停」的物理保证,不依赖听写质量。

回声判据为什么要看「连续片段」

判成回声 = 不打断。只比字集重合会误判:比对基准是她这一轮说过的全部文本,说得越久字池越大,用户任何一句话都可能「字都说过」。
所以加第二道:转写里要有一段连续的字也连续出现在她话里(≥40% 且 ≥3 字)。

第三类输入:叫停

「停」「别说了」「行了行了别说了」这类不是附和、也不该当成实词:

当成结果
附和不作废 —— 她接着说,用户体感是「我让她停也不停」
实词作废本轮 + 拿这句去生成 —— 她停一下,然后回你一句「好的」
叫停(现在)作废输出、掐掉生成、不开新一轮,回到待命
叫停不等定稿

partial 一命中就停,不等定稿(等定稿要多半秒)。代价是 partial 可能被 ASR 改口 —— 那时她已经停了,改口后的实词定稿照常成一轮。

判据分两档,理由是「快而糙的判据放可撤销的动作上,慢而准的放不可撤销的动作上」:

时机判据它决定
partial整句精确匹配闭嘴 —— 可撤销,停错了用户再说一句就回来
定稿整句组合判据回不回话 —— 开口收不回来,且这一档不吃延迟预算

组合判据把整句切成「反义 / 原子 / 虚词」三种词:有一段三者都不是(= 有实词) 就走实词路。所以:

判例
行了行了别说了        → 叫停(原子+原子+原子)
好了我知道了你别说了  → 叫停
你先停一下            → 叫停(「你」「先」是虚词)
停一下我想问个事      → 实词路,她该回答
你别停下来啊          → 反义,用户在喊接着说
好了吗 / 行了吗       → 疑问,用户在等回答
还行吧 / 还不错       → 评价,不是叫停
这张表是中文的,且不可配

叫停词表内置在 SDK 里,不开放配置 —— 收宽一个字就会把真话判成叫停。要改请提 issue 并带上录音。

语义判停(可选模型)

内置的规则判据只抓得住「尾巴是虚词」那一类。抓不住的是这种:

规则抓不到的判早
用户说:给我查一下 …(换气)… 上海的天气
        ↑ 语法上完整、尾巴是实词,只有模型知道后面还有宾语

所以留了一个和 VAD 完全对称的注入口。装 Smart Turn v3 那一类 (Whisper-tiny + 线性头,本地跑,一轮一次推理)不用动状态机:

voice/eot.go
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.7msWhisper 谱,纯 Go,零分配
ONNX 推理(1 线程)92.9ms
ONNX 推理(2 线程,默认)54.0ms合计约 68.7ms 一次
ONNX 推理(4 线程)41.5ms不取:200 会话 × 4 线程会把省下的吃回去
每会话内存+256KB8 秒滚动音频缓冲;不启用时一个字节都不分配
推理不在音频主循环上跑

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.LLMModelqwen3.7-flash
Dashscope.MaxTokens200口语回复的长度硬闸;失控的长回复既费钱又抬打断率
Dashscope.Temperature上游默认
Dashscope.ASRModelqwen3-asr-flash-realtime
Dashscope.ASRLang自动判定建议显式配 zh:孤立单字是语种判定最不稳的输入
Dashscope.TTSModelqwen3-tts-flash-realtimecosyvoice-* 自动换协议
Dashscope.Voicelongxing_v3
Dashscope.TTSInstructions—语气指令,仅 qwen3-tts 系
Dashscope.TTSRate / .TTSPitch—语速 / 音高
Dashscope.TTSSSMLfalse仅 cosyvoice-*

核心装配

字段默认说明
VADModel—silero_vad.onnx 路径(需 -tags silero)
Prober—自己注入 VAD;与 VADModel 至少给一个
EndOfTurn—语义判停模型;不配退回规则判据
SmartTurnModel—Smart Turn v3 路径(需 -tags smartturn)
Persona—系统提示词
TimeZoneAsia/Shanghai她说「现在几点」用的时区
MaxHistory20带给 LLM 的历史条数(用户+agent 合计)
SkillsDir—技能预设目录;加载失败在启动时炸
Precachefalse寒暄直出:命中整段跳过 LLM+TTS(首音 ~160ms vs 全链 ~870ms)
Toolbox—业务工具

承载面与安全

字段默认说明
MaxConns200同时在跑的实时连接上限。每条吃一份 ASR/TTS 上游 + 一份 ONNX 会话,按机型和上游配额调
MaxSessions10000存活 token 上限
ReadTimeout90s实时连接读超时(最长静默存活)
RequireSessionfalse公网必开:实时口必须带有效 token
AdminToken—保护签发口;空 = 不鉴权(启动告警,只许内网)
AllowOrigins同源浏览器来源白名单;"*" 全放

观测与调试

字段默认说明
Eventstrue把判定过程下发客户端
OnEvent—服务端收一份事件流(内容日志、自定义埋点)
LogTextfalse允许日志携带用户原话;默认只给字数
DevPagefalse挂 /dev 调试台
TracerProvider—非空 = 开 OTel
Loggerslog.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) 在两条路上都生效。子请求会自动带上当前日期 —— 「今天天气」这类问题需要它。

鉴权:两把钥匙

钥匙护的口不配的后果
AdminTokenPOST /v1/sessions(Authorization: Bearer …)任何人都能签发会话 —— 只许内网
会话 tokenWS /v1/realtime?token=…不开 RequireSession 时任何人都能连、都能说话

会话 token 15 分钟过期,有效期内可重复使用(断线重连是常态,一次性 token 会让每次重连都要重新签发)。安全性靠短 TTL 兜。

技能 Skills(YAML)

SkillsDir 指向一个目录,里面每个 *.yaml 是一个命名预设。 签发时用 {"skill":"<文件名>"} 选用。

skills/nurse.yaml
persona: 你是护理助手,说话慢一点,多确认。
llm_model: qwen3.7-flash
tts_voice: longxing_v3
max_history: 12
timezone: Asia/Shanghai
search: false
tools: [check_schedule]

预设只填空位:请求里显式给出的字段永远赢。未知技能名在签发时就报错, 不会静默退回默认人设。目录加载失败在 agent.New 时炸 —— 技能文件是交付物,坏了要在启动时看见。

工具箱与 MCP

Toolbox 接口
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

快而糙的判据只配放在可逆动作上。此前 partial 一来就停, 现场是一个 2~3 字的片段(「应该」)把整首歌不可逆地掐掉。

有待播音频时,报幕只放行第一句

播放要等本轮出站全部排空,报幕多长音乐就晚多久(实测曾达 6~8 秒)。 这条卡在代码里,不靠 prompt —— 措辞约束不住模型行为。

让 MCP 工具放音频(交付方不写 Go)

工具结果里除了 text,再带一块 resource_link 或内联 audio, SDK 取回来播。配了 MEDIA_HOSTS 才开。

MCP 工具返回
{"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。

格式不猜:只收 audio/wav 与 audio/L16;rate=24000;channels=1

裸流里没有采样率。猜错不报错,只会让她的声音变调(44.1k 当 24k 播)、 快一倍(双声道)或变噪声(24bit),而服务端一片正常。 mp3/opus 请入库时转好:ffmpeg -i in.mp3 -ar 24000 -ac 1 -c:a pcm_s16le out.wav

URI 是工具返回的数据,不是配置 —— 两道 SSRF 闸

一个被攻破或只是写错的 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 关闭)。 小曲库翻来覆去就那几首,而音频是下载完才播的 —— 重复下载浪费的正是用户在等的时间。

MEDIA_CACHE_IGNORE_QUERY 只给预签名 URL 用

预签名的签名和过期都在 query 里,每次都不同,不开的话永远命中不了。 但 query 里若有 ?v=2 ?quality=high 这类标识内容的参数, 一开就是灾难:两份音频撞成一个键,用户点 A 听到 B,而且随机(谁先进缓存谁赢)。

源站说不许缓存就不缓存

no-store / private / no-cache 都尊重。 private 也拦,是因为我们是共享缓存 —— 所有会话共用一份, 放行的话按用户签发的私有音频会被发给别的用户。

自定义 ASR:Commit 的契约

换识别上游要实现 voiceagent.ASR。其中 Commit 有一条 不能想当然的约定:

types.go
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

POST /v1/sessions
签发
curl -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本会话工具白名单,只能收窄

实时连接与音频

WS /v1/realtime?token=…
方向格式
上行16k s16le 单声道 PCM,二进制帧。分片大小随意(SDK 自己攒窗)
下行(音频)24k s16le 单声道 PCM,二进制帧,20ms 一帧
下行(事件)JSON,文本帧
采样率是双率,别共用一个音频上下文

上行 16k(识别模型钉死),下行 24k(合成质量)。浏览器端要两个 AudioContext —— 共用一个的表现是她的声音变调。

收到 flush 事件要立刻清空播放队列

服务端停了,客户端手里压着的还会继续播一两百毫秒。这是终端侧唯一必须实现的事件。

这条通道只出不进:上行只有音频,文本帧一律忽略。判定权全在服务端 —— 加一个「客户端说我说完了」的口子,等于把判停权分一半出去。

浏览器客户端:一条必须绕的坑

只开 echoCancellation 是没用的

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> 播,浏览器就把它当 「远端参与者音频」而愿意消除。

环回 AEC
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每次进出留一对,泄漏几轮后「越用越卡」而页面看着正常
实测对比(2026-08-06,同一台设备、同一套服务端)

接环回前:抢跑命中率 17%,落空的原文是转错字 (继续记↔继续继续、那挺↔应该挺快)。

接环回后:命中率 60%,落空全部变成前缀没长完 (不是你完↔不是你完整的说出来)。

「听错」和「没听完」是两回事 —— 前者是回声污染,后者只是抢跑抢早了。 这个差别就是 AEC 有没有生效的判据。

下行事件参考

所有事件都有 t(类型)和 at(毫秒,流内时钟)。 未知的 t 请忽略 —— 加事件是兼容的加法。

t字段含义 / 终端该做什么
partialtext识别中间结果。显示成灰字,会被改写
heardtext定稿。这句进了历史
saytext她说的一句(在这句的第一片音频之前发)
sourcessources[]联网搜索的来源
flush—立刻清空播放队列
turnoutcome asr llm_first tts_first first_byte spoken一轮的账,全部毫秒
bargeinverdict stop text一次挂起的裁定
specverdict(hit/miss)抢跑命中/落空
reopengap收口之后用户又开口了
musicstate kind id text reason曲库开播/停播
erroretype code text见「错误码」

轮次报表(t=turn)

字段量的是
outcomecompleted / 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读文件失败(截断、挂载掉线、权限)—— 服务端日志有原始错误
bargein 的 text 是「最后一个 partial」

不是做裁定时看到的那一句。ASR 的 partial 是逐字长出来的前缀,裁定可能发生在 「行」那一刻而报出来的是「行吧行吧」。拿它排障之前先想清楚这一条。

错误码

etypecode怎么处理
invalid_requestinvalid_tokentoken 无效或过期 → 重新签发再连
invalid_requestbad_request请求体解析不了
server_errorsession_setup会话装配失败(VAD/上游建不起来)→ 报障
server_errorupstream_asr识别上游断流 → 可重连
server_errorupstream_tts合成上游建连失败 → 查音色/配额
server_errorgeneration生成链路中途出错
server_errortoo_many_sessions签发数或连接数超限 → 退避重试
server_errorinternal兜底,细节在服务端日志

握手阶段被拒(token 错、连接满)会先发一条 error 事件再关连接 —— 只关的话客户端拿到的是一个 1006,三种原因长得一模一样。

可观测:日志与 OTel

OTel

配 TracerProvider(或 OTLP_ENDPOINT)就开:每轮一条 trace, turn 下挂 asr / generation(含 llm_first / tts_first)/ speak, 打断是独立的短 span,靠 session.id 归到一起。回填式 —— 主循环零埋点开销。

OTel 配置
# 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 MB2.1 GB
CPU0.6% 单核1.2 核
编排热路径816 ns/帧、0 分配—

MaxConns 默认 200 就是按这个来的。线程不涨(intra/inter 都是 1, 50 个 VAD 只占 6 个 OS 线程)。

真正的上限大概率不在 SDK 这边

每条会话要一条 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 并带上真机录音。调用方没有我们的语料和埋点,凭感觉调只会退化。

生产就绪清单

  1. RequireSession=1 且 AdminToken 已配 —— 公网两把钥匙都要有
  2. DEV_PAGE 关掉,或在网关挡 /dev
  3. LOG_TEXT 关掉 —— 它会把用户原话写进日志
  4. ALLOW_ORIGINS 收窄 到你的域名
  5. MAX_CONNS 按上游配额调 —— 默认 200 可能远超你的额度
  6. ASR_LANG=zh —— 孤立单字的识别会稳一截
  7. OTLP_ENDPOINT + OTEL_SERVICE_NAME —— 不配就没有 trace
  8. 验过 /version 与进程 exe 的 sha
  9. 两个构建标签一起验过(如果启用了语义判停)
  10. 回退路径演练过 —— 留一份上一版二进制

MOSS-Live Voice Agent Runtime · 本页由 SDK 仓库当前代码核对生成 · 有出入以代码为准,并请提 issue