配ると何が増えるか
実装:
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 本にはできない順に見ていく。
- 配ると費用は人数ぶん増え、終わるまでの時間は変わらない: 買っているのは時間だ
- 出た答えは混ぜられない: 子は互いを知らないので、選んで捨てるしかない
- 依存のある仕事は配れない: 実装とレビューは同時に走らない
- 単価を当てると、いくらで何を買っているかが出る: 費用と時間の交換比になる
① 配ると費用は人数ぶん増え、終わるまでの時間は変わらない
配る形を実装する。
// 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
}要点は newModel と newTools を関数で受け取っているところだ。子は真っ新な窓から始まるので、モデルも道具も毎回作り直す。使い回すと、2人目が1人目の続きから始まってしまう。
そして前置き(何をしてほしいか)も、人数ぶん渡し直すことになる。
同じ仕事を1人・2人・3人に配って測った。前置きは26文字。
| 人数 | 呼び出し | 渡した文字 | 終わるまで | 順にやるなら | 選べること |
|---|---|---|---|---|---|
| 1 | 5 | 215 | 4 | 4 | 1 |
| 2 | 10 | 430 | 4 | 8 | 1 |
| 3 | 15 | 645 | 4 | 12 | 1 |
渡した文字は3.0倍、終わるまでの時間は1.0倍、選べることは1.0倍になった。テストで、費用が人数に正比例すること、終わるまでの時間が変わらないこと、選べることが1のままであることを固定した。
読み方は素直だ。買っているのは時間で、払っているのはトークンになる。順にやれば12手ぶんの時間がかかるところを、4手で終わらせるために3倍払っている。
いちばん右の列が、この章の中心になる。増えていない。
② 出た答えは混ぜられない
3人に配れば3本の答えが出る。だが選べることは1のままだった。なぜか。
Hermes と Pi で読み替えるの③で見たとおり、子は親の会話履歴を知らない。そして互いのことも知らない。3人は同じ前置きを受け取っただけで、隣が何を書いているかを一度も見ていない。
だから3本は、足し合わせられない。片方の良いところともう片方の良いところを混ぜて1本にする、ということが起きない。できるのは、どれか1本を選んで残りを捨てることだけだ。
つまり増えたのは候補の数であって、答えの質ではない。
そして選ぶ側にも制約が付く。統合する側が受け取るのは、子の最終要約だけだ。
// 配る人数を増やすと、費用は正比例で増え、選べることは増えない。
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人でやる | 配る | 順にやる | |
|---|---|---|---|
| 渡した文字 | 215 | 645(3倍) | 645 |
| 終わるまで | 4 | 4 | 12(3倍) |
| 選べること | 1 | 1 | 1 |
| 出る答え | 1本 | 3本(混ぜられない) | 3本 |
| 依存のある仕事 | 順にやる | 配れない | 順にやる |
裏どり:
- 数字はすべて手元の測定: 人数1・2・3の表も、親と子の文字数も、この章の実装をテストで固定したもの。実物のモデルを並べて走らせた測定ではない
- 子が互いを知らないこと: Hermes と Pi で読み替えるの③で引いたとおり、子は「完全に真っ新な会話から始まり、親の会話履歴も、それまでの道具呼び出しも一切知らない」。親に返るのは最終要約だけになる
- 単価: Anthropic の料金ページから。1タスクの規模も同ページの計算例(1時間のセッションで入力5万・出力1.5万)を借りた。単価も導入価格も動く
- サブスクの枠: プランごとのトークン量は公開されておらず、倍率だけが示されている。だから枠での試算はできない
- ④の費用は計算であって実測ではない: 実際に配って請求を見たわけではない。自分の1タスクが何トークンかは、手元の計測から取り直すのが早い
簡略化したこと
- 子の速さを同じにしている: 実際にはばらつくので、終わるまでの時間はいちばん遅い1人で決まる。ここでは全員同じ手数にした
- 失敗する子を扱っていない: 3人のうち1人が転ぶ場合、やり直すのか捨てるのかで費用が変わる
- 選ぶ手順を作っていない: 3本からどう選ぶかは実装していない。「選べることは1」を数として置いただけになる
- 投機の採用率を測っていない:
1/pのpは、実際の仕事から取るしかない - 同時に走る数の上限を扱っていない: 実物には既定の並列数があり、それを超えると順番待ちになる
- 前置きの共有を扱っていない: 実物には、同じ前置きを安く送り直す仕組み(キャッシュ)がある。ここでは毎回そのまま払う形にした
参考資料
- Pricing(Anthropic) — 単価と、1タスクの規模に借りた計算例
- 前提: エージェントの枠組み / Hermes と Pi で読み替える
- 関連: 人が見る前に落とす / グラフを CI に写す / 強いほうを既定にしない
- 実装: llm/harness