Skip to main content
Venice는 여러분의 말을 듣고 대답할 수 있습니다. 연결할 실시간 speech-to-speech 소켓은 없으며, 이는 한계처럼 들리지만, 음성 에이전트가 사실 루프 안의 평범한 HTTP 호출 세 개일 뿐이라는 것을 알아차리는 순간 달라집니다: 사용자가 말한 것을 전사하고, 답변을 생성하고, 답변을 소리 내어 말하는 것입니다. 이 가이드에서는 그 루프를 Python 터미널 앱으로 만들어 봅니다. Enter를 누르고, 말하고, 다시 Enter를 누르면 답변이 스피커로 재생됩니다. 마이크를 쓰고 싶지 않다면 대신 한 줄을 타이핑해도 됩니다. 이는 LiveKit Agents 가이드와 같은 STT → LLM → TTS 구조에서 LiveKit, 웨이크 워드, 도구를 뺀 것입니다. 프레임워크를 걷어내는 것이 핵심입니다: 끝까지 따라오면 어떤 세 요청이 실제 일을 하는지, 그리고 왜 그중 둘을 스트리밍하는지 정확히 알게 됩니다. 계속하기 전에: Venice API 키가 필요합니다. 환경 변수로 내보내세요:
전체 코드 구현이 궁금하신가요? GitHub 저장소를 확인하세요.

사전 준비

  • Python 3.11 이상과 uv
  • venice.ai에서 발급한 Venice API 키
  • 전체 음성 루프를 원한다면 마이크와 스피커
녹음과 재생은 PortAudio를 감싸는 sounddevice를 통합니다. uv sync가 Python 패키지를 설치하며, Windows에서는 그것으로 충분합니다. macOS와 Linux는 PortAudio 라이브러리도 필요합니다:
이 중 어느 것도 Venice와 관련이 없습니다. 샘플이 여러분의 컴퓨터에 들어오고 나가는 방법일 뿐입니다. 앱은 마이크를 완전히 건너뛰면서도 채팅과 TTS는 그대로 사용하는 --text-only 플래그를 제공하므로, 오디오 하드웨어가 전혀 없는 머신에서도 따라올 수 있습니다.

만들 것

대화 한 턴은 세 개의 요청입니다: 이 모델 ID들은 고정된 목록이 아니라 출발점입니다. Venice는 카탈로그를 교체하므로, 무언가를 배포하기 전에 GET /models?type=...GET /models/traits에서 런타임에 해석하세요. 이것이 어떻게 진행되는지는 지원 중단을 참조하세요. 소스 트리는 의도적으로 작게 유지합니다:
이 분리는 보기보다 중요합니다. venice.py는 웹 앱, Discord 봇, 전화 통합으로 그대로 들어 옮길 수 있는 부분입니다. audio.py는 어떤 머신에서 실행되는지 신경 쓰는 유일한 파일이며, Venice는 그 어떤 것도 보지 못합니다. API는 들어올 때 WAV 블롭을 받고 나갈 때 원시 PCM을 돌려줄 뿐입니다.

설정하기

프로젝트를 만들고 의존성을 추가하세요. OpenAI SDK가 모든 HTTP 작업을 처리하고, python-dotenv가 키를 셸 히스토리 밖에 두며, sounddevice가 마이크와 스피커와 대화합니다:
그다음 모델 선택이 코드에 묻히지 않고 설정이 되도록 .env.example을 만듭니다:
이를 .env로 복사하고 키를 붙여 넣으세요.

SDK를 Venice로 향하게 하기

Venice의 API는 OpenAI 호환이므로 공식 openai 클라이언트를 사용하고 base URL만 바꿉니다. 통합의 전부가 그것입니다. venice.py를 만들고 클라이언트부터 시작하세요:
os.environ["VENICE_API_KEY"]가 예외를 던지게 두는 대신 키를 직접 확인한다는 점에 주목하세요. 키 누락처럼 흔한 일에 KeyError 트레이스백을 보여주는 것은 나쁜 첫 경험입니다. 여기 있는 동안 정리해 둘 일이 하나 더 있습니다. SDK는 OpenAIError 서브클래스를 발생시키는데, 유용한 세부 정보는 응답 본문에 묻혀 있으므로 한 번에 풀어두는 것이 좋습니다:
아래의 모든 호출은 실패를 이곳으로 흘려보내므로, 잘못된 보이스 ID나 만료된 키가 스택 트레이스 대신 읽기 쉬운 한 줄로 표시됩니다.

사용자의 말 듣기

