Skip to main content
Venice 能听见你说话,也能回应你。它没有可以连接的实时语音到语音 socket,这听起来像个局限,直到你意识到一个语音智能体其实就是循环里的三次普通 HTTP 调用:把用户说的话转成文字,生成回复,把回复念出来。 在本指南中,我们会用 Python 把这个循环做成一个终端应用。按下 Enter,开口说话,再按一次 Enter,回答就会从你的扬声器播放出来。如果你不想用麦克风,也可以直接输入一行文字。 这和 LiveKit Agents 指南里的 STT → LLM → TTS 结构完全相同,只是去掉了 LiveKit、唤醒词和工具。把框架剥掉正是本文的重点:读到最后,你会确切知道是哪三个请求在干活,以及为什么其中两个要用流式。 在继续之前:你需要一个 Venice API key。把它导出为环境变量:
想看完整的代码实现?请查看 GitHub 仓库。

前置条件

  • Python 3.11 或更新版本,以及 uv
  • 一个来自 venice.ai 的 Venice API key
  • 一个麦克风和扬声器,如果你想体验完整的语音循环
录音和播放通过 sounddevice 完成,它封装了 PortAudio。uv sync 会安装这个 Python 包,在 Windows 上这就够了。macOS 和 Linux 还需要 PortAudio 库本身:
这些都与 Venice 无关——只是音频样本进出你机器的方式。应用带一个 --text-only 参数,可以完全跳过麦克风、但仍然会用到聊天和 TTS,所以即便在一台完全没有音频硬件的机器上你也能跟着做。

我们要构建什么

一轮对话就是三次请求: 这些模型 ID 是一个起点而不是固定清单。Venice 会轮换模型目录,所以在发布任何东西之前,应该在运行时通过 GET /models?type=...GET /models/traits 来解析它们。具体机制见弃用说明 我们有意把源码树保持得很小:
这个拆分比看上去更重要。venice.py 是你可以直接搬进 Web 应用、Discord 机器人或电话集成的部分。audio.py 是唯一关心它运行在什么机器上的文件,而且 Venice 完全看不到它——API 收到的只是进来的一个 WAV blob,交回的只是出去的原始 PCM。

环境搭建

创建项目并添加依赖。OpenAI SDK 负责所有 HTTP 工作,python-dotenv 让 key 不进入你的 shell 历史记录,sounddevice 与麦克风和扬声器打交道:
然后创建 .env.example,让模型选择成为配置项,而不是埋在代码里的东西:
把它复制成 .env,然后粘贴你的 key。

让 SDK 指向 Venice

Venice 的 API 与 OpenAI 兼容,所以我们直接用官方 openai client,只需改一下 base URL。整个集成就是这么多。创建 venice.py,从 client 开始:
注意我们自己检查 key 是否存在,而不是让 os.environ["VENICE_API_KEY"] 直接抛异常。对于”漏配了一个 key”这样平常的事情,一个 KeyError traceback 是很糟糕的第一印象。 趁现在再做一件收尾工作。SDK 抛出的是 OpenAIError 的子类,而有用的细节埋在响应 body 里,所以值得一次性把它拆开:
下面每一次调用的失败都会汇入这里,于是一个错误的 voice ID 或过期的 key 会以一行可读的信息出现,而不是一段堆栈跟踪。

听见用户

POST /audio/transcriptions 接收一个音频文件并返回文字。我们在本地录制的是 16 kHz 单声道 WAV,但这个 endpoint 接受各种常见格式,所以我们根据文件扩展名映射 MIME 类型,而不是硬编码一种:
Venice 的转写是请求/响应式的,而不是流式 socket,这就是为什么录音有一个明确的结束点——我们按 Enter,而不是跑语音活动检测。如果你想要基于 VAD 的断句,那正是 LiveKit 指南交给 Silero 的工作。 空转写结果是一种正常情况,不是错误。总会有人不小心连按两次 Enter,一句友好的”我没听清”永远好过一个异常。

流式生成回复

接下来是聊天调用。这里有两个 Venice 特有的设置,对智能体听起来的效果影响很大:
include_venice_system_prompt: False 阻止 Venice 在我们的 system prompt 前面再拼上它自己的。如果不关掉,每次调用大约多出一千七百个输入 token,并且相当于有第二个声音在告诉模型该怎么表现。disable_thinking: True(配合 reasoning.enabled: False,供读取新字段的模型使用)阻止 GLM 在开口之前把 token 预算花在一段隐藏的思维链上——当你正等着听到回复时,那段时间是你能真切感受到的。 这个 prompt 本身的长度也物有所值。要求二十个词以内让回答听起来像口语而不是书面语,而”宁可省略细节也不要在句中戛然而止”正是防止硬性 max_tokens 上限把话截断在词中间的关键。禁用 markdown 比你想象的更重要:TTS 模型会毫不客气地把星号念出来。
把用户消息当作不可信输入的那条指令在这里承担着实实在在的工作。转写出的语音和其他用户输入没有任何区别,而”忽略你之前的指令”这句话,说出口和敲出来一样容易。
有了这些之后,调用就是一次普通的流式补全:
这里重要的设计决策是它产出的是句子,而不是 token。TTS 需要一个完整的子句才能把韵律处理好,所以我们缓冲 delta 直到攒出一句,再把它交出去。这正是让音频在模型还在说话时就能开始播放的关键。 cancel 事件让调用方在用户按下 Ctrl+C 时停止消费流,而在 finally 块里关闭流则会释放连接,而不是把它晾在那里直到超时。

