飲食店を題材に Twilio ConversationRelay で「電話の一次対応を AI に任せる」シナリオを組んでみた
はじめに
本記事では、電話の一次対応を AI に任せるデモを、着信・FAQ 応答・SMS とメールの送信・担当者へのエスカレーションまで一本のシナリオで実装した内容を紹介します。題材は架空の飲食店の問い合わせ対応です。全体の構成は次の通りです。
音を扱う実装は動かしてみないと分からない勘所が多く、本記事はその勘所を中心にまとめます。なお、動作するコードについては末尾の付録に全文を載せているので参考にしてください。
Twilio とは
Twilio とは、SMS や音声通話、ビデオ通話、メール送信などのコミュニケーション機能を API として提供するクラウドサービスです。アプリケーションにわずかなコードを追加するだけで、本格的な通信機能を組み込めます。
検証環境
- 音声基盤: Twilio ConversationRelay
- 生成 AI: Amazon Bedrock (Claude Haiku 4.5, us-east-1)
- メール送信: SendGrid
- 通知: Slack (Incoming Webhook)
- 実行環境: fly.io (Node.js 24, Fastify)
対象読者
- 電話の一次対応の負荷を、生成 AI で減らせないか検討している方
- Twilio ConversationRelay で音声 AI を組む具体的な構成と勘所を知りたい方
- 音声・SMS・メール・チャットを 1 つのシナリオでつなぐ実装例を見たい方
参考
背景と課題
電話の一次対応には、次の 3 つの課題があります。
1 つ目は、繰り返しの問い合わせ対応です。営業時間や料金といった定型的な問い合わせが繰り返しかかり、担当者は都度手を止めます。NTT コム オンラインの調査では、コールセンターに電話をかける前に Web で自己解決を試みる人が 70.9% でした (出典: NTT コム オンライン 2023 年度調査レポート )。それでも解決に至らず電話に流れる問い合わせが、現場の負荷になります。
2 つ目は、人材の採用と定着です。CCAJ (日本コンタクトセンター協会) の調査では、採用に苦労していると回答した企業が 82% でした (出典: CCAJ 2025 年度コンタクトセンター企業実態調査 )。育成にも時間がかかり、離職が続けば対応品質が安定しません。
3 つ目は、時間外対応です。1 日 24 時間のうち営業時間を 9 時間とすれば、残り 15 時間は電話に出られません。電話がつながらなければ、顧客は競合へ流れ、機会損失につながります。
そこで、この一次対応を AI に任せられるかを、飲食店の問い合わせを題材に、着信から応答までを通しで実装して検証しました。
構成と実装
作ったシナリオは次の通りです。顧客が電話をかけると、AI が日本語で応答し、営業時間や場所などの FAQ に答えます。通話が終わると、確認内容を SMS で発信者へ送り、通話サマリをスタッフへメールします。AI が対応困難と判断したときは、要約を Slack へ通知して担当者へ引き継ぎます。
構成の中心は Twilio ConversationRelay です。ConversationRelay が音声認識と音声合成を引き受けるため、サーバー側は発話テキストを受け取って生成 AI を呼び、応答テキストを返すことに専念できます。着信時には、Connect の中で ConversationRelay を使い、WebSocket サーバーの URL と日本語の音声を指定する TwiML を返します。
<Response>
<Connect action="https://your-app.example.com/voice/action">
<ConversationRelay url="wss://your-app.example.com/ws"
welcomeGreeting="お電話ありがとうございます。AI が一次対応いたします。"
language="ja-JP" ttsProvider="Google" voice="ja-JP-Chirp3-HD-Kore"
transcriptionProvider="Google" />
</Connect>
</Response>
今回は Google の生成音声 ja-JP-Chirp3-HD-Kore を使いました。
LLM については速度重視で、 Amazon Bedrock の Claude Haiku 4.5 を us-east-1 で呼ぶようにしました。
ホスティングは fly.io です。ConversationRelay は永続的な WebSocket 接続を必要とするため、リクエスト応答型の関数基盤は不向きで、常駐サーバーを置ける環境が要ります。なお fly.io からは IAM ロールが使えないため、Bedrock を呼ぶ最小権限の IAM ユーザーを Terraform で作成し、アクセスキーをシークレットに設定しました。
WebSocket 上では、接続直後の setup で発信者情報を受け取り、prompt で発話テキストを受け取ります。応答は text メッセージで返し、担当者へ引き継ぐときは end を送ります。この会話ロジックは、送信・生成 AI 呼び出し・通知・SMS を注入する形にして、単体テストできるようにしました (全文は付録)。
再現には、Twilio の電話番号、Bedrock を有効にした AWS 環境、fly.io と SendGrid のアカウント、Slack の Incoming Webhook を用意します。fly deploy でデプロイし、電話番号の音声 Webhook を、上の TwiML を返すエンドポイントに向ければ動きます。実際に電話をかけると、AI は営業時間などを日本語で答え、通話後に SMS とメールが届き、返金を求める発話では Slack に要約が飛んで通話が終わりました。
実装で得た知見
応答は全文を待たないようにする
最初は生成 AI の応答を全文そろえてから読み上げに渡していましたが、体感で遅く感じました。ログで測ると、発話の終わりから最初のトークンまで約 1 秒かかっていました。原因は生成 AI の速度ではなく、全文の完成を待つ設計です。
そこで Bedrock の ConverseStream に替え、デルタが届くたびに text トークンを送るようにしました。最初の一文が届いた時点で読み上げが始まり、待ち時間が短くなりました。
for await (const ev of res?.stream ?? []) {
const delta = ev?.contentBlockDelta?.delta?.text;
if (!delta) continue;
full += delta;
hold += delta;
// 末尾に印の長さぶんを残し、末尾に付く制御印を読み上げに漏らさない
if (hold.length > marker.length) {
const cut = hold.length - marker.length;
emit(hold.slice(0, cut)); // emit が onToken 経由で text トークンを送る
hold = hold.slice(cut);
}
}
emit(hold);
エスカレーションの判定に、応答の末尾へ [[ESCALATE]] という制御印を付けさせているのですが、そのまま逐次送ると印まで読み上げられてしまいます。上のように末尾を印の長さぶん保留してから送ることで、印が音声に漏れるのを防いでいます。
AI が話している最中の発話も取りこぼさない
テスト中、質問がサーバーに届かないことがありました。原因は ConversationRelay の reportInputDuringAgentSpeech の既定が none で、AI が話している最中の発話は通知されない仕様だったことです (この既定は 2025 年 5 月に any から none に変わりました。出典: ConversationRelay の TwiML リファレンス )。
割り込んで話せる (=バージイン) ようにするには、reportInputDuringAgentSpeech と interruptible を speech にします。
connect.conversationRelay({
url: wsUrl,
language: 'ja-JP',
ttsProvider: 'Google',
voice: 'ja-JP-Chirp3-HD-Kore',
transcriptionProvider: 'Google',
interruptible: 'speech',
reportInputDuringAgentSpeech: 'speech', // 既定 none だと発話中の声が届かない
});
終了フックでは切断だけにする
当初、担当者への引き継ぎ時に、Connect の action コールバックで案内文を読み上げていました。すると、AI が話した声とは別の既定音声で、同じ内容が二重に流れてしまいました。
対処として、引き継ぎの案内は AI に言わせ、action では読み上げず切断だけを行います。
// エスカレーション終了時の action コールバック。AI が既に案内済みなので読み上げない
fastify.post('/voice/action', async (request, reply) => {
const response = new twilioSdk.twiml.VoiceResponse();
response.hangup();
reply.header('content-type', 'text/xml').send(response.toString());
});
メールは認証済みドメインから送らないと迷惑メールに入る
通話サマリのメールが、最初は迷惑メールに振り分けられました。送信元を、単なる送信者認証だけの独自ドメインにしていたためです。SPF/DKIM が効かず、受信側が認証に失敗したメールと判断します。受信側の個人設定で安全な送信者に登録しても、企業のメールセキュリティが上書きするため効かないことがあります。
対処は、SendGrid でドメイン認証済みのドメインから送ることです。送信元をそのドメインのアドレスに変えるだけで、DKIM 署名が付き、受信箱に届きました。DNS を触らずに済んだのは、既にドメイン認証済みのドメインがアカウントにあったためです。
まとめ
電話の一次対応を、着信から FAQ 応答、SMS とメール、担当者への引き継ぎまで、一本のシナリオで通しで動作させました。音声認識と合成を ConversationRelay に任せられる一方で、応答を逐次返す、発話中も取りこぼさない、案内を二重に流さない、メールを認証済みドメインから送る、といった音声 AI 特有の勘所が、動作させて初めて見えてきました。本記事が、電話一次対応の自動化を検討する際の参考になれば幸いです。
ウェビナーのご案内
クラスメソッドでは Twilio の活用に関するウェビナーを開催しています。

