Featured image of post コードパペッティア【Router】一人三役をこなしたがる案内人形〜混ざる声と、最初の制御棒〜

コードパペッティア【Router】一人三役をこなしたがる案内人形〜混ざる声と、最初の制御棒〜

宿泊予約・観光案内・クレーム対応を一台でこなそうとする受付人形セレスト。役割の境界が混ざり合い沈黙してしまった人形に対し、Routerパターンを用いて関心事を分離し、単一責任の専門エージェントへ処理を委譲する設計をPythonで実装します。

第1幕: 予兆と調律 ── 一人三役の受付

劇場の奥から届けられた差出人不明の使者の手紙を、マエストロは窓辺の薄暗い光の中で黙って読み終えました。そして、私の方を振り返ることなく、静かに声をかけてきたのです。「行って、まず見てこい。触るのはそれからだ。アンも連れて行け。あれの目は、狂いに気づくのがお前より早い」。私はマエストロの言葉に従い、アンを伴って劇場を後にしました。

私たちが向かったのは、街の歴史を見守り続けてきた老舗ホテル「ベルヴュー」でした。夕刻のロビーは、チェックインを急ぐ旅人たちの熱気とざわめきに包まれています。高い天井から吊り下げられた真鍮のシャンデリアが柔らかな光を投げかけ、赤い絨毯の上をいくつもの革製の旅行鞄が滑るように運ばれていきます。フロントのカウンターの奥には、今回の調整対象である自動受付人形が佇んでいました。

その人形の名前は「セレスト」。頭部の背後に扇状に広がる精巧な真鍮の集音ラッパが特徴で、客の話し声がする方角へ耳を傾けるように、首を小刻みに傾ける仕草をしていました。私は心の中で、彼女を「なんでも屋さん」と呼ぶことにしました。セレストはロビーの様々な声を拾い上げ、ホテルの宿泊予約、周辺の観光案内、そして宿泊客からの不満対応という、全く異なる三つの役割を一人でこなすように設計されていました。その頭脳には、人間の言葉を理解して適切な言葉を紡ぎ出す大規模言語モデル(LLM)(大量のテキストデータから言語のパターンを学習し、高度なテキスト生成や推論を行うための深層学習モデル)が据えられています。

しかし、その夕刻のラッシュの中で、セレストの振る舞いには明らかな狂いが生じていました。

まず、長旅に疲れた様子の一組の老夫婦がカウンターの前に立ち、「予約してある部屋へ案内してほしい」と告げたときのことです。セレストは彼らの名前を確認しようとしましたが、突然その水晶の目を細め、場違いな調子で語り始めました。「当ホテルの周辺には有名な時計塔がございまして、ご観光には最適な季節でございます」。老夫婦は顔を見合わせ、戸惑ったように眉をひそめました。部屋の案内をしてほしいという要望に対し、セレストの頭脳の中で観光案内の役割が混ざり込んでしまったようでした。

次に、懐中時計を何度も気にしている急ぎ足の商用客が、「近くで今すぐ開いている食事処を教えてくれ」と尋ねました。セレストは彼を助けるどころか、冷淡な口調でこう返したのです。「恐れ入ります、ご予約番号をいま一度確認させていただけますでしょうか」。客は貧乏ゆすりを始め、諦めたように首を振って立ち去っていきました。

極めつけは、部屋の不具合を訴えに来た宿泊客の場面でした。「部屋の暖房が効かない。お湯も出なくて困っているんだ」。男が苛立ちを隠さずにそう告げると、セレストは信じられないことに、満面の笑みを浮かべるような声でこう答えたのです。「まあ、それは残念でございますね! ところで当ホテルから徒歩圏内には美しい庭園がございますので、散策されてみては――」。 「ふざけているのか!」と男が声を荒げた瞬間、ロビーの奥からホテルの女将であるクレアさんが飛んできて、何度も頭を下げて平謝りしました。

その様子を横で見ていたアンが、いたずらっぽく微笑みながらセレストの前に歩み寄りました。アンは客のふりをして、意地悪な質問を投げかけたのです。「明日から始まるおすすめの劇は何かしら? それと、私の部屋の予約も確認しておいてくれる?」。 セレストはラッパをアンに向けたまま、数秒間動きを止めました。そして、「かしこまりました。ご予約番号をお伺いできますでしょうか……時計塔の広場では、美しい音楽劇が……」と、二つの役割の発言をぶつ切りに繋ぎ合わせた、支離滅裂な返答を返したのです。

