구독해서 새 게시물에 대한 알림을 받으세요.

에이전트에 음성 추가

2026-04-15

7분 읽기
이 게시물은 English로도 이용할 수 있습니다.

본 콘텐츠는 사용자의 편의를 고려해 자동 기계 번역 서비스를 사용하였습니다. 영어 원문과 다른 오류, 누락 또는 해석상의 미묘한 차이가 포함될 수 있습니다. 필요하시다면 영어 원문을 참조하시기를 바랍니다.

많은 사람의 경우 AI 에이전트를 처음 경험하는 것은 채팅창에 입력하는 것이었습니다. 그리고 에이전트를 매일 사용하는 사람들은 프롬프트를 안내하거나 가이드할 수 있는 가격을 낮추는 파일을 아주 능숙하게 작성할 수 있습니다.

하지만 에이전트가 가장 유용할 때가 있는데도 항상 문자가 우선인 것은 아닙니다. 긴 통근 중이거나 몇 개의 진행 중인 세션을 처리하는 중이거나, 단순히 상담원과 자연스럽게 대화하고, 다시 말하게 하고, 상호 작용을 계속하고 싶을 수 있습니다.

에이전트에게 음성을 추가할 때 해당 에이전트를 별도의 음성 프레임워크로 이동할 필요는 없습니다. 오늘, Agents SDK용 실험적인 음성 파이프라인을 공개할 예정입니다.

@cloudflare/voice를 이용하면, 이미 사용하고 있는 동일한 에이전트 아키텍처에 실시간 음성을 추가할 수 있습니다. 음성은 Agents SDK가 이미 제공하고 있는 동일한 도구, 지속성, WebSocket 연결 모델을 사용하여 동일한 Durable Object와 대화하는 또 다른 방식이 될 뿐입니다. 

@cloudflare/voice 는 다음을 제공하는 Agents SDK를 위한 실험적인 패키지입니다. 

  • 전체 대화 음성에이전트를 위한 withVoice(Agent)

  • 받아쓰기 또는 음성 검색과 같은 음성 텍스트 변환 전용 사용 사례의 경우withVoiceInput(Agent) 

  • React 앱을 위한useVoiceAgentuseVoiceInput 후크 

  • 플레이오프에 구애 받지 않는 클라이언트를 위한 VoiceClient 

  • 기본 제공 Workers AI 공급자로 외부 API 키 없이 시작할 수 있습니다. 

즉, 이제 동일한 Agent 클래스, Durable Object 인스턴스, 동일한 SQLite 지원 대화 이력을 유지하면서 사용자가 단일 WebSocket 연결을 통해 실시간으로 대화할 수 있는 에이전트를 구축할 수 있습니다. 

마찬가지로 중요한 것은 이 스택이 하나의 고정된 기본 스택보다 커야 한다는 점입니다. @cloudflare/voice의 공급자 인터페이스는 의도적으로 작습니다. Cloudflare는 음성, 전화, 전송 공급자가 Cloudflare와 함께 구축하여 개발자가 단일 음성 아키텍처에 종속되는 대신 사용 사례에 적합한 구성 요소를 믹스앤매치할 수 있도록 지원하고자 합니다.

음성으로 시작하기

Agents SDK에서 음성 에이전트에 대한 최소 서버 측 패턴은 다음과 같습니다. 

import { Agent, routeAgentRequest } from "agents";
import {
  withVoice,
  WorkersAIFluxSTT,
  WorkersAITTS,
  type VoiceTurnContext
} from "@cloudflare/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
  transcriber = new WorkersAIFluxSTT(this.env.AI);
  tts = new WorkersAITTS(this.env.AI);

  async onTurn(transcript: string, context: VoiceTurnContext) {
    return `You said: ${transcript}`;
  }
}

export default {
  async fetch(request: Request, env: Env) {
    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  }
} satisfies ExportedHandler<Env>;

