跳到正文
Hacker News· FlyingSnake·· 9 天前AI 评分31

Voxlocal:用 Rust 编写的极简本地语音智能体

Voxlocal: A minimal voice agent written in Rust

AI 导读

开发者开源了 Voxlocal,一个用 Rust 编写、完全本地运行的低延迟语音智能体 CLI 工具,面向 macOS,仅聚焦英语汽车维修场景。它串联 Whisper 语音识别、嵌入向量检索、LLM 决策与 Piper 语音合成,并刻意避开复杂异步流式与 VAD 以保持流程可检查,作者明确提醒不要用于生产环境。

正文 · AI 翻译

“man muss immer umkehren” – Carl Gustav Jacob Jacobi

“存疑时,做减法”

我研究语音代理已经有一段时间了,它们是一项迷人的技术。与语音代理交谈并完成工作,感觉如同魔法。你有没有想过语音代理在幕后是如何工作的?这篇博文是我试图理解其内部运作机制的尝试。

语音代理到底是什么?

AI 代理是一种自主软件系统,它使用 AI 模型进行推理、规划步骤并执行任务,以达成特定目标。语音代理是它的一种形式——一个能够进行自然类人语音对话以完成任务的系统。

简单来说,语音代理会听、思考、执行任务并说话。

鸟瞰图

Diagram of a phone call passing through a telephony provider, speech recognition, an LLM with tools, and speech synthesis

这张图信息量很大。由于我很好奇这一切是如何运作的,我用 Rust 创建了一个极简项目,以了解幕后发生的更精细的细节。

在深入语音代理部分之前,让我们先把遗留组件放到一边。

电话服务提供商

电话网络是一个复杂的话题,远远超出了本文的范围。在我们的案例中,电话服务提供商这一部分将传统的公共电话网络(PSTN/SIP)与基于云的 AI 应用连接起来。它执行诸如处理通话生命周期、双向流传输、转码以及保持低延迟等复杂任务。

这一部分执行的一项主要任务是对丢失或延迟的数据包进行抖动缓冲,以及进行音频上采样以保持 STT 准确率。这一部分试图将网络延迟保持在阈值以下。

现代电话网络是一项工程奇迹,与现代互联网密切相关。

既然这些已经交代清楚,让我们来看看我们正在烹饪什么。

隆重推出 voxlocal,我们前卫的语音代理

Voxlocal 是一个完全本地化、低延迟的 macOS 语音代理。它是一个用 Rust 编写的 CLI 工具,让用户可以与语音代理交谈并获得回复。本着实验精神,我使用了极简组件和微型模型,以尽可能接近那种魔法。不要在生产环境中使用它!

它的关注点非常狭窄:一个汽车服务用例,通过英语语言。我还使用了一些现成的组件,如 Whisper 和 Piper,而不是重新造轮子。为了保持流水线可检查且透明,voxlocal 刻意避免了复杂的异步流传输或 VAD(语音活动检测)。我可能会在后续修订中更深入地探讨它们。

探索源代码:

开源项目 · Rust · macOS samkhawase / voxlocal 一个极简、完全本地化的语音代理,旨在让对话的每一步都易于检查。 查看仓库

让我们一步步探索它

1. 当你与语音代理交谈时会发生什么?

当你与语音代理交谈,或给它一个音频样本时,它会将其分解为多个较小的任务来完成工作。它从音频中识别出词语,并通过去除“Um”、“ah”等来清理文本。它使用这些词语来查找相关信息,并据此选择一项操作。最后,它将回复转换回语音。

在 voxlocal 中,麦克风音频被捕获并首先传递给语音识别:

let capture = record_until(RECORD_SECS)?;
let transcript = stt.transcribe(&capture.samples)?;

转录文本随后进入共享的回合流水线。在流水线中,代理对文本进行规范化,搜索匹配的上下文,将请求路由到相关工具,最后准备回复:

run_turn(
    &norm, &rag, &embedder, &mut llm, tts.as_mut(),
    transcript, &mut budget,
)?;

每个阶段都使用不同的技术来执行其操作。

  • 语音识别从音频中确定词语
  • 检索以数值形式比较文本块,使用数学1(余弦相似度)
  • LLM 预测一个动作
  • 普通的旧 Rust 代码执行工具调用并执行动作。
  • 语音合成从最终回复生成音频

然而这太枯燥和学术了。让我们通过一个工作示例来揭开各层,用户问代理:“换油要多少钱?”

2. 从声波到词语

