Featured image of post コードパペッティア【Self-Correction Loop】いつもの毒舌を失くした相棒のオルゴール〜過熱する繰り言と、正しく止める勇気〜

コードパペッティア【Self-Correction Loop】いつもの毒舌を失くした相棒のオルゴール〜過熱する繰り言と、正しく止める勇気〜

決算の夜、辛口な自律人形アンが同じ誤りを繰り返し過熱して声を失う。型を見ない盲目的リトライがレートリミットを食い潰す顛末と、エラーで仕分けて1回だけ言い直させるSelf-Correction/Fail-FastのPython実装を添えて。

羽根ペンの引っかかる音と、綴りをめくる紙の音だけが、工房に規則正しく続いていました。

今夜は珍しく、作業台をマエストロと私、それにアンの三人で囲んでいます。今期の興行の決算を、明朝には劇場主のもとへ届けなければなりません。「今夜のうちに、片をつける」――マエストロがそう切り出したときの声は、いつもの寡黙な調子のままでしたが、心なしか急いているように聞こえました。この人がこんな風に急ぐところを、私はあまり見た記憶がありません。

決算の締め日を一日でも過ぎれば、劇場主から次の興行の元手を引き出す話に響く――そう聞かされたのは、つい先週のことでした。だからこその、この夜なべです。

ガスランプの灯りが、書類の山と、山の陰にできた影を、静かに揺らしていました。

第1幕: 予兆と調律 ── 順調だった問い合わせが、初めて突き返される

マエストロは、興行ごとの帳面を一冊ずつ広げては、隅の走り書きと突き合わせる作業を黙々と続けていました。いつもなら鼻歌のひとつも漏れそうな手つきが、今夜はどこか硬く見えました。決算のための材料は、劇場のあちこちに散らばっています。小道具の消耗品、衣装の繕い代、客入りの記録――それを一枚ずつ突き合わせていく代わりに、今期からは近道がありました。アンです。

「先月、小道具の消耗品は何をいくつ買ったか、教えてくれる?」

私が尋ねると、アンは澄まし顔で頷いて、机の脇に据えられた真鍮の伝声管に顔を寄せ、澄んだ声で口上を吹き込みました。劇場の奥にある「元帳保管庫」――何十年ぶんの興行記録と会計を綴じ込んだ、鍵のかかった部屋――へ、この管を通して問い合わせを送るのです。待つほどもなく、伝声管の奥で小さな音がして、応答札が戻ってきます。アンはそれを一瞥するなり、すらすらと数字を読み上げてみせました。

「ほら、ちゃんと合っているでしょう」

得意げに扇を開いて、アンが言います。数字を書き取りながら、私は素直に頷きました。ほんの数ヶ月前までは、思いもよらない仕事です。糸を引くのは私で、動くのはアン――それが当たり前だったのに、今夜のアンは、私の手を借りずに、保管庫という見知らぬ相手と、自分の言葉でやり取りをしているのです。

「衣装の繕い代の内訳は?」続けて尋ねると、また同じ手順で、また澄んだ答え。「先週の夜会帰りのお客様の入りは、去年の同じ時期と比べてどう?」――少し意地悪な質問のつもりでしたが、アンは動じもせず、二つの数字を並べて返してきました。

「これくらいで驚かないでちょうだい」

扇の陰から、ちらりと得意げな目がこちらを見ます。最近になって、決算の問い合わせ役を任されたばかりのアンです。慣れないなりに板についてきた、どこか誇らしげな横顔でした。糸を引く私の指図がなくとも、彼女は自分の言葉で保管庫に語りかけ、欲しい答えを引き出せる――それは、これまで見てきたどんなアンの仕事よりも、彼女らしい仕事に見えました。マエストロも、書類から目を上げてその様子を眺め、何も言わずにまた手元へ戻る、という具合です。

順調なやり取りが、十度、二十度と続きます。けれど決算の対象期間が、先だっての貸切の一夜――東坑道の開通祝いに劇場を一夜借り切った、あの祝賀――をまたぐ質問にさしかかったところで、様子が変わりました。

「あの晩の興行の内訳を」

アンが口上を吹き込むと、いつもより長い間がありました。そして、聞き慣れない乾いた音が返ってきたのです。応答札の代わりに――突き返し札でした。

「あら」アンが扇を持つ手を止めます。「……突き返されるなんて」