서버가 전부입니다. 연속 전사자와 텍스트 음성 변환 공급자를 추가하고 onTurn()을 구현합니다. 클라이언트 측에서는 React 후크를 사용하여 클라이언트에 연결할 수 있습니다. 

import { useVoiceAgent } from "@cloudflare/voice/react";

function App() {
  const {
    status,
    transcript,
    interimTranscript,
    startCall,
    endCall,
    toggleMute
  } = useVoiceAgent({ agent: "my-agent" });

  return (
    <div>
      <p>Status: {status}</p>
      {interimTranscript && <p><em>{interimTranscript}</em></p>}
      <ul>
        {transcript.map((msg, i) => (
          <li key={i}>
            <strong>{msg.role}:</strong> {msg.text}
          </li>
        ))}
      </ul>
      <button onClick={startCall}>Start Call</button>
      <button onClick={endCall}>End Call</button>
      <button onClick={toggleMute}>Mute / Unmute</button>
    </div>
  );
}

React를 사용하고 있지 않다면 VoiceClient@cloudflare/voice/client에서 직접 사용할 수 있습니다. 

음성 파이프라인의 작동 방식

Agents SDK 를 사용하면 모든 에이전트는 자체 SQLite 데이터베이스, WebSocket 연결 및 애플리케이션 로직을 갖춘, 상태를 저장하고 주소를 지정할 수 있는 서버 인스턴스인 Durable Object 입니다. 음성 파이프라인은 이 모델을 대체하는 대신 확장합니다. 

높은 수준에서 흐름은 다음과 같습니다.

BLOG-3198 2

단계별로 파이프라인을 구성하는 방법은 다음과 같습니다.

  1. 오디오 전송: 브라우저가 마이크 오디오를 캡처하고 에이전트가 이미 사용하고 있는 동일한 WebSocket 연결을 통해 16kHz 모노 PCM을 스트리밍합니다.

  2. STT 세션 설정: 통화가 시작되면 상담원이 통화 지속 시간 동안 지속되는 전사 세션을 생성합니다. 

  3. STT 입력: 오디오가 해당 세션으로 지속적으로 스트리밍됩니다.

  4. STT 차례 감지: 음성 텍스트 변환 모델 자체에서 사용자가 발화를 끝낸 시간을 결정하고 해당 차례에 대한 안정적인 스크립트를 내보냅니다. 

  5. LLM/애플리케이션 로직: 음성 파이프라인은 해당 스크립트를 onTurn() 메서드에 전달합니다.

  6. TTS 출력: 응답은 오디오로 합성되어 클라이언트에게 다시 전송됩니다. onTurn() 이 스트림을 반환하면 파이프라인은 이를 문장 분할하고 문장이 준비되면 오디오를 전송하기 시작합니다. 

  7. 지속성: 사용자 및 에이전트 메시지가 SQLite에 유지되므로 재연결 및 배포 후에도 대화 기록이 유지됩니다.  

나머지 에이전트와 함께 목소리를 높여야 하는 이유

많은 음성 프레임워크는 오디오 입력, 전사, 모델 응답, 오디오 출력 등 음성 루프 자체에 중점을 둡니다. 기본적인 사항도 중요하지만, 에이전트에게는 단순한 음성 이상의 의미가 있습니다. 

프로덕션에서 실행되는 실제 에이전트는 증가할 것입니다. 조직에서는 상태, 일정, 지속성, 도구, 워크플로우, 전화 통신, 이 모든 것을 채널 간에 일관되게 유지할 수 있는 방법을 필요로 합니다. 에이전트가 점점 더 복잡해짐에 따라 음성은 독립 실행형 기능으로 더 이상 존재하지 않으며, 이제는 더 큰 시스템의 일부가 되었습니다. 

