エージェントに音声を追加する
多くの人にとって、AIエージェントとの最初の体験はチャットボックスへの入力でした。日常的にエージェントを使っている人は、詳細なプロンプトやマークダウンファイルを作成してエージェントを導くのが得意になっているでしょう。しかし、エージェントがもっとも役に立つ場面のいくつかは、常にテキスト優先ではありません。長い通勤中だったり、複数セッションを扱っていたり、ただ自然に話しかけてエージェントに応答してほしい場合もあります。
エージェントに音声機能を追加するのに、エージェントを別のボイスフレームワークに移す必要はありません。本日、Agents SDK向けの実験的な音声パイプラインをリリースします。@cloudflare/voice を使えば、既存のエージェントアーキテクチャにリアルタイム音声を追加できます。音声は、Agents SDKが既に提供する同じDurable Object、同じツール、永続性、WebSocket接続モデルに対する別の入出力手段になるだけです。
@cloudflare/voice は Agents SDK 用の実験的パッケージで、以下を提供します:
withVoice(Agent) — フル会話対応の音声エージェント向け
withVoiceInput(Agent) — 音声入力(議事録や音声検索など)専用ユースケース向けの音声→テキスト
- Reactアプリ用の
useVoiceAgent と useVoiceInput フック
- フレームワーク非依存のクライアント
VoiceClient
- 外部APIキーなしで始められる組み込み Workers AI プロバイダ:
- Continuous STT with Deepgram Flux
- Continuous STT with Deepgram Nova 3
- Text-to-speech with Deepgram Aura
これにより、ユーザーが単一のWebSocket接続でリアルタイムに会話できるエージェントを構築でき、同じ Agent クラス、Durable Object インスタンス、SQLite バックエンドの会話履歴を保持できます。さらに重要なのは、これを単一の固定スタックに限定したくない点です。@cloudflare/voice のプロバイダインターフェースは意図的に小さくしてあり、音声・電話・トランスポートプロバイダが参加できるようにしているため、開発者はユースケースに合わせて部品を組み合わせられます。
音声で始める
以下は 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>;
これがサーバー側の全てです。連続トランスクリプタ(continuous transcriber)とテキスト→音声プロバイダを追加し、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を使っていない場合は、@cloudflare/voice/client の VoiceClient を直接利用できます。
音声パイプラインの仕組み
Agents SDK では、すべてのエージェントは Durable Object — 独自の SQLite データベース、WebSocket 接続、アプリケーションロジックを持つステートフルでアドレス可能なサーバーインスタンス — です。音声パイプラインはこのモデルを置き換えるのではなく拡張します。大まかな流れは次の通りです:
ここからパイプラインを段階的に説明します:
- オーディオ輸送:ブラウザがマイク音声をキャプチャし、同じエージェントが使うWebSocket接続を介して16 kHzモノPCMをストリームします。
- STT セッション設定:通話開始時に、通話時間中存続する連続トランスクリプタセッションがエージェントによって作成されます。
- STT 入力:音声はそのセッションに継続的に流れ込みます。
- STT ターン検出:音声→テキストモデル自体がユーザーの発話終了を判断し、そのターンに対する安定したトランスクリプトを出力します。
- LLM/アプリケーションロジック:音声パイプラインはそのトランスクリプトを
onTurn() メソッドに渡します。
- TTS 出力:応答は音声合成されクライアントへ送信されます。
onTurn() がストリームを返した場合、パイプラインは文単位でチャンク化し、文が準備でき次第合成を開始します。
- 永続化:ユーザーとエージェントのメッセージはSQLiteに永続化されるため、再接続やデプロイ後も会話履歴が保持されます。
なぜ音声はエージェントの他の要素と一緒に成長すべきか
多くの音声フレームワークは音声ループそのもの(オーディオ入力、転写、モデル応答、オーディオ出力)に焦点を当てます。これらは重要なプリミティブですが、エージェントにはそれ以外にも多くの要素があります。本番環境で動く実際のエージェントは成長します。状態管理、スケジューリング、永続化、ツール、ワークフロー、電話連携などが必要になり、それらをチャネル間で一貫して保つ方法が必要です。
エージェントが複雑になると、音声は独立した機能ではなく大きなシステムの一部になります。Agents SDKでは音声を別スタックとして構築するのではなく、同じ Durable Object ベースのエージェントプラットフォーム上に構築しました。これにより、後でアプリケーションを再設計することなく必要なプリミティブを取り込めます。
音声とテキストは同じ状態を共有する
ユーザーは最初にテキスト入力を行い、音声に切り替え、再びテキストに戻るかもしれません。Agents SDK では、これらはすべて同じエージェントへの異なる入力に過ぎません。同じ会話履歴がSQLiteに保存され、同じツールが利用可能です。これにより、より明快なメンタルモデルとシンプルなアプリケーションアーキテクチャが得られます。
レイテンシ削減は… ネットワークパスを短くすることから来る
音声体験は、ユーザーが話し終えた瞬間から良し悪しが決まります。発話が終わってからシステムが転写し、考え、話し返すまでの時間は対話性を感じさせるのに十分速くなければなりません。多くの音声レイテンシは純粋なモデル時間ではなく、異なるサービス間でオーディオやテキストを往復させるコストです。オーディオはSTTへ、トランスクリプトはLLMへ、応答はTTSへ、それぞれの受け渡しがネットワークオーバーヘッドを生みます。
Agents SDK の音声パイプラインでは、エージェントがCloudflareのネットワーク上で動作し、組み込みプロバイダが Workers AI バインディングを使います。これによりパイプラインがより密になり、自分で複数のインフラを繋ぎ合わせる必要が減ります。
組み込みのストリーミング
音声エージェントのやり取りは、最初の文がすばやく再生される(Time-to-First Audio)ほど自然に感じられます。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() に送ります。アクティブな通話中は、応答は音声として話されテキストとしても表示できますし、通話外ではテキストのみでも構いません。これにより、実装を分割することなく真のマルチモーダルエージェントが実現します。
ほかに何が作れるか
音声エージェントは依然としてエージェントなので、通常の Agents 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.str
(元のソースはここで切れています。上記は提供された内容に基づく翻訳とコード例です。)