まだ、余裕のある声でした。私はアンの手から札を受け取り、覗き込みます。書かれていたのは、綴じ方が合わない、という趣旨の指摘でした。

「客数、では数えられない夜があった、ということかしら」

アンが自分なりに分析してみせます。当たらずとも遠からず、というところでした。あの晩は、いつもの木戸銭のお客様ではなく、東坑道の開通祝いで劇場を丸ごと借り切った、一括の貸し賃で決まる興行です。保管庫の綴じ方には、つい先だって、この新しい興行の型を記す欄が増えていました。アンの口上の組み立ては、まだそれを知らないままでした。

マエストロは決算資料に目を落としたまま、口を挟みませんでした。壁の時計は、もう九時を回っています。

「もう一回、言い方を変えて訊いてみて」

私はアンに頼みました。これ自体は、間違った頼み方ではなかったはずです。札の文言を読んで、言い方を直す――筋としては、まっとうな対処でした。悪手は、まだ起きていません。

アンが言い直します。けれど、微妙に的を外したのでしょう、二度目の突き返し札が戻ってきました。

「……あら、心外だわ」

まだ皮肉る余裕はありました。ただ、伝声管の呼び鈴を続けて鳴らしたせいでしょうか、管の奥で、きし、と小さな軋みがしたのです。誰も気に留めませんでした。私も含めて。

時間がない。直さなければ、決算に穴が空く。――そう思うたびに、私の中で、天秤が少しずつ傾いていくのが分かりました。「正しく直す」ことより、「とにかく通す」ことのほうへ。

第2幕: 絡糸と破綻 ── 同じ声を、速く、高く

三度目の突き返し札が戻ってきたとき、私はもう、その文言を読んでいませんでした。

「大丈夫、大丈夫。もう一回だけ」

アンの背中の鍵を摘まみ、巻き直します。同じ口上を、もう一度、そのまま伝声管へ。戻ってきたのは、同じ突き返し札でした。当然です。何も変えていないのですから。

四度目。五度目。私はもう、巻き直す手を止められなくなっていました。

「動いている間に、なんとか全体をまとめないと……」

自分に言い聞かせるように呟いた声は、震えていました。まとめなければ。動いている間に。今はまだ、間に合う――そのはずでした。

アンの声に、変化が出始めたのは、その頃です。

いつもの気位の高い、優雅な言い回しの語尾が、わずかに欠けるようになりました。

「……存じ、ません、こんな……」

止めなければ、と頭のどこかで分かっていました。分かっていながら、止められない。時間切れの焦りと、アンへの心配とが、私の中でせめぎ合っていたのです。伝声管の呼び鈴を、私は連打する形になっていました。それに気づく余裕もないまま、管の奥の軋みは、次第に大きくなっていきます。

六度目を巻き直したとき、アンは同じ文句を、今度は二度重ねて繰り返しました。「……存じません、こんな……こんな」。たった一言、繰り返しが増えただけです。それだけで、背筋が冷えました。それでも私は、鍵を摘まむ手を止めませんでした。まだ大丈夫。もう少しだけ。そう思っていたのは、たぶん私だけでした。

巻き直すたび、アンの声は速く、高くなっていきました。同じ断片――「……存じません、こんな、こんな、こんな」――が、壊れたオルゴールのように加速して繰り返されます。白磁の頬に、うっすらと赤みが差しているように見えました。背中のゼンマイ機構から、熱を帯びた軋み音がし、覗く歯車の縁が、ランプの灯りを受けて鈍く光ります。オイルの匂いに、焦げるような臭いが混じり始めました。

これは、まだ「直っていない」のではない。「悪くなっている」のだ――そう気づいたときには、もう遅すぎました。

次に呼び鈴を鳴らそうとした、その刹那でした。指先に、それまでと違う硬い感触が返ってきました。紐が、もう動かなくなっていたのです。調速機が働いた――伝声管そのものが、塞がれてしまった。

同時に、アンの機構が限界に達しました。声が不自然に伸びたあと、ふつりと途切れ――アンは、動かなくなりました。

工房が、静まり返ります。柱時計の振り子の音だけが、やけに大きく聞こえました。私は、動かなくなった伝声管の紐と、沈黙したアンとを、代わる代わる見つめました。手が震えて、鍵はまだ指先に挟んだままでした。巻き直す先を失って、宙に浮いた鍵です。何をどうすればいいのか、分かりませんでした。