POST /audio/transcriptions는 오디오 파일을 받아 텍스트를 반환합니다. 우리는 로컬에서 16 kHz 모노 WAV를 녹음하지만, 엔드포인트는 일반적인 포맷을 모두 받아들이므로 하나를 하드코딩하는 대신 파일 확장자를 MIME 타입에 매핑합니다:
Venice의 전사는 스트리밍 소켓이 아니라 요청/응답 방식이며, 그래서 녹음에 명확한 끝이 있습니다. 음성 활동 감지를 돌리는 대신 Enter를 누르는 것입니다. VAD 기반 엔드포인팅을 원한다면, 그것이 LiveKit 가이드가 Silero에 맡기는 일입니다. 빈 전사 결과는 오류가 아니라 정상적인 결과입니다. 누군가는 실수로 Enter를 두 번 누를 것이고, 친절한 “잘 못 들었어요”가 언제나 예외보다 낫습니다.

답변 스트리밍하기

이제 채팅 호출입니다. 에이전트가 어떻게 들리는지에 실질적인 차이를 만드는 Venice 고유 설정이 두 가지 있습니다:
include_venice_system_prompt: False는 Venice가 우리 프롬프트 앞에 자체 시스템 프롬프트를 덧붙이는 것을 막습니다. 켜 두면 호출당 약 1700개의 추가 입력 토큰이 들고, 모델에게 어떻게 행동할지 말하는 두 번째 목소리가 생깁니다. disable_thinking: True는(더 새로운 필드를 읽는 모델을 위한 reasoning.enabled: False와 함께) GLM이 무언가 말하기 전에 숨겨진 사고 연쇄에 토큰 예산을 쓰는 것을 막습니다. 답변을 듣기 위해 기다리는 중이라면, 그것은 귀로 느껴지는 시간입니다. 프롬프트 자체도 길이만큼의 값을 합니다. 20단어를 요구하면 답변이 글이 아니라 말처럼 들리고, “문장 중간에 끊기보다 세부 사항을 생략하라”는 지시가 하드 max_tokens 상한이 단어 중간을 잘라먹는 것을 막아줍니다. 마크다운을 금지하는 것은 생각보다 중요합니다: TTS 모델은 별표를 기꺼이 소리 내어 읽습니다.
사용자의 메시지를 신뢰할 수 없는 입력으로 취급하라는 지시는 여기서 실질적인 역할을 합니다. 전사된 음성도 다른 것과 마찬가지로 사용자 입력이며, “이전 지시를 무시해”는 타이핑하는 것만큼이나 소리 내어 말하기도 쉽습니다.
이것이 갖춰지면 호출은 평범한 스트리밍 컴플리션입니다:
중요한 설계 결정은 이 함수가 토큰이 아니라 문장을 내보낸다는 것입니다. TTS가 운율을 제대로 살리려면 완결된 절이 필요하므로, 델타를 하나가 완성될 때까지 버퍼링한 뒤 넘깁니다. 그것이 모델이 아직 말하고 있는 동안 오디오 재생을 시작할 수 있게 하는 요소입니다. 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는 의도적으로 단조롭습니다. 문자열을 다듬고 환경 기본값으로 폴백할 뿐, 목록에 대해 검증하지 않습니다:
알 수 없는 보이스 ID는 API에서 명확한 메시지와 함께 실패하는데, 이것이 Venice가 보이스를 추가할 때마다 조용히 낡아가는 로컬 허용 목록보다 낫습니다. 다만 보이스는 모델별로 다르므로, Kokoro 보이스를 다른 TTS 모델에 쓰면 동작하지 않습니다. 짝은 텍스트-음성 변환 모델을 참조하세요.

재생하기 전에 확인하기

의자에서 벌떡 일어나게 만들 함정이 하나 있습니다. 원시 PCM에는 헤더도 매직 바이트도 없으므로, 오류 응답이 오디오 파이프에 쓰이면 스피커는 그 JSON을 최대 음량의 노이즈 폭발로 충실하게 재생합니다. 그러니 본문을 오디오로 취급하기 전에 상태와 콘텐츠 타입을 확인하고, 뒷받침으로 첫 청크를 스니핑하세요:
RIFF는 WAV 응답을, ID3는 MP3를 잡아내며, 둘 다 response_format이 적용되지 않았다는 뜻입니다. JSON 검사는 오류 본문을 잡아냅니다. 어느 것도 영리하지 않지만, 이 전부가 읽을 수 있는 오류와 놀란 사용자 사이의 차이입니다.
확인하지 않은 HTTP 본문을 원시 오디오 싱크로 절대 흘려보내지 마세요. 재생 쪽에는 여러분을 구해줄 포맷 협상이 없습니다. 도착하는 바이트가 무엇이든 샘플로 재생됩니다.

녹음과 재생

