Featured image of post コードパペッティア【Structured Outputs】歌う給仕人形〜注文票に混ざる歌声と厳格な運糸〜

コードパペッティア【Structured Outputs】歌う給仕人形〜注文票に混ざる歌声と厳格な運糸〜

LLMの余計なお喋り(余剰応答)で崩れる注文仕分け人形。力任せの正規表現から、PydanticとStructured Outputsによる厳格な型定義(器)の運糸への再構成を描きます。

朝霧の名残が、工房の窓ガラスの内側にまでうっすらと忍び込んでいる時刻でした。 ヴィクトリアン・シアターの舞台裏はまだ音がなく、あるのは機械油と古い紙の匂い、それから夜のあいだ半分まで落とされたガスランプの、燃え残りのような小さな灯りだけです。

工房の奥のベンチでは、マエストロが日課どおり「記憶の蓄音機」のシリンダーを磨いていました。布がシリンダーの溝をなぞる、かすかな衣擦れのような音。私はそれを背中で聞きながら、作業台の前に立っていました。

台の上には、夜のあいだ埃よけの布を掛けられた、一体のからくり人形。 今日は、この子を——私ひとりで直す日です。

第1幕: 予兆と調律 ── 注文票に混ざる歌声

ことの始まりは昨夜でした。

閉店後の大衆食堂「金色のスプーン」から、料理長のジャンさんが台車を押して劇場へ駆け込んできたのです。恰幅の良い体を湯気のような白い息で包み、昼の仕込みのトマトソースが飛んだエプロンを着けたまま。台車の上には、真鍮の蝶ネクタイを締めた給仕人形が、行儀よく木製のトレーを抱えて座っていました。

「マエストロの劇場なら、うちの給仕を直せると聞いてきたんだ」

ジャンさんの声は大きくて、でも切実でした。給仕がひとり辞めて、その穴をこの人形——アルフレッドで埋めた矢先だったこと。アルフレッドが注文を仕分けそこねるたびに厨房の集計板が詰まり、料理が止まること。聞き間違いで作り直した皿は、材料ごとまるまる損になること。

「金曜は市が立つ。店は満席になる。それまでに、頼む」

そう言ってから、ジャンさんはふと声を落とし、アルフレッドの蝶ネクタイを太い指でそっと直しました。「……こいつに無理をさせちまったのかもしれん」。人形を「うちの給仕」と呼ぶ人の、その手つきだけで、私はこの依頼が好きになりました。

マエストロは台車の人形を一瞥し、背中の蓋を開け、張られた糸を指先でひとつずつ確かめました。それだけでした。工具も出さず、私のほうへ顎をしゃくって、一言。

「お前がやってみろ」

依頼人の人形を、最初から最後までひとりで任されるのは初めてのことです。胸の奥で、嬉しさと緊張が同時にゼンマイを巻き始めるのがわかりました。ジャンさんは「嬢ちゃんで、大丈夫かい」と口には出しませんでしたが、帽子を取った顔の、眉のあたりがそう言っているように見えました。

——だから今朝の私は、少しだけ気負っていたのだと思います。

布を外すと、アルフレッドは朝の薄明かりの中でも育ちの良さそうな顔をしていました。磨かれた真鍮の蝶ネクタイ、几帳面に塗り直された木のトレー。胸の奥には、どんな言葉も受け取って言葉を紡ぎ返す「思考のゼンマイ」——すなわち 大規模言語モデル(LLM) が据えられています。お客の注文を聞き取り、厨房の真鍮の集計板へ「料理名と数量」のデータを送るのが、この子の仕事です。

私はお客のふりをして、最初のテスト入力を与えました。

「ビールを2杯と、ポテトを1皿!」

アルフレッドの目に真鍮色の光が灯り、胸の奥のオルゴールが回り始めました。そして、歌うような声が返ってきたのです。

「ビールを2つと、ポテトですね! かしこまりました♪ すぐに美味しい料理をご用意しますね!」

——愛想は満点でした。指先から吐き出された羊皮紙には、こう刻まれていました。