どれくらい、そうしていたでしょうか。数十秒だったのか、もっと長かったのか、今ではもう覚えていません。呼び戻せない何かを、取り返しのつかない形で壊してしまったのではないか――そんな考えばかりが、頭の中をぐるぐると回っていました。

第3幕: 示唆と編み直し ── 一度だけ、正しく言い直す

足音が、思いのほか早く近づいてきました。

マエストロです。決算資料を作業台に置いたまま、まっすぐこちらへ歩いてくる――マエストロがこんなに急ぐところを見たのは、この工房に来て初めてでした。いつもなら、奥で何か別の作業をしながら、様子を窺うように現れる人です。今日は違いました。使い古された燕尾服の袖から覗く手には、常のように真鍮の小さな工具を握ったままでしたが、今はそれを使う様子もなく、まっすぐアンのそばへ来て、片膝をつきました。

「壊れた関節をそのまま動かそうとするな。糸が千切れる前に、動きを止めろ」

低い声が、鍵を摘まんだままの私の手を止めました。

まだ気持ちが急いていた私は、次の思いつきを口にしていました。

「……なら、せめて、何が悪かったかだけでも、アンに伝え続ければ、いつかは……」

「エラーをアンに投げ返すのは治療ではない。それはただ、病人に自分の体を切り刻めと言っているようなものだ。……一度言って直らぬものは、百度重ねても直らぬ。それどころか、言葉を重ねるほど、良かったところまで削れていく。そのたびに、あの伝声管を使える回数を、確実に減らしているのだ。無尽蔵ではない」

マエストロは、そこで言葉を切りました。いつもなら、ここで工房の奥へ戻ってしまう人です。今日は、続けませんでした。答えの手前で止まるいつもの流儀を、この人が自分から破ったのだと、私は遅れて気づきました。

エラーを見ずに繰り返すのは、論外でした。エラーを見せて繰り返すだけでも、足りない。要るのは、エラーの種類を見分けることと、直せるほうには一度だけ、正しく言い直させること――マエストロの言葉を、自分の言葉に置き換えながら、私は作業台に向き直りました。

まず、言葉を整えます。Fail-Fast(回復の見込みがないエラーを検知したら、取り繕わずに即座に処理を止める設計原則)という言い方を、マエストロは知りません。けれど、彼が言ったことは、まさにこれでした。壊れた関節をそのまま動かし続けるな、というのは、直せないものに手間を重ねて悪化させるな、ということです。そしてもう一つ、自己修復(Self-Correction)――回復できる誤りに限って、その誤りの中身を手がかりに、直す機会を与える設計。マエストロの二言目は、この機会を「無制限に」与えることの危うさを言い当てていました。

保管庫とのやり取りをやり直す前に、まず、何が起きていたのかを整理します。伝声管を通じたやり取りには、性質の違う三つの壊れ方がありうる、と気づいたのです。

一つ目は、伝声管そのものが一時的に混線するような、外側の、時が経てば直るかもしれない障害。二つ目は、アンの口上が保管庫の綴じ方と噛み合わない――突き返し札が示す、直せば直る誤り。三つ目は、調速機が塞いだ伝声管のように、口上をどう直したところで開かない、致命的な断絶です。

私の一行――エラーを見ずにとにかく巻き直す――は、この三つをすべて同じ「もう一度」に押し込んでいました。二つ目の誤りは何度も同じ姿で再現し続け、三つ目の誤りは直る見込みがないまま呼び出しだけを重ね、その挙句、外部の使用制限――調速機――を食い潰して自滅したのです。

アンが伝声管へ吹き込む口上は、彼女の頭の中で、いつも同じ三つの問いに整理されています。どの種類の記録が要るのか。どの項目が欲しいのか。いつの期間の話なのか。この三つを一つの型――LedgerQuery――にまとめてから、初めて保管庫へ差し出す。今回の突き返し札は、この型そのものが悪かったのではなく、型は合っていても、保管庫の綴じ方――貸切興行という新しい記録の型が加わった、あの綴じ方――と噛み合わなかった、という話でした。

