エージェントに音声機能を追加する
2026-04-15 Sunil Pai Korinne Alpers 7分で読める
多くの人にとって、AIエージェントとの最初の経験はチャットボックスへのテキスト入力を通じたものです。エージェントを日常的に使用している人の中には、詳細なプロンプトやマークダウンファイルを作成してエージェントをガイドすることに非常に長けている人もいるでしょう。しかし、エージェントが最も有用となる場面の多くは、必ずしもテキストファーストではありません。長い通勤中かもしれませんし、複数のセッションを同時に処理しているかもしれません。あるいは単に自然にエージェントに話しかけ、エージェントが話し返し、対話を続けたいだけかもしれません。エージェントに音声機能を追加するために、そのエージェントを別の音声フレームワークに移動させる必要はありません。本日、Agents SDKの実験的な音声パイプラインをリリースします。@cloudflare/voiceを使用すると、既に使用しているのと同じエージェントアーキテクチャに対してリアルタイム音声を追加できます。音声は、Agents SDKが既に提供する同じDurable Object、ツール、永続性、およびWebSocket接続モデルを使用して、同じDurable Objectと対話する別の方法になります。
@cloudflare/voiceについて
@cloudflare/voiceは、Agents SDKの実験的なパッケージで、以下を提供します:
- withVoice(Agent) - 完全な会話音声エージェント用
- withVoiceInput(Agent) - 音声入力のみのユースケース(口述筆記や音声検索など)用
- useVoiceAgent と useVoiceInput フック - Reactアプリ用
- VoiceClient - フレームワークに依存しないクライアント
組み込みWorkers AIプロバイダーが含まれているため、外部APIキーなしで開始できます:
- Deepgram Fluxを使用した継続的なSTT
- Deepgram Nova 3を使用した継続的なSTT
- Deepgram Auraを使用したテキスト音声変換
これにより、単一のWebSocket接続を介してリアルタイムでユーザーが話しかけることができるエージェントを構築できるようになり、同じエージェントクラス、Durable Objectインスタンス、およびSQLiteでサポートされた会話履歴を保持できます。同様に重要なことに、これを1つの固定デフォルトスタックより大きなものにしたいと考えています。@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>;
これがサーバー全体です。継続的なトランスクライバー、テキスト音声変換プロバイダーを追加し、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の音声がその仮定から始まるようにしたかったのです。
音声とテキストが同じ状態を共有
ユーザーはテキスト入力で始まり、音声に切り替わり、テキストに戻る可能性があります。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.string()
})
})
}
});
return result.textStream;
}
}