Voxlocal: Rust로 작성된 최소한의 음성 에이전트
요약
본 글은 음성 에이전트의 작동 원리를 탐구하며, 특히 Rust로 작성된 로컬 저지연 음성 에이전트 'Voxlocal'을 소개합니다. Voxlocal은 사용자가 영어 언어를 통해 자동차 서비스와 같은 특정 작업을 수행할 수 있도록 설계되었으며, 기본적인 구성 요소와 마이크로 모델을 활용하여 투명한 파이프라인을 유지하는 데 초점을 맞추었습니다.
핵심 포인트
- 음성 에이전트는 듣기-생각하기-작업수행-말하기의 과정을 거칩니다.
- Voxlocal은 Rust 기반으로 작성된 macOS용 완전 로컬 저지연 음성 에이전트입니다.
- 복잡한 비동기 스트리밍이나 VAD를 피하고, 기본적인 구성 요소로 투명성을 확보했습니다.
- 주요 목표는 영어 언어를 통한 자동차 서비스 사용 사례에 초점을 맞추고 있습니다.
“
man muss immer umkehren” – Carl Gustav Jacob Jacobi
“의심스러울 때는 빼라(When in doubt, subtract)”
저는 오랫동안 음성 에이전트를 다뤄왔고, 이 기술은 정말 매혹적입니다. 음성 에이전트와 대화하며 작업을 완료하는 것은 마법 같습니다. 혹시 음성 에이전트가 내부적으로 어떻게 작동하는지 궁금해한 적이 있나요? 이 블로그 게시물은 그들의 내부 작동 원리를 이해하려는 저의 시도입니다.
음성 에이전트란 무엇인가?
AI 에이전트는 AI 모델을 사용하여 추론(reason), 단계 계획(plan steps) 및 작업을 실행하여 특정 목표에 도달하는 자율 소프트웨어 시스템입니다. 음성 에이전트는 이것의 한 버전으로, 작업을 완료하기 위해 자연스러운 인간과 같은 구어 대화에 참여할 수 있는 시스템입니다.
쉽게 말해, 음성 에이전트는 듣고(listen), 생각하고(think), 작업을 수행하며(do tasks), 말합니다(speak).
전반적인 개요 (Bird’s eye view)