型ヒント付きのコードで、まずアンの旧い仕立てを、正直にお見せします。

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/self-correction-retry/before.py
"""自律人形アンの元帳保管庫問い合わせ(Before): 盲目的リトライ。

`execute_query`が送出する例外の型を一切見ず、起きたら同じプロンプトのまま
`MAX_BLIND_ATTEMPTS`回まで再送する。回復可能な誤り(綴じ方の不整合)も
致命的な誤り(調速機による伝声管の断絶)も、同じ「もう一度」に押し込まれる
(核心公理=入力が変わらなければ出力もおおむね変わらない、への違反)。
"""
import sys
import os
from typing import Callable

from pydantic import BaseModel

# 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 LedgerQuery(BaseModel):
    """アンが組み立てる問い合わせの構造(保管庫への口上の中身)"""
    record_type: str
    fields: list[str]
    period: str | None = None


class LedgerQueryAgent(BaseAgent):
    """自律人形アンの元帳保管庫への問い合わせ(Before)。

    `execute_query`(伝声管を通じて保管庫へ問い合わせ、応答札の中身を返す
    注入関数)が送出する例外の型を一切見ず、`except Exception`で一律に
    握り潰して同じプロンプトのまま次の周へ進む。回復可能(綴じ方の不整合)か
    致命的(伝声管の断絶)かを見分ける分岐が、コードのどこにも存在しない。
    """

    MAX_BLIND_ATTEMPTS = 20  # 「念のため多めに」という判断そのものが失敗の種

    def __init__(
        self,
        name: str,
        system_prompt: str,
        execute_query: Callable[[LedgerQuery], str],
        model: str = "gpt-4o",
    ):
        super().__init__(name, system_prompt, model)
        self.execute_query = execute_query

    def run(self, user_input: str) -> str:
        prompt = f"{self.system_prompt}\n\n依頼: {user_input}"
        last_error: Exception | None = None
        for _ in range(self.MAX_BLIND_ATTEMPTS):
            try:
                response = self.client.chat.completions.create(
                    model=self.model, messages=[{"role": "user", "content": prompt}]
                )
                raw = response.choices[0].message.content or ""
                plan = LedgerQuery.model_validate_json(raw)
                return self.execute_query(plan)
            except Exception as e:
                # 型を一切見ずに握り潰し、同じプロンプトのまま次の周へ(入力を一切変えない)
                last_error = e
                continue
        assert last_error is not None
        raise last_error

短いコードです。短くて、そして一つの節――except Exception:――に、すべての欠陥が詰まっています。

execute_queryが投げる例外(プログラムの実行中に起きた異常を、通常の処理を中断して呼び出し元へ伝える仕組み)を、この一節は型を見ずに、なんでも受け止めます。PydanticのValidationError(保管庫へ渡す口上の型が合わないときに送出される、検証失敗の例外)も、自前で用意したArchiveQueryError(保管庫が突き返し札で示す、綴じ方の不一致)も、ArchiveLineSevered(調速機が伝声管を塞いだ、致命的な断絶)も、区別なくlast_errorに収まり、同じプロンプトのままcontinueで次の周へ流れます。

MAX_BLIND_ATTEMPTS = 20という数字にも、正直に向き合わなければなりません。これは根拠のある数ではありませんでした。「念のため、多めに」という、私の当時の判断そのものが、この数字に表れています。

模擬公演で、この旧い仕立てを机の上で早回ししてみました。試験用の伝声管に、最初の四回は綴じ方の不一致を、五回目からは恒久的に断絶を返すよう仕込みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
execute_query = ScriptedExecuteQuery(
    [ArchiveQueryError("綴じ方が一致しません")] * 4 + [ArchiveLineSevered("伝声管が塞がれました")]
)
agent = BeforeAgentModule.LedgerQueryAgent(
    name="アン", system_prompt=MISSION_TEXT, execute_query=execute_query
)

with self.assertRaises(ArchiveLineSevered):
    agent.run("先月の決算の内訳を教えて")

self.assertEqual(execute_query.call_count, 20)

結果は、律儀なものでした。二十回、ちょうど呼び出されます。途中で自分から止まることは、一度もありません。そして最後に手元に残るのは、当初のただの綴じ方の不一致ではなく――調速機の断絶そのものでした。二十回の呼び出しに送られたプロンプトを並べてみると、一字一句、同じでした。入力を変えずに送り続けたのですから、当然です。何も変わらないまま、ただ、使ってはいけない場面で使い続けた回数だけが積み上がっていたのです。