우리는 Agents SDK의 목소리가 이러한 가정에서 시작되기를 원했습니다. 음성을 별도의 스택으로 구축하는 대신 동일한 Durable Object 기반 에이전트 플랫폼 위에 음성을 구축했으므로 나중에 애플리케이션을 다시 설계하지 않고도 필요한 나머지 기본 요소를 가져올 수 있습니다.

동일한 상태를 공유하는 음성 및 텍스트

사용자는 입력으로 시작했다가 음성으로 전환하고, 다시 텍스트로 돌아갈 수 있습니다. Agents SDK에서는 모두 동일한 에이전트에 대한 서로 다른 입력일 뿐입니다. 동일한 대화 기록이 SQLite에 있고, 동일한 도구가 제공됩니다. 이렇게 하면 추론할 수 있는 더욱 명확한 멘탈 모델과 훨씬 단순한 애플리케이션 아키텍처를 얻을 수 있습니다. 

더 짧은 대기 시간은...

더 짧은 네트워크 경로 

음성 경험은 좋거나 나쁘게 느낍니다. 사용자가 말하기를 중단하면 시스템은 대화를 하고 있다고 느낄 수 있을 만큼 빠르게 스크립트를 작성하고, 생각하고, 다시 말하기 시작해야 합니다. 

대기 시간이 많으면 순수한 모델 시간이 아닙니다. 이는 다양한 위치에 있는 다양한 서비스 간에 오디오와 텍스트가 바운스되는 비용입니다. 오디오는 STT로 이동하고, 전사는 LLM으로 이동하며, 응답은 TTS 모델로 이동해야 하며, 핸드오프할 때마다 네트워크 오버헤드가 추가됩니다. 

Agents SDK 음성 파이프라인을 사용하면 에이전트가 Cloudflare의 네트워크에서 실행되고 기본 제공 공급자는 Workers AI 바인딩을 사용합니다. 따라서 파이프라인이 더 긴밀하게 유지되고 직접 연결해야 하는 인프라의 양이 줄어듭니다. 

기본 제공 스트리밍

음성 에이전트 상호 작용이 첫 번째 문장을 빠르게 말하면 훨씬 더 자연스럽게 느껴집니다(첫 번째 문장 오디오 시작 시간이라고도 함). onTurn() 이 스트림을 반환하면 파이프라인은 이를 문장으로 분할하고 문장이 완성되면 합성을 시작합니다. 즉, 사용자는 나머지 부분이 생성되는 중에 답변의 시작 부분을 들을 수 있습니다. 

더욱 현실적인 백엔드 

다음은 LLM 응답을 스트리밍하고 문장별로 다시 말하기 시작하는 전체 예제입니다.

import { Agent, routeAgentRequest } from "agents";
import {
  withVoice,
  WorkersAIFluxSTT,
  WorkersAITTS,
  type VoiceTurnContext
} from "@cloudflare/voice";
import { streamText } from "ai";
import { createWorkersAI } from "workers-ai-provider";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
  transcriber = new WorkersAIFluxSTT(this.env.AI);
  tts = new WorkersAITTS(this.env.AI);

  async onTurn(transcript: string, context: VoiceTurnContext) {
    const ai = createWorkersAI({ binding: this.env.AI });

    const result = streamText({
      model: ai("@cf/cloudflare/gpt-oss-20b"),
      system: "You are a helpful voice assistant. Be concise.",
      messages: [
        ...context.messages.map((m) => ({
          role: m.role as "user" | "assistant",
          content: m.content
        })),
        { role: "user" as const, content: transcript }
      ],
      abortSignal: context.signal
    });

    return result.textStream;
  }
}

export default {
  async fetch(request: Request, env: Env) {
    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  }
} satisfies ExportedHandler<Env>;

Context.messages는 최근 SQLite 기반 대화 기록을 제공하며, context.signal은 사용자가 중단하는 경우 파이프라인이 LLM 호출을 중단할 수 있도록 합니다. 

음성 입력: withVoiceInput