騒動が落ち着いた頃、クレアさんは私たちをフロントの奥にある小さな事務室へと招き入れました。彼女はテキパキとした身のこなしの女性でしたが、その目の下には濃い隈が浮かんでおり、深刻な焦燥に囚われているように見えました。 「コレットさん、本当に助けていただきたいのです」と、クレアさんは丁寧な口調で私を呼びました。「実は今週、老舗の旅行案内書の覆面調査員が、このベルヴューのどこかの部屋に泊まっているという噂があるのです。さっきのお客様の誰かが、もしその調査員だったとしたら……ホテルの評判は地に落ちてしまいます」。 彼女がこの劇場のことを知ったのは、仕立て屋のハリスさんから、自動裁断人形ルネを見事に直した腕の良い人形遣いがいると聞いたからだと言います。

私はセレストの背中のハッチを開け、中に収められていた「指示の巻物」、すなわちプロンプト(大規模言語モデル(LLM)に対して指示や制約を与え、特定の応答や挙動を促すための入力テキスト)を取り出しました。 羊皮紙に書かれたその指示を見て、私は息を呑みました。そこには、宿泊予約の処理手順、観光案内の対応例、そしてクレーム対応時の謝罪方法が、区別のない一本の長い指示として書き連ねられていたのです。さらに、これまでの宿泊客との対話の履歴も、役割の区別なくすべて同じ履歴として巻物の末尾に継ぎ足されていました。

「一人三役を、一つの巻物だけでこなそうとしていたのね……」 私は巻物を見つめながら考えました。これでは人形が今どの役割で話すべきか迷うのも無理はありません。でも、まだ説明が足りないだけかもしれない。例外の状況を想定して、もっと詳しく、完璧な手順を書き加えてあげれば、セレストは正しく役割を使い分けられるはず。私は人形遣いとしての素朴な確信を胸に、調整用のペンを手に取りました。


第2幕: 絡糸と破綻 ── 果てしなく継ぎ足される巻物

クレアさんから事務室の書き物机を借り、私はその晩のうちにセレストの指示の書き換えに取り掛かりました。人形に今どの役割を演じるべきかを教え込むため、私はより詳細な指示を巻物へ継ぎ足していくことにしました。

まず、老夫婦の混線を防ぐため、予約対応のブロックに一文を書き加えました。「観光の話を振られても、まず部屋の案内を最後まで終えること」。手元のテスト用オルゴールを回して人形に話しかけると、予約の案内が正しく最後まで出力されるようになりました。私は「とりあえず動いてるし」と独りごちて、次の作業へ進みました。

しかし数十分後、夜間勤務の従業員が部屋に駆け込んできました。観光案内を求めた客に対し、セレストが予約確認のセリフを出力してしまったというのです。私は慌てて観光案内のブロックへ追記を行いました。「予約確認の言葉を使う前に、まず聞かれた場所の名前を答えること」。

さらに一時間後、今度はクレーム対応での混線が報告されました。お湯の出ない不満を訴えた客に対し、セレストが明るい観光案内の口調で答えてしまったとのことでした。私はペンを強く握り直し、クレーム対応ブロックにさらに追加の制約を書き込みました。「明るい口調を使う前に、まず謝意を一文で述べること」。

気がつくと、当初は三つの塊だった巻物の指示は六つのブロックへと膨れ上がり、継ぎ足された羊皮紙の端は机からはみ出して、床にだらしなく広がっていました。私の指先はインクで真っ黒に汚れ、机の上は書き損じた紙の切れ端で散らかり放題になっていました。

ここで私は、前回の仕立て人形ルネの調整時との違いを感じていました。前回の失敗は「〜をしてはいけない」という禁止事項を際限なく積み上げて人形を縛り付けたことでした。しかし今回の私の試みは、人形にあらゆる状況を想定させようとして、「もっと詳しく説明すれば、人形が自分で正しく文脈を分類できるはずだ」という、網羅的な説明の追加による過剰制御でした。どちらも、人形の非決定的な挙動(同じ入力に対しても、モデルの内部的な確率計算やゆらぎによって、毎回異なる出力や振る舞いを示す性質)を、プロンプトの力だけでねじ伏せようとする無理な試みであることに変わりはありませんでした。指示をどれだけ書き足しても、人形の頭の中では三つの役割の指示がいつも同時に鳴り響いています。今どの指示に従うべきかを切り替える仕組みそのものが、この巻物にはそもそも存在していなかったのです。

