Featured image of post コードパペッティア【ReAct Loop】同じ角を掘り続ける迷子のモグラ人形〜考え続ける自由と、数える約束〜

コードパペッティア【ReAct Loop】同じ角を掘り続ける迷子のモグラ人形〜考え続ける自由と、数える約束〜

迷路のように入り組んだ坑道で同じ場所をぐるぐると巡り続け、石炭を浪費する探鉱人形モグラ三号。終了条件をLLM自身に委ねるReActループの危険性を解き明かし、max_iterationsによる制御ガードと構造化された状態管理をPythonで実装します。

第1幕: 予兆と調律 ── 数えられない足取り

夏の午後の光が、劇場の高い天窓から差し込み、宙を舞う微細な木屑や金属の粉を金色に染め上げていました。私は作業台の傍らに立ち、古い真鍮製オルゴールのドラムピンを磨く作業に没頭していました。磨き布が真鍮の表面を擦るきゅっきゅっという単調な音が、工房の平穏な空気に溶けていきます。

そこへ突如として、激しい金属扉の開閉音とともに、重い足音が飛び込んできました。事前のお知らせも、紹介の回想を挟む余地もありませんでした。

「頼む、手を貸してくれ! 劇場の人形遣いはこちらか!」

息を切らせて滑り込んできたのは、鉱山主任技師のバロウズさんでした。四十代後半の無骨な実務家で、その頑丈な作業服には石炭の黒い煤と重い機械油の匂いが分厚く染みついていました。彼の大きな掌は、長年の坑道作業によってできた固い胼胝で覆われており、そこには丸めた紙テープの束がまるで命綱のように握りしめられていました。引きずられたテープの端が、工房の磨かれた床の上でカサカサと虚しい音を立てています。

「バロウズさん、落ち着いてください。その紙テープは……?」

私が声をかけると、彼は煤けた額の汗を拭いもせず、震える手でそのテープを私の目の前に広げました。

「新しく開通させた東坑道の安全確認のために、探鉱人形の『モグラ三号』を先行させたんだ。だが、送り出して数時間、一向に戻ってこない。それどころか、坑道の奥から引いた電信線を通じて、詰所の受信機がこの紙テープをひたすら吐き出し続けている。見てくれ、この異様な記述を」

私はテープに顔を近づけ、そこに刻まれた文字をそっと指先でなぞりました。そこには、モグラ三号の頭脳である大規模言語モデル(LLM)(大量のテキストデータから言語のパターンを学習し、高度なテキスト生成や推論を行うための深層学習モデル)が紡ぎ出したであろう「思考」と「行動」の記録が、果てしなく連続していました。

「Thought: 北に進むべきか。Action: move_north. Observation: 壁に突き当たった。」 「Thought: では、東に進むべきか。Action: move_east. Observation: 壁に突き当たった。」 「Thought: 再び北に進むべきか。Action: move_north. Observation: 壁に突き当たった。」

文字のわずかな揺らぎこそあれ、やっていることは同じ場所の無意味な往復でした。私はその機械的な足取りの滑稽さと痛ましさに、胸の内で彼を「ぐるぐる屋さん」と呼ぶことにしました。

「それじゃあ、ぐるぐる屋さんは今も坑道の奥で、同じ角を掘り続けているのですか?」

私の問いに、バロウズさんは苦しそうに頷きました。

「そうだ。安全性が確認できていない坑道だから、人間が直接入って回収することもできない。しかも、モグラ三号の背中にある小さな石炭炉の燃料は、この瞬間も刻一刻と減り続けている。石炭が尽きれば、あいつは暗闇の中で完全に沈黙し、二度と回収できなくなるだろう」

その時、工房の奥の暗がりから、静かにマエストロが姿を現しました。彼は私たちの会話を黙って聞きながら、右手の親指で真鍮製の小さな計数器(タリーカウンター)をカチ、カチと無意識に動かしていました。その金属音は、狂ったテープの規則性に静かな警告を与えているかのようでした。マエストロはテープの文字面を見るのではなく、紙が擦れる乾いた音と、バロウズさんの荒い息づかいにしばらく耳を傾けていました。

そして、計数器を動かす手を止め、ぶっきらぼうに問いかけたのです。

「……そのモグラは、いつまで考え続けるつもりで送り出したんだ?」

私はその問いに、すぐには答えることができませんでした。

私たちは急ぎ、バロウズさんの馬車に飛び乗り、劇場の外の世界へと向かいました。馬車が激しく揺れるたび、車内に立ち込める石炭の匂いが私の鼻腔を刺激します。到着した鉱山の詰所は、湿った土と鉄錆の匂いに満ちていました。部屋の隅では、電信の受信機がカチカチと狂ったようなリズムを刻み、紙テープの山を床へと吐き出し続けています。

私は受信機の脇に積まれた過去の指示書を手に取り、モグラ三号に最初に与えた指示、すなわち思考のゼンマイを動かすためのプロンプト(大規模言語モデル(LLM)に対して指示や制約を与え、特定の応答や挙動を促すための入力テキスト)を確認しました。

そこにはこう書かれていました。 「壁にぶつかったら、それまでの経路を踏まえて、出口が見つかるまで考え続けなさい」

「なるほど……」

私は顎に手を当てて考えました。これは、これまでの失敗のように「人形を縛りすぎて動かなくなった」のではない。むしろ逆です。人形に対して十分に「考え続けろ」と信頼して委ねた結果、人形が自由になりすぎて、自ら止まる方法を忘れてしまったのではないか。もっと自由に、制限を設けずに思考を重ねさせれば、いつか正しい出口を見つけ出してくれるはずだ――人形遣いとしての未熟な万能感が、私の胸の中で静かに頭をもたげていました。


第2幕: 絡糸と破綻 ── 石炭が消える速さ

詰所の簡素な木製机の上で、私はインクの匂い立つペンを握り、モグラ三号へ電信で送るための追加の指示を書き殴っていました。自ら考える力を信じるのなら、中途半端な妥協を許してはならない。私はそう確信していました。

「壁に当たっても、あらゆる可能性を試すまで、決して立ち止まらず、深く考え続けよ」

この一文を打電させ、坑道の奥深くへと送り届けました。人形の自律性を最大限に解き放つための、私なりの「運糸」のつもりでした。

数分後、電信受信機が再びけたたましく鳴り響き、吐き出されたテープには、今までよりも明らかに長大で複雑な自問自答が記録され始めました。

「Thought: 北は壁だった。しかし、私の触覚が捉えた微風はそこから吹いていたのではないか。あるいは、先ほど試した東の角に、見落とした亀裂があるかもしれない。もう一度、念入りに調べてみる価値はある。Action: move_east...」

インクの文字が書き連ねられていく様子を見つめながら、私は「これで、より慎重に可能性を精査してくれているはず」と手応えを感じていました。そして、これまでの口癖を少し変えて、ぽつりと呟いたのです。

「動いてるけど、ちょっと変かも……?」

その言葉の響きに、私自身も無意識の違和感を覚えていました。これまでの調整のように、動いているからといって無条件に安心できるわけではないという、暗い予感が胸をよぎったのです。

その違和感は、すぐに現実の恐怖となって目の前に突きつけられました。

「おい、コレット嬢! 大変だ、石炭の残量計を見てくれ!」

詰所の若い技手が、壁に取り付けられたモグラ三号からの状態表示盤を指さし、青ざめた声を出しました。表示盤に備えられた真鍮の指針が、異常な速度で左へ、すなわち「枯渇」の方向へと傾いていたのです。

「深く考えれば考えるほど、モグラの頭脳は激しく発熱し、内部の炉を余計に焚きつけなければならない! 思考のゼンマイを回すためのエネルギーが、通常移動の何倍もの速度で消費されているんだ!」

技手の言葉は、私にとって冷や水を浴びせられるような衝撃でした。物語の中で「石炭の消費」と呼ばれるその現象は、現実の演算世界における実行コストそのものでした。どれだけ自由に考えさせても、その思考サイクル自体が有限の資源を貪り食っているという厳然たる事実を、私は見落としていたのです。

バロウズさんが私の肩を掴み、その無骨な顔を近づけて迫りました。

「本当にこれで合っているのか、コレット嬢! あいつの石炭はあと一時間も保たないぞ! 何か手を打ってくれ!」

私はペンのインクで汚れた手を震わせ、返す言葉を失っていました。

指針はすでに、警告を示す赤色の領域へと滑り込んでいました。それにもかかわらず、紙テープが吐き出す記録は、ますます長く、そして支離滅裂なものへと崩壊していきました。

「Thought: 私はどこにいるのか。北は壁。東も壁。先ほどの思考は正しかったか。いや、思考自体が壁に当たっている。もう一度、最初の位置から再考せねばならない。私は北へ進む。Action: move_north...」

それは、状態を保持できない頭脳が陥る、果てしない自己模倣のループでした。同じ角を「初めて訪れた」かのように新鮮な驚きをもって再検討し、そのたびに石炭の炉が激しく火花を散らして燃料を灰に変えていく。その破壊的な無駄遣いの様を、私はただ見守るしかありませんでした。

そして、最悪の瞬間が訪れました。

カチカチカチ……と不規則に鳴り響いていたテレグラフの金属音が、突如として途絶えたのです。

静寂。

重苦しい、粘りつくような静寂が、詰所の薄暗い部屋を満たしました。吐き出されかけた紙テープが、受信機の吹き出し口でだらりと垂れ下がったまま動きを止めました。

「……止まった、のか?」

バロウズさんの声は、信じられないほど掠れていました。

石炭が完全に尽き、モグラ三号の心臓部である炉の火が消えたのです。自律という名の放任を与えられた人形は、自分がどこにいるのかという位置情報すら報告しないまま、迷路の暗闇の底でただの鉄の塊と化してしまいました。

「私のせいです……」

私は自分の打った「決して立ち止まるな」という指示が、人形のゼンマイを極限まで巻き上げ、自らを焼き尽くさせる引き金になったことを悟りました。頭から血の気が引き、指先が氷のように冷たくなっていくのを感じました。

「バロウズさん、私は一度、工房へ戻ります。この絡まった糸を解くための道具が、あそこにはあるはずです」

私は詰所の技手に工房への急使を走らせるよう頼むと、自らも馬車に飛び乗り、暗くなりかけた道を劇場へと必死に引き返しました。


第3幕: 示唆と編み直し ── 数える約束

息を切らせて工房の扉を押し開けると、そこにはすでにアンがマエストロの傍らに佇んでいました。急使の知らせを受け、彼女も待機してくれていたのでしょう。アンは机の上に広げられた、あの無残な途絶の記録テープに水晶の瞳を落としながら、冷ややかに、しかし的確な批評を口にしました。

「あなた、“考え続けろ"とだけ命じて、“何回考えたか"を数えてすらいないでしょう。この人形、同じ角を何度も"初めて"であるかのように扱って、勝手に自滅しているじゃない。自律的であることと、無規律であることの区別もつかないのかしら?」

アンの言葉は、私の胸の最も痛いところを正確に射抜きました。

マエストロは何も言わず、作業台の上に二つの真鍮の計数器を並べました。 片方は、上部に小さな金属製のペグが差し込まれており、親指でレバーを押していくと、五回目で「カチン」と鈍い音を立ててレバーが固定され、それ以上回らなくなりました。 もう片方はペグが抜かれており、レバーを押すたびに数字のホイールがフリースピンし、何百回、何千回でも際限なく回り続けるものでした。

マエストロはフリースピンする方を激しく回して見せた後、ペグの刺さった方をそっと指し示し、ぶっきらぼうに言いました。

「……ゼンマイを巻きすぎだ。自律とは、勝手に踊らせることではない」

その一言が、私の頭の中で絡み合っていた思考の糸を一気に解きほぐしていきました。

「考える自由を与えることと、無制限に考えさせることは、同じではない……」

私は呟きました。人形に思考を委ねるのなら、その外側にある枠組み——「これ以上は引き返さねばならない」という境界線を、プログラムの側で厳格に保証してあげなければならなかったのです。指示の巻物に「五回試したら諦めなさい」と書き込むだけでは足りません。それもまた、思考のゼンマイの読み解き方に委ねられる、もう一本の糸に過ぎないからです。現に「深く考え続けよ」と念を押すほど、モグラは自分の判断でその糸をどこまでも引き伸ばしてしまいました。だからこそこの境界だけは、糸の外側——プログラムのwhileの条件式として、モグラ自身の解釈が届かない場所に刻まねばならないのです。

ペグを刺した方のレバーを試しに押し込んでみると、五回目で小さく「カチン」と鳴り、それ以上は動かなくなります。ペグを抜いた方は、同じ強さで押しても指先に何の抵抗も返さず、数字の輪だけが虚しく回り続けました。同じ「数える」はたらきでありながら、片方には終わりの手応えがあり、片方には、ない。この手応えの分かれ目を、坑道の奥で今も足を止めずにいるはずのモグラ三号の頭脳に置き換えると、思考の流れは次のように枝分かれするはずでした。

終了権限の所在を比較するBefore/Afterフローチャート。Beforeはwhile Trueの無条件ループでLLMの自己申告(declare_exit_found)だけを頼りに繰り返し石炭を消費して沈黙に至る様子を、Afterはcurrent_iterationがmax_iterations未満かという外部条件で判定し上限到達時はIterationLimitReachedを送出してstateとsafe_checkpointを保持する様子を示す

思考の中身そのものは、これまで通りモグラに委ねてよい。けれど、それが何回繰り返されたかを数え、上限でレバーを固定する役目だけは、モグラ自身の外側に立つ者が担わねばならないのです。

私は作業台に向かい、この設計をPythonの堅牢な構造へと落とし込む作業を開始しました。比喩のベールを剥ぎ取り、素の技術用語でロジックを再定義します。

今回実装するReAct制御ガード(LLMを用いた思考ループにおいて、外部プログラムによってループ回数の上限や状態の追跡を強制する制御構造)は、無限の再帰によるリソース消費を防止するための設計パターンです。このループの構成要素として、LLMの推論プロセスであるThought(思考)、外部ツールを動かすための命令であるAction(行動)、およびその実行結果のフィードバックデータである**Observation(観測)**を定義します。

あわせて、モグラへ渡す指示の巻物そのものも書き改めました。以前は「思考」と「行動」を地の文に紛れ込ませた自由な文章で答えさせていたため、合言葉(declare_exit_found)を含んでいるかどうかを、力任せに読み解くしかありませんでした。これからは、必ず思考と行動を決まった升目(JSON形式)に収めて答えるよう指示を改め、升目からはみ出す余地そのものを狭めます。

まず、1回のサイクルを記録する構造体 ExplorationStep と、全体のループ状態を管理する ExplorationState を、型検証ライブラリである Pydantic を用いて定義しました。

そして、ループの上限回数を示す max_iterations に到達した際、システムを異常クラッシュさせるのではなく、それまでの探索履歴と「最後に安全が確認された位置」を保持したまま安全に処理を打ち切るための、専用の例外クラス IterationLimitReached を定義します。これにより、呼び出し側は例外をキャッチして「安全なフォールバック(初期位置への退避処理など)」へと遷移させることが可能になります。

ただし、この設計における重要な留保として、今回の after.py の実装は「既に訪れた分岐を避ける判断(重複探索の回避ロジック)」そのものは含んでいません。それは状態の「記録」を行うだけであり、探索の効率化自体はLLMのプロンプト側、あるいは別の記憶探索アルゴリズムに依存します。この境界線を明確に理解した上で、私は変更前の 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
# file: agents/code-puppeteer/tests/react-control-loop/before.py
import sys
import os
from typing import Dict, List, Optional

# 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 MazeExplorerAgent(BaseAgent):
    """迷路探索人形モグラ三号の思考ループ。

    「もっと自由に考えさせれば、いつか出口が見つかるはず」という過剰な信頼のもと、
    終了条件を一切持たない while True で思考(Thought)と行動(Action)を繰り返す。
    """

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

    def run(self, user_input: str, history: Optional[List[Dict[str, str]]] = None) -> str:
        self.history = history if history is not None else []
        current_observation = user_input
        self.history.append({"role": "user", "content": current_observation})

        # ガードなし: 出口が見つかるまで、あるいはAPIが落ちるまで無限に繰り返す
        while True:
            messages = [{"role": "system", "content": self.system_prompt}] + self.history
            response = self.client.chat.completions.create(
                model=self.model,
                messages=messages,
            )
            content = response.choices[0].message.content or ""
            self.history.append({"role": "assistant", "content": content})

            if "declare_exit_found" in content:
                return content

            # 次の観測(壁への衝突など)は、直前の応答テキストをそのまま次のユーザー入力として渡すだけ
            current_observation = content
            self.history.append({"role": "user", "content": current_observation})

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/react-control-loop/after.py
import json
import sys
import os
from typing import Dict, List, Optional
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 ExplorationStep(BaseModel):
    """1回の思考・行動・観測を収める記録の器"""
    thought: str
    action: str  # 例: "move_north", "move_east", "declare_exit_found"
    observation: Optional[str] = None


class ExplorationState(BaseModel):
    steps: List[ExplorationStep] = Field(default_factory=list)
    max_iterations: int = Field(gt=0)
    current_iteration: int = 0
    exit_found: bool = False


class IterationLimitReached(Exception):
    """上限到達時、安全な分岐点へ退避したことを示す例外。

    Beforeの「外部要因(APIエラー)による無情報の強制終了」とは異なり、
    それまでの全ステップ記録(state)と最後に安全と確認できた地点(safe_checkpoint)を
    保持したまま、内部ロジックが意図的に打ち切ることを表す。
    """

    def __init__(self, state: ExplorationState, safe_checkpoint: str):
        super().__init__(f"{state.max_iterations}回で打ち切り。安全地点へ退避: {safe_checkpoint}")
        self.state = state
        self.safe_checkpoint = safe_checkpoint


class MazeExplorerAgent(BaseAgent):
    """迷路探索人形モグラ三号の思考ループ(ReAct制御ガード版)。

    「いつまで考えるか」をLLM自身に委ねず、外側のプログラムが`max_iterations`で決める。
    重複探索の回避(既に訪れた分岐を避ける判断)は実装しない(意図的なスコープ外)。
    """

    def __init__(self, name: str, system_prompt: str, max_iterations: int, model: str = "gpt-4o"):
        super().__init__(name, system_prompt, model)
        self.state = ExplorationState(max_iterations=max_iterations)

    def run(self, user_input: str, history: Optional[List[Dict[str, str]]] = None) -> str:
        # 各ステップの observation は「その行動が引き起こした結果」を表す。
        # 初回の観測(user_input)はどのステップの結果でもないため、履歴の先頭に固定で置く。
        while self.state.current_iteration < self.state.max_iterations:
            messages = [{"role": "system", "content": self.system_prompt}]
            messages.append({"role": "user", "content": user_input})
            for step in self.state.steps:
                messages.append({
                    "role": "assistant",
                    "content": json.dumps({"thought": step.thought, "action": step.action}),
                })
                if step.observation:
                    messages.append({"role": "user", "content": step.observation})

            response = self.client.chat.completions.create(model=self.model, messages=messages)
            content = response.choices[0].message.content or ""

            try:
                data = json.loads(content)
                thought = data.get("thought", "")
                action = data.get("action", "")
            except (json.JSONDecodeError, AttributeError):
                # パース失敗時も安全側に倒す(クラッシュさせない)
                thought = content
                action = "unknown"

            if action == "declare_exit_found":
                self.state.steps.append(ExplorationStep(thought=thought, action=action))
                self.state.current_iteration += 1
                self.state.exit_found = True
                return thought

            # この行動が引き起こした結果(簡易シミュレーション)。既踏の分岐を憶えて避ける判断はしない
            resulting_observation = f"Observed effect of action: {action}"
            self.state.steps.append(
                ExplorationStep(thought=thought, action=action, observation=resulting_observation)
            )
            self.state.current_iteration += 1

        # 上限到達時のフォールバック処理: クラッシュではなく、安全な分岐点への退避
        safe_checkpoint = "start"
        if self.state.steps and self.state.steps[-1].observation:
            safe_checkpoint = self.state.steps[-1].observation
        raise IterationLimitReached(self.state, safe_checkpoint)

コードの記述を終えた私は、詰所の試験用オルゴール(モック環境)を用い、模擬公演による動作確認に取り掛かりました。

まず、無制限に探索を繰り返すBeforeの設計に対し、出口が見つからないダミー入力を与えると、外部の擬似API制限に達するまで無限に呼び出し続け、最終的に制御不能の状態でクラッシュすることを確認しました。

次に、Afterの設計に max_iterations=5 を設定して同様の入力を与えると、ちょうど5回目の呼び出しを完了した時点で、外部エラーではなく内部ロジックが IterationLimitReached 例外を投げ、確実にループを停止させることに成功しました。構造化された ExplorationState には、5回分の思考と行動が整然と記録されており、最後にモグラが観測した「安全な位置」も正確に保持されていました。この5という数字は、これまでの坑道図面から読み取れる分岐点までの平均的な歩数と、一回の思考が食う石炭の量から逆算した、あくまで暫定の目安に過ぎません。少なすぎれば真っ当な探索の途中で諦めさせてしまい、多すぎればBeforeほどではないにせよ無駄な石炭を焼くことになる——その按配を見極めるのも、これからの調整の仕事です。

石炭の残量計の針が赤の領域へと滑り込んでいくのを、ただ見ていることしかできなかったあの日の記憶は、まだ生々しく胸に残っていました。試験用オルゴールが回り続ける間、私は模擬公演の記録を見比べます。数字が刻まれた紙テープが二本。一本は際限なく吐き出され続け、もう一本はちょうど五行目で静かに途切れています。

模擬公演での検証結果を示す2本の電信テープの図。Beforeは無限に印字され続け千切れて散らばるテープで制御不能なクラッシュを、Afterはmax_iterations=5でちょうど5つの刻みで整然と終わり安全な位置を示す焼印が付いたテープで安全な打ち切りを表す

二本のテープの終わり方の違いこそが、今日の編み直しの答えそのものでした。

試験用オルゴールのシリンダーが静かに回転を止めると、詰所のテレグラフが再び音を立て始めました。しかしその音は、これまでの焦燥に満ちたカオスな乱れではなく、一定の間隔を保ちながらカチ……カチ……カチ……と、落ち着いた静かなリズムを刻んでいたのです。それは、人形が自分の歩幅を正しく数え始めたことを告げる、確かな約束の音でした。


第4幕: 開演と調和 ── 帰ってきた足音

私はアンとともに(マエストロは「工房で見守るのが人形師の役目だ」と言って残り、同行はしませんでした)、再び地下の詰所へと舞い戻りました。新しく編み直した制御モジュールを電信で打電し、モグラ三号の予備受信機へと送り込みます。同時に、バロウズさんは作業員たちを手配し、万が一の自動停止時に備え、坑道の入り口近くまで予備の石炭を運ばせる実務的な手はずを整えていました。

「打電、完了しました。再起動します」

私がスイッチを入れると、詰所の壁のテレグラフが規則正しくカチ……カチ……と時を刻み始めました。送り出される電信の文字が、紙テープの上に一文字ずつ印刷されていきます。

「Thought: 北の壁を確認した。Action: move_east」 「Observation: Observed effect of action: move_east」 「Thought: 東の壁を確認した。Action: move_south」

そこには、無駄に膨れ上がった過去の言い訳はなく、型通りに整理された純粋な思考と行動だけが流れていました。

私は胸の中で、ある冷徹な事実を自覚していました。この新しいモグラ三号は、決して「迷路を解く賢さ」自体を手に入れたわけではないのです。既踏の分岐を自律的に避けるロジックはコードに組み込んでいません。彼が今、同じ角をぐるぐると回らずに進めているのは、ただ単に「上限回数に達する前に、幸運にも正しい出口へ繋がる分岐を選び取れている」という、偶然の巡り合わせに過ぎないのです。

それでも、私の心はBeforeの時とは違い、静かに落ち着いていました。勝利の核心は、賢く解くことではなく、「見つからない場合でも、リソースを使い果たす前に自ら立ち止まり、安全な場所で助けを待てる」という、設計された安全性を手に入れたことにあるのですから。

「コレット嬢、モグラの足音が聞こえないか?」

バロウズさんが、ヘッドランプを坑道の暗闇に向けながら叫びました。

静まり返った坑道の奥から、確かに、土を引っ掻くようなザッ、ザッという規則正しい音が聞こえてきました。続いて、真鍮の触覚が坑道の壁に触れる微かな金属音と、カチカチという歯車の噛み合う確かな足音が響きます。

次の瞬間、煤で真っ黒に汚れた真鍮の体を揺らしながら、モグラ三号が自力で坑道の出口へと這い出てきたのです。背中の小さな石炭炉からは、役目を終えた薄い白煙がたなびいていました。そして、その頑丈な掘削爪の間には、鈍い虹色の光を放つ大きな原石の欠片が、しっかりと抱え込まれていました。出口を見つけるだけでなく、見事な鉱脈の存在を物理的に持ち帰ってくれたのです。

バロウズさんは大きな安堵の息を吐き出すと、モグラ三号の前にひざまずき、その煤けた真鍮の背中を、無骨な大きな掌でぽんと力強く叩きました。

「……見事だ。恩に着る、コレット嬢。あいつを闇の中に置き去りにせずに済んだ」

彼の感謝は短く、職人らしい簡潔なものでした。しかし、すぐにその鋭い目が私に向けられました。

「……だが、次もこうして上手く戻ってこられるとは限らんのだろう?」

私はその実務的な問いかけに、正直に頷きました。

「はい。今回は運が良かったのもあります。モグラはまだ、自分が歩いた道を完璧に記憶して避けることはできません。でも、もし出口が見つからなかったとしても、背中の石炭を全て燃やし尽くして自滅する前に、必ず手前の安全な分岐点まで戻り、そこで炉を眠らせて待機するよう約束を交わしました。詰所の受信機は、その『打ち切りました』という信号と、最後にいた安全な場所の記録を必ず一緒に受け取れるように仕組んであります。だから次に何かあっても、捜索隊はどこを掘ればいいか分からないまま闇雲に探すのではなく、その最後の記録を頼りに迎えに行けます。私たちは、もう彼を無駄死にさせることはありません」

バロウズさんは私の言葉を咀嚼するように黙って頷くと、モグラ三号の爪から受け取った原石の欠片を、私の手へと手渡してくれました。

「この鉱山からの、ささやかな謝礼だ。劇場の調律師にふさわしい石だろう」

それはハリスさんのミートパイのように温かくはなく、クレアさんの呼び鈴のように洗練されてもいませんでしたが、光にかざすと地層の奥深くが秘めていた結晶が不思議な輝きを放つ、美しく重い原石でした。

アンが私の横から原石を覗き込み、少しだけ口元を綻ばせました。 「フン、ただの石炭の親戚かと思ったけれど、磨けば少しは使えそうな光ね。あなたの拙い運糸も、今回は辛うじてその石と同じくらいには役に立ったかしら」 いつもの辛辣なダメ出しとは違う、彼女なりの静かな労いの言葉が、私の張り詰めていた心を優しく解きほぐしていきました。

劇場の工房に戻ると、夜の静寂が私たちを迎えてくれました。 マエストロは作業台の前に立ち、昼間に使った計数器を片付けているところでした。私たちの帰宅に気づいても振り返ることはありませんでしたが、上限のペグをそっと指先で確かめてから、棚の奥の定位置へと戻すその所作には、いつにない静かな敬意が宿っているように見えました。

私は作業台の前に座り、革表紙の「操り人形師の覚書」を開きました。 四ページ目の新しい羊皮紙を前にして、私は万年筆を握ったまま、初めて手を止めました。

これまでのようには、今日の教訓を綺麗な一段の数式や完璧な勝利として書き留めることができなかったからです。運の要素が残り、まだ解決すべき課題が暗闇の中に残されていることを知ってしまったから。

それでも、私はインクを走らせました。その躊躇いそのものが、私の成長の足跡なのだと信じて。

「自由に考えさせることは、人形への優しさではない。ここまで、と数えてあげる約束こそが、暗闇を歩む人形の最後の命綱になる」


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

  • 調整したからくり(モジュール/関数名): MazeExplorerAgent(Pydanticを用いた ExplorationState / ExplorationStep による状態管理)
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 終了判断をモデル自身の自己申告にのみ依存させると、曖昧な観測値の連続によって同一の結論を無限にループし、自力での停止が困難になる非決定的な特性
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • max_iterations による思考サイクルの物理的制限と、上限到達時にそれまでの全ステップ記録および安全なチェックポイント(safe_checkpoint)を保持して安全に打ち切る IterationLimitReached 例外の送出制御
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • 人形の自律性を過剰に信頼し、「より深く粘り強く考えよ」という無制限の指示を与えた結果、思考サイクルごとの実行リソース(石炭燃料)の劇的な浪費と、終わりのない無限ループによる自己崩壊を招いたこと
  • 模擬公演の記録(検証のポイント):
    • ガードのない Before エージェントの無限ループによる強制クラッシュの再現、After エージェントにおける設定された上限回数での正確な打ち切り、上限内での正常系正常終了、および ExplorationStep を通じた構造化ステップログの型アサーション検証
  • マエストロの言葉:
    • 「……ゼンマイを巻きすぎだ。自律とは、勝手に踊らせることではない」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。