AI時代の3層アーキテクチャ:関数型コア・決定的シェル・非決定的エッジ
はじめに:もしもAIが、あなたの会社の在庫を「-400個」にしたら?
想像してみてください。あなたが導入した最新のLLM(大規模言語モデル)ベースの需要予測システムが、稼働数日後に暴走を始めたら...。
在庫管理システムを見ると、異常な発注が実行され、DB上の在庫が「-400個」というあり得ない数値になっています。慌てて原因を調査すると、LLMが提案した発注数を、検証なしにそのままデータベースに書き込んでいたことが判明しました。
実在庫は100個しかないのに、AIは「500個発注すべき」と提案します。この提案が検証をすり抜けて実行され、在庫がマイナスを突き破ってしまいました。
さらに事態をややこしくしたのは、その「再現性のなさ」でした。
障害対応チームが原因究明のため、AIに同じデータで再実行をかけると、今度は「300個」という、最初とは異なる提案が返ってきました。さらに翌週、利用していたAIモデルのベンダー側(クラウド側)でサイレント・アップデート(※)が走り、今度は同じ入力に対して「700個」と提案されるようになりました。
(※ AIモデルは、品質向上のために提供者側で日々アップデートされており、同じ入力でも昨日と今日で出力が変わることがあります)
実行タイミングやモデルのバージョンによって提案内容が変わり、障害の根本原因の特定が困難になりました。システムの根幹であるべき「在庫は0以上(stock >= 0)」というルール(不変条件)が守られなかったこと。そして、その原因となったAIのロジックが「揺らい」でいたこと。
問題の本質は、AIの曖昧な「可能性」と、システムの厳密な「確定性」をごちゃ混ぜにしてしまったことにあります。
LLMの提案は、あくまで確率的な「未来の可能性」の一つに過ぎません。対して、データベースの記録は、揺らいではいけない「過去・現在の事実」です。この2つを区別せずに扱うと、AIの『揺らぎ』がシステム全体に伝染し、予測不可能な挙動を引き起こしてしまうのです。
これまでのシステム設計は、良くも悪くも「決定的」であることを前提としていました。同じ入力には同じ出力が返り、エラーは再現可能でした。しかしAI時代では、この「非決定性」がシステムの中核に入り込んできます。
本稿では、こうしたAI時代の基幹システムを安全に構築するために私が考えている、3層アーキテクチャの設計アプローチを紹介します。これは、システムの性質を「非決定的(な性質)」「決定的(な性質)」「純粋(な性質)」という3つに明確に分離し、AIの『揺らぎ』をシステムのコアに伝染させないようにする設計パターンです。
3つの「世界」の問題
AIシステムを安全に作ろうとすると、本質的に異なる3つの「世界」を扱っていることに気づきます。問題は、これらを混在させてしまうことです。
1. 予測できない「非決定的な世界」 (AIや外部API)
まずは、LLMの提案生成のような「非決定的な世界」です。同じ入力でも、実行タイミングや環境によって違う答えが返ってくることがあります。
これはバグではありません。冒頭の事故で触れたAIモデル自体のサイレント・アップデートによっても起こりますし、AIが本質的に持つ確率的な性質によるものでもあります。
ユーザーの「在庫を補充して」という曖昧な自然言語入力や、刻一刻と変わる外部APIのレスポンスも同様です。この世界は「可能性の空間」であり、単一の答えではなく、複数の候補が存在します。この不確実性を無視することが、冒頭の事故の始まりでした。
2. 揺らいではいけない「決定的な世界」 (DB書き込みやイベントログ)
次に、データベースへの書き込みやイベントソーシングの履歴のように、「決定的な世界」があります。ここでは、同じ入力(コマンド)に対して、常に同じ結果(イベント)が記録されなければなりません。
ここで重要なのは、副作用がないという意味ではないことです。DBへの書き込みは副作用ですが、そのプロセスは「決定的」で、再現可能で、監査可能である必要があります。
3. 計算が常に一致する「純粋な世界」 (ビジネスロジック)
最後に、副作用を一切持たない、数学的な関数のような「純粋な世界」です。在庫がマイナスにならないか(stock >= 0)をチェックするロジックや、状態遷移の計算がここにあたります。
純粋な関数は、DBを読んだり、現在時刻を取得したりしません。すべての情報は引数で渡され、結果は戻り値として返されます。同じ引数なら、いつ、何度実行しても、必ず同じ結果が返ってきます。
混在の危険性
これらの世界をごちゃ混ぜにすると、先ほどの事故のように、AIの『揺らぎ』がシステム全体に伝染していきます。AIの提案がそのままDBに書き込まれ(「非決定」と「決定」の混在)、ビジネスロジックの途中でAIを呼び出し(「純粋」と「非決定」の混在)、システム全体が予測不可能になってしまうのです。
3層アーキテクチャ: Functional Core, Deterministic Shell, Non-deterministic Edge
この「3つの世界」の問題に対応するため、アーキテクチャも3つの層に分離します。
-
Non-deterministic Edge(非決定的エッジ): 最も外側。AIや外部APIと接続し、非決定的な「可能性」を生成します。
-
Deterministic Shell(決定的シェル): 中間層。DB書き込みなど決定的な「副作用」を管理します。
-
Functional Core(純粋関数的コア): 最も内側。純粋なビジネスロジック(不変条件の検証など)だけを実行します。