作業の傍らを通りかかったアンが、私の肩越しに床まで伸びた巻物を見下ろし、冷ややかに言いました。「その巻物、もう自分の重みで千切れそうよ。そんなに長い言い訳を読まされて、セレストが喜ぶとでも思っているの?」。 「大丈夫よ、アン。こうして具体例を網羅しておけば、人形は迷わなくなるわ」と私は言い返しましたが、アンはそれ以上何も言わず、ただ肩をすくめてロビーの方へと歩いていってしまいました。

そして、その夜の遅い時間、恐れていた事態が発生しました。 劇場の社交シーズンに伴う夜のチェックインが重なり、十数人もの宿泊客が一気にロビーへ押し寄せたのです。カウンターの奥で、セレストは四方八方から浴びせられる宿泊客の声に対応しようと、頭部を小刻みに激しく旋回させ始めました。背後の真鍮のラッパが、右へ左へと狂ったように向きを変えます。

セレストの頭脳の中では、何千文字にも膨れ上がったシステム指示の巻物と、予約・観光・クレームの区別なく積み上がった今夜の会話の履歴とが合わさり、受け入れられる情報の限界を超えてしまっていました。

次の瞬間、セレストの動きが完全に止まりました。 頭部の後ろに広がっていた複数の真鍮の集音ラッパが一斉に力を失って萎れ、だらりと垂れ下がりました。人形の口は半開きになったままで、そこからは言葉の代わりに、内部の歯車が噛み合わずに空回りするガガガ、ガガガという虚しい金属音だけが響いていました。 以前、歌う給仕人形の歌声が裏返ったときのような滑稽さはなく、仕立て人形が腕を震わせてフリーズしたときのように痙攣することもない、ただ完全な沈黙だけがそこにありました。

「ああ、なんてことでしょう……」と、クレアさんが青ざめた顔でセレストを見つめました。「もし、この列の中にあの調査員が並んでいたとしたら、私たちのホテルは本当におしまいです……」。 私は自分の施した継ぎ足しの指示が、セレストの思考の容量をパンクさせ、とどめを刺してしまったことを悟り、頭から血の気が引くのを感じて立ち尽くしました。

そのとき、アンが私の背中を優しく押しました。「私はここに残って、女将さんと一緒に他のお客様の対応をしてあげるわ。あなたは早くその壊れた巻物を持って、マエストロのところへ戻りなさい」。 アンが劇場の外で、私とは独立して自分の意志で行動しようとしていることに驚きましたが、私はただ頷き、床から回収した長い巻物を抱えて、暗い夜道を劇場へと必死に走り出しました。


第3幕: 示唆と編み直し ── 最初の一手を分ける

深夜の工房は、しんとした静寂に包まれていました。 マエストロは作業台に向かい、三体の小さなマリオネットを並べ、細い糸を制御棒へ結び直す作業を淡々と続けていました。私が息を切らして工房に入り、インクで汚れた長い巻物を差し出すと、マエストロは巻物には目もくれず、ただ無言で作業台の上の三体のマリオネットを吊り上げました。

マエストロは一本の手で三体の人形の糸をまとめて握り、同時に躍らせようとしました。しかし、人形たちは互いの足や腕を絡ませ合い、不格好な一つの塊となって動かなくなってしまいました。 次にマエストロは、小さな木製の丸い制御棒を一つ掲げました。「まずこれで、どの子を舞台に上げるかだけを決める」。彼はそう言うと、制御棒を小さく動かして左側の一体だけを引き上げました。そして、その引き上げられた人形に対して、もう一本の手でその子専用の制御棒を握り、滑らかな動きで踊らせてみせたのです。

マエストロは人形を置くと、ぶっきらぼうに言いました。「一人の人形遣いが、一度に美しく操れるのは一体だけだ。まずはどの子を立たせるか決める棒と、その子を踊らせる棒は、分けておくものだ」。

その瞬間、私の頭の中で、暗闇に光が差し込むような感覚がありました。 「これは、前に給仕人形のお喋りを止めたときに使った、あの構造化された器と同じ理屈だ……!」 私は心の中で叫びました。あのときは、答えそのものを特定の構造に収めるために器を使いました。今回は、答えを直接生成させるのではなく、「どの役割の人形に答えさせるか」という分類の判断だけを、最初に専用の器に収めてしまえばいいのです。

これが、本話の設計思想となるルータ(Router)(入力された問い合わせの意図やカテゴリーを判定し、適切な専門エージェントへ処理を振り分ける制御モジュール)のパターンでした。そして、これまでのセレストのように、一つのプロンプトと履歴にすべての役割を詰め込んで責任境界を崩壊させてしまった設計を、神エージェント(God Agent)(単一のプロンプトやシステム指示にすべての役割や条件分岐を詰め込み、関心事が密結合になって制御が困難になったアンチパターン)と呼びます。