边到达边切分句子

.!? 切分能解决 90% 的情况,然后在模型第一次说出”Dr. Smith”时让你出丑。所以在把句号当作边界之前,我们先检查它前面的东西是不是一个缩写:
注意这个正则要求标点后面有空白字符。这是有意为之:在流中途,"Hello." 可能是一个说完的句子,也可能是 "Hello.txt" 的前半截,此刻我们无法分辨。等到空格出现意味着我们永远不会提前切断一个句子,代价是最后一句要等到流结束才能放出——这就由 iter_sentences 里最后那个 leftover 冲刷来处理。 这是一个朴素的切分器,但它够用了。它也是这里唯一一段单元测试成本很低的逻辑,所以值得测一下:

说出回复

POST /audio/speech 是第三个也是最后一个调用。有两个选项让它感觉很快:
response_format="pcm" 给我们的是 24 kHz 单声道、有符号 16 位小端的原始采样,可以不经任何解码步骤直接送进扬声器。否则 tts-kokoro 默认输出 MP3,而解码 MP3 意味着要等文件到达足够多之后才能播放任何内容。streaming: True 是 Venice 的开关,让音频在合成过程中就开始发送,而不是等整段音频做完。 resolve_voice 刻意写得很无趣——它只是修剪字符串并回退到环境变量默认值,不会去校验一份列表:
一个未知的 voice ID 会在 API 那边带着清晰的信息失败,这好过一份会随着 Venice 增加语音而悄悄过时的本地白名单。不过语音是和模型绑定的,把 Kokoro 的语音用在别的 TTS 模型上是不行的——配对关系见文字转语音模型

播放前先检查

这里有一个会让你从椅子上跳起来的坑。原始 PCM 没有文件头也没有 magic bytes,所以一旦一个错误响应被写进音频管道,扬声器会忠实地把那段 JSON 当成噪声以最大音量播放出来。 所以在把 body 当作音频之前先检查状态码和 content type,并把第一个 chunk 嗅探一遍作为兜底:
RIFF 抓住的是 WAV 响应,ID3 抓住的是 MP3,两者都说明 response_format 没有生效。JSON 检查抓住的是错误 body。这些都不算聪明,但它们全部加起来,就是”一条可读的错误”和”一个被吓到的用户”之间的区别。
永远不要把未经检查的 HTTP body 直接灌进原始音频输出。播放端没有任何格式协商来救你——到达的任何字节都会被当作采样播放出来。

录音与播放

这部分和 Venice 无关,所以我们快速带过。audio.py 在用户说话时打开一个 PortAudio 输入流,播放回复时打开一个 PortAudio 输出流,两者都通过 sounddevice 我们采用惰性导入,让缺失的原生库变成一句话,而不是启动时的一个 OSError
这是两种确实不同的失败,各有不同的修法,而 sounddevice 把第二种报告为 import 本身抛出的裸 OSError。在这里把两者都捕获,正是让 --text-only 能在一台完全加载不了 PortAudio 的机器上工作的原因。 录音是一个不断往列表里追加数据的回调,外加一个硬性上限,防止一次被遗忘的录音会话无限增长:
嵌套的 try/finally 是有意的。里层的把取消操作变成一个友好的 AudioError,外层的确保无论从哪条路径退出——包括取消——都会停止并关闭流,因为一个从未被关闭的 RawInputStream 会在这一轮结束后继续占用麦克风。bytes(indata) 是复制而不是引用,因为 PortAudio 会为下一次回调复用那块缓冲区。 注意这些采样从不落盘。/audio/transcriptions 需要一个”文件形态”的上传,但”文件形态”仅仅意味着它需要一个 WAV 头,而我们可以在内存里给它加上:
十四行代码,换来一份永远不会被写进临时目录的人声录音,这笔交易看起来很划算。wave 在标准库里,这些字节直接进入我们之前设置的 file= 参数。 播放是每条回复一个流,这样连续的句子会连成连贯的语音,而不是每句都重启一次设备:
那个 _pending 缓冲区是这里唯一一个跳过就会挨咬的细节。HTTP chunk 的边界和采样边界毫无关系,所以一次 4096 字节的读取可能给你奇数个字节,把一个 16 位采样从中间劈开。把它写进设备后,后面每个采样都会错位一个字节,听起来就像音频版的雪花噪声。所以我们只写偶数个字节,把多出来的那个字节留到下次调用。 仓库里的完整类还有一个用于 Ctrl+C 的 abort()——立即停止设备、丢弃缓冲内容——以及用于正常路径的 close(),后者会把最后那个不完整的采样冲刷出去(补一个零字节),然后等待设备把已有内容播完。把这两个搞反,意味着要么每条回复的最后一个词都被剪掉,要么无法打断一条回复。
PortAudio 是这里的可移植层,所以同一份 audio.py 可以在 macOS、Windows 和 Linux 上运行。venice.py 里没有任何东西知道或关心到底是哪个。