모든 음성 인터페이스가 다시 말해야 하는 것은 아닙니다. 받아쓰기, 전사, 음성 검색이 필요한 경우도 있습니다. 이러한 사용 사례에는 withVoiceInput을 사용할 수 있습니다

import { Agent, type Connection } from "agents";
import { withVoiceInput, WorkersAINova3STT } from "@cloudflare/voice";

const InputAgent = withVoiceInput(Agent);

export class DictationAgent extends InputAgent<Env> {
  transcriber = new WorkersAINova3STT(this.env.AI);

  onTranscript(text: string, _connection: Connection) {
    console.log("User said:", text);
  }
}

클라이언트에서 useVoiceInput은 전사 중심의 가벼운 인터페이스를 제공합니다. 

import { useVoiceInput } from "@cloudflare/voice/react";

const { transcript, interimTranscript, isListening, start, stop, clear } =
  useVoiceInput({ agent: "DictationAgent" });

이는 음성이 입력 방법이고 전체 대화 루프가 필요하지 않을 때 유용합니다. 

동일한 연결에서의 음성 및 문자 메시지

동일한 클라이언트가 sendText(“What’s the weather?”)를 호출할 수 있으며, 이는 STT를 우회하고 텍스트를 onTurn()에 직접 전송합니다. 통화 중에 응답을 음성으로 보내고 텍스트로 표시할 수 있습니다. 통화 외에는 텍스트 전용으로 유지될 수 있습니다. 

이렇게 하면 다른 코드 경로로 구현을 분할하지 않고도 진정한 멀티모달 에이전트를 얻을 수 있습니다. 

그 밖에 무엇을 구축할 수 있을까요? 

음성 에이전트는 여전히 에이전트이므로 모든 일반 에이전트 SDK 기능이 계속 적용됩니다. 

도구 및 일정

세션이 시작되면 다음과 같이 발신자에게 인사할 수 있습니다. 

import { Agent, type Connection } from "agents";
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
  transcriber = new WorkersAIFluxSTT(this.env.AI);
  tts = new WorkersAITTS(this.env.AI);

  async onTurn(transcript: string) {
    return `You said: ${transcript}`;
  }

  async onCallStart(connection: Connection) {
    await this.speak(connection, "Hi! How can I help you today?");
  }
}

다른 에이전트와 마찬가지로 음성 알림을 예약하고 도구를 LLM에 노출할 수 있습니다. 

import { Agent } from "agents";
import {
  withVoice,
  WorkersAIFluxSTT,
  WorkersAITTS,
  type VoiceTurnContext
} from "@cloudflare/voice";
import { streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import { z } from "zod";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
  transcriber = new WorkersAIFluxSTT(this.env.AI);
  tts = new WorkersAITTS(this.env.AI);

  async speakReminder(payload: { message: string }) {
    await this.speakAll(`Reminder: ${payload.message}`);
  }

  async onTurn(transcript: string, context: VoiceTurnContext) {
    const ai = createWorkersAI({ binding: this.env.AI });

    const result = streamText({
      model: ai("@cf/cloudflare/gpt-oss-20b"),
      messages: [
        ...context.messages.map((m) => ({
          role: m.role as "user" | "assistant",
          content: m.content
        })),
        { role: "user" as const, content: transcript }
      ],
      tools: {
        set_reminder: tool({
          description: "Set a spoken reminder after a delay",
          inputSchema: z.object({
            message: z.string(),
            delay_seconds: z.number()
          }),
          execute: async ({ message, delay_seconds }) => {
            await this.schedule(delay_seconds, "speakReminder", { message });
            return { confirmed: true };
          }
        })
      },
      abortSignal: context.signal
    });

    return result.textStream;
  }
}

런타임 모델 전환

또한 음성 파이프라인을 사용하면 연결별로 트랜스크립션 모델을 동적으로 선택할 수 있습니다. 

예를 들어 대화형 순서 변경에는 Flux를, 정확도가 더 높은 받아쓰기에는 Nova 3을 선호할 수 있습니다. createTranscriber()를 재정의하여 런타임에 전환할 수 있습니다. 