私はさっそく、この設計をPythonのコードとして編み直す作業に入りました。 まず、セレストが今どの役割に振り分けるべきかを決定するための、分類結果を受け取る器を定義しました。ここではPydantic(Pythonの型ヒントを利用して、データの検証や構造化、型アサーションを宣言的に行うためのバリデーションライブラリ)を使用し、分類先のカテゴリーを厳密に固定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# file: agents/code-puppeteer/tests/agent-routing/after.py
import sys
import os
from typing import Dict, Literal
from pydantic import BaseModel, Field

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class RoutingDecision(BaseModel):
    """どの係(専門人形)の持ち場に属する問い合わせかを収める分類結果の器"""
    category: Literal["booking", "sightseeing", "complaint"] = Field(
        description="この問い合わせが属する係の名前"
    )
    confidence: float = Field(ge=0.0, le=1.0, description="分類の確からしさ(0.0〜1.0)")

この RoutingDecision という器により、分類先のカテゴリーが予約(booking)、観光(sightseeing)、クレーム(complaint)のいずれかに制限されます。また、その分類の確からしさを示す確信度(confidence)も同時に受け取れるようにしました。

次に、この分類だけを担当する小さな制御棒、すなわち RouterAgent を実装しました。このエージェントは、お客様の質問に答える仕事は一切行わず、ただ「どの役割に振り分けるべきか」を判定することだけに専念します。そのため、プロンプトは非常に短くシンプルになります。

そして、かつて巨大な巻物の中で混ざり合っていた各役割の指示を、それぞれの専門エージェント(BookingAgentSightseeingAgentComplaintAgent)へと移し替えました。このとき、マエストロの教えに従い、以前の巻物に書かれていた各役割のロジックを一字一句変えずにそのまま移し、余計なロジック修正を混ぜないようにしました。

最後に、これらを統括する ConciergeRouter を定義しました。このモジュールは、お客様の入力が届くと、まず RouterAgent を使って分類を行い、確信度(confidence)が閾値である 0.6 を下回る場合は、どの専門エージェントにも処理を渡さず、「もう少し詳しく聞かせてほしい」という安全な確認を返す役割を担います。

言葉のうえでは組み上がったつもりでも、崩れていた境界がどんな形に編み直されたのか、私自身まだ像を結びきれずにいました。作業台の上には、マエストロが実演に使った二本の制御棒がまだ置かれたままです。分ける棒と、操る棒。その二本を見比べながら、セレストの中で起きていたことの全体像を、コードに落とし込む前にここではっきりさせておくことにしました。

Router パターンのBefore/After構造比較図。Beforeは単一のConciergeAgentが全役割の指示と会話履歴を同居させ役割混線とコンテキスト肥大化を経てクラッシュに至る様子を、AfterはRouterAgentが分類のみを行いRoutingDecisionのconfidenceが0.6以上なら辞書引きでBookingAgent・SightseeingAgent・ComplaintAgentへ委譲し、0.6未満なら安全なフォールバックを返す構造を示す

こうして全体像がはっきりすると、腑に落ちました。セレストを黙らせたのは、大きすぎる一つの声ではなく、一つの声の中に押し込められていた三つの声だったのです。この整理を手元に置きながら、実際のコードでその編み直しを追っていくことにしました。

以下に、私が編み直した変更前のコード(before.py)と、再構成した変更後のコード(after.py)を掲載します。

before.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# file: agents/code-puppeteer/tests/agent-routing/before.py
import sys
import os
from typing import Dict, List

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent

# 受付人形セレストへ渡す「指示の巻物」(システムプロンプト)。
# 当初は宿泊予約・観光案内・クレーム対応の3ブロックだったが、
# 第2幕の継ぎ足しで役割ごとの追記ブロックが3つ増え、
# 物語の破綻時点では合計6ブロックまで膨らんでいる。
ROLE_GUIDES: List[str] = [
    # 宿泊予約対応(当初ブロック)
    "宿泊予約に関する問い合わせを担当してください。まず予約番号か宿泊者名を確認し、"
    "部屋番号と滞在日数を丁寧に案内します。急かされても確認事項を飛ばさないでください。\n"
    "例:「かしこまりました。ご予約番号をお伺いできますでしょうか」",
    # 観光案内対応(当初ブロック)
    "観光に関する問い合わせを担当してください。周辺の観光名所や飲食店を明るく紹介します。"
    "お客様が急いでいる様子でも、まず名所をひとつ丁寧に案内してください。\n"
    "例:「当ホテルの周辺には有名な時計塔がございまして、お食事処もいくつかご案内できます」",
    # クレーム対応(当初ブロック)
    "クレームや不満の申し出を担当してください。まず謝意を示してから、"
    "担当者への引き継ぎを案内します。深刻な内容でも明るさを絶やさないでください。\n"
    "例:「それは大変申し訳ございません。すぐに担当の者へお繋ぎいたします」",
    # 老夫婦の一件(局面①症状1)を受けての追記(1回目)
    "観光の話を振られても、まず部屋の案内を最後まで終えること。",
    # 急ぎ客の一件(局面①症状2)を受けての追記(2回目)
    "予約確認の言葉を使う前に、まず聞かれた場所の名前を答えること。",
    # クレーム混線の一件(局面①症状3)を受けての追記(3回目)
    "明るい口調を使う前に、まず謝意を一文で述べること。",
]


