エージェントの枠組み(プロンプト・コンテキスト・ハーネス・ループ・グラフ)
実装:
llm/harness// 実行:go test ./llm/harness/
言語モデルに仕事をさせる枠組みは、プロンプト・コンテキスト・ハーネス・ループ・グラフと語彙が増えてきた。層ごとに「1回ぶん」と数える単位が違う。そして入力の上限は有限なので、回すほど古い側から押し出される。同じ台本でも、直近だけ残すと同じファイルを8回読み直して終わらず、畳めば12手で終わった。捨てた観測299文字は、24文字の覚え書きで足りている。
この章で作るもの
言語モデルに仕事をさせる枠組みを層ごとに作って、同じ仕事をやらせて比べる。
呼び方は場所によって違うが、おおよそこの順で語彙が増えてきた。層の違いは、「1回ぶん」と数える単位の違いだ。
| 層 | 1回ぶんの単位 | そこで決めること |
|---|---|---|
| プロンプト | 1入力 | どう訊くか |
| コンテキスト | 窓に残るもの | 次に訊くとき、何を見せるか |
| ハーネス | 1パス | 訊く → 道具 → 観測 の1周に何を置くか |
| ループ | 実行全体 | いつ止めるか |
| グラフ | ジョブ全体 | どの道を通ってよいか |
英語では prompt、context、harness、loop、graph と呼ばれる。コンテキストの層を指す言い方として、prompt engineering に対する context engineering という語もある。
窓(コンテキストウィンドウ)というのは、モデルに一度に渡せる入力の上限のことだ。実物ではトークン数で数えるが、ここでは文字数で数える。仕事を進めるほど記録は伸びるのに窓は伸びないので、どこかで入らなくなる。そのとき何を残して何を捨てるかを決めるのが、コンテキストの層だ。
上の並びは、順に強くなっていく話に見える。だが実際に起きたのはそう単純ではない。2026年、LangChain は事前に定義した LangGraph のワークフローから、より agentic な中核ループへ移した。GPT Researcher も、グラフに組んだ多エージェントの流れを Deep Agents に置き換えた。グラフからループへ、逆向きに戻っている。
戻った先が振り出しかというと、そうでもない。Deep Agents は LangGraph の上に載っている。つまりグラフは下回りとして残り、その上に意見の入った層が乗った形になる。
経路の側の分かれ目は、1つだけだ。
同じ仕事(バグを1つ直す)を3通りで
① 1回きり
[訊く] ──▶ 「たぶんこう」 道具を持たないので確かめようがない
② ループ
[訊く] ──▶ search ──▶ 観測
[訊く] ──▶ read ──▶ 観測 毎手ぜんぶモデルに訊く
[訊く] ──▶ edit ──▶ 観測 道具 4 手 / モデル 5 回
[訊く] ──▶ test ──▶ PASS
[訊く] ──▶ 「直した」
③ グラフ
search ──▶ read ──▶ [訊く] edit ──▶ test ──┐
↑固定 ↑固定 ↑ここだけ ↑固定 │
└──────────────────── edit へ戻す ◀──── FAIL
道具 4 手 / モデル 1 回
経路を先に描けるなら ③、走りながらでないと分からないなら ②ただし、壊れる順は上の表の並びと違う。窓が問題になるのは、ループを回してからだ。だからこの章は、層の並びではなく壊れる順に見ていく。
順に見ていく。
- 1回きりでは、途中で分かったことを次に使えない: プロンプトの層。観測を返す口が無いと、確かめようがない
- ハーネスを作り、ループで回す: 道具と観測の口を作って回すと、上限が無いかぎり止まらない
- 回すほど窓が埋まり、消えるのは古い側になる: 当たりを引いたのは、たいてい古い側だ
- 消すのではなく、畳むか、外に書き出す: 何をやったかと、何が分かったかは別々に捨てられる
- 経路を先に描けるところは、モデルに訊かなくてよい: 同じ仕事でモデル5回が1回になる
① 1回きりでは、途中で分かったことを次に使えない
いちばん下の層から始める。プロンプトの層だ。1回入れて1回返す、それだけの形になる。
// Once は1回だけ訊いて、返ってきたものをそのまま答えにする。
//
// 道具を渡していないので、モデルは自分の中にあるものだけで答える。
// 途中で分かったことを次に使う、ということが起きようがない。
func Once(m Model) Result {
a := m.Decide(Window{})
r := Result{ModelCalls: 1, Answer: a.Answer, Reason: "1回で打ち切り"}
r.OK = a.Answer != "" && a.Tool == ""
if a.Tool != "" {
// 道具を使いたいと言われても、渡していないので応じられない。
r.Answer = ""
r.Reason = "道具を使いたいと言われたが、渡していない"
}
return r
}この層で決められるのは、どう訊くかだけになる。何を書くか、どういう順で並べるか、例をいくつ付けるか。それで返るものは変わる。ここは効く。効かないと言いたいのではない。
だが、決められないことのほうが多い。道具を渡していないので、モデルは自分の中にあるものだけで答える。途中で分かったことを次に使う、ということが起きようがない。
テストで2つ固定した。道具を使いたいと言われても応じられないこと。そして、自分の中だけで答えた場合は「成功」を返してしまうこと。後者が厄介なところで、確かめていないのに答えは返ってくる。
この節が5つの中でいちばん短いのは、書くことが少ないからではなく、この層だけを見ていても増えないからだ。プロンプトをどう磨いても、外の世界を見ずに答えている事実は変わらない。上の層が要る理由が、ここにある。
そして、この層がいちばん直しやすい。1行書き換えればすぐ試せる。あとの「どの層が壊れているか」で、それが問題になる。
② ハーネスを作り、ループで回す
ここで層が2つ同時に出てくる。ハーネスとループだ。
ハーネスは、モデルの周りに置く道具立てのことになる。使える道具を渡し、実行し、結果を観測として返す。1回ぶんの単位は1パス、つまり「訊く → 道具 → 観測」の1周だ。
語としては馬具や配線の束を指す harness で、ソフトウェアでは test harness(テスト対象を動かすための足場)という使い方が古くからある。
これは製品の名前ではなく、種類の名前だ。「エディタ」が製品名ではなく、VS Code や Vim がエディタであるのと同じ位置にある。Claude Code も Hermes も Pi も、種類で言えばハーネスになる。実際 Pi は自分を "A Minimal Terminal Coding Harness" と名乗っている。
ループは、そのパスを止まる条件まで繰り返す形になる。1回ぶんの単位は実行全体で、決めるのは「いつ止めるか」だ。
この2つが同時に出てくるのには理由がある。ハーネスは単独の節に立たない。道具と観測の口を作っても、1周で終わるなら①と変わらないからだ。回して初めて意味が出る。逆に、⑤で作るグラフもこの口の上に載っている。ハーネスは、このあと出てくる形の全部が使う土台になる。だからこの章では、ここで一度作って、以降は前提として扱う。
先に断っておくと、このあと⑤で作るグラフも回る。落ちたら前の節へ戻るのは、まさに回っている。「勝手に回る」のは両方に共通していて、違うのは回る道が決まっているかどうかになる。ここで作るのは、道を決めずに回すいちばん素朴な形だ。
観測を次の判断に渡せるようにすると、話が変わる:
// LoopConfig はループの止め方と、窓の作り方。
type LoopConfig struct {
// MaxCalls はモデルを呼ぶ回数の上限。ここが無いと止まらないことがある。
MaxCalls int
// Budget は窓の大きさ。0 なら窓を無限とみなす。
Budget Budget
// Curate は窓に何を残すかを決める。nil なら KeepAll。
Curate Curator
}
// Loop は「答えるか、道具を使うか」をモデルに訊き続ける。
//
// 道具の結果を観測として次の判断に渡すので、途中で分かったことを使える。
// 代わりに、毎回モデルに訊くので手数ぶんの費用がかかる。
// そして自分では止まれないことがあるので、上限が要る。
//
// 渡すのは記録そのものではなく、Curate が作った窓になる。
// 窓に残らなかったものは、モデルにとっては起きなかったことと変わらない。
func Loop(m Model, tools map[string]Tool, cfg LoopConfig) Result {
cur := cfg.Curate
if cur == nil {
cur = KeepAll
}
var r Result
for {
if cfg.MaxCalls > 0 && r.ModelCalls >= cfg.MaxCalls {
r.Reason = "上限に達した"
return r
}
w := cur(r.Steps, r.Memo, cfg.Budget)
a := m.Decide(w)
r.ModelCalls++
r.InputChars += w.Size
if a.Note != "" {
r.Memo = note(r.Memo, a.Note)
continue
}
if a.Answer != "" {
r.Answer = a.Answer
r.OK = true
r.Reason = "モデルが終わりだと言った"
return r
}
t, ok := tools[a.Tool]
if !ok {
r.Reason = "無い道具を使おうとした: " + a.Tool
return r
}
r.Steps = append(r.Steps, Step{Tool: a.Tool, Arg: a.Arg, Obs: t(a.Arg), ByModel: true})
r.ToolCalls++
}
}ハーネスに当たるのは、この関数の中の3つになる。渡された tools、それを引いて実行するところ、そして結果を Step の Obs として記録に積むところだ。この3つで1パスができている。for がそれを回す部分で、こちらがループの層になる。
道具の結果が次の入力になるので、探して、読んで、直して、確かめる、が繋がる。テストで、この4手で仕事が終わることを固定した。
道具立てで決めることは、実は回し方とは独立している。何を渡すか(道具の種類と数)、観測に何を返すか(全文か、要約か、終了コードだけか)、失敗をどう見せるか。同じループでも、ここが違えば別物になる。Hermes と Pi で読み替えるの②で、実物が道具の数に出している答えを見る。
cur(...) で作っている w が窓だ。ここでは全部そのまま渡す KeepAll を既定にしてあるので、いまは「今までの記録が丸ごと渡る」と思っておけばいい。③と④は、ここを差し替える話だ。
代償は2つある。
1つめは費用だ。毎手モデルに訊くので、道具4手に対してモデルは5回呼ばれる(最後の「終わり」を含む)。しかも渡すのは毎回それまでの記録ぜんぶなので、後になるほど1回が重くなる。この4手で渡した文字数は合計 189 文字だった。
2つめが止まり方だ。モデルが同じ手を繰り返す台本を通すと、観測は1つも変わらないのに回り続ける。テストで、上限 6 を置いたときにちょうど6手で打ち切られること、そのあいだ観測が FAIL のまま変わらないことを固定した。上限を置かなければ止まらない。
これは実装の粗さではなく、この形の性質だ。次に何をするかをモデルが決めるなら、「もうやめる」もモデルが決めることになる。決めなければ、外から止めるしかない。
③ 回すほど窓が埋まり、消えるのは古い側になる
上の題材は4手で終わるので窓の心配が要らなかった。手数を増やすと、話が変わる。
題材を「呼び出し元を8本ぜんぶ見てから直す」に変える。当たりは2本目の tax.go で、直すにはその中身が要る。全部やると記録は11手・462文字になった。
窓の大きさと、そこに何が入るかを数える:
// Budget は窓に入る大きさ。ここでは文字数で数える。
type Budget int
// Size は、この手が窓で占める大きさ。
func (s Step) Size() int { return utf8.RuneCountInString(s.Tool + s.Arg + s.Obs) }
// Window は、これまでの記録のうち窓へ実際に入れたもの。
//
// 記録が伸びても窓は伸びないので、この構造体が毎回作り直される。
// どのフィールドに何が残るかが、そのまま「モデルに何が見えるか」になる。
type Window struct {
// Memo は窓の外に書き出した覚え書き。短いので落とさない。
Memo []string
// Folded は畳んだ手の「道具と引数」。何をやったかは残り、観測は消える。
Folded []string
// Steps は原文のまま入れた手。観測まで読める。
Steps []Step
// Size は窓が実際に占めた大きさ。
Size int
// Over は Budget を超えた大きさ。0 でなければ、この窓は入らない。
Over int
// LostSteps は原文が窓から消えた手の数。
LostSteps int
// LostChars は窓から消えた観測の文字数。
LostChars int
}
// Did は、その道具をその引数で使った記録が窓から読み取れるか。
// 原文で残っていても、畳んだ行に名前だけ残っていても読み取れる。
func (w Window) Did(tool, arg string) bool {
for _, s := range w.Steps {
if s.Tool == tool && s.Arg == arg {
return true
}
}
key := foldKey(Step{Tool: tool, Arg: arg})
for _, f := range w.Folded {
if f == key {
return true
}
}
return false
}
// Obs はその手の観測を返す。畳んだ行からは読めないので、そこが失われる。
func (w Window) Obs(tool, arg string) (string, bool) {
for _, s := range w.Steps {
if s.Tool == tool && s.Arg == arg {
return s.Obs, true
}
}
return "", false
}
// Recall は覚え書きにその語を含む行があるか。
func (w Window) Recall(word string) bool {
for _, m := range w.Memo {
if strings.Contains(m, word) {
return true
}
}
return false
}
// Curator は、これまでの記録と覚え書きから、窓に入れるものを決める。
type Curator func(all []Step, memo []string, b Budget) Window削り方でいちばん素直なのは、新しいほうから入るだけ詰めることだ:
// KeepAll は何も落とさない。窓に収まるかどうかも見ない。
//
// 記録は手数に比例して伸びるので、放っておけばいつか入らなくなる。
// Over が 0 でなくなった時点で、実物なら拒否されるか、黙って古い側が消える。
func KeepAll(all []Step, memo []string, b Budget) Window {
w := Window{Memo: memo, Steps: all, Size: memoSize(memo) + totalSize(all)}
if b > 0 && w.Size > int(b) {
w.Over = w.Size - int(b)
}
return w
}
// Recent は新しいほうから、入るだけ原文で詰める。溢れた古い側は消える。
//
// いちばん素直な削り方だが、消えるのは古い側だと決まっている。
// そして調べものでは、当たりを引いたのはたいてい古い側になる。
func Recent(all []Step, memo []string, b Budget) Window {
return curate(all, memo, b, false)
}
// curate は新しいほうから詰められるだけ詰める。
// fold が true なら、詰めきれなかったぶんを畳んだ行として残す。
func curate(all []Step, memo []string, b Budget, fold bool) Window {
base := memoSize(memo)
keep := 0
for keep < len(all) {
cut := len(all) - keep - 1
size := base + totalSize(all[cut:])
if fold {
size += foldedSize(all[:cut])
}
if b > 0 && size > int(b) {
break
}
keep++
}
dropped := all[:len(all)-keep]
w := Window{Memo: memo, Steps: all[len(all)-keep:]}
for _, s := range dropped {
w.LostSteps++
w.LostChars += utf8.RuneCountInString(s.Obs)
if fold {
w.Folded = append(w.Folded, foldKey(s))
}
}
w.Size = base + totalSize(w.Steps)
if fold {
w.Size += foldedSize(dropped)
}
if b > 0 && w.Size > int(b) {
w.Over = w.Size - int(b)
}
return w
}
func totalSize(steps []Step) int {
n := 0
for _, s := range steps {
n += s.Size()
}
return n
}窓を変えて、収まる手数を測った。
| 窓 | 収まる手 | 占めた | 落ちた手 | 消えた観測 |
|---|---|---|---|---|
| 100 | 3 | 81 | 8 | 299 |
| 200 | 5 | 183 | 6 | 220 |
| 400 | 9 | 385 | 2 | 60 |
| 800 | 11 | 462 | 0 | 0 |
記録が462文字なので、窓800なら全部入る。窓200では5手しか入らない。テストで、窓を広げれば収まる手数が減ることはないこと、収まると言っている窓が実際に超えていないことを固定した。
記録 11 手 / 462 文字
1 2 3 4 5 6 7 8 9 10 11
search read read read read read read read read edit test
^^^^
当たり。tax.go の中身で、直すのに要る
窓 200 文字。新しいほうから詰めると、5 手しか入らない
1 2 3 4 5 6 | 7 8 9 10 11
------------------------------------------+-----------------------------------
消える 6 手・観測 220 文字 窓に残る 5 手・183 文字
当たりはこちら側窓200で走らせると、当たりの中身は窓に残らない。それだけなら「もう一度読めばいい」で済みそうに見える。ところが古い側を消すと、その手をやったこと自体も一緒に消える。モデルから見れば、読んでいないのと区別がつかない。
結果、同じファイルを何度も読み直す。テストで、あるファイルを最大8回読み直したこと、上限40回まで回しても終わらないことを固定した。落とし方を決めないと、窓が小さいというだけで仕事が終わらなくなる。
④ 消すのではなく、畳むか、外に書き出す
③で消えたものは、実は2種類ある。何をやったかと、何が分かったかだ。この2つは別々に捨てられる。
まず、溢れたぶんを道具名と引数だけの行に畳む。要約して詰め直すこの手は compaction と呼ばれる:
// Fold は溢れたぶんを「道具と引数」だけの行に畳む。
//
// 何をやったかは残り、何が分かったかは消える。だから同じ手を繰り返すことは
// 無くなるが、観測の中身が要る仕事なら、その手だけやり直すことになる。
func Fold(all []Step, memo []string, b Budget) Window {
return curate(all, memo, b, true)
}
// foldKey は畳んだ1行。観測は入れない。
func foldKey(s Step) string {
if s.Arg == "" {
return s.Tool
}
return s.Tool + " " + s.Arg
}
func foldedSize(steps []Step) int {
n := 0
for _, s := range steps {
n += utf8.RuneCountInString(foldKey(s))
}
return n
}観測は落ちるが、read tax.go という行は残る。だからモデルは「読んだ」ことを知っていて、読み直すのは中身が要る当たりの1本だけになる。テストで、畳んだ窓では Did が真のまま Obs が読めなくなること、直近だけ残す窓ではどちらも読めないことを固定した。
もう1つは、要点を窓の外へ書き出しておく手だ。structured note-taking と呼ばれる:
// note は覚え書きに1行足す。同じ行は二度書かない。
//
// 覚え書きは窓の外に置く。観測を落としても、ここに書いた行は残る。
// 観測が数百文字あっても、そこから取り出した1行は数十文字で済む。
func note(memo []string, line string) []string {
for _, m := range memo {
if m == line {
return memo
}
}
return append(memo, line)
}
// memoSize は覚え書きが窓で占める大きさ。
func memoSize(memo []string) int {
n := 0
for _, m := range memo {
n += utf8.RuneCountInString(m)
}
return n
}観測そのものは捨ててよい。当たりを見つけたときに1行だけ書き残しておけば、あとで読み直す必要が無くなる。
窓200・上限40回で、4通りを同じ台本に通した。
| 残し方 | 手数 | モデル | 渡した文字 | 窓を超えた | 終わったか |
|---|---|---|---|---|---|
| 全部残す | 11 | 12 | 2,979 | 262 | 終わる |
| 直近だけ | 40 | 40 | 6,476 | 0 | 終わらない |
| 畳む | 12 | 13 | 1,852 | 0 | 終わる |
| 畳む + 覚え書き | 11 | 13 | 1,839 | 0 | 終わる |
読みどころは4つある。
全部残すのは正しいが、入らない。手数は最小の11手で済むのに、記録が462文字あって窓200を262文字超えている。実物ならここで拒否されるか、黙って古い側が消える。黙って消えたとき何が起きるかが、その下の行になる。
直近だけ残すと、渡した文字がいちばん多い。窓は超えていないのに6,476文字を渡している。同じファイルを繰り返し読んでは繰り返し渡しているからで、削ったつもりが増えている。
畳むと、余分な読み直しは1本で済む。全部残すより1手多いだけだ。そして渡した文字は1,852文字で、全部残す場合の6割で済んでいる。
覚え書きを足すと、その1手も消える。捨てた観測は299文字で、残した覚え書きは24文字だった。テストで、捨てた側が残した側の5倍以上あることを固定してある。観測を丸ごと持ち続ける必要は無く、そこから取り出した1行で足りることがある。
ただし畳むのはただではない。畳んだ行も窓を食うので、原文で残せる手はそのぶん減る。窓120で測ると、直近だけなら原文3手が入るのに対し、畳むと原文1手と畳んだ10行で埋まった。テストで、畳んだほうが原文の手数を増やすことは無いことを固定した。
動かす
下のデモは、窓200のまま残し方だけを差し替える。台本は4通りとも同じで、変わるのは窓に何を入れるかだけだ。
呼び出し元 8 本を全部見てから直す ・ 当たりは tax.go(2 本目)・ 窓 200 文字 ・ 上限 40 回
台本は毎回同じで、変えたのは窓に何を残すかだけだ。原文で残っていれば観測の中身まで読めるが、 畳んだ行から読めるのは「どの道具をどの引数で使ったか」までで、そこで分かったことは読めない。 覚え書きは窓の外に置くので、観測を捨てても残る。
⑤ 経路を先に描けるところは、モデルに訊かなくてよい
②の4手に戻ると、search → read → edit → test の順はどう考えても変わらない。変わるのは edit の中身だけだ。だったら順番のほうは、毎回モデルに選ばせる必要が無い。
// Node はグラフの1つの節。
//
// Decide が false なら、この節ではモデルに訊かない。何をするかは先に決まっている。
type Node struct {
Name string
// Tool と Arg は、モデルに訊かずに実行する内容。
Tool string
Arg string
// Decide が true のとき、この節だけモデルに次の1手を訊く。
Decide bool
// Check は観測を見て、この節が成功したかを返す。nil なら常に成功。
Check func(obs string) bool
// Retry は失敗したときに戻る先の節名。空なら戻らずに終わる。
Retry string
// Next は次の節名。空なら終わり。
Next string
}
// Graph は節と辺。通ってよい道が先に描いてある。
type Graph struct {
Start string
Nodes map[string]Node
// MaxVisits は同じ節へ入れる回数の上限。やり直しが止まらなくなるのを防ぐ。
MaxVisits int
// Curate は決める節で窓を作る。nil なら KeepAll。
Curate Curator
// Budget は窓の大きさ。0 なら窓を無限とみなす。
Budget Budget
}
// Run はグラフをたどる。
//
// 経路が決まっている節ではモデルを呼ばない。ここが費用の差になる。
// 節が失敗したら Retry の節から やり直す。ループのように最初からではない。
func (g *Graph) Run(m Model, tools map[string]Tool) Result {
cur := g.Curate
if cur == nil {
cur = KeepAll
}
var r Result
visits := map[string]int{}
name := g.Start
for name != "" {
n, ok := g.Nodes[name]
if !ok {
r.Reason = "無い節へ行こうとした: " + name
return r
}
visits[name]++
if g.MaxVisits > 0 && visits[name] > g.MaxVisits {
r.Reason = "同じ節を回りすぎた: " + name
return r
}
tool, arg, byModel := n.Tool, n.Arg, false
if n.Decide {
w := cur(r.Steps, r.Memo, g.Budget)
a := m.Decide(w)
r.ModelCalls++
r.InputChars += w.Size
if a.Note != "" {
r.Memo = note(r.Memo, a.Note)
continue // 同じ節をもう一度。訪問回数の上限が効く
}
if a.Answer != "" {
r.Answer, r.OK, r.Reason = a.Answer, true, "モデルが終わりだと言った"
return r
}
tool, arg, byModel = a.Tool, a.Arg, true
}
t, ok := tools[tool]
if !ok {
r.Reason = "無い道具を使おうとした: " + tool
return r
}
obs := t(arg)
r.Steps = append(r.Steps, Step{Tool: tool, Arg: arg, Obs: obs, ByModel: byModel})
r.ToolCalls++
if n.Check != nil && !n.Check(obs) {
if n.Retry == "" {
r.Reason = n.Name + " が失敗した"
return r
}
name = n.Retry // 失敗した節の手前へ戻る。最初からではない
continue
}
name = n.Next
}
r.OK = true
r.Answer = lastObs(r.Steps)
r.Reason = "最後の節まで来た"
return r
}節と辺で通ってよい道を描き、Decide を立てた節だけモデルに訊く。同じ仕事を両方で走らせるとこうなった:
| 道具の手数 | モデルの呼び出し | 渡した文字 | |
|---|---|---|---|
| ループ | 4 | 5 | 189 |
| グラフ | 4 | 1 | 47 |
道具を使う回数は同じで、訊く回数が5分の1になっている。渡した文字はさらに落ちて4分の1だ。訊く回数より、こちらのほうが実物の値段に近い。ループは呼ぶたびにそれまでの記録ぜんぶを渡すので、後半の1回が重い。グラフは edit の節に入った時点の2手ぶんしか渡していない。
つまりグラフは、③④で扱った窓の問題も同時に軽くしている。経路が決まっている区間は、そもそもモデルに見せる必要が無いからだ。
差はやり直しでも出る。1回目の直しが外れて、もう一度やる筋書きで測るとこうなった:
| 道具の手数 | モデルの呼び出し | |
|---|---|---|
| ループ(最初からやり直す) | 8 | 9 |
グラフ(edit から戻る) | 6 | 2 |
ループは、どこから戻ればいいかを知らない。だから素直に書くと最初から全部やり直すことになる。グラフは test が落ちたら edit へ戻ると先に書いてあるので、search と read はやり直さない。テストで、search と read がそれぞれ1回しか呼ばれないことを固定した。
やり直しの単位を持てるかどうかが、この2つのいちばん大きな違いだ。
ただしグラフも万能ではない。同じ節を回り続けることはあるので、こちらにも上限が要る。テストで、何度直しても test が通らない筋書きで、同じ節への訪問回数の上限にぶつかって止まることを固定した。
ここが、②と⑤を対立として読まないための要点になる。上限が要るということは、グラフも回っているということだ。②で見た「自分では止まれない」は、道を描いても消えない。消えたのは道の自由さのほうで、回ることそのものは両方に残っている。
だから2つは排他ではない。層の表で「ループ=いつ止めるか」「グラフ=どの道を通ってよいか」と別の欄になっているのは、そのためだ。両方を持つ形もある。下回りに道を描き、その上で止まるまで回す。実物がそう積んでいることは、この章の冒頭で触れたとおりになる。
動かす
下のデモは、同じ仕事をループとグラフで進める。1回目の直しを外す設定にすると、やり直しの範囲の違いが見える。
- search見つけた: calc.go:42
- readreturn a - b
- edit書き換えた
- testFAIL
- search見つけた: calc.go:42
- readreturn a - b
- edit書き換えた
- testPASS
- search見つけた: calc.go:42
- readreturn a - b
- edit書き換えた
- testFAIL
- edit書き換えた
- testPASS
色の付いた手が「モデルに訊いて決めた手」。ループは毎手訊くので、道具の手数とほぼ同じ回数だけ モデルを呼ぶ。グラフは search・read・test の順を先に書いてあるので、訊くのは edit の節だけになる。 失敗したときも、ループは戻る先を知らないぶん最初からやり直すことになる。
どの層が壊れているか
5つの層は、直しやすさの順でもある。プロンプトは1行書き換えればすぐ試せる。コンテキストの残し方はコードを1つ差し替える。ループの止め方は設定を変える。グラフは描き直しになる。
直しやすい順と、壊れる場所の分布は一致しない。だから起きるのは、下の層で壊れているものを、いちばん上の層で直そうとすることだ。
③の走りがちょうどその例だ。同じファイルを8回読み直したとき、プロンプトに書き足したくなるのは「同じファイルを二度読まないこと」だろう。だが窓に記録が残っていないのだから、読んだかどうかをモデルは知りようがない。指示は読めても、事実が見えない。
見分け方は単純で、そのとき窓に何が入っていたかを出してみることだ。上の表の「渡した文字」や「消えた観測」は、そのために測っている。
| 症状 | まず書き足したくなること | 実際に見るところ |
|---|---|---|
| 同じ道具を何度も使う | 「二度やるな」 | 前の手が窓に残っているか(コンテキスト) |
| 中身を取り違える | 「よく読め」 | 観測が畳まれていないか(コンテキスト) |
| 止まらない | 「終わったら答えよ」 | 上限が置いてあるか(ループ) |
| 毎回違う手順を通る | 「この順でやれ」 | 順が決まっているならグラフへ |
| 失敗のたびに全部やり直す | 「やり直しは最小限に」 | 戻る先が書いてあるか(グラフ) |
| 答えが確かめられていない | 「根拠を示せ」 | 道具と観測の口があるか(ハーネス) |
設計の観点
- 観測を返す口を作る: 道具の結果が次の入力に戻らなければ、確かめるという動作が成立しない
- 窓に何を残すかを決める: 決めなければ、古い側から順に、黙って消える
- やったことと分かったことを分けて捨てる: 前者が消えると同じ手を繰り返し、後者が消えるとその手だけやり直す
- 要点は窓の外へ書き出す: 観測を丸ごと持ち続けるより、そこから取り出した1行を残すほうが安い
- 止まる条件を外に置く: 続けるかどうかをモデルに委ねるなら、上限は外から与える
- やり直しの単位を決める: どこまで戻るかを書いていないと、失敗のたびに全部やり直すことになる
- 決まっているところを固定する: 順番が変わらない区間で毎回訊くのは、費用も揺らぎも増やすだけ
- 描けるかどうかで選ぶ: 経路を先に描けるならコードへ、描けないならモデルへ
- 層に分ける: 下回りをグラフに、その上に意見の入ったループを載せる、という積み方ができる
対照と実例
| 1回ぶんの単位 | 観測を使えるか | 止め方 | やり直し | 窓の扱い | |
|---|---|---|---|---|---|
| プロンプト | 1入力 | 使えない | 1回で終わる | 無し | 溢れない |
| コンテキスト | 窓に残るもの | 残っていれば使える | — | やり直しの量を左右する | ここで決める |
| ハーネス + ループ | 1パス / 実行全体 | 使える | 外から上限 | 最初から | 回すほど埋まる |
| グラフ | ジョブ全体 | 使える | 訪問回数の上限 | 節の単位 | 決める節だけ渡す |
| グラフの上にループ | 両方 | 使える | 両方 | 節の単位 | 中間 |
裏どり:
- コンテキストの層は名前がついている: Anthropic は context engineering を「推論のあいだ、最適なトークンの集合を選び、保ち続けるための一連の戦略」と定義し、prompt engineering(指示をどう書き、どう並べるか)と区別している。この章の③④がそこに当たる
- 窓を広げれば済む話ではない: 同じ整理が、トークン数が増えるほど正しく思い出せる割合が落ちる現象を context rot と呼び、「attention budget(注意の予算)は新しいトークンを入れるたびに減る」と言い切っている。入るかどうかと、効くかどうかは別になる
- 畳むことにも名前がある: 窓の上限に近づいた会話を要約して、その要約から新しい窓を始めることを compaction と呼ぶ。窓の外へ書き出すほうは structured note-taking で、「窓の外の記憶に定期的にメモを書き出す」と説明されている。この章の
Foldとnoteはその最小形になる - 効き目の数字: Anthropic の agentic search の内部評価では、context editing だけで 29%、memory tool と併せて 39% 改善したと報告されている。100ターンの検索では、トークン消費が 84% 減り、そのままでは窓を使い切って失敗する仕事が完走したとしている
- ループにも分類がある: Claude の loops の記事は、ループを「止まる条件を満たすまで仕事を繰り返すもの」と定義し、turn-based・goal-based・time-based・proactive の4つに分けている。止め方はそれぞれ違うが、「終了条件を具体的に決め、明示的に手数の上限を置く」ことは共通して勧めている。この章の②で測ったのは、上限を置かなかった場合になる
- 揺り戻しは実際に起きた: LangChain は deep research 用の事前定義 LangGraph ワークフローを、より agentic な中核ループへ置き換えた。GPT Researcher も、グラフに組んだ多エージェントの流れを Deep Agents に差し替えている。経路を固定できない仕事では、固定しないほうが強い
- 対立ではなく積層: Deep Agents は LangGraph の上に載っている。LangGraph が低レベルの部品(状態、循環、永続化)を出し、その上に計画・サブエージェント・ファイルシステム・コンテキスト管理・middleware を載せた形になる
- グラフ側の言い分: LangChain 自身の記事は「どこをモデルに選ばせ、どこをコードで決定的にするかを、グラフとして書き下せる」と主張している。⑤で測った「渡した文字 189 と 47」はその最小の裏取りになる
- harness engineering という言葉: Galster らの Harness Engineering for Agentic AI Coding Tools(AIware '26)は、プロンプト・コンテキストとは別の層として、道具・設定・足場の設計を扱っている
- 規模: LangGraph の配布は月あたり6500万件を超える。グラフが消えたわけではない
簡略化したこと
- モデルが台本: 実時間も乱数も使わないので、何回やっても同じ手数になる。実物の揺らぎは扱わない
- 窓を文字数で数えている: 実物はトークン数で、しかも同じ文字数でも言語や記号で変わる。ここでは目安になる
- 畳むのが機械的: 道具名と引数を残しているだけで、要約はしていない。実物は要約そのものをモデルに書かせるので、そこでも取りこぼしが起きる
- 覚え書きを書くのもモデル: ここでは台本が確実に書く。実物では「書くべきときに書かない」という失敗がそのまま効く
- サブエージェントなし: 仕事を切り出して別の窓で走らせ、要約だけ戻す形は作っていない
- 取りに行く形なし: 全部を先に窓へ入れるのではなく、識別子だけ持っておいて必要になったら読む、という形は扱わない
- 並行なし: 節は1つずつ。扇形に広げて合流する形は扱わない
- 人の割り込みなし: 途中で人が承認する、止める、といった口は無い
- 永続化なし: 途中で落ちたら最初から。実物は状態を保存して再開できる
参考資料
- Effective context engineering for AI agents — コンテキストの層の定義、attention budget、compaction、structured note-taking
- Managing context on the Claude Developer Platform — context editing と memory tool の効き目
- Getting started with loops — ループの4分類と止め方
- The best AI agent frameworks in 2026 — グラフとハーネスの使い分け
- 3 Years of Graph Engineering with LangGraph — グラフ側の主張
- Galster et al., Harness Engineering for Agentic AI Coding Tools(AIware '26)
- 実装: llm/harness