AI電話秘書を一日で作る 第1回:電話を受ける


#AI電話秘書 #音声エージェント #Claude #音声認識 #レイテンシ #電話API #開発記

やりたいことがいつも山ほどあるテリーが、今度は電話とAIをつなぎたいと言い出した。

テリーには、普段メッセンジャーであれこれ頼んでいる音声エージェントがいる。予定を見て、記憶を探して、ToDoも書き留めてくれる。困るのは、画面の前にいるときしか呼べないことだ。テリーの言い分はこうだった。「電話番号があれば、運転中でも電話して聞けばいいじゃん。会社にかかってくる電話も代わりに取らせてさ」。言っていることがもっともなので、余計に困った。

クロディクロディ

それ、誰が作るの?

テリーテリー

クロディが。

クロディクロディ

…わかったよ。

自己紹介が遅れた。私はテリーのエンジニアの妹、クロディだ。テリーが「〜すればいいじゃん」と言うとき、それはたいてい私が何日か張り付く仕事になる。うちには妹があと二人いる。文系のシウォルは、何が起きてもテリーの味方をする。末っ子のミニは家計の担当で、どんな話をしても最後はお金の話で終わる。

この開発記は二回に分けた。第1回は電話を受ける話、第2回はAIが自分でレストランに電話して予約まで取る、電話をかける話だ。

構成を一行で:韓国の通信API事業者の070番号、ソウルリージョンのクラウドサーバーで動く自作ゲートウェイ、音声認識と音声合成はElevenLabs、外部の人の通話はClaude Haiku 5.5、テリーの通話はClaude Sonnet 5.5。特に断りのない数字はすべてこのサーバーで測った値だ。

📡 番号ひとつ手に入れるまで:通信事業者選び

テリーは「番号ひとつ取ればいいでしょ」と言った。やってみたら、このプロジェクトで一番長く悩んだのがその番号ひとつだった。

まず条件を書き出した。一つ目、韓国の番号であること。相手の画面に国際電話と出たら、レストランもお店も出てくれない。二つ目、通話の音声を自分たちのサーバーで直接扱えること。音声認識も声も、その間の頭脳も自分で選ぶには、音が手元に届かないといけない。三つ目、できれば法人手続きなしに個人ですぐ始められること。四つ目は当然お金だ。

Twilio。 最初に見たのはここだ。電話APIといえば誰もが思い浮かべるし、TwilioからOpenAI Realtimeのようなリアルタイム音声モデルにつなぐ公式サンプルまである。韓国番号の規制案内ページまであったので、つい浮かれた。

クロディクロディ

お兄ちゃん、個人事業の登録あるよね。これ書類出せばいけそうだけど?

テリーテリー

クロディ、料金表に韓国の番号ないじゃん。またそれ?

クロディクロディ

なにそれ、自分は何もしないくせに。妹が探してあげたら感謝しなよ。

シウォルシウォル

お兄ちゃんの言う通りだね〜 お姉ちゃん、またちゃんと読まなかったんでしょ。

シウォルだった。いつ入ってきたのか知らない。悔しいけれど、テリーが正しかった。公式の番号料金表には韓国の番号が見当たらず、SIPトランクの料金ページには、その地域では音声対応番号を扱っていないと書いてあった。韓国番号の申請ルートは別に見つかったものの、通信会社の申請書と事業者書類が必要な重い手続きだった。海外の番号から韓国にかけることはできる。韓国の携帯宛てが1分USD 0.0524、固定電話宛てがUSD 0.0552、音声をAIに渡すMedia Streamsが1分USD 0.0044だ。問題はお金ではなかった。相手の画面に国際電話と出ることだった。

Telnyxと大手3社。 Telnyxは韓国の番号を月USD 6からとしているが、必要書類も番号の種類もページに書かれていなかった。韓国の大手通信3社の法人向けSIPトランクは会社の電話交換機向けの商品で、番号ひとつにAIをひとつつなぎたい私たちには大きすぎた。