さて、編み直しです。三つの壊れ方を、まず型で分けます。

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
# file: agents/code-puppeteer/tests/self-correction-retry/after.py
"""自律人形アンの元帳保管庫問い合わせ(After): 3層エラートリアージ+1回だけの自己修復。

例外の型で三層(一時的インフラ障害/回復可能な意味論的エラー/致命的エラー)に仕分け、
回復可能(`ValidationError`・`ArchiveQueryError`)にだけ、保管庫が示した客観的な
事実を次の口上へ織り込んで1回だけ言い直させる。`ArchiveLineSevered`や未知の例外は
`except`節が拾わず、そのまま伝播する(Fail-Fast)。Beforeと同じ骨格(組み立て→
LLM呼び出し→検証→実行)のまま、エラー処理の制御構造だけが変わる(単一差分)。
"""
import sys
import os
from typing import Callable

from pydantic import BaseModel, ValidationError

# 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 LedgerQuery(BaseModel):
    """アンが組み立てる問い合わせの構造(保管庫への口上の中身)"""
    record_type: str
    fields: list[str]
    period: str | None = None


class ArchiveQueryError(Exception):
    """保管庫が突き返し札で示す、綴じ方の不整合(スキーマ不一致)。回復可能。"""

    def __init__(self, message: str) -> None:
        super().__init__(message)


class ArchiveLineSevered(Exception):
    """調速機が働き伝声管そのものが塞がれた状態。致命的・回復不能。"""


def build_correction_prompt(prompt: str, error: Exception) -> str:
    """直前の失敗理由を、要約や意訳を挟まずに次の口上へ差し込む(Raise, don't mask)。

    `ValidationError`なら`.errors()`のloc/msgを整形し、`ArchiveQueryError`なら
    そのメッセージをそのまま使う。発生する場所は違うが、どちらも「保管庫が示した
    客観的な事実」を運ぶという点で同じ扱いにする。
    """
    if isinstance(error, ValidationError):
        detail = "; ".join(
            f"{'.'.join(str(part) for part in e['loc'])}: {e['msg']}" for e in error.errors()
        )
    else:
        detail = str(error)
    return (
        f"{prompt}\n\n前回の口上は保管庫に届きませんでした。理由: {detail}。"
        "この点だけを直して、もう一度口上を述べてください。"
    )


class LedgerQueryAgent(BaseAgent):
    """自律人形アンの元帳保管庫への問い合わせ(After)。クラス名はBeforeと同一(=同じ人形の同じ役目)。

    回復可能なエラー(`ValidationError`・`ArchiveQueryError`)だけを`except`節で拾い、
    `build_correction_prompt`で次の口上を書き換えて`max_self_correction`回だけ
    言い直させる。`ArchiveLineSevered`や未知の例外は`except`節が拾わず、
    そのまま伝播する(Fail-Fast)。
    """

    def __init__(
        self,
        name: str,
        system_prompt: str,
        execute_query: Callable[[LedgerQuery], str],
        max_self_correction: int = 1,
        model: str = "gpt-4o",
    ):
        super().__init__(name, system_prompt, model)
        self.execute_query = execute_query
        self.max_self_correction = max_self_correction

    def run(self, user_input: str) -> str:
        prompt = f"{self.system_prompt}\n\n依頼: {user_input}"
        attempt = 0
        while True:
            response = self.client.chat.completions.create(
                model=self.model, messages=[{"role": "user", "content": prompt}]
            )
            raw = response.choices[0].message.content or ""
            try:
                plan = LedgerQuery.model_validate_json(raw)
                return self.execute_query(plan)
            except (ValidationError, ArchiveQueryError) as e:
                if attempt >= self.max_self_correction:
                    raise
                attempt += 1
                prompt = build_correction_prompt(prompt, e)
            # ArchiveLineSevered、その他未知の例外はexceptが拾わず、そのまま伝播する(Fail-Fast)

ArchiveQueryErrorValidationError――生まれる場所は違います。前者は保管庫が実際に問い合わせを弾いたときに、後者はそもそもアンの口上がLedgerQueryという型(記録の種類・欲しい項目・期間を表す構造)に収まらなかったときに。けれど、どちらも「保管庫、あるいは検証の仕組みが、具体的な理由を示してくれている」という点では同じです。だからbuild_correction_promptは、この二つを同じ扱いにします。要約や言い換えを挟まず、示された理由をそのまま次の口上へ書き足す――これが、マエストロの二言目に対する、私なりの答えでした。