import { Agent, type Connection } from "agents";
import {
  withVoice,
  WorkersAIFluxSTT,
  WorkersAINova3STT,
  WorkersAITTS,
  type Transcriber
} from "@cloudflare/voice";

export class MyAgent extends VoiceAgent<Env> {
  tts = new WorkersAITTS(this.env.AI);

  createTranscriber(connection: Connection): Transcriber {
    const url = new URL(connection.url ?? "http://localhost");
    const model = url.searchParams.get("model");
    if (model === "nova-3") {
      return new WorkersAINova3STT(this.env.AI);
    }
    return new WorkersAIFluxSTT(this.env.AI);
  }
}

클라이언트에서는 후크를 통해 쿼리 매개변수를 전달할 수 있습니다. 

const voiceAgent = useVoiceAgent({
  agent: "my-voice-agent",
  query: { model: "nova-3" }
});

파이프라인 후크

다음과 같이 단계 간에 데이터를 가로챌 수도 있습니다. 

  • afterTranscribe(스크립트, 연결)

  • beforeSynthesize(text, connection)

  • afterSynthesize(audio, text, connection)

이러한 후크는 콘텐츠 필터링, 텍스트 정규화, 언어별 변환 또는 사용자 지정 로깅에 유용합니다. 

전화 및 전송 옵션

기본적으로 음성 파이프라인은 1:1 음성 에이전트를 위한 가장 간단한 경로로 단일 WebSocket 연결을 사용합니다. 하지만 이것이 유일한 옵션은 아닙니다. 

Twilio를 통한 전화 통화

Twilio 어댑터를 사용하여 전화 통화를 동일한 에이전트에 연결할 수 있습니다.

import { TwilioAdapter } from "@cloudflare/voice-twilio";

export default {
  async fetch(request: Request, env: Env) {
    if (new URL(request.url).pathname === "/twilio") {
      return TwilioAdapter.handleRequest(request, env, "MyAgent");
    }

    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  }
};

따라서 동일한 에이전트가 웹 음성, 텍스트 입력, 전화 통화를 처리할 수 있습니다. 

한 가지 주의할 점은 기본 Workers AI TTS 공급자는 MP3를 반환하는 반면, Twilio는 Mulaw 8Khz 오디오를 기대한다는 점입니다. 프로덕션 텔레포니의 경우, PCM 또는 Mulaw를 직접 출력하는 TTS 공급자를 사용하는 것이 좋을 수 있습니다. 

WebRTC

어려운 네트워크 상태에 더 적합하거나, 다수의 참여자를 포함할 전송이 필요하다면, 음성 패키지에 SFU 유틸리티가 포함되어 있고 사용자 지정 전송도 지원됩니다. 기본 모델은 현재 WebSocket 네이티브이지만, 글로벌 SFU 인프라에 연결하기 위해 더 많은 어댑터를 개발할 계획입니다. 

Cloudflare와 함께 구축하기 

음성 파이프라인은 설계상 공급자에 구애받지 않습니다. 

내부적으로 각 단계는 작은 인터페이스로 정의됩니다. 전사자는 연속 세션을 열고 오디오 프레임이 도착함에 따라 수락하는 반면, TTS 공급자는 텍스트를 가져와 오디오를 반환합니다. 공급자가 오디오 출력을 스트리밍할 수 있다면 파이프라인도 이를 사용할 수 있습니다.

interface Transcriber {
  createSession(options?: TranscriberSessionOptions): TranscriberSession;
}

interface TranscriberSession {
  feed(chunk: ArrayBuffer): void;
  close(): void;
}

interface TTSProvider {
  synthesize(text: string, signal?: AbortSignal): Promise<ArrayBuffer | null>;
}