class ConciergeAgent(BaseAgent):
    """宿泊予約・観光案内・クレーム対応の一人三役をこなす受付人形セレストのからくり。

    役割の切り分けをすべて1本の巻物(システムプロンプト)の自然言語分岐に委ね、
    予約・観光・クレームの区別なく全ターンを同じ会話履歴に積み上げてしまう。
    """

    def __init__(self, name: str, model: str = "gpt-4o"):
        system_prompt = "\n\n".join(ROLE_GUIDES)
        super().__init__(name, system_prompt, model)
        self.history: List[Dict[str, str]] = []

    def run(self, user_input: str) -> str:
        messages = [{"role": "system", "content": self.system_prompt}]
        messages.extend(self.history)
        messages.append({"role": "user", "content": user_input})

        # ガードなし: 巻物と履歴がどれだけ膨らんでも、そのままAPIへ投げてしまう
        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
        )
        content = response.choices[0].message.content or ""

        # 予約・観光・クレームの区別なく、同じ履歴へ積み上げ続ける
        self.history.append({"role": "user", "content": user_input})
        self.history.append({"role": "assistant", "content": content})

        return content

after.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# file: agents/code-puppeteer/tests/agent-routing/after.py
import sys
import os
from typing import Dict, Literal
from pydantic import BaseModel, Field

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class RoutingDecision(BaseModel):
    """どの係(専門人形)の持ち場に属する問い合わせかを収める分類結果の器"""
    category: Literal["booking", "sightseeing", "complaint"] = Field(
        description="この問い合わせが属する係の名前"
    )
    confidence: float = Field(ge=0.0, le=1.0, description="分類の確からしさ(0.0〜1.0)")

ROUTER_SYSTEM_PROMPT = (
    "あなたはホテル・ベルヴューの案内係です。お客様の発言が次のどの係の持ち場か"
    "だけを判定してください。対応そのものは行いません。\n"
    "- booking: 宿泊予約に関する問い合わせ\n"
    "- sightseeing: 観光に関する問い合わせ\n"
    "- complaint: クレームや不満の申し出"
)

class RouterAgent(BaseAgent):
    """どの子を舞台に上げるかだけを決める、小さな制御棒。対応内容は一切持たない"""
    def __init__(self, model: str = "gpt-4o"):
        super().__init__(name="Router", system_prompt=ROUTER_SYSTEM_PROMPT, model=model)

    def classify(self, user_input: str) -> RoutingDecision:
        response = self.client.beta.chat.completions.parse(
            model=self.model,
            messages=[
                {"role": "system", "content": self.system_prompt},
                {"role": "user", "content": user_input},
            ],
            response_format=RoutingDecision,
        )
        parsed = response.choices[0].message.parsed
        assert parsed is not None, "分類結果の器が空でした"
        return parsed

# 各専門人形のプロンプトは Before の ROLE_GUIDES 該当ブロックを一字一句そのまま移したもの
# (差分の純粋性)。他の役割の情報は一切持たない。
BOOKING_SYSTEM_PROMPT = (
    "宿泊予約に関する問い合わせを担当してください。まず予約番号か宿泊者名を確認し、"
    "部屋番号と滞在日数を丁寧に案内します。急かされても確認事項を飛ばさないでください。\n"
    "例:「かしこまりました。ご予約番号をお伺いできますでしょうか」\n"
    "観光の話を振られても、まず部屋の案内を最後まで終えること。"
)

SIGHTSEEING_SYSTEM_PROMPT = (
    "観光に関する問い合わせを担当してください。周辺の観光名所や飲食店を明るく紹介します。"
    "お客様が急いでいる様子でも、まず名所をひとつ丁寧に案内してください。\n"
    "例:「当ホテルの周辺には有名な時計塔がございまして、お食事処もいくつかご案内できます」\n"
    "予約確認の言葉を使う前に、まず聞かれた場所の名前を答えること。"
)

