多くのチームは、安全検査を並列化した瞬間に、安全を少しだけ手放している。しかも、ほとんどの場合それに気づけない。
エージェント基盤では、1つのactionに対して多数の検査を走らせる。identityは正しいか。authorityは足りているか。tool callは許可範囲に入っているか。証跡はあるか。予算を越えないか。surface固有の契約を破っていないか。これらを順番に走らせればlatencyは足し算になる。だから並列化したくなる。
しかしfail-closedなsystemでは、素朴な並列化は安全性を静かに弱める。速くなったように見えるが、実行できなかった検査を見落とす。完了順で結果が揺れる。共有budgetを競合させる。実行時stateを読んだせいでreplayできなくなる。これは性能問題ではなく、統治の問題である。
この記事の背骨はこの2行だけである。fan-outは速くするための技術であり、安全性を生む場所ではない。安全性を決めるのは、並列に散らした結果をどう正規化し、欠けた結果をどう扱い、次のactionをどちらへ倒すかである。
もう1つ、この記事の隠れた原則を先に置いておく。決定では収束し、証跡では区別する。reject、timeout、store failure、low confidence、budget exhaustionは、decisionでは制限側へ収束する。しかしevidenceでは別々の理由として残す。fail-closedは雑な思考停止ではなく、debug可能性を残した保守的設計である。
1. なぜ多ハーネスを並列に走らせるのか
ここでいうHarnessは、1つのagent actionに対する独立した検査単位である。unit testのように関数の戻りだけを見るものではない。action envelope、actor identity、authority boundary、tool permission、evidence、budget、risk class、surface固有の契約を読み、actionを進めてよいか、制限すべきか、止めるべきか、人間へ返すべきかを判定する。
MARIA OSのようなagent governance runtimeでは、Harnessは大きく2種類に分かれる。横断Harnessはidentity、authority、trust、budget、evidence、audit traceのようにsurfaceをまたいで効く。部分HarnessはSales、Audit、Voice、Meeting、Workflow、Auto-Devなど、surface固有の失敗モードを検査する。
この分類は、そのまま並列実行の単位になる。横断Harness同士は同じaction snapshotを読む。部分Harnessも、それぞれのsurface contractに対して同じsnapshotを読む。互いの副作用を読まないなら、同一stage内で並列に走らせられる。
順次実行ならlatencyは足し算になる。
並列実行なら、理想的には最も遅いHarnessに近づく。
この式だけを見ると、並列化は明らかに正しい。問題は、右辺の最後にあるfan-inを軽く見た瞬間に起きる。Harnessを速く走らせることと、Harnessの結果を安全に意思決定へ変換することは、別の設計問題である。
2. 中心命題:安全は正規化されたenvelope列へのfoldにある
並列Harness設計では、fan-outとfan-inを分けて考える必要がある。
fan-outは、action snapshotを複数のHarnessへ配る。ここで欲しいのは独立性、timeout、resource cap、trace ID、episode IDである。fan-inは、それらのHarness結果を1つのruntime decisionへ畳み込む。ここで欲しいのは可換性、単調性、fail-closed、監査可能性である。
ただし、fan-inへ入れるものは生のdomain resultではない。static Harness、dynamic Harness、meeting gate、audit gate、budget check、policy checkが、それぞれ別の単位、別のscore、別のseverityを返すなら、foldは横断的に安全性を保証できない。安全性は、正規化されたenvelope列に対するfoldに宿る。
正規化関数はglue codeではない。Trusted Computing Base、つまり信頼計算基盤の一部である。domain resultをenvelopeへ写すmappingが雑なら、domainのfailがenvelopeのwarnに化ける。これはサイレントな降格であり、fold以前に安全性が壊れている。
したがって正規化mappingはmonotoneでなければならない。domainでより制限的な結果は、envelopeでもより制限的なdecisionへ写る。共通score scaleも意味を持たなければならない。全Harness横断でscore 0.5が何を意味するのかを定義しない正規化は、数字を揃えただけで意味を失う。
domain固有情報は捨てない。foldが見るのは共通フィールドだけでよい。decision、score、findings、provenance、failure kindである。observedMetrics、requiredFixes、domain payloadはopaque payloadとして同乗させる。決定では収束し、証跡では区別するためである。
並列だから安全なのではない。正規化と集約が正しいから、並列にしても安全性が壊れない。
この命題を守るために、実装上の規律は5つに分かれる。
1つ目。並列の答えは、毎回同じでなければならない。2つ目。実行できなかった検査は、合格ではない。3つ目。並列にしてよいのは、独立なものだけである。4つ目。budgetを数える場所を間違えてはいけない。5つ目。再現できない並列は、監査に耐えない。
以下では、それぞれを「素朴な実装」「どう静かに壊れるか」「規律」の順で見る。
3. 規律1:並列の答えは、毎回同じでなければならない
素朴な実装はこうなる。Harnessを配列に入れ、Promise.allで実行し、戻ってきた順にreduceする。見た目は正しい。実際、すべてが成功し、すべてのHarnessが同じ速度で、集約が順序に依存しないなら問題はない。
しかし並列実行では完了順が非決定的である。network I/O、LLM call、vector store、external policy service、cache hit、GC、schedulerの都合で、同じepisodeでも戻り順は変わる。もしfoldが順序依存なら、最終判定が揺れる。
典型的な壊れ方は、最後の結果を優先する実装である。あるHarnessがreviewを返し、別のHarnessがallowを返す。完了順によって最終結果がreviewにもallowにもなる。これはrace conditionではなく、集約関数の設計ミスである。
規律は単純だ。各Harnessの生結果を共通envelopeへ正規化し、restrictivenessに全順序を置き、最も制限的なものを選ぶ。scoreは楽観側ではなく悲観側へ畳む。つまりrestrictivenessはmax、confidenceやquality scoreはminである。これなら完了順が変わっても結果は変わらない。
type Restrictiveness =
| "allow"
| "review"
| "quarantine"
| "block"
const rank: Record<Restrictiveness, number> = {
allow: 0,
review: 1,
quarantine: 2,
block: 3,
}
type HarnessDecision = {
kind: "harness-envelope"
harnessId: string
decision: Restrictiveness
score: number
findings: string[]
provenance: {
source: "fulfilled" | "rejected" | "timeout" | "untrusted"
calibrationVersion: string
}
payload?: unknown
}
function foldDecisions(results: HarnessDecision[]): HarnessDecision {
return results.reduce((acc, current) => ({
kind: "harness-envelope",
harnessId: "fan-in",
decision:
rank[current.decision] > rank[acc.decision]
? current.decision
: acc.decision,
score: Math.min(acc.score, current.score),
findings: [...acc.findings, ...current.findings],
provenance: {
source: "fulfilled",
calibrationVersion: acc.provenance.calibrationVersion,
},
}))
}このfoldは可換に近い。少なくともdecisionとscoreについては順序非依存である。findingsの配列順だけは実行順に依存しうるので、監査ログではharnessIdでsortするか、各findingにharnessIdとstartedAtを持たせる。人間が読む順序と、機械が決める安全性を混ぜてはいけない。
重要なのは、並列化によって安全性を作ろうとしないことである。並列はただ速い。安全性は、正規化されたenvelope列と、完了順に左右されないfoldに閉じ込める。
4. 規律2:実行できなかった検査は、合格ではない
次の素朴な実装はPromise.allである。これはJavaScriptでは自然な選択に見える。しかしfail-closedなHarness runtimeでは危険である。
Promise.allは1つのPromiseがrejectした時点で全体をrejectする。すると、他のHarnessが返していたかもしれないblockやquarantineを捨てる。逆にcatchで握りつぶす実装では、rejectしたHarnessが存在しなかったかのように扱われる。どちらも危険である。
Harnessが実行できない理由はいくつもある。policy storeが落ちる。vector storeがtimeoutする。schema validationが例外を投げる。外部risk serviceが429を返す。LLM evaluatorがparse不能なJSONを返す。どれも「合格」ではない。検査できなかったという事実そのものが、制限側へ倒す根拠である。
ここでrejectとtimeoutは、同じoutcomeだが違うメカニズムである。rejectはPromiseがsettleしてerrorを返す。timeoutはまだsettleしていない。だからtimeoutはallSettledだけでは扱えない。各HarnessをPromise.raceでper-harness timeoutと競争させ、timeout側を制限的なenvelopeへ変換する。
timeout値はcalibration定数である。1つの遅いHarnessが全体のbudgetを食わないように個別に置く。さらに、policy thresholdと同じようにversionとprovenanceを持たせる。なぜそのHarnessだけ800msなのか、なぜ別のHarnessは3秒なのかを、後から説明できなければならない。
規律はallSettledを使うこと。そしてrejected、timeout、untrusted outputを、最も制限的な結果として正規化することだ。ここで背骨の2行目が初めて実装になる。
function withTimeout<T>(
task: Promise<T>,
timeoutMs: number,
): Promise<T | "timeout"> {
return Promise.race([
task,
new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), timeoutMs),
),
])
}
async function evaluateAll(
snapshot: ActionSnapshot,
harnesses: Harness[],
): Promise<HarnessDecision> {
const settled = await Promise.allSettled(
harnesses.map((harness) =>
withTimeout(
harness.evaluate(snapshot),
harness.calibration.timeoutMs,
),
),
)
const normalized = settled.map((result, index): HarnessDecision => {
const harnessId = harnesses[index].id
if (result.status === "fulfilled" && result.value !== "timeout" && isTrusted(result.value)) {
return result.value
}
return {
kind: "harness-envelope",
harnessId,
decision: "quarantine",
score: 0,
findings: ["harness_unavailable_or_untrusted"],
provenance: {
source:
result.status === "fulfilled" && result.value === "timeout"
? "timeout"
: result.status === "rejected"
? "rejected"
: "untrusted",
calibrationVersion: harnesses[index].calibration.version,
},
}
})
return foldDecisions(normalized)
}ここではblockではなくquarantineにしている。これは設計選択である。外部actionの実行直前ならblockでよい。draft generationのように副作用がない処理ならquarantineにして、人間レビューまたは再評価へ送る方が運用しやすい。重要なのは、少なくともallowにはしないことだ。
ただし、timeoutしたHarnessは裏でまだ走り続けている可能性がある。runtimeが「閉じる」と判定した後に、遅れて完了する仕事がある。この設計が安全なのは、Harnessがsnapshotを読んでenvelopeを返すだけの純関数であり、書き込み副作用を持たないからである。もしHarnessがstoreへ書くなら、timeout後に遅れて着地する副作用が事故になる。timeoutの安全性は、後で述べるsnapshotと純粋性に依存している。
決定ではtimeoutもrejectionも制限側へ収束する。しかし証跡では区別する。timedOutHarnessIdsとfailedHarnessIdsは別々に残す。timeoutは遅延、容量、外部依存の問題であり、rejectはbug、schema error、例外の問題である。outcomeを収束させても、debug情報まで潰してはいけない。
この規律は、障害時のUXを悪くするように見える。しかしagent governanceでは、検査不能のときに進めるUXの方がはるかに悪い。安全なsystemは、壊れたときに静かに楽観しない。
5. 規律3:並列にしてよいのは、独立なものだけ
3つ目の壊れ方は、依存関係の隠蔽である。横断Harnessと部分Harnessを全部まとめてfan-outする。最初は問題ない。ところがある日、authority Harnessがsurface Harnessの出力を読み始める。あるいはbudget Harnessがtool Harnessの推定costを読む。あるいはtrust Harnessがevidence Harnessのconfidenceを読む。
この瞬間、暗黙の依存辺ができる。依存があるものを同じstage内で並列実行すると、読んだり読まなかったりする。古い値を読む。空の値を読む。前回episodeの値を読む。最悪の場合、cycleができる。Harness runtimeが監査基盤であるはずなのに、監査基盤自体が非決定的になる。
規律は、依存をDAGとして宣言することだ。依存は、発見するものではなく宣言するものである。第1段では、依存宣言から実行stageを導出する。stage内は並列、stage間は順次。cycle検出はこの時点で無料で付いてくる。
第2段では、宣言した依存が読める範囲そのものを縛る。priorResultsを全結果Mapとして渡してはいけない。宣言済み依存だけを含むRecordへ絞る。これで依存宣言は順序のヒントではなく、強制力のある契約になる。
flowchart LR
S[Action Snapshot] --> A[Identity Harness]
S --> B[Authority Harness]
S --> C[Evidence Harness]
A --> D[Surface Contract Harness]
B --> D
C --> E[Budget Harness]
D --> F[Fan-in Fold]
E --> Ftype HarnessNode = {
id: string
dependsOn: string[]
evaluate: (input: {
snapshot: ActionSnapshot
priorResults: Record<string, HarnessDecision>
}) => Promise<HarnessDecision>
}
async function runHarnessDag(
snapshot: ActionSnapshot,
stages: HarnessNode[][],
): Promise<HarnessDecision> {
const decisions = new Map<string, HarnessDecision>()
for (const stage of stages) {
const settled = await Promise.allSettled(
stage.map((node) =>
node.evaluate({
snapshot,
priorResults: pick(decisions, node.dependsOn),
}),
),
)
settled.forEach((result, index) => {
const node = stage[index]
decisions.set(node.id, normalizeSettled(node.id, result))
})
}
return foldDecisions([...decisions.values()])
}この実装では、stageそのものは事前にtopological sortされている前提である。実際のruntimeでは、起動時またはdeploy時にDAGを検証する。存在しない依存を参照していないか。cycleがないか。同一stage内で互いを参照していないか。高リスクHarnessが未承認の依存を追加していないか。
pick(decisions, node.dependsOn)が重要である。Harnessは宣言していない結果を読めない。宣言グラフが真のdataflow graphになる。これにより、依存は実装の副作用から推測するものではなく、review可能なcontractになる。
並列化の単位は分類ではなく独立性で決まる。分類は良い手がかりだが、最後に信じてよいのはDAGである。依存はコメントではなくデータにする。そうしないと、コードレビューでしか見えない安全性になる。
6. 規律4:数える場所を、間違えない
budget raceは地味だが危険である。素朴な実装では、各Harnessが共有budgetを読む。自分のactionが予算内か判断する。必要ならbudgetを消費済みに更新する。単体では正しい。しかしfan-out内でこれをやると、read-decide-writeが競合する。
例えば残りbudgetが100で、3つのHarnessがそれぞれ60のcostを見積もる。全員が同時に残り100を読む。全員が「60なら通る」と判断する。最終的に180のcostが許可される。これはDB transactionの話に見えるが、Harness runtimeではもっと広い。token、tool call、external API、customer-visible action、approval capacity、human review queueもbudgetである。
規律は、budget判定をfan-outに置かないことだ。fan-out内のHarnessはcost estimate、risk estimate、resource requestを返すだけにする。fan-in後に一度だけ、正規化された合計をbudget policyへ渡す。
この設計では、個別Harnessは予算を消費しない。budget Harnessだけが、集約済みrequestを見て判定する。必要ならreservation tokenを発行する。そのtokenがないactionは実行できない。
budgetは安全性だけでなく監査にも関係する。どのHarnessがいくら見積もり、fan-inでどう合計され、どのpolicyが許可または拒否したか。この列が残っていないと、後からcost anomalyを説明できない。
実装上のポイントは、budgetをbooleanにしないことだ。allowed: trueではなく、requestedCost、reservedCost、budgetScope、reservationId、expiresAtを持つ。許可は状態ではなく、期限付きの証跡である。
7. 規律5:再現できない並列は、監査に耐えない
最後はsnapshotである。素朴な実装では、各Harnessが必要なstoreを直接読む。user state、policy state、memory、tool registry、budget ledger、evidence store、risk profileをそれぞれのタイミングで読む。これも単体では自然に見える。
しかし並列実行では、Harnessごとに読んだ世界が違うことがある。A Harnessはpolicy version 12を読み、B Harnessはpolicy version 13を読む。C Harnessはmemory write前を読み、D Harnessはwrite後を読む。あるHarnessはevidence追加前に落ち、別のHarnessは追加後に通る。このepisodeをreplayしても同じ判定にならない。
監査に耐える並列Harnessは、episode時点で凍結したsnapshotを読む純関数に近づけるべきである。すべてのHarnessが同じActionSnapshotを受け取り、そこにpolicyVersion、toolRegistryVersion、budgetLedgerVersion、evidenceRefs、memoryRefs、inputHash、createdAtを含める。
type ActionSnapshot = {
episodeId: string
actionId: string
actorId: string
mariaCoordinate: string
inputHash: string
policyVersion: string
toolRegistryVersion: string
budgetLedgerVersion: string
evidenceRefs: string[]
memoryRefs: string[]
createdAt: string
}snapshotは巨大なcopyである必要はない。多くの場合はversioned refsでよい。重要なのは、Harnessが実行中に好きなstoreを好きなタイミングで読むのではなく、episodeが固定した世界を読むことだ。
この設計にすると、Harnessは再実行できる。なぜreviewになったのかを説明できる。新しいHarnessを追加したとき、過去episodeに対してbacktestできる。人間レビューの判断とmachine decisionの差分を比較できる。
再現性は監査のためだけではない。修復のためでもある。再現できない失敗は直しにくい。直せない失敗は、学習に変換しにくい。
8. 5つは2つに畳める
ここまで5つの規律を分けて見た。しかし実際には、すべて同じ2つの原則へ畳める。
1つ目は、正規化されたenvelope列に対する順序非依存のfoldである。完了順に依存しない。stage内の実行順に依存しない。Harness数が増えても、restrictivenessは最も制限的な値へ、scoreは最も低い値へ畳む。budgetもfan-in後に一度だけ判定する。DAGも、どの結果がいつ読めるかを明示してfoldの前提を守るためにある。snapshotも、foldに入る値が同じ世界から来ることを保証するためにある。
2つ目は、実行・信頼できないものを制限側へ倒すことである。rejectしたHarnessは合格ではない。timeoutしたHarnessは合格ではない。parse不能なLLM outputは合格ではない。storeが読めなかったbudget checkは合格ではない。versionが混ざったsnapshotは合格ではない。これらはすべて、allowではなくreview、quarantine、blockへ倒す。
つまり、並列マルチハーネス設計の中心は大量のHarnessをどう走らせるかではない。大量のHarnessから返ってきた不完全で非同期で失敗しうるdomain resultを、どう正規化されたenvelopeへ変換し、どう1つの保守的なdecisionへ畳むかである。
正規化が入ると拡張性も変わる。新しいdomain Harnessを足すとは、既存foldを書き換えることではない。monotoneなnormalizerを書き、共通score scaleの意味を満たし、domain payloadを保持するenvelopeを生成することになる。foldは1つのまま増やせる。
ここでカタルシスがある。5つの規律は別々のベストプラクティスではない。全部、同じ姿勢の実装である。
安全性はfan-inに宿る。並列実行はlatency最適化にすぎない。実行・信頼できないものは、最も制限的な側へ倒す。
9. 監視基盤が、自分の哲学を体現する
この設計が面白いのは、監視基盤そのものが、監視対象に要求している哲学で動く点である。
agentにはauthority boundaryを守れと言う。ではHarness runtime自身がstoreを読めないとき、どうするのか。agentには証跡なしに進むなと言う。ではHarness自身のconfidenceが低いとき、どうするのか。agentにはbudgetを越えるなと言う。ではbudget ledgerが競合したとき、どうするのか。agentには不確実なら人間へ返せと言う。ではHarnessが実行できないとき、どうするのか。
答えは全部同じでなければならない。信頼できないなら、人間へ返す。制限側へ倒す。quarantineする。blockする。少なくともallowしない。
ただし、debug情報まで同じ箱へ潰してはいけない。決定では収束し、証跡では区別する。store failure、Harness reject、timeout、low confidence、budget exhaustionは、runtime decisionでは制限側へ収束する。しかしevidenceでは別々に残す。timedOutHarnessIds、failedHarnessIds、loopBudgetReliabilityReason、lowConfidenceFindingsは、運用上まったく別の意味を持つ。
この自己一貫性がないsystemは、時間とともに破綻する。表面上はagentに統治を要求していても、統治基盤自身が楽観的に失敗するなら、最も重要な境界はそこから漏れる。
例えばLearning Storeが一時的に読めないとする。素朴なsystemは「学習なしで進める」と判断するかもしれない。しかし過去に同じ失敗があったかもしれない。人間が同じpatchを拒否していたかもしれない。高リスクsurfaceで再発禁止になっていたかもしれない。storeが読めないことは、情報がないことではない。安全判断に必要な情報へ到達できないことである。
だから制限側へ倒す。低リスクならreviewへ、実行直前ならquarantineへ、高リスクならblockへ送る。これは保守的すぎるように見える。しかし統治できる自律では、停止や人間返却は失敗ではない。境界を守るための正常動作である。
ここで初めて、この設計が単なるHarness runtimeの実装技法ではなく、統治できる自律の一部であることが見える。agentを統治するsystemは、自分自身も同じ規律で統治されなければならない。そうでなければ、自律性は外側から見た統制の演出にすぎなくなる。
10. 速くしても壊れない、をどう設計するか
エージェント基盤を作るなら、並列化は避けられない。identity、authority、trust、evidence、budget、surface contract、quality、policy、memory、auditを毎回順番に評価していたら、runtimeは遅すぎる。latencyのためにfan-outするのは正しい。
しかし、並列は安全の設計ではない。並列はただのlatency最適化である。安全を決めるのは、fan-inである。正規化されたenvelope列に対する、完了順に依存しないfoldである。rejectやtimeoutを合格にしない姿勢である。依存をDAGとして宣言し、読めるprior resultsを縛ることである。budgetをfan-in後に一度だけ数えることである。episode snapshotでreplay可能にすることである。
この設計を守ると、Harnessを増やせる。surfaceを増やせる。agentを増やせる。新しいdomain Harnessは、foldの分岐を増やすのではなく、envelopeを生成して既存foldへcomposeする。検査を並列化しても、最終decisionは保守的に畳まれる。失敗した検査は消えず、制限側へ合成される。監査時には、同じsnapshotから同じdecisionを再構成できる。
一般化された教訓は短い。
エージェントを並列に動かすすべての人へ。並列はlatencyの最適化であって、安全の設計ではない。安全を決めるのは、常に集約と、実行できなかった時の倒し方だ。
安全性はfan-inに宿る。並列実行はlatency最適化にすぎない。
実行・信頼できないものは、最も制限的な側へ倒す。