우리는 Agents SDK의 음성 지원이 모델과 전송의 고정된 조합에서만 작동하는 것을 원하지 않았습니다. 저희는 기본 경로가 단순하면서도 생태계가 성장함에 따라 다른 공급자를 쉽게 연결할 수 있기를 원했습니다. 

기본 제공 공급자는 Workers AI를 사용하므로 외부 API 키 없이 시작할 수 있습니다.

  • 대화형 스트리밍STT를 위한 WorkersAIFuxSTT

  • 받아쓰기 스타일 스트리밍 STT를 위한WorkersAIova3STT

  • 텍스트 음성 변환을 위한 WorkersAITS

하지만 더 큰 목표는 상호 운용성입니다. 음성 또는 음성 서비스를 유지하는 경우 이러한 인터페이스는 나머지 SDK 내부 사항을 이해하지 않고도 구현할 수 있을 만큼 충분히 작습니다. 여러분의 STT 공급자가 스트리밍 오디오를 허용하고 발화 경계를 감지할 수 있다면 전사 인터페이스를 충족할 수 있습니다. TTS 공급자가 오디오 출력을 스트리밍할 수 있다면 더욱 좋습니다. 

다음 제품과 상호 운용성을 위해 노력하고 싶습니다.

  • AssemblyAI, Rev.ai, Speechmatics 또는 실시간 전사 API를 사용하는 서비스와 같은 STT 공급자

  • TTS 공급자(예: PlayHT, LMNT, Cartesia, Coqui, Amazon Polly, Google Cloud TTS)

  • Vonage, Telnyx, Bandwidth 등의 플랫폼용 전화 어댑터

  • WebRTC 데이터 채널, SFU 브리지 및 기타 오디오 전송 계층을 위한 전송 구현

저희는 개별 공급자를 넘어서는 협업에도 관심이 있습니다.

  • STT + LLM + TTS 조합에 대한 대기 시간 벤치마킹

  • 영어가 아닌 음성 에이전트를 위한 다국어 지원 및 개선된 문서

  • 접근성 작업, 특히 멀티모달 인터페이스 및 언어 장애 관련

음성 인프라를 구축하고 있으며 최고 수준의 통합을 확인하고 싶다면 PR을 열거나 문의해 주세요.

지금 사용해 보세요

음성 파이프라인은 오늘부터 실험적인 패키지로 제공됩니다.

npm create cloudflare@latest -- --template cloudflare/agents-starter

@cloudflare/voice를 추가하고, 에이전트에 전사기와 TTS 공급자를 제공하고 배포한 다음 대화를 시작하세요. API 참조를 읽어볼 수도 있습니다.

흥미로운 것을 구축했다면 github.com/cloudflare/agents에서 이슈를 제기하거나 PR을 보내주세요. 음성에는 별도의 스택이 필요하지 않아야 하며, Cloudflare는 최고의 음성 에이전트가 다른 모든 제품과 마찬가지로 내구성 있는 애플리케이션 모델에 구축된 것이라고 생각합니다.

BLOG-3198 3

Cloudflare에서는 전체 기업 네트워크를 보호하고, 고객이 인터넷 규모의 애플리케이션을 효과적으로 구축하도록 지원하며, 웹 사이트와 인터넷 애플리케이션을 가속화하고, DDoS 공격을 막으며, 해커를 막고, Zero Trust로 향하는 고객의 여정을 지원합니다.

어떤 장치로든 1.1.1.1에 방문해 인터넷을 더 빠르고 안전하게 만들어 주는 Cloudflare의 무료 애플리케이션을 사용해 보세요.

더 나은 인터넷을 만들기 위한 Cloudflare의 사명을 자세히 알아보려면 여기에서 시작하세요. 새로운 커리어 경로를 찾고 있다면 채용 공고를 확인해 보세요.
Cloudflare WorkersAIAgents Week개발자에이전트Durable Objects

X에서 팔로우하기

Sunil Pai|@threepointone
Cloudflare|@cloudflare

관련 게시물