
シェア:
Benjamin AronovはVonageの開発者支援者です。彼はRuby on Railsのバックグラウンドを持つ実績のあるコミュニティ・ビルダーです。Benjaminは故郷であるテルアビブのビーチを楽しんでいる。テルアビブを拠点に、世界最高のスタートアップの創設者たちと出会い、学ぶことができる。技術以外では、完璧なパン・オ・ショコラを求めて世界中を旅するのが好き。
Vonage + Deepgram Voice 音声エージェントにツールと人材の移行を追加する
所要時間:2 分
はじめに
『 Vonage Voice API と Deepgram のガイド は、驚くほど役に立ちます。
このガイドを読み終える頃には、Vonageの番号に電話をかけ、AIエージェントと会話し、話している最中に割り込みを行い、Deepgramの Voice Agent APIによって実現される自然な音声会話を行うことができるようになります。
しかし、そのエージェントには(ツールがないため)、実際にはあまりお役に立てません。
エージェントに何らかのアクションを実行するよう依頼しても、そのアクションに関する情報がプロンプトに含まれていない限り、エージェントは情報を得る場所がありません。別のシステムに依存するタスクをエージェントに与えると、新たな問題が発生します。つまり、そのシステムからの応答を待つ間、呼び出し元は待機することになります。
Voiceを使えば、そうした細かな点もすぐに気づくことができます。API呼び出しが遅い場合、ローディング中のスピナーが表示されるのではなく、音として「沈黙」が聞こえてくるのです。
AIエージェントにおけるこの音声特有の問題については、以下の記事で紹介されています。 『遅延をループさせない:Voice AIにおけるエージェントループの適切な位置づけ』で取り上げられました。この記事で提唱されているアイデアの一つは、実際の電話通話中に発生する処理を最小限かつ予測可能な範囲に抑えることです。
この記事の目的は、会話型のデモを、有用な作業を実行でき、予測可能な形で失敗し、そして何よりも重要なこととして、後で改善するための十分な情報を残せるエージェントへと発展させることです。このチュートリアルでは、既存のVonage + Deepgramエージェントを拡張し、以下のことができるようにします:
エージェントに注文状況を確認するためのツールを1つ提供する
検索処理に厳密なタイムアウトを設定する
呼び出し元を待機させたままにせずに、障害に対処する
エージェントが対応できない転送リクエストを実際の電話番号に転送する
各通話中に何が起きたかを記録として保存する
要約 クイックスタートは GitHubで公開されています。
前提条件
必要なものは以下の通りです:
「Voice」機能が有効なVonageアプリケーション
そのアプリケーションに紐付けられたVonageの電話番号
人間によるサポート先として利用できる2つ目の電話番号
A Deepgramアカウント Voice Agentへのアクセス権限を持つDeepgramアカウント
エージェントにツールを提供しましょう
ユースケースはあえてシンプルに、「注文状況の確認」とします。呼び出し側は次のように言います:
「注文番号A1001はどこにありますか?」
Deepgramは、エージェントが外部情報を必要としていることを認識し、当社のApplicationsに対して以下の呼び出しを行うよう要求します:
getOrderStatus("A1001")
当社のアプリケーションは検索を実行し、その結果をDeepgramに返します。その後、モデルがその結果を自然な音声応答に変換します。
このチュートリアルでは、注文のバックエンドはモック化されています。後で、ご自身のデータベースやAPIに置き換えることもできます。ここでは、成功したり、失敗したり、あるいは処理に時間がかかりすぎたりする可能性がある検索機能があれば十分です:
// src/tools/order-status.ts
type OrderResult =
| { status: "in_transit"; estimatedDelivery: string }
| { status: "delivered"; deliveredAt: string }
| { status: "not_found" }
| { status: "error"; reason: string };
const KNOWN_ORDERS: Record<string, OrderResult> = {
A1001: {
status: "in_transit",
estimatedDelivery: "2026-07-29"
},
A1002: {
status: "delivered",
deliveredAt: "2026-07-24T16:42:00Z"
}
};また、テスト用の特別注文IDがいくつかあります:
SLOW で始まる ID の返却には 2 秒かかります。
FAIL で始まる ID は、一時的なネットワーク障害をシミュレートします。
それ以外の場合はすべて not_found が返されます。
これにより、このチュートリアル用に完全なバックエンドを構築することなく、実際のデータベースや外部APIから最終的に得られるような動作を再現することができます。
Deepgramにツールを登録する
DeepgramのVoice Agent APIは、 クライアントサイドでの関数呼び出しをサポートしています。この関数は、エージェントの「設定」メッセージ内で次のように記述します:
// src/agent/agent-config.ts
const GET_ORDER_STATUS_FUNCTION = {
name: "getOrderStatus",
description:
"Look up the current shipping status of a single order by its order number.",
parameters: {
type: "object",
properties: {
orderId: {
type: "string",
description: 'The caller\'s order number, for example "A1001".'
}
},
required: ["orderId"]
}
};Deepgramには、この関数用のHTTPエンドポイントを指定しません。その代わりに、DeepgramがWebSocket経由でFunctionCallRequestを送信します。つまり、ツールの実際の実行は、引き続き当アプリケーションが制御することになります。
これは、次のように判断できるという点で役立ちます:
そのツールが許可されているかどうか
どのくらいの時間稼働できるか
失敗した場合に再試行すべきかどうか
うまくいかなかった場合、発信者にどのようなメッセージが流れるか
エージェントへのプロンプトも同様に限定的です:
// src/agent/agent-config.ts (SYSTEM_PROMPT constant)
You are an order-status voice agent for a live phone call.
You may ONLY help the caller check the status of an order.
Rules:
- Ask for the caller's order number if they have not provided it.
- Call getOrderStatus exactly once with the order number.
- You can look up only one order per call.
- If the result is clear, read it back in one or two natural spoken sentences.
- If the lookup fails or times out, use the fallback response.
- If the caller asks about billing, returns, disputes, or anything else,
use the handoff response immediately.
- Do not answer general questions.
- Do not make up information.プロンプトはモデルに何をすべきかを指示しますが、私たちはプロンプトだけに頼っているわけではありません。注文照会を実行する前に、アプリケーションはリクエストが対象範囲内であることと、その呼び出しに対してツールがまだ使用されていないことを確認します。この仕組みについては、「関数呼び出しの処理」のセクションでご確認いただけます。
検索に期限を設ける
ここで、電話対応向けのシステム構築は、通常のチャットボットの構築とは異なってきます。注文処理の応答に6秒かかると想像してみてください。サーバーの観点から見れば、それは単に処理が遅いリクエストに過ぎません。
電話をかけた側の立場から言えば、質問をした後、6秒間の沈黙が続いた。
この例では、ルックアップに1500ミリ秒が割り当てられています:
// src/agent/tool-policy.ts
export const TOOL_TIMEOUT_MS = 1500; ラッパーは、その期限を目安にバックエンドへの呼び出しを競合させます:
// src/tools/order-status.ts
export async function lookupOrderStatus(
orderId: string,
timeoutMs: number = TOOL_TIMEOUT_MS
): Promise<OrderLookupOutcome> {
const startedAt = Date.now();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<"__timeout__">((resolve) => {
timer = setTimeout(() => resolve("__timeout__"), timeoutMs);
});
try {
const raced = await Promise.race([
getOrderStatus(orderId),
timeout
]);
if (raced === "__timeout__") {
return {
kind: "timeout",
durationMs: Date.now() - startedAt
};
}
return {
kind: "result",
result: raced,
durationMs: Date.now() - startedAt
};
} catch (err) {
return {
kind: "transport_error",
error: new TransportError(
err instanceof Error ? err.message : "unknown tool failure"
),
durationMs: Date.now() - startedAt
};
} finally {
if (timer) clearTimeout(timer);
}
}重要な点は、3つの結果が考えられるということです:
結果 | その意味 | 私たちの活動 |
結果 | バックエンドから応答がありました | 結果を返す |
タイムアウト | 1500ミリ秒以内に応答がありませんでした | フォールバックを使用する |
transport_error | 接続に失敗しました | もう一度試してみてください |
not_found レスポンスであっても、検索は成功したものとみなされます。バックエンドからは応答がありましたが、注文が見つからなかっただけです。
それはタイムアウトとは異なります。
通話中に起こりうる事態を制限する
この例では、ルールを意図的に最小限に抑えています:
// src/agent/tool-policy.ts
export const MAX_TOOL_CALLS = 1;
export const MAX_RETRIES = 1;
export const ALLOWED_TOOLS = ["getOrderStatus"] as const; 電話の着信ごとに、独自のポリシーインスタンスが作成されます:
// src/agent/tool-policy.ts
export function createToolPolicy() {
let callsMade = 0;
let retriesUsed = 0;
return {
authorizeCall(toolName: string) {
if (toolName !== "getOrderStatus") {
return { allowed: false };
}
if (callsMade >= MAX_TOOL_CALLS) {
return { allowed: false };
}
return { allowed: true };
},
recordCall() {
callsMade += 1;
},
authorizeRetry(outcome: OrderLookupOutcome) {
if (outcome.kind !== "transport_error") {
return { allowed: false };
}
if (retriesUsed >= MAX_RETRIES) {
return { allowed: false };
}
return { allowed: true };
},
recordRetry() {
retriesUsed += 1;
}
};
}タイムアウトが発生した場合、再試行は行われません。
すでに1.5秒待っています。同じ処理に時間がかかるリクエストをもう一度実行しても、また1.5秒間何も起こらないだけかもしれません。
接続の切断や一時的なアップストリームの問題などはすぐに回復する可能性があるため、転送エラーが発生した場合、1回の再試行が行われます。
関数呼び出しの処理
DeepgramがFunctionCallRequestを送信すると、WebSocketハンドラーはツールを実行する前に、それらのルールを適用します。
主な流れは以下の通りです:
// src/voice/websocket-handler.ts
async function handleFunctionCall(fn: DeepgramFunctionCall) {
// Is this request supported?
const handoffReason =
classifyHandoffReason(lastCallerUtterance);
if (handoffReason) {
requestHandoff(handoffReason);
respondToFunction(
fn,
JSON.stringify({ status: "handoff" })
);
return;
}
// Has the tool already been called?
const auth = policy.authorizeCall(fn.name);
if (!auth.allowed) {
fallbackUsed = true;
inject(FALLBACK_RESPONSE);
respondToFunction(
fn,
JSON.stringify({ status: "blocked" })
);
return;
}
policy.recordCall();
// Run the lookup
let outcome = await lookupOrderStatus(orderId);
// Retry one transport failure
if (
outcome.kind === "transport_error" &&
policy.authorizeRetry(outcome).allowed
) {
policy.recordRetry();
outcome = await lookupOrderStatus(orderId);
}
// Return a successful result
if (outcome.kind === "result") {
respondToFunction(
fn,
JSON.stringify(outcome.result)
);
return;
}
// Otherwise use the fallback
fallbackUsed = true;
inject(FALLBACK_RESPONSE);
}実際のハンドラもツールの呼び出しとタイミングを記録しますが、呼び出し側がどのように体験するかを制御するのはこの部分です。
固定のフォールバックを使用する
検索に失敗した場合、モデルが独自に復旧策を考案してしまうようなことは避けたいものです。
このアプリケーションは、固定の応答を挿入します:
// src/agent/fallback-responses.ts
export const FALLBACK_RESPONSE =
"I'm having trouble retrieving that order right now. " +
"Please try again later, or contact our support team and they can help.";DeepgramのInjectAgentMessageを使えば、その応答を会話の中に直接挿入することができます:
// src/voice/websocket-handler.ts
sendJson(dg, {
type: "InjectAgentMessage",
message: FALLBACK_RESPONSE,
behavior: "interrupt"
}); ここでは、割り込みの挙動が重要になります。
デフォルトの動作を使用すると、呼び出し側が話している最中に、挿入されたメッセージが拒否されてしまう可能性があります。挿入されたメッセージがフェイルセーフの経路となっている場合、そのような事態に気づくのは避けたいところです。
また、フィールド名にも注意してください。それは「message」であり、「content」ではありません。
どちらも些細な点ですが、こうしたことが原因で、理由がほとんどわからないまま通話がつながらなくなってしまうことがあります。
対応できないリクエストを人間に転送する
注文状況担当者は、あらゆる質問に答えようとしてはいけません。
もし誰かがこう言ったとしたら:
「請求内容について異議を申し立てたいのですが。」
これを「請求依頼」として分類します:
// src/agent/tool-policy.ts
export type HandoffReason =
| "billing"
| "returns"
| "cancellation"
| "account"
| "unsupported"; このデモでは、分類は意図的に制約が設けられ、決定論的なものとなっています:
// src/agent/tool-policy.ts
const HANDOFF_KEYWORDS = [
["cancellation", ["cancel", "cancellation"]],
["returns", ["return", "returns", "refund", "exchange"]],
[
"billing",
["billing", "invoice", "charge", "payment", "dispute"]
],
["account", ["account", "password", "login", "access"]]
];ここでは2回目のモデル募集はありません。
もし発信者から、担当者の業務範囲外の依頼があった場合、私たちは毎回同じ対応をとるべきだと分かっています。それは、その電話を別の担当者に転送することです。
// src/voice/websocket-handler.ts
function requestHandoff(reason: HandoffReason): void {
if (handoffRequested) return;
handoffRequested = true;
handoffReason = reason;
void transferToHuman(callUuid, reason).catch((err) => {
console.error[transfer] failed: ${String(err)});
});
} transferToHuman() は、アクティブな Vonage 通話を新しい NCCO で更新します。
NCCOはまず、電話をかけてきた相手に状況を説明し、その後、サポート番号に転送します:
// src/voice/transfer-to-human.ts
const ncco = [
{
action: "talk",
text: "That's something our support team handles directly. Let me connect you now.",
language: "en-US"
},
{
action: "connect",
from: process.env.VONAGE_NUMBER,
endpoint: [
{
type: "phone",
number: process.env.SUPPORT_PHONE_NUMBER
}
]
}
];次に、アクティブな通話を転送します:
// src/voice/transfer-to-human.ts
await fetch(
https://api.nexmo.com/v1/calls/${callUuid},
{
method: "PUT",
headers: {
Authorization: Bearer ${jwt},
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "transfer",
destination: {
type: "ncco",
ncco
}
})
}
);ローカルテストを行う場合、SUPPORT_PHONE_NUMBER には、手元にある別の電話番号を指定するだけで構いません。
ある電話から自分のVonageの番号に電話をかけ、請求額について異議を申し出ると、もう一方の電話が鳴るはずです。
AIに、そもそもアクセス権限すら与えられていない請求の問題を解決できるふりをさせるよりも、そのほうがはるかに良い代替策だ。
転送の設定
実際のハンドオフを機能させるには、環境に以下の値を追加してください:
# .env.example
VONAGE_APP_ID=your_application_id
VONAGE_PRIVATE_KEY_PATH=./vonage_private.key
VONAGE_NUMBER=15551234567
SUPPORT_PHONE_NUMBER=15557654321また、既存の設定も必要になります:
# .env.example
DEEPGRAM_API_KEY=your_deepgram_key
BASE_URL=https://your-ngrok-url.ngrok-free.app
DB_PATH=./calls.db
AGENT_VERSION=order-status-v1 Vonageおよびサポートの電話番号には、E.164形式の番号を使用してください。
通話中のやり取りを保存する
また、このアプリケーションは、呼び出しが終了した際に、SQLiteに小さなレコードを書き込みます。
これは注文データベースではありません。音声対話そのものの記録です。
-- src/storage/db.ts
CREATE TABLE IF NOT EXISTS call_records (
call_id TEXT PRIMARY KEY,
agent_version TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
transcript TEXT NOT NULL,
tool_calls TEXT NOT NULL,
latency TEXT NOT NULL,
fallback_used INTEGER NOT NULL,
handoff_requested INTEGER NOT NULL,
handoff_reason TEXT,
outcome TEXT NOT NULL
);レコードからは、次のようなことがわかります:
電話の相手と担当者の発言内容
どのツールが呼び出されたか
どれくらい時間がかかったか
フォールバックが使用されたかどうか
発信者が転送されたかどうか
なぜ移籍が実現したのか
電話がどのように終わったか
たとえば、請求に関する電話のやり取りは、おおよそ次のような流れになるでしょう:
{
"agentVersion": "order-status-v1",
"fallbackUsed": false,
"handoffRequested": true,
"handoffReason": "billing",
"outcome": "handoff"
} たとえその周りで複雑な評価システムを構築することがなかったとしても、それは役に立ちます。
同じ理由で通話者が転送され続けている場合は、その状況を確認できます。タイムアウトが増加し始めた場合も、同様に確認できます。また、後でプロンプトやモデル、ツールを変更した場合でも、「agent_version」フィールドを使えば、どのバージョンがどの通話を処理したかを簡単に把握できます。
エージェントの実行方法
まずngrokを起動します:
ngrok http 3000 そのHTTPS URLを、BASE_URLおよびVonageアプリケーションの「Answer」と「Event」のWebhookに設定してください。
次に、アプリケーションを起動します:
npm run dev電話をかける前に、回答ウェブフックが正しく動作しているか確認できます:
curl "https://YOUR-NGROK-URL/answer?uuid=test123&from=15551234567" 返されたNCCOには、次のようなWebSocketエンドポイントが含まれているはずです:
wss://YOUR-NGROK-URL/socket?callUuid=test123
「4回の通話」を試してみてください
主要な動作を、それぞれ個別の電話通話としてテストしてみてください。
電話 | 何と言えばいいだろう | どうあるべきか |
1 | 「注文番号A1001はどこにありますか?」 | エージェントが配送状況を読み上げる |
2 | 「注文番号SLOW999はどこですか?」 | 検索がタイムアウトすると、あらかじめ設定された代替再生が開始される |
3 | 「XYZ123という注文はどこにありますか?」 | エージェントから、その注文が見つからないと伝えられます |
4 | 「請求内容について異議を申し立てたいのですが。」 | 通話はお手元のサポート用電話に転送されます |
それぞれを新しい呼び出しとして実行してください。この例では、1回の呼び出しにつき1件の注文検索のみ可能です。
その後、記録を確認してください:
sqlite3 calls.db \
"SELECT
call_id,
agent_version,
outcome,
fallback_used,
handoff_requested,
handoff_reason
FROM call_records
ORDER BY started_at DESC
LIMIT 4;" データには、さまざまな経路が示されているはずです:
完了
フォールバック
完了
引き継ぎ
ラストコールの場合、handoff_reason は「billing」とする必要があります。
結論
Vonage + Deepgram の公式チュートリアルでは、会話型 AI エージェントを実際の電話通話に組み込んでいます。ここでは、そのエージェントに 1 つの有用な役割を与えました。それは、注文情報を検索し、バックエンドの応答が遅すぎる場合は待機を中止し、何か問題が発生した場合は適切にフォールバックし、対応できないリクエストを人間に転送することです。
注文のバックエンドはモック化されていますが、getOrderStatus() を独自のデータベースや API に置き換えても、パターンは同じです。
これは、ヴィンスの「レイテンシをループさせない」という考え方の実践例です。呼び出し側が待機している間は、処理の規模を小さく保ち、外部呼び出しには期限を設け、有用なエスケープルートを提供します。
通話終了後は、こうした遅延の制約はなくなります。そこで、保存しておいた記録、通話記録、ツール呼び出し、結果、引き継ぎ理由、およびエージェントのバージョンといった情報が役立つのです。
たとえば、返品が引き継ぎの理由として繰り返し現れ始めた場合、それらの通話記録を参考に、次に開発すべき機能を決定し、候補となるバージョンに返品対応機能を追加した上で、保存された通話記録を用いてテストを行い、その後、利用者に公開することができます。
これがループエンジニアリングのワークフローの始まりです。でも、その話は次の記事で!
ご質問がある場合、またはあなたが作っているものを共有したい場合は、こちらをクリックしてください。
登録する 開発者ニュースレター
フォローする X(旧ツイッター)最新情報
Videoの方がいいですか? 当社の YouTubeチャンネルでチュートリアルをご覧ください
LinkedInの LinkedIn の Vonage デベロッパーページ
開発者体験の向上にご協力ください。以下の 「開発者の声」フィードバックにご協力ください
最新の開発者向けニュース、ヒント、イベント情報をお届けします。