codeup · mosi/MOSS-Live/sdk

交付团队接入手册

实时全双工语音 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 自然过期)

两层入口,按需选一层:

入口适用
agentagent.New(cfg).Serve(":8080")开箱即用:DashScope 全家 + WS/WebRTC + 会话签发,30 行起完整服务。绝大多数交付从这进。
dialogdialog.New(…)五个口(ASR/LLM/TTS/Toolbox/Prober)全自定义——换上游供应商才走这层。

五分钟跑起来

  1. 准备依赖

    Go ≥ 1.22、libonnxruntime 1.18.1、libopus、模型文件 silero_vad.onnx(清单见「部署」)。私仓拉取:GOPRIVATE=codeup.aliyun.com

  2. 写 main.go(这就是全部)
    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)
        }
    }
  3. 构建并运行
    bash
    go build -tags "silero nolibopusfile" .
    
    DASHSCOPE_API_KEY=sk-xxx \
    SILERO_VAD=/models/silero_vad.onnx \
    ADMIN_TOKEN=xxx ./yourservice
  4. 签会话、连实时口
    bash
    # 你的后端来调,带 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 表)。

依赖版本 / 来源说明
libonnxruntime1.18.1 · microsoft/onnxruntime releaseVAD / 声纹 / Smart Turn 推理
libopus发行版包WebRTC 音频;nolibopusfile 标签省掉 opusfile
go modulecodeup.aliyun.com/mosi/MOSS-Live/sdk私仓:GOPRIVATE=codeup.aliyun.com
bash
# 本地开发常用(库装在 /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 端点
LLMModelqwen3.7-flash
MaxTokens200口语回复长度硬闸——语音场景防长篇播报
Temperature00 = 上游默认
ASRModelqwen3-asr-flash-realtime
TTSModelqwen3-tts-flash-realtimecosyvoice-* 自动换协议
Voicelongxing_v3音色隶属于模型家族,换音色先确认它属于哪个 TTS 模型(踩坑实录 #2)
TTSInstructions / TTSRate / TTSPitch / TTSSSML合成风格微调,按需

核心装配

字段默认说明
VADModelsilero_vad.onnx 路径(构建需 -tags silero
Prober自定义 VAD 注入(16k 单声道、512 样本一窗、返回语音概率)。非空时优先于 VADModel;不带 silero 标签构建则必填
SpeakerModelCAM++ 声纹 onnx(可选,配合 SpeakerGate)
Persona部署级默认人设(会话可覆盖)
TimeZoneAsia/Shanghai「现在几点」的时区
MaxHistory20上下文轮数上限
Toolbox业务工具(mcptool.New 的产物接这里)。非空时自建搜索工具不再装配——你的工具箱全权负责这条会话的工具面
SkillsDir技能预设目录(*.yaml)。加载失败 New 直接报错——技能文件是交付物,坏了要在启动时炸出来
Precachefalse寒暄直出缓存:命中时首音 ~160ms(全链 ~870ms)
RTCnilWebRTC 边:UDPPort(媒体口,防火墙要放行)、PublicIP(NAT 后必填)
CORSOrigins浏览器走 /v1/rtc 信令必配;预检 404 的表现是 Failed to fetch 且服务端零日志
ReadTimeout90s实时连接读超时 = 会话最长静默存活
Eventsfalse把判定过程下发终端(partial/heard/say/turn/bargein…)
OnEvent服务端收一份事件流(内容日志、自定义埋点),与 Events 互不影响
Observer按传输维度("ws"/"webrtc")提供指标接收器,接你自己的 Prometheus
TracerProvidernilOTel 接线,见「可观测」
Token / AdminToken见「鉴权:两把钥匙」
TurnJudge / TurnJudgeModel / SpeakerGateshadow / — / shadow实验开关,见「实验开关与不可配面」
Loggerslog.Default()

Search.Builtin=true 走模型内置搜索(DashScope enable_search,一趟往返、带来源)—— 这是唯一的搜索形态,生产在用。

互斥规则

内置搜索会把 function calling 完全压死(实测 0 片 tool_calls)。所以:配了 Toolbox(MCP 业务工具)时内置搜索自动让位。要在带业务工具的会话里提供搜索, 把搜索做成你 MCP server 里的一个工具(曾内置三家自建搜索供应商,因生产从未使用已全部移除)。

字段说明
Builtin开关。开着且无工具箱时注入 enable_search
Sites站点白名单;空 = 不限站点
Days时效(天);0 = 不限

鉴权:两把钥匙

钥匙保护面发给谁怎么带
AdminTokenPOSTDELETE/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"} 选用。

字段类型说明
personastring人设(系统提示词)
llm_model / asr_model / tts_modelstring覆盖对应模型
voicestringTTS 音色,和 tts_model 成对出现(见下)
searchbool 可空开/关搜索;不写 = 部署默认
max_historyint上下文轮数
timezonestring时区
tools[]string放行的工具名。只能在部署级 MCP 白名单内收窄,Specs 和 Invoke 两头都拦(模型幻觉出的工具名也执行不了)

规则三条:请求永远赢(签发请求里显式给的字段覆盖预设);坏文件启动炸(不会静默用默认人设); 未知技能 400(错误体带 code)。

交付样板(examples/delivery/skills/aftersale.yaml,注释全是实测教训):

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
为什么没有 Skill DSL

流程编排(先确认身份再查订单)交给 LLM + 工具调用——业界成熟形态(Vapi assistant / OpenAI session 配置)本质都是一份命名配置。状态机框架是被验证过不需要的东西, 三个真实场景跑出共性之前不会有。

工具箱 MCP

把你的业务系统(查订单/退款/CRM)包成标准 MCP server(streamable HTTP), SDK 端 mcptool.New 一行接入——协议层零自造,客户端用官方 modelcontextprotocol/go-sdk

go
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必填工具白名单。空 = 一个都不放;一个都没匹配上 = 启动失败(配置了工具却静默没有工具,比启动失败难查得多)
Timeout5s单次调用硬预算。超时把「工具超时」当文本还给模型,让她接着说话

工具轮语义

业务侧最小实现(完整可跑见 examples/delivery/main.go,内嵌了一个真 MCP server):

go
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

请求体全部可省,省 = 用部署默认:

字段类型说明
skillstring技能名(SkillsDir 里的文件名)。未知技能 → 400
personastring覆盖人设(也覆盖技能里的)
llm_model / asr_model / tts_modelstring覆盖对应模型
voicestringTTS 音色
searchbool 可空不传 ≠ false,不传 = 用默认
max_historyint0 = 默认
timezonestring
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。

下行事件参考

公共字段:t(类型)、at(毫秒,流内时间——音频驱动的时钟, 和终端 performance.now() 不同源,别相减;量端到端延迟在终端自己打点)。

t载荷含义与终端动作
partialtext识别中间结果,会被覆盖——只做 UI 预览
heardtext用户这一句的定稿(判早拼接后的完整文本)
saytextagent 的一句。在开始推音频之前发出——看到它不等于用户听见了
sourcessources[]本轮联网搜索来源(展示引用)
turnoutcome, endpoint, asr, llm_first, tts_first, first_byte, spoken一轮的账,全毫秒。0 = 没走到那一步,不是 0ms。outcome:completed / interrupted / empty / error
bargeinverdict, stop, text一次挂起的裁定:real(真打断,stop=停播耗时)/ backchannel(附和,零卡顿续播)/ echo / speaker(旁人)/ false(超时误判续播)
reopengap判停判早:收口后用户又开口,前半句已接住——无需终端动作
spectextverdict抢跑起跑 / 结果(hit/miss/resumed/empty)
flush唯一的下行控制指令:把还没播的音频丢掉。真打断时发一次;不执行的后果是打断没停住(服务端停了,你队列里压着的照播)
erroretype, code, text结构化错误(形态照 OpenAI Realtime)。发生后连接通常随即关闭——据 code 决定重连还是报配置

错误码

error 事件与 /v1/sessions 错误体共用一张表。 etype 两类:invalid_request(调用方能修)/ server_error(服务方的事,重连或等恢复)。

codeetype场景终端该做什么
session_setupserver_error会话装配失败(VAD/LLM 建不起来)报障,重连大概率无用
upstream_asrserver_error识别上游建连失败/中途断流可重连(上游抖动)
upstream_ttsserver_error合成上游建连失败可重连
generationserver_error某一轮生成失败(已播部分保留,会话还在)无需动作,下一轮照常
too_many_sessionsinvalid_request签发数超限(429)退避重试
bad_requestinvalid_request请求体解析不了(400)修请求
internalserver_error兜底,细节在服务端日志报障
兼容承诺

加新 code 是兼容变更——终端要容忍未知 code,按 etype 兜底;改已有 code 的语义是 BREAKING(commit message 会标)。

OTel 链路

agent.Config.TracerProvider 塞你的 TracerProvider(OTLP 送你的 collector),每轮对话一条 trace。回填式建树——对话热路径零开销;nil = 不埋。

span 树
turn                        属性:mosslive.outcome / first_byte_ms / dropped_ms
├─ endpoint    停口 → 收口(判停窗)
├─ asr         收口 → 定稿
├─ generation  定稿 → 首帧出站
│   ├─ llm_first            子段之外的缝 = 攒句与出站对表,图上直接可见
│   └─ tts_first
└─ speak       首帧 → 播完
go
// 装配侧(backend 参考实现):
exp, _ := otlptracehttp.New(ctx, otlptracehttp.WithEndpointURL(endpoint + "/v1/traces"))
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp), sdktrace.WithResource(res))
cfg.TracerProvider = tp // 采集不通只降级告警,不拒绝启动

