はじめに
ここ2 年間、AI での電話音声応対をいろいろな形で作ってきました。
Twilio ConversationRelay + 生成 AI のように、文字起こしを Twilio 側で行ってテキストで生成 AI とやり取りするパターン。Speech to Speech のように、音声データをそのまま WebSocket で生成 AI に渡すパターン。どちらもそれなりに動くものは作れました。
でも結局、一長一短でした。ユースケースが限定的になったり、電話をかけた人に我慢を強いるような感じになったり。「AI が電話に出る」というデモとしては成立しても、「毎日かかってくる電話をこれに任せたい」と胸を張って言える手応えは、正直ありませんでした。
そんな中で TypeSafe AI の Jev を見たとき、これが今いちばん理想の IVR を作れるのではないか、と思いました。生成 AI で発生しがちな待ち時間を大幅に削れるのでは? そして、しっかりガードレールを効かせた形で生成 AI をコントロールできるのでは?
この記事は、その仮説を確かめるために、まず「待ち時間の短縮」に効く形で Jev を使った IVR を実装してみた記録です。実測値とコードを交えて、何が良くて、何がまだ足りないかを、なるべく正直に書きます。
コードは GitHub に置いています(TypeScript / Node.js)。記事中の抜粋はここから引いています。
これまでの作り方で、何がつらかったのか
先に、私が引っかかっていたポイントを整理しておきます。同じところで悩んでいる方には、たぶん心当たりがあると思います。
テキストで生成 AI とやり取りする構成(ConversationRelay + LLM)は、作りやすくて制御もしやすい。ただ、毎ターン LLM が「何をどう答えるか」を考えてから最初の 1 文字を出すので、話し終わってから声が返るまでに間ができます。しかもその間が一定ではなく、たまに長い。電話でいちばんつらいのは、この「たまに来る長い沈黙」です。かけた側は「聞こえてる?」と不安になります。
Speech to Speech の構成は、応答の出だしは速い。でも、文字を経由しないので、こちらのコードが会話に介入する余地が薄くなります。「この場合は必ずこの文言で」「この条件なら人につなぐ」といった業務ルールを、プロンプトのお願いベースで守ってもらうしかない。守ってくれることが多くても、守ってくれなかった 1 回が電話では事故になります。
つまり、速さと制御のどちらかを諦めていたわけです。
Jev は何が違うのか
Jev は文章を生成するモデルではありません。状態(state)と型付きの質問を渡すと、型付きの答えと確率を返すモデルです。TypeSafe はこれを「System One モデル」と呼んでいます。質問の型は 3 つだけ。
| 型 | 返るもの | 今回の使いどころ |
|---|---|---|
| Choice | 選択肢のどれか + 確率分布 + confidence | 意図、機器、症状 |
| Noul | Yes の確率 (0〜1) | 「今の発話は質問への答えか」「緊急か」「人が必要か」 |
| Score | 順序付きレベル上の重み付き位置 | (今回は未使用) |
たとえば「キッチンの蛇口から水漏れしてるんですけど」という発話に対して、「意図は?」「どの機器?」「どんな症状?」「緊急?」を 1 回のリクエストにまとめて投げると、全部並列で判定して返してくれます。返ってくるのは文章ではなく { intent: 'TROUBLE', equipment: 'FAUCET', symptom: 'LEAK', is_emergency: 0.53 } のような値です。
これを見て思ったのは、「判定」と「文章生成」を分けられる、ということでした。判定を Jev に任せて、その結果でコードが分岐し、文章は定型文か、必要なときだけ LLM に作らせる。速さと制御を両方取りにいける構成です。
作ったもの
題材は 水まわり修理業者の一次受付 にしました。水道(蛇口・キッチン・トイレ・お風呂・洗面台)、給湯器、排水・下水、水栓・トイレのリフォーム。お客様は電話番号にかけるか、ブラウザの「AI Call」ボタンで話しかけます。AI は切り分けから訪問の受付まで通しで対応して、人が出るのは有人転送のときだけ、という分担です。
実際の会話はこんな感じです。
お客様: 給湯器が故障してて、お湯が出ないんです
AI : 給湯器の故障ですね。お湯はまったく出ませんか、それとも途中で止まりますか。
お客様: 全然出ないです
AI : お風呂のリモコンにエラー番号は出ていますか。出ていれば番号を教えてください。
お客様: 111 が出てます
AI : ありがとうございます。 ← ここまで Jev + 定型文(即時)
ガスの元栓が開いているか確認してください。リモコンの運転を ← ここだけ OpenAI(約1秒後)
一度切って入れ直すと改善することがあります。
それでは、点検と修理にお伺いします。お伺いする日にちと時間のご希望を教えてください。
お客様: 明日の午前中
AI : 明日の午前中ですね。お名前を教えてください。
「どの機器の、どんな症状か」の切り分け、コールセンターのような状況確認、日時・名前・住所の聞き取り、復唱、緊急時の応急処置案内、有人転送、終話。このあたりは全部 Jev の判定 + 定型文 です。LLM(gpt-4o-mini)が文章を作るのは、聞き取った状況をふまえた「一言アドバイス」と、「止水栓はどこ?」のような具体的な質問への回答だけ。
最初は切り分けが終わったらすぐ日程の質問に進む作りだったのですが、自分で電話してみて「故障のことに何も触れないまま日程の話をされるのは冷たいな」と感じて、状況確認 → 一言アドバイス → 日程、の順に直しました。こういうところは、実際にかけてみないと分からないものですね。
構成
電話 / ブラウザ ─▶ Twilio ConversationRelay ─(WebSocket/JSON)─▶ Node.js サーバー
STT / TTS / 割り込み │
├─▶ Jev : 判定(毎ターン、1 リクエスト)
└─▶ OpenAI: 文章生成(必要なターンだけ)
ConversationRelay は、音声認識・音声合成・割り込み(barge-in)を Twilio 側でやってくれるので、サーバーは WebSocket の JSON だけ相手にすればよくて、音声を扱うコードを一切書かずに済みます。発話が確定すると {"type":"prompt","voicePrompt":"…","last":true} が届き、{"type":"text","token":"…","last":true} を返せば読み上げてくれます。
<Response>
<Connect action="https://example.com/twiml/handoff" method="POST">
<ConversationRelay url="wss://example.com/relay"
welcomeGreeting="お電話ありがとうございます。水まわりのトラブル受け付けです。…"
language="ja-JP" ttsProvider="Google" voice="ja-JP-Neural2-B"
interruptible="any" dtmfDetection="true" />
</Connect>
</Response>
サーバー側は大きく 3 つの部品でできています。Jev と話す部品、OpenAI と話す部品、そして「今回のターンはどちらで返すか」を決める状態遷移です。順に中身を見ていきます。
コードで見る 3 つの部品
1. Jev との連携(jevService.ts)
Jev の API はエンドポイントが 1 つだけです。POST https://api.typesafe.ai/v1/systemone に model・state・questions を送ると、questions と同じキーで答えが返ります。
state には「最新の発話」と「直近の会話」を JSON で入れています。「トイレです」のような短い答えも、直前に AI が何を聞いたかが recent_turns に入っていれば文脈で判定できます。
function buildRequest(model: string, input: ClassifyInput): JevRequest {
return {
model, // 'jev-latest'
state: {
latest_caller_utterance: input.utterance,
recent_turns: input.history.map((turn) => ({ speaker: turn.role, text: turn.text })),
},
questions: { ...IVR_QUESTIONS, ...extraQuestions(input.expecting) },
};
}
questions は、毎ターン送る 3 問と、会話の段階に応じて「相乗り」させる質問の合成です。
// 毎ターン送る 3 問(抜粋)
export const IVR_QUESTIONS = {
intent: {
type: 'choice',
instructions: 'This is a Japanese phone call to a plumbing / water-equipment repair company. What does the caller want in `latest_caller_utterance`? …',
criteria: {
TROUBLE: 'Reports a problem with water equipment or asks for a repair … 例:「キッチンの蛇口から水漏れしてる」「お湯が出ない」',
STATUS_CHECK: '…', KNOWLEDGE_FAQ: '…', HUMAN_HANDOFF: '…', CLOSING: '…',
UNKNOWN: 'None of the above, unintelligible, off-topic, …',
},
},
is_confident: {
type: 'noul',
instructions: 'Can you tell what kind of help the caller is asking for in `latest_caller_utterance`? Missing details such as dates, names or numbers do not matter; …',
criteria: { true: '…', false: 'Only fillers, fragments, noise, …' },
},
requires_human: { type: 'noul', instructions: '…', criteria: { true: '…', false: '…' }
},
};
// 会話の段階に応じて相乗りさせる質問
function extraQuestions(expecting: Expectation | undefined) {
if (expecting === 'triage') return TRIAGE_QUESTIONS; // equipment / symptom / is_emergency
if (expecting === undefined) return { ...TRIAGE_QUESTIONS, provides_datetime: … };
const [slotId, slotQuestion] = SLOT_QUESTIONS[expecting]; // detail / datetime / name / address / confirmation
return { [slotId]: slotQuestion };
}
日時待ちなら provides_datetime(Noul)、状況確認中なら answers_question(Noul)、復唱後なら confirmation(Choice: YES / NO / NEITHER)。Jev が判定するのは「答えになっているか」だけで、値そのものはお客様の発話からコードが抜き出します(正規表現やトリム)。Jev に文章を作らせない、値を生成させない。これがこの設計の芯です。
返ってきた JSON は zod で検証し、コードが扱いやすい JevJudgment という形に詰め替えます。equipment や confirmation のように「聞いたときだけ返る」答えは optional にしてあります。
const { answers } = await evaluate(body, signal); // zod で schema 検証済み
return {
intent: answers.intent.choice,
intentConfidence: answers.intent.confidence, // Choice の分布の集中度 (0〜1)
isConfident: answers.is_confident.noul, // Noul は Yes の確率そのもの
requiresHuman: answers.requires_human.noul,
provides: {
datetime: answers.provides_datetime?.noul,
name: answers.provides_name?.noul,
address: answers.provides_address?.noul,
detail: answers.answers_question?.noul,
},
confirmation: answers.confirmation && { choice: answers.confirmation.choice, confidence: answers.confirmation.confidence },
triage: answers.equipment && answers.symptom && answers.is_emergency && {
equipment: { choice: answers.equipment.choice, confidence: answers.equipment.confidence },
symptom: { choice: answers.symptom.choice, confidence: answers.symptom.confidence },
emergency: answers.is_emergency.noul,
},
latencyMs: elapsed(),
degraded: false,
};
電話で大事なのは「Jev が落ちても通話を落とさない」ことです。classify() は 絶対に例外を投げません。1 ターンに 1.5 秒の期限を切り(リトライを含めた合計)、429 / 5xx / 529 なら 1 回だけ再試行、それでもだめなら intent: 'UNKNOWN' の「縮退した判定」を返します。すると状態遷移は普通に聞き返しに入り、2 回続けば有人転送、という通常のフローに乗ります。お客様から見ると「すみません、もう一度お願いします」と言われただけで、システム障害には見えません。
async function classify(input: ClassifyInput): Promise<JevJudgment> {
const body = buildRequest(config.model, input);
const signal = AbortSignal.timeout(config.timeoutMs); // 1 ターン分の期限。リトライ間で共有
for (let attempt = 0; ; attempt += 1) {
try {
return toJudgment(await evaluate(body, signal));
} catch (error) {
if (attempt >= config.maxRetries || signal.aborted || !isRetryable(error)) {
logger.error({ err, attempt }, 'Jev classification failed; using degraded judgment');
return degradedJudgment(elapsed()); // UNKNOWN 扱い → 聞き返しへ
}
await sleep(RETRY_BACKOFF_MS * 2 ** attempt);
}
}
}
HTTP クライアントは undici で、keep-alive の接続を使い回しています。最初の 1 回だけ TLS 確立で 800ms ほどかかり、2 回目以降が 250ms 前後、というのが実測でした。
2. OpenAI との連携(openAiService.ts)
OpenAI を呼ぶ入口は 2 つだけです。「止水栓はどこ?」のような質問に答える answerFaq と、状況確認のあとに一言添える adviseOnTrouble。どちらも中身は「探す → 組み立てる → 生成する → 読み上げ用に整える」の 4 段です。
async function answerFaq(question: string, context: TroubleContext = {}, signal?: AbortSignal): Promise<FaqAnswer> {
// 1. 探す: Jev が判定したカテゴリ・機器に合うナレッジを優先して 3 件まで
const hits = searchKnowledge(knowledge, question, { category: context.category, equipment: context.equipment });
try {
// 2. 組み立てる: 参考情報 + エラーコード情報 + お客様の状況 + 質問
const user = buildUserPrompt(question, hits, context, codesIn(question));
// 3. 生成する
const content = await complete({ system, user, signal });
// 4. 読み上げ用に整える: Markdown/URL を除去し、100 文字で文の切れ目にそろえる
const text = toSpeech(content ?? '', config.maxChars);
if (text === '') throw new Error('OpenAI returned an empty completion');
return { text, ok: true, knowledgeIds: hits.map((h) => h.entry.id), latencyMs: elapsed() };
} catch (error) {
if (signal?.aborted) throw error; // 割り込みで中断されたときだけ投げる
logger.error({ err }, 'FAQ generation failed');
return { text: MESSAGES.faqFailed, ok: false, knowledgeIds: [], latencyMs: elapsed() };
}
}
生成そのものは Chat Completions の普通の呼び出しです。temperature: 0.2、max_tokens: 200、タイムアウト 8 秒。signal を渡しているのがポイントで、生成中にお客様が別のことを話し始めたら、WebSocket 側の AbortController からここまで中断が伝わります。
const client = new OpenAI({ apiKey: config.apiKey, timeout: config.timeoutMs, maxRetries: 1 });
const completion = await client.chat.completions.create(
{
model: config.model, // 'gpt-4o-mini'
temperature: 0.2,
max_tokens: 200,
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
},
{ signal },
);
システムプロンプトは「電話で読み上げられる」ことに全振りしています。100 文字以内・1〜2 文・記号や箇条書き禁止・参考情報にないことは言わない・分解や工具作業は勧めない。それでも守られないことがあるので、最後に toSpeech で Markdown や URL を剥がし、文字数を超えていたら直近の「。」で切っています。LLM のお願いと、コードでの強制を二重にかけるわけです。
ここで意識したのは、OpenAI 側に「会話を進める仕事」を一切させないことです。質問はしない、日時も聞かない、次に何を言うかも決めない。渡された材料で 1〜2 文を作るだけ。会話の主導権はずっと状態遷移(次の部品)が握っています。
3. どちらで返すかを、どう判断しているか(stateMachine.ts)
Jev の判定が返ったら、decide() という 純粋関数 が「次の状態」と「やること」を返します。入力は現在の状態・Jev の判定・発話・しきい値。副作用はありません。返す「やること」は 3 種類だけです。
| アクション | 誰が喋るか | 例 |
|---|---|---|
say |
定型文(Jev のみ) | 「給湯器の故障ですね。お湯はまったく出ませんか」「明日の午前中ですね。お名前を教えてください」 |
faq |
定型文の cushion → OpenAI の生成文 → 定型文の followUp | 「少々お調べします」→(生成)→「お伺いする日にちと時間は」 |
handoff |
定型文を喋って通話を終える/転送する | 有人転送、終話 |
判定の順番がそのまま設計方針になっているので、実物を載せます。
export function decide(state, judgment, utterance, policy): Decision {
// ① 人が必要なら、何よりも先に転送
if (judgment.requiresHuman >= policy.requiresHumanThreshold) {
return handoff(state, 'REQUIRES_HUMAN', MESSAGES.handoff);
}
// ② 聞きかけの質問(機器・症状・確認・日時・名前・住所・復唱)への答えなら、それを受け取って次へ
const filled = state.task && fillSlot(state, state.task, judgment, utterance, policy);
if (filled) return filled;
// ③ 「大丈夫です」「代わって」は要求ではないので is_confident が低い。intent の確信度で判定
if (judgment.intentConfidence >= policy.confidenceThreshold) {
if (judgment.intent === 'CLOSING') return handoff(state, 'COMPLETED', MESSAGES.closing);
if (judgment.intent === 'HUMAN_HANDOFF') return handoff(state, 'REQUESTED', MESSAGES.handoff);
}
// ④ 何を求めているか分からなければ聞き返す(2 回連続で有人転送)
if (judgment.intent === 'UNKNOWN' || judgment.isConfident < policy.confidenceThreshold) {
return clarify(state, policy);
}
// ⑤ 新しい用件
switch (judgment.intent) {
case 'TROUBLE': return advanceTriage(state, {}, judgment, utterance, policy); // 切り分け → say
case 'STATUS_CHECK': return proceed(state, { kind: 'STATUS_CHECK', awaiting: 'identifier' }, MESSAGES.askIdentifier);
case 'KNOWLEDGE_FAQ': return { // ← OpenAI に行く入口その 1
state: { ...state, clarifyFailures: 0 },
action: { kind: 'faq', purpose: 'answer', cushion: MESSAGES.faqCushion, question: utterance,
context: troubleContext(state), followUp: state.task && promptFor(state.task) },
};
// …
OpenAI に行くのは、コード上たった 2 か所です。
-
⑤ で
intentがKNOWLEDGE_FAQだったとき(purpose: 'answer')。「止水栓はどこ」「いくらかかる」「何時まで」など、情報で答える質問。 -
② の
fillSlotで、状況確認の最後の答えを受け取ったとき(purpose: 'advice')。聞き取った内容をまとめて一言アドバイスを作らせる。
それ以外は全部 say か handoff、つまり Jev の判定と定型文だけで返ります。「LLM を呼ぶかどうか」を LLM に聞いているわけではなく、Jev の Choice の結果と、状態機械の現在位置で決まっています。だから「どのターンで生成 AI が喋りうるか」を、コードを読めば列挙できます。これがガードレールとして効いている部分です。
しきい値は .env で変えられます。値は本物の Jev でのシミュレーションから決めた出発点で、通話ログを見て調整する前提です。
| しきい値 | 既定 | 使いどころ |
|---|---|---|
CONFIDENCE_THRESHOLD |
0.65 |
is_confident がこれ未満なら聞き返し。Choice(intent / equipment / symptom / confirmation)の confidence の採用基準にも使う |
REQUIRES_HUMAN_THRESHOLD |
0.7 | これ以上で有人転送 |
SLOT_THRESHOLD |
0.6 | 「今の発話は聞いていた答えか」(Noul)の採用基準 |
EMERGENCY_THRESHOLD |
0.7 | これ以上で応急処置を先に案内し、日時を飛ばして最短受付 |
最後に WebSocket 側。decide() が返したアクションを実行する部分が、Jev だけで済むターンと OpenAI を挟むターンの分岐点です。
async function run(action: Action, turn: number): Promise<void> {
switch (action.kind) {
case 'say': return say(action.text); // Jev + 定型文で完結
case 'faq': return runFaq(action, turn); // cushion を即時に喋り、OpenAI を待つ
case 'handoff': return handoff(action.reason, action.text);
}
}
runFaq の中身は、この記事の「本題」の節で出てくるとおりです。
速さの話 — 実測してみた
期待していた「待ち時間の短縮」は、どれくらい効いたのか。同じ 10 発話 × 2 周、接続ウォームアップ後の実測です(東京から)。比較対象は gpt-4o-mini に返答をストリーミング生成させる「LLM だけ」の構成。
表の p90 は 90 パーセンタイル、つまり「10 回に 9 回はこの時間以内に収まる(10 回に 1 回はこれより遅い)」という値です。電話では平均よりこちらのほうが体感に近いので、中央値と並べて載せています。
| 計測対象 | 中央値 | p90 | 最大 |
|---|---|---|---|
| Jev の判定が返るまで(=定型文を送れる時点) | 249ms | 342ms | 591ms |
| gpt-4o-mini: 最初のトークン | 522ms | 1,067ms | 1,422ms |
| gpt-4o-mini: 最初の 1 文が揃うまで(TTS を始められる時点) | 672ms | 1,228ms | 1,462ms |
| gpt-4o-mini: 全文 | 695ms | 1,235ms | 1,587ms |
中央値で 0.4 秒、p90 で 0.9 秒の差。数字以上に効いているのは ばらつき です。LLM は 10 回に 1 回は 1.2 秒かかりますが、Jev は最大でも 0.6 秒。冒頭に書いた「たまに来る長い沈黙」が、定型の会話からは消えました。
質問を増やしても遅くならない
「判定を 7 問に増やしたら遅くなるのでは」と心配して測ったのですが、杞憂でした。
| リクエスト | 入力トークン | 中央値 |
|---|---|---|
| 1 問だけ | 480 | 523ms |
| 3 問 | 1,067 | 524ms |
| 7 問(切り分けターン) | 2,040 | 524ms |
| 7 問 + 会話履歴 6 ターン | 2,385 | 525ms |
GET /v1/models(推論なし) |
— | 526ms |
(この日は我が家のネットワーク側が遅く、全体が 520ms 前後でした。)質問数にも state の量にも依存しません。応答ヘッダの x-envoy-upstream-service-time を見ると、推論そのものは 54〜138ms で、残りはエッジからオリジンまでの往復でした。なので「質問を絞って速くする」必要はなく、必要な判定は全部同じリクエストに詰めてよい、と割り切れます。
本題: 生成 AI に考えさせる前に、まず一言返す
LLM に文章を作らせる場面では、どうしても 1〜2 秒かかります。以前の構成では、この間ずっと無音でした。かけた人は「聞こえてる?」と不安になり、こちらは「少々お待ちください」と言いたくても、その一言を出すためにすら LLM の最初のトークンを待つしかありませんでした。
Jev を使うと、判定が返った時点(≈250ms)で、判定結果に応じた「つなぎの一言」を先に喋れます。
お客様: 止水栓ってどこですか
├─ 250ms Jev: intent = KNOWLEDGE_FAQ → AI「少々お調べしますのでお待ちください。」 ← 定型文
└─ 1.3s OpenAI(ナレッジ検索+生成) → AI「トイレの止水栓はタンクの横の壁か…」 ← 生成文
お客様: 111 が出てます(状況確認の最後の答え)
├─ 230ms Jev: answers_question = 0.96 → AI「ありがとうございます。」 ← 定型文
└─ 1.5s OpenAI → AI「ガスの元栓が開いているか確認して…」 ← 生成文
質問なら「少々お調べします」、確認の答えなら「ありがとうございます」、切り分けが終わったら「給湯器の故障ですね」。どれも人間のオペレーターが自然に挟む相槌です。これが 0.25 秒で出るだけで、そのあとの 1 秒の生成待ちは「間」ではなく「調べてくれている時間」に変わります。Jev の速さは、定型ターンを速くするためというより、生成 AI のターンの無音を埋めるために効いている、というのが実装してみての実感です。
実装は、状態遷移が返すアクションに cushion(先に喋る定型文)と followUp(生成文のあとに続ける定型文)を持たせるだけです。
// stateMachine.ts — 純粋関数。状況確認が終わったターンで返すアクション
function advise(state, task, cushion: string): Decision {
const report = [task.report, ...task.questions.map((q, i) => `Q: ${q}\nA: ${task.answers[i]}`)].join('\n');
return {
state: { ...state, task: { kind: 'REPAIR', awaiting: 'datetime', triage: task.triage } },
action: {
kind: 'faq',
purpose: 'advice',
cushion, // 「ありがとうございます。」
question: report, // 最初の申告 + 確認の Q/A を LLM へ
context: { category, equipment, symptom }, // ナレッジ検索の絞り込みに使う
followUp: `${MESSAGES.visitIntro}${MESSAGES.askDatetime}`, // 「それでは、点検と修理にお伺いします。…」
},
};
}
// relayHandler.ts — WebSocket 側。cushion を先に送ってから LLM を待つ
async function runFaq(action: FaqAction, turn: number): Promise<void> {
say(action.cushion, { interruptible: false }); // ← 即時
const controller = new AbortController();
pendingFaq = controller;
try {
const answer = action.purpose === 'advice'
? await deps.openAi.adviseOnTrouble(action.question, action.context, controller.signal)
: await deps.openAi.answerFaq(action.question, action.context, controller.signal);
if (!isCurrent(turn)) return; // 生成中に次の発話が来ていたら捨てる
say([answer.text, action.followUp].filter(Boolean).join(''));
} catch (error) {
if (controller.signal.aborted || !isCurrent(turn)) return;
say(MESSAGES.faqFailed);
}
}
電話ならではで効いた細かい点も書いておきます。
-
cushion は
interruptible: false。短い定型文なので、割り込みで途切れると「少々…」で終わって不自然になります。 -
生成中の相槌は無視する。「少々お調べします」に対する「はい」で LLM 生成をキャンセルしないよう、一定文字数未満の発話は流します。長い発話(新しい要求)が来たら
AbortControllerで生成を中断し、古い答えは捨てます。 -
失敗時は謝罪ではなく省略。一言アドバイスの生成に失敗したら、何も言わず日程の質問に進みます。FAQ 回答の失敗だけ謝罪文にしています。
-
followUp で文脈を戻す。「止水栓はどこ?」に答えたあと、聞きかけだった日時の質問を同じ発話の末尾に付けて会話を戻します。
ガードレールの話 — 生成 AI に渡す「事実」は表から引く
もう一つの仮説、「しっかりガードレールを効かせた形で生成 AI をコントロールできるのでは」についても、今回の範囲で分かったことを書きます。
Jev の判定でコードが分岐する構成にすると、生成 AI が喋る場面がはっきり限定されます。今回なら「一言アドバイス」と「具体的な質問への回答」だけ。分岐も文言も日程の聞き方も、LLM の気分で変わることがありません。
その上で、LLM が喋る場面の品質を左右するのは「何を渡すか」です。給湯器の故障なら、状況確認で「お風呂のリモコンにエラー番号は出ていますか」と聞いて、答えに含まれるコードをこちらの表と突き合わせてからプロンプトに入れます。
// src/errorCodes.json — コードを触らずに追加・修正できる
{
"code": "111",
"equipment": "WATER_HEATER",
"meaning": "点火不良(お湯を沸かす火がつかない)",
"action": "ガスの元栓が開いているか、ガスメーターが遮断されていないかを確認し、お風呂のリモコンの運転を一度切って入れ直してください。",
"needsVisit": "再発する場合は点検が必要です。"
}
// 発話から 3〜4 桁の数字(と E1 形式)を拾い、表と照合する
export function findErrorCodes(text: string, entries: readonly ErrorCodeEntry[]): readonly ErrorCodeMatch[] {
const normalized = text.normalize('NFKC').toUpperCase().replace(/[\s\-ー-]/g, '');
const codes = [...new Set(normalized.match(CODE_PATTERN) ?? [])];
return codes.map((code) => ({ code, entry: entries.find((entry) => entry.code === code) }));
}
ここで大事にしたのは、表にないコードも「登録なし」として渡すことです。何も渡さないと、LLM は一般知識で意味を推測してしまいます。「コード 999: 登録なし。意味を推測せず、訪問時に確認すると伝える」と書いておくと、実際にこう答えました。
| お客様の答え | AI のアドバイス(gpt-4o-mini) |
|---|---|
| 「111 が出てます」 | ガスの元栓が開いているか確認してください。リモコンの運転を一度切って入れ直すと改善することがあります。 |
| 「888 です」 | 点検時期のお知らせです。そのままお使いいただけますので、定期点検のご予約をおすすめします。 |
| 「999 です」(表にない) | エラーコードについては、訪問時に確認します。 |
| 「エラーは出てないです」 | お風呂のリモコンの電源やガスの元栓を確認してください。 |
判定は Jev、値の抜き出しと照合はコード、文章にするのは LLM。エラーコードでもこの分担は変わりません。表は JSON なので、扱うメーカーが増えたら現場の人が行を足すだけで済みます(3 件は一般的な給湯器の表示をもとにしたサンプルなので、運用するなら取扱説明書に合わせて書き換えてください)。
ちなみに LLM がコードを「八百八十八」と読み上げたので、「一桁ずつ読む」という指示を足しました。電話は本当にこういう細部の積み重ねです。
Jev で判定しているもの一覧
最終的に Jev に聞いている判定です。全部合わせても 1 ターン 1 リクエスト、入力 1,100〜2,000 トークン(Jev は入力のみ課金、$0.042 / 100 万トークン)。
| 段階 | 質問 | 型 | コード側の処理 |
|---|---|---|---|
| 毎ターン | intent |
Choice | TROUBLE / STATUS_CHECK / KNOWLEDGE_FAQ / HUMAN_HANDOFF / CLOSING / UNKNOWN |
| 毎ターン | is_confident |
Noul | 0.65 未満なら聞き返し(2 回連続で有人転送) |
| 毎ターン | requires_human |
Noul | 0.7 以上で有人転送 |
| 切り分け中 | equipment |
Choice | 蛇口 / キッチン / トイレ / お風呂 / 洗面台 / 給湯器 / 屋外排水 / ウォシュレット / 不明 |
| 切り分け中 | symptom |
Choice | 水漏れ / 詰まり / 故障 / 交換 / 高圧洗浄 / 不明 |
| 切り分け中 | is_emergency |
Noul | 0.7 以上で応急処置の定型文 → 日時を飛ばして最短受付 |
| 状況確認中 | answers_question |
Noul | 直前の質問への答えか。答えは発話のまま保持(エラーコードはここから拾う) |
| 日時・名前・住所待ち | provides_* |
Noul | 含まれていれば正規表現で抜き出す |
| 復唱後 | confirmation |
Choice | YES / NO / NEITHER |
業務カテゴリ(水道・給湯器・排水・リフォーム)は Jev に直接選ばせず、機器 × 症状の組み合わせからコードの表で決めています。「トイレの水漏れ」は水道、「トイレ本体の交換」はリフォーム、のようにカテゴリは組み合わせで決まるからです。ルールはコードに、判定はモデルに。
Jev を使ってみて、つまずいたところ
うまくいった話ばかりだと参考にならないので、つまずいたところも。
質問文の言い回しで確率が大きく変わる
最初 is_confident を「自動システムが聞き返さずに act on できるほど明確か」と書いたら、「来週の金曜日に予約をお願いしたいんですけど」が 0.20 でした。日時や名前が揃っていないから「act できない」と解釈されたようです。「どんな種類の助けを求めているか分かるか。詳細の有無は問わない」に書き直したら 0.95。
Jev は指示どおりに答えていただけで、悪いのは私の質問でした。しきい値をいじる前に質問文を疑う、が正解です。
「大丈夫です」は要求ではない
終話(「大丈夫です」)や「オペレーターに代わって」は is_confident が 0.2 前後になりました。「どんな助けを求めているか」を聞いているので当然で、これらは Choice の confidence(分布の集中度)でゲートするようにしました。1 つのしきい値で全部を裁こうとしない。
緊急判定と有人転送がぶつかる
requires_human の基準に「緊急事態・事故」を入れていたら、「水が止まらない、床が水浸し」が有人転送になってしまいました。自動受付で扱う水の緊急事態は除外する、と基準に明記して解決。判定基準には、業務の役割分担そのものを書く必要がありそうです。
日本語は問題なかった
公式ドキュメントには「英語が主で CJK は同等ではない」とあります。身構えていたのですが、今回の範囲(意図分類、機器・症状、Yes/No 判定)では試した発話はほぼ想定どおりでした。「トイレです」「わかりません」のような短い答えも、recent_turns に直前の質問を入れておけば文脈で判定できます。
コストはどうか
期待していた方には申し訳ないのですが、gpt-4o-mini が安すぎて差は出ません。1 ターンあたり Jev 約 $0.00005、LLM 最小構成 約 $0.00003、ナレッジをプロンプトに同梱した現実的な LLM 構成で約 $0.00013。通話料(ConversationRelay)が桁違いに大きいので、コストを下げたいなら通話を短くするほうが効きます。Jev の価値は、速さの安定と、分岐と文言をコードが握れることにあります。
おわりに
過去いろいろ作ってきて、「速さ」と「制御」のどちらかを諦めていた電話 AI が、Jev を挟むことで両方を取りにいける手応えを得られました。
-
定型の会話は Jev の判定 + 定型文で返す。LLM を通らないので速く、ぶれない。
-
生成 AI に考えさせるときも、Jev の判定が返った 0.25 秒の時点で「少々お調べします」「ありがとうございます」と一言返す。無音が「間」ではなく「調べてくれている時間」になる。
-
判定は Jev、値の抜き出しと業務ルールはコード、事実は表から引いて LLM に渡す。生成 AI が自由に喋る場面を限定できる。
今回は「待ち時間の短縮」に軸足を置きました。次は、もう一つの仮説だった「ガードレール」の側、つまり Jev の確率をどこまで業務判断に使えるか(有人転送の判断、聞き返しの回数、緊急度のしきい値)を、実際の通話ログで詰めていこうと思っています。
同じように電話 AI で悩んでいる方の参考になれば嬉しいです。
コードはこちらです。セットアップ手順は README にまとめています。
TypeSafe のドキュメントは https://docs.typesafe.ai/ 、ConversationRelay は https://www.twilio.com/docs/voice/conversationrelay を参照してください。