コンストラクタで受け取るmax_self_correctionが、既定では1。そしてrun()except (ValidationError, ArchiveQueryError)――この一節そのものが、分類の実装です。回復できる誤りだけを、この型指定で拾う。1回だけ、build_correction_promptで口上を書き直して、もう一度だけ挑む。それ以外の例外――ArchiveLineSeveredも、見たことのない例外も――は、この節が拾わないので、そのまま外へ伝わります。

コードの分かれ道は二つですが、三つの壊れ方と矛盾するわけではありません。一時的な障害(一つ目の壊れ方)と致命的な断絶(三つ目の壊れ方)は、このrun()の中では区別する意味がないのです。どちらも「今すぐ言い直したところでは直らない」という一点で同じ扱いにしてよく、一時的な障害だけを気長に待つ仕組みは、また別に用意すればいい話でした。この関数が背負っているのは、あくまで二つ目――回復可能な誤り――の見分けと、その1回きりの言い直しだけです。

以下は、両者の分かれ道を一枚に描いたものです。

Before/After対比のインフォグラフィック。左のBeforeは型を見ない盲目的リトライで、例外を型を問わず握り潰して同じプロンプトのまま再送する出口のない輪となり、上限20回で尽きて最後に捕まえた例外をそのまま伝播する。右のAfterは3層トリアージと1回だけの言い直しで、ValidationErrorとArchiveQueryErrorだけを「1回、使ったか?」の判定で仕分け、未使用ならbuild_correction_promptがエラー内容を織り込んで言い直し、使用済みやArchiveLineSevered等はFail-Fastで即伝播、成功なら応答札を返す

Beforeの丸い矢印――同じ節へ何度でも戻る、出口のない輪――と、Afterの分かれ道とを、見比べていただければと思います。輪を断ち切っているのは、たった一つの問い「1回、使ったか?」でした。

改めて数え上げれば、execute_queryの中身も、LedgerQueryの定義も、LLMへの問いかけ方も、一字も変えていません。変わったのは、エラーが起きたときにどう応じるか、その一点だけです。

型ヒントにも、今回ばかりは助けられました。execute_query: Callable[[LedgerQuery], str]――この一行を読むだけで、渡す関数がLedgerQueryを受け取って文字列を返すものだと分かります。(Beforeで使っていた)last_error: Exception | Noneも同じです。「まだ何も失敗していない」という状態を、コードの上で素直に言い表せる。PydanticのValidationErrorが実行時に「その値は本当に正しいか」を確かめてくれる相棒だとすれば、型ヒントは書いている最中に「そもそも渡すものの形が合っているか」を教えてくれる相棒です。二人の相棒がいてくれたおかげで、私は自分の書いたコードの誤りに、随分と早く気づけるようになりました。

似た仕組みを、ずっと前――思考ループに回数の上限を設けた回でも見た気がする、と思われるかもしれません。あのときの上限は、迷路から出られなくなった探鉱人形に「何回まで考えさせるか」という数そのものが答えでした。回数さえ決めれば、それで解決していたのです。今回の「1」は違います。回数を決める前に、まず型で仕分けることが先にあって、「1」はその先に出てくる、いわば結果にすぎません。回数だけを真似て「1回まで」と決めても、何が回復可能で何が致命的かを見分ける節がなければ、この仕組みは働かないのです。

この線引きは、古びたリトライの手法を焼き直しただけのものではありません。時間を置けば通じるようになるかもしれない一時的な障害への対処――バックオフを挟んで気長に待つ仕組み――は、それはそれで別に要ります。ただ、今回のコードにその役目を持たせるべきではない、というだけの話でした。

なぜ「一度」なのか。ここは、マエストロの言葉を鵜呑みにするだけでは終わらせたくないところです。build_correction_promptが渡せるのは、保管庫や検証の仕組みが示した「具体的な理由」だけでした。一度目の言い直しでは、この理由は確かに新しい手がかりとして口上に加わります。けれど、それでもなお同じ理由で弾かれたなら、二度目に渡せる理由は、一度目と同じものでしかありません。同じ理由をもう一度書き足したところで、口上に新しい手がかりが増えるわけではなく、ただ指示が積み重なっていくだけなのです。マエストロの言葉には、この仕組みの裏付けとなる三つの理由が畳み込まれていました。一度目の言い直しがいちばんよく効くこと。それ以上重ねれば、既に合っていたところまで直しすぎて崩れる恐れがあること。そして、直すたびに、使える回数を実際に消費していくこと。三つの理由が、一つの数へ収束していたのです。