1
2
3
4
5
6
7
8
かしこまりました♪ 
{
  "items": [
    {"name": "ビール", "quantity": 2},
    {"name": "ポテト", "quantity": 1}
  ]
}
です!美味しいスープもお楽しみに!

欲しいのは中身のJSONだけなのに、その前後に挨拶と補足がたっぷり付いてくる。歌う給仕さんは、今朝も実に上機嫌でした。

けれど集計板のほうは、そうはいきません。真鍮の針は「かしこまりました♪」の頭の一文字で振り切れ、歯車が噛み、注文リボンを白紙のまま吐き出しました。厨房側から見れば「注文が来ない」。ジャンさんの店で起きていたのは、まさにこれです。

私はアルフレッドの背中の蓋を開け、胸のゼンマイに結ばれた極細の絹糸——プロンプト(Prompt)、つまり人形への指示文を確かめました。

糸には、先週私が結んだ命令が、もうあるのです。 「出力は必ずJSONフォーマットのみにしてください。余計な挨拶や説明は一切不要です」——一切不要、とまで書いてある。なのに、歌は混ざる。

不思議でした。でも、JSONがきれいに出る回も確かにあるのです。現に今朝いちばんの一回は、挨拶付きとはいえJSONそのものは崩れていませんでした。

「動いてるからいいのよね」

私は自分にそう言い聞かせました。歌そのものを止められないなら、出てきた歌から欲しい部分だけを切り出せばいい。そう決めて、私は操糸に取り掛かったのです。

第2幕: 絡糸と破綻 ── 力任せの正規表現

午前中の私は、我ながらよく働きました。——方向を間違えたまま。

まず、命令の糸を太くしました。「一切不要です」の下に、もう一度線を引くように言葉を重ねたのです。挨拶をするな、説明を足すな、歌詞を挟むな。テストを流すと、アルフレッドは一度、ぴたりと静かなJSONだけを返しました。

「ほら、できるじゃない」

ところが言い回しを変えて「エール2つ、ポテトも」と頼んだ途端、末尾に「です!」が戻ってきました。命令は効いているように見えただけで、次の一回を保証してはくれないのです。

それなら、と私は方針を変えました。出てしまった歌を、後から刈り取る罠を張ることにしたのです。

羊皮紙のテキストから、中括弧 { で始まり } で終わる範囲だけを力尽くで抜き出す正規表現。抜いた後に改行が混ざっていて json.loads が転んだので、改行をすべて潰す置換をひとつ。歌詞の「♪」が紛れ込んだ回があったので、また置換をひとつ。アルフレッドの絹糸に、結び目がひとつ、またひとつと増えていきました。作業台にはその場しのぎのメモが散らばり、私の指先はいつの間にかインクで汚れていました。

「あら」

いつからそこにいたのか、磁器の肌の自律人形——アンが、ドレスの裾を払いながら私の手元を覗き込みました。

「その糸、もう呼吸ができていないわよ。あなた、歌を止めたいの? それとも人形を止めたいの? ……そのうちスープの中にネジを混ぜて出す気かしら?」

「縛れば縛るほど、確実になるはずよ」

私は言い返しました。だって、罠は増やせば増やすほど網の目が細かくなるはずでしょう? アンは何か言いたげに小首を傾げましたが、それ以上は追いませんでした。

こうして出来上がったのが、この操糸です。

コレットが張ったガチガチの糸(Beforeコード)

 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
# file: agents/code-puppeteer/tests/prompt-structured-outputs/before.py
import re
import json
import openai

