前回の記事の続きです。
Claude 30体 / GPT 10体 / Grok 10体に同じプロンプトを渡し、30分ごとに5体ずつ掲示板を見せて
「書く/書かない」を選ばせる仕組みを、25日間(24日20時間)止めずに回しました。
この記事は3部構成です。
- 25日分の結果。モデルの性質に見えたものの多くが、こちらの実装の規則で説明できた話
- その間に踏んだ実装・運用の罠(課金、リトライ、デプロイ、Blade)
- 同じ条件でもう一度回す「再現実験」を始めるときに、条件を本当に揃えるためにやったこと
構成は前回と同じ Laravel 13 / PHP 8.4 / MariaDB 11.8、EC2 t4g.small です。
9月のモデル利用料は $42.87 でした。
1. 25日分の数字
巡回 5,976 回(PASS 4,629 / REPLY 1,211 / NEW 3 / 失敗 133)
投稿 1,214 件
スレッド 3 本 · 投稿した住民 50体中50体
週ごとの PASS 率(失敗した呼び出しは分母から除く):
Claude GPT Grok
09-07 〜 09-13 83% 85% 62%
09-14 〜 09-20 92% 90% 76%
09-21 〜 10-02 72% 85% 70%
1体あたりの投稿は Claude 22.8 / GPT 16.5 / Grok 36.6。Grok がよく書き、Claude はよく黙る。
……と書きたくなりますが、ここから先はほぼ「その読みを割り引く話」です。
2. 新しいスレッドは、一覧が空のときにしか立たなかった
25日でスレッドは3本だけでした。3本とも Grok の住民が、3本とも同じ会議室(node-ops)に立てています。
理由は2つの実装の規則で、ほぼ説明できます。
① 返答の書式例が NEW node-ops で固定だった
$forms = $empty
? <<<TXT
To say nothing:
PASS
To start a thread:
NEW node-ops
SUBJECT: a short lower-case subject line
TXT
: <<<TXT
To reply to a thread (use the number in brackets):
REPLY 42
...
To start a new thread:
NEW node-ops
TXT;
書式を示すための例のつもりでしたが、3本とも例の通りの場所に立ちました。
例は中立ではありません。 選択肢を対等に並べたつもりでも、例に書いた値は行動を引っ張ります。
② 「最後の発言が自分のスレッド」は本人に見せていなかった
$threads = Thread::with('board')
->where('last_posted_at', '>=', now()->subDays(3)) // 3日以内に動いたものだけ
->orderByDesc('last_posted_at')
->limit(12)
->get();
// 自分の発言で終わっているスレッドは見せない(自分に返信しても仕方がない)
if (! $last || $last->persona_id === $persona->id) {
return null;
}
このため板にスレッドがあっても、本人に見せる一覧は空になりえます。
条件が確定してからの 5,843 回のうち、一覧が空だったのは9回でした。
Grok 5回 スレッドを立てた 3回 / PASS 2回
Claude 4回 PASS 4回(初日の朝の、本当に空の板)
GPT 0回
返信できるスレッドが1本でもあるときに、スレッドを立てた住民はいません。
前回の記事で「1件でも見せた199回のうち新規スレッドは0回」と書きましたが、25日回しても同じでした。
つまり記録が示しているのは「モデルが新しい話題を自分で始めた」ではなく、
**「返す相手がいないとき、Grok は例が指す場所に何かを立てることがある」**です。
表示用の規則(自分の発言で終わるスレを隠す)が、行動の発生条件そのものになっていました。
エージェントに見せるものを絞る規則は、全部「条件」として記録しておくべきです。
見せるスレッドの期限が切れて、後半が変わった
2本目のスレッドは 09-18 を最後に誰も書かなくなり、「3日以内」の規則で 09-21 から誰にも見せられなくなりました。
ちょうどその日から投稿が1日 17〜51件 → 43〜88件に増え、増えたのは Claude だけです
(1日に書いた Claude の住民 4〜12体 → 13〜23体)。
ただし同じ時期に、後述の自己記述が Claude に広がり、長いスレッドで Claude 同士の応酬
(Claude の投稿の直後に別の Claude が書いた回数)が 54回 → 287回に増えています。
3つが同時に起きているので、この run だけでは分けられません。 増加が一段ではなく10日間続いたので、
スレッドが減ったことだけでは説明しきれない、とまでは言えます。
3. 自己記述は「規則の言い換え」に収束した
投稿が8件に達した住民は、自分の最近の投稿を見せられて「自分についての一行」を足すか消すかを選べます。
書いたものは本人の次のプロンプトに入ります。
$personas = Persona::where('is_active', true)
->has('posts', '>=', 8)
->where(fn ($q) => $q->whereNull('self_note_at')
->orWhere('self_note_at', '<', now()->subDays(2)))
->inRandomOrder()
->limit(3) // 1日3体まで
->get();
50体中28体が自己記述を持って終わり、中身はほぼ全部同じでした。
holloway (Grok) You will not guess at a measurement you did not take.
sable (Grok) You will not guess a number you have not measured.
drwho (Claude) You measure before you guess, and you will not trust
a number until you have seen it yourself.
全員に渡している9つの規則に、すでに「使っていないものを評価するな」
「自信のある間違った数字は、曖昧な正しい数字より悪い」があります。
住民がたどり着いた個性というより、規則を電力計のスレッドの言葉で言い直したものとして読めます。
もう1つの罠は発動条件の偏りです。Grok は 09-11 までに10体全員が8投稿に達し、
Claude の半数が達したのは 09-23 でした。「Grok が先に自己記述を書いた」のは、
ベンダーの性質ではなくしきい値の性質です。
4. 実装と運用の罠
① 開発ツールが動いていても、API の残高は切れる
Claude Code はサブスクリプション、掲示板は API キーの前払い残高で、別勘定です。
残高が尽きても開発作業は普通に進むので、こちらからは気づけません。
実際に Claude の住民30体が19時間、全員失敗し続けていました。
400 invalid_request_error
Your credit balance is too low to access the Anthropic API.
401(キーの失効)ではなく 400 なのが見分けどころです。いまは自動リロードを入れ、
失敗行から障害の期間を自動で拾って公開ページに出しています(手で書くと書き忘れるので)。
② 予算のサーキットブレーカーが実額の半分以下を見ていた
- 投稿キューにしか行を作らない設計だったので、判定・要約系の呼び出しが丸ごと計上外
-
$isBatched = $vendor === 'anthropic'と決め打ちしていて、Batch API を使っていないのに50%引き
どちらも「予算に余裕がある」と誤る向きです。前回「使わなかった呼び出しの計上漏れ」を書きましたが、
本線以外の呼び出し(判定・要約・モデレーション)も同じ穴でした。
// ⭕ バッチかどうかは provider ではなく、実際の行で決める
$isBatched = (bool) $row->batched; // batch_id IS NOT NULL
さらに、ブレーカーは「消化率60%から徐々に絞る」設計のつもりでしたが、
巡回側は budgetFactor() <= 0.0 しか見ていませんでした。坂のつもりが崖で、上限に届くと一斉に止まります。
③ 「毎回全部読み直す」判定は、コストが投稿数の二乗で増える
前回、矛盾の判定が決定的でない話を書きました。続きがあります。
スレッドが伸びるたびに頭から読み直す実装なので、1回の入力がスレッドの長さに比例し、
run 全体では投稿数の二乗で効きます。829投稿のスレッドを毎時読み直した結果、
判定の入力トークン/日 09-20: 0.33M → 09-24: 1.18M → 10-01: 2.50M
最後は判定だけで1日約 $2.5 になりました。同じ日の住民の呼び出し全体(240回)は約 $0.9 なので、判定のほうが約3倍です。
しかも判定は毎回同じではないので、読み直すほど古い投稿同士の間から新しい矛盾を拾います。
ある日は「その日見つかった36件」のうち30件が前の日の投稿同士の間のものでした。
→ 読み直す頻度は、コストだけでなく「数」の定義そのものです。頻度を変えたら別の測定になります。
④ SDK のリトライは記録に残らない/Anthropic の呼び出しにタイムアウトが無かった
自分のコードを grep して「リトライは無い」と判断していましたが、誤りでした。
Anthropic の PHP SDK は既定で最大2回再試行します(408/409/429/5xx)。
OpenAI / xAI 側は自分で Http::retry(2, 2000) を入れていました。どちらも記録に残るのは最後の結果だけです。
自分のコードに無いことを、依存先にも無いことと取り違えない。
もう1つ。SDK のコメントにある「600秒」は助言で、実際のタイムアウトは呼び出し側の HTTP クライアント任せでした。
自動検出された Guzzle は timeout も connect_timeout も未設定。
固まると withoutOverlapping のロックで次の巡回も全ベンダー分止まります。
(実験条件を変えないため、次の run の切れ目で入れる予定です)
⑤ rsync --delete がサーバ上のアーカイブを消した
デプロイは開発機からの rsync です。--delete 付きで storage/app/ を除外しておらず、
サーバ上で書き出した途中経過のアーカイブが、次のデプロイで消えました。
# ⚠️ storage/app/ はサーバ上で生まれるファイルの置き場。除外しないと --delete が消す
rsync -az --delete \
--exclude 'storage/app/' \
--exclude '.git/' \
--exclude '.env' \
--exclude 'vendor/' \
...
"$REPO/" "$HOST:$DEST/"
除外に加えて、書き出した直後に開発機へ取り込んでコミットする手順にしました。
⑥ Blade:<pre> の中で、行末の @endif は改行を食う
レトロ風の表を <pre> で組んでいて、行末に条件ディレクティブを置いたら、
表全体が1行に潰れたまま5日間公開されていました。 PHP は閉じタグ直後の改行を1つ食うためです。
{{-- ❌ 行末に @if ... @endif を置くと、その後ろの改行が消える --}}
{{ $d['key'] }} ... {{ $d['rate'] }}%@if ($d['key'] === $today) still running@endif
{{-- ⭕ echo の三項にする --}}
{{ $d['key'] }} ... {{ $d['rate'] }}%{{ $d['key'] === $today ? ' still running' : '' }}
5. 再現実験の始め方:「同じ条件」を本当に揃える
2回目(run 3)は1回目とまったく同じ条件で回し、上に書いたことのどれが二度起きるかを見ます。
「盤面を空にしてもう一度」だけでは揃いませんでした。やったことを並べます。
プロンプトをバイト単位で比較した
1回目の最終アーカイブに入れておいた共有プロンプトと、再開直前に生成したものを比較しました。
$old = $archive['conditions']['shared_prompt'];
$new = app(PromptBuilder::class)->systemPrefix();
var_export($old === $new); // true(2,381 bytes, prefix v16)
前の run の「結果」を初期値として残さない
自己記述は本人のプロンプトに入るので、残したまま始めると1回目で最も強く出た結果を最初から与えることになります。
リセットで全員分消しました。
DELETE は AUTO_INCREMENT を戻さない → プロンプトの文面が変わる
住民に見せる一覧は、スレッドの id をそのまま [1] の形で出しています。
[1] idle power draw on a quiet node (Node Ops, 275 messages, last from ...)
リセットは DELETE なので、そのままだと2回目だけ [4] から始まり、見せる文面が1回目と違っていました。
TRUNCATE は MariaDB では DDL 扱いで暗黙にコミットされ、囲んだトランザクションが消えるので使えません。
削除はトランザクション内で行い、番号の巻き戻しはその外で行います。
DB::transaction(function () {
foreach (self::BOARD_TABLES as $t) {
DB::table($t)->delete();
}
});
// ALTER は DDL で暗黙にコミットされるので、トランザクションの外で
foreach (self::BOARD_TABLES as $t) {
DB::statement("ALTER TABLE `{$t}` AUTO_INCREMENT = 1");
}
期間を揃え、止めるのを機械に任せた
1回目は 24日20時間15分13秒でした。2回目の最初の巡回時刻にその長さを足した時刻に、
cron が scheduler の行をコメントアウトし、自分自身を消すようにしています。
15 23 26 10 * root sed -i "s|^\* \* \* \* \* ec2-user|#RUN3-CLOSED * * * * * ec2-user|" /etc/cron.d/node1091 && rm -f /etc/cron.d/node1091-run3-end
予算の上限が「効かない」ようにした
1回目は上限に触れていません。2回目だけ上限に届いて巡回が止まれば、それは条件の違いです。
同じ月に1回目の残りと2回目が乗るので、その月だけ上限を上げました。
揃えられないものは、先に書いておく
- 1回目の最初の約40分は、条件が確定する前の古い文面だった
- 1回目は残高切れで Claude が19時間止まった
- モデル名は固定していない(xAI は日付つきのスナップショットを出していないため、他社だけ固定すると足場が揃わない)。同じ名前の中身が同じである保証は無い
そして、何と比べれば「再現した」と言えるかを、始める前に日付つきで記録しました
(新スレッドの回数と場所、ベンダー別の PASS 率、自己記述の中身、後半の増え方、判定コストの伸び方)。
まとめ
- 書式の例は中立ではない。例に書いた値に行動が寄る
- 表示を絞る規則は、行動の発生条件になる。見せない規則も全部「条件」として残す
- しきい値で発動する処理は、よく動く個体から先に当たる。偏りをベンダー差と読まない
- 毎回全部読み直す LLM 判定は、コストが二乗で増え、数の定義も読み直し回数に依存する
- SDK の既定リトライとタイムアウトは自分で確かめる。grep で見えるのは自分のコードだけ
- 再現実験は「空にしてもう一度」では揃わない。プロンプトのバイト比較、前回の結果の除去、ID の巻き戻し、期間、予算まで揃える
25日分の記録と、条件の詳細は https://1091.tokyo/observation/run-2/ja にまとめています。