我惊讶地了解到,麦克风通过连续测量气压来捕捉声音,并将读数转换为音频样本。voxlocal 然后将捕获的音频转换为单声道2,每秒 16,000 个样本,然后将其传递给 Whisper 进行进一步处理。

Whisper 是我们训练好的神经网络,它旨在寻找音频中类似语音声音的模式。然后它估计声音是否构成一个词序列。Whisper 的创建者已经训练并微调了它来完成这项任务,我们的程序只是搭便车并调用该模型3。

let mut params =
    whisper_rs::FullParams::new(
        whisper_rs::SamplingStrategy::Greedy { best_of: 1 }
    );
params.set_language(Some("en"));
params.set_temperature(0.0);
state.full(params, audio)
    .map_err(|e| anyhow!("whisper decode failed: {e}"))?;

在这个例子中,我们使用贪婪解码而不是采样来选择最可能的下一个标记。温度 0 意味着它只会返回最高可能性的第一个匹配,如 oil 或 change。我们可以这样做,因为我们的上下文非常有限,而且我们了解我们的对话。

一旦我们从 Whisper 获得分段响应,voxlocal 的任务就是将它们连接成一个转录文本。

let mut out = String::new();
for i in 0..state.full_n_segments() {
    if let Some(segment) = state.get_segment(i) {
        if let Ok(text) = segment.to_str_lossy() {
            out.push_str(&text);
        }
    }
}
let transcript = out.trim().to_string();

在我们的例子中,输出是:

“What does an oil change cost?”

在这个阶段,输出是基于输出信号的估计。它可能与实际说出的词语略有不同。由于噪声、口音、节奏、音色等各种因素,模拟到数字的转录总是很棘手。因此,我们需要一个额外的阶段来清理它,并使其更一致以便进一步处理。

3. 清理识别出的语音

人类说话时并不总是一致,经常添加填充词如“Um”、尴尬的停顿或数字的特殊发音(“noyynTherTee”而不是“Nine Thirty”)。voxlocal 应用简单且可预测的规则来清理文本,然后再将其送入下一阶段。

例如,voxlocal 中的规范化器移除常见的填充短语、修剪空白并连接分隔符:

s = self.thousands.replace_all(&s, "$1$2").into_owned();
s = self.fillers.replace_all(&s, " ").into_owned();
s = self.space.replace_all(&s, " ").into_owned();

当我们说“Um yeah, what does an oil change cost?”时,它可能变成“What does an oil change cost”。程序只是使用现有的已知转换,并让下一阶段解释其背后的含义。它只是正则表达式,在每一轮中重复使用,符合我们的极简方法。

4. 将词语转换为向量

现在 voxlocal 已经清理了词语,它需要从中找出用户意图。用户可以说“What does an oil change cost”和“How much for an oil service”,两者具有相同的意图。那么我们如何弄清楚意图呢?

答案是使用嵌入模型,它将文本转换为数字列表(你可以说 vector 来显得很酷)。通过这个技巧,具有相关含义的文本往往具有指向相同方向的向量。

我们在 voxlocal 中使用 MiniLM 为规范化查询创建一个向量。

pub fn embed_one(&self, text: &str) -> Result<Vec<f32>> {
    Ok(self.embed_batch(&[text])?.remove(0))
}

在函数 embed_batch 内部,MiniLM 为每个标记生成一个表示。然后代码使用掩码平均池化将这些标记表示组合成一个句子向量。简单来说:它对真实标记进行平均,并忽略为使批次长度相同而添加的填充。

let mask = attention.to_dtype(DType::F32)?.unsqueeze(2)?;
let summed = hidden.broadcast_mul(&mask)?.sum(1)?;
let counts = mask.sum(1)?;
let pooled = summed.broadcast_div(&counts)?.contiguous()?;

模型如何确定哪些模式对含义和意图重要?答案在于模型学习到的权重,这些权重是预训练好的,可直接用于voxlocal。然后,voxlocal的池化代码将 token 级输出缩减为整个句子的单个向量。这纯粹是模型的输出表示;Rust 代码并不定义每个坐标的含义。

最后,向量经过 L2 归一化。

let norm = row.iter().map(|v| v * v).sum::<f32>().sqrt();
let unit_vector = row.iter().map(|v| v / norm).collect::<Vec<_>>();

简单来说,归一化确保每个非零向量的长度为一。这是下一阶段所需要的,下一阶段使用简单的点积来比较方向。根据数学原理,即使措辞不同,相似的含义也会指向相似的方向。是的,如果你想知道的话,它确实从字面和几何上计算了夹角的余弦值。我仿佛能看到我的高中数学老师 Bhalérao 先生,带着得意的神情对我微笑。