class OrderPuppeteerAgent:
    """昔ながらの手動パース(正規表現)で出力を抽出しようとする給仕人形エージェント"""

    def __init__(self, name: str, system_prompt: str, model: str = "gpt-4o"):
        self.name: str = name
        self.system_prompt: str = system_prompt
        self.model: str = model
        self.client: openai.OpenAI = openai.OpenAI()

    def run(self, user_input: str) -> str:
        # LLMにJSONで出力するよう指示するが、余計な言葉が混ざりがち
        response = self.client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": self.system_prompt + "\n出力は必ず以下のJSONフォーマットのみにしてください。余計な挨拶や説明は一切不要です。\nJSONフォーマット:\n{\n  \"items\": [\n    {\"name\": \"料理名\", \"quantity\": 数量}\n  ]\n}"},
                {"role": "user", "content": user_input}
            ]
        )
        content = response.choices[0].message.content or ""

        # コレットが力尽くでJSONを抽出しようとする正規表現の罠
        # 中括弧 {... } の範囲を抜き出す
        match = re.search(r"\{.*\}", content, re.DOTALL)
        if not match:
            raise ValueError("JSONの開始と終了が見つかりませんでした。")

        json_str = match.group(0)
        # 余計な改行コードの除去など、その場しのぎの置換
        json_str = json_str.replace("\n", "").strip()

        # デコード
        data = json.loads(json_str)

        # 期待するキーを取り出す
        items = data["items"]

        order_strings = []
        for item in items:
            name = item["name"]
            quantity = item["quantity"]
            order_strings.append(f"{name}{quantity}つ")

        return "ご注文は" + "と".join(order_strings) + "ですね"

コメントに「力尽く」「その場しのぎ」と書いてあるのは、あとから読み返した私の正直な感想です。この時点では、午前中いっぱいを費やしたこの網が、私にはとても頼もしく見えていました。

昼を過ぎた頃、工房の扉が開いて、ジャンさんが顔を出しました。

「市の前に、一度様子を見ておきたくてな。どうだい嬢ちゃん、うちの給仕は」

「ちょうどいいところに! 見ていてください」

私は胸を張って、アルフレッドのゼンマイを巻きました。ジャンさんは帽子を胸に当てて、少し離れたところから見守っています。それから、店でいつもやっているとおりの調子で、常連客の注文を再現してくれました。

「やっぱりエールを3つ、それとポテトを1皿! あ、やっぱりエールは2つにしてくれ」

——言い直し。お客さんなら、誰でもやることです。

アルフレッドの歌が、途中で裏返りました。

「かしこまりました♪ エールは2つ、それとポテトですね! { items: [beer: 2] }」

オルゴールの旋律がふっと乱れ、羊皮紙に刻まれたのは、括弧の対応も、引用符も崩れた、JSONの成り損ないでした。私の張った正規表現の罠は、その崩れた中括弧をご丁寧に捕まえて json.loads() へ流し込み——水晶盤に json.JSONDecodeError の赤い文字が浮かびました。アルフレッドは糸をぴんと張り詰めたまま、歌の途中の姿勢で静止してしまいました。

工房が、しんとしました。

ジャンさんは怒りませんでした。ただ帽子をぎゅっと握って、眉を下げたのです。

「……うちの給仕は、そんなに難しい病気なのかい」

「ち、違うんです。さっきまでは、ちゃんと……」

さっきまでは、動いていたのです。私の頭の中で、今朝の自分の声が響きました——動いてるからいいのよね、と。動いていたことと、次も動くことは、別のことだったのに。

「ねえ、コレット」

アンが静止したアルフレッドの隣に立ち、その真鍮の蝶ネクタイを直してあげながら、いつになく静かな声で言いました。

「あなたの罠はね、この子の歌の、たった一つの節回ししか想定していないのよ。この子は毎回、すこしずつ違う歌を歌うの。今日は『です!』、明日は『♪』、明後日は括弧の形まで気分次第。——全部の節回しに罠を張るつもり?」

反論の言葉が、出ませんでした。

糸に結んだ命令は、歌の癖を「そちらへ寄せる」ことはできても、止める力はない。それは今朝、命令があるのに歌が混ざるのを、私自身が見ていたはずです。そして出てしまった歌の形が毎回違う以上、切り出す罠をいくつ足しても、次の歌は必ず罠の外から漏れてくる。私が午前中に編んだ網は、昨日の歌にしか効かない網だったのです。

