スクリプトでエージェントを編むDynamic Workflowsを触って、自分のパイプラインの「重さ」の正体がわかった
Claude Code の Dynamic Workflows は、サブエージェントの群れを JavaScript で決定論的に制御する仕組みだ。最小コードで挙動を実測し、最後に自分のralph連結パイプラインと突き合わせたら、ゲートでもチェックでもなく『どこで重さを払っているか』だけが本当の違いだった。
「スクリプトを書いてエージェントを制御する」。Claude Code に Dynamic Workflows という機能が増えたと聞いて、まずそこに引っかかった。自分はずっと ralph-loop を連結したパイプラインで生成系の仕事を回してきたので、「で、自分のと何が違うんだ」を知りたかった。
結論を先に言う。ゲートの種類でも、出力チェックの有無でもなかった。違うのは「どこで重さを払っているか」だけだった。そこに辿り着くまでに、最小のコードを書いて実際に走らせ、データの受け渡しと暴走耐性を一つずつ潰していった。その記録を残す。
一言でいうと何なのか
Dynamic Workflows は 「サブエージェントの群れを、モデルの気まぐれではなく決定論的な JavaScript で制御する」 仕組みだ。
従来のエージェント制御は2系統だった。ひとつはモデル主導——親エージェントが「次はこれを呼ぼう」と都度判断する方式で、柔軟だが再現性が弱い。もうひとつは静的パイプライン——YAML やコードで順序を固定する方式で、堅いが融通が利かない。
Dynamic Workflows はその中間に立つ。制御フロー(ループ・分岐・fan-out)は普通の JS で書き、各ステップの中身だけを LLM エージェントに委ねる。ralph-loop が「1プロセスをループで回す」縦方向の進化だとすれば、こちらは「多数のエージェントを構造化して並列に走らせる」横方向の進化だ。
スクリプトの骨格はこうなる。
export const meta = {
name: 'review-changes',
description: '変更をレビューし各指摘を検証する',
phases: [{ title: 'Review' }, { title: 'Verify' }],
}
// 以下が本体(async コンテキスト、await 直書き可)
phase('Review')
const result = await agent('このdiffのバグを探せ', { schema: FINDINGS_SCHEMA })meta は純粋なリテラルしか書けない(変数・関数呼び出し・スプレッド禁止)。ここがパーミッションダイアログや進捗表示に出る部分だ。
実際に動かしてみた — pipeline と parallel を測る
机上で理解しても仕方ないので、いちばん挙動が分かれそうな2つのプリミティブ、pipeline と parallel の違いを実測することにした。
pipeline(items, stage1, stage2, ...)— 各アイテムを全ステージに独立して流す。ステージ間にバリアがない。アイテムAがstage3にいる間、アイテムBはまだstage1、で構わないparallel(thunks)— 並列実行するがバリアあり。全部終わるまで次に進まない
絵にするとこうだ。parallel はステージ間に「全件待ち」の関門が挟まるが、pipeline にはそれが無い。
差を観測するには「速いタスク」と「遅いタスク」が要る。LLM の応答時間は出力トークン数にほぼ比例するので、生成させる文章の語数を 15〜320 語でばらけさせてレイテンシ差を人工的に作った。
ここで地味に効いた制約がひとつ。スクリプト内では Date.now() が封印されている(resume の決定性を壊すため)。だから「時刻」では測れない。代わりに完了イベントに連番を振る論理クロックを置いた。JS は単一スレッドなので、await で中断したコールバックが再開する瞬間は必ず1つずつ順番に処理される。ゆえに ++tick は競合せず、その値がそのまま「完了順」になる。
let tick = 0
function mark(kind, id) {
const t = ++tick
log(`#${t} ${kind} item ${id}`)
}
const WORDS = [15, 40, 90, 180, 320]
const items = WORDS.map((w, i) => ({ id: i, words: w }))
// ---- pipeline(バリアなし)----
phase('Pipeline')
await pipeline(
items,
async (it) => {
const txt = await agent(`Write exactly ${it.words} words about clouds.`, { model: 'haiku' })
mark('PIPE-s1-done', it.id)
return { it, txt }
},
async (prev) => {
mark('PIPE-s2-START', prev.it.id) // ← stage2 に入った瞬間
await agent(`Summarize in one sentence:\n${prev.txt}`, { model: 'haiku' })
mark('PIPE-s2-done', prev.it.id)
}
)
// ---- parallel(バリアあり)----
phase('Parallel')
const s1 = await parallel(items.map((it) => async () => {
const txt = await agent(`Write exactly ${it.words} words about clouds.`, { model: 'haiku' })
mark('PAR-s1-done', it.id)
return { it, txt }
}))
log('===== BARRIER: 全 stage1 完了 =====')
await parallel(s1.map((prev) => async () => {
mark('PAR-s2-START', prev.it.id)
await agent(`Summarize in one sentence:\n${prev.txt}`, { model: 'haiku' })
}))結果: no-barrier が効く瞬間
20エージェント・約16秒で返ってきた。完了順(tick)を並べると、違いが一目でわかる。
pipeline(バリアなし):
#01 PIPE-s1-done item0 ← 最速(15語)が stage1 完了
#02 PIPE-s2-START item0 ← 即 stage2 へ。誰も待たない
#03 PIPE-s1-done item1
#04 PIPE-s2-START item1
#05 PIPE-s1-done item2
#06 PIPE-s2-START item2
#07 PIPE-s2-done item0 ┐
#08 PIPE-s2-done item1 │ 0・1・2 は両ステージ完走
#09 PIPE-s2-done item2 ┘
#10 PIPE-s1-done item3 ← 180語、ようやく stage1 完了
#11 PIPE-s2-START item3
#12 PIPE-s2-done item3
#13 PIPE-s1-done item4 ← 最遅(320語)がやっと stage1 完了
#14 PIPE-s2-START item4
#15 PIPE-s2-done item4
核心は #07 だ。最速の item0 は完全に終わっているのに、最遅の item4 は #13 まで stage1 すら終わっていない。速いタスクは遅いタスクを一切待たずに最後まで駆け抜けた。「最初のstage2開始(#2) < 最後のstage1完了(#13)」——ステージがインターリーブしている。これが「実時間 ≒ 最遅の単一チェーン」の正体だ。
parallel(バリアあり):
#01–#05 PAR-s1-done item0,1,2,3,4 ← stage1 が全部終わるまで…
────────── BARRIER ──────────
#06–#10 PAR-s2-START item0,1,2,3,4 ← …stage2 は1つも始まらない
#11–#15 PAR-s2-done item0,3,4,2,1
最速の item0 は #01 で stage1 を終えていたのに、最遅の item4 が終わる #05 まで待たされてから #06 でようやく次段へ。この待ち時間が parallel の代償だ。fan-out で各タスクのレイテンシ差が大きいほど、この差は開く。
公式ドキュメントが「デフォルトは pipeline、バリアが本当に必要なときだけ parallel」と言い切っている理由が、数列で腑に落ちた。バリアが正当化されるのは「次段が前段の全結果を横断的に必要とする」とき——全件 dedup してから高コスト処理に渡す、合計0件なら検証フェーズごとスキップ、といった場合だけだ。
データの受け渡しは「3つの境界」
ここで一度勘違いをした。「ステージ間はファイルでも JSON でもなく、生の JS オブジェクトを渡している」と。半分正しく、半分間違っている。正確には境界が3つあって、シリアライズされる境界とされない境界が混在している。
| 境界 | シリアライズ | 何が起きるか |
|---|---|---|
| ① ステージ間 | されない | pipeline が前段の返り値を次段の引数に渡すだけ。同一プロセス内の JS 値 |
| ② → エージェント入力 | される | LLM はトークンしか食えない。プロンプト文字列に埋め込む瞬間にテキスト化 |
| ③ ← エージェント出力 | される | テキスト、または schema 付きなら tool-call の JSON として出力される |
さっきのコードで prev(= {it, txt})が次段に渡るのは境界①でシリアライズなし。だが `...\n${prev.txt}` でプロンプトに埋め込んだ瞬間が境界②で、ここは確実にテキスト化される。そして prev.it(元の {id, words})は一度も LLM に渡っていない——JS の中だけを回って終わった値だ。
つまり 「LLM に見せる値」は必ずシリアライズされ、「制御のために JS が持っているだけの値」(ループ変数・カウンタ・元データ)はされない。脱シリアライズされたわけではなく、シリアライズがオーケストレータの外側に押し出されただけ。LLM との I/O は相変わらず JSON とテキストだ。
schema を渡すと「手パース」が消える
出力の型を保証したいときは agent() に JSON Schema を渡す。
const FINDING = {
type: "object",
properties: {
risk_level: { type: "string", enum: ["high", "medium", "low"] },
clause: { type: "string" },
reason: { type: "string" },
},
required: ["risk_level", "clause", "reason"],
}
const f = await agent("この契約条項のリスクを判定せよ", { schema: FINDING })
// f は検証済みオブジェクト。f.risk_level に直接触れる仕組み上、schema を渡すとサブエージェントは自由テキストではなく StructuredOutput ツールの呼び出しを強制される。検証がツール呼び出し層で走り、スキーマに合わなければモデルが自動でリトライする。だから返ってきた時点で型が保証されている。「JSON でくれと頼んで祈り、JSON.parse で落ちる」という一番脆い部分が構造的に消える。ワイヤ上では JSON が流れているが、その直し込みを harness が肩代わりしてくれる、という言い方が正確だ。
暴走はどこまで防げるか — 幅と深さ
自律ループで 8時間暴走を経験した身としては、ここが最重要だった。結論から言うと、暴走には2つの軸があって、Dynamic Workflows が守るのは片方だけだ。
| 軸 | 何が暴走するか | pipeline/parallel は守るか |
|---|---|---|
| 幅(spawn 数) | オーケストレータが無限にエージェントを起動 | 守る。有限配列を1回なめるだけ。5件なら5回 |
| 深さ(個々の滞留) | 1個の agent() が返ってこない | 守らない。管轄外 |
幅方向には、さらにハードな床がある。同時実行は min(16, コア数-2) にキャップされ、生涯エージェント総数は 1000 で強制停止する。while ループを書くときも、件数・「K周連続で新発見ゼロ」・予算のいずれかでガードすれば止まる。特に予算ガードは要注意で、budget.total を頭に付けないと未設定時に remaining() が Infinity になり1000上限まで直行する。
問題は深さだ。agent() に timeout オプションは無く、Date.now() も setTimeout も封印されているので、自前のウォッチドッグも組めない。parallel はバリアゆえ、1個のハングが全体を止める最悪挙動になる。深さ方向で効く防壁は、トークン予算(焼き続けるタイプの暴走なら spent() が上限に達して agent() が例外を投げる)と、最終的には人が TaskStop で殺す運用しかない。ralph-loop が --timeout で1イテレーションに壁時計の上限を掛けられたのとは対照的だ。ここは率直に弱点だと思う。
自分のパイプラインと比べる
さて本題。自分のパイプラインは ralph-loop を連結したもので、各ステップが収束ループになっている。突き合わせていくと、思っていたところと違う場所に「差」があった。
「ゲートが違うのでは」と思った。違わなかった。 こちらは検証ゲートをコード/LLM/エージェンティックの3種持っている。Dynamic Workflows の「verify」——adversarial verify(N体の懐疑エージェントに反証させ多数決で棄却)や judge panel——も、部品としては同じ「ツールを持ったエージェントが判定する」もので、新しい概念ではない。workflow が足しているのは部品ではなく並べ方だ。1個→N体の冗長化、同一チェック→異なるレンズ(correctness / 法的リスク / 再現性)の多様化、そして parallel 数行で投票を組める安さ。
「出力チェックの有無では」とも思った。それも違った。 こちらは schema 指定もゲートも持っている。フォーマット検証も意味検証も両方ある。能力軸では、ほぼ同じだ。
突き詰めると、残った本当の違いは2つだけだった。
- チェックが強制か任意か。 自分のパイプラインは全ステップにゲートが構造上ついていて、チェックが既定で必ず走る。workflow の既定
agent()は schema フォーマット検証だけで、意味チェック(adversarial verify)はオプトイン——書かなければ走らず、ステップを無検査で素通しできてしまう。 - チェック失敗時に何をするか。 自分のは fail → 再生成(ralph が補正ループ)で corrective。workflow の verify は fail → その候補を捨てるで selective。同じ「落ちた」でも、片方は直しに戻り、片方は間引く。
そしてもうひとつ、構造そのものの違い。自分のパイプラインはステップ間の通貨がディスク上のファイルで、フォーマット指定されたファイルが次ステップへの契約になり、それがチェーンしている。workflow はメモリ上の JS 値で、揮発する。
ここが「自分の方が重い」の正体だった。重さは 「全ステップ強制チェック × 補正ループ × ファイル永続」 の合算だ。だが——重いことは劣ることではない。ファイルベースは再実行性(step3 が落ちても step2 の出力が残り、1ステップだけ回し直せる)と監査証跡(どの入力からどう出力したかが全部ディスクに残る)を買っている。リーガルのような領域では、この中間生成物は無駄ではなく成果物だ。自分は意図して重さを払っていた。
結局、どう使い分けるか
以前プロダクトを実コードで分類したとき、「検証層がどこから来るか」で型が決まると書いた。今回の比較は、その続きとして腑に落ちる。
- DoD が機械チェック可能な工程(テスト緑・型エラー0・ビルド通過)→ ralph の収束ループが最適。チェッカが既にあるから DoD は安く書けて、loop-to-green がそのままハマる
- 良し悪しが主観的な工程(この調査結果は妥当か・この文章は良いか・過剰主張はないか)→ 閾値が書けないので収束ループに向かない。workflow の verify パネルで複数視点から交差検証して確信を上げる方が効く
自分のパイプラインの重さのうち、ファイル永続は監査のために残す。フォーマット収束を ralph ループで回している部分は、workflow の schema(tool-call 1回ぶんの自動リトライ)に逃がせば軽くなる。理想は二者択一ではなく、「各ステップの中身は workflow で fan-out + schema で回し、ステップ境界だけファイル契約で永続化する」ハイブリッドだ。
Dynamic Workflows は ralph-loop の上位互換ではない。幅の暴走には強いが深さには弱く、チェックは任意で、中間物は揮発する。その代わり、レイテンシのばらつく多数のタスクを同期点なしで束ね、検証を安く何本も並べられる。自分が何を題材に、どの工程で「重さ」を払うべきか——その判断軸を一本増やしてくれる道具として、手元に置いておく価値はある。