1. 会议字幕接 3.5 Transcribe:先拿 TaoToken Key 并固定 Base URL
最近在给会议字幕服务接 3.5 Transcribe 时,最先遇到的不是音频采样率问题,而是 WebSocket 握手阶段频繁返回 401 和 1006:本地音频文件明明能转写,一放到多会议室并发链路里就断。排查后发现,根因不在模型参数,而在 Key 生命周期、Base URL 和连接复用策略没有统一。TaoToken 官网入口见 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=meeting_subtitle_intro,下面按会议字幕系统开发者的视角,把接入、Key 轮换、WebSocket 日志和延迟统计串成一套可复现流程。
近期实时语音模型更新很快,Gemini 3.8 Live 与 3.5 Transcribe 这类低延迟转写能力开始被更多会议系统纳入技术选型。但落到工程上,真正影响上线的是:Key 怎么发、Base URL 写在哪、WebSocket 断流后怎么重连、多个会议室并发时怎么避免单个 Key 被限流。本文不讨论模型榜单,只讨论字幕服务的可运行配置。
先把基础事实固定下来:3.5 Transcribe 支持实时语音转写,适合会议字幕、直播字幕、语音助手等场景。会议字幕服务通常由三部分组成:
- 前端采集麦克风或系统音频,做降采样、分片、静音检测;
- 后端维护 WebSocket 长连接,对接实时转写模型;
- 转写结果回流到字幕渲染层,同时写入日志和延迟统计。
在部署会议字幕服务之前,先去 TaoToken 官网获取 Key,并配置 Base URL 为https://taotoken.net/api。注意,Base URL 是工具配置里的统一入口,不要在后面拼 UTM 参数。建议把 Key 放在环境变量或密钥管理服务里,不要提交到 Git,也不要下发到浏览器端。正确做法是:前端只连你自己的字幕网关,字幕网关再拿着YOUR_API_KEY去连 TaoToken。
# 本地开发环境变量示例,不要把真实 Key 提交到仓库 export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="YOUR_API_KEY" export TAOTOKEN_TRANSCRIBE_WS_URL="wss://<从控制台或文档获取的实时转写地址>"如果你还没有 Key,可以走 TaoToken 官网完成注册与控制台创建:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=key_rotation_setup。创建后先不要急着压测,拿一条本地音频文件跑通 WebSocket,再上多会议室并发。这样后面出现 401、429、1006 时,才能判断是 Key 问题、并发问题还是音频格式问题。
2. WebSocket 音频分片与字幕日志:从 401/1006 报错反推配置
会议字幕的实时性要求高,常见做法是后端与转写服务保持 WebSocket 长连接,把音频按 20ms 到 100ms 分片发送。3.5 Transcribe 的实时转写能力可以通过流式方式返回 partial 和 final 结果。不同客户端库、采样率、声道数、编码格式都会影响识别稳定性。为了避免“能连上但没字幕”的玄学问题,建议第一步就打开结构化日志。
字幕 WebSocket 日志建议写成 JSON Lines,每行一个事件。不要只打印文本,否则后面统计延迟和定位 Key 失败时会很痛苦。一个可用的日志格式如下:
{"ts":1710000000.123,"session_id":"room-1024","event":"ws_connected","key_id":"k1","model":"3.5-transcribe"} {"ts":1710000000.456,"session_id":"room-1024","event":"partial","key_id":"k1","text":"大家好","latency_ms":333} {"ts":1710000001.012,"session_id":"room-1024","event":"final","key_id":"k1","text":"大家好,我们开始今天的评审。","latency_ms":889} {"ts":1710000002.110,"session_id":"room-1024","event":"ws_closed","key_id":"k1","code":1006,"reason":"connection closed"}其中key_id必须记录,但不要把完整 Key 写进日志。只记录 Key 的编号或前 8 位。这样当某个 Key 被限流或失效时,可以快速定位。latency_ms也不建议只记录最终耗时,应该拆成首包延迟和 final 延迟。
下面是一个可直接改造的 Python WebSocket 客户端示例。它从环境变量读取 Key 和 WebSocket 地址,发送 PCM 分片,并记录 partial 与 final 日志。注意:TAOTOKEN_TRANSCRIBE_WS_URL请以 TaoToken 控制台或文档中给出的实时转写地址为准,不要硬编码猜测端点。
import asyncio import base64 import json import os import time import uuid import websockets WS_URL = os.environ["TAOTOKEN_TRANSCRIBE_WS_URL"] API_KEY = os.environ["TAOTOKEN_API_KEY"] BASE_URL = os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api") SESSION_ID = str(uuid.uuid4()) KEY_ID = "k1" async def log(event, **fields): row = { "ts": time.time(), "session_id": SESSION_ID, "event": event, "key_id": KEY_ID, **fields, } print(json.dumps(row, ensure_ascii=False), flush=True) async def transcribe_file(path): headers = {"Authorization": f"Bearer {API_KEY}"} async with websockets.connect( WS_URL, additional_headers=headers, ping_interval=20, ping_timeout=20, max_size=2**23, ) as ws: await log("ws_connected", model="3.5-transcribe", base_url=BASE_URL) await ws.send(json.dumps({ "type": "config", "model": "3.5-transcribe", "language": "auto", "sample_rate": 16000, "encoding": "pcm_s16le", "interim": True, })) sent_at = time.time() with open(path, "rb") as f: while True: chunk = f.read(640) # 16k * 2 bytes * 20ms = 640 bytes if not chunk: break await ws.send(chunk) await asyncio.sleep(0.02) try: msg = await asyncio.wait_for(ws.recv(), timeout=0.01) data = json.loads(msg) now = time.time() latency_ms = int((now - sent_at) * 1000) if data.get("type") == "partial": await log("partial", text=data.get("text"), latency_ms=latency_ms) elif data.get("type") == "final": await log("final", text=data.get("text"), latency_ms=latency_ms) except asyncio.TimeoutError: continue await ws.send(json.dumps({"type": "end"})) await log("audio_sent", path=path) if __name__ == "__main__": asyncio.run(transcribe_file("meeting_16k_mono.pcm"))这段代码里最容易出问题的是三处:
Authorization头是否正确带上了Bearer;sample_rate和实际音频是否一致,16k 音频不要标成 48k;encoding是否与发送字节一致,PCM 与 Opus 不能混用。
401 通常表示 Key 无效、复制时带了空格、或者请求头格式不对。1006 通常表示连接被异常关闭,可能是网络抖动、代理超时、Key 被禁用,也可能是客户端没有及时发心跳。先把日志打全,再谈优化。
3. TaoToken Key 轮换脚本:多 Key 池、健康检查与连接热切换
会议字幕系统一旦上生产,就不是一个会议室在跑。早高峰可能同时有几十个会议室创建字幕通道。如果所有连接都用一个YOUR_API_KEY,遇到限流时整条字幕链路都会抖动。因此,Key 轮换不是“高级玩法”,而是会议字幕服务的基础设施。
推荐做法是维护一个本地 Key 池,按会议会话或按连接轮换。注意,不要在代码里写死 Key,也不要把 Key 池放在公开仓库。可以用 JSON 文件、Kubernetes Secret、Vault 或配置中心下发。下面先给一个本地可执行的 JSON 结构:
{ "keys": [ {"id": "k1", "key": "YOUR_API_KEY", "enabled": true}, {"id": "k2", "key": "YOUR_API_KEY_2", "enabled": true}, {"id": "k3", "key": "YOUR_API_KEY_3", "enabled": false} ] }然后写一个 KeyPool 类,负责轮询、健康检查和热更新。健康检查可以请求统一的模型列表接口,具体路径以 TaoToken 控制台或文档为准。这里用BASE_URL + /v1/models作为示例,实际接入时请按文档替换。
# key_pool.py import itertools import json import signal import threading import time import httpx class KeyPool: def __init__(self, path: str, base_url: str): self.path = path self.base_url = base_url.rstrip("/") self._lock = threading.Lock() self._keys = [] self._cycle = None self.reload() def reload(self): with open(self.path, "r", encoding="utf-8") as f: data = json.load(f) keys = [item for item in data["keys"] if item.get("enabled", True)] if not keys: raise RuntimeError("KeyPool 中没有可用 Key") with self._lock: self._keys = keys self._cycle = itertools.cycle(keys) def next(self): with self._lock: return next(self._cycle) def mark_bad(self, key_id: str): with self._lock: for item in self._keys: if item["id"] == key_id: item["enabled"] = False self._cycle = itertools.cycle(self._keys) def health_check(self, key: dict, timeout: float = 3.0) -> bool: url = f"{self.base_url}/v1/models" headers = {"Authorization": f"Bearer {key['key']}"} try: resp = httpx.get(url, headers=headers, timeout=timeout) return resp.status_code == 200 except Exception: return False def install_sighup(pool: KeyPool): def _handler(signum, frame): pool.reload() print("[key_pool] reloaded", flush=True) signal.signal(signal.SIGHUP, _handler) if __name__ == "__main__": base_url = "https://taotoken.net/api" pool = KeyPool("keys.json", base_url) install_sighup(pool) while True: key = pool.next() ok = pool.health_check(key) print(f"[health] key_id={key['id']} ok={ok}", flush=True) if not ok: pool.mark_bad(key["id"]) time.sleep(30)这个脚本的重点不是“健康检查本身”,而是轮换策略:
- 新会议创建字幕连接时,调用
pool.next()取一个 Key; - 连接建立后,把
key_id写入 WebSocket 日志; - 如果出现 401 或 429,立即标记该 Key 短期不可用,并换下一个 Key 重连;
- 不要主动断开已有正常连接,避免字幕闪断;
- 通过
SIGHUP或配置中心监听实现 Key 热更新。
你可以把轮换粒度做成“每个会议室会话一个 Key”,也可以做成“每 N 个连接轮换一次”。如果某个 Key 被限流,健康检查会把它的错误率暴露出来。注意,健康检查只做轻量请求,不要用真实音频流去做探活。
如果你需要更系统地管理 Key,可以直接在 TaoToken 控制台创建和禁用 Key:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=key_rotation_setup。轮换脚本只负责本地调度,最终权限仍以控制台状态为准。
4. 延迟统计:partial/final 时间戳到 P95 看板
会议字幕能不能用,最终看延迟。3.5 Transcribe 的实时转写如果 partial 延迟过高,参会人会感觉字幕“追不上说话”。所以从第一天就要记录延迟,而不是等用户投诉。
建议在 WebSocket 日志里至少记录四类时间戳:
audio_sent_ts:某段音频发送时间;first_partial_ts:第一次收到 partial 的时间;final_ts:收到 final 的时间;reconnect_ts:重连发生时间。
然后用脚本计算 P50、P95、最大值。下面是一个本地统计示例,读取前面生成的subtitle_ws.jsonl:
import json import statistics from collections import defaultdict def load_jsonl(path): rows = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: rows.append(json.loads(line)) except json.JSONDecodeError: continue return rows def percentile(values, p): if not values: return 0 values = sorted(values) idx = int(round((p / 100) * (len(values) - 1))) return values[max(0, min(idx, len(values) - 1))] rows = load_jsonl("subtitle_ws.jsonl") latency = defaultdict(list) errors = defaultdict(int) for row in rows: event = row.get("event") if event in ("partial", "final") and "latency_ms" in row: latency[event].append(row["latency_ms"]) if event in ("ws_closed", "error"): errors[row.get("code", "unknown")] += 1 for event, values in latency.items(): print( event, "count=", len(values), "p50=", percentile(values, 50), "p95=", percentile(values, 95), "max=", max(values), ) print("errors=", dict(errors))输出大概会像这样:
partial count= 4200 p50= 286 p95= 910 max= 2300 final count= 380 p50= 740 p95= 1880 max= 4100 errors= {1006: 3, 429: 12}看到429升高,就优先查 Key 轮换和并发上限;看到1006升高,就查网络、心跳和 Key 状态;看到partial的 P95 远高于 P50,就查音频分片是否忽大忽小、后端是否在转发时做了阻塞操作。TaoToken 侧也有控制台用量信息,可以和本地日志对照:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=latency_stats。不要只看平均值,会议字幕这种实时链路,P95 和重连次数比平均值更有决策价值。
5. 常见故障排查:429、采样率、乱码与断流重连
会议字幕服务的报错通常集中在几个地方。下面按排查顺序给出可执行建议。
429 限流。先确认是不是所有会议室共用一个 Key。如果是,改成 Key 池轮换。其次检查重连逻辑是否有风暴:连接失败后立刻无限重试,会把限流放大。正确做法是带随机抖动的指数退避,例如 1s、2s、4s、8s,并记录key_id。如果某个 Key 连续 429,先把它从池中禁用十分钟。
401 无效认证。检查Authorization: Bearer YOUR_API_KEY是否完整;检查 Key 是否被误删或禁用;检查部署环境变量是否被覆盖。不要在前端直接使用 Key,前端只连你的网关。
采样率不匹配。3.5 Transcribe 接入时,推荐统一成 16kHz、单声道、16bit PCM。如果你的采集端是 48kHz 双声道,先在后端做降采样和混音,再分片发送。不要一边发 48k 数据,一边在 config 里声明 16k。
乱码或识别为空。常见原因是字节序、编码格式、音频头。PCM 裸流不要带 WAV 头;如果带 WAV 头,前几十字节会被当成音频。Opus 需要按对应封装发送。先用本地ffmpeg生成标准测试音频,再对比识别结果。
1006 断流。优先看心跳间隔和代理超时。WebSocket 长连接经过 Nginx、网关、负载均衡时,空闲超时可能只有 60 秒。建议客户端每 20 秒发 ping,服务端回 pong。断流后不要直接丢弃当前字幕,应该保留最近一段上下文,重连后继续发送后续音频。重连时从 Key 池重新取 Key,避免复用已失效 Key。
上传速度与分片大小。20ms 分片延迟低,但包数量多;100ms 分片吞吐好,但首字延迟可能变高。会议字幕建议从 20ms 到 40ms 开始压测,观察 partial P95 和 CPU 占用。不要为了降低包量把分片拉到 500ms,参会人会明显感觉字幕跳跃。
日志脱敏。WebSocket 日志里记录key_id,不要记录完整 Key;记录文本时注意会议隐私,必要时只记录字符数和时间戳。生产环境建议把subtitle_ws.jsonl按天切割,并设置保留周期。
6. Claude Code、Codex、CC Switch 的辅助配置
会议字幕系统的开发过程中,通常会写一些本地脚本:音频转码、Key 池检查、日志统计、压测报告。这些工作可以用 Claude Code 或 Codex 辅助,但配置要分清工具,不要把 Claude Code 的ANTHROPIC_*环境变量套到 Codex 上。
Claude Code 配置。编辑~/.claude/settings.json,把 Base URL 指向 TaoToken,Key 使用YOUR_API_KEY:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }这里的ANTHROPIC_BASE_URL不加 UTM,不要写成带查询参数的地址。保存后重启 Claude Code,确认它读取的是这份 settings.json。
Codex 配置。Codex 不要用ANTHROPIC_*。它使用~/.codex/config.toml,核心是 provider、base_url 和 env_key:
model = "gpt-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"然后在 shell 里设置:
export TAOTOKEN_API_KEY="YOUR_API_KEY"Codex 读取的是TAOTOKEN_API_KEY,不是ANTHROPIC_API_KEY。两者混用会导致 401 或 provider 找不到。
CC Switch 三件套。如果你用 CC Switch 管理多个 AI 编码工具,添加 TaoToken 时重点填三件套:Provider 名称、Base URL、API Key。Provider 名称可以写taotoken,Base URL 填https://taotoken.net/api,API Key 填YOUR_API_KEY。切换供应商后,回到对应工具确认配置文件是否同步更新。CC Switch 只是切换器,不替代 Key 轮换脚本,生产字幕服务的 Key 仍然要走 Key 池。
这些配置用于开发辅助即可,不建议直接用于会议字幕生产链路。生产链路的 Key 应该由网关侧统一管理,并配合轮换、限流和审计。
7. 上线前检查清单与 CTA
上线会议实时字幕前,建议过一遍下面这份清单:
- Key 不硬编码,
YOUR_API_KEY只出现在环境变量或密钥服务里; - Base URL 统一为
https://taotoken.net/api,工具配置里不加 UTM; - WebSocket 日志包含
session_id、key_id、event、latency_ms; - Key 池支持轮换、健康检查、失败禁用和热更新;
- 429、401、1006 有独立计数和告警;
- partial P95、final P95、重连次数进入看板;
- 音频统一 16kHz 单声道 PCM,分片大小经过压测;
- 前端不暴露 Key,只连自有字幕网关;
- 生产 Key 与开发 Key 分离,控制台可随时禁用。
如果你还没开始接入,可以按这个顺序走:
- 先到模型对话页验证 3.5 Transcribe 的实时转写效果:https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=subtitle_cta_chat
- 需要更高并发或编码工具支持,查看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=subtitle_cta_plan
- 创建和管理用于轮换的 API Key:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=subtitle_cta_keys
- 如果你同时用 Claude Code 开发字幕脚本,参考 Claude Code 文档:https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=subtitle_cta_claudecode
会议字幕的核心不是“接上一个模型”就结束,而是把 Key、Base URL、WebSocket 日志、轮换脚本和延迟统计做成可观测、可恢复的链路。先把 401 和 1006 打掉,再把 P95 压到可接受范围,最后才是扩展语言和会议室规模。