この記事は「渡部 Advent Calendar 2025」の3日目の記事です。
私は対話が苦手だ。みたいな話ではなく対話って難しい営みだよなと思った話です。難しいというか面白いなと思ったポイントを書いてみます。
アドカレ2日続けてポエムかよって思いもあるが溜まっていたストックなので消化していく。
タスク指向型と非タスク指向型がある
「対話」は、大きく分けて2種類があります。ひとつはタスク指向型対話。もうひとつは非タスク指向型対話です。
タスク指向型は「目的を達成するための会話」です。たとえば「レストランを予約する」「サーバの障害を報告する」「買い物リストを作る」といった、 明確なゴールを共有した上で情報を交換するタイプです。一方で、非タスク指向型は「目的のない会話」です。雑談、相談、共感、愚痴、アイスブレイクなどがこれに当たります。ゴールは定義されず、むしろ会話そのものが目的となる。
この2種類の対話は、心理的にも文化的にも異なりますが、技術的に見てもまったく別の難しさを持っています。タスク指向型は「構造化されているからこそ難しい」。非タスク指向型は「構造化されていないから難しい」。いずれも人間の自然な対話を再現しようとすると、システム設計というか実装がとてつもなく難しいということ気づきます。
ちなみにこの辺を体系だって学ぼうとすると大学の教科書みたいなのがたくさん出てきます。そしてもれなく数学がついてきます。
現実世界とのインターフェースの複雑さ
タスク指向型の対話システムは、単に言葉を理解するだけではなく、外部システムとの双方向連携が必須です。たとえば「明日の19時に渋谷でイタリアンを予約して」と言われた場合、内部的には次のような処理が発生します。
- エンティティ抽出(意図・場所・時間・料理ジャンルの検出)
- スロット補完(未指定パラメータの確認:「人数は何名ですか?」)
- 外部API呼び出し(予約DB・在庫システムなど)
- エラー処理(満席・通信失敗・入力不備など)
- 状態更新とレスポンス生成
これらは単なる自然言語処理ではなく、トランザクション制御・状態遷移管理・例外処理を含む複合的な問題です。つまり、対話はAPI設計や分散システム設計と同質の難易度を持つことになります。
状態管理の爆発的複雑性
タスク指向型では「対話状態」を常に維持しなければなりません。しかし、ユーザーが一度に複数のスロットを更新したり、順序を入れ替えたり、途中で意図を変えたりすると、状態空間が爆発します。
例: 「やっぱり6時じゃなくて7時にして。場所も新宿の方がいいかも。」
このような修正発話は、単純な状態遷移表では表現できません。また、「6時→7時に変更」という操作は差分更新を要するため、自然言語理解と状態管理を密結合させる必要があります。この結合部のモデリングが非常に難しく、わずかな設計ミスで「状態の破綻」が起きることになります。以下はこの辺を考慮せずに実装してる例です。状態数が指数関数的に爆発していくのがわかりやすいかなと思います。
「変更したいスロットの組み合わせ」を全部状態に持つナイーブ実装
from enum import Enum, auto from dataclasses import dataclass from typing import Optional class DialogState(Enum): START = auto() FILLING_SLOTS = auto() CONFIRM = auto() # 変更系(地獄の入り口) CHANGE_TIME = auto() CHANGE_PLACE = auto() CHANGE_PEOPLE = auto() CHANGE_BUDGET = auto() CHANGE_TIME_PLACE = auto() CHANGE_TIME_PEOPLE = auto() CHANGE_TIME_BUDGET = auto() CHANGE_PLACE_PEOPLE = auto() CHANGE_PLACE_BUDGET = auto() CHANGE_PEOPLE_BUDGET = auto() CHANGE_TIME_PLACE_PEOPLE = auto() CHANGE_TIME_PLACE_BUDGET = auto() CHANGE_TIME_PEOPLE_BUDGET = auto() CHANGE_PLACE_PEOPLE_BUDGET = auto() CHANGE_TIME_PLACE_PEOPLE_BUDGET = auto() @dataclass class BookingSlots: time: Optional[str] = None place: Optional[str] = None people: Optional[int] = None budget: Optional[int] = None @dataclass class DialogContext: state: DialogState slots: BookingSlots def handle_user_utterance(ctx: DialogContext, utterance: str) -> DialogContext: """ 超ナイーブな実装例。 「やっぱり時間変えて / 場所も… / 予算も…」みたいな修正発話を すべて状態として分岐させようとしたらどうなるか、をあえて書いたもの。 """ u = utterance # 例: 変更意図検出(あり得ないくらい雑な実装) wants_time = "時間" in u or "時" in u wants_place = "場所" in u or "新宿" in u or "渋谷" in u wants_people = "人数" in u or "人" in u wants_budget = "予算" in u or "円" in u # 組み合わせ爆発 if wants_time and not wants_place and not wants_people and not wants_budget: ctx.state = DialogState.CHANGE_TIME elif wants_place and not wants_time and not wants_people and not wants_budget: ctx.state = DialogState.CHANGE_PLACE elif wants_people and not wants_time and not wants_place and not wants_budget: ctx.state = DialogState.CHANGE_PEOPLE elif wants_budget and not wants_time and not wants_place and not wants_people: ctx.state = DialogState.CHANGE_BUDGET elif wants_time and wants_place and not wants_people and not wants_budget: ctx.state = DialogState.CHANGE_TIME_PLACE elif wants_time and wants_people and not wants_place and not wants_budget: ctx.state = DialogState.CHANGE_TIME_PEOPLE elif wants_time and wants_budget and not wants_place and not wants_people: ctx.state = DialogState.CHANGE_TIME_BUDGET elif wants_place and wants_people and not wants_time and not wants_budget: ctx.state = DialogState.CHANGE_PLACE_PEOPLE elif wants_place and wants_budget and not wants_time and not wants_people: ctx.state = DialogState.CHANGE_PLACE_BUDGET elif wants_people and wants_budget and not wants_time and not wants_place: ctx.state = DialogState.CHANGE_PEOPLE_BUDGET elif wants_time and wants_place and wants_people and not wants_budget: ctx.state = DialogState.CHANGE_TIME_PLACE_PEOPLE elif wants_time and wants_place and wants_budget and not wants_people: ctx.state = DialogState.CHANGE_TIME_PLACE_BUDGET elif wants_time and wants_people and wants_budget and not wants_place: ctx.state = DialogState.CHANGE_TIME_PEOPLE_BUDGET elif wants_place and wants_people and wants_budget and not wants_time: ctx.state = DialogState.CHANGE_PLACE_PEOPLE_BUDGET elif wants_time and wants_place and wants_people and wants_budget: ctx.state = DialogState.CHANGE_TIME_PLACE_PEOPLE_BUDGET # …ここからさらに「今は time だけ埋まっている状態で change_place が来た場合」 # みたいな条件を足し始めると、本当に終わらない if-else ツリーになる return ctx
エラーハンドリングの難易度
タスク指向型では、成功/失敗が明確に定義されている分だけ、失敗時のリカバリ戦略が重要になります。
- 外部APIが落ちている場合にどう案内するか
- 必須スロットが永遠に埋まらない場合にどうフェイルするか
- 同じ情報を繰り返し要求されたときにどう応答を変えるか
これらは人間なら「空気を読んで」対応できますが、システムでは逐次的に条件分岐を設けなければならない。その結果、if文の森が形成され、保守性が急速に悪化します。
if intent == "reserve": if api_down("restaurant"): if user_said("急いでる"): apologize_fast() else: fallback_suggestion() else: if not slots.time: if inferred_time_from_history(): fill_from_context() else: ask_time() elif not slots.people: ask_people() else: if is_available(slots): if user_said("やっぱりキャンセル"): cancel_flow() else: confirm() else: if user_said("別の場所で探して"): search_area(user_specified_or_default()) else: ask_alternative_time() else: if intent == "smalltalk": handle_chitchat() else: fallback()
例の波動拳にも見えてきそうですね。
人間の曖昧さとルールの衝突
「タスク」とはいえ、人間の発話は常に曖昧です。「安いやつ」「近いところ」「いい感じの」といった定性的表現は、どんなにスロットを定義しても欠落します。これを解決しようとすると、
- 辞書ベース+確率モデルのハイブリッド化
- NLUの信頼度スコアに基づくフォールバック戦略
- 対話履歴による再解釈(contextual re-interpretation)
などの高度な仕組みが必要になり、最終的には言語理解+計画立案+例外処理の総合問題に行き着きます。
まとめ
対話する時って実はこんなにも高度な営みが裏では行われていたというのを対話システムに携わることで気づきました。これを全てのパターンで実装しようとすると凄まじい計算量になってレスポンスが遅すぎて会話にならないんじゃないかなと思います。これをいい感じにしていく対話システムって本当にすごいですよ。
明日は渡部さんによる4日目の記事です。何が書かれるんでしょうかね。