Skip to content

エージェントの枠組み(プロンプト・コンテキスト・ハーネス・ループ・グラフ)

実装: 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 回


  経路を先に描けるなら ③、走りながらでないと分からないなら ②
経路を先に描けるかどうか。描ける区間はコードで固定し、描けない区間だけモデルに決めさせる。実物はこの2つを積み重ねている

ただし、壊れる順は上の表の並びと違う。窓が問題になるのは、ループを回してからだ。だからこの章は、層の並びではなく壊れる順に見ていく。

順に見ていく。

  1. 1回きりでは、途中で分かったことを次に使えない: プロンプトの層。観測を返す口が無いと、確かめようがない
  2. ハーネスを作り、ループで回す: 道具と観測の口を作って回すと、上限が無いかぎり止まらない
  3. 回すほど窓が埋まり、消えるのは古い側になる: 当たりを引いたのは、たいてい古い側だ
  4. 消すのではなく、畳むか、外に書き出す: 何をやったかと、何が分かったかは別々に捨てられる
  5. 経路を先に描けるところは、モデルに訊かなくてよい: 同じ仕事でモデル5回が1回になる

① 1回きりでは、途中で分かったことを次に使えない

いちばん下の層から始める。プロンプトの層だ。1回入れて1回返す、それだけの形になる。

go

// 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周で終わるなら①と変わらないからだ。回して初めて意味が出る。逆に、⑤で作るグラフもこの口の上に載っている。ハーネスは、このあと出てくる形の全部が使う土台になる。だからこの章では、ここで一度作って、以降は前提として扱う。

先に断っておくと、このあと⑤で作るグラフも回る。落ちたら前の節へ戻るのは、まさに回っている。「勝手に回る」のは両方に共通していて、違うのは回る道が決まっているかどうかになる。ここで作るのは、道を決めずに回すいちばん素朴な形だ。

観測を次の判断に渡せるようにすると、話が変わる:

go

// 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、それを引いて実行するところ、そして結果を StepObs として記録に積むところだ。この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文字になった。

窓の大きさと、そこに何が入るかを数える:

go

// 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

削り方でいちばん素直なのは、新しいほうから入るだけ詰めることだ:

go

// 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
}

窓を変えて、収まる手数を測った。

収まる手占めた落ちた手消えた観測
1003818299
20051836220
4009385260
8001146200

記録が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 と呼ばれる:

go

// 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 と呼ばれる:

go

// 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通りを同じ台本に通した。

残し方手数モデル渡した文字窓を超えた終わったか
全部残す11122,979262終わる
直近だけ40406,4760終わらない
畳む12131,8520終わる
畳む + 覚え書き11131,8390終わる

読みどころは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 回

手数40モデル40渡した文字6,476余分な手35窓を超えた0
searchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.gosearchread calc.goread tax.goread fee.goread cart.go
最後に窓へ入っていたもの ・ 152 文字
原文read tax.goread fee.goread cart.go
畳んだなし(消えた 37 手 / 観測 1327 文字)
覚え書きなし
古い側を消すと、読んだこと自体が窓から消える。同じファイルを最大 8 回読み直し、余分な手が 35 回積み上がって、上限 40 回まで回っても終わらない

台本は毎回同じで、変えたのは窓に何を残すかだけだ。原文で残っていれば観測の中身まで読めるが、 畳んだ行から読めるのは「どの道具をどの引数で使ったか」までで、そこで分かったことは読めない。 覚え書きは窓の外に置くので、観測を捨てても残る。

⑤ 経路を先に描けるところは、モデルに訊かなくてよい

②の4手に戻ると、search → read → edit → test の順はどう考えても変わらない。変わるのは edit の中身だけだ。だったら順番のほうは、毎回モデルに選ばせる必要が無い

go

// 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 を立てた節だけモデルに訊く。同じ仕事を両方で走らせるとこうなった:

道具の手数モデルの呼び出し渡した文字
ループ45189
グラフ4147

道具を使う回数は同じで、訊く回数が5分の1になっている。渡した文字はさらに落ちて4分の1だ。訊く回数より、こちらのほうが実物の値段に近い。ループは呼ぶたびにそれまでの記録ぜんぶを渡すので、後半の1回が重い。グラフは edit の節に入った時点の2手ぶんしか渡していない。

つまりグラフは、③④で扱った窓の問題も同時に軽くしている。経路が決まっている区間は、そもそもモデルに見せる必要が無いからだ。

差はやり直しでも出る。1回目の直しが外れて、もう一度やる筋書きで測るとこうなった:

道具の手数モデルの呼び出し
ループ(最初からやり直す)89
グラフ(edit から戻る)62

ループは、どこから戻ればいいかを知らない。だから素直に書くと最初から全部やり直すことになる。グラフは test が落ちたら edit へ戻ると先に書いてあるので、searchread はやり直さない。テストで、searchread がそれぞれ1回しか呼ばれないことを固定した。

やり直しの単位を持てるかどうかが、この2つのいちばん大きな違いだ。

ただしグラフも万能ではない。同じ節を回り続けることはあるので、こちらにも上限が要る。テストで、何度直しても test が通らない筋書きで、同じ節への訪問回数の上限にぶつかって止まることを固定した。

ここが、②と⑤を対立として読まないための要点になる。上限が要るということは、グラフも回っているということだ。②で見た「自分では止まれない」は、道を描いても消えない。消えたのは道の自由さのほうで、回ることそのものは両方に残っている

だから2つは排他ではない。層の表で「ループ=いつ止めるか」「グラフ=どの道を通ってよいか」と別の欄になっているのは、そのためだ。両方を持つ形もある。下回りに道を描き、その上で止まるまで回す。実物がそう積んでいることは、この章の冒頭で触れたとおりになる。

動かす

下のデモは、同じ仕事をループとグラフで進める。1回目の直しを外す設定にすると、やり直しの範囲の違いが見える。

デモループとグラフ1回目の直しが外れる
一発で直る1回目の直しが外れる
ループ 道具 8 手 ・ モデル 9
  1. search見つけた: calc.go:42
  2. readreturn a - b
  3. edit書き換えた
  4. testFAIL
  5. search見つけた: calc.go:42
  6. readreturn a - b
  7. edit書き換えた
  8. testPASS
モデルが終わりだと言った
グラフ 道具 6 手 ・ モデル 2
  1. search見つけた: calc.go:42
  2. readreturn a - b
  3. edit書き換えた
  4. testFAIL
  5. edit書き換えた
  6. testPASS
最後の節まで来た
ループは戻る先を知らないので最初からやり直して 8 手。グラフは edit へ戻ると先に書いてあるので 6 手。search と read はやり直していない

色の付いた手が「モデルに訊いて決めた手」。ループは毎手訊くので、道具の手数とほぼ同じ回数だけ モデルを呼ぶ。グラフは 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 で、「窓の外の記憶に定期的にメモを書き出す」と説明されている。この章の Foldnote はその最小形になる
  • 効き目の数字: 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つずつ。扇形に広げて合流する形は扱わない
  • 人の割り込みなし: 途中で人が承認する、止める、といった口は無い
  • 永続化なし: 途中で落ちたら最初から。実物は状態を保存して再開できる

参考資料