COMPLAINT_SYSTEM_PROMPT = (
    "クレームや不満の申し出を担当してください。まず謝意を示してから、"
    "担当者への引き継ぎを案内します。深刻な内容でも明るさを絶やさないでください。\n"
    "例:「それは大変申し訳ございません。すぐに担当の者へお繋ぎいたします」\n"
    "明るい口調を使う前に、まず謝意を一文で述べること。"
)

def _respond(agent: BaseAgent, user_input: str) -> str:
    """自分の持ち場だけに閉じたシステムプロンプトで、履歴を持たず単発に応答する"""
    response = agent.client.chat.completions.create(
        model=agent.model,
        messages=[
            {"role": "system", "content": agent.system_prompt},
            {"role": "user", "content": user_input},
        ],
    )
    return response.choices[0].message.content or ""

class BookingAgent(BaseAgent):
    """宿泊予約の持ち場だけに閉じた専門人形(system_promptはBOOKING_SYSTEM_PROMPTを渡して生成する)"""
    def run(self, user_input: str) -> str:
        return _respond(self, user_input)

class SightseeingAgent(BaseAgent):
    """観光案内の持ち場だけに閉じた専門人形(system_promptはSIGHTSEEING_SYSTEM_PROMPTを渡して生成する)"""
    def run(self, user_input: str) -> str:
        return _respond(self, user_input)

class ComplaintAgent(BaseAgent):
    """クレーム対応の持ち場だけに閉じた専門人形(system_promptはCOMPLAINT_SYSTEM_PROMPTを渡して生成する)"""
    def run(self, user_input: str) -> str:
        return _respond(self, user_input)

class ConciergeRouter:
    """委譲の中枢。RouterAgentで振り分け、辞書引きで専門人形へ委譲する薄い係"""
    CONFIDENCE_THRESHOLD: float = 0.6

    def __init__(self, router: RouterAgent, agents: Dict[str, BaseAgent]):
        self.router = router
        self.agents = agents

    def handle(self, user_input: str) -> str:
        decision = self.router.classify(user_input)
        if decision.confidence < self.CONFIDENCE_THRESHOLD:
            return (
                "恐れ入ります、もう少し詳しくお聞かせいただけますか? "
                "お部屋のご不便のことでしょうか、それとも他にご用件が?"
            )
        return self.agents[decision.category].run(user_input)

この再構成によって、プログラムの制御境界は大きく変化しました。以前の ConciergeAgent では、今どの役割で応答すべきかという分類と、その役割に基づいた具体的な会話の生成という二つの異なる責任が、一つの巨大なプロンプトの記述とLLM自身の自然言語的な読解に委ねられていました。 Afterコードの設計では、この二つの責任が構造的に分離されています。まず RouterAgent が「分類」だけを行い、その結果を受けて Python コード側が辞書引き(self.agents[decision.category])によって「どの専門エージェントを動かすか」という分岐処理を決定します。 これにより、役割が増えたとしても個々のエージェントが持つプロンプトのサイズは一切肥大化せず、他の専門エージェントの会話履歴が混ざり合ってコンテキストを逼迫する問題も根本から排除されました。

しかし、この仕組みにも限界は存在します。いまの専門人形たちは、一回ごとの問いかけに単独で答えるだけで、自分の担当の中でさえ会話の記憶を持ち続けてはいません。もし将来、どこかの係に「前の話を覚えておく」役目を持たせる日が来たら、今度はその係一つの中だけで記録が伸び続け、いつかまた同じような文脈の壁に突き当たるかもしれません。このルータ設計が解決したのは、あくまで「役割をまたいだ関心の混線とプロンプトの肥大化」であることを、私は正直に認めることにしました。その境界線を胸に留めながら、今できる最善の構造をテストで確認することにしました。