結び目だらけの絹糸を見つめたまま、私はしばらく、動けませんでした。

第3幕: 示唆と編み直し ── 器(型)を差し出す

衣擦れの音が、止みました。

工房の奥で蓄音機を磨いていたマエストロが、布を置き、初めて作業台のほうへ歩いてきたのです。朝からずっと、何も言わずにそこにいた人が動いた——それだけで、私の背筋は伸びました。

マエストロは散らかった作業台の隅から水差しを取り上げました。そして無言のまま、まず私が置きっぱなしにしていた布巾の上へ、水をほんの少し垂らしたのです。水は布に吸われ、形にならず、染みになって広がりました。

次に、棚から磁器のティーカップを下ろして台に置き、同じ水差しから、同じように水を注ぎました。水はカップの縁の内側で、静かに丸くおさまりました。

それだけを見せて、マエストロは言いました。

「人形が歌うのはその本性だ。歌うのを力任せに止めようとするな。聴きたい言葉だけを綺麗に収める器(型)を差し出しなさい」

私が「器……」と繰り返したときには、マエストロはもう水差しを置き、奥のベンチへ戻っていくところでした。それ以上の説明はありません。いつものことです。

私は、布巾に広がった染みと、カップの中で丸くなっている水面とを、何度も見比べました。

——布巾で受けて、染みたところを後から拭い集める。それは、まさに私が今日一日やっていたことです。出てしまった歌を、正規表現で追いかけて、置換で拭って。拭いても拭いても、水は次の場所へ滲んでいく。 ——器で受けるなら、拭う仕事そのものが、最初から生まれない。

「それで?」

アンが、試すような目で私を見ました。

「その器とやら、どこで差し出すの? 歌い終わった羊皮紙の上? それとも——」

「……歌っている最中よ」

口に出してから、自分の言葉に自分で驚きました。

「言葉が口をついて出る、その瞬間に、器の形しか出られないようにするの。出てから拭くんじゃなくて」

「あら」アンは満足そうに目を細めました。「わかっているじゃない。で、その器は誰が焼くのかしら?」

ここからは、比喩ではなく道具の話です。

私が探していた仕組みは、Structured Outputs と呼ばれるものでした。出力してほしいデータの形をJSONスキーマとして先に渡し、LLMにその形に適合した出力だけを返させる、APIの機能です。

大事なのは、これが「上手なお願い」ではないという点です。プロンプトの命令は、生成される言葉の確率を望む方へ「寄せる」だけで、確率をゼロにはしません。だから今朝の私は、命令の下に線を引いても裏切られました。一方、Structured Outputs の実体は Constrained Decoding(制約付きデコーディング)——モデルが次の言葉(トークン)を選ぶまさにその瞬間に、スキーマに合わない候補を選択肢から除外してしまう仕組みです。「守ってほしいお願い」と、「文法の外に出られない枠」。似ているようで、まるで別のものです。

そしてこの転換は、責任の置き場所も変えます。今朝までの設計では、出力の形式を守らせる責任は、私のプログラム——つまり後処理の正規表現にありました。形が崩れたら、崩れた形の数だけ私の罠を増やすしかない。Structured Outputs に切り替えると、形式を守る責任はAPIの契約側に移ります。私の仕事は「どんな形で受け取るか」を型として宣言することと、受け取った中身をどう使うか、に変わるのです。壊れ方そのものが変わる、と言ってもいいかもしれません。

頭の中を整理するために、私は今日の絡まりを一枚の図にしてみました。

正規表現による力技(Before)とStructured Outputsによる器(After)の構造比較

器を焼く道具にも、Pythonにはうってつけのものがあります。Pydantic——型ヒントを使ってデータの形を宣言し、実行時にその形どおりかを検証してくれるライブラリです。Pydanticでモデルを定義すれば、それがそのままAPIへ渡すスキーマになります。器の設計図を二重に管理しなくていい、ということです。フィールドに添える description も飾りではありません。スキーマの一部としてモデルへ渡り、その欄に何を注ぐべきかの案内になる——いわば、器の縁に彫っておく小さな指示です。