5. 查找相关知识

现在,作为单位长度向量的查询voxlocal已准备好与现有服务文档进行比较。voxlocal将查询向量与每个文档向量进行比较,并找到有用的上下文。

计算向量的点积(即余弦相似度),并比较相似度。更好的匹配意味着向量指向更相似的方向。我们的搜索比较分数,并丢弃低于参考阈值的分数。剩余的匹配项被排序,并返回请求数量。

let mut scored: Vec<(&Doc, f32)> = self.docs
    .iter()
    .map(|doc| {
        let sim: f32 = doc.embedding.iter()
            .zip(query.iter())
            .map(|(a, b)| a * b)
            .sum();
        (doc, sim)
    })
    .filter(|(_, sim)| *sim >= RAG_MIN_SIM)
    .collect();
scored.sort_by(|a, b| {
    b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);

我们需要阈值,因为我们要丢弃排名较高但不相关的文档。

如果这太数学化了,让我用简单的话解释一下: 假设我们查询的向量是[1.0, 0.0],而一个指向相似方向的文档向量是[0.8, 0.6]。它们的点积是1.0×0.8 + 0.0×0.6 = 0.8。一个主要指向另一个方向的向量(不相关的词)为[0.1, 0.995],得分约为0.1。哪个点积更接近 1.0 就是赢家。查询匹配得到 $\text{Similarity} = (1.0 \times 0.8) + (0.0 \times 0.6) = 0.8$。

文档还必须至少得分0.35才能被包含。示例换油查询得分为0.816,而像“hi”这样的不相关问候语针对此语料库的得分约为0.10–0.15。这有助于我们剔除不相关的词。

这是一个最小的 RAG 示例,搜索最多只返回 2 个文档。管道为语言模型准备它们,并将上下文限制在 320 个字符。语言模型根据这些证据决定要执行什么响应和操作。

6. 语言模型如何选择操作

现在,我们有用户的问题和检索到的上下文可用于下一阶段。在voxlocal中,我们的语言模型(SmolLM2)被提示决定并返回结构化的工具调用,而不是自由形式的答案。

例如,对于“换油要多少钱?”,预期的输出是工具名称加上其参数:

{
  "tool": "check_price",
  "args": {
    "service": "oil change"
  }
}

模型一次一个 token 地产生这个输出,一个 token 可以是一个完整的单词、单词的一部分、标点符号或 JSON 片段。在每一步,我们的模型根据前面的提示和 token 预测哪个 token 最可能是下一个。Rust 代码通过 Candle 运行该生成,并使用包含学习信息的模型文件。

路由器限制可以生成的 token 数量,然后将结果解析为ToolCall:

let (raw, truncated) = self.generate(&prompt, max_new)?;
let call = parse_tool_call(&raw)
    .or_else(|| truncated.then(|| repair_truncated(&raw)).flatten())
    .ok_or_else(|| anyhow!("model did not emit valid tool JSON: {raw:?}"))?;

上面的片段展示了生成与解释之间的边界。模型返回一个文本值,程序尝试将该文本解释为规范动作。模型的输出不会被 voxlocal 自动视为可执行命令。

小型语言模型可能会遇到问题,在 JSON 中途停止或产生格式错误的输出。当它注意到生成被截断时,voxlocal 会尝试修复以克服这一问题。如果解析仍然失败,voxlocal 的路由器组件会为该轮返回错误。下一节将解释应用代码如何检查和执行有效的工具调用。

7. 从模型输出到动作

正如我们所见,模型生成的工具调用在此阶段仍然是文本。为了执行指定的动作,voxlocal 必须解析它并决定该工具被允许做什么。

voxlocal 解析模型的 JSON,然后将其提出的工具名称映射到支持的工具集上。它还会从检索结果中获取服务(如果有的话):

let call = parse_tool_call(&raw)
    .or_else(|| truncated.then(|| repair_truncated(&raw)).flatten())
    .ok_or_else(|| anyhow!("model did not emit valid tool JSON: {raw:?}"))?;
let mut call = call;
call.tool = snap_tool(&call.tool, query);
if let Some(hit) = top_hit {
    call.args.insert(
        "service".to_string(),
        serde_json::Value::String(hit.title.clone()),
    );
}

我们在这里看到的是统计预测与程序化行为之间一个有趣的边界。Rust 程序解析模型的建议,并在执行前应用规则。

例如,路由器会从当前查询中提取时间,而不是盲目信任模型可能从示例中复制的时间。

然后执行器处理我们为此示例设置的支持动作:

match call.tool.as_str() {
    "book_appointment" => { /* prepare booking reply */ }
    "check_price" => { /* look up service price */ }
    other => format!("Unknown tool {other}."),
}

对于这个项目,实际上不会执行任何操作,因为这是一个模拟。在现实生活中,应用可能会调用日历或 API。

8. 将回复转回声音

在此阶段,我们已经准备好给用户的答案,现在是程序将文本响应转换回语音的时候了。Piper 将文字转换为音频表示,然后使用其训练好的语音模型生成最终音频。与 Whisper 的情况一样,我们借助学习到的模型权重来帮助处理时序、音高、音色等。

voxlocal 通过 piper-rs 调用 Piper 并合成语音:

pub fn speak(&mut self, text: &str) -> Result<(Vec<f32>, u32)> {
    self.piper
        .create(text, false, None, None, None, None)
        .map_err(|e| anyhow!("piper synth failed: {e}"))
}

voxlocal 将生成的音频样本及其采样率传递给 rodio 进行播放。

let source = rodio::buffer::SamplesBuffer::new(
    1,
    sample_rate,
    samples.to_vec(),
);
player.append(source);
player.sleep_until_end();

我们需要记住,神经合成发生在 Piper 的 ONNX 语音模型4内部,而不是在这个 Rust 封装中。voxlocal 将文本交给 Piper 并接收音频,然后由 rodio 通过选定的输出设备播放。

这最后一步完成了从呼叫者语音到智能体语音响应的路径。在电话系统中,最终音频将通过通话的媒体流发送回去,而不是通过本地扬声器播放。

测量流水线

9. 时间花在哪里

语音智能体的响应时间包括计算以及录制或播放音频所花费的时间。voxlocal 有五秒的录音窗口,但模型计算并不是 5 秒。我们可以通过本地运行程序来查看详细信息,这里有一个(删节过的)请求展示了该流水线。不同阶段的日志显示了转换过程,延迟预算显示了所测量阶段花费了多长时间。

[Stage 1: Voice capture]: capture complete: 80554 samples (5150 ms)
[Stage 2: Speech to text]: produced 37 characters: "What is the price for the oil change?"
[Stage 3: Text normalization]: output: "What is the price for the oil change?" (0.9 ms)
[Stage 4: Retrieval augmented generation]: selected document: oil change (0.816)
[Stage 4: Retrieval augmented generation]: using 86 context characters (12.2 ms)
[Stage 5: LLM tool routing]: generating up to 48 tokens with 86 context characters
  [llm]    {"tool": "check_price", "args": {"service": "brake pads"}}
  [tool]   check_price {"service": String("oil change")}
[Stage 5: LLM tool routing]: reply prepared: "oil change is $79." (68.6 ms)
[Stage 6: Text to speech]: generated 47616 samples at 22050 Hz (72.1 ms)
[Stage 7: Audio playback]: playback complete
latency budget:
  capture     5149.6 ms
  stt           55.1 ms
  normalize      0.9 ms
  rag           12.2 ms
  llm           68.6 ms
  tts           72.1 ms
  play        1135.3 ms
  TOTAL       6493.9 ms  <- total compute

注意:有趣的是,模型错误地在其提示中复用了预约示例中的 brake pads。路由器在执行价格查询之前,使用检索到的文档将服务更正为 oil change。

10. 一次对话,多种计算

该图展示了一次对话经过多种表示形式:

Voxlocal pipeline showing audio capture, transcription, normalization, retrieval, tool routing, and speech playback

这个极简语音代理帮助我理解了每个转换是如何让请求流经整个管道的。Whisper 转录音频,MiniLM 将文本向量化,SmolLM2 预测工具调用,Rust 执行它,Piper 将回复发送回用户。观察和调试这个语音代理及其各个活动部件很有趣。

最终思考

过去一年里我一直在与信息洪流作斗争,很难跟上。在这种情况下,每当我被信息淹没时,我都会剥开表层,回到第一性原理来重新定位自己。一个下午的氛围工程,以及从第一性原理出发想象语音代理,帮助我理解了所有这些阶段和模型如何结合在一起,提供无缝体验。

正如 Jacobi 和 Munger 所说。

脚注

  1. 斯坦福信息检索导论:点积解释了向量表示之间的余弦相似度。↩

  2. Whisper 音频预处理使用以 16 kHz 采样的单声道音频。↩

  3. OpenAI Whisper README描述了该模型的训练和语音识别任务。↩

  4. Piper 文档描述了其音素化和 ONNX 语音模型↩

来源:Hacker News · samkhawase.com