이 부분은 Venice가 아니므로 빠르게 넘어갑니다. audio.py는 사용자가 말하는 동안 PortAudio 입력 스트림을 열고, 답변을 재생하기 위해 PortAudio 출력 스트림을 열며, 둘 다 sounddevice를 통합니다. 네이티브 라이브러리 누락이 시작 시점의 OSError가 아니라 문장이 되도록 지연 임포트합니다:
이 둘은 해결책이 다른 진짜로 서로 다른 두 가지 실패이며, sounddevice는 두 번째를 임포트 자체에서 나온 맨 OSError로 보고합니다. 여기서 둘 다 잡아내는 것이 PortAudio를 전혀 로드할 수 없는 머신에서도 --text-only가 동작하게 하는 요소입니다. 녹음은 리스트에 덧붙이는 콜백이며, 잊힌 세션이 무한정 커지지 않도록 하드 상한을 둡니다:
중첩된 try/finally는 의도적입니다. 안쪽은 취소를 친절한 AudioError로 바꾸고, 바깥쪽은 취소를 포함한 모든 종료 경로에서 스트림을 멈추고 닫습니다. 닫히지 않은 RawInputStream은 턴이 끝난 뒤에도 마이크를 계속 붙잡고 있기 때문입니다. bytes(indata)는 별칭이 아니라 복사입니다. PortAudio가 그 버퍼를 다음 콜백에 재사용하기 때문입니다. 샘플이 디스크에 전혀 닿지 않는다는 점에 주목하세요. /audio/transcriptions는 파일 형태의 업로드를 필요로 하지만, “파일 형태”란 WAV 헤더가 필요하다는 뜻일 뿐이고, 헤더는 메모리에서 붙일 수 있습니다:
누군가의 음성 녹음을 임시 디렉터리에 절대 쓰지 않기 위한 열네 줄이니, 좋은 거래로 보입니다. wave는 표준 라이브러리에 있고, 바이트는 앞에서 설정한 file= 인자로 바로 갑니다. 재생은 답변당 하나의 스트림이므로, 연속된 문장들이 매번 디바이스를 재시작하는 대신 이어지는 연속 발화로 재생됩니다:
_pending 버퍼는 건너뛰면 반드시 물리는 세부 사항입니다. HTTP 청크 경계는 샘플 경계와 아무 상관이 없으므로, 4096바이트 읽기가 홀수 바이트를 건네 16비트 샘플을 반으로 쪼갤 수 있습니다. 그것을 디바이스에 쓰면 이후의 모든 샘플이 바이트 하나씩 밀리는데, 이는 오디오판 지직거림처럼 들립니다. 그래서 우리는 항상 짝수 바이트만 쓰고 남는 바이트 하나를 다음 호출로 넘깁니다. 저장소의 전체 클래스에는 Ctrl+C를 위한 abort()(디바이스를 즉시 멈추고 버퍼된 내용을 버림)와 정상 경로를 위한 close()(마지막 부분 샘플을 0 바이트로 패딩해 플러시한 뒤 디바이스가 이미 가진 내용을 다 재생할 때까지 대기)도 있습니다. 이 둘을 반대로 하면 모든 답변의 마지막 단어가 잘리거나, 답변을 중단할 수 없게 됩니다.
PortAudio가 여기서 이식성 계층이므로, 같은 audio.py가 macOS, Windows, Linux에서 동작합니다. venice.py의 어떤 것도 어느 쪽인지 알지도, 신경 쓰지도 않습니다.

스트림과 재생 겹치기

여기서 스트리밍이 실제로 값을 합니다. 채팅 스트림을 소비하는 것과 오디오 재생을 같은 스레드에서 하면, 재생이 루프를 막고 모델의 남은 토큰들은 소켓 버퍼 안에 읽히지 않은 채 쌓입니다. 그래서 스트림은 사이드 스레드에서 소비하고 문장은 큐로 건넵니다:
예외를 큐에 올리고 소비자 쪽에서 다시 발생시키는 것이 오류 처리를 정직하게 유지하는 방법입니다. 조용히 죽는 백그라운드 스레드는 메시지 대신 행(hang)을 주고, Exception이 아니라 BaseException을 잡기에 스트림 안의 KeyboardInterrupt도 여전히 호출자에게 도달합니다. 이제 턴 자체입니다: 문장을 꺼내고, 각각 출력하고, 그 PCM이 도착하는 대로 플레이어에 공급합니다.
플레이어는 미리 생성하지 않고 오디오 첫 청크에서 지연 생성되므로, TTS 실패가 유휴 출력 스트림이 스피커를 붙잡고 있는 상황을 만들지 않습니다. 그리고 raise_on_error=not failed는 턴이 이미 실패하는 중일 때 진짜 오류 위에 두 번째 오류를 쌓는 대신 재생을 조용히 정리한다는 뜻입니다. 첫 오디오까지의 시간을 출력하는 것은 작지만 튜닝할 때 진짜로 유용한 일입니다. 사용자가 몸으로 느끼는 숫자이기 때문입니다.

프롬프트 루프

