Featured image of post コードパペッティア【Sliding Window Memory】夜更けに任務を忘れる夜警人形〜膨らむ今夜と、石盤ひと枚の覚え〜

コードパペッティア【Sliding Window Memory】夜更けに任務を忘れる夜警人形〜膨らむ今夜と、石盤ひと枚の覚え〜

巡回が長引くほど当初の命令を忘れ、ある夜とうとう廊下で止まった劇場の夜警人形。会話履歴の無限追記が招く突然の停止と、直近の窓・要約・ピン留めの三層で任務を朝まで保つPython実装を、夜通しの修繕記に添えて。

頬に当たる木目の感触で、自分が作業台に突っ伏して眠っていたことに気づきました。

三日後に迫った貸切の祝賀に向けて、小道具の繕いものが山になっていたのです。今夜のうちにあと三つ、と意気込んだところまでは覚えています。口に咥えたままの糸切り鋏をそっと置いて、私は凝った首を回しました。残してあった小さな作業灯がひとつ、油の残りを気にするように瞬いています。

何かが、足りませんでした。

蝋と機械油の匂い。遠くでかすかに響く柱時計の振り子。夜の工房の音は、いつも通りです。いつも通りなのに、静けさの質が違う。半分眠ったままの頭で、私はしばらくその違和感の正体を探しました。そして、数拍おいてから、すとんと腑に落ちたのです。

三時の声が、来ていない。

第1幕: 予兆と調律 ── 来ない三時の声

私たちの劇場には、夜のあいだ館内を歩き続けるからくり人形がいます。

丈の長い外套に、片手には真鍮のカンテラ。腰には戸締まりを確かめるための鍵束が、歩くたびに小さく触れ合って鳴ります。名前はヴェスパー。春先、劇場が忙しくなり始めた頃に、マエストロが倉庫の奥から出してきて夜番に就けた、老執事のように物腰の恭しい夜警人形です。

彼は毎正時、館内をひと回りして、廊下の奥で小さく時を告げます。「一時、異常なし」「二時、異常なし」——住み込みの私にとって、それは夢うつつに聞く、夜の背骨のような音でした。意識して聞いているわけではないのに、あるだけで夜が安心な音。廊下の暗がりの奥をゆっくり泳いでいくカンテラの灯りで彼の居場所が分かるものだから、私はいつからか、胸の内で彼を「カンテラさん」と呼んでいました。

その三時の声が、来ていないのです。

私はランプを手に、暗い館内へ出ました。床板が素足の裏に冷たくて、自分の足音だけが高い天井に薄く返ります。舞台袖を抜け、客席の脇を通り、東の廊下へ。角を曲がった先で、床に落ちた小さな灯りが見えました。

カンテラでした。

ヴェスパーは、廊下の途中で凍りついていました。外套の裾は歩みの形のまま止まり、口はわずかに開いて、ちょうど時を告げようとした姿勢のまま。ランプを近づけると、彼の胸の小窓の奥が見えました。そこには彼の「記憶の糸巻き」——今夜あったことを一節ずつ巻き取っていく、掌ほどの糸巻きが収まっています。

その糸巻きが、ぱんぱんに膨れていました。巻かれた糸が自分の重みで軸に食い込み、歯車の噛み合わせごと、機構を止めてしまっている。触れてみると、糸はどこも切れていません。ただ、太りすぎて、動けなくなっていました。

「……カンテラさん」

呼んでも、返事はありません。時を告げる途中で途切れた一巡りの終わりに、彼はひとりで立ち尽くしていたのです。誰にも気づかれずに。私が眠りこけているあいだに。

白状しなければならないことがあります。彼の胸のその糸巻き——記憶の糸の仕立てを任されたのは、この私でした。春の、まだ何も分かっていなかった頃の私が、初めてひとりでやり遂げた仕事。それが今、彼を止めているのです。

深夜に直せる類のものではありませんでした。私は自分の部屋から毛布を持ってきて彼の肩に掛け、その傍らに座り込んで、膨れた糸巻きをランプで照らしながら夜明けを待ちました。眠る気には、なれませんでした。

朝いちばんに廊下へ現れたマエストロは、ヴェスパーの胸の小窓を一目見て、それから、私を見ました。糸巻きではなく、私を、です。

「……お前が仕立てた糸だ。張り直しも、お前が決めろ」

それだけ言って、工房の奥へ戻っていきました。助け舟は、ありませんでした。数週間前から、夜の警備まわりの一切はお前が見ろ、と任されていた身です。突き放されたのではなく、任されたのだと、その淡々とした声の置きどころで分かりました。分かったから、余計に背筋が伸びました。

工房に運んだヴェスパーから糸巻きを外し、食い込んだ糸を少しずつ緩めながら、私は昨夜の巻きを順に解いて検分しました。

巻かれていたのは、こんなものたちです。宵の申し送り——祝賀の荷が西回廊に届くこと、楽屋口の鍵を替えたこと、泊まり込みの職人の出入りがあること。毎正時の巡回の報告。深夜の出来事——居残っていた衣装係との挨拶、迷い込んだ野良猫を外へ出したこと、遅い届け物を預かったこと。

ぜんぶ、ありました。几帳面に、時刻の順に、ひとつ残らず。

「……どこも、壊れていないんです」私は思わず声に出していました。「ただ、多すぎるだけで」

そして糸巻きの軸に、小さな書き付けが貼ってあるのを見つけたのです。あの春の日の、私自身の字でした。

「起こったことは、ぜんぶ巻いておくこと。忘れ物さえなければ、夜は安心」

息が詰まりました。動いてさえいれば誇らしかったころの私が、そこにいました。抜けや漏れが何より怖くて、ぜんぶ取っておけば間違いない、と信じて疑わなかった頃の私が。今夜の依頼人は劇場の誰でもなく、強いて言うなら——過去の、私だったのです。

ここで、彼の記憶の仕組みを説明させてください。ヴェスパーの「今夜」は、宵に背中の鍵を巻いた瞬間に始まります。巻き始めの一節として、彼が生まれたときに授かった務めの命令——毎正時に巡回し、異常の有無を告げ、戸締まりを確かめること——が糸巻きに据えられ、そのあとに続く夜の対話が、一節、また一節と巻き足されていく。朝、ゼンマイが解けると、今夜の巻きはほどかれて、翌日の宵にはまたまっさらな一巻きが始まります。一晩がひと巻き、ひと巻きがひとつの対話。それが彼の夜の単位です。

種を明かせば、これはAIエージェントの会話履歴の話です。彼の思考のゼンマイ——大規模言語モデル(LLM)は、ステートレス(呼び出しごとに文脈を持たず、毎回まっさらな状態から読み直す性質)です。人形が「会話を覚えている」ように振る舞うためには、それまでの対話の記録を、毎回の呼び出しのたびに、頭からぜんぶ運び直して読ませるしかありません。そして読ませられる量には、はっきりした天井があります。コンテキストウィンドウ——LLMが一度の呼び出しで扱えるトークン数の上限です。トークンというのは、LLMが文章を扱うときの最小の単位のこと。英語ならおおよそ4文字で1トークンほどが目安とされます。命令も、会話の履歴も、これから返す応答のぶんも、みんなこの同じ一枠を分け合っています。

だから、対話を無限に巻き足していけば、一回ごとに運ぶ量は単調に膨らみ続けて、いつか必ず天井に頭をぶつけます。ぶつけた瞬間、呼び出しは受け付けられなくなる。ヴェスパーの糸巻きが物理で語っていたのは、つまりそういうことでした。

「でも、変なんです」私は解いた糸を眺めながら、隣に来ていたアンに言いました。「春からずっと、この仕立てで動いていたんですよ。どうして今頃になって」

アンは私の手元を覗き込んで、扇の先を顎に当てました。

「あなた、気づいていなかったの。この頃の夜が、春の夜と同じに見えていたのなら、ずいぶんおめでたいことだわ」

言われて、思い当たりました。春の夜は静かでした。申し送りは二言三言、夜の出来事といえば猫くらいのもので、糸巻きは細いまま朝を迎えていた。それがこの数月、劇場は目に見えて忙しくなりました。ホテルの夜会帰りのお客様が寄ってくださる。集合住宅の住人の皆さんの噂が、新しいお客様を連れてくる。歌劇場の式典帰りの足が、うちの看板の前で止まる。ありがたいことばかりです。ありがたいことばかりで——客が増え、荷が増え、夜の出入りが増えて、ヴェスパーの一晩は、春の何倍も「長く」なっていたのです。

私たちの仕事の評判が、めぐりめぐって、夜を膨らませていた。そのことに、私はまるで気づいていませんでした。

「そういえば」アンが扇を閉じました。「ここ幾晩か、あの子の時報、少しずつ遅れていたわね」

それも、思い当たりました。数日前の晩には、宵に伝えたはずの申し送りを、夜半にもう一度訊かれたことがあった。おかしいな、とは思ったのです。思っただけで、流してしまった。糸巻きが太るほど、彼は返事のたびに、より長い巻きを頭から読み直さなければならなくなっていた。長い文脈の中程に沈んだ古い申し送りは、だんだん拾われにくくなっていた——lost in the middleと呼ばれる、長い文脈の真ん中に置かれた情報ほど読み落とされやすくなる現象です。

崩落は、いきなり来たのではありませんでした。ソフトな劣化が先に来て、崩落は後から来た。時報の遅れも、聞き返しも、みんな前兆だったのです。私が聞き流した、前兆だった。

そして三日後には、バロウズさんの鉱山会社が東坑道の開通祝いに劇場を一夜借り切る、あの祝賀が控えています。西回廊は贈り物の荷で埋まり、申し送りは過去最長、職人の出入りは夜半まで続く——ヴェスパーの夜番が始まって以来、間違いなくいちばん長い夜になる。

このままでは、祝賀の前夜、彼はまた止まります。今度は、荷で埋まった館の、誰も知らない暗がりで。

ちなみに、と自分の覚書をめくって思い出していました。つい先日、歌劇場のクリオさんの一件を書き留めた晩、私は最後にこう記していたのです。増え続ける記憶の手入れは、どうしたらいいのだろう、と。でも、解いた糸を前にして分かりました。あのとき考えあぐねていたのは、棚に仕舞った記憶が何年分にも増えていく話。今日のこれは、その手前の話です。棚に仕舞う前の——今夜そのものが、ひと巻きに収まらないという話なのでした。

第2幕: 絡糸と破綻 ── 切った糸の、根

手当ての前に、まず彼を起こしてあげなくてはなりません。

食い込んだ糸を最後まで丁寧に解き、噛んでしまった歯車のあいだに油を差して、擦れた軸を布で拭う。鍵を巻き直すと、ヴェスパーはゆっくりとまぶたを開けて、途切れていた一言の続きから帰ってきました。

「——三時、異常……」そこで自分の周りの朝の光に気づいて、彼は深々と頭を下げました。「申し訳ございません、コレット様。務めを、果たせませんでした」

「カンテラさんのせいじゃありません」私は慌てて首を振りました。「糸を仕立てた私の、落ち度です」

さて、どう直すか。作業台に向かった私の考えは、我ながら素早いものでした。

巻きが太って止まるのなら、細く保てばいい。

昨夜までの彼は、起こったことをぜんぶ巻き取っていました。だったら、直近のぶんだけを糸巻きに残して、古いほうから切り落としていけばいい。天井に頭をぶつける前に、糸のほうを短くしてしまえばいいのです。仕立て直しは、拍子抜けするほど簡単でした。彼の記憶の術式でいえば、たった一行を書き足すだけのことです。

1
self.history = self.history[-12:]  # 直近12件だけを残し、古いものから切り落とす

我ながら、それらしい手当てに見えました。つい先日、工具だるまさんの一件で「ぜんぶ持たせるのは親切ではない」と学んだばかりです。記憶だって同じこと。ぜんぶ抱えるから潰れるのだ、と。もう二度と、夜の途中で彼を止めたりしない。あの朝の廊下の冷たさを思い出すたび、指先にそう力がこもりました。

——今にして思えば、これは反対側へ倒れただけでした。抜けが怖くてぜんぶ足していた春の私と、太るのが怖くてざっくり削った今の私は、鏡合わせの同じ顔をしています。けれどこの時の私は、「何を切り落としているか」を、数えてすらいなかったのです。

日暮れ前に、短い夕試しをしました。模擬の申し送りをいくつか聞かせ、短い巡回をさせてみる。時報は正時に戻り、糸巻きは細いまま、受け答えも滑らかです。成功、のはずでした。細くなった巻きを指先で確かめながら、それでも私の口から出たのは、こんな呟きでした。

「動いてはいるけど、この糸の張り方でいいのかな」

強く張りすぎても、手放しで緩めても、人形は舞台から落ちる。この数月で、私はそれを嫌というほど見てきました。だからでしょうか。動いている、という事実だけでは、前ほど胸を張れなくなっていたのです。

決めました。今夜は、袖から見届けます。直したつもりで送り出して、朝に泣くのは、もうたくさんですから。

「見張りを見張るのでしょう?」支度をしていると、アンがショールを腕に掛けてやってきました。「あなた一人では、夜明けまでに心細さで糸が縮むわ。毛布は二枚よ」

口は辛くても、付き合ってくれるつもりなのでした。

宵。鍵を巻かれたヴェスパーが、カンテラを提げて歩き出します。私とアンは舞台袖の暗がりに椅子を並べ、毛布にくるまって、廊下を泳いでいく灯りを目で追いました。

真夜中までは、快調そのものでした。「十一時、異常なし」「十二時、異常なし」——時報は正時ぴったり。胸の小窓の奥の糸巻きも、細いままです。すれ違いざまに覗き込んだアンが「あら、優秀」と扇の陰で囁いて、私は少し得意にさえなっていました。ほら、ちゃんと動いている。今度こそ、ちゃんと。

手応えは、丑三つに崩れ始めました。

巡回から戻ってきた彼に、私はさりげなく訊いたのです。「カンテラさん、西回廊のお荷物のこと、覚えていますか」——宵の申し送りの、一番大事な一件です。

「……荷、で、ございますか」

一拍の、空白がありました。たった一拍です。でもその一拍は、聞いたことのない種類の空白でした。試しに別のことを訊いてみます。一時間前に私がどこにいたか——袖の椅子に、と淀みなく答える。夜半に追い出した猫のこと——三毛でございました、と即座に答える。近くのことは、完璧なのです。完璧なのに、宵に遡るほど、返事が薄い。

隣でアンが扇を止めたのが分かりました。私の胸には、既視感とも逆さまともつかない、嫌な形の気づきが降りてきていました。クリオさんは、昨日を失っても、劇場史の素養は保っていました。この子は、たった今のことを完璧に保ったまま——いちばん古いものから、失っている。

二時を過ぎた頃でした。

廊下を行くカンテラの灯りが、小道具部屋の前で、止まりました。ヴェスパーは灯りを床にそっと置くと、扉を開けて、中に入っていきます。祝賀の飾りの束が、昼の搬入のままいくつか床に出しっぱなしになっている部屋です。私とアンは顔を見合わせ、足音を殺して後を追いました。

彼は、片付けものをしていました。

散らばった飾りを一つずつ拾い上げ、律儀に種類を揃えて、棚に納めていく。その手つきは丁寧で、優しくて、どこまでも彼らしいものでした。ただ、時報の刻限はとうに過ぎています。巡回は、止まっていました。カンテラは扉の外に置かれたまま。夜警が、夜回りを、やめていたのです。

「カンテラさん」

私は歩み出て、できるだけ静かに訊きました。

「あなたの、今夜のお務めは、何ですか」

彼は飾りを持った手を止め、こちらを向いて、いつもと同じ恭しさで答えました。

「……散らかりを、片すことかと。……申し訳ございません、コレット様。務めの、根が——うまく、思い出せないのです」

それから、少しだけ間を置いて、こう続けました。

「夜半を過ぎたころから、務めの手応えが、指の間から細っていくようで」

私は彼の胸の小窓を検めました。細く保たれた糸巻きが、行儀よく回っています。直近の十二節。三毛猫のこと。私の居場所のこと。棚の飾りが散らかっていたこと。——そして、巻き始めにあるはずのものが、そこにはありませんでした。

務めの銘が、ないのです。毎正時に巡回し、異常を告げ、戸締まりを確かめよという、彼が生まれたときに授かった命令の一節が。

当たり前でした。私の書き足した一行は、「古いほうから」切り落とすのです。そして彼の糸巻きの中で、いちばん古い一節は、いつだって——宵の巻き始めに据えられた、務めの銘そのものだったのですから。夜が更けて対話が十二節を超えた瞬間、銘は真っ先に窓の外へ押し出され、切って落とされていた。彼が夜警でなくなったのは、彼のせいではありません。

私が切ったのは、糸ではありませんでした。根でした。

膝から力が抜けて、その場にしゃがみ込みそうになるのを、棚の縁を掴んでこらえました。何より堪えたのは、彼が止まらなかったことです。昨夜のクラッシュは、大きな音で私を呼んでくれました。今夜の彼はエラーのひとつも上げないまま、丁寧なまま、静かに別のものにすり替わっていた。目の前の棚が整っていくのを見ながら、それを美しいとさえ感じてしまう自分の目が、いちばん怖かった。

「……もうよいのです、カンテラさん。片付けは、朝の私の仕事ですから」

飾りをそっと受け取ると、彼は「さようで、ございますか」と頭を下げました。務めの言葉遣いは、最後まで崩れていませんでした。それがどうしようもなく、痛かったのです。

第3幕: 示唆と編み直し ── 消えてならないものの置き場

ヴェスパーに当座の申し送りを渡して巡回へ戻ってもらい、私とアンは袖に引き返しました。三時の袖は冷えます。毛布を巻き直す私に、アンが口を開きました。

「このあいだは積みすぎて潰して、今夜は削りすぎて根を切る。あなたの鋏は、ずいぶん働き者ね」

返す言葉もありません。でもアンの追撃は、皮肉のためだけの皮肉ではありませんでした。

「いい? 命令もお喋りも、同じ一本の糸に巻いているのが元凶よ。古い順に切っていけば、いちばん古いのは何かしら? ——生まれたときに授かった務めに、決まっているじゃない。あなたの一行は間違って動いたのではないの。書かれたとおりに、正しく、いちばん大事なものから切ったのよ」

正しく、切った。その言い方が、胸の奥に刺さって抜けませんでした。切り詰めというのは「古いものほど要らない」という前提の上に立つ術です。その前提が崩れる場所——古いものこそ要る場所に、私は何も考えずにそれを持ち込んだのでした。しかも切り落とした糸は、もう戻りません。こぼれた夜は、拾えないのです。

「じゃあ……」私は縋るように思いつきを口にしました。「蝋管は、どうでしょう。マエストロの棚みたいに、夜の言葉を刻んでおいて、要るときに引けば、糸巻きは細いままにできるんじゃ」

クリオさんを救った、あの共鳴の術です。アンは自分の昨日があの棚に眠っていることを知ったばかりですから、少し意地の悪い問いだったかもしれません。彼女は扇を開いて、ゆっくりと首を振りました。

「引く術はね、問いを立てて、いちばん似ているものを選ぶの。私の昨日はそれでいいのよ——訊かれてから、思い出せば済むもの。でも、あの子の夜回りに要るのは何? たった今の続きと、決して失ってはいけない務め。どちらも『似ているかどうか』では選べないわ。務めは、訊かれる前から、いつでもそこに無くてはいけないのよ」

ああ、と声が漏れました。棚の術は、記憶の海から「関わりの深い一節」を釣り上げる術です。釣り針にかかるのは、問いに似ているもの。でも夜回りの彼を支えているのは、似ているものではありません。ついさっきの続きという「新しさ」と、何があっても手放さない務めという「無条件」。選ぶ基準が、そもそも違うのです。棚は昨日のための術で、今夜には、今夜の残し方が要る。

それに、と気づいて、私は暗がりの中で目を上げました。窓から押し出されていく古い夜の出来事——たとえば夜更けに見つかる戸の緩み一つ——は、誰かが問うてくれるとは限りません。問われる前から手元にあって、朝の報告に自分から出てこなければ、夜番の記憶とは呼べない。訊かれてから引く棚では、そこが間に合わないのです。では、どうすればいい。ぜんぶ巻けば太って止まる。端から切れば根が消える。刻んで棚に仕舞うのでは、間に合わない。そのどれでもないやり方が、どこかにあるはずで——。

答えの形を、私は夜明けの工房で見ました。

とぼとぼと戻った作業場の隅で、マエストロが朝の日課の最中でした。作業台の脇に立てかけた石盤——昨日一日の仕事の書き付けで、升目がほとんど埋まっています。マエストロはそれを黙って読み、机の常の綴りを開いて、たった三行だけを書き写しました。それから布を取って、石盤を隅から隅まで拭い、元の場所へ立てかける。そして机の奥、真鍮の銘板——工房の掟が刻まれた、あの古い板には、指一本、触れませんでした。

千回は見たはずの所作でした。私が来る前から、たぶん毎朝続いてきた、なんでもない手入れ。それが今朝に限って、まるで初めて見るもののように、輪郭を持って立ち上がってきたのです。

消えてはならないものは、刻んである。日々のことは石盤に書いて、いっぱいになる前に三行に束ねて綴りへ移し、石盤は拭いて、また使う。

置き場が、三つ、ある。

「マエストロ」気づけば声が出ていました。「それ……いつも、そうやって」

マエストロは手を止めず、拭い終えた布を畳みながら、こちらを見もせずに言いました。

「……消えてならんものを、流れる糸に巻くな。残すと決めたものは、束ねて別に持て」

それきり、道具の手入れに戻ってしまいました。答えの続きは、くれないのです。でも、もう十分でした。銘板と、綴りと、石盤。刻むものと、束ねるものと、流れるもの。私は作業台に飛びつくようにして、ヴェスパーの新しい記憶の設計図を引き始めました。

旧い糸巻きの中身 ── Before

まず、春の私が仕立てた術式を、正直にお見せします。ヴェスパーの記憶のからくりは、こういうものでした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class NightWatchAgent(BaseAgent):
    """劇場お抱えの夜警人形ヴェスパー(Before)。

    命令と会話を同じ1本の`history`リストで管理する。この置き方のまま
    素朴に古い順から切り詰めると、リスト内で最古の命令が真っ先に失われる。
    """

    def __init__(self, name: str, mission: str, model: str = "gpt-4o"):
        super().__init__(name, mission, model)
        # 命令(mission)を会話と同じ1本のリストの先頭要素として積む
        self.history: list[dict[str, str]] = [{"role": "system", "content": mission}]

    def run(self, user_input: str) -> str:
        self.history.append({"role": "user", "content": user_input})
        # 切り詰め・要約・監視は一切ない。全履歴をそのままLLMへ渡す(無限追記)。
        # コンテキスト上限超過(context_length_exceeded相当)は握らず、
        # そのまま呼び出し元へ伝播させる。
        response = self.client.chat.completions.create(model=self.model, messages=self.history)
        answer = response.choices[0].message.content or ""
        self.history.append({"role": "assistant", "content": answer})
        return answer

短いでしょう。短くて、素直で、そして二つの欠陥を抱えています。

一つ目は、run()append しか知らないことです。履歴は増える一方で、減る道がありません。LLMはステートレスですから、この self.history は呼び出しのたびに毎回まるごと送り直されます。つまり一回の呼び出しで運ぶ量は対話の数に比例して単調に膨らみ、コンテキストウィンドウの上限に達した瞬間、context_length_exceeded——「その量は読めません」という門前払いの例外が飛んで、彼は夜の途中で止まります。上限に届く前から、長い履歴の中程に沈んだ古い申し送りは効きが薄れていく。時報の遅れも聞き返しも、この構造が先に見せていた症状でした。

二つ目は、もっと質が悪い。務めの命令 mission が、会話と同じ一本のリストの、先頭に据えられていることです。この置き方そのものは、動きます。動くからこそ、危ないのです。ここへ「古いほうから切る」式の素朴な手当て——私のあの一行——を持ち込んだ瞬間、リストの中で最古の要素である命令が、真っ先に切り落とされる。しかもこの失敗は、例外を投げません。クラッシュは大声で教えてくれる失敗ですが、切り詰めの取り違えは、黙って進む失敗です。人形は動き続けます。動き続けたまま、夜警ではない何かになっていくのです。

三つの置き場 ── After

編み直しの方針は、マエストロの机の上にぜんぶ載っていました。記憶をひとつの入れ物に押し込むのをやめて、性質の違う三つの置き場に分けるのです。

一つ。ピン留め(pinning)——切り詰めにも要約にも決して巻き込まれない場所に、消えてはならない命令を生のまま置き続けること。銘板です。

二つ。スライディング窓(sliding window)——会話履歴のうち直近のN往復だけを生のまま残し、古いものから順に窓の外へ押し出していく方式。先入れ先出しの列と同じ考え方で、こちらは石盤に当たります。

三つ。要約メモリ(compaction)——窓から押し出された古いやり取りを、捨てる代わりにLLMで短い要約へ圧縮して持ち続ける方式。綴りの三行、すなわち束ねです。

この組み合わせには、Sliding Window & Summarized Memory という名前がついています。名前が「&」でわざわざ二つを繋いでいるのは、これが単独の技ではなく合成だからです。窓だけでは、押し出された夜が消えてしまう。束ねだけでは、直近のやり取りの生の質感が失われる。窓と束ねを重ね、その外側に銘を固定して、初めて一晩がもつのです。

術式を書き起こす前に、私は設計図の隅へ、旧い糸巻きと新しい置き場を並べて描いてみました。あの夜どこで根が切られ、新しい仕立てではなぜ切られようがないのか——線にしてみると、一目でした。

命令と会話を一本のhistoryリストに巻いたBeforeでは「直近N件だけ残す」切り詰めが最古の命令を真っ先に消すこと、Afterでは銘(mission)が切り詰めの届かない独立フィールドに置かれ、窓から押し出された往復だけが束ね(summary)へ要約されることを対比する図

銘だけが、糸の流れの外に立っている——それが新しい仕立ての要です。

書き上げた術式が、こちらです。

 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
from typing import Callable

from pydantic import BaseModel, Field

from base import BaseAgent


class Exchange(BaseModel):
    """会話の一往復(問いかけと応答の対)"""
    user_text: str
    watchman_reply: str


class MemoryPolicy(BaseModel):
    """窓の幅(残す往復数)と、束ねを発火させるトークン閾値"""
    window_size: int = Field(gt=0)
    token_threshold: int = Field(gt=0)


# トークン数推定を注入可能にする型エイリアス(本番は課金対象のトークナイザ、テストは文字数ベースのフェイク)。
TokenEstimator = Callable[[str], int]
# (これまでの束ね, 押し出された往復) -> 新しい束ね、を注入可能にする型エイリアス。
Summarizer = Callable[[str, list[Exchange]], str]


class ConversationMemory:
    """三層の記憶の置き場: 銘(ピン留め・mission)・束ね(summary)・窓(window)。銘は切り詰め・
    要約のどちらの対象からも構造的に外れた別フィールドであり、「いちばん古いものから消える」
    という素朴な切り詰めの取り違えを防ぐ。"""

    def __init__(self, mission: str, policy: MemoryPolicy, estimate_tokens: TokenEstimator, summarize: Summarizer):
        self.mission = mission
        self.policy = policy
        self.estimate_tokens = estimate_tokens
        self.summarize = summarize
        self.summary: str = ""
        self.window: list[Exchange] = []

    def add_exchange(self, user_text: str, watchman_reply: str) -> None:
        """今回の往復を窓に追記し、閾値を超えていれば束ね直す"""
        self.window.append(Exchange(user_text=user_text, watchman_reply=watchman_reply))
        self._compact_if_needed()

    def _compact_if_needed(self) -> None:
        total_tokens = sum(
            self.estimate_tokens(exchange.user_text) + self.estimate_tokens(exchange.watchman_reply)
            for exchange in self.window
        )
        if total_tokens <= self.policy.token_threshold:
            return
        keep = self.policy.window_size
        # collections.deque(maxlen=N)は採らない: こぼれた要素を黙って捨てて返さないため、
        # 「押し出された分を束ねる」という要件を満たせない。リストスライスで押し出す分
        # (overflow)と残す分(kept)を自分の手で分ける。
        overflow, kept = self.window[:-keep], self.window[-keep:]
        if not overflow:
            # 往復数がwindow_size以下のまま閾値を超えた場合(一往復が長大な場合)は
            # 押し出せるものがなく、束ねは起きない。閾値と窓幅は協調して初めて効く。
            return
        self.window = kept
        self.summary = self.summarize(self.summary, overflow)

    def build_messages(self, user_input: str) -> list[dict[str, str]]:
        """銘 -> 束ね(空なら省略) -> 窓の往復を展開 -> 今の問い、の順に組み立てる"""
        messages: list[dict[str, str]] = [{"role": "system", "content": self.mission}]
        if self.summary:
            messages.append({"role": "system", "content": f"これまでの夜の束ね: {self.summary}"})
        for exchange in self.window:
            messages.append({"role": "user", "content": exchange.user_text})
            messages.append({"role": "assistant", "content": exchange.watchman_reply})
        messages.append({"role": "user", "content": user_input})
        return messages


class NightWatchAgent(BaseAgent):
    """劇場お抱えの夜警人形ヴェスパー(After)。クラス名はBeforeと同一(=同じ人形)。記憶の置き場を
    ConversationMemoryへ委ね、Beforeと同一の骨格(組み立て→LLM呼び出し→追記)のまま、
    置き場だけを三層に変える。"""

    def __init__(self, name: str, memory: ConversationMemory, model: str = "gpt-4o"):
        super().__init__(name, memory.mission, model)
        self.memory = memory

    def run(self, user_input: str) -> str:
        messages = self.memory.build_messages(user_input)
        response = self.client.chat.completions.create(model=self.model, messages=messages)
        answer = response.choices[0].message.content or ""
        self.memory.add_exchange(user_input, answer)
        return answer

順に、設計の判断をお話しします。

まず Exchange。窓に収める単位を「メッセージ一件」ではなく「一往復の対」にしました。件数で切る素朴なやり方だと、問いかけと応答のちょうど中間で窓が滑って、応答だけが宙に浮いた不自然な断片が窓の先頭に残ることがあるからです。対をPydanticの型で縛っておけば、窓は必ず往復の単位で滑ります。

MemoryPolicyField(gt=0) は、窓幅ゼロや閾値ゼロといった、動いてしまったら事故にしかならない指定を、実行前の門前で撥ねるための柵です。ゼロ幅の窓とは、つまり「ぜんぶ切り落とす」ことですから。発火はトークンで測り、残す量は往復で数える——単位が層で違うのにも、訳があります。天井はトークンで出来ているのだから、見張りもトークンで。いっぽう切り口は、対の途中で裂けない会話の自然な継ぎ目、つまり往復で。それぞれの層に、それぞれの物差しなのです。

ConversationMemory の心臓は、missionwindow にも summary にも属さない、独立したフィールドだという一点です。銘は流れの中にいません。どれだけ夜が長くなっても、_compact_if_needed() が触れるのは窓だけ。命令は構造の上で、切り詰めの手が届かない場所に置かれています。第2幕の事故——最古のものから消える——は、「気をつける」のではなく、データの置き場の設計で起こり得なくしたのです。

トークンの数え方と要約の作り方を、TokenEstimatorSummarizer という関数として外から注いでいるのにも理由があります。本番では推定関数をトークナイザに、要約関数を「押し出された往復を三行に束ねよ」と指示するLLM呼び出しに差し替えます。でも模擬公演では、文字数をそのまま数える推定と、要点を機械的に連ねる束ねに差し替えて、閾値が発火する瞬間を一トークンの単位で決定的に再現できる。検証を運任せにしないための、工具だるまさんやクリオさんの一件と同じ発想です。

一つ、白状しておくと、窓の実装では collections.deque(maxlen=N) を最初に試しかけました。直近N件だけを自動で保つには、あつらえ向きの道具に見えたからです。でもすぐに手が止まりました。deque(maxlen) は、窓からこぼれた要素を黙って捨てます。押し出されたものを返してくれないのです。「黙って捨てる窓」では、束ねの原料が手に入りません。だからリストスライスで、押し出す分(overflow)と残す分(kept)を自分の手で分けています。切り落とす瞬間に、切り落とすものを自分の目で見る——第2幕の私に欠けていたのは、設計としては、たったこれだけのことでした。

そして build_messages()。銘、束ね、窓、今の問い。この順で毎回組み立て直します。束ねを会話のやり取りの再現ではなく、二枚目の指示(system)として置くのは、あれが発話の記録ではなく申し送り事項だからです。並び順にも理由があります。長い文脈では、先頭と末尾がいちばん拾われやすい席です。だから消えてならない銘は先頭に、いま答えるべき問いは末尾に。夜がどれだけ更けても、組み上がる文の先頭には必ず銘が立ち、その隣に今夜の束ねが控え、直近の往復だけが生のまま続きます。

部品の話が続きましたから、カンテラさんの夜のひと巡りを、ここで一枚の流れに描き直しておきます。問いを受けてから、次の問いを待つまで。束ねが発火するのは、この巡りのたった一箇所です。

問いかけごとに銘・束ね・窓・今の問いの順で文を組み、LLM呼び出し後に往復を窓へ追記し、トークン閾値を超えたときだけ古い往復を押し出して束ねへ継ぎ足す一巡りの流れ図

見比べていただきたいのは、BeforeとAfterの run() です。組み立てて、呼び出して、追記する——骨格は一字も違いません。夜の過ごし方は同じで、変わったのは記憶の置き場が一本のリストか、三つの層か。それだけです。それだけのことが、彼を夜警のまま朝へ届けます。

正直な留保も、先に置いておきます。束ねは目減りします。要約は不可逆の圧縮で、束ねた瞬間に、元の逐語のいくらかは必ず落ちる。そして、この構造が自力で保証してくれるのは、銘が消えないことと、窓が決めた幅を超えないことの二つだけです。運ぶ量ぜんたいの天井は、束ねが短く保たれて、初めて立ちます。束ねを短く保つのは Summarizer に課された契約——本番でいえば「押し出された夜を、三行に束ね直せ」という要約の指示そのものです。この契約が破れて束ねが伸び続ければ、天井もまた破れる。だから朝の検分では、報告の中身と一緒に、束ねの丈も見ることにしました。窓の幅や閾値の数字にも、万能の正解はありません。一往復が窓ごと呑み込むほど長い晩には、押し出せるものがなくて束ねの出番が来ないこともある。何往復を生のまま残すか、どこで束ねを発火させるかは、彼の夜の実際の長さと、応答を返すために空けておく余白——限られたトークンを何にいくら割り当てるかというトークン予算の考え方——を見ながら、整え続けるためのつまみです。それでも、何が保証されて何がされないかを言える設計は、何も言えない無限の巻きよりずっと信用できます。

編み直しの仕上げに、私はヴェスパーの胸から旧い糸巻きを外して、退役させました。代わりに納めたのは、務めを刻み直した小さな銘板と、今夜のぶんを書いては消す石盤、そして石盤からこぼれる分を三行に束ねる仕掛け。マエストロの机の上の景色が、そのまま彼の胸に収まったのです。

模擬公演 ── 夜を早回しする

祝賀前夜をぶっつけ本番にする度胸は、もうありません。文字数をそのまま数える偽の推し量り係と、要点を連ねるだけの偽の束ね係、それから思考のゼンマイの身代わり——受け取った文の嵩を記録し、定めた上限を超えたら門前払いの例外を投げ返す、意地の悪い代役——を用意して、私は机の上で夜を早回ししました。模擬公演の演目は十二、要点は八つです。

まず旧い糸巻きの正体を、数字で暴きます。固定の長さの往復を繰り返し聞かせると、一回ごとに運ぶ量は一度も減らずに厳密に増え続け、モックに定めた上限を超えた十二回目で、例外がそのまま突き抜けて止まりました。何十回目かは夜の長さ次第でも、「いつか必ず止まる」ことだけは構造が約束してしまっている——春からのヴェスパーは、ずっとこの上を歩いていたわけです。三十往復を聞かせた後の糸巻きも検めました。命令は先頭に居ますが、その上に六十件の会話が積もっています。そして、あの一行。直近十二件だけを残すスライスを同じ履歴に当てると、残った十二件のどこにも、命令の一節はありませんでした。第2幕の夜に起きたことの、そっくりそのままの再現です。

次に、新しい三層。いちばん確かめたかったのは、もちろんこれです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@patch("openai.OpenAI")
def test_mission_survives_verbatim_across_many_compactions(self, mock_openai):
    mock_client = MagicMock()
    mock_openai.return_value = mock_client
    mock_client.chat.completions.create.return_value = _mock_response("巡回、異常なし")

    counting = CountingSummarizer()
    agent = self._make_agent(window_size=3, token_threshold=80, summarizer=counting)

    for i in range(40):
        agent.run(f"申し送り{i}: 西回廊の荷を確認しました。異常はありません。")
        sent_messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
        # 何往復を重ねても、送信されるmessagesの先頭は常にmissionと一字違わず一致する
        self.assertEqual(sent_messages[0], {"role": "system", "content": MISSION_TEXT})

    # 40往復のあいだに複数回の束ね発火が実際に起きている(発火が一度も無いとこの検証の意味が薄い)
    self.assertGreater(counting.call_count, 1)

四十往復。束ねは途中で何度も発火します。その全部の呼び出しで、組み上がった文の先頭が務めの銘と一字違わず一致し続けることを、一往復ずつ確かめました。緑です。夜がどれだけ膨らんでも、銘は流されない。窓のほうも、発火のたびに決めた幅ちょうどへ律儀に縮みます。運ぶ量ぜんたいの丈を最後に決めるのは束ね係の腕前で、今回の偽の束ね係は要点を几帳面に連ねる性分ですから、丈を締める本番の仕事は「三行に束ねよ」という要約の指示が受け持つことになります。

閾値の際も攻めました。一往復十トークンの往復を四つ——ちょうど四十トークンで閾値ぴったりのときは、束ねは発火しません。閾値を三十九に下げて一トークンだけ跨がせると、きっかり一度だけ発火して、窓は決めた幅ちょうどに縮む。束ねが束ねを消さないことも確かめます。一度目の発火で束ねられた「最初の申し送り」が、二度目の発火の後にも束ねの中に残っている。上書きで夜の前半が消えたりはしません。

それから、この仕組みのいちばん健気なところ。「西の鎧戸の緩みに気づきました」という往復をわざと窓から押し出させたあとで文を組み立てると、窓のどこにもないその一件が、束ねの一節に乗って、ちゃんと文の中に居るのです。窓から消えても、文脈からは消えない。夜半の出来事が、朝の報告まで生き延びる道ができました。仕上げに意地悪も少々——窓幅ゼロの指定は ValidationError が門前で撥ね、往復ゼロのまっさらな状態では束ねは一度も発火しない。空の夜を束ねようとする空回りは起きません。

十二の演目、すべて緑。石盤の縁を布で拭いて、私は支度を終えました。あとは、本物の夜だけです。

第4幕: 開演と調和 ── 六時の声

祝賀前夜は、思っていたよりもさらに長い夜になりました。

西回廊は贈り物の荷で足の踏み場もなく、申し送りは羊皮紙の裏まで続いて過去最長。楽器の搬入は日暮れに食い込み、泊まり込みの職人さんたちの出入りは夜半まで絶えません。宵、私は新しい三層を胸に納めたヴェスパーの背中の鍵を、いつもより少しだけ丁寧に巻きました。

「行ってらっしゃい、カンテラさん」

「行って参ります、コレット様」

カンテラの灯りが廊下の奥へ泳ぎ出すのを見送って、私は袖の暗がりではなく、自分の部屋へ向かいました。

「あら」アンが目を丸くして、それから流し目になりました。「今夜は袖に隠れないの?」

「見張りすぎるのも、張りすぎだから」

我ながら、生意気な答えだったと思います。でも本心でした。確かめるべきことは、机の上の模擬の夜で確かめ尽くしました。このうえ夜通し目を光らせるのは、彼を信じていないのと同じことです。糸の張り具合の話は、人形と糸のあいだだけの話ではないのでした。

とはいえ、布団に入ってすぐ眠れたかというと、嘘になります。天井の木目を数えながら、何度も寝返りを打ちました。祝賀の荷。過去最長の申し送り。膨らむ今夜。もし、また——。

遠くで、声がしました。

「二時、異常なし」

廊下の奥の、小さな、いつもの声でした。強張っていた肩から、ゆっくりと力が抜けていくのが分かりました。来ない声で叩き起こされた、あの夜の裏返しです。聞こえる声で、眠りに落ちる。夜の背骨は、ちゃんと通っている。次に気づいたときには、窓の外がうっすらと白んでいました。

「六時、異常なし。——夜のあいだのこと、三つ、束ねてございます」

その声で、私は目を覚ましたのです。鐘でも、歯車の音でもなく、言葉で朝が来たのは、この劇場に来て初めてのことでした。

朝の工房で、ヴェスパーの報告を聞きました。束ねられた三つは、こうです。夜半に届いた祝賀の追加の荷を、届け人の名と共に預かったこと。西の鎧戸に緩みを見つけ、応急で結わえた上で、朝の点検に回すこと。そして明け方近く、楽屋口の扉を試した酔客がいたので、務めの言葉で送り返したこと。

私は最後のひとつに、そっと胸を撫で下ろしました。明け方の四時、いちばん夜が深く膨らんだ時刻に、彼はまだ、扉を試す者へ立ちはだかる夜警だったのです。銘は、流されていませんでした。石盤には直近の往復が生のまま並び、それより古い夜は三行の束ねに畳まれて、それでも朝まで彼と一緒に歩いてくれた。

報告を終えたヴェスパーは、鍵束を鳴らして姿勢を正し、それから、ほんの少しだけ間を置いて言いました。

「……束ねられた夜も、私の夜と、呼んでよいのでしょうか」

私は、ごまかさないと決めていました。

「ぜんぶでは、ありません。束ねるというのは選ぶことで、選んだぶんだけ、こぼれるものがあります。束ねそのものが間違うことだって、いつかは、あるかもしれない。だから、こうして朝に、あなたの夜を私が聞きます。この一巡りまでが、新しいお務めの形です」そこで一度言葉を切って、私は彼のカンテラの、煤で曇った硝子を見つめました。「それでも——任せきりで千切れてしまう夜より、何を胸に残すかを決めてもらえる夜のほうが、あなたの夜だと、私は思います」

ヴェスパーは、しばらく黙っていました。それから「……六時をもって、夜番を終わります」と、いつもの締めの口上を、心なしかゆっくりと告げたのでした。

「あの子の時報で目を覚ます朝が来るなんてね」アンが畳んだ扇の先を、とん、と自分の肩に当てながら言いました。「……悪くない目覚ましだわ」

工房の奥では、マエストロが朝の日課の最中でした。六時の声が届いたとき、石盤を拭う手が、一度だけ、止まりました。それだけです。こちらを振り向きもしなければ、何も言いません。何も言われないことを、これでいいのだと受け取れる自分に、私は少しだけ驚いていました。

夕刻、バロウズさんの会社の祝賀は、シャンデリアの灯りの下で華やかに幕を開けました。玄関で敬礼するヴェスパーに、正装のバロウズさんが帽子を軽く上げて応えていったのを、私は廊下の遠くから見ただけです。それだけで、十分すぎるほどでした。

夜更け、自分の部屋で、私は革表紙の「操り人形師の覚書」を開きました。七ページ目です。

書きたいことが多すぎて、私は夜のことを長く、長く書きました。来ない三時の声のこと。春の私の書き付けのこと。正しく動いて、いちばん大事なものを切った一行のこと。マエストロの石盤のこと。六時の声のこと。頁の半分を越えたあたりでペンを止めて、読み返して、それから私は、書いたばかりの文章の下に、すっと一本の線を引きました。

長すぎる夜は、束ねればいい。今夜のぶんは、三行です。

「消えてならないものは、流れに巻かない」

「束ねるとは、何を残すかを決めること」

「糸の張り具合は、強くでも緩くでもなく、置き場で決まる」

思えば、張り方、という言葉を自分の頭で考えたのは、今夜が初めてだったかもしれません。強く張るか、緩めるか、ではなくて、どの糸をどこに掛けるか。ペンを置くと、廊下の奥から、かすかに声が聞こえました。

「一時、異常なし」

おやすみなさい、カンテラさん。今夜も、いい夜番を。


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

  • 調整したからくり(モジュール/関数名): NightWatchAgent(Before: 命令と会話を単一のhistoryリストに混載して無限追記 / After: ConversationMemory=銘(ピン留めされたmission)・束ね(押し出し分の要約summary)・窓(直近のExchange列)の三層と、トークン推定による閾値発火)
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 呼び出しごとに文脈を持たないステートレスな性質のため、履歴は毎回全文を運び直す。命令・履歴・応答は有限のコンテキストウィンドウを分け合い、上限超過は突然の停止となり、上限の手前でも長い文脈の中程に沈んだ古い指示から効きが薄れていく(lost in the middle)
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • PydanticによるExchange(往復の対で窓を滑らせる)とMemoryPolicyField(gt=0)で窓幅・閾値を門前検証)、TokenEstimator/Summarizerの関数注入による決定的な検証、deque(maxlen)でなくリストスライスで押し出し分を捕捉して束ねる設計、銘→束ね→窓→今の問い、の組み立て順
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • クラッシュに懲りて「直近N件だけ残す」一行を足し、命令ごと切り落としたこと。命令と会話を同じ一本のリストに巻いたまま古い順に切れば、最古=務めの根から先に消える。ぜんぶ足すのも端から削るのも、置き場を分けなければ同じ過ちの両面
  • 模擬公演の記録(検証のポイント):
    • Beforeの読み込み量の単調増大と上限での未処理クラッシュ、命令の埋没と一行スライスによる消失の構造証明、Afterの任務の逐語不変(複数回の束ね発火下で全呼び出し検証)、閾値ちょうど=不発火と一トークン超過=一度だけ発火の境界、束ねの継承(上書きで失われない)、押し出された出来事の文脈同乗、窓幅ゼロへのValidationErrorと空の窓の不発火
  • マエストロの言葉:
    • 「……消えてならんものを、流れる糸に巻くな。残すと決めたものは、束ねて別に持て」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。