そして、依存関係は必ず内側(Core)に向かうようにします。CoreはShellやEdgeを知りません。
層と層の間には、明確な「境界」を設けます。
-
Edge → Shellの境界(非決定性の固定化): AIが生成したフワフワした「可能性」(複数の提案)を、揺るぎない「事実(スナップショット)」としてDBに書き込み、固定化します。
-
Shell → Coreの境界(副作用の分離): DBアクセスなどの「副作用」をShell側に隔離し、Coreには計算に必要な純粋なデータだけを渡します。
これらは「こういうルールにしましょう」という口約束ではなく、できれば型システムなどで強制したい、厳密な境界です。
なぜ3層も必要なのか?
「なぜわざわざ3層も? 2層ではダメなのか?」と思うかもしれません。これは、先ほど挙げた3つの本質的に異なる性質(非決定性、副作用、純粋性)に、それぞれ専用の場所を与えるためです。
もし「非決定層」と「決定層」の2層だけだとどうなるでしょう? おそらく「決定層」の中に、DBアクセス(副作用)とビジネスロジック(純粋であるべき)が混在してしまいます。これでは、結局ビジネスロジックのテストが困難になり、ロジックの正しさを保証できません。
既存アーキテクチャとの違い
この考え方はClean Architectureなどと似ていますが、重要な違いがあります。
このアーキテクチャが従来と少し違うのは、LLMが吐き出すような**『複数の可能性のリスト(Array<Proposal>)』を、システムの第一級の入力として正面から扱う「Edge層」を定義した**点です。
そして、その『揺らぎ』を、先ほどの「非決定性の固定化」という境界で厳密にスナップショットに変換(固定化)する。この点に新しさがあると考えています。
第1層: Non-deterministic Edge (非決定的エッジ)
〜 可能性を生成する「クリエイティブ」層 〜
さて、最初の層である「Edge」から見ていきましょう。
この層の唯一の役割は、LLMや外部APIといった「予測不可能な相手」と会話し、複数の「可能性」を生成することです。ここはシステムの中で唯一、非決定的な『揺らぎ』が許される場所です。
// Edge層が返すべきデータのイメージ
type EdgeOutput<T> = {
value: T; // AIによる提案内容 (例: { item: "A", quantity: 500 })
provenance: Provenance; // この提案に至った「証跡」
};
// 証跡 (Provenance) の中身
type Provenance = {
execution_id: string; // 実行ID
model: string; // "gpt-4o", "claude-3-opus" など
temperature: number;
timestamp: string;
decision_trace: { // 重要なのはここ
tools_used: string[]; // "inventory_api" など
documents_referenced: string[]; // "rule_v2.1" など
rationale_summary: string; // PIIを含まない決定根拠
};
input_hash: string; // 入力のハッシュ
};
// Edge層の実行クラスのイメージ
class DemandForecastEdge {
async generateProposals(context: Context): Promise<Array<EdgeOutput<OrderProposal>>> {
// LLMを呼び出し、複数のシナリオを生成 (temperature > 0)
// 例: [
// { value: { quantity: 300 }, provenance: {...} }, // 堅実な提案
// { value: { quantity: 700 }, provenance: {...} } // 積極的な提案
// ]
return proposals;
}
}
この層で重要なポイントがいくつかあります。
-
DBに書き込まない: Edgeは提案するだけです。状態を「確定」させる判断は、ここでは行いません。
-
必ず「証跡(Provenance)」を残す: 「なぜAIがこの結論に至ったか」を必ず記録します。どのモデルを使ったか、どのルールを参照したか、などです。
-
複数の可能性を返す: 1つの「完璧な答え」を求めません。「堅実な案」「積極的な案」のように複数の候補を返すことで、次の層が判断材料を持てるようになります。
ここで非常に悩ましいのが「ログ」の扱いです。
AIの生の推論ログ(Reasoning Steps)をすべて保存したくなりますが、そこには顧客名などのPII(個人識別情報)や機密情報が混入するリスクが常につきまといます。
そのため私自身は、生のログは原則として保存せず、PIIなどを除外して 「要約された決定トレース(rationale summary)」のみを証跡として記録する アプローチを推奨します。
このEdge層に、temperature設定、確率的サンプリングといった、AI特有の「非決定性のカオス」をすべて閉じ込めます。
第2層: Deterministic Shell (決定的シェル)
〜 可能性を「事実」に変える「マネージャー」層 〜
Edge層が「クリエイティブ」なら、Shell層は「マネージャー」です。Edgeが生み出したフワフワした「可能性」を受け取り、それを揺るぎない「事実」として固定化し、実行を管理します。
Shellには大きく3つの役割があります。
1. 可能性を「スナップショット」として固定化する
Shellが最初に行う最も重要な仕事は、Edgeから受け取った非決定的な提案を、「決定的」なデータに変換することです。これを「Stabilize(固定化)」と呼ぶことにします。
具体的には、提案内容のスナップショットを生成し、Append-Only(追記専用)なストレージに保存します。
// Shell層の中核機能
private async stabilize<A>(data: A): Promise<Snapshot<A>> {
// RFC 8785 (JCS) に従ってJSONを正規化(キーをソート)
const canonical = jcs(data);
// 高速なハッシュ関数 (BLAKE3) でハッシュ値を計算
const hashBytes = blake3(canonical);
const id = Buffer.from(hashBytes).toString('hex');
const snapshot = {
id: id, // ★ ハッシュ値そのものをIDにする
value: data,
timestamp: new Date().toISOString(),
hash: id
};
// このスナップショットをDBに書き込む
await this.snapshotStore.append(snapshot);
return snapshot;
}
ここでのちょっとした工夫は、スナップショットのIDをUUIDなどではなく、 JSONの内容を正規化(JCS)した上でのハッシュ値(BLAKE3推奨)そのものにすること です。これにより、同じ内容の提案が来たら必ず同じIDが生成され、重複実行の防止にも役立ちます。
一度スナップショットとして書き込まれた「事実」は、もう二度と変わりません。
2. 検証し、承認する
スナップショットとして固定化したら、次に「どの提案を採用するか」を検証・承認します。
ここでよくある間違いは、AIが提示する「確信度(Confidence Score)」を鵜呑みにしてしまうことです。AIの「自信」は、必ずしも「正しさ」を意味しません。
ここではビジネスルールとの整合性や、過去のデータとの一貫性を評価し、リスクが高いと判断されれば「Human-in-the-Loop (HITL)」、つまり人間の承認を介在させます。
3. I/Oを実行する(Coreの指示に従って)
最後に、ShellはDBへの書き込みやイベントの発行といった「副作用(I/O)」を実行します。
ただし、Shellは「何を」書き込むかを自分で決めません。 「現在の状態」と「採用された提案」を次のCore層に渡し、「どうすべきか?」と尋ねます。そして、Coreが返してきた純粋な結果(「このイベントを発行せよ」「この状態を保存せよ」)を受け取って、Shellが代わりに実行します。
データは「Shell → Core → Shell」と往復するのです。
第3層: Functional Core (純粋関数型コア)
〜 ビジネスロジックを守る「金庫室」 〜
いよいよシステムの心臓部、「Functional Core」です。 この層のルールはただ一つ。副作用を起こさないことです。
Coreは、数学の関数のように「純粋」でなければなりません。
- データベースにアクセスしない
- LLMや外部APIを呼び出さない
- ログを出力しない
- 現在時刻さえも取得しない(引数で渡してもらう)
// Core層が受け取る純粋なデータ
type PureInput = {
current_state: State; // 現在の状態
command: OrderProposal; // 実行すべきコマンド
rules: BusinessRules; // 適用すべきルール
timestamp: string; // ★ 時刻も引数で受け取る
};
// Core層が返す純粋な結果
type PureOutput = {
next_state: State; // 次の状態
events: Event[]; // 発行すべきイベント
validation: ValidationResult; // 検証結果
};
// Core層の実装(完全に純粋)
class InventoryCore {
validate(input: PureInput): PureOutput {
// ★ 不変条件の検証 (stock >= 0)
if (input.current_state.stock + input.command.quantity < 0) {
return { validation: { ok: false, error: "INSUFFICIENT_STOCK" }, ... };
}
// ビジネスルールの検証
if (...) { ... }
// 状態遷移の計算(純粋)
const next_state = { ... };
// イベントの生成(純粋)
const event = { ... };
return { next_state, events: [event], validation: { ok: true } };
}
}
冒頭の事故で破られた「在庫はマイナスにならない(stock >= 0)」という最も重要な不変条件は、この「金庫室」の中で、純粋なロジックとして検証されます。
なぜ、ここまで徹底して純粋性にこだわるのでしょうか?
それは、圧倒的な「テストの容易さ」のためです。Core層のテストでは、データベース接続やAPI呼び出しといった、セットアップが面倒な**「副作用」のモックが一切不要になります**。
もちろん、ロジックが複雑になれば、内部で呼び出す他の純粋な関数を「スタブ(固定値を返すだけの簡単なモック)」に差し替えることはありますが、それも純粋なデータのやり取りに過ぎません。ただ引数(Input)を与え、戻り値(Output)が期待通りかを検証するだけで、ビジネスロジックの正しさを保証できるのです。
また、状態を共有しないため、レースコンディションの心配もなく、並行実行も安全です。
念には念を:データベース制約との連携
「CoreでチェックしているからDBは大丈夫」と考えるのは危険です。 Core層での検証に加え、データベース層でも CHECK (stock >= 0) のような制約をかけることを強く推奨します。
これは「ベルトとサスペンダー(二重の備え)」の考え方です。万が一アプリケーションのバグやAIの暴走でCoreのチェックをすり抜けても、最後の砦であるデータベースが不正な状態を防いでくれます。
アーキテクチャの「関所」:2つの重要な境界変換
さて、3つの層を分離しましたが、データは層と層の間を移動する必要があります。この「境界」こそが、このアーキテクチャの肝です。
境界1:Stabilize (AIの「可能性」を「事実」に変える)
これは、カオスな「Edge(非決定的層)」から、秩序ある「Shell(決定的層)」への入り口にある「関所」です。
-
役割: Edgeが生成したフワフワした複数の「可能性」を受け取り、改ざん不可能な「スナップショット(事実)」としてDBに永久保存します。
-
型シグネチャ(イメージ):
IO(Array<Proposal>)→IO(Snapshot<Proposal>)
// 再掲:Shell層の中核機能
async function stabilize<A>(data: Array<A>): Promise<Snapshot<Array<A>>> {
// 1. JSONを「正規化」する
// (キーを辞書順にソートし、空白を詰める)
const canonical = jcs(data); // RFC 8785 (JCS)
// 2. 高速なハッシュ関数 (BLAKE3) でハッシュ値を計算
const hashBytes = blake3(canonical);
const id = Buffer.from(hashBytes).toString('hex');
// 3. ハッシュ値そのものをIDとして、DBに保存する
const snapshot = {
id: id,
value: data,
timestamp: new Date().toISOString(),
hash: id
};
await snapshotStore.append(snapshot); // 追記専用ストレージへ
return snapshot;
}
技術的なポイントは2つです。
-
JSONの正規化 (JCS): 同じ内容のJSONでも、キーの順序が違うだけでハッシュ値は変わってしまいます。それを防ぐため、ハッシュ化する前に必ずキーをアルファベット順にソートします。
-
ハッシュ値そのものをIDにする: UUIDなどを使わず、内容のハッシュ(BLAKE3推奨)をそのままIDにします。これにより「同じ内容の提案」は必ず「同じID」を持つことになり、重複実行の防止にも役立ちます。
この「関所」を通過した瞬間、**AIの「揺らぎ」**は消え去ります。それはもはや「可能性」ではなく、システムが公式に受理した、監査可能な「事実(スナップショット)」となるのです。
境界2:Purify (副作用を「金庫室」の手前で分離する)
これは、「Shell(決定的層)」から「Core(純粋な金庫室)」へ入るための、最後のセキュリティチェックです。
Coreは「純粋」でなければならず、DBアクセスや時刻取得などの「副作用」を持ち込めません。そこで、Shellが「お膳立て」をします。
-
役割: Coreが必要とするデータを「先に」副作用(I/O)で集め、純粋なデータだけをCoreに渡す。
-
型シグネチャ(イメージ):
(IO<A>, (A) => B) => IO<B>
これは型シグネチャで見ると難解ですが、やっていることは**「衛兵と金庫室の番人」**の比喩で考えると簡単です。
-
衛兵(Shell)が外の世界で仕事する: 衛兵(
Shell)が、まず副作用(IO)を使って「現在のDBの状態(A)」を読み込みます。io_action: () => Promise<State> -
衛兵が番人にデータだけ渡す: 衛兵は「金庫室(
Core)」の中には入れません。入り口で、金庫室の番人(pure function)に、さっき取ってきた純粋な「データ(A)」だけを渡します。pure_function: (data: State) => ValidationResult -
番人(Core)が中で計算する: 番人(
pure function)は、金庫室の中で純粋な計算だけを行い、「計算結果(B)」を衛兵に返します。番人は決して外(DB)に触りません。 -
衛兵が結果を外の世界に反映する: 衛兵は、番人から受け取った「計算結果(
B)」を使って、外の世界で「DBに書き込む(IO)」という次の副作用を実行します。
データが「Shell → Core → Shell」と往復しているのがポイントです。
この「副作用を外側(Shell)に追い出し、純粋な計算(Core)を分離する」という構造は、まさにFunctional Core, Imperative Shellの設計思想そのものであり、IOモナドや後述するEffect型のようなアプローチの核心でもあります。
さらに言えば、この「解釈を差し替える」という構造は、圏論(Category Theory)における「自然変換(Natural Transformation)」の考え方と通じるものがあります。Coreの純粋なロジック(F[_])はそのままに、Fの解釈を「本番用の非決定的な実装(IO)」と「テスト用の決定論的な実装(IdやState)」とで自在に差し替える—その理論的な背景となる考え方です。この厳密な分離こそが、Edge層の非決定性をコントロール下に置く鍵となります。
どうやって「純粋さ」を強制するのか?
「言うは易し」ですが、どうやってCoreの開発者がうっかり new Date() や console.log を書くのを防ぐのでしょう?
-
Promise: 正直、
Promiseを使っているだけでは防げません。コードレビューと規律に頼ることになります。 -
Effectの場合: Effect は、この「うっかり」を型システムで解決します。Effect という型が、「そのコードが副作用を持つかどうか」を型レベルで明確に区別してくれるのです。
もし純粋であるべき関数(pure function)が、うっかり副作用を含むコード(Effect)を返そうとすると、型が一致しないため、コンパイルエラーとして実行前に検出できます。
このように、アーキテクチャのルールを開発者の「気合」や「注意力」の問題から、「コンパイラによる自動的な強制」へと変えてくれる点—それこそが、Effectのようなライブラリ、ひいては型システムの真価です。
他の分野での使い道
この「Edge / Shell / Core」の分離は、在庫管理システム以外にも、AIが絡む多くの「高信頼性」が求められる領域で役立つはずです。
-
カスタマーサービスチャットボット
-
Edge: 複数の応答候補を生成。「攻撃的な回答」「丁寧な回答」「ユーモラスな回答」など。
-
Shell: 候補をスナップショット化。不適切表現(NGワード)やブランドガイドライン違反がないか検証。
-
Core: 検証済みの回答と「現在の会話状態」を純粋に組み合わせて、「次の会話状態」と「発行すべきイベント」を計算する。
-
-
コンテンツ(記事や画像)生成システム
-
Edge: 複数の記事ドラフトや画像を生成。
-
Shell: スナップショット化。著作権侵害のチェック、法的要件(景表法など)の検証、承認ワークフロー(HITL)の実行。
-
Core: 「承認済み」イベントを受け取り、コンテンツのバージョンを純粋にインクリメントし、公開可能な状態に遷移させる。
-
-
異常検知・アラートシステム
-
Edge: 「これは異常かもしれない」という複数のシグナルを非決定的に検出。
-
Shell: シグナルをスナップショット化。過去のアラートと照合し、既知の誤検知(メンテナンスなど)を除外。
-
Core: 「本当に異常」と判断されたイベントを受け取り、アラートルール(「3回発生したらP1」など)に基づいて純粋に状態を更新し、「P1アラート発行」イベントを返す。
-
とはいえ、どこから手をつければ?(段階的導入の道筋)
このアーキテクチャは理想的ですが、かなり厳格です。既存の巨大なシステムにいきなりすべてを適用するのは現実的ではありません。
もし導入するなら、以下のステップで段階的にシステムの改善を進めることを推奨します。
ステップ1: まずは「金庫室」を掃除する (Coreの純粋化)
最初の一歩は、既存のビジネスロジックから「副作用」をできる限り取り除くことです。DBアクセスやAPI呼び出しがロジックの途中に散らばっているのを、関数の外側に追い出します。
これだけでも、システムの「見通し」が劇的に良くなります。何より、何より、データベース接続やAPI呼び出しといった厄介な副作用のモックが不要になり、ロジック単体での単体テストが格段に容易になるため、開発チームはすぐにその恩恵を感じられるはずです。
- 完了の目安: Coreロジックのテストカバレッジが向上し、テストコードから副作用に関するモックが減り、純粋なスタブ(固定値を返すだけの簡単な関数)だけでテストが完結する割合が増えること。
ステップ2: 「監査ログ」を整備する (Shellの決定的記録)
次に、Shell層の「記録」を強化します。すべての状態変更を、必ず「イベント」として記録するようにします(イベントソーシング)。また、AIや外部からの入力を「スナップショット」として保存します。
これにより、システムに「何が起こったか」を完全に追跡できるようになります。「なぜこの在庫数になったのか?」を、イベントログを再生するだけで100%再現できる状態を目指します。
- 完了の目安: すべての状態変更が、イベントログから説明可能になること。
ステップ3: AIという「猛獣」を檻に入れる (Edgeの分離)
AIのロジックがシステムのあちこちに点在しているなら、それを「Edge」という一つの層に隔離します。LLMの呼び出しは、すべてこの「檻」の中でのみ行われるようにします。
そして、その檻の「外」には、必ず「証跡(Provenance)」付きのデータしか出せないようにします。これで、AIの『揺らぎ』がシステムの他の部分に伝染するのを防ぎます。
- 完了の目安: LLM呼び出しコードが
Edgeディレクトリ(あるいはモジュール)に集約されること。
ステップ4: 「型」で境界を強制する (最終仕上げ)
最後は、これら層の間の「境界」を、Effectのような型システムを使って強制することです。
ステップ1〜3は、いわば「設計上のルール」であり、レビューや開発者の規律に依存します。ステップ4は、そのルール違反を「コンパイルエラー」にするための最終仕上げです。学習コストはかかりますが、これによりアーキテクチャの堅牢性が飛躍的に高まります。
まとめ: AIの「揺らぎ」を「信頼」に変えるために
この記事は、「AIが在庫を-400個にしてしまった」という悪夢のようなシナリオから始まりました。
AIは本質的に「非決定的」で、「揺らぎ」を持っています。そのAIの「可能性」を、そのまま「確定的」であるべき基幹システムに繋いでしまえば、システム全体がカオスに陥るのは当然です。
提案する「Edge / Shell / Core」の3層アーキテクチャは、この問題を解決するための設計パターンです。
-
Edge がAIの「揺らぎ」をすべて引き受け、
-
Shell がその「可能性」を「事実」として固定化し、
-
Core が「純粋な金庫室」でビジネスの不変条件を守る。
この「決定論的なシステム」と「非決定論的なAI」をいかに安全に「接合」するか、という課題は、本稿の3層アーキテクチャに限らず、業界全体における普遍的な関心事です。
例えば、Springフレームワークの作者が開発する「Embabel」というOSSも、まさにこの「接合点」に焦点を当てています(参考記事)。EmbabelがGOAPという決定論的な計画アルゴリズムを用いてAIの非決定性を制御しようとするアプローチは、本稿とは手段が異なりますが、根底にある「AIの揺らぎをいかに制御下に置くか」という課題意識を共有している好例と言えるでしょう。
このアーキテクチャの導入は、正直に言って簡単ではありません。しかし、これにより「AIの予測不可能性」と「ビジネスの信頼性」という、一見矛盾する2つの要求を両立させることができます。
AIがどれだけクリエイティブな提案をしようとも、それがビジネスの根幹を揺るがすことはありません。なぜなら、すべての提案は「スナップショット」として監査され、「純粋なロジック」によって検証されてから、初めて「決定的な事実」として記録されるからです。
AIの「可能性」を、ビジネスの「信頼性」へ。このアーキテクチャが、そのための堅牢な基盤になれば幸いです。
(補足) 現実的なトレードオフと注意点
この設計は銀の弾丸ではありません。実装する前に、いくつか現実的な注意点があります。
この設計は「やりすぎ」か?(導入コスト)
この問いへの答えは、ドメインによりけり、です。
-
採用を推奨するドメイン: 在庫管理、金融取引、医療記録、法務システムなど、監査証跡の完全性と不変条件の厳格な保護が求められる基幹システム
-
慎重に検討すべきドメイン: MVP(最小実用製品)のプロトタイプ、速度が最優先されるリアルタイム推薦、A/Bテスト基盤など
この厳格な分離は、高い学習コストと実装コストを伴います。システムの「信頼性」が「開発速度」を上回る場合にのみ、採用を検討してください
「型システム」の限界と現実
記事中ではEffectを推奨しましたが、TypeScriptの標準的な Promise だけでは、Core層での new Date() のような「うっかり副作用」をコンパイル時に防ぐことはできません。
Effect は非常に強力ですが、チーム全体の学習コストがかかります。まずは「設計上の規律」として始め、Lintルールなどで補完し、最終的に型システムでの強制を目指すのが現実的な道筋かもしれません。
スナップショットの「正規化」は必須
Shell層でスナップショットのハッシュ値をIDにすると書きましたが、これはJSONのキーの順序をソートする「正規化(RFC 8785 JCS)」が必須です。これを忘れると、同じ内容でもハッシュ値が異なってしまいます。
一番ややこしい「承認待ち」の話
この記事では、Shell層の「検証・承認(Human-in-the-Loop)」をさらっと流しましたが、現実にはここが一番複雑です。
AIの提案を人間が承認するまでには、数分、あるいは数日かかるかもしれません。その承認待ちの状態をどう管理するか? タイムアウトしたらどうするか?
この非同期なワークフロー管理(Sagaパターンやステートマシン)は、それ自体が巨大な設計トピックであり、この記事のスコープ外です。実装時には、TemporalやZeebeのようなワークフローエンジンの導入も合わせて検討する必要があるでしょう。
Discussion