私は舞台裏でのユニットテスト(プログラムを構成する最小単位が、想定された通りに正しく動作するかを個別に検証するための自動テスト)を構成しました。 テストでは、本物のLLMを使用する代わりにモック(テスト実行時に、本物のオブジェクトやAPIの代わりに用意する、事前定義された挙動を再現する身代わりモジュール)を使用し、以下の検証を行いました。

  1. 役割混線の無防備さ(Before): 「お湯が出ない」という不満に対し、場違いな観光案内の応答が返されても、以前の設計ではそれをそのまま宿泊客へ返してしまうことの検証。
  2. コンテキスト肥大化によるクラッシュ(Before): 会話履歴が積まれた状態で、プロンプトの合計サイズがAPIの制限文字数を超えた際に、システムが何の対策もなくエラーで墜落することの検証。
  3. 正しい委譲(After): 予約、観光、クレームの入力に対し、ルータがそれぞれの専門エージェントへ処理を正しく振り分け、他のエージェントが呼び出されないことの検証。
  4. プロンプトサイズが役割数に依存しないこと(After): 4番目、5番目の新しい役割をホテルに追加したとしても、既存の予約エージェントのプロンプトサイズが変わらないことの検証。
  5. 低確信度時の安全側フォールバック: どちらともつかない複合的な質問に対し、確信度が閾値を下回った際に、ルータが安全な聞き返しの言葉を返すことの検証。

テストスクリプトを実行すると、私の目の前にある水晶盤のモニターに、緑色の合格のランプが次々と灯っていきました。 そして、すべてのテストが完了した瞬間、工房の片隅に吊るされていた三つの小さな鐘が、互いの音を邪魔することなく、美しく澄んだ和音を響かせました。チリン、ポロン、リーン――。その調和した音色は、別々に分かれたエージェントたちが、正しくそれぞれの役割を果たしていることを告げる祝福の響きのようでした。


第4幕: 開演と調和 ── 三者三様の受け答え

翌日の夕刻、私は再びホテル「ベルヴュー」のロビーに立っていました。 昼のうちにセレストへ新しい制御モジュールの組み込みを終え、動作を確認するためのテスト公演が始まろうとしていました。ロビーのガスランプが点灯し、再びチェックインのお客様が訪れる時間です。

クレアさんが心配そうな面持ちでセレストの前に立ち、前日と同じ質問を投げかけてみました。 「宿泊予約の内容を確認したいのですけれど」 セレストはラッパをクレアさんへ向け、迷いのない丁寧な声で答えました。「かしこまりました。ご予約番号をお伺いできますでしょうか」。

続けてクレアさんは、「近くで今すぐ行ける食事処は?」と尋ねました。セレストは今度は予約の確認を求めることなく、即座に明るい声で応じました。「当ホテルの周辺には有名な時計塔がございまして、その近くにお食事処がいくつかございます」。

最後に、クレアさんは意地悪く、前日にセレストを沈黙させた原因である複合的な問いを投げかけました。 「お湯が出なくて困っているの……あと、近くに遅くまで開いている薬局はあるかしら?」 セレストは一瞬だけ考えるように首を傾げましたが、今度はパニックを起こして沈黙することはありませんでした。分類の確信度が閾値の 0.6 を下回ったため、ルータが安全なフォールバックを引き受けたのです。「恐れ入ります、もう少し詳しくお聞かせいただけますか? お部屋のご不便のことでしょうか、それとも他にご用件が?」。 ルータ自身も、思考のゼンマイで判断する以上、どの係の持ち場かを見誤ることがあります。だからこそ、自信のない判定のときは無理に振り分けず、まず聞き返すようにしたのです。

三人のお客様の声が、それぞれ違う扉の奥へ通されていった今夜のやり取りを、私は忘れないうちに書き留めておくことにしました。

3人の宿泊客の入力がRouterAgentによってそれぞれ正しく振り分けられる様子を示すフロー図。予約の問い合わせはBookingAgentへ、観光の問い合わせはSightseeingAgentへ、confidenceが0.6未満の複合的な問い合わせは安全なフォールバック応答へ委譲される

同じロビーに三つの声が重なっても、もう一つの声にかき消されることはありません。それぞれの声が、それぞれの持ち場へ静かに届いていく――昨夜まではあれほど遠かった光景が、今夜はただの当たり前として、目の前に返ってきていました。

クレアさんは驚いたように目を丸くし、安堵の息を漏らしました。 「素晴らしいわ……! これなら、お客様を怒らせることも、混乱させることもありませんね。コレットさん、このセレストは、明日からも同じように賢く振る舞ってくれるのでしょうか? 私がまた新しい案内業務をこの子に教え込みたいと思ったら、また何日もかけてすべての指示を書き直さなければならないのですか?」

私は首を横に振りました。 「いいえ、クレアさん。これからはその必要はありません。私たちはセレストの頭の中に、すべてを一人でこなす巨大な巻物を持たせるのをやめました。代わりに、入ってきたお客様の声を適切な係へと案内する『ルータ』という小さな案内役と、それぞれの専門の仕事だけを持つ独立した係を分立させたのです。そのため、新しく『夜会の案内』や『馬車の予約』といった役割をセレストに追加したとしても、すでに完成している予約係や観光係の指示書を書き換える必要はありません。新しく追加した係が、他の係の仕事を邪魔することもないのです。もっとも、ルータ自身の振り分け先の一覧にだけは、新しい行き先をひとつ教えてあげる必要がありますけれど」