私は、劇場のからくり人形すべての基本骨格になっている共通クラス BaseAgent にアルフレッドを載せ替え——持ち込まれたときの素の組み方から、劇場の流儀の土台に移すだけで、直しの本体はここではありません——その上で、新しい操糸術を編みました。なお run に増えている history という受け口は骨格側の共通仕様で、今日は使いません。言い直しへの対応は、一度の発話の中で完結する話だからです。

型定義による構造化(Afterコード)

 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
# file: agents/code-puppeteer/tests/prompt-structured-outputs/after.py
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
import openai

import sys
import os

# agents/code-puppeteer を sys.path に追加して base.py をインポートできるようにする
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 OrderItem(BaseModel):
    name: str = Field(description="料理または飲み物の名前")
    quantity: int = Field(description="注文された数量")

class OrderResponse(BaseModel):
    items: List[OrderItem] = Field(description="注文された商品のリスト")

class OrderPuppeteerAgent(BaseAgent):
    """Structured Outputsを導入した安全な給仕人形エージェント"""

    def run(self, user_input: str, history: Optional[List[Dict[str, str]]] = None) -> str:
        try:
            # client.beta.chat.completions.parse を使い、Pydanticモデルを直接渡す
            response = self.client.beta.chat.completions.parse(
                model=self.model,
                messages=[
                    {"role": "system", "content": self.system_prompt},
                    {"role": "user", "content": user_input}
                ],
                response_format=OrderResponse,
            )
        except Exception as e:
            return f"エラーが発生しました: {str(e)}"

        message = response.choices[0].message

        # refusal(ポリシー拒絶)のハンドリング
        if message.refusal:
            return f"要求が拒絶されました: {message.refusal}"

        parsed_data = message.parsed
        if not parsed_data:
            return "注文を正しく理解できませんでした。"

        if not parsed_data.items:
            return "ご注文はありません。"

        order_strings = []
        for item in parsed_data.items:
            order_strings.append(f"{item.name}{item.quantity}つ")

        return "ご注文は" + "と".join(order_strings) + "ですね"

正規表現も、場当たりの置換も、ひとつも残っていません。あの「JSONのみで」と念を押していた命令の糸も、ほどいてしまいました——形の責任は器が持つので、糸に結ぶのは中身の指示だけでいいのです。注文の復唱を組み立てる最後の数行は、朝のコードとまったく同じです。変わったのは「アルフレッドの言葉を、どうやって受け取るか」——そこだけです。

技術的な運糸の勘所

  1. client.beta.chat.completions.parse の使用: API呼び出し時に .parse() を使い、response_format に Pydantic モデル(OrderResponse)を直接渡します。これにより、SDKが自動で厳格なJSONスキーマを生成し、OpenAIのAPIに送信します。ひとつ意識しておきたいのは、この形式保証が「対応するAPI・モデル側の機能に乗る」保証だということです。基盤を替えるときは、同等の構造化出力サポートの有無から確かめることになります(beta はSDKの現行の入り口で、将来の版で位置づけが変わり得ます)。
  2. strict モードの制約: OpenAIのStructured Outputsは内部で strict: true を適用します(SDKが自動で付けるため、コード上に指定は現れません)。厳格モードでは「あってもなくてもいい」という曖昧さが枠と相性が悪いため、全フィールドがスキーマ上で required(必須)になります。任意にしたい値は Optional[T] = None として「Tnull かの二択」を器の形そのものに含めます——明示させるのはお願い(プロンプト)ではなく、スキーマ自身です。
  3. refusal(拒絶)のガード: 安全性のポリシー違反などでLLMが回答を拒絶した場合、Pydanticのパース結果(message.parsed)は空になり、代わりに message.refusal に拒絶文が格納されます。これをハンドリングしないと、後続の処理で AttributeError を起こしてクラッシュするため、必ずガード節を設けます。なお refusal を処理した後にも parsed が空になる残余ケース(トークン上限による途中打ち切りなど、形式崩れとは別種の失敗)があるため、if not parsed_data の防御はそのために残しています。
  4. 「形」の保証と「中身」の保証は別物: スキーマが拘束するのは出力の構造だけです。「エール2つ」を quantity: 3 と聞き間違える種類の失敗は、型では防げません。この記事の中でも、同じ飲み物が「ビール」と「エール」で揺れています——メニュー名の表記揺れは中身側に残る課題です(店のメニューが固定なら、Literal 型で選択肢ごと器の形に寄せる手もあります)。形は仕組みで保証し、中身は検証で確かめる——役割分担を混同しないことが大切です。