指标与事件回流

形态用途
Observerfunc(transport string) dialog.Observer结构化指标:TurnReport / BargeInReport / Spec / Reopen / Drop——接你自己的 Prometheus
OnEventfunc(dialog.Event)服务端收一份事件流(内容日志、自定义埋点);与发给终端的 Events 互不影响,可同时开

TurnReport 关键字段(零值 = 没走到那一步):

字段含义
Outcomecompleted / interrupted / empty / error
Endpoint / ASR / LLMFirst / TTSFirst / FirstByte首音链各段(FirstByte ≠ 各段之和,差值 = 攒句与对表的缝,本身是信息)
Spoken / Dropped真播出的时长 / 被打断作废丢弃的时长
Holds / HoldHits / HoldWait判停 hold 自证账(enforce 档才非零):推迟收口次数 / 其中等来用户下文的次数 / 等待合计

服务端日志两行关键账(JSON,可直接进 Loki):语义判停(每轮判官裁定与命中窗情况)、 判停hold(每次 hold 的 hit/miss 与 wait_ms)。

部署要点

模型文件清单

都在构建期下载并钉死 sha256(参考 backend Dockerfile),运行期只读挂载即可:

文件用途来源
silero_vad.onnxVAD——判停的唯一声学判据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

实验开关与不可配面

实验开关(部署级)

开关档位说明
TurnJudgeshadow默认 / enforce / off语义判停:shadow 只记账不生效;enforce 判「没说完」时延长收口(MaxHold 700ms 封顶,判错方向有判早自愈兜底);off 不装
TurnJudgeModelonnx 路径 / 空非空走本地 Smart Turn v3(吃音频不吃文本、不花 LLM 调用费);空走 LLM 文本判官
SpeakerGateshadow默认 / 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 不清队列的表现:服务端明明停播了, 用户耳朵里她还在说——打断体验全毁在最后一米。

版本与兼容承诺

本页由 SDK 仓 docs/site/index.html 生成 · 与代码同仓同版本 · 契约源文件 docs/contract.md · 更新方式见仓库 README