Twilio の最新トピックや実装事例に興味のある方は、ぜひ Twilio ウェビナー にアクセスしてみてください。
付録: ソースコード
再現できるよう、主要なファイルの全文を載せます。計測ログなど一部は読みやすさのため簡略化しています。
app/src/twiml.js
// ConversationRelay を含む TwiML を生成する。
import twilio from 'twilio';
const { VoiceResponse } = twilio.twiml;
export const DEFAULT_VOICE = 'ja-JP-Chirp3-HD-Kore';
export function buildConnectTwiml({
wsUrl,
actionUrl,
welcomeGreeting = 'お電話ありがとうございます。AI が一次対応いたします。ご用件をお話しください。',
voice = DEFAULT_VOICE,
}) {
const response = new VoiceResponse();
const connect = actionUrl ? response.connect({ action: actionUrl }) : response.connect();
connect.conversationRelay({
url: wsUrl,
welcomeGreeting,
language: 'ja-JP',
ttsProvider: 'Google',
voice,
transcriptionProvider: 'Google',
// AI の発話中でも割り込んで話せるようにする (バージイン)。
interruptible: 'speech',
reportInputDuringAgentSpeech: 'speech',
});
return response.toString();
}
app/src/faq.js (FAQ 知識と読み上げ向けシステムプロンプト)
// デモ用の架空飲食店の FAQ 知識と、AI のシステムプロンプトを構築する。
export const RESTAURANT = {
name: 'ビストロ クラスメソッド',
hours: '11:00〜15:00 と 17:00〜22:00 (ラストオーダー 21:00)。水曜定休。',
location: '東京都千代田区丸の内 1-1-1。JR 東京駅 丸の内南口から徒歩 3 分。',
budget: 'ランチはお一人 1,200 円前後、ディナーはお一人 4,000 円前後です。',
reservation: 'お電話またはこの AI で承ります。ご希望の日時と人数をお知らせください。',
parking: '専用駐車場はありません。近隣のコインパーキングをご利用ください。',
};
export function buildSystemPrompt(faq = RESTAURANT) {
return [
`あなたは飲食店「${faq.name}」の電話一次対応 AI です。電話越しにお客様と話しています。`,
'',
'店舗情報:',
`- 営業時間: ${faq.hours}`,
`- 場所: ${faq.location}`,
`- 予算: ${faq.budget}`,
`- 予約: ${faq.reservation}`,
`- 駐車場: ${faq.parking}`,
'',
'話し方の決まり:',
'- 音声で読み上げられます。記号や markdown は使わないでください。アスタリスクや番号付き・箇条書きの記号を出力してはいけません。',
'- 英語やアルファベットをそのまま書かず、カタカナで言ってください。',
'- 自然で聞き取りやすい丁寧語で話してください。不自然な言い回し (たとえば「ご件」) は使わないでください。',
'- 一度の返答は 1 文から 2 文までにし、短く答えてください。',
'',
'対応の方針:',
'- 店舗情報で答えられることは、その範囲で正確に答えてください。',
'- 次のいずれかに当てはまる場合は、返答の一番最後に [[ESCALATE]] という印だけを付けてください。担当者に引き継ぐ合図です。',
' - 店舗情報にない内容で、確かな回答ができないとき',
' - クレームや強い不満を示されたとき',
' - 契約・解約・返金など、意思決定を伴う判断が必要なとき',
'- [[ESCALATE]] を付けるときは、その前に「担当者から折り返しご連絡します」と一言だけ伝えてください。',
].join('\n');
}
app/src/bedrock.js (ストリーミングと制御印の除去)
// Amazon Bedrock の Claude Haiku 4.5 を ConverseStream で呼び出す。
import { BedrockRuntimeClient, ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime';
import { ESCALATION_MARKER } from './escalation.js';
import { sanitizeForSpeech, collapseWhitespace } from './sanitize.js';
export function createBedrockClient({ region }) {
return new BedrockRuntimeClient({ region });
}
// ストリーミング版。生成されたトークンを onToken へ逐次渡し、読み上げを早く始める。
// エスカレーション印は読み上げに乗せないよう、末尾を保留して除去する。
export async function streamReply({ client, modelId, systemPrompt, history, onToken, maxTokens = 300, temperature = 0.3 }) {
const command = new ConverseStreamCommand({
modelId,
system: [{ text: systemPrompt }],
messages: history.map((m) => ({ role: m.role, content: [{ text: m.content }] })),
inferenceConfig: { maxTokens, temperature },
});
const res = await client.send(command);
const marker = ESCALATION_MARKER;
let full = '';
let hold = '';
const emit = (raw) => {
const speak = sanitizeForSpeech(raw.split(marker).join(''));
if (speak && onToken) onToken(speak);
};
for await (const ev of res?.stream ?? []) {
const delta = ev?.contentBlockDelta?.delta?.text;
if (!delta) continue;
full += delta;
hold += delta;
// 末尾に marker 長ぶんを残し、末尾に付く印が読み上げに漏れないようにする。
if (hold.length > marker.length) {
const cut = hold.length - marker.length;
emit(hold.slice(0, cut));
hold = hold.slice(cut);
}
}
emit(hold);
const escalate = full.includes(marker);
const fullText = collapseWhitespace(sanitizeForSpeech(full.split(marker).join('')));
return { fullText, escalate };
}
app/src/ws-handler.js (会話ロジック)
// ConversationRelay の WebSocket プロトコルを処理する会話ロジック。
// 送信 (emit)・LLM ストリーミング・通知・SMS は注入し、単体テスト可能にする。
export function createConversation(deps) {
const session = { history: [], from: null, to: null, callSid: null, lastAssistant: null };
async function onMessage(msg) {
if (!msg || typeof msg !== 'object') return {};
if (msg.type === 'setup') {
session.from = msg.from ?? null;
session.to = msg.to ?? null;
session.callSid = msg.callSid ?? null;
return {};
}
if (msg.type === 'prompt') {
session.history.push({ role: 'user', content: msg.voicePrompt });
const { fullText, escalate } = await deps.streamReply({
client: deps.client,
modelId: deps.modelId,
systemPrompt: deps.systemPrompt,
history: session.history,
onToken: (token) => deps.emit({ type: 'text', token, last: false }),
});
// 読み上げターンの終了を通知する。
deps.emit({ type: 'text', token: '', last: true });
session.history.push({ role: 'assistant', content: fullText });
session.lastAssistant = fullText;
if (escalate) {
const lastUser = [...session.history].reverse().find((m) => m.role === 'user');
await deps.notifyEscalation({
from: session.from,
to: session.to,
request: lastUser?.content ?? '',
reply: fullText,
transcript: session.history,
time: deps.now ? deps.now() : null,
});
deps.emit({
type: 'end',
handoffData: JSON.stringify({ reasonCode: 'live-agent-handoff', reason: fullText }),
});
}
return { escalate, reply: fullText };
}
return {};
}
async function onClose() {
if (deps.sendSms && session.from && session.lastAssistant) {
await deps.sendSms({ to: session.from, summary: session.lastAssistant });
}
if (deps.sendEmail && session.lastAssistant) {
await deps.sendEmail({
caller: session.from,
called: session.to,
summary: session.lastAssistant,
transcript: session.history,
time: deps.now ? deps.now() : null,
});
}
}
return { session, onMessage, onClose };
}
app/src/server.js (HTTP と WebSocket の組み立て)
// Fastify サーバー。TwiML を返す HTTP と、ConversationRelay の WebSocket を 1 プロセスで扱う。
import Fastify from 'fastify';
import formbody from '@fastify/formbody';
import websocket from '@fastify/websocket';
import twilioSdk from 'twilio';
import { loadConfig } from './config.js';
import { buildConnectTwiml } from './twiml.js';
import { buildSystemPrompt } from './faq.js';
import { createBedrockClient, streamReply } from './bedrock.js';
import { notifyEscalation } from './slack.js';
import { sendSms } from './sms.js';
import { buildSummaryEmail, sendSummaryEmail } from './email.js';
import { createConversation } from './ws-handler.js';
export function buildServer(overrides = {}) {
const config = overrides.config ?? loadConfig();
const fastify = Fastify({ logger: overrides.logger ?? true });
fastify.register(formbody);
fastify.register(websocket);
const systemPrompt = buildSystemPrompt();
const bedrockClient = overrides.bedrockClient ?? createBedrockClient({ region: config.awsRegion });
const twilioClient =
overrides.twilioClient ??
(config.twilioAccountSid && config.twilioAuthToken
? twilioSdk(config.twilioAccountSid, config.twilioAuthToken)
: null);
const wsUrl = () => (config.publicBaseUrl ? config.publicBaseUrl.replace(/^http/, 'ws') : '') + '/ws';
const actionUrl = () => (config.publicBaseUrl ? config.publicBaseUrl : '') + '/voice/action';
fastify.get('/', async () => 'ok');
const voiceHandler = async (request, reply) => {
const twiml = buildConnectTwiml({ wsUrl: wsUrl(), actionUrl: actionUrl() });
reply.header('content-type', 'text/xml').send(twiml);
};
fastify.get('/voice', voiceHandler);
fastify.post('/voice', voiceHandler);
// AI が既に音声で引き継ぎを伝えているため、ここでは読み上げず切断のみ行う。
fastify.post('/voice/action', async (request, reply) => {
const response = new twilioSdk.twiml.VoiceResponse();
response.hangup();
reply.header('content-type', 'text/xml').send(response.toString());
});
fastify.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket) => {
const conversation = createConversation({
client: bedrockClient,
modelId: config.bedrockModelId,
systemPrompt,
streamReply,
emit: (obj) => socket.send(JSON.stringify(obj)),
now: () => new Date().toISOString(),
notifyEscalation: (args) =>
config.slackWebhookUrl ? notifyEscalation({ webhookUrl: config.slackWebhookUrl, ...args }) : Promise.resolve(false),
sendSms: (twilioClient && config.twilioFromNumber)
? ({ to, summary }) =>
sendSms({ client: twilioClient, from: config.twilioFromNumber, to, body: `本日はお電話ありがとうございました。${summary}` })
: null,
sendEmail: (config.sendgridApiKey && config.emailFrom && config.emailTo)
? async ({ caller, called, summary, transcript, time }) => {
const { subject, body } = buildSummaryEmail({ from: caller, to: called, summary, transcript, time });
return sendSummaryEmail({ apiKey: config.sendgridApiKey, fromEmail: config.emailFrom, toEmail: config.emailTo, subject, body });
}
: null,
});
socket.on('message', async (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
return;
}
await conversation.onMessage(msg); // 応答の送信は onMessage 内の emit で行う
});
socket.on('close', () => {
conversation.onClose().catch((err) => fastify.log.error(err));
});
});
});
return fastify;
}