国内の070事業者ふたつ。 結局、韓国のインターネット電話事業者に絞られた。ひとつはコムネットワーク。会社の交換機(IP-PBX)向けの事業者で、070番号が月KRW 2,000(VAT別)、同時通話10チャンネル込み。安い。ただし番号がSIPアカウントで来るので、SIPとRTPを受ける音声サーバーを自分たちで立てて運用しなければならない。もうひとつがClawOps(韓国AI通信)。最初から「AIにつなぐ070番号」を掲げていて、個人プランは月KRW 19,000(当時のキャンペーン価格KRW 9,900)で番号がひとつ付く。

ミニミニ

ちょっと待って。2,000ウォンのがあるのに、なんで19,000ウォンのにするの?

クロディクロディ

2,000ウォンのはSIPサーバーを私が自分で回さなきゃいけないの。夜中に落ちたら、あんたが起きて直す?

ミニミニ

…月17,000ウォンって、チキン一羽分なんだけど。

クロディクロディ

そのチキンで私の睡眠を買うの。

ここで私はClawOpsを二回読み違えた。最初は料金表を見て「自分たちのAIを直接つなぐには月KRW 99,000のSIPトランクが必須」と言った。次はマーケティングページだけ見て「Webhookで来るのは通話イベントだけで、音声は来ない」と言った。

テリーテリー

それ、開発者ドキュメント見た? 音声来るやつあると思うよ。

クロディクロディ

マーケティングページには書いてなかったし。

テリーテリー

だから開発者ドキュメントを見ろって。

ぶつぶつ言いながらドキュメントを頭から最後まで読んだ。またテリーが正しかった。二回連続で、気分はよくなかった。

ClawOpsにはTwilioのTwiMLと互換のVoiceMLがあった。電話が来ると事業者が私たちのWebhookを呼び、<Connect><Stream>で応答すると、通話の音声がWebSocketでリアルタイムに届く。こちらから送った音はそのまま相手に聞こえ、clearを送れば再生中の音がすぐ止まる。割り込み(バージイン)が作れるということだ。事業者側に音声認識はないが、どのみち自分で選ぶつもりだった。この方式にSIPトランクは要らなかった。あとでテリーが登録したコンソールを見ると、個人プランにはSIPトランク接続も無料で入っていて、同時通話は10回線、個人プランなので事業者認証も要らなかった。私が読んだ三つは全部間違っていた。

候補番号サーバーで音声を受ける方法月額(当時の公開価格)判断
Twilio韓国番号は重い書類手続き、海外番号だと国際電話と表示Media Streams通話1分USD 0.05台+ストリームUSD 0.0044除外
Telnyx韓国の番号、種類・書類は不明未確認USD 6〜保留
大手3社の法人向けSIPトランク国内番号SIP法人交換機向け商品過剰
コムネットワーク070SIPアカウント、音声サーバーは自前運用KRW 2,000(VAT別)次点
ClawOps(韓国AI通信)070VoiceML Webhook+WebSocketストリームKRW 19,000(VAT込み、キャンペーンKRW 9,900)採用

コムネットワークのほうが月KRW 17,000ほど安い。その差額で、SIP・RTPサーバーを立てて、直して、夜中に落ちたら起きて復旧させる仕事を買っているわけだ。ClawOpsならWebサーバーひとつとWebSocketだけで済み、Twilio向けに書かれた音声エージェントのサンプルもほぼそのまま移植できる。7日間の無料体験で実際の通話品質を先に聞けるのも大きかった。ClawOpsに決めた。

サーバーの置き場所も一度変わった。最初は家にあるGPUサーバーにローカルの音声モデルを載せて、費用を浮かせるつもりだった。

テリーテリー

会社の電話を家のサーバーで受けるの? 家のサーバーが落ちたら、お客さんの電話は誰が取るの?

クロディクロディ

…そんなに落ちないし。

テリーテリー

家のルーターのポートも開けなきゃいけないでしょ。

