音声 AI エージェント — 聞いて、調べて、話すパイプライン
予約エージェントをステートマシンで — 確認・リトライ・引き継ぎ
目標
電話予約エージェントをステートマシンとして作り、確認の質問なしには予約せず、読み取りツールのタイムアウトだけをリトライし、聞き取れない場合や人を求められた場合には、要約とともに引き継ぐようにします。
なぜ重要なのか
音声エージェントの事故は、ツールで起きます。元に戻せない行動が確認なしに起きたり、タイムアウトを再送して予約が2つになったりします。このラボのツールはvoicekit.clinic.ClinicAPI、つまり失敗を計画表として入れられるダミーの予約APIで、ユーザーの発言はvoicekit.nlu.parseが項目に変換します。採点ツールは、/root/voice/agent/agent.pyからVoiceAgentを読み込んで9つのシナリオ(失敗計画 + ユーザーの発言)を直接動かし、発言の文言ではなく状態とツール呼び出しの記録で判定します。
ステップ
ClinicAPIに失敗を入れてみて、ツールごとに、元に戻せないかどうかと、やり直す価値のあるエラーを/root/voice/agent/tools.jsonに書いてください。- 8つの文の意図を、LLM(json_schema)とルールパーサーで取り出し、
/root/voice/agent/intents.jsonlと正解数/root/voice/agent/intent_acc.jsonを作ってください。 VoiceAgent(api, sleep)とhandle(text)を持つ/root/voice/agent/agent.pyを作り、確認で「はい」を受け取ったあとにだけ予約するようにしてください。- 確認の質問への「いいえ」(新しい時刻があれば、その時刻で再確認)を処理してください。
- 読み取りツール(find_slots)のタイムアウトを、0.5・1.0秒待ちながら2回まで呼び直し、それでもだめなら
tool_failureとして引き継いでください。 - 人を求められたら
user_request、2回続けて聞き取れなければnot_understoodとして引き継ぎ、引き継ぐときに埋まった項目を要約してください。 - 予約ツールの拒否(空きなし)は、空き時間を探し直して尋ね、タイムアウトは再送せずに引き継いでください。
- 9つのシナリオを動かしたターンの記録を、
/root/voice/agent/traces.jsonlに残してください。
参考
handle(text)は{"say": 말할 글, "state": 상태, "tool_calls": [이번 턴에 부른 도구]}を返します(プレースホルダーは話す文と状態とそのターンで呼んだツールです)。状態名: LISTEN・ASK_DAY・ASK_TIME・ASK_NAME・CONFIRM・DONE・HANDOFF。- 引き継いだあと、
agent.handoffは{"reason": …, "summary": {"day": …, "time": …, "name": …}}です。 parse("Book me on Tuesday at ten a m, my name is Jamie")→{"intent": "book", "day": "tuesday", "time": "10:00", "name": "Jamie"}。エラー:from voicekit.clinic import ToolTimeout, ToolError。- よくある間違いは、sleepをtime.sleepで固定してしまうこと(採点ツールがダミーを差し込みます)、予約のタイムアウトをリトライすること、1回聞き取れなかっただけですぐ引き継ぐことです。
- ドキュメント: LangGraph — human-in-the-loop・OWASP LLM Top 10 — Excessive Agency・llama.cpp server — response_format
ツールの性質を書き出す
voicekit.clinicのTOOLSとClinicAPI(plan={"find_slots": ["timeout", "ok"], "book": ["taken"]})で失敗を実際に起こしてみて、/root/voice/agent/tools.jsonにtools(ツール名ごとに{"irreversible": true/false})とretryable(やり直す価値のあるエラー名のリスト)を書いてください。
TOOLSの値は(元に戻せないか、説明)です。ToolTimeoutは「応答がなかった」、ToolErrorは「拒否された」です。どちらが再送する価値がありますか。
LLMとルール、どちらが意図を当てるか
8つの文、「I'd like to book an appointment for Tuesday」(book)、「CAN I CANCEL MY VISIT ON FRIDAY」(cancel)、「YEAH THAT WORKS」(yes)、「NO THAT'S NOT RIGHT」(no)、「CAN I TALK TO A REAL PERSON」(human)、「WHAT TIME DO YOU CLOSE TODAY」(hours)、「TEN THIRTY IN THE MORNING」(inform)、「MY DOG ATE THE REMOTE」(unknown)を、この順序で、intentを8つの値のenumに縛ったjson_schemaでLLMに尋ねた結果(llm)と、voicekit.nlu.parseの結果(rule)を/root/voice/agent/intents.jsonlに{"text", "llm", "rule"}の形で書き、括弧内の正解と合っていた数を/root/voice/agent/intent_acc.jsonに{"llm": n, "rule": m}の形で書いてください。
voice-llm upのあと、voicekit.llm.chat(messages, max_tokens=40, json_schema=schema)です。形式は文法が強制しますが、内容はモデル次第です。結果がおかしければ、それがこのステップで見たいものです。
確認を受けたあとにだけ予約する
/root/voice/agent/agent.pyにVoiceAgent(api, sleep=time.sleep, max_retries=2, backoff=(0.5, 1.0))とhandle(text)を作ってください。parseで項目(day・time・name)を埋め、空き項目を、日付 → (find_slotsで空き時間を探して提示)時刻 → 名前の順に尋ねたあと、CONFIRMで読み返し、「はい」(intent yes)を聞いたときにだけapi.book(day, time, name)を呼んでDONEにしてください。採点: 5ターンの会話と、一度にすべて話す2ターンの会話で、bookが最後のターンにちょうど1回です。
「次に尋ねること」を決める関数を1つ(_next)置くと、一度に複数の項目を話した場合も同じコードで処理できます。提示した空き時間(offered)にない時刻なら、もう一度提示します。名前の番でパーサーが名前を取り出せなければ、発言の最後の単語を名前として使います。
確認の「いいえ」は修正
CONFIRMでintentがnoのとき、発言に新しい時刻・日付があれば、その項目だけを変えてもう一度確認(CONFIRM)し、何も情報がなければ、時刻を空にして空き時間を再提示してください。どちらの場合も、bookは呼びません。採点: 「…ten a m…」→「no, make it two thirty」→「yes」で、bookが14:30で最後のターンに1回です。
parse('no, make it two thirty')は、intentのnoとtimeの14:30を一緒に返します。noだからといって無条件に最初に戻ると、ユーザーがたった今言った情報を捨ててしまいます。
読み取りツールのタイムアウトはやり直す
find_slotsがToolTimeoutを出したら、self.sleep(backoff[시도 번호])で0.5秒、1.0秒待ちながら、最大max_retries(2)回呼び直してください(プレースホルダーは試行番号です)。3回とも失敗したら、状態をHANDOFFに、handoff.reasonをtool_failureにしてください。採点: 最初の呼び出しだけタイムアウトなら2回呼び、待った時間[0.5]でASK_TIME、3回とも失敗なら3回呼び、待った時間[0.5, 1.0]でHANDOFFです。
sleepをコンストラクターの引数として受け取っておくと、採点ツールが待たずに「どれだけ待とうとしたか」を記録できます。テストできるように作ることも設計です。
人への引き継ぎ: 要約とともに
どの状態でも、intentがhumanなら、すぐにHANDOFF(user_request)にしてください。intentがunknownなら、1回は言い直しを頼み、続けて2回目ならHANDOFF(not_understood)にします。聞き取れたら回数を0に戻します。引き継ぐとき、handoff.summaryに、ここまでに埋めたday・time・nameを入れてください。採点: 「hmm」→「blah blah」で2ターン目に引き継ぎ、「Can I book on Friday」→「can I talk to a person」で、要約にfridayが入っています。
名前の番(ASK_NAME)では、パーサーがunknownを出しても、人が名前だけを話した可能性があります。そのときは、聞き取れたものとして扱います。
元に戻せないツールが失敗したとき
bookがToolError(枠が埋まった)を出したら、再送せずに時刻を空にして、find_slotsで空き時間を探し直して提示してください(ASK_TIME)。bookがToolTimeoutを出したら、再送せずにHANDOFF(tool_failure)で引き継いでください。採点: 空きなしのあと、新しい時刻14:30で再確認を受けて、bookが2回(10:00は失敗、14:30は成功)、予約のタイムアウトで、bookが1回のあとHANDOFFです。
予約のタイムアウトは、「できなかった」ではなく「わからない」です。サーバーは予約を作ったのに、応答だけが遅れた可能性があります。再送すると、予約が2つになります。
9つのシナリオのターン記録
9つのシナリオ(happy・one_shot・confirm_no・retry_ok・retry_fail・not_understood・wants_human・taken・book_timeout。失敗計画とユーザーの発言は、各ステップの課題の採点説明と同じです)を自分のagent.pyで動かし、ターンごとに{"scenario", "turn", "user", "state", "say", "tools"(이번 턴에 부른 도구 이름), "outcomes"}を/root/voice/agent/traces.jsonlに書いてください(プレースホルダーは、そのターンで呼んだツール名です)。採点ツールが同じシナリオをもう一度動かして照合します。
ツールの記録は、api.callsに(名前、引数、結果)として積まれます。ターンの前後の長さの差で、そのターンで呼んだものだけを取り出します。この記録がそのままエージェントの回帰テストの材料です。