1. 从一次 WebSocket 1006 断连说起:Key 到底该放在哪一层
把 Gemini Live API 接进全双工语音 Agent 的人,大概率都经历过这样一个夜晚:第一轮语音往返正常,第二轮开始浏览器控制台刷出WebSocket connection closed with code 1006,后端日志里只留下一行invalid API key或者permission denied。更糟的是,为了排查方便,有人把 Key 直接写进了前端.env并打进 bundle,问题从「连不上」升级成「必须立刻轮换密钥」。
这类故障的根因往往不在模型,而在 Key 管理边界没有被定义清楚。全双工语音 Agent 和普通的文本问答不一样:它同时存在浏览器麦克风采集、长连接信令、服务端推理调用三条链路,任何一条链路上多放了一个 Key,都会把「可观测」和「可撤销」这两件事同时弄坏。本文用一个可落地的方案把边界固定下来——先在 TaoToken 官网 申请 Key,请求侧统一使用https://taotoken.net/api作为 Base URL,然后按层拆分配置,最后产出三样东西:一张 Key 管理边界图、一段可运行的 Agent 调用片段、一份 Token 记录。
需要提前说明的是:本文讨论的 Gemini Live 系列是原生语音到语音的全双工模型,托管形态不提供开放权重,因此「本地部署一套再改 Key」这条路并不存在,所有讨论都围绕网关侧凭据展开。TaoToken 在这里承担的是统一入口的角色:一个 Key、一个 Base URL、一份账单,语音会话与文本会话走同一套凭据体系。
2. Key 管理边界图:三层各持有什么
先给结论:长连接凭据与推理凭据必须分离,浏览器侧永远不出现长期有效的 Key。下面这张表是本文的主产出之一,可以直接抄进你团队的设计文档。
| 层级 | 运行位置 | 持有凭据 | 有效期 | 可撤销粒度 | 典型错误 |
|---|---|---|---|---|---|
| 采集层 | 浏览器 / 移动端 | 会话级临时凭据(ephemeral token) | 分钟级,随会话结束失效 | 单会话 | 把长期 Key 打进 bundle |
| 信令层 | 你的后端 / 边缘节点 | TaoToken Key(YOUR_API_KEY) | 长期,按环境区分 | 按 Key、按项目 | 前后端共用同一把 Key |
| 推理层 | 后端调用 | 同一把 Key,经 Base URL 转发 | 长期 | 按 Key、按配额 | 多环境混用导致账单对不上 |
把这张表翻译成数据流:
[浏览器麦克风] | 音频帧 / 控制帧(无长期 Key) v [你的信令服务] —— 持有 YOUR_API_KEY ——> https://taotoken.net/api | | | 转发音频 + 会话参数 | 统一鉴权 / 配额 / 记账 v v [全双工语音 Agent 会话] <—— 语音回复帧 —— [模型推理] | v [Token / 会话记录 JSONL] ——> 对账、限流、成本归因这张图的价值在于回答一个具体问题:谁有权调用、谁只负责转发。信令服务是唯一持有YOUR_API_KEY的进程,浏览器只拿一次性凭据;即便临时凭据被截获,攻击窗口也只有一次会话的长度,不会波及你的账号配额。
顺带提一句工具调用的边界:语音 Agent 常见的「帮我查一下订单」这类能力,很容易被做成 Agent 直连数据库。正确做法是让 Agent 只产出结构化意图,真正的 SQL 由人在本地或受控跳板机上执行,凭据不进模型上下文。
3. 在 TaoToken 侧准备好 Key 与 Base URL
这一步是所有配置的前提。打开 TaoToken 官网,登录后进入控制台创建一把项目级 Key,然后把它写进服务端环境变量,不要提交到版本库。
# .env.server(仅服务端可见,加入 .gitignore) TAOTOKEN_API_KEY=YOUR_API_KEY TAOTOKEN_BASE_URL=https://taotoken.net/api三个容易踩的细节:
- Base URL 不要带
/v1。https://taotoken.net/api是根地址,OpenAI 兼容 SDK 会自己补/v1/chat/completions之类的路径;如果你手写https://taotoken.net/api/v1再交给 SDK,就会出现/v1/v1/...的 404。 - Key 按环境分开。本地、预发、线上各一把,出问题时可以只吊销一把而不影响全部。
- 不要在客户端代码里做「Key 兜底」。类似
process.env.KEY || 'sk-xxx'的写法一旦进入前端构建产物,等于把凭据公开。
创建 Key 的入口在 API Keys 控制台,建议命名带上环境和用途,例如voice-agent-prod-signaling,这样后面看 Token 记录时能直接归因到具体服务。
4. Agent 调用片段:服务端如何用同一把 Key 跑通双工会话
全双工语音的关键在于「边听边说」,所以调用片段要分两部分看:推理调用(文本 / 多模态)走标准 HTTP,语音信令走 WebSocket。下面这段 Python 是服务端的推理侧示例,负责把会话上下文、工具调用结果整理后送去推理。
import os from openai import OpenAI # 唯一读取 Key 的地方,集中在这一行 client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=f"{os.environ['TAOTOKEN_BASE_URL']}/v1", ) def reply_for_turn(session_id: str, transcript: str) -> str: """一次语音轮次的推理侧调用,返回值交给 TTS / 语音帧编码。""" resp = client.chat.completions.create( model=os.environ.get("VOICE_AGENT_MODEL", "your-voice-model-id"), messages=[ {"role": "system", "content": "你是全双工语音助手,回答控制在两句话内,不要输出 Markdown。"}, {"role": "user", "content": transcript}, ], temperature=0.6, extra_headers={"X-Session-Id": session_id}, # 便于按会话归因 ) usage = resp.usage record_turn(session_id, usage.prompt_tokens, usage.completion_tokens) return resp.choices[0].message.contentrecord_turn的实现放在第 6 节。这里先强调调用形状:所有出网请求都从同一个client走,这样 Key 只有一个来源,替换供应商时只需要改TAOTOKEN_BASE_URL。
信令侧是一个 WebSocket 代理骨架。它做的事情很简单——浏览器连你的服务,你的服务带上 Key 连上游,双向转发帧:
import { WebSocketServer, WebSocket } from "ws"; const BASE = process.env.TAOTOKEN_BASE_URL!; // https://taotoken.net/api const KEY = process.env.TAOTOKEN_API_KEY!; // YOUR_API_KEY // 上游 WS 端点由 Base URL 派生,具体路径以网关侧文档为准 const UPSTREAM = BASE.replace(/^http/, "ws") + process.env.VOICE_WS_PATH!; const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (client) => { const upstream = new WebSocket(UPSTREAM, { headers: { Authorization: `Bearer ${KEY}` }, // Key 只在这里出现 }); const pending: Buffer[] = []; upstream.on("open", () => { while (pending.length) upstream.send(pending.shift()!); }); client.on("message", (data: Buffer) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); // 首帧到达前的缓冲 }); upstream.on("message", (data: Buffer) => { if (client.readyState === WebSocket.OPEN) client.send(data); }); const close = (code = 1000) => { client.readyState === WebSocket.OPEN && client.close(code); upstream.readyState === WebSocket.OPEN && upstream.close(code); }; client.on("close", () => close()); upstream.on("close", () => close()); });这段代码解决的就是文章开头那个 1006:浏览器永远不直接连上游,凭据泄漏面收敛到服务端进程。同时因为 Buffer 队列的存在,网络抖动导致的首帧丢失也不会直接触发断连。
如果你用 Claude Code 或 Codex 作为开发期的辅助工具去调试这段代理,注意它们的凭据配置格式完全不同,下一节展开。
5. Claude Code、Codex 与 CC Switch:三套配置的正确写法
同一个 Key 体系要覆盖命令行工具,最容易出错的地方是「把 Anthropic 的环境变量套到 Codex 上」。两者字段不通用,写错了只会得到 401。
5.1 Claude Code:settings.json 里配 ANTHROPIC_*
Claude Code 读取settings.json的env段,Key 字段是ANTHROPIC_AUTH_TOKEN,地址字段是ANTHROPIC_BASE_URL,注意这里填的是根地址,不带/v1:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "your-claude-model-id", "ANTHROPIC_SMALL_FAST_MODEL": "your-fast-model-id" } }ANTHROPIC_SMALL_FAST_MODEL建议显式指定,否则子任务可能回落到默认模型,账单上会出现你没预期的条目。配置完成后跑一次只读命令验证连通性,不要一上来就让它改代码。
5.2 Codex:config.toml 里用 provider + env_key
Codex 走的是~/.codex/config.toml,结构是「定义 provider + 指定读取哪个环境变量」,没有ANTHROPIC_*这一套:
model = "your-codex-model-id" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api/v1" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"配套的环境变量:
export TAOTOKEN_API_KEY=YOUR_API_KEY注意base_url在这里带上了/v1,因为 Codex 不会替你补路径。这和 5.1 的 Claude Code 正好相反,是两套配置最容易混淆的点。
5.3 CC Switch 三件套:Profile、环境隔离、Key 轮换
当你同时维护 Claude Code、Codex、以及自研 Agent 时,手工改配置文件迟早出错。把「三件套」固定下来:
- Profile 分组:按「供应商 + 环境」建 Profile,例如
taotoken-prod、taotoken-dev,切 Profile 等价于同时切 Base URL 和 Key。 - 环境隔离:CLI 工具只从环境变量取 Key,配置文件里不落明文;
.env文件按环境命名并全部进.gitignore。 - Key 轮换:Profile 支持同时登记新旧两把 Key,轮换时先切流量、观察 Token 记录无异常后再吊销旧 Key。
三件套落地后,切换供应商变成一次 Profile 切换,而不是在多个文件里搜替换。这也是把 Key 边界收敛到「配置层」而不是「代码层」的直接收益。
6. Token 记录:让每一轮语音都能对上账
全双工语音的成本结构和文本不同:一次会话可能包含几十个轮次,每轮都有输入音频、上下文重放和输出音频,如果不记录,月底只看到总额,无法定位是谁在消耗。
落盘格式建议用 JSONL,一行一轮,字段尽量扁平:
import json, time, uuid from pathlib import Path LOG_PATH = Path("./logs/voice_turns.jsonl") LOG_PATH.parent.mkdir(exist_ok=True) def record_turn(session_id: str, prompt_tokens: int, completion_tokens: int, audio_ms: int = 0, model: str = "unknown") -> None: row = { "ts": time.time(), "session_id": session_id, "turn_id": str(uuid.uuid4()), "model": model, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, "audio_ms": audio_ms, "key_alias": "voice-agent-prod-signaling", # 与 TaoToken 侧 Key 命名对齐 } with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n")汇总的时候按会话和 Key 别名两个维度切开:
import json from collections import defaultdict agg = defaultdict(lambda: {"turns": 0, "tokens": 0, "audio_ms": 0}) for line in open("./logs/voice_turns.jsonl", encoding="utf-8"): r = json.loads(line) k = (r["key_alias"], r["session_id"]) agg[k]["turns"] += 1 agg[k]["tokens"] += r["total_tokens"] agg[k]["audio_ms"] += r["audio_ms"] for (alias, sid), v in sorted(agg.items(), key=lambda x: -x[1]["tokens"])[:20]: print(f"{alias}\t{sid}\tturns={v['turns']}\ttokens={v['tokens']}\taudio={v['audio_ms']}ms")这套记录能直接回答三个运维问题:某个会话是不是异常长、某把 Key 的消耗是不是突然抬升、某个模型是不是被意外调用。字段里的key_alias必须和 TaoToken 控制台里的 Key 命名保持一致,否则对账时会出现「日志里有一把,控制台里找不到」的情况。
7. 常见报错与定位顺序
把排障顺序固定下来,比记住十个错误码更有用。
第一类:401 / 403。先确认 Key 是从环境变量读的还是硬编码兜底;再确认 Base URL 有没有多写或漏写/v1;最后确认 Key 是否属于当前环境。顺序不要颠倒,因为前两步占了绝大多数。
第二类:WebSocket 1006 / 1005。1006 是异常关闭,通常不是鉴权问题,而是上游主动断开或代理层没做首帧缓冲。检查代理代码里是否有pending队列,检查是否有反向代理的proxy_read_timeout小于会话空闲时间。
第三类:429。并发或速率触顶。先把信令服务和批处理任务的 Key 分开,避免互相挤占;再按第 6 节的记录看是不是某个会话在疯狂重试。
第四类:只有第一轮有声音。典型原因是上下文重放时把音频帧当文本塞进了 messages,导致后续轮次请求体异常。检查每轮是否只重放转写文本而非原始音频。
第五类:账单对不上。回到第 5 节,确认 Claude Code 的ANTHROPIC_SMALL_FAST_MODEL和 Codex 的 provider 配置都显式指定了模型,避免默认模型混入。
8. 落地清单与下一步
把本文的产出物收拢成一份可以打勾的清单:
- 在 TaoToken 官网 创建项目级 Key,命名带环境和用途。
- 服务端环境变量固定
TAOTOKEN_BASE_URL=https://taotoken.net/api,Base URL 不带/v1。 - 浏览器侧只拿会话级临时凭据,长期 Key 不出服务端进程。
- 信令代理加上首帧缓冲与双向关闭清理,避免 1006。
- Claude Code 用
settings.json+ANTHROPIC_*;Codex 用config.toml+env_key;两者不要混用。 - CC Switch 三件套:Profile 分组、环境隔离、Key 轮换。
- Token 记录落 JSONL,按
key_alias与session_id两个维度汇总。
下一步建议按顺序走:先到 模型对话 用一段真实音频验证链路,确认无误后再看 Coding Plan 评估长期用量,然后在 API Keys 里把生产 Key 和开发 Key 拆开,最后对照 Claude Code 文档 把命令行侧的配置补齐。Key 管理边界一旦固定下来,后面换模型、加工具、扩并发都只是改配置,不会再动到凭据本身。