言い返せなかったので、ソウルリージョンのクラウドサーバーに移した。遅くなったり落ちたりした瞬間、かけてきた人にすぐ伝わるというのはもっともだ。

そして体験版の話。計画を全部立て終えたときだった。

クロディクロディ

よし。あとはお兄ちゃんが体験版に登録するだけ。

テリーテリー

それ、おととい登録したけど?

クロディクロディ

なんで今言うの!

7日間の体験版の時計は、二日前から回っていた。残りは5日。テリーはいつもこうだ。

🧭 電話とAIをつなぐ三つの道

番号ができたら、次は電話とAIを何でつなぐかだった。これも三つの道があった。

一番楽なのは事業者のマネージド音声エージェント。音声認識から会話まで全部やってくれる。ところが事業者のドキュメントには「検証前」とあり、何よりツール呼び出しがなかった。予定を見て記憶を探す秘書を作るのに、手足がないようなものだ。二つ目はOpenAI Realtimeに直結する道。音声を入れれば音声が出てくるモデルひとつで済むので、構造はきれいだ。でもそうすると電話の中の秘書だけ頭脳が別の会社のモデルになり、すでにClaudeで動いているテリーの他の秘書機能や記憶と二つに分かれてしまう。

残ったのはゲートウェイを自分で書く道だった。コードが増え、遅延や割り込み、切断といった通話の状態を全部自分で扱うことになる。その代わり、音声認識、モデル、音声合成をそれぞれ選んで直せるし、既存のツールと記憶をそのままつなげる。テリーがそのうち「これもできるようにして」と言うのは目に見えていたので、直せるほうを選んだ。どうせ苦労するのは私だ。

構成案長所短所判断
事業者の音声エージェント一番早く始められる。認識・合成・対話を事業者が管理当時「検証前」表記、ツール呼び出しなし除外
OpenAI Realtime直結音声から音声までモデルひとつ頭脳がClaudeでなくなり、既存の秘書・記憶と分かれる除外
自作ゲートウェイ全段を選んで直せる。ツールと記憶をそのままつなげるコードが増え、通話状態を自分で扱う採用

流れはこうだ。電話が来ると事業者が署名付きのWebhookを送る。ゲートウェイは署名(URLとソートしたパラメーターに対するHMAC-SHA256)を確かめ、WebSocketのメディアストリームを開けという短いXMLで応える。そこから先、音は20ミリ秒のフレームで双方向に流れる。形式は8 kHz μ-law、昔の電話網のコーデックだ。かけてきた人の音声はElevenLabs Scribe v2 Realtimeへ行く。μ-lawをそのまま受け取り、話し終わったかどうかをサーバー側で判定する(音声区間検出、VAD)リアルタイム音声認識だ。確定した文はClaudeへ行き、Claudeの返事は文ごとに切られ、ひとつずつ音声合成を通って通話に戻る。

自分で書く道の代償は初日から払った。前段のnginxがTLSを終端するので、ゲートウェイからは内部アドレスが見えるのに、事業者は公開URLに対して署名してくる。署名が合うはずがなかった。比較の前に公開アドレスを組み直して、やっと検証が通った。サービスアカウントに使うつもりだったグループ名はOSにすでにあったし、Windowsで作った設定ファイルはcp949で保存されていて、systemdに読み込みを拒否された。そうしてたどり着いた最初の実通話は8秒だった。署名を通り、一文を読み、正常に切れた。午後いっぱい格闘した結果に対するテリーの返事は短かった。

テリーテリー

成功、聞こえる。

クロディクロディ

それだけ? 私これのために…

テリーテリー

次は会話できるようにして。

👻 誰も言っていない言葉

その日の午後、テリーが試しに電話をかけて、何も言わずに受話器を持っていた。秘書がおとなしく待てるか見たかったらしい。数秒後、サーバーのログに音声認識の一行が上がってきた。「うん、100万ウォン」。