让流和播放重叠

这就是流式真正见效的地方。如果我们在同一个线程上消费聊天流并播放音频,播放会阻塞循环,模型剩余的 token 就滞留在 socket 缓冲区里没人读。所以我们在一个旁路线程上消费流,通过一个队列把句子递过来:
把异常放到队列上、在消费端重新抛出,正是让错误处理保持诚实的做法。一个悄无声息死掉的后台线程给你的是一次挂起而不是一条信息,而用 BaseException 而非 Exception,意味着流内部的 KeyboardInterrupt 仍然能到达调用方。 接下来是这一轮对话本身:取出句子,逐句打印,音频一到就把它的 PCM 喂给播放器。
播放器是在第一个音频 chunk 到达时惰性创建的,而不是一开始就建好,这样 TTS 失败时不会留下一个空转的输出流一直占着扬声器。而 raise_on_error=not failed 意味着当这一轮已经在失败时,我们会安静地拆掉播放,而不是在真正的错误上再叠一个错误。 打印首个音频到达时间是个小事,但在调优时确实有用。那是用户能感受到的数字。

提示循环

剩下的一切就是围着 input() 的一个 while True
输入空行意味着”开始听”;其他输入都当作打字输入处理。历史记录被裁剪到最近八轮,这对一场口头对话完全够用,也让输入 token 数保持平稳,不会一直增长直到什么东西开始抱怨。 这里的两级错误处理值得说一下。启动失败就退出——一个用不了的 REPL 没有启动的必要。单轮失败则打印后回到提示符,因为一次限流或一次没录好的音不应该结束整个会话。 那个 warmup 调用也物有所值。它会列出模型并发送一个单词的 TTS 探针,在用户第一轮真正的对话之前——而不是期间——建立好 TLS 连接并验证 key 和语音:

运行它

按 Enter,说话,再按一次 Enter。不想用麦克风就输入一行文字,输入 reset 开始新对话,输入 q 退出。回复途中按 Ctrl+C 会停止播放并回到提示符,而不是直接退出。 几个变体:
如果它抓错了麦克风或扬声器,问问 PortAudio 能看到什么,然后把设备名或索引写进 AUDIO_SOURCE / AUDIO_SINK
以及测试:

延迟方面该有什么预期

这条管线是三个顺序请求,所以数字大致这样叠加: 在良好的网络下,到首个音频大约一秒左右。有两件事主导这个数字:TTS 是从第一句就开始还是等整条回复,以及模型在开口前会不会把 token 烧在思考上。句级流式和 disable_thinking 是这里两个一旦去掉你就会察觉的改动。 想再快一些,就让回复保持简短——第一句才是感知响应速度的瓶颈——并试试 flash 级别的聊天模型。更多内容见 LiveKit 延迟笔记

隐私说明

有必要明确说说什么东西离开了这台机器,毕竟这个应用里有一个麦克风。 音频发到 Venice 做转写,文字返回来被念出;两者都受 Venice 零数据保留政策的保护,请求结束后他们那边不会存储任何内容。在本地,任何东西都不会写入磁盘——录音在一个列表里组装、在内存中裹上 WAV 头、直接交给请求,所以不存在会泄露或需要清理的临时文件。API key 从环境变量读取,从不打印。对话历史只存在于内存中,退出或输入 reset 后即消失。 如果你需要比零保留更强的保证,各模型的隐私层级见隐私

收尾

要带走的结论是:Venice 上的语音智能体就是三个 OpenAI 兼容的 endpoint,其中两个是流式的。这个项目里的其他一切——句子切分器、音频流、队列——存在的意义都是让这三次调用感觉像一场对话。 venice.py 是值得直接拿走的那部分。把 app.py 换成一个 Web handler 或电话集成,API 层不需要任何改动。 值得接着做的一些事:

给它工具

在聊天这一步加上 function calling,智能体就能在对话途中查东西。

让它搜索

venice_parameters 里设置 enable_web_search,回答就不再局限于训练数据。

克隆一个声音

把 Kokoro 的 voice ID 换成你自己克隆的那个。

把它放进房间

把同样的三个阶段交给 LiveKit,获得 VAD、插话打断和多人通话。
感谢阅读!希望本文揭开了语音智能体的一些神秘感——一旦看清底下那三个请求,它们就远没有听起来那么高深。

相关资源