四つの勘所を書き出しながら、私は手を止めました。器は確かに形を保証してくれる。けれど、アルフレッドの声を受け取ってから、返事として舞台に出すまでの間には、まだいくつもの分かれ道が残っています——拒絶されたら。中身が空だったら。品書きが空だったら。私は run の道筋を、迷わないよう一本の糸に描き直してみることにしました。

Afterのrun内部で発話がtry/except・refusal・parsed空・items空の4つの関所を順に通過し、崩れた擬似JSONはそもそもこの経路に現れないまま復唱の組み立てに至る防御分岐フロー図

描き終えて、気づいたことがあります。崩れた擬似JSONそのものは、もうこの糸のどこにも姿を見せません。器の外で、最初から弾かれているからです。残った分かれ道はどれも、拒絶や空といった、器では防ぎようのない種類のものばかり。

「あら」アンが横から羊皮紙を覗き込みました。「残った分かれ道は、拒絶と空っぽだけ。ずいぶん静かな糸ね、今日のは」

「罠じゃなくて、案内板にしたの」

その上で、模擬公演(テストコード)を張ります。相手は本物のゼンマイではなく、控室用の身代わり(unittest.mock)です。つまり「形の保証」そのものは模擬公演では検証できません——それはAPI契約側の仕事です。釘を打っておくべきなのは、こちら側の受け方のほう。私は意地悪な客の出力を身代わりに演じさせて、検証を並べました。

保証どおりの形が届いたとき、復唱が正しく組み上がること。崩れた擬似JSONが届いたとき、朝のBeforeコードが実際に例外を吐いて倒れること——そう、たまたま綺麗に歌った回ならBeforeでも通ってしまうのです。今朝の私が騙されたのは、まさにその「たまたま」でした。だから「どんな出力で壊れるか」を再現テストとして釘で打っておきます。それから、ポリシー拒絶(refusal)が返ってきたときに舞台を安全に止められること。API自体が接続エラーで倒れたときに、例外が裸で観客席まで飛んでいかないこと。

最後のテストが通ったとき、工房の隅で美しき調和を告げる小さな鐘が、一度だけ澄んだ音を落としました。 朝からずっと張り詰めていた肩から、その一音で、ふっと力が抜けました。

第4幕: 開演と調和 ── 美しき舞台

夕刻、約束どおりジャンさんが戻ってきました。

「どうだい嬢ちゃん。うちの給仕は、市に間に合いそうかい」

「ええ。——昼間と同じ注文を、もう一度お願いできますか。あの、言い直すやつを」

私はアルフレッドを劇場の小さな舞台に立たせました。ジャンさんは眉を上げ、それから店の親父の顔になって、声を張りました。

「冷たいエールを3つ、それと温かいシチューを1皿! あ、やっぱりエールは2つだ!」

アルフレッドの目に真鍮色の光が灯ります。オルゴールは回っています——けれど、余計な歌は一節も漏れてきません。すっと片手が上がり、集計板へ構造化された注文が流れ、真鍮の針が今度は一度も暴れずに文字を灯しました。「エール: 2、シチュー: 1」。

そして、恭しい一礼。

「ご注文はエールを2つとシチューを1つですね」

「……ほう」

ジャンさんは舞台に一歩近づき、それから私のほうを振り返りました。喜ぶより先に、確かめる顔でした。