模擬公演です。机の上に、試験用の伝声管を据えます。

まず、一度目に綴じ方の不一致を、二度目に正しい応答を返すよう仕込みました。二度目の口上に渡されたプロンプトを覗くと――一度目の突き返し札の文言が、そっくりそのまま織り込まれています。一度目には無かった言葉が、二度目に初めて現れる。入力が、確かに変わっていました。

1
2
3
4
5
6
first_prompt = call_list[0].kwargs["messages"][0]["content"]
second_prompt = call_list[1].kwargs["messages"][0]["content"]

self.assertNotIn("客数では数えられない一括契約が混在しています", first_prompt)
self.assertIn("客数では数えられない一括契約が混在しています", second_prompt)
self.assertTrue(second_prompt.startswith(first_prompt))

ValidationErrorでも試します。口上の型が合わない出力を最初に、正しい出力を二度目に返すよう仕込むと、こちらも同じ経路で救われました。生まれた場所が違っても、直し方は同じでよかったのです。

意地悪もしました。何度直しても綴じ方が合わない仕込みにすると、ちょうど二回――最初の一回と、一度だけの言い直し――で、私の元へ誤りが戻ってきます。三度目は、ありません。

1
2
3
4
5
6
7
8
9
execute_query = ScriptedExecuteQuery([ArchiveQueryError("綴じ方が一致しません")])
agent = AfterAgentModule.LedgerQueryAgent(
    name="アン", system_prompt=MISSION_TEXT, execute_query=execute_query
)

with self.assertRaises(ArchiveQueryError):
    agent.run("先月の決算の内訳を教えて")

self.assertEqual(execute_query.call_count, 2)  # 初回+1回の自己修復=ちょうど2回

二回で止まる、というだけの、地味な確認です。けれど地味だからこそ、安心できました。「1回」と決めた通りに、律儀に1回で止まってくれる――当たり前のようで、当たり前に確かめておかなければならないことでした。

最初から調速機が塞がっている仕込みでは、言い直しの機会を一度も使わず、即座に諦めました。空の口上や、壊れた形の応答を渡しても、ValidationErrorが土台のところできちんと働くことも確かめます。

問いから答えに至る道筋を、二つの例で一枚に描いておきます。

自己修復1回の道筋を描いたインフォグラフィック。依頼「先月の決算の内訳を」から1回目のLLM生成・検証を行い、綴じ方不一致のArchiveQueryErrorとfieldsが配列でないValidationErrorの2本のエラーの糸がひとつの環で束ねられてbuild_correction_promptに合流し、attemptが0から1へ進む。1回目のエラー文言を含む口上で2回目のLLM生成・検証を行い、成功なら応答札を返し、再びエラーならattempt上限に達してFail-Fastとして伝播する

全て、緑でした。

正直に、留保も置いておきます。この仕組みが直せるのは、綴じ方が合わない、型が合わない、といった、機械的に検出できる誤りだけです。たとえば、貸切興行の契約金額を、通常興行の客数の欄に取り違えて記したとしても、型としてはLedgerQueryにきちんと収まってしまいます。保管庫も、突き返し札を出す理由がありません。文法も型も合っているのに、意図とは違う記録を指してしまう――そういう、誰もエラーと教えてくれない誤りまでは、直せないのです。「一度」という数にも、万能の正解はありません。逓減する効き目と、使える回数の重みとを見比べて選んだ、意図的な最小限の床なのです。

第4幕: 開演と調和 ── 応答札に並ぶ文字

新しい仕立てで、本物の伝声管に、決算の続きを問います。

一度目、案の定、貸切興行の記録に、突き返し札。無理もありません。アンの口上の組み立て自体は、まだ何も直していないのですから。今度は、その文言をそのまま次の口上へ織り込みました。二度目――戻ってきた応答札に、求めていた数字が、過不足なく書き記されているのが見えます。

塞がったままだった部分にも、念のため、小さな確認を投げてみました。答えは、即座に諦めるというものでした。開くはずのないものを、開くまで叩き続けたりはしない。それを、私自身の手で確かめられたことが、何よりの収穫でした。

決算資料が、明朝の期限に間に合う形で整っていきます。

アンが、目を覚ましたのは、最後の数字を書き終えた頃でした。