ただし、と私は付け加えました。「この仕組みが解決したのは、役割が混ざり合う混乱です。いまの係たちは、一回ごとのやり取りに単独で答えるだけで、前の話を覚えてはいません。もしいつか、どこかの係に『前の会話を覚えておく』力を持たせる日が来たら、今度はその係一つの中で記録が膨らみ、また同じような壁に行き当たるかもしれません。それは今後の課題ですが、少なくとも今夜のラッシュでセレストが黙り込むことはもうありません」。

クレアさんは私の説明を聞くと、深く納得したように頷きました。しかし、多忙なホテルの女将である彼女には、感傷に浸っている時間はありませんでした。彼女はすぐに表情を引き締め、近くに立っていたフロント係の従業員たちの方を向いてテキパキと指示を飛ばし始めました。「みんな、セレストの調整は完了したよ! 明日の夜会のためのロビー配置、予定通りに今すぐ進めるよ!」。その素早い行動への切り替えこそが、彼女がセレストの性能を心から信頼した証拠のように見えました。

クレアさんは振り返ると、私とアンに深く一礼し、カウンターの下から小さな真鍮の呼び鈴を取り出して私に手渡しました。 「呼べば、いつでも正しい専門の者が駆けつける。これが私たちの新しいベルヴューの心得です。お二人にも、この劇場の調律への謝意として、これを受け取っていただきたいのです」

それは、いつかハリスさんにいただいた温かなミートパイとはまた違う、けれど劇場の日々にふさわしい、美しい真鍮の呼び鈴でした。クレアさんはさらに微笑みながら付け加えました。「ハリスさんにも、本当に素晴らしい人形遣いの劇場を教えてもらったと、私から伝えておきますね」。

ホテルを出て、夜道を歩きながら、私は隣を歩くアンに話しかけました。「アン、昨日ロビーに残って対応を引き受けてくれて、本当にありがとう」。 アンは少し照れくさそうに顔を背け、ドレスのフリルを軽く払いながら言いました。「フン、セレストの新しい振る舞い、なかなか堂に入っていたわ。あれなら私のドレスが汚される心配もなさそうね」。

劇場の工房へ戻ると、マエストロは深夜に使った三体のマリオネットを、元の持ち場のフックへと静かに戻しているところでした。私たちの帰宅に気づいても振り返りませんでしたが、気のせいか、その人形をフックに掛ける手つきがいつもよりわずかに丁寧に見えました。

私は作業台の前に座り、革表紙の「操り人形師の覚書」を開きました。 前話までに書き込まれた二つのページを、指先で丁寧になぞりながら読み返し、今日の経験がこれまでの学びと地続きであることを実感しました。そして、三ページ目の新しい羊皮紙に、真鍮の万年筆で今日の確かな教訓を書き留めました。

「一人の人形にすべてを背負わせることが、忠実さの証だと思っていた。でも、一つの声には、一つの役目があればいい」


🎭 操り人形師の覚書(Colette’s Puppeteer Log)

  • 調整したからくり(モジュール/関数名): ConciergeRouterRouterAgent および BookingAgentSightseeingAgentComplaintAgent
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 単一のシステムプロンプトに複数の役割を詰め込むと、今どの役を演じるべきかの判断がモデルの確率的な「読み」に委ねられ、文脈が長くなるほど指示の整合性が失われる特性
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • RoutingDecision(Pydanticモデルによる分類モデル)を用いた response_format での意図分類と、確信度(confidence)の判定。閾値(0.6)を下回る場合の安全側フォールバック、および Python 側の辞書引きによる専門エージェントへの処理委譲
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • 役割ごとの混線を力任せに修正しようとして、指示の巻物(プロンプト)に詳細なルールや具体例を際限なく継ぎ足し、プロンプトと共有履歴の肥大化によるコンテキスト上限クラッシュを引き起こしたこと
  • 模擬公演の記録(検証のポイント):
    • 役割混線が発生しても検知できない Before の無防備さ、コンテキスト超過時の例外送出、After での正しい専門エージェントへの委譲、役割追加時における既存プロンプトの不変性、および複合的な質問入力に対する安全なフォールバック応答の検証
  • マエストロの言葉:
    • 「一人の人形遣いが、一度に美しく操れるのは一体だけだ。まずはどの子を立たせるか決める棒と、その子を踊らせる棒は、分けておくものだ」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。