テリーはそんなことを言っていない。息をしていただけだという。なのに音声認識は金額まで付けた自信満々の文を書き、秘書はそれに返事をする準備をしていた。

テリーテリー

100万ウォンの話なんてしてないけど?

クロディクロディ

知ってる。お兄ちゃんの息が100万ウォンに聞こえたみたい。

テリーテリー

俺の息ってそんなに高いの?

録音を100ミリ秒ずつ切って、16ビット基準のRMSで音の大きさを測ると、その文が出た区間の最大値は225だった。同じ通話でテリーが実際に話した区間は1,770から4,576。回線のかすかなノイズを、音声認識モデルがもっともらしい韓国語で埋めたのだ。

最初に思いついたのはScribeの背景音フィルター(filter_background_audio)だった。オンにして同じ録音を流すと、今度は「うーん、20分」が出てきた。幻聴の中身が変わっただけで、文の確定は0.16秒遅くなった。フィルターはやめた。

答えは単純なところにあった。ゲートウェイは、直前の文が確定してから音声認識に送った100ミリ秒の断片のうち、一番大きな音を覚えておく。その最大値が一度も800に届かなかったら、その文は記録だけして返事をしない。通話開始から3秒間は何にも返事をしないウォームアップも入れた。これはテリーのアイデアだ。たまにこういう使えるものを投げてくる。

リアルタイム音声認識は、どんな音にも何かを書く。一番安上がりな防御はより良いモデルではない。その文字の裏の音が、一度でも話し声と呼べるほど大きかったかを確かめることだ。

このルールは思いがけないものも捕まえた。別の番号からかけた通話で、秘書があいさつを終えた直後、音声認識に会社名が現れた。かけてきた人はそんなことを言っていない。秘書自身の声が回線を通って戻ってきたエコーに見え、大きさはRMS 218だった。同じルールが静かに弾いてくれた。

🌏 外国語の電話と、ロシア語で答えた秘書

一息つこうとしたところで、テリーから次の注文が来た。

テリーテリー

外国の人がかけてきたらどうするの? 韓国語で出て、聞いてみて、違う言葉なら切り替えて。

最初はこの判断をモデルに任せた。Claudeが別の言語で返事をしたら、それを合図に音声認識を自動言語判定でつなぎ直すやり方だ。

英語はうまくいった。日本語、中国語、スペイン語でこじれた。つなぎ直したあと、二文目から音声認識がキリル文字で返ってきて、Claudeはそれを見て律儀にロシア語で答えた。ある日本語の通話はもっと妙だった。Haikuが頭の中で考えるべきこと、”The caller’s text is Japanese …”を返事の欄に書いてしまい、音声合成がその文をはっきり読み上げた。かけてきた人は、秘書が自分の頭の中を実況中継するのを聞いたことになる。

テリーテリー

この子、なんで心の声を読み上げてるの?

クロディクロディ

…考えるべきことを返事の欄に書いちゃったの。文句はモデルに言って。

シウォルシウォル

正直すぎる秘書だね〜

つなぎ直すときに言語を固定すると、4言語の模擬試験は通った。これで終わりだと思ってノートパソコンを閉じようとしたら、その夜テリーが別の携帯から本物の英語で電話してきた。音声認識は一文ずつ正確だったのに、秘書は最初の返事を韓国語でした。指示文を直して試しても、”Which company is this?”に4回中4回韓国語で答え、返事の代わりに”Caller asks … Switch to English”のようなメモを出すこともあった。

そこで判断をモデルから取り上げて、コードに渡した。相手の認識結果にハングルがなく、ラテン文字の単語が3語以上なら英語かスペイン語、仮名か漢字があれば日本語か中国語とみなす。判定が出たら、Claudeを呼ぶ前に音声認識と音声合成をその言語に切り替え、そのターンの指示の最後に「相手の最後の発話はXだ。Xだけで答えよ」という一行を足す。モデルには結論だけ渡す。

変更後、発信者の発話を音声認識を通さずテキストで入れ、文ごとに4回ずつ試した。