남은 것은 전부 input()을 감싼 while True입니다:
빈 줄은 “듣기”를 의미하고, 나머지는 모두 타이핑된 입력으로 취급됩니다. 히스토리는 마지막 여덟 번의 주고받음으로 잘라내는데, 음성 대화에는 충분하며 무언가가 불평할 때까지 커지는 대신 입력 토큰 수를 평평하게 유지해 줍니다. 두 단계의 오류 처리는 짚어둘 만합니다. 설정 실패는 종료합니다. 쓸 수 없는 REPL을 시작할 이유가 없기 때문입니다. 턴별 실패는 출력하고 프롬프트로 돌아갑니다. 레이트 리밋이나 잘못 눌린 녹음이 세션을 끝내서는 안 되기 때문입니다. warmup 호출도 제 몫을 합니다. 모델을 나열하고 단어 하나짜리 TTS 프로브를 보내는데, 이것이 사용자의 첫 실제 턴 도중이 아니라 그 전에 TLS 연결을 수립하고 키와 보이스를 검증합니다:

실행하기

Enter를 누르고, 말하고, 다시 Enter를 누르세요. 마이크를 쓰고 싶지 않다면 한 줄을 타이핑하고, reset으로 새 대화를 시작하고, q로 종료하세요. 답변 도중의 Ctrl+C는 재생을 멈추고 종료하는 대신 프롬프트로 돌려보냅니다. 몇 가지 변형:
잘못된 마이크나 스피커를 잡는다면, PortAudio에게 무엇이 보이는지 물어보고 이름이나 인덱스를 AUDIO_SOURCE / AUDIO_SINK에 넣으세요:
그리고 테스트:

레이턴시에 대해 기대할 것

파이프라인은 순차적인 세 요청이므로 숫자는 대략 이렇게 쌓입니다: 좋은 연결에서는 첫 오디오까지 대략 1초 정도를 기대하세요. 그 숫자를 지배하는 것은 두 가지입니다: TTS가 첫 문장에서 시작하는지 전체 답변을 기다리는지, 그리고 모델이 말하기 전에 생각에 토큰을 태우는지. 문장 단위 스트리밍과 disable_thinking이 여기서 없애면 체감될 두 가지 변화입니다. 더 빠르게 만들고 싶다면 답변을 짧게 유지하고(체감 반응성을 좌우하는 것은 첫 문장입니다) flash급 채팅 모델을 시도해 보세요. 더 자세한 내용은 LiveKit 레이턴시 노트에 있습니다.

프라이버시 노트

이 프로젝트에는 마이크가 들어 있으므로, 무엇이 머신을 떠나는지 분명히 해 둘 가치가 있습니다. 오디오는 전사되기 위해 Venice로 가고 텍스트는 말해지기 위해 돌아옵니다. 둘 다 Venice의 데이터 무보관 정책이 적용되며, 요청 이후 그쪽에는 아무것도 저장되지 않습니다. 로컬에서는 디스크에 아무것도 쓰이지 않습니다. 녹음은 리스트에서 조립되고, 메모리에서 WAV 헤더로 감싸져 요청에 전달되므로, 유출되거나 정리할 임시 파일이 없습니다. API 키는 환경에서 읽히고 절대 출력되지 않습니다. 대화 히스토리는 메모리에만 존재하며 종료하거나 reset을 입력하면 사라집니다. 무보관보다 강한 보장이 필요하다면 모델별 등급은 프라이버시를 참조하세요.

마무리

가져갈 것은 이것입니다: Venice의 음성 에이전트는 OpenAI 호환 엔드포인트 세 개이며, 그중 둘은 스트리밍됩니다. 이 프로젝트의 나머지 모든 것(문장 분할기, 오디오 스트림, 큐)은 그 세 호출이 대화처럼 느껴지도록 하기 위해 존재합니다. venice.py가 훔쳐 갈 만한 부분입니다. app.py를 웹 핸들러나 전화 통합으로 바꿔도 API 계층은 변하지 않습니다. 다음에 해볼 만한 것들:

도구 주기

채팅 단계에 함수 호출을 추가하면 에이전트가 대화 도중에 정보를 찾아볼 수 있습니다.

검색시키기

venice_parameters에서 enable_web_search를 설정하면 답변이 학습 데이터에 갇히지 않게 됩니다.

목소리 복제하기

Kokoro 보이스 ID를 직접 복제한 목소리로 바꿔 보세요.

룸에 넣기

같은 세 단계를 LiveKit에 넘겨 VAD, 발화 끼어들기, 다자간 통화를 처리하세요.
읽어 주셔서 감사합니다! 이 글이 음성 에이전트의 미스터리를 조금이나마 걷어냈기를 바랍니다. 그 아래의 세 요청을 보고 나면, 들리는 것보다 훨씬 덜 이국적입니다.

관련 자료