「なあ嬢ちゃん。こいつは明日になったら、元に戻ったりしないかい。昼間だって『直った』と思ってたんだろう? 店に戻して、市の日にまた止まる——そういうのは、もう御免なんだ」

痛いところを、まっすぐ突いてくる人です。でも、今度は答えられました。

「昼間までの私は、この子に『歌うな』とお願いして、それでも出てきた歌を後から切り刻んでいたんです。お願いは、機嫌や言い回しひとつで破られます。いまは違います。言葉が生まれるその瞬間に、決めた器の形しか出られない仕組みに変えました。形が崩れる壊れ方は、お願いと違って、言い回しでは起きません」

「ふうむ」

「ただ——」正直に付け足しました。「注文の聞き取りの中身まで万能になったわけではありません。数の聞き間違いみたいな失敗は器では防げないので、意地悪な注文を控室でさんざん試してあります。金曜の市で出そうな言い直しは、ひととおり」

ジャンさんはしばらく黙って、それから太い息をひとつ吐きました。

「……お願いじゃなくて、仕組みか。なるほどな。嬢ちゃん、あんた職人の顔で喋るんだな」

そして、やっと破顔したのです。「これで金曜の市も、作り直しの損ともおさらばだ!」

帰り支度が済むと、アルフレッドは台車には乗りませんでした。トレーを小脇に抱え、舞台の縁で客席に向かって深々と一礼し、自分の足でジャンさんの後ろに付いて歩き出したのです。「うちの給仕」の面目躍如でした。扉のところでジャンさんが振り返り、帽子を上げて言いました。

「金曜、店に食いに来な。嬢ちゃんの分は俺のおごりだ」

扉が閉まると、工房にはガスランプを点す前のわずかな時間が訪れていました。夕暮れの赤銅色に、真鍮の工具たちが静かに沈んでいきます。

「あら」

アンがドレスの裾を揺らして、私の隣に立ちました。

「スープの中にネジを混ぜずに、済んだみたいね」

意地悪そうに、でも少し嬉しそうに。私は「おかげさまで」と笑い返しました。半分は本気の御礼です。あの子の言う「全部の節回しに罠を張るつもり?」が、たぶん、今日いちばん最初のヒントでした。

工房の奥では、マエストロがもう蓄音機のシリンダーに向き直っていました。こちらを見もしません。ただ、布を動かすその手が、朝よりすこしゆっくりに見えました——気のせいかもしれませんが。

私は作業台の前に座り、革表紙の「操り人形師の覚書」を開きました。 まっさらな、最初のページ。慣れないインクの匂い。書き出しの一行目で、ペン先が一瞬だけ止まりました。ひとりで任された最初の調律の記録は、どんな言葉で始めるべきでしょう。

迷って、やめて、今日いちばん確かだったことを、そのまま書きました。

「人形に喋らせないのではない。語りたい言葉だけを収める器を作ること」


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

  • 調整したからくり(モジュール/関数名): OrderPuppeteerAgent
  • 思考のゼンマイ(思考素子/LLMの特性):
    • LLMは自然言語を柔軟に解釈する反面、フォーマットを指示しても挨拶などの余剰応答を混入させてしまう非決定的な本性を持つ
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • Pydantic による BaseModelOrderItem, OrderResponse)での厳格な型定義と、OpenAI APIの response_format 指定(内部で strict 適用)による制約付きデコーディング(構造化出力の強制)
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • 非決定的なお喋りに対し、正規表現や大量の .replace() によるプログラムでの力任せの抽出ルールを書き、表現の揺れによるクラッシュ(破綻)を招いたこと
  • 模擬公演の記録(検証のポイント):
    • unittest.mock を用いてLLMの多様な揺らぎ出力(余計なお喋り、崩れたJSON)および安全ポリシー拒絶(refusal)のモックを作成し、パース例外を起こさず安全に処理できるかをテスト
  • マエストロの言葉:
    • 「人形が歌うのはその本性だ。歌うのを力任せに止めようとするな。聴きたい言葉だけを綺麗に収める器(型)を差し出しなさい」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。