꽤 복잡한 다이어그램입니다. 이 모든 것이 어떻게 작동하는지 궁금해서, 저는 내부에서 무슨 일이 벌어지는지 더 자세히 이해하기 위해 Rust로 기본적인 프로젝트를 만들었습니다.
음성 에이전트 부분으로 들어가기 전에, 레거시 구성 요소들을 먼저 짚고 넘어가겠습니다.
전화 통신 제공자 (Telephony Provider)
전화 통신 네트워킹은 복잡한 주제이며 이 기사의 범위를 훨씬 벗어납니다. 저희의 경우, 전화 통신 제공자 세그먼트는 레거시 공중전화망(PSTN/SIP)과 클라우드 기반 AI 애플리케이션을 연결하는 역할을 합니다. 이 부분은 통화 수명 주기 처리(handling the call lifecycle), 양방향 스트리밍(bi-directional streaming), 트랜스코딩(transcoding) 및 낮은 지연 시간 유지와 같은 복잡한 작업을 수행합니다.
이 세그먼트가 하는 주요 작업 중 하나는 손실되거나 지연된 패킷에 대한 지터 버퍼링(jitter buffering)과 STT 정확도를 보존하기 위한 오디오 업샘플링(audio upsampling)입니다. 이 세그먼트는 네트워크 지연 시간을 임계값 이하로 유지하려고 노력합니다.
현대적인 전화 통신은 공학적 경이로움이며 현대 인터넷과 밀접하게 관련되어 있습니다.
이것을 제외하고, 우리가 무엇을 만들고 있는지 살펴보겠습니다.
우리의 아방가르드 음성 에이전트인 voxlocal 소개
Voxlocal은 macOS용 완전 로컬(fully local)의 저지연 음성 에이전트입니다. Rust로 작성된 CLI 도구이며, 사용자가 음성 에이전트와 대화하고 응답을 받을 수 있게 해줍니다. 실험적인 정신으로, 저는 최대한 마법 같은 경험에 가깝게 구현하기 위해 기본적인 구성 요소(bare bones components)와 마이크로 모델(micro models)을 사용했습니다. 프로덕션 환경에서는 사용하지 마십시오!
이 도구의 초점은 매우 좁습니다: 영어 언어를 통한 자동차 서비스 사용 사례입니다. 또한 바퀴를 재발명하는 대신 Whisper나 Piper 같은 기성 구성 요소(off-the-shelf components)도 몇 가지 사용하고 있습니다. 파이프라인을 검사 가능하고 투명하게 유지하기 위해, voxlocal은 복잡한 비동기 스트리밍(async streaming)이나 VAD (Voice Activity Detection)를 의도적으로 피했습니다. 향후 개정판에서 이에 대해 더 깊이 다룰 수도 있습니다.
소스 코드를 살펴보세요:
Open-source project · Rust · macOS samkhawase / voxlocal 대화의 각 단계를 쉽게 검사할 수 있도록 구축된 최소한의 완전 로컬 음성 에이전트. 저장소를 보기 단계별로 탐색해 봅시다
1. 음성 에이전트와 대화하면 어떤 일이 발생하나요?
음성 에이전트와 이야기하거나 오디오 샘플을 제공하면, 이 도구는 작업을 수행하기 위해 이를 여러 개의 작은 작업으로 분해합니다. 오디오에서 단어를 인식하고 'Um', 'ah' 같은 소리를 제거하여 텍스트를 정리합니다. 이 단어들을 사용하여 관련 정보를 찾고 그에 기반하여 행동(action)을 선택합니다. 마지막으로 응답을 다시 음성으로 변환합니다.
voxlocal에서는 마이크 오디오가 캡처되어 먼저 음성 인식(speech recognition)으로 전달됩니다:
let capture = record_until(RECORD_SECS)?;
let transcript = stt.transcribe(&capture.samples)?;
이후 전사된 텍스트(transcript)는 공유 턴 파이프라인(shared turn pipeline)으로 들어갑니다. 이 파이프라인에서 에이전트는 텍스트를 정규화하고, 일치하는 컨텍스트를 검색하며, 요청을 관련 도구로 라우팅하고, 마지막으로 답변을 준비합니다:
run_turn(
&norm, &rag, &embedder, &mut llm, tts.as_mut(),
transcript, &mut budget,
...
각 단계는 작업을 수행하기 위해 서로 다른 기술을 사용합니다.
- 음성 인식 (Speech recognition)은 오디오에서 단어를 결정합니다.
- 검색 (Retrieval)은 수치 형태를 사용하여 텍스트 블록을 수학(Maths)적으로 비교합니다 (코사인 유사도 (cosine similarity)).
- 대규모 언어 모델 (LLM)이 행동을 예측합니다.
- 평범한 Rust 코드가 도구 호출(tool calls)을 수행하고 행동을 실행합니다.
- 음성 합성 (Speech synthesis)은 최종 답변으로부터 오디오를 생성합니다.
하지만 이것은 너무 건조하고 학문적입니다. 사용자가 에이전트에게 “오일 교환 비용은 얼마인가요?”라고 질문하는 작동 예시를 통해 레이어들을 풀어보겠습니다.
2. 음파에서 단어로
마이크가 공기압을 지속적으로 측정하여 소리를 포착하고, 그 수치를 오디오 샘플로 변환한다는 사실에 놀랐습니다. voxlocal은 포착된 오디오를 초당 16,000개의 샘플을 가진 모노(mono) 형식으로 변환한 다음, 추가 처리를 위해 Whisper에 전달합니다.
Whisper는 우리가 학습시킨 신경망이며, 음성 소리와 유사한 패턴을 오디오에서 찾는 데 설계되었습니다. 그리고 이 소리가 단어의 연속적인 시퀀스를 이루는지 추정합니다. Whisper 개발자들은 이 작업을 위해 모델을 훈련하고 미세 조정(fine tuned)했으며, 저희 프로그램은 단순히 이를 이용해 모델을 호출합니다.
let mut params =
whisper_rs::FullParams::new(
whisper_rs::SamplingStrategy::Greedy { best_of: 1 }
...
이 예시에서는 샘플링 대신 그리디 디코딩(greedy decoding)을 사용하여 가장 가능성이 높은 다음 토큰을 선택합니다. Temperature 0은 가장 높은 확률의 첫 번째 일치 항목, 예를 들어 oil이나 change를 반환한다는 의미입니다. 저희의 컨텍스트가 매우 제한적이고 대화 내용을 알고 있기 때문에 이것이 가능합니다.
Whisper로부터 응답을 세그먼트(segments)로 받은 후, 이들을 하나의 스크립트로 합치는 것이 voxlocal의 역할입니다.
let mut out = String::new();
for i in 0..state.full_n_segments() {
if let Some(segment) = state.get_segment(i) {
...
저희 경우 출력은 다음과 같습니다:
“What does an oil change cost?”
이 단계에서 출력되는 결과는 출력 신호로부터 만들어진 추정치입니다. 실제 발화된 단어와 약간 다를 수 있습니다. 아날로그-디지털 전사(transcribing) 과정은 노이즈, 억양, 운율(cadence), 음색(timbre) 등 다양한 요인 때문에 항상 까다롭습니다. 따라서 추가적인 단계가 필요하며, 이 단계는 데이터를 정리하여 후속 처리에 더 일관성 있게 만듭니다.
3. 인식된 음성 클리닝 (Cleaning up recognized speech)
인간은 말할 때 항상 일관적이지 않으며, "Um"과 같은 필러 단어(filler words), 어색한 침묵, 또는 숫자 발음의 특이점("Nine Thirty" 대신 "noyynTherTee") 등을 자주 추가합니다. voxlocal은 텍스트를 다음 단계로 전달하기 전에 간단하고 예측 가능한 규칙을 적용하여 이를 정리합니다.
예를 들어, voxlocal의 정규화기(normalizer)는 다음과 같은 작업을 수행합니다:
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"로 바뀔 수 있습니다. 프로그램은 단순히 기존에 알려진 변환을 사용하고 다음 단계가 그 뒤에 숨겨진 의미를 해석하도록 합니다. 이는 기본 원칙(bare bones approach)을 유지하기 위해 각 차례마다 재사용되는 단순한 정규 표현식(regular expressions)입니다.
4. 단어를 벡터로 변환 (Turning words into vectors)
voxlocal이 단어들을 정리했으므로, 이제 사용자 의도(user intent)를 찾아야 합니다. 사용자가 "What does an oil change cost"와 "How much for an oil service"라고 말하더라도 동일한 의도를 가질 수 있습니다. 그렇다면 어떻게 의도를 파악할까요?
답은 임베딩 모델(embedding model)을 사용하는 것입니다. 이 모델은 텍스트를 숫자 목록, 즉 벡터(vector)로 변환합니다 (멋있게 들리도록 '벡터'라고 말해도 됩니다). 이러한 트릭 덕분에 의미가 관련된 텍스트들은 같은 방향을 가리키는 벡터를 갖는 경향이 있습니다.
우리는 voxlocal에서 MiniLM을 사용하여 정규화된 질의(query)에 대한 하나의 벡터를 생성합니다.
pub fn embed_one(&self, text: &str) -> Result<Vec<f32>> {
Ok(self.embed_batch(&[text])?.remove(0))
}
embed_batch 함수 내부에서
, MiniLM은 각 토큰에 대한 표현(representation)을 생성합니다. 이후 코드는 마스크 평균 풀링(masked mean pooling)을 사용하여 이 토큰 표현들을 하나의 문장 벡터로 결합합니다. 간단히 말해, 실제 토큰들은 평균을 내고 배치(batch)의 길이를 동일하게 맞추기 위해 추가된 패딩(padding)은 무시하는 것입니다.
let mask = attention.to_dtype(DType::F32)?.unsqueeze(2)?;
let summed = hidden.broadcast_mul(&mask)?.sum(1)?;
let counts = mask.sum(1)?;
...
모델은 의미와 의도에 어떤 패턴이 중요한지 어떻게 결정할까요? 그 답은 모델의 학습된 가중치(learned weights)에 있습니다. 이 가중치들은 사전 훈련(pretrained)되었으며 voxlocal을 위해 바로 사용할 준비가 되어 있습니다.
. voxlocal의 풀링 코드
이후 코드는 토큰 수준의 출력들을 전체 문장에 대한 단일 벡터로 축소합니다. 이것은 순전히 모델의 출력 표현(output representation)이며, Rust 코드가 각 좌표의 의미를 정의하는 것은 아닙니다.
마지막으로, 이 벡터는 L2-정규화(L2-normalized)됩니다.
let norm = row.iter().map(|v| v * v).sum::<f32>().sqrt();
let unit_vector = row.iter().map(|v| v / norm).collect::<Vec<_>>();
간단히 말해, 정규화는 모든 0이 아닌 벡터가 길이가 1이 되도록 만듭니다. 이는 방향을 비교하는 간단한 내적(dot product)을 사용하는 다음 단계에 필요합니다. 수학적으로 유사한 의미들은 단어 표현이 달라도 비슷한 방향을 가리킨다고 합니다. 그리고 네, 정말로 문자 그대로 기하학적으로 각도의 코사인 값(cosine of the angle)을 계산합니다. 혹시 궁금하시다면요. 저는 고등학교 수학 선생님인 Mr. Bhalérao가 저를 향해 만족스러운 표정으로 웃는 모습이 눈에 선합니다.
5. 관련 지식 찾기 (Finding relevant knowledge)
쿼리(query)가 단위 길이 벡터(unit-length vector)가 된 voxlocal은 이제 이를 기존 서비스 문서와 비교할 준비가 되었습니다. voxlocal은 쿼리 벡터를 각 문서 벡터와 비교하여 유용한 컨텍스트(context)를 찾아냅니다.
벡터들의 내적(dot products), 즉 코사인 유사도(cosine similarity)를 계산하여 유사도를 비교합니다. 더 좋은 매치란 벡터들이 더 유사한 방향을 가리킨다는 것을 의미합니다. 저희 검색 기능은 점수들을 비교하고 기준 임계값(reference threshold)보다 낮은 것은 제거합니다. 남아 있는 매치들은 정렬되고 요청 번호가 반환됩니다.
let mut scored: Vec<(&Doc, f32)> = self.docs
.iter()
.map(|doc| {
...
우리는 높은 순위로 나오지만 관련 없는(irrelevant) 문서를 버리고 싶기 때문에 임계값이 필요합니다.
너무 수학적으로 느껴진다면, 간단한 용어로 설명해 드리겠습니다:
쿼리 벡터가 [1.0, 0.0]이라고 가정하고,
비슷한 방향을 가리키는 문서 벡터가 [0.8, 0.6]이라면,
내적은 $1.0 imes 0.8 + 0.0 imes 0.6 = 0.8$입니다.
반면, 주로 다른 방향을 가리키며 벡터가 [0.1, 0.995]인 관련 없는(unrelated) 단어는 약 $0.1$점을 얻습니다.
내적 값이 1.0에 더 가까운 것이 승자입니다. 쿼리 매치는 $ ext{Similarity} = (1.0 imes 0.8) + (0.0 imes 0.6) = 0.8$을 산출합니다.
문서는 포함되기 위해 최소한 0.35점 이상을 얻어야 합니다. 샘플 오일 교환(oil-change) 쿼리는 0.816점을 받은 반면, “hi”와 같은 관련 없는 인사말은 이 코퍼스에 대해 약 0.10–0.15점을 받습니다. 이는 우리가 관련 없는 용어들을 걸러내는 데 도움을 줍니다.
이것은 최소한의 RAG(Retrieval-Augmented Generation) 예시이며, 검색 결과는 최대 2개의 문서만 반환합니다. 이 파이프라인은 해당 문서를 언어 모델에 적합하게 준비하고 컨텍스트를 320자로 제한합니다. 언어 모델은 이 증거(evidence)를 기반으로 어떤 응답과 행동을 수행할지 결정합니다.
6. 언어 모델이 행동을 선택하는 방법
이제 사용자 질문과 검색된 컨텍스트가 다음 단계를 위해 준비되었습니다. voxlocal에서 저희의 언어 모델(SmolLM2)은 자유 형식의 답변 대신 구조화된 도구 호출(structured tool call)을 결정하고 반환하도록 프롬프트됩니다.
예를 들어, “오일 교환 비용이 얼마인가요?”라는 질문에 대해 의도하는 출력은 도구 이름과 그 인자들로 구성됩니다:
{
"tool": "check_price",
"args": {
...
모델은 이 출력을 한 토큰씩 생성하며, 토큰은 전체 단어일 수도 있고, 단어의 일부일 수도 있으며, 구두점이나 JSON 조각일 수도 있습니다. 각 단계에서 저희 모델은 이전 프롬프트와 토큰을 기반으로 다음에 올 가장 가능성 높은 토큰이 무엇인지 예측합니다. Rust 코드는 이 생성을 Candle를 통해 실행하며, 학습된 정보를 담고 있는 모델 파일을 사용합니다.
라우터는 생성될 수 있는 토큰의 개수를 제한하고, 그 결과를 ToolCall로 파싱합니다.
let (raw, truncated) = self.generate(&prompt, max_new)?;
let call = parse_tool_call(&raw)
.or_else(|| truncated.then(|| repair_truncated(&raw)).flatten())
...
위 스니펫은 생성과 해석 사이의 경계를 보여줍니다. 모델은 텍스트 값을 반환하고, 프로그램은 그 텍스트를 표준화된(canonical) 동작으로 해석하려고 시도합니다. voxlocal에서 모델의 출력은 자동으로 실행 가능한 명령으로 처리되지 않습니다.
소형 언어 모델(SLM)은 JSON 중간에 멈추거나 잘못된 출력을 생성하는 문제에 직면할 수 있습니다. 생성이 잘렸다는 것을 감지하면, voxlocal은 이를 극복하기 위해 복구(repair)를 시도합니다. 만약 파싱이 여전히 실패하면, voxlocal의 라우터 구성 요소가
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기