交付团队接入手册
实时全双工语音 Agent,
从签发到开口的全部细节
SDK 管住语音对话最难的部分——判停、打断裁决、抢跑、配速;你负责三件事:
人设与技能、业务工具(MCP)、部署。事件 JSON 与 /v1/sessions
是最先冻结的两层;Go 接口 v0 期间锁 commit 消费。
概览与分层
SDK 负责一台语音 Agent 的全部编排:判停(用户说完了吗)、打断裁决(这声音是打断、附和还是回声)、 抢跑、上下文历史、出站配速、VAD/声纹、DashScope 三件(ASR/LLM/TTS)、WS 与 WebRTC 两种传输。
终端 你的服务(嵌 SDK)
│ ① POST /v1/sessions ←── 你的后端签发(persona / 模型 / 音色 / 工具)
│ ← {session_id, token, expires_at}
│ ② WS /v1/realtime?token=… 或 POST /v1/rtc?token=…(WebRTC 信令)
│ 上行:音频(16k s16le 单声道 PCM;WebRTC 为 Opus track)
│ 下行:音频(24k)+ JSON 事件(见「下行事件参考」)
│ ③ DELETE /v1/sessions/{id}(或 TTL 15min 自然过期)两层入口,按需选一层:
| 层 | 入口 | 适用 |
|---|---|---|
agent | agent.New(cfg).Serve(":8080") | 开箱即用:DashScope 全家 + WS/WebRTC + 会话签发,30 行起完整服务。绝大多数交付从这进。 |
dialog | dialog.New(…) | 五个口(ASR/LLM/TTS/Toolbox/Prober)全自定义——换上游供应商才走这层。 |
五分钟跑起来
- 准备依赖
Go ≥ 1.22、
libonnxruntime1.18.1、libopus、模型文件silero_vad.onnx(清单见「部署」)。私仓拉取:GOPRIVATE=codeup.aliyun.com。 - 写 main.go(这就是全部)
package main import ( "log/slog" "os" _ "time/tzdata" // 容器里没有 tzdata 也要能算对时间 "codeup.aliyun.com/mosi/MOSS-Live/sdk/agent" ) func main() { a, err := agent.New(agent.Config{ Dashscope: agent.Dashscope{APIKey: os.Getenv("DASHSCOPE_API_KEY")}, VADModel: os.Getenv("SILERO_VAD"), // silero_vad.onnx 路径 Persona: "你是一只爱讲冷笑话的电子鹦鹉,回答要短,像聊天不像播报。", Events: true, // 判定过程发给客户端,调试期开着 AdminToken: os.Getenv("ADMIN_TOKEN"), // 管理面鉴权,生产必配 }) if err != nil { slog.Error("装配失败", "err", err) os.Exit(1) } if err := a.Serve(":8080"); err != nil { slog.Error("serve", "err", err) os.Exit(1) } } - 构建并运行
go build -tags "silero nolibopusfile" . DASHSCOPE_API_KEY=sk-xxx \ SILERO_VAD=/models/silero_vad.onnx \ ADMIN_TOKEN=xxx ./yourservice
- 签会话、连实时口
# 你的后端来调,带 AdminToken curl -X POST 127.0.0.1:8080/v1/sessions \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -d '{"persona":"你是售后助手……"}' # ← {"session_id":"…","token":"…","expires_at":"…"} # 终端拿 token 连 WS,推 16k s16le PCM,收音频与事件 # ws://127.0.0.1:8080/v1/realtime?token=…
路由一共四个:/v1/realtime(WS)、/v1/rtc(WebRTC 信令,配了 RTC 才有)、
/v1/sessions(签发/撤销)、/healthz。完整可跑示例:
examples/minimal(上面这个)、examples/delivery(技能 + MCP 全链交付样板)。
构建与 CGO
VAD/声纹/本地判停是 ONNX 推理(CGO),音频编解码要 libopus。构建标签
silero 控制 CGO 部分;不带标签也能编过,但必须自己注入
voice.Prober(见 Config 表)。
| 依赖 | 版本 / 来源 | 说明 |
|---|---|---|
libonnxruntime | 1.18.1 · microsoft/onnxruntime release | VAD / 声纹 / Smart Turn 推理 |
libopus | 发行版包 | WebRTC 音频;nolibopusfile 标签省掉 opusfile |
go module | codeup.aliyun.com/mosi/MOSS-Live/sdk | 私仓:GOPRIVATE=codeup.aliyun.com |
# 本地开发常用(库装在 /opt/onnxruntime 时) CGO_CFLAGS="-I/opt/onnxruntime/include" \ CGO_LDFLAGS="-L/opt/onnxruntime/lib" \ go build -tags "silero nolibopusfile" ./... # 运行期 LD_LIBRARY_PATH=/opt/onnxruntime/lib ./yourservice
用配套基础镜像构建(参考 backend 仓 Dockerfile:onnxruntime 与模型文件都在构建期下载并钉死 sha256)。模型清单见「部署 → 模型文件清单」。
agent.Config 全字段
零值字段 = 用默认。默认档就是生产在跑的组合。
Dashscope(上游三件)
| 字段 | 默认 | 说明 |
|---|---|---|
APIKey必填 | — | DashScope API Key |
HTTP | 官方端点 | LLM 端点。是原生 multimodal-generation 协议不是 compatible-mode,给错会被自动纠正并告警 |
WS | 官方端点 | ASR/TTS 的 realtime WebSocket 端点 |
LLMModel | qwen3.7-flash | |
MaxTokens | 200 | 口语回复长度硬闸——语音场景防长篇播报 |
Temperature | 0 | 0 = 上游默认 |
ASRModel | qwen3-asr-flash-realtime | |
TTSModel | qwen3-tts-flash-realtime | cosyvoice-* 自动换协议 |
Voice | longxing_v3 | 音色隶属于模型家族,换音色先确认它属于哪个 TTS 模型(踩坑实录 #2) |
TTSInstructions / TTSRate / TTSPitch / TTSSSML | — | 合成风格微调,按需 |
核心装配
| 字段 | 默认 | 说明 |
|---|---|---|
VADModel | — | silero_vad.onnx 路径(构建需 -tags silero) |
Prober | — | 自定义 VAD 注入(16k 单声道、512 样本一窗、返回语音概率)。非空时优先于 VADModel;不带 silero 标签构建则必填 |
SpeakerModel | — | CAM++ 声纹 onnx(可选,配合 SpeakerGate) |
Persona | — | 部署级默认人设(会话可覆盖) |
TimeZone | Asia/Shanghai | 「现在几点」的时区 |
MaxHistory | 20 | 上下文轮数上限 |
Toolbox | — | 业务工具(mcptool.New 的产物接这里)。非空时自建搜索工具不再装配——你的工具箱全权负责这条会话的工具面 |
SkillsDir | — | 技能预设目录(*.yaml)。加载失败 New 直接报错——技能文件是交付物,坏了要在启动时炸出来 |
Precache | false | 寒暄直出缓存:命中时首音 ~160ms(全链 ~870ms) |
RTC | nil | WebRTC 边:UDPPort(媒体口,防火墙要放行)、PublicIP(NAT 后必填) |
CORSOrigins | — | 浏览器走 /v1/rtc 信令必配;预检 404 的表现是 Failed to fetch 且服务端零日志 |
ReadTimeout | 90s | 实时连接读超时 = 会话最长静默存活 |
Events | false | 把判定过程下发终端(partial/heard/say/turn/bargein…) |
OnEvent | — | 服务端收一份事件流(内容日志、自定义埋点),与 Events 互不影响 |
Observer | — | 按传输维度("ws"/"webrtc")提供指标接收器,接你自己的 Prometheus |
TracerProvider | nil | OTel 接线,见「可观测」 |
Token / AdminToken | — | 见「鉴权:两把钥匙」 |
TurnJudge / TurnJudgeModel / SpeakerGate | shadow / — / shadow | 实验开关,见「实验开关与不可配面」 |
Logger | slog.Default() |
联网搜索
Search.Builtin=true 走模型内置搜索(DashScope
enable_search,一趟往返、带来源)—— 这是唯一的搜索形态,生产在用。
内置搜索会把 function calling 完全压死(实测 0 片 tool_calls)。所以:配了 Toolbox(MCP 业务工具)时内置搜索自动让位。要在带业务工具的会话里提供搜索, 把搜索做成你 MCP server 里的一个工具(曾内置三家自建搜索供应商,因生产从未使用已全部移除)。
| 字段 | 说明 |
|---|---|
Builtin | 开关。开着且无工具箱时注入 enable_search |
Sites | 站点白名单;空 = 不限站点 |
Days | 时效(天);0 = 不限 |
鉴权:两把钥匙
| 钥匙 | 保护面 | 发给谁 | 怎么带 |
|---|---|---|---|
AdminToken | POSTDELETE/v1/sessions | 你的后端(服务间凭据,private key 角色) | Authorization: Bearer <token>,错/缺 = 401 |
Token | /v1/realtime、/v1/rtc | 终端(public key 角色) | 查询参数。这是部署级门闸;会话身份靠签发的 session token |
不配 AdminToken = 签发口无鉴权(启动会告警)。只允许内网部署这么跑;暴露公网必配, 否则任何人都能签任意人设的会话。两把钥匙别混发——AdminToken 永远不下发终端。
技能 Skills(YAML)
Skill = 命名的会话预设。「开发一个技能」= 写一个 YAML +(需要工具时)起一个
MCP server,不写 Go。文件放 SkillsDir,文件名(去扩展名)就是技能名,
签发时 {"skill":"aftersale"} 选用。
| 字段 | 类型 | 说明 |
|---|---|---|
persona | string | 人设(系统提示词) |
llm_model / asr_model / tts_model | string | 覆盖对应模型 |
voice | string | TTS 音色,和 tts_model 成对出现(见下) |
search | bool 可空 | 开/关搜索;不写 = 部署默认 |
max_history | int | 上下文轮数 |
timezone | string | 时区 |
tools | []string | 放行的工具名。只能在部署级 MCP 白名单内收窄,Specs 和 Invoke 两头都拦(模型幻觉出的工具名也执行不了) |
规则三条:请求永远赢(签发请求里显式给的字段覆盖预设);坏文件启动炸(不会静默用默认人设); 未知技能 400(错误体带 code)。
交付样板(examples/delivery/skills/aftersale.yaml,注释全是实测教训):
# 售后助手技能 —— 交付团队的「开发一个技能」就是写这个文件。 # ⚠️ 防编造那句不是废话:没有它实测 3 轮里 1 轮模型跳过工具、 # 现编一个「已发货,物流更新中」。业务工具不在 SDK 搜索豁免语的 # 覆盖面里,人设必须自己把这条立住。 persona: | 你是商城的售后语音助手。用户报出订单号时,必须先调用 query_order 工具拿到结果再开口——订单状态只能来自工具返回,一个字都不许自己编; 没调用工具就不知道状态,查不到就如实说查不到。 回答要短,像客服打电话,不要念符号。 # ⚠️ 音色和模型成对出现:longxing_v3 是 cosyvoice 系音色, # 不写 tts_model 会落到默认 qwen3-tts-flash-realtime,报「voice 不存在」。 tts_model: cosyvoice-v3-flash voice: longxing_v3 tools: [query_order] max_history: 8
流程编排(先确认身份再查订单)交给 LLM + 工具调用——业界成熟形态(Vapi assistant / OpenAI session 配置)本质都是一份命名配置。状态机框架是被验证过不需要的东西, 三个真实场景跑出共性之前不会有。
工具箱 MCP
把你的业务系统(查订单/退款/CRM)包成标准 MCP server(streamable HTTP),
SDK 端 mcptool.New 一行接入——协议层零自造,客户端用官方
modelcontextprotocol/go-sdk。
box, err := mcptool.New(ctx, mcptool.Config{
Endpoint: "https://tools.example.com/mcp",
Headers: map[string]string{"Authorization": "Bearer " + os.Getenv("MCP_TOKEN")},
Allow: []string{"query_order", "refund"}, // ⚠️ 空 = 全拒
}, logger)
if err != nil { /* 连不上 / 白名单全没匹配上都在这炸 */ }
defer box.Close()
a, _ := agent.New(agent.Config{ /* … */, Toolbox: box})| mcptool.Config | 默认 | 说明 |
|---|---|---|
Endpoint必填 | — | streamable HTTP 端点 |
Headers | — | 附到每个请求(鉴权放这,部署级凭据,永远不进会话规格) |
Allow必填 | — | 工具白名单。空 = 一个都不放;一个都没匹配上 = 启动失败(配置了工具却静默没有工具,比启动失败难查得多) |
Timeout | 5s | 单次调用硬预算。超时把「工具超时」当文本还给模型,让她接着说话 |
工具轮语义
- 一轮最多一次工具往返(调用 → 回填 → 再生成),不做链式编排——语音场景用户在线上等着听。
- 一切失败都变成文本还给模型(超时/调用失败/参数不合法):模型看到原因能改口,中断这一轮用户听到的是她突然哑了。
- 模型调用工具前说的前导(「我查一下哈」)会照常播出,盖住工具执行的等待。
业务侧最小实现(完整可跑见 examples/delivery/main.go,内嵌了一个真 MCP server):
srv := mcp.NewServer(&mcp.Implementation{Name: "shop-tools", Version: "v1"}, nil)
mcp.AddTool(srv, &mcp.Tool{Name: "query_order", Description: "按订单号查询订单状态"},
func(_ context.Context, _ *mcp.CallToolRequest, in queryIn) (*mcp.CallToolResult, queryOut, error) {
// 换成你真实业务系统的查询;协议层一字不用改
})会话签发 /v1/sessions
POST/v1/sessions需要 AdminToken
请求体全部可省,省 = 用部署默认:
| 字段 | 类型 | 说明 |
|---|---|---|
skill | string | 技能名(SkillsDir 里的文件名)。未知技能 → 400 |
persona | string | 覆盖人设(也覆盖技能里的) |
llm_model / asr_model / tts_model | string | 覆盖对应模型 |
voice | string | TTS 音色 |
search | bool 可空 | 不传 ≠ false,不传 = 用默认 |
max_history | int | 0 = 默认 |
timezone | string | |
tools | []string | 会话级工具白名单(只能收窄) |
响应:{"session_id","token","expires_at"}。token 在 TTL(15min)内可重复使用
——断线重连是常态,安全靠短 TTL 不靠一次性。
DELETE/v1/sessions/{id} 主动撤销。
错误体:{"etype","code","error"},code 表见「错误码」。
实时连接与音频
| 口 | 协议 | 上行 | 下行 |
|---|---|---|---|
WS/v1/realtime?token=… | WebSocket | 二进制帧:16kHz s16le 单声道 PCM | 二进制帧:24kHz s16le PCM + 文本帧 JSON 事件 |
POST/v1/rtc?token=… | WebRTC(HTTP 信令) | Opus track(浏览器原生 AEC 生效) | Opus track + DataChannel JSON 事件 |
上行 16k(识别模型钉死),下行 24k(合成质量)。终端要两个采样率各一个采集/播放链路; Opus 自描述,WebRTC 端自动适配。把 24k 音频按 16k 播的表现是「声音慢放变低沉」; 反向则尖细——见踩坑实录 #1。
- 事件通道只出不进:上行只有音频,文本帧一律忽略。
- 判停/打断的判定权全在服务端——这是设计而非缺口,别在终端做二次判停。
- 浏览器端建议走 WebRTC:AEC 原生生效(回声/自打断问题的第一解法)。
下行事件参考
公共字段:t(类型)、at(毫秒,流内时间——音频驱动的时钟,
和终端 performance.now() 不同源,别相减;量端到端延迟在终端自己打点)。
| t | 载荷 | 含义与终端动作 |
|---|---|---|
partial | text | 识别中间结果,会被覆盖——只做 UI 预览 |
heard | text | 用户这一句的定稿(判早拼接后的完整文本) |
say | text | agent 的一句。在开始推音频之前发出——看到它不等于用户听见了 |
sources | sources[] | 本轮联网搜索来源(展示引用) |
turn | outcome, endpoint, asr, llm_first, tts_first, first_byte, spoken | 一轮的账,全毫秒。0 = 没走到那一步,不是 0ms。outcome:completed / interrupted / empty / error |
bargein | verdict, stop, text | 一次挂起的裁定:real(真打断,stop=停播耗时)/ backchannel(附和,零卡顿续播)/ echo / speaker(旁人)/ false(超时误判续播) |
reopen | gap | 判停判早:收口后用户又开口,前半句已接住——无需终端动作 |
spec | text 或 verdict | 抢跑起跑 / 结果(hit/miss/resumed/empty) |
flush | — | 唯一的下行控制指令:把还没播的音频丢掉。真打断时发一次;不执行的后果是打断没停住(服务端停了,你队列里压着的照播) |
error | etype, code, text | 结构化错误(形态照 OpenAI Realtime)。发生后连接通常随即关闭——据 code 决定重连还是报配置 |
错误码
error 事件与 /v1/sessions 错误体共用一张表。
etype 两类:invalid_request(调用方能修)/
server_error(服务方的事,重连或等恢复)。
| code | etype | 场景 | 终端该做什么 |
|---|---|---|---|
session_setup | server_error | 会话装配失败(VAD/LLM 建不起来) | 报障,重连大概率无用 |
upstream_asr | server_error | 识别上游建连失败/中途断流 | 可重连(上游抖动) |
upstream_tts | server_error | 合成上游建连失败 | 可重连 |
generation | server_error | 某一轮生成失败(已播部分保留,会话还在) | 无需动作,下一轮照常 |
too_many_sessions | invalid_request | 签发数超限(429) | 退避重试 |
bad_request | invalid_request | 请求体解析不了(400) | 修请求 |
internal | server_error | 兜底,细节在服务端日志 | 报障 |
加新 code 是兼容变更——终端要容忍未知 code,按 etype 兜底;改已有 code 的语义是 BREAKING(commit message 会标)。
OTel 链路
agent.Config.TracerProvider 塞你的 TracerProvider(OTLP 送你的
collector),每轮对话一条 trace。回填式建树——对话热路径零开销;nil = 不埋。
turn 属性:mosslive.outcome / first_byte_ms / dropped_ms ├─ endpoint 停口 → 收口(判停窗) ├─ asr 收口 → 定稿 ├─ generation 定稿 → 首帧出站 │ ├─ llm_first 子段之外的缝 = 攒句与出站对表,图上直接可见 │ └─ tts_first └─ speak 首帧 → 播完
- 打断是独立
bargein短 span(verdict / stop_ms 进属性)。 - 判停 hold 的自证账进 turn span 属性:
mosslive.holds/hold_hits/hold_wait_ms——按mosslive.holds>0过滤就是被 hold 轮次清单。
// 装配侧(backend 参考实现): exp, _ := otlptracehttp.New(ctx, otlptracehttp.WithEndpointURL(endpoint + "/v1/traces")) tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp), sdktrace.WithResource(res)) cfg.TracerProvider = tp // 采集不通只降级告警,不拒绝启动
指标与事件回流
| 口 | 形态 | 用途 |
|---|---|---|
Observer | func(transport string) dialog.Observer | 结构化指标:TurnReport / BargeInReport / Spec / Reopen / Drop——接你自己的 Prometheus |
OnEvent | func(dialog.Event) | 服务端收一份事件流(内容日志、自定义埋点);与发给终端的 Events 互不影响,可同时开 |
TurnReport 关键字段(零值 = 没走到那一步):
| 字段 | 含义 |
|---|---|
Outcome | completed / interrupted / empty / error |
Endpoint / ASR / LLMFirst / TTSFirst / FirstByte | 首音链各段(FirstByte ≠ 各段之和,差值 = 攒句与对表的缝,本身是信息) |
Spoken / Dropped | 真播出的时长 / 被打断作废丢弃的时长 |
Holds / HoldHits / HoldWait | 判停 hold 自证账(enforce 档才非零):推迟收口次数 / 其中等来用户下文的次数 / 等待合计 |
服务端日志两行关键账(JSON,可直接进 Loki):语义判停(每轮判官裁定与命中窗情况)、
判停hold(每次 hold 的 hit/miss 与 wait_ms)。
部署要点
- 有状态服务:一条会话一个进程实例。多副本必须做会话亲和(token 签发和实时连接要落同一进程), 无脑水平扩容会让 token 查不到。
- 单进程能扛的量先单进程:编排是内存态,横向拆分只在真有并发压力时做。
- 延迟预算(生产实测,qwen3.7-flash 档):首音 P50 ≈ 870ms = 判停 310 + ASR 168 + LLM 289 + TTS 253;寒暄直出命中时 ~160ms。 P90 尾巴主要来自命中联网搜索的轮次。你在这条链外每加一跳,用户就多等一跳——别在中间再加代理。
- TTL 与重连:会话 15min TTL,token 可重复用;终端断线重连直接复用 token。
- 健康检查打
/healthz;版本核对建议自带/version(参考 backend:把 commit 钉进二进制)。
模型文件清单
都在构建期下载并钉死 sha256(参考 backend Dockerfile),运行期只读挂载即可:
| 文件 | 用途 | 来源 |
|---|---|---|
silero_vad.onnx | VAD——判停的唯一声学判据 | snakers4/silero-vad |
smart-turn-v3.2-cpu.onnx | 本地语义判停(8MB int8,CPU 百毫秒内) | pipecat-ai/smart-turn(BSD-2)· sha256 2bb02631… |
campplus.onnx | 声纹(可选) | CAM++ · sha256 dd1740aa… |
libonnxruntime | 推理运行时 | 1.18.1 · microsoft/onnxruntime release |
实验开关与不可配面
实验开关(部署级)
| 开关 | 档位 | 说明 |
|---|---|---|
TurnJudge | shadow默认 / enforce / off | 语义判停:shadow 只记账不生效;enforce 判「没说完」时延长收口(MaxHold 700ms 封顶,判错方向有判早自愈兜底);off 不装 |
TurnJudgeModel | onnx 路径 / 空 | 非空走本地 Smart Turn v3(吃音频不吃文本、不花 LLM 调用费);空走 LLM 文本判官 |
SpeakerGate | shadow默认 / enforce | 声纹门:enforce 拒非锚点说话人——会把拿起玩具的朋友当旁人,慎用 |
不可配面(问了也不给)
判停窗、打断证据窗、防抖阈值、配速前瞻、抢跑窗、判官时机——
这些不在 /v1/sessions 里,Go API 也没有字段,相关实现整体收在
internal/,连 import 都做不到。每个值都来自真实录音实测(判停 300ms 出自
756 条录音的扫窗),改错的表现是「体验变差但不报错」。要调 → 提需求,上游改默认值 + 全量回归。
踩坑实录
都是真实链路上栽过的,按出现频率排:
#1 · 双采样率:上行 16k、下行 24k
合成/播放素材时把 24k 音频按 16k 灌上行,表现是零报错静默失败:会话建立、 VAD/ASR 全乱、一个 heard 都没有。反过来终端用 16k 播放链放 24k 下行,声音慢放变低沉。 自查方法:算时长——字节数 ÷ 2 ÷ 采样率必须等于真实秒数。
#2 · 音色和 TTS 模型成对出现
voice 隶属于模型家族(longxing_v3 是 cosyvoice 系)。技能/请求里只给
voice 不给 tts_model,落到默认 qwen3-tts 家族,上游报
「Invalid voice specified」。写技能时两个字段永远一起给。
#3 · 模型会「表演」调工具
人设没立防编造条款时,实测 3 轮里 1 轮模型跳过工具直接编结果(甚至把 「(调用 query_order 工具)」当台词念出来)。业务工具的人设必须写明: 结果只能来自工具返回,没调用就说不知道。样板见「技能」一节。
#4 · 内置搜索与 function calling 互斥
开着内置搜索时模型不吐 tool_calls(实测 0 片)。SDK 已做自动让位(有 Toolbox 就关内置), 但如果你同时需要搜索和业务工具:把搜索做成你 MCP server 里的一个工具。
#5 · flush 事件必须执行
终端播放队列是你自己的缓冲。收到 flush 不清队列的表现:服务端明明停播了,
用户耳朵里她还在说——打断体验全毁在最后一米。
版本与兼容承诺
- module:
codeup.aliyun.com/mosi/MOSS-Live/sdk,v0,Go 接口不承诺兼容。 锁定用 go.mod pin commit(pseudo-version),升级前读 git log。 - 最先冻结的两层:下行事件 JSON、
/v1/sessions请求/响应。破坏性改动会在 commit message 开头标BREAKING:。 - 错误码:加新 code 兼容(按 etype 兜底),改语义 BREAKING。
- 疑问 / 要参数 / 报问题:走 codeup 仓 issue,附
turn事件的账和复现会话的session_id。