ゆっくりと、まぶたが開きます。最初の一言は、いつもの気位でも、皮肉でもありませんでした。掠れた、小さな確認でした。

「……止まって、いた?」

その声を聞いた瞬間、自分がどれほど怖かったのか、私は初めて分かりました。答える声が、震えていたのです。

私は、正直に伝えました。何が起きたか。私が何を間違えたか。アンは黙って聞いていました。

少し間を置いてから、アンはいつもの調子を装って言いました。

「……お礼は言わないわ。次はもっと上手にやってちょうだい」

扇へ伸ばしかけた手が、途中で止まり、そのまま膝の上で指を組み直します。本調子には、まだ遠いのでしょう。それでも、その一言だけで、十分でした。

マエストロは、決算資料を手に取りながら、こちらを見ずに言いました。

「……間に合ったな」

それだけ言って、自分の作業へ戻っていきました。

工房は、いつもの静けさを取り戻していました。ガスランプの灯りが、書類の山を、さっきと同じように静かに照らしています。同じ景色のはずなのに、今夜はどこか違って見えました。

入力を変えなければ、何も変わらない。頭では分かっていたはずのことを、私は今夜、アンの声で思い知りました。エラーの中身を読まずに再起動を重ねた私の一行が、いちばん大事な相棒を、あそこまで追い詰めたのです。すべてが直せるわけではないことも、今夜は分かりました。型が合わない、綴じ方が違う――そういう誤りは、言葉にして渡せば直せます。けれど、言葉にならない誤り、誰もエラーだと教えてくれない誤りは、この仕組みでもまだ、手の届かないところにあります。それでも、何が直せて、何がまだ直せないかを、今の私は言葉にできる。昨日までの私には、それができませんでした。

夜更け、革表紙の「操り人形師の覚書」を開きます。八ページ目です。今夜のことを書き終えても、私はいつものように一人で頁を閉じませんでした。隣にいるアンに向かって、書いたばかりの文章を、声に出して読み聞かせたのです。

「エラーは、隠さず言葉にするから直る」

「直せるものと、直せないものを、まず見分ける」

「一度で直らぬものは、百度重ねても直らない」

読み終えて顔を上げると、アンが小さく呟きました。

「……悪くない締めくくりね」

扇を閉じる音が、静かな工房に小さく響きました。窓の外では、じきに夜が明けます。決算資料の束は、もう明朝の使いを待つばかりでした。ガスランプの灯りを一つ落として、私は椅子から立ち上がります。動いている間に、ではなく――ちゃんと直してから、明日を迎えられそうでした。


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

  • 調整したからくり(モジュール/関数名): LedgerQueryAgent(Before: 例外の型を判別しないexcept ExceptionブロードキャッチとMAX_BLIND_ATTEMPTS=20回の盲目的リトライ / After: except (ValidationError, ArchiveQueryError)のみを拾いbuild_correction_promptで口上を書き換えてmax_self_correction=1回だけ再試行、ArchiveLineSevered等は即伝播)
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 入力(プロンプト)が変わらなければ、出力もおおむね変わらない。エラーの種類を見ずに同じ入力で再送すれば、回復可能な誤りは再現し続け、致命的な誤りは回復の見込みなく呼び出しだけを浪費する
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • PydanticのValidationErrorと自前例外ArchiveQueryErrorを同一のexcept節で受けて1回だけbuild_correction_promptで口上を書き換える設計、ArchiveLineSeveredなど未知の例外はexcept節が拾わず自然に伝播させることでFail-Fastを実現する構造
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • エラーの中身を見ずに「とにかくもう一度」と再起動を重ね、外部の使用制限(調速機)を食い潰して自滅したこと。加えて「エラーを見せて何度でも」も治療ではない――回数の歯止めのなさが過剰修正と使用制限の浪費を招く
  • 模擬公演の記録(検証のポイント):
    • Beforeの20回の盲目的リトライが自力で止まらず最終的に致命的な断絶が伝播すること、AfterのArchiveQueryError/ValidationError双方が同一経路で1回の言い直しにより救済されること、2回目のプロンプトに1回目のエラー文言が実際に含まれること、1回使い切った後は即座に伝播し3回目が呼ばれないこと、ArchiveLineSeveredは初回から一切リトライされないこと
  • マエストロの言葉:
    • 「壊れた関節をそのまま動かそうとするな。糸が千切れる前に、動きを止めろ」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。