527 lines
15 KiB
Markdown
527 lines
15 KiB
Markdown
# WS v1 协议完整说明(中文)
|
||
|
||
本文档描述 `/ws` 端点的 WebSocket v1 协议,覆盖:
|
||
- 客户端输入(JSON 文本消息 + 二进制音频);
|
||
- 服务端输出(JSON 事件 + 二进制音频);
|
||
- 每个参数的类型、约束、含义与使用方式;
|
||
- 握手顺序、状态机、错误语义与实现细节。
|
||
|
||
实现对照来源:
|
||
- `models/ws_v1.py`
|
||
- `core/session.py`
|
||
- `core/duplex_pipeline.py`
|
||
- `app/main.py`
|
||
|
||
---
|
||
|
||
## 1. 传输与基础规则
|
||
|
||
- 连接地址:`ws://<host>/ws`
|
||
- 单连接双通道承载:
|
||
- 文本帧:JSON 控制消息(严格校验 schema)
|
||
- 二进制帧:原始 PCM 音频
|
||
- JSON 校验策略:
|
||
- 所有已定义客户端消息都 `extra="forbid"`,即不允许未声明字段;
|
||
- `hello.version` 固定必须是 `"v1"`;
|
||
- 缺失 `type` 或未知 `type` 会返回协议错误。
|
||
|
||
---
|
||
|
||
## 2. 状态机与消息顺序
|
||
|
||
### 2.1 服务端状态
|
||
|
||
- `WAIT_HELLO`:等待 `hello`
|
||
- `WAIT_START`:已通过握手,等待 `session.start`
|
||
- `ACTIVE`:会话运行中,可收发文本/音频
|
||
- `STOPPED`:会话结束
|
||
|
||
### 2.2 正确顺序
|
||
|
||
1. 客户端发送 `hello`
|
||
2. 服务端返回 `hello.ack`
|
||
3. 客户端发送 `session.start`
|
||
4. 服务端返回 `session.started`
|
||
5. 客户端可持续发送:
|
||
- 二进制音频
|
||
- `input.text`(可选)
|
||
- `response.cancel`(可选)
|
||
- `tool_call.results`(可选)
|
||
6. 客户端发送 `session.stop` 或直接断开连接
|
||
|
||
顺序错误会返回 `error`,`code = "protocol.order"`。
|
||
|
||
---
|
||
|
||
## 3. 客户端 -> 服务端消息(输入)
|
||
|
||
## 3.1 `hello`
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "hello",
|
||
"version": "v1"
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | 固定 `"hello"` | 消息类型 | 握手第一条消息 |
|
||
| `version` | string | 是 | 固定 `"v1"` | 协议版本 | 版本不匹配会 `protocol.version_unsupported` 并断开 |
|
||
|
||
## 3.2 `session.start`
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "session.start",
|
||
"audio": {
|
||
"encoding": "pcm_s16le",
|
||
"sample_rate_hz": 16000,
|
||
"channels": 1
|
||
},
|
||
"metadata": {
|
||
"appId": "assistant_123",
|
||
"channel": "web",
|
||
"configVersionId": "cfg_20260217_01",
|
||
"client": "web-debug",
|
||
"output": {
|
||
"mode": "audio"
|
||
},
|
||
"systemPrompt": "你是简洁助手",
|
||
"greeting": "你好,我能帮你什么?",
|
||
"dynamicVariables": {
|
||
"customer_name": "Alice",
|
||
"plan_tier": "Pro"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | 固定 `"session.start"` | 启动会话 | 握手后第二阶段消息 |
|
||
| `audio` | object \| null | 否 | 仅支持固定值 | 音频格式描述 | 仅用于声明;MVP 实际只接受固定 PCM |
|
||
| `audio.encoding` | string | 否 | 固定 `"pcm_s16le"` | 编码格式 | 非该值会在模型校验层报错 |
|
||
| `audio.sample_rate_hz` | number | 否 | 固定 `16000` | 采样率 | 16kHz |
|
||
| `audio.channels` | number | 否 | 固定 `1` | 声道数 | 单声道 |
|
||
| `metadata` | object \| null | 否 | 任意对象(会被白名单过滤) | 运行时配置 | 用于 app/channel/提示词/输出模式等覆盖 |
|
||
|
||
`metadata` 白名单策略(关键):
|
||
- 允许透传的标识字段(ID 类):
|
||
- `appId` / `app_id`
|
||
- `channel`
|
||
- `configVersionId` / `config_version_id`
|
||
- 允许透传的覆盖字段:
|
||
- `firstTurnMode`
|
||
- `greeting`
|
||
- `generatedOpenerEnabled`
|
||
- `systemPrompt`
|
||
- `output`
|
||
- `bargeIn`
|
||
- `knowledge`
|
||
- `knowledgeBaseId`
|
||
- `openerAudio`
|
||
- `history`
|
||
- `userId`
|
||
- `assistantId`
|
||
- `source`
|
||
- `dynamicVariables`
|
||
- 客户端传入 `metadata.services` 会被忽略(服务端会记录 warning),服务配置由后端/环境变量决定。
|
||
|
||
`metadata.dynamicVariables` 规则:
|
||
- 可选;必须是 `object<string,string>`。
|
||
- key 正则:`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`
|
||
- 最多 30 个变量,单个 value 最长 1000 字符。
|
||
- `systemPrompt` / `greeting` 中支持占位符语法:`{{variable_name}}`。
|
||
- 内置系统变量(始终可用):`{{system__time}}`、`{{system_utc}}`、`{{system_timezone}}`。
|
||
- `system__time`:会话开始时的本地时间(`YYYY-MM-DD HH:mm:ss`)
|
||
- `system_utc`:会话开始时的 UTC 时间(`YYYY-MM-DD HH:mm:ss`)
|
||
- `system_timezone`:会话开始时的本地时区
|
||
- 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing`。
|
||
- 若 `dynamicVariables` 结构/内容非法,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_invalid`。
|
||
|
||
`output.mode` 用法:
|
||
- `"audio"`(默认语音输出)
|
||
- `"text"`(纯文本输出)
|
||
- 纯文本模式下仍会收到 `assistant.response.delta/final`;
|
||
- 不会收到 TTS 音频帧与 `output.audio.start/end`。
|
||
|
||
## 3.3 `input.text`
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "input.text",
|
||
"text": "你能做什么?"
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | 固定 `"input.text"` | 文本输入 | 跳过 ASR,直接触发 LLM 回答 |
|
||
| `text` | string | 是 | 非空字符串为佳 | 用户文本 | 用于文本聊天或调试 |
|
||
|
||
## 3.4 `response.cancel`
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "response.cancel",
|
||
"graceful": false
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 默认值 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | - | 固定 `"response.cancel"` | 请求中断当前回答 |
|
||
| `graceful` | boolean | 否 | `false` | 取消方式 | `false` 立即打断;`true` 当前实现主要用于记录日志,不强制中断 |
|
||
|
||
## 3.5 `tool_call.results`
|
||
|
||
仅在工具执行端为客户端时使用(`assistant.tool_call.executor == "client"`)。
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "tool_call.results",
|
||
"results": [
|
||
{
|
||
"tool_call_id": "call_abc123",
|
||
"name": "weather",
|
||
"output": { "temp_c": 21, "condition": "sunny" },
|
||
"status": { "code": 200, "message": "ok" }
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | 固定 `"tool_call.results"` | 工具执行回传 | 客户端工具结果上送 |
|
||
| `results` | array | 否 | 默认为空数组 | 多个工具结果 | 可批量回传 |
|
||
| `results[].tool_call_id` | string | 是 | 任意字符串 | 工具调用ID | 必须与 `assistant.tool_call.tool_call_id` 对应 |
|
||
| `results[].name` | string | 是 | 任意字符串 | 工具名 | 建议与请求一致 |
|
||
| `results[].output` | any | 否 | 任意 JSON | 工具输出 | 供模型后续组织回答 |
|
||
| `results[].status` | object | 是 | 包含 `code`、`message` | 执行状态 | 用于判定成功/失败 |
|
||
| `results[].status.code` | number | 是 | HTTP 风格状态码 | 状态码 | `200-299` 判定成功 |
|
||
| `results[].status.message` | string | 是 | 任意字符串 | 状态描述 | 例如 `"ok"` / `"timeout"` |
|
||
|
||
处理规则:
|
||
- 未请求过的 `tool_call_id` 会被忽略(防止伪造/串话);
|
||
- 重复回传会被忽略;
|
||
- 超时未回传会由服务端合成超时结果(`504`)。
|
||
|
||
## 3.6 `session.stop`
|
||
|
||
示例:
|
||
|
||
```json
|
||
{
|
||
"type": "session.stop",
|
||
"reason": "client_disconnect"
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|
||
|---|---|---|---|---|---|
|
||
| `type` | string | 是 | 固定 `"session.stop"` | 结束会话 | 正常结束推荐发送 |
|
||
| `reason` | string \| null | 否 | 任意字符串 | 结束原因 | 服务端会回传到 `session.stopped.reason` |
|
||
|
||
---
|
||
|
||
## 4. 二进制音频输入(客户端 -> 服务端)
|
||
|
||
在 `session.started` 之后可持续发送二进制音频。
|
||
|
||
固定格式(MVP):
|
||
- 编码:`pcm_s16le`
|
||
- 采样率:`16000`
|
||
- 声道:`1`
|
||
- 帧长:20ms = `640 bytes`
|
||
|
||
分包规则:
|
||
- 单个 WebSocket 二进制消息可包含 1 帧或多帧;
|
||
- 长度必须是 `640` 的整数倍;
|
||
- 不是 `640` 倍数会触发 `audio.frame_size_mismatch`,该消息整包丢弃;
|
||
- 奇数字节长度会触发 `audio.invalid_pcm`。
|
||
|
||
---
|
||
|
||
## 5. 服务端 -> 客户端事件(输出)
|
||
|
||
所有 JSON 事件都包含统一包络字段。
|
||
|
||
## 5.1 统一包络(Envelope)
|
||
|
||
```json
|
||
{
|
||
"type": "event.name",
|
||
"timestamp": 1730000000000,
|
||
"sessionId": "sess_xxx",
|
||
"seq": 42,
|
||
"source": "asr",
|
||
"trackId": "audio_in",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
字段说明:
|
||
|
||
| 字段 | 类型 | 含义 | 使用说明 |
|
||
|---|---|---|---|
|
||
| `type` | string | 事件类型 | 见下方事件清单 |
|
||
| `timestamp` | number | 事件时间戳(毫秒) | 由 `ev()` 生成 |
|
||
| `sessionId` | string | 会话ID | 同一连接固定 |
|
||
| `seq` | number | 单会话递增序号 | 可用于重放、去重、排序 |
|
||
| `source` | string | 事件来源 | 常见:`asr`/`llm`/`tts`/`tool`/`system`/`client`/`server` |
|
||
| `trackId` | string | 事件轨道 | 常用:`audio_in`/`audio_out`/`control` |
|
||
| `data` | object | 结构化数据 | 顶层业务字段会镜像进 `data` 以兼容旧客户端 |
|
||
|
||
关联ID(在 `data` 内自动注入,存在时):
|
||
- `turn_id`:一次用户-助手对话轮次
|
||
- `utterance_id`:一次用户语音话语
|
||
- `response_id`:一次助手生成响应
|
||
- `tool_call_id`:一次工具调用
|
||
- `tts_id`:一次 TTS 播放段
|
||
|
||
## 5.2 事件类型与参数
|
||
|
||
### 5.2.1 会话与控制类
|
||
|
||
1. `hello.ack`
|
||
- 关键字段:`version`
|
||
- 含义:握手成功,应紧接着发送 `session.start`
|
||
|
||
2. `session.started`
|
||
- 关键字段:
|
||
- `trackId`
|
||
- `tracks.audio_in`
|
||
- `tracks.audio_out`
|
||
- `tracks.control`
|
||
- `audio`(回显客户端声明的音频元信息)
|
||
- 含义:会话进入 ACTIVE,可发音频/文本
|
||
|
||
3. `config.resolved`
|
||
- 关键字段:
|
||
- `config.channel`
|
||
- `config.output.mode`
|
||
- `config.tools.enabled`
|
||
- `config.tools.count`
|
||
- `config.tracks`
|
||
- 含义:服务端公开配置快照(SaaS 安全),便于前端展示与排错
|
||
- 发送策略:可选调试事件,默认关闭(`ws_emit_config_resolved=false`)
|
||
- 不应返回:
|
||
- `assistantId` / `appId` / `configVersionId`
|
||
- `services`(provider/model/baseUrl 等内部运行细节)
|
||
- 系统提示词原文及其它内部编排细节
|
||
|
||
4. `heartbeat`
|
||
- 关键字段:无业务字段(仅 envelope)
|
||
- 含义:保活心跳
|
||
- 默认间隔:`heartbeat_interval_sec`(默认 50s)
|
||
|
||
5. `session.stopped`
|
||
- 关键字段:`reason`
|
||
- 含义:会话结束确认
|
||
|
||
6. `error`
|
||
- 关键字段:
|
||
- `sender`
|
||
- `code`
|
||
- `message`
|
||
- `stage`
|
||
- `retryable`
|
||
- `trackId`
|
||
- `data.error`(结构化错误镜像)
|
||
- 含义:统一错误事件
|
||
|
||
### 5.2.2 识别与输入侧(ASR/VAD)
|
||
|
||
1. `input.speech_started`
|
||
- 字段:`probability`
|
||
- 含义:检测到语音开始
|
||
|
||
2. `input.speech_stopped`
|
||
- 字段:`probability`
|
||
- 含义:检测到语音结束
|
||
|
||
3. `transcript.delta`
|
||
- 字段:`text`
|
||
- 含义:ASR 增量识别文本(节流发送)
|
||
|
||
4. `transcript.final`
|
||
- 字段:`text`
|
||
- 含义:ASR 最终识别文本
|
||
|
||
### 5.2.3 输出侧(LLM/TTS/Tool)
|
||
|
||
1. `assistant.response.delta`
|
||
- 字段:`text`
|
||
- 含义:助手增量文本输出(节流发送)
|
||
|
||
2. `assistant.response.final`
|
||
- 字段:`text`
|
||
- 含义:助手完整文本输出
|
||
|
||
3. `assistant.tool_call`
|
||
- 字段:
|
||
- `tool_call_id`
|
||
- `tool_name`
|
||
- `arguments`(对象)
|
||
- `executor`(`client` 或 `server`)
|
||
- `timeout_ms`
|
||
- `tool_call`(完整工具调用对象)
|
||
- 含义:通知客户端发生工具调用(用于可视化或客户端执行)
|
||
|
||
4. `assistant.tool_result`
|
||
- 字段:
|
||
- `source`(`client` 或 `server`)
|
||
- `tool_call_id`
|
||
- `tool_name`
|
||
- `ok`(boolean)
|
||
- `error`(失败时 `{code,message,retryable}`)
|
||
- `result`(原始结果对象)
|
||
- 含义:工具调用结果回执
|
||
|
||
5. `output.audio.start`
|
||
- 含义:TTS 音频输出开始边界
|
||
|
||
6. `output.audio.end`
|
||
- 含义:TTS 音频输出结束边界
|
||
|
||
7. `response.interrupted`
|
||
- 含义:当前回答被打断(barge-in 或 cancel)
|
||
|
||
8. `metrics.ttfb`
|
||
- 字段:`latencyMs`
|
||
- 含义:首包音频时延(TTFB)
|
||
|
||
### 5.2.4 工作流扩展事件(可选)
|
||
|
||
若 `metadata.workflow` 生效,会额外出现:
|
||
- `workflow.started`
|
||
- `workflow.node.entered`
|
||
- `workflow.edge.taken`
|
||
- `workflow.tool.requested`
|
||
- `workflow.human_transfer`
|
||
- `workflow.ended`
|
||
|
||
这些事件用于外部可视化工作流状态,不影响基础语音会话协议。
|
||
|
||
---
|
||
|
||
## 6. 服务端二进制音频输出(服务端 -> 客户端)
|
||
|
||
- 音频为 PCM 二进制帧;
|
||
- 发送单位对齐到 `640 bytes`(不足会补零后发送);
|
||
- 前端通常结合 `output.audio.start/end` 做播放边界控制;
|
||
- 收到 `response.interrupted` 后应丢弃队列中未播放完的旧音频。
|
||
|
||
---
|
||
|
||
## 7. 错误模型与常见错误码
|
||
|
||
统一结构(`error` 事件):
|
||
|
||
```json
|
||
{
|
||
"type": "error",
|
||
"sender": "client",
|
||
"code": "protocol.invalid_message",
|
||
"message": "Invalid message: ...",
|
||
"stage": "protocol",
|
||
"retryable": false,
|
||
"trackId": "control",
|
||
"data": {
|
||
"error": {
|
||
"stage": "protocol",
|
||
"code": "protocol.invalid_message",
|
||
"message": "Invalid message: ...",
|
||
"retryable": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
字段语义:
|
||
- `sender`:错误来源角色(如 `client` / `server`)
|
||
- `code`:机器可读错误码
|
||
- `message`:人类可读描述
|
||
- `stage`:阶段(`protocol|audio|asr|llm|tts|tool`)
|
||
- `retryable`:是否建议重试
|
||
- `trackId`:错误归属轨道
|
||
|
||
常见错误码:
|
||
- `protocol.invalid_json`
|
||
- `protocol.invalid_message`
|
||
- `protocol.order`
|
||
- `protocol.version_unsupported`
|
||
- `protocol.unsupported`
|
||
- `audio.invalid_pcm`
|
||
- `audio.frame_size_mismatch`
|
||
- `audio.processing_failed`
|
||
- `server.internal`
|
||
|
||
---
|
||
|
||
## 8. 心跳与超时
|
||
|
||
服务端后台任务逻辑:
|
||
- 每隔约 5 秒检查一次连接;
|
||
- 超过 `inactivity_timeout_sec`(默认 60 秒)未收到任何客户端消息则关闭会话;
|
||
- 每隔 `heartbeat_interval_sec`(默认 50 秒)发送一次 `heartbeat`。
|
||
|
||
客户端建议:
|
||
- 持续上行音频或定期发送轻量文本消息,避免被判定闲置;
|
||
- 用 `heartbeat` + `seq` 检测连接活性和事件乱序。
|
||
|
||
---
|
||
|
||
## 9. 实战接入建议
|
||
|
||
1. 建连后立即发送 `hello`,收到 `hello.ack` 后再发 `session.start`。
|
||
2. 语音输入严格按 16k/16bit/mono,并保证每个 WS 二进制消息长度是 `640*n`。
|
||
3. UI 层把 `assistant.response.delta` 当作流式显示,把 `assistant.response.final` 当作收敛结果。
|
||
4. 播放器用 `output.audio.start/end` 管理一轮播报生命周期。
|
||
5. 工具调用场景下,若 `executor=client`,务必按 `tool_call_id` 回传 `tool_call.results`。
|
||
6. 出现 `error` 时优先按 `code` 分流处理,而不是仅看 `message`。
|
||
|
||
---
|
||
|
||
## 10. 最小完整时序示例
|
||
|
||
```text
|
||
Client -> hello
|
||
Server <- hello.ack
|
||
Client -> session.start
|
||
Server <- session.started
|
||
Server <- (optional) config.resolved
|
||
Client -> (binary pcm frames...)
|
||
Server <- input.speech_started / transcript.delta / transcript.final
|
||
Server <- assistant.response.delta / assistant.response.final
|
||
Server <- output.audio.start
|
||
Server <- (binary pcm frames...)
|
||
Server <- output.audio.end
|
||
Client -> session.stop
|
||
Server <- session.stopped
|
||
```
|