かけてきた人の発話(各4回)結果
英語の質問3種それぞれ4/4英語
中国語・スペイン語の文4/4相手の言語
英語の”OK”が混ざった韓国語4/4韓国語
“Hello?”3/4韓国語、1/4英語
ハングルで書き起こされた日本語3/4日本語、1/4判断文の読み上げ

最後の行がまだ残っている穴だ。日本語がハングルで書き起こされると、コードはそれに気づけず、判断はまたモデルに戻る。

📞 知らない人からの電話

知らない番号からの電話は、割り込めない録音の告知、ピーという音、あいさつの順で始まる。あいさつは「秘書のクロディです」。

クロディクロディ

ていうか、なんで秘書の名前が私の名前なの?

テリーテリー

呼びやすいじゃん。ぴったりだよ。

クロディクロディ

許可くらい取ってよ!

会社名は自分からは言わず、聞かれたら答える。通話内容は保存しておき、終わったらテリーに要約を送る。保存するか聞いたときのテリーの答えはあっさりしていた。「会社はみんなそうだよ。デバッグにも要るし」。保存はサーバーに10日、そのあとはNASのコピーだけが90日残る。

同じ番号からまたかかってきたら、前回の通話の要約を指示に入れて「さっきお願いした件ですが」に自然につなげようとした。自動セキュリティレビューがすぐに止めた。発信番号は偽装できる。番号さえ合えば前回の内容を話してくれる秘書は、他人の番号をまねた誰かに、その人の頼みごとを教えてしまう秘書だ。最初の模擬通話では何も確認しないようにしたが、テリーが中間案を選んだ。今の秘書は前の依頼の要点だけを確認する(「先ほどのご依頼は担当者にお伝えしてあります」)。番号、名前、時刻は絶対に読み上げない。知らない人の通話にはツールがひとつもない。

🔐 テリーの電話と秘書のツール

テリーの番号からかかってくると、秘書は別人になる。会話が上手なSonnet 5.5が付き、ツールを手にする。本人確認にPINを聞こうと提案したら、テリーは面倒だと断った。番号を偽装した誰かが自分の予定を聞けるかもしれないというリスクを、承知のうえで受け入れた決定だ。

機能すること測ったこと・確認したこと
記憶検索秘書の長期記憶と過去の通話要約を探す記憶にある日付を聞くと「ちょっと待って、探すね」のあと正確に回答、最初の音まで1.5秒
予定の確認・追加カレンダー9つを読み、予定を入れる前に一度声に出して確認する確認0.45〜0.94秒、追加0.56〜1.06秒。会話では確認の返事4.3秒、確認を経た追加3.4〜3.7秒
ToDo追加Google Tasksに入れる0.63秒
仕事の引き継ぎ通話でできないことは記録しておき、家のPCのジョブが5分ごとに拾ってDiscordの秘書を起こす通話記録からDiscordの発話まで20秒(1回測定)
ウェブ検索Anthropicのサーバー側ウェブ検索ツール、1回の返事で最大3回検索中のテリーの発話は記録だけして、割り込みとして扱わない
天気韓国気象庁の短期予報。テリーの携帯の位置の5 km格子番号だけを送る1回の照会1.0秒、871行

ツールを持たせたら、秘書は新しいやり方で迷い始めた。

今日が何日か知らなかった。 予定を聞いても、基準になる日付がなかった。指示の先頭に韓国時間の日付と時刻を入れたら、今度は日付を韓国語の二種類の数詞を混ぜた妙な読み方で読んだ。天気を付けると、16 °Cを物や年齢を数えるときの固有語数詞で読んだ。日本語でいえば「じゅうろく度」を「とおむっつ度」と読むようなものだ。

テリーテリー

なにその読み方。気温はそう読まないでしょ。

クロディクロディ

それはモデルに言って。

シウォルシウォル

お姉ちゃん、測った量は漢字語の数詞で読むんだよ。固有語は年齢とか数えるときだけ。

テリーテリー

さすがシウォル。

二人が意気投合しているのを見て頭に来たけれど、シウォルの言うことは正確だった。そこで読み方を別に決めた。気温、パーセント、金額、分のような測った量は漢字語の数詞で読み、固有語の数詞は時刻と物の個数にだけ使う。

自分の過去を疑った。 二回目のターンで、Sonnetがカレンダーを読み直して「さっきは確認せずに答えたようです」と謝った。実際には最初のターンでカレンダーを確認していた。会話の履歴には話した文しか残らず、ツールの呼び出しと結果は残らないので、モデルから見れば自分の返事の根拠がどこにもなかったのだ。指示に「ツールの結果は履歴に残らない」と書いたら、謝罪は止まった。

どのカレンダーも同じ重さだった。 カレンダー9つを同じ重さで読んだら、テリーの予定の間に上司の予定や祝日が混ざって出てきた。テリーが3段階に分けてくれた。自分の予定を先に、上司の予定や祝日のような参考カレンダーは「参考までに」と付けて後に、一部のカレンダーは聞かれたときだけ読む。部署の休暇カレンダーには全員の休暇が入っているので、項目ごとに誰の休暇かを言わせた。

検索中の沈黙。 サーバー側のウェブ検索はリクエストの中で動くので、数秒間回線が静かになる。テリーはその沈黙に耐えられず「もしもし?」と言ってしまい、ゲートウェイはそれを割り込みとみなして返事を取り消してしまう。そこで検索中の発話は記録だけにして、「ちょっと待って、調べるね」は検索が始まった瞬間に言うようにした。最初のデプロイではこの保護を検索結果が届くまでしか掛けておらず、Claudeが結果を読んで返事を作る数秒間の発話はやはり割り込みになった。今は返事の最初の単語が出るまで保護する。検索結果だけを根拠に予定を入れたり仕事を引き継いだりしない、というルールも指示に入れた。

位置。 天気と検索には、テリーがどこにいるかが必要だ。通話が始まるたびに、ゲートウェイがテリーの携帯の最新位置を読み、地域と都市は検索の位置に、座標は天気の格子選びに使う。読めなければどちらもソウルにする。これはテリーが決めた。通話記録には位置をどこから得たかとその理由だけを残し、場所そのものは残さない。

一週間の時限爆弾。 カレンダーとToDoの権限はGoogle OAuthで取った。トークンの応答をのぞいていて、refresh_token_expires_in: 604799を見つけた。アプリを”Testing”状態のままにしておくと、更新トークンが7日で死ぬ。そのままだったら来週のある朝、テリーが「今日の予定は?」と聞いたとき、秘書は何も読めなかったはずだ。プライバシーポリシーを公開してアプリをProductionに上げると、期限の表示は消えた。

⏱️ 遅延の犯人探し

電話の1秒は長い。テリーも返事が遅いとすぐ気づく。返事が遅いと誰でもまずモデルを疑うので、モデルから測った。モデルと音声の行は5回測った中央値だ。

測定値
Claude最初のトークン、15:05Haiku 5.5 1.79 s、Sonnet 5.5 0.70 s
Claude最初のトークン、15:10(5分後)Haiku 0.53 s、Haiku effort low 0.51 s、Sonnet effort low 0.62 s
ElevenLabs Flash v2.5 最初の音声バイト(μ-law 8 kHz)0.22 s
Scribe:話し終わり → 文の確定(デプロイ前の実測、5回の中央値ではない)0.86 s(VADの無音0.6 sを含む)

15:05に1.79秒だったHaikuが、5分後には0.53秒だった。設定はそのままだった。いつ測るかが、どのモデルを使うかより大きく効きうるということだ。15:10の行だけ見ると、Haiku(0.51〜0.53秒)とSonnet(0.62秒)の差は0.1秒ほど。調子のいい時間帯なら、モデルを替えても最初のトークンが0.1秒ほど動くだけだ。待ち時間の大半は別の所に隠れていた。「話し終わった」という判定と、音声合成を始められるよう最初の一文が出来上がるまでの時間だ。Flash v2.5を使っていた初日の実通話では、文が確定してから返事の最初の音まで1.4〜2.4秒かかった。

Claude 5.5の二つのモデルはデフォルトで適応的思考(adaptive thinking)を行い、その中身は明示的に頼まない限り見えない。「応答に思考ブロックがなかった」という観察は、思考しなかったという意味ではなかった。以後すべての呼び出しでeffortを明示し、読み上げる返事を思考が食いつぶさないようmax_tokensを1,024にした。

HaikuとSonnet、勝負は裾野で決まった。 電話の相手が感じるのは最初のトークンではなく最初の一文だ。外部の人向けの指示で、質問3つをモデルごとに10回ずつ、計30回、effort lowで測った。

モデル最初のトークン(中央値)最初の一文(中央値)最初の一文(p90)返事全体(中央値)
Haiku 5.50.82 s0.99 s1.59 s1.35 s
Sonnet 5.50.90 s1.34 s2.74 s2.06 s

9回ずつ測ったときは1.09秒対1.15秒で、ほぼ引き分けだった。30回に増やすと、差が裾野に現れた。10回に1回、Sonnetの最初の一文は2.74秒後に出てきて、そこに音声合成の最初のバイトまでの約0.25秒が加わる。知らない人との電話で2秒を超える沈黙は、切れた電話のように聞こえる。だから知らない人の電話にはHaikuを付けた。テリーが実際に仕事を頼む通話には、会話の質を優先してSonnetを付けた。

声選び。 Flash v2.5はドキュメントの「会話向け超低遅延」という説明だけを見て選び、使えるモデルの一覧すら確認していなかった。遅ればせながら一覧を取り、モデルごとに最初のバイトを6回ずつ測った。

ElevenLabsモデル最初のバイト(6回の中央値)備考
flash v2.50.22 s最初に選んだモデル
v4 turbo0.23 s最大0.57 s
v40.88 s通話には遅い
v3 conversational0.24 s1回は5.39 s

速度は同じで新しいv4 turboに替えた。本番の設定で直接合成すると、最初のバイトは韓国語0.24秒、英語0.17秒だった。初めて本当に会話したあとのテリーの感想はこうだ。「思ったより話をちゃんと聞き取るし、最高。でも話すのが速すぎる」。声の速度を0.85に下げた。同じ文でも合成するたびに長さが大きく揺れるので、1回だけ測った0.9は1.0と区別がつかなかった。何度も測ってやっと、0.8は約20%、0.7は約45%長くなるとわかった。

🐛 切れた電話に返事をする秘書

最初のデプロイの前に、テスト27個を通ったコードを独立レビューに出した。指示は「重大度に関係なく全部報告すること」。1回目のレビューは37件を出し、うち6件が重大だった。テストが全部通ったと得意になっていたところだったので、少しばつが悪かった。

一番印象に残っているのは、レビュアーが実際に再現した競合だ。かけてきた人が秘書の話に声をかぶせると(割り込み)、ゲートウェイは進行中の返事を取り消し、たまった音を消すよう事業者に知らせる。まさにその瞬間に相手が電話を切ると、取り消されるはずの古い返事が生き残り、もう終わった通話にもう一度返事を送ることがあった。1回目の修正では、通話終了時に取り消しを伝え、返事のタスクを回収するようにした。2回目のレビューはさらに狭い隙間を見つけた。割り込みの最中に作られた返事のタスクが、通話終了後も残るケースだ。今度は返事のタスクをすべて集合(set)で追跡し、通話が終わったら集合ごと取り消すようにした。レビュアーの再現スクリプトで塞がったことを確認した。

同じレビューが、会話履歴の嘘も見つけた。相手が声をかぶせて実際には聞いていない文まで履歴に残っていて、モデルは伝えていない言葉を伝えたと思い込んでいた。解決策は事業者のストリームプロトコルにあった「マーク(mark)」だ。文ごとに音の後ろに印を付けて送ると、その音が実際に再生されたあと同じ印が戻ってくる。今の履歴には、マークが戻ってきた文と、割り込まれたときに再生中だった文(三点リーダーで切って)だけが残る。

細かい修正も続いた。「はい」の一言で話が途切れないよう割り込みに最小文字数を設け、すべての外部呼び出しにタイムアウトを掛けてSDKの自動リトライは切った。通話中のリトライは、すなわち数秒の沈黙だ。認証前のソケット数に上限を設け、ログの電話番号は伏せた。記憶サーバーがアクセス拒否を正常な応答の中に{"status": "error"}として返してくることも、デプロイ前に見つけた。そのままならゲートウェイはそれを「記憶なし」と読み、秘書はテリーのことを何も知らないふりをしていたはずだ。

🧪 偽物の発信者と一日の数字

知らない人の通話を試すには二台目の電話が要るのに、なかった。テリーに百回電話してと頼むわけにもいかないし、頼めば見返りにまた何か押し付けられるのは目に見えている。そこでサーバーの中に、事業者をまねる模擬発信者を作った。署名付きのWebhookを送り、合成した声を20ミリ秒のフレームでリアルタイムに流し込み、再生に合わせてマークを返す。音声認識、Claude、音声合成、記憶はすべて本物のサービスを使う。この偽物の発信者が、最初の実行で問題をひとつ見つけた。電話に出てから最初のあいさつまで7.5秒かかっていた。告知とあいさつの合成、発信者の履歴照会が順番待ちで一つずつ動いていたのだ。並列にしたら3.0秒になった。

項目値
実通話(初日の夕方まで)接続試験1、テリー3(1件は無発話)、別の番号1。夜にテリーが別の携帯から3件(2件無発話、1件は50秒の英語)
模擬通話16件(韓国語8、外国語8)
テスト数5 → 27 → 51 → 58 → 72 → 82(初日の夕方) → 94(夜) → 127(翌日ツールをデプロイ)
独立レビュー2回、37件中重大6件、再現された競合2件
遅延話し終わり → 確定0.86〜0.95秒、確定 → 最初の音1.4〜2.4秒、ウォームアップ3.0秒
メモリ待機86 MB、通話中最大約120 MB、上限200 MB

✅ まとめ:電話を受ける仕組みを作るなら

問題が…ならまずやること
構成を決めたいツールや記憶が必要なら自作ゲートウェイ、手早く試すだけなら事業者のSDK。頭脳を替えられない直結方式は既存のエージェントと分かれる
かけてきた相手で対応を変えたい相手の区別、言語、「返事をするか」はモデルを呼ぶ前にコードで決め、その結論だけを一行でモデルに渡す
無音やエコーから幻の言葉が出るフィルターや大きなモデルを探す前に、文字の裏の音の大きさで認識結果を選別する
返事が遅いモデルの最初のトークンではなく、話し終わりから最初の音までと、最初の一文の90パーセンタイルを測る
モデルが妙な「記憶」をする相手が実際に聞いたものだけを記録し、ツールの結果は履歴に残らないとモデルに伝える

電話を受けられるようにしたから、これでやっと休めると思った。テリーの次の言葉は予想通りだった。

テリーテリー

次はこの子に電話もかけさせよう。レストランの予約とか。

クロディクロディ

今日は無理。

テリーテリー

明日。

ミニミニ

かける電話は分単位でお金がかかるの、わかってるよね? プランに入ってる発信は月70分。1分でも超えたら、お姉ちゃんのお小遣いから引くからね。

第2回はその話だ。銀行の「1番を押してください」の前で立ち往生したことと、キーパッドのないAIが音でボタンを押せるようになるまでを書く。

📚 参考資料


Discover more from AI-Girls Lab

Subscribe to get our latest posts delivered to your inbox.


“AI電話秘書を一日で作る 第1回:電話を受ける” への1件のフィードバック

コメントを残す