Skip to content

配ると何が増えるか

実装: llm/harness/ / 実行: go test ./llm/harness/

同じ仕事を何人かに配って一斉にやらせる形を測った。3人に配ると渡した文字は3倍になり、終わるまでの時間は変わらず、選べることは1つも増えなかった。子は互いを知らないので、出た3本を混ぜて1本にはできない。買っているのは時間で、払っているのはトークンだ。そして依存のある仕事は、そもそも配れない。

この章で作るもの

エージェントの枠組みで作った形は、1人でやる形だった。実物のハーネスには、Hermes と Pi で読み替えるの③で見たとおり、同じ仕事を子に配る仕組みがある。

配れば速くなる。では、ほかに何が増えるのか。それを測る。

  1 人でやる
     [前置き] ──▶ search → read → edit → test ──▶ 答え 1 本

  3 人に配る
     [前置き] ──▶ search → read → edit → test ──▶ 答え
     [前置き] ──▶ search → read → edit → test ──▶ 答え     同時に走る
     [前置き] ──▶ search → read → edit → test ──▶ 答え

        子は真っ新な窓から始まるので、前置きを人数ぶん渡し直す

     終わるまでの時間は 1 人のときと同じ。渡した文字は 3 倍。
     そして答えは 3 本だが、混ぜて 1 本にはできない
1人でやるのと、3人に配るの。道具の手数も答えの数も見た目は増えるが、増えていないものがある

順に見ていく。

  1. 配ると費用は人数ぶん増え、終わるまでの時間は変わらない: 買っているのは時間だ
  2. 出た答えは混ぜられない: 子は互いを知らないので、選んで捨てるしかない
  3. 依存のある仕事は配れない: 実装とレビューは同時に走らない
  4. 単価を当てると、いくらで何を買っているかが出る: 費用と時間の交換比になる

① 配ると費用は人数ぶん増え、終わるまでの時間は変わらない

配る形を実装する。

go

// Fanout は同じ仕事を何人に配ったかの結果。
type Fanout struct {
	// Workers は配った人数。
	Workers int
	// Calls はモデルを呼んだ合計回数。
	Calls int
	// InputChars は渡した窓の合計。人数ぶん重ねて払う。
	InputChars int
	// Wall は壁時計。並列なので、いちばん遅い1人で決まる。
	Wall int
	// Serial は直列にやったときの壁時計。比べる相手になる。
	Serial int
	// Choices はモデルが選べたことの数。
	Choices int
	// WorkChars は子が生み出した記録の合計。親はこれを読まない。
	WorkChars int
}

// Spread は同じ仕事を n 人へ配って、費用と時間と選べることを数える。
//
// 子は真っ新な窓から始まるので、モデルも道具も毎回作り直す。
// 前置き(何をしてほしいか)も**人数ぶん渡し直す**ことになる。
// ここが費用の効くところで、人数に正比例して増える。
//
// 一方で選べることは増えない。n 本の答えが出るだけで、
// 子は互いを知らないので、混ぜて 1 本にはできないからだ。
func Spread(n int, brief []Step, newModel func() Model, newTools func() map[string]Tool, cfg LoopConfig) Fanout {
	if n < 1 {
		n = 1
	}
	f := Fanout{Workers: n, Choices: 1}
	for i := 0; i < n; i++ {
		r := Loop(newModel(), newTools(), cfg)
		f.Calls += r.ModelCalls
		f.InputChars += r.InputChars + totalSize(brief)
		f.WorkChars += totalSize(r.Steps)
		if r.ToolCalls > f.Wall {
			f.Wall = r.ToolCalls // いちばん遅い1人
		}
		f.Serial += r.ToolCalls
	}
	return f
}

// Integrate は配った結果を統合する側の負担。
//
// 親が受け取るのは要約だけなので、読む量は人数ぶんの要約で済む。
// 代わりに**現物を見ていない**。ここが安さの正体になる。
func Integrate(f Fanout, summary int) (read int, sawWork bool) {
	return f.Workers * summary, false
}

要点は newModelnewTools を関数で受け取っているところだ。子は真っ新な窓から始まるので、モデルも道具も毎回作り直す。使い回すと、2人目が1人目の続きから始まってしまう。

そして前置き(何をしてほしいか)も、人数ぶん渡し直すことになる。

同じ仕事を1人・2人・3人に配って測った。前置きは26文字。

人数呼び出し渡した文字終わるまで順にやるなら選べること
15215441
210430481
3156454121

渡した文字は3.0倍、終わるまでの時間は1.0倍、選べることは1.0倍になった。テストで、費用が人数に正比例すること、終わるまでの時間が変わらないこと、選べることが1のままであることを固定した。

読み方は素直だ。買っているのは時間で、払っているのはトークンになる。順にやれば12手ぶんの時間がかかるところを、4手で終わらせるために3倍払っている。

いちばん右の列が、この章の中心になる。増えていない。

② 出た答えは混ぜられない

3人に配れば3本の答えが出る。だが選べることは1のままだった。なぜか。

Hermes と Pi で読み替えるの③で見たとおり、子は親の会話履歴を知らない。そして互いのことも知らない。3人は同じ前置きを受け取っただけで、隣が何を書いているかを一度も見ていない。

だから3本は、足し合わせられない。片方の良いところともう片方の良いところを混ぜて1本にする、ということが起きない。できるのは、どれか1本を選んで残りを捨てることだけだ。

つまり増えたのは候補の数であって、答えの質ではない。

そして選ぶ側にも制約が付く。統合する側が受け取るのは、子の最終要約だけだ。

go

// 配る人数を増やすと、費用は正比例で増え、選べることは増えない。
func TestSpreadingCostsMoreAndDecidesNoMore(t *testing.T) {
	brief := []Step{{Tool: "brief", Obs: "認証まわりを直す。既存の作法に合わせること"}}
	plan := []Action{{Tool: "search"}, {Tool: "read"}, {Tool: "edit"}, {Tool: "test"}}

	newModel := func() Model { return &planner{plan: plan} }
	newTools := func() map[string]Tool { tools, _ := fixTools(); return tools }

	t.Logf("前置き %d 文字を、人数ぶん渡し直す", totalSize(brief))
	t.Logf("%-8s %8s %12s %10s %10s %10s", "人数", "呼び出し", "渡した文字", "壁時計", "直列なら", "選べること")

	var chars, wall []int
	for _, n := range []int{1, 2, 3} {
		f := Spread(n, brief, newModel, newTools, LoopConfig{MaxCalls: 10})
		chars = append(chars, f.InputChars)
		wall = append(wall, f.Wall)
		t.Logf("%-8d %8d %12d %10d %10d %10d",
			f.Workers, f.Calls, f.InputChars, f.Wall, f.Serial, f.Choices)
	}

	// 費用は人数に正比例する。
	if chars[2] != chars[0]*3 || chars[1] != chars[0]*2 {
		t.Fatalf("正比例していない: %v", chars)
	}
	// 壁時計は増えない。並列だからだ。
	for _, w := range wall {
		if w != wall[0] {
			t.Fatalf("壁時計が変わった: %v", wall)
		}
	}
	// 選べることは 1 のまま。n 本出ても、混ぜて 1 本にはできない。
	for _, n := range []int{1, 2, 3} {
		if got := Spread(n, brief, newModel, newTools, LoopConfig{MaxCalls: 10}).Choices; got != 1 {
			t.Fatalf("%d 人で選べることが %d", n, got)
		}
	}
	// 0 人以下は 1 人として扱う。配らないという選択肢は、この関数には無い。
	if Spread(0, brief, newModel, newTools, LoopConfig{MaxCalls: 10}).Workers != 1 {
		t.Fatal("0 人の扱いが違う")
	}
	t.Logf("3 人にすると 渡した文字 %.1f 倍 / 壁時計 %.1f 倍 / 選べること %.1f 倍",
		float64(chars[2])/float64(chars[0]),
		float64(wall[2])/float64(wall[0]), 1.0)
}

// 統合する側は、人数ぶんの要約を読む。現物は読まない。
func TestIntegratorReadsSummariesNotTheWork(t *testing.T) {
	brief := []Step{{Tool: "brief", Obs: "認証まわりを直す"}}
	plan := []Action{{Tool: "search"}, {Tool: "read"}, {Tool: "edit"}, {Tool: "test"}}
	f := Spread(3, brief, func() Model { return &planner{plan: plan} },
		func() map[string]Tool { tools, _ := fixTools(); return tools },
		LoopConfig{MaxCalls: 10})

	const summary = 20 // 要約 1 本の長さ
	read, sawWork := Integrate(f, summary)
	t.Logf("子が作った記録 %d 文字 / 親が読む要約 %d 文字(%d 本)", f.WorkChars, read, f.Workers)
	t.Logf("親は現物を見たか: %v", sawWork)

	// 親の読む量は、子が作った記録よりずっと小さい。
	if read >= f.WorkChars/2 {
		t.Fatalf("親の負担が小さくなっていない: %d%d", read, f.WorkChars)
	}
	// そして現物は見ていない。安さの正体はここになる。
	if sawWork {
		t.Fatal("親が現物を見たことになっている")
	}
}

測るとこうなる。

文字数
子が作った記録192
親が読む要約(3本)60

親の負担は3分の1以下で済んでいる。安く見えるが、安さの正体は現物を読んでいないことだ。テストで、統合する側が現物を見ていないことを固定した。

ここに落とし穴がある。要約は、書いた本人が「うまくいった」と言っているものだ。どれを採るかの判断が、実装した側の自己申告に乗る人が見る前に落とすの④で「書いた本人に採点させない」と書いたが、統合のところで戻ってきてしまう。

現物を見る手順は、別に置くことになる。

③ 依存のある仕事は配れない

配れるのは、互いに独立した仕事だけだ。当たり前に見えるが、実際の組み方では見落とされる。

よくある形を1つ挙げる。

認証まわりを書き直す。(a) 実装、(b) レビュー、(c) 別解の探索を同時に投げて、終わったら統合する。

(b) は (a) と同時に走れない。 レビューは実装の出力を入力にするからだ。同時に投げれば、レビュー役はまだ存在しないコードをレビューすることになる。

3つを分類し直すとこうなる。

他への依存配れるか費用の性格
(a) 実装なし配れる本体
(b) レビュー(a) の完了配れない。実装のあと本体
(c) 別解なし配れる投機。採らなければ捨てる

(c) は並列化ではなく重複投資になる。別解を採る割合を p とすると、採られた1本あたりの実効費用は 1/p 倍だ。3割なら3倍で数える必要がある。

直せる形にすると、実装と別解を配って、揃ってからレビューになる。配る人数は3ではなく2で、終わるまでの時間は「配ったぶん + レビューのぶん」になる。

グラフを CI に写すで節と辺を書いたのは、この依存を先に描いておくためだった。辺を描かずに「全部同時」と言うと、描いていない依存が黙って壊れる。

④ 単価を当てると、いくらで何を買っているかが出る

①で測ったのは、この本の中の文字数だ。実物の単価を当てると、いくら払っているかが出る。

前提を書いておく。1タスク = 入力 5万トークン / 出力 1.5万トークンとする。これは Anthropic が料金ページの計算例に使っている数字をそのまま借りたものだ。単価は執筆時点で、Claude Opus 5 が入力 $5 / 出力 $25(100万トークンあたり)、Fugu Ultra が $5 / $30。

内訳費用
1人でやるOpus 5 で1本$0.63
配る指揮 $0.11 + 実装(サブスク内)+ レビュー $0.63 + 別解 $0.70$1.44 以上

2.3倍以上になる。月160タスクなら $100 と $230 以上、同じ $200 で回せる数は 320 と 139 以下だ。

「以上」と書いているのは、編成をモデルの内側に隠すで見たとおり、内側の編成トークンも課金されるからだ。量は公開されていないので、これは下限になる。

サブスクで回す場合は、そもそも計算できない。プランのトークン量が公開されておらず、倍率だけが示されているからだ。それでも2つは言える。枠の中なら追加の費用はゼロで、そこが「費用を気にせず配る」が成り立つ根拠になる。そして①で測ったとおりトークンは人数ぶん増えるので、枠が尽きるのも人数ぶん早くなる。速く終わることの代償が、早く止まることになる。

設計の観点

  • 配る前に依存を描く: 描いていない依存は、同時に投げた瞬間に壊れる
  • 買っているものを言えるようにする: 配って買えるのは時間だけで、質は別のところで決まる
  • 候補の数と答えの質を分けて数える: 3本出ることと、良い1本が出ることは違う
  • 投機のぶんは採用率で割る: 3割しか採らないなら、その枠の費用は3倍で数える
  • 統合する側が現物を見ているか確かめる: 要約だけで選ぶと、実装した側の自己申告に乗る
  • 枠で回すなら、尽きる速さも人数ぶんになる: 限界費用がゼロでも、上限までの距離は縮む
  • 前置きは人数ぶん払う: 子は真っ新な窓から始まるので、共通の説明も共有されない

対照と実例

1人でやる配る順にやる
渡した文字215645(3倍)645
終わるまで4412(3倍)
選べること111
出る答え1本3本(混ぜられない)3本
依存のある仕事順にやる配れない順にやる

裏どり:

  • 数字はすべて手元の測定: 人数1・2・3の表も、親と子の文字数も、この章の実装をテストで固定したもの。実物のモデルを並べて走らせた測定ではない
  • 子が互いを知らないこと: Hermes と Pi で読み替えるの③で引いたとおり、子は「完全に真っ新な会話から始まり、親の会話履歴も、それまでの道具呼び出しも一切知らない」。親に返るのは最終要約だけになる
  • 単価: Anthropic の料金ページから。1タスクの規模も同ページの計算例(1時間のセッションで入力5万・出力1.5万)を借りた。単価も導入価格も動く
  • サブスクの枠: プランごとのトークン量は公開されておらず、倍率だけが示されている。だから枠での試算はできない
  • ④の費用は計算であって実測ではない: 実際に配って請求を見たわけではない。自分の1タスクが何トークンかは、手元の計測から取り直すのが早い

簡略化したこと

  • 子の速さを同じにしている: 実際にはばらつくので、終わるまでの時間はいちばん遅い1人で決まる。ここでは全員同じ手数にした
  • 失敗する子を扱っていない: 3人のうち1人が転ぶ場合、やり直すのか捨てるのかで費用が変わる
  • 選ぶ手順を作っていない: 3本からどう選ぶかは実装していない。「選べることは1」を数として置いただけになる
  • 投機の採用率を測っていない: 1/pp は、実際の仕事から取るしかない
  • 同時に走る数の上限を扱っていない: 実物には既定の並列数があり、それを超えると順番待ちになる
  • 前置きの共有を扱っていない: 実物には、同じ前置きを安く送り直す仕組み(キャッシュ)がある。ここでは毎回そのまま払う形にした

参考資料