👏

非決定論的なAI Agentを基幹システムに組み込む実践:Embabelと3層アーキテクチャの適用サンプル

に公開

こんにちは、ログラスCTOの伊藤(@itohiro73)です。

ログラス プロダクトチーム Advent Calendar 2025、ついに最終日を迎えました。
今年もログラスは、エンジニアリングの力で「良い景気を作ろう。」に一歩でも近づくために、技術的卓越性の追究と還元を意識し続けてきました。2レーンでのアウトプットの集大成を、ぜひご堪能ください。

最終日の本記事では、私がここ最近とくに重要なトピックだと考えている「非決定論的なAI Agentをエンタープライズの基幹システムに組み込むためのアーキテクチャ」についてお話しします。

エンタープライズの業務システムにおいて、データの信頼性やアウトプットの一貫性を保つことは非常に重要です。一方で、生成AIのアウトプットが持つ「ゆらぎ」を、いかにして決定論的なプログラムの規律の中に正しく落とし込んでいくか。この非決定的な要素を確かな事実へと繋いでいくプロセスは、AIを実務に組み込む上で避けては通れない重要論点だと考えています。本稿では、そのためのアーキテクチャの一案として、Embabelと3層アーキテクチャを用いた実装例を紹介します。

Embabel:非決定性と決定性を繋ぐ架け橋

以前、私はこちらの記事で、KotlinのAI Agentフレームワーク「Embabel」を紹介しました。

Embabelの本質的な価値は、単にLLMをシステムに組み込むことではありません。「LLMによる非決定論的なアウトプット」と「旧来型の決定論的なアウトプット」をワークフローとして型安全に繋ぎ合わせることにあります。

しかし、エンタープライズ領域でAIを「基幹の歯車」として動かすには、もう一歩踏み込んだ規律が必要です。それは、「AIが返した確率的なアウトプットを、いつ、どこで、どのようにして『揺るぎない事実』へと固定化するか」 という設計思想です。

非決定性を堅牢な型へと落とし込む3層アーキテクチャ

そこで今回は、Embabelが担う「非決定的なアウトプットを型に変換するプロセス」そのものに、@_knih によるログラスの記事「AI時代の3層アーキテクチャ:関数型コア・決定的シェル・非決定的エッジ」で提唱された3層アーキテクチャを適用するアプローチを実装してみました。

最も外側の Edge(非決定的エッジ) 層はLLMと接続し、非決定的な「可能性」を生成します。中間の Shell(決定的シェル) 層は副作用(DB I/O)を管理し、可能性を「事実」として固定化します。最も内側の Core(純粋関数的コア) 層は副作用を一切排除し、純粋なビジネスロジック(事前条件の検証)だけを実行します。

今回、Embabelと3層アーキテクチャの適用例として、 「入金消込(Payment Reconciliation)」 という題材を取り上げ、段階的な実装プロセスを解説します。

ユースケース:入金消込における「曖昧マッチング」の自動確定

企業の基幹業務において、銀行振込明細の依頼人名と売掛金データの顧客名を突き合わせる「消込」は、多くの企業が直面する課題の一つです。

たとえば、銀行から受け取った明細が 2024-01-15 100000 株式会社XYZカンパニー というテキストで、DB内に 株式会社XYZカンパニー という名前の顧客が存在し、その顧客に未払い請求書 ¥100,000 が紐づいているとします。人間なら「ああ、これはXYZカンパニーさんの請求だな」と分かりますが、これをプログラムでやろうとすると、表記揺れ(カ)XYZカンパニー、XYZカンパニー、XYZ Company等)や金額の端数処理の壁にぶつかります。

AIはこのような問題の解決に非常に有用といえますが、ルールベースではない一定の非構造化を伴うアウトプットに対し、ゆるいシステム統合を行ってしまうと、再現性や追跡可能性を失ってしまう恐れがあります。この「非構造化された入力」を「厳格でトラッキング可能な業務処理」へと変換するプロセスに、3層構造を段階的に適用してみます。

非決定的なアウトプットを「事実」へと固定化させる3つの層

この「確率論的なアウトプット」と「厳格なルール」という、性質の異なる二つの処理を一つのコードに混ぜてしまうと、システムのテスト可能性や保守性は一気に損なわれてしまいます。AIの出した答えが「たまたま合っていた」のか、それとも「業務ルールに照らして正しかった」のか。この境界を曖昧にしたままでは、基幹システムとしての説明責任を果たすのは難しいでしょう。

そこで、AIによる非決定的なアウトプットを、段階的に「揺るぎない事実」へと固定化させていくためのフレームワークとして、3つの層の役割を割り当てます。Edge(非決定的エッジ) 層では、AIが曖昧さを解釈し、判断根拠(証跡)と共に「可能性」を提示します。Shell(決定的シェル) 層では、その可能性を不変のスナップショットとして保存し、副作用(DB更新等)を管理します。Core(純粋関数的コア) 層では、保存されたデータが業務ルールを満たすかの厳密な検証をします。

AIの力を最大限に活かしつつ、システムの堅牢性を損なわない。この「関所」を設ける設計思想を用いて非決定的なAIを決定論的な基盤のパーツとして機能するように変化させていきます。では、この設計を具体的にどう実装へ落とし込んでいったのか。ルールベースから始まり、AI Agentによる自律化に至るまでの進化の過程を解説します。

実装の進化:ルールベースからGOAP自動連携まで

今回の実装は、4つのPhaseを経て段階的に進化させました。この段階的なアプローチは、実際のプロダクト開発における価値提供の仕方そのものです。Phase 1で基盤を固め、Phase 2で部分的なAI化によりユーザー価値を提供し、Phase 3/Phase 4でさらなる自動化を実現するというステップをとってみました。

Phase 1: ルールベース実装(3層アーキテクチャの基盤)

まず、AIを一切使わずに3層アーキテクチャの骨格を作ります。

Edge層: SimpleReconciliationMatcher

@Component
class SimpleReconciliationMatcher(...) {
    // ルールベースで明細をパース
    fun parseBankStatement(text: String): BankStatement {
        val regex = Regex("""(\d{4}[-/]\d{2}[-/]\d{2})\s+([0-9,]+)\s+(.+)""")
        // ... 正規表現でパース
    }

    // マッチング提案を生成(EdgeOutput型で証跡付き)
    fun generateMatchProposal(...): EdgeOutput<MatchProposal?> {
        return EdgeOutput(
            result = proposal,
            provenance = Provenance(
                modelName = "rule-based-matcher",
                rawResponse = reasoning,
                steps = listOf(...)  // 推論ステップを記録
            )
        )
    }
}

Shell層: ReconciliationService

@Service
@Transactional
class ReconciliationService(...) {
    fun analyzeAndReconcile(statements: List<String>): ReconciliationSnapshot {
        // 1. スナップショット作成(入力を不変化)
        val baseSnapshot = snapshotManager.createSnapshot(inputData, "bank-statements")

        // 2. Edge層で可能性を生成 → Core層で検証
        val events = statements.map { text ->
            val proposalOutput = matcher.generateMatchProposal(...)
            ReconciliationCore.calculateReconciliation(proposalOutput.result, ...)
        }

        // 3. スナップショット保存
        snapshotManager.saveSnapshot(baseSnapshot)
        return ReconciliationSnapshot(...)
    }
}

Core層: ReconciliationCore(純粋関数)

object ReconciliationCore {
    fun calculateReconciliation(
        proposal: MatchProposal,
        snapshotId: String,
        reconciledAt: Instant
    ): ReconciliationEvent {
        // 副作用なしの純粋な検証ロジック
        val amountDiff = (proposal.invoice.amount - proposal.bankStatement.amount).abs()

        return when {
            amountDiff == BigDecimal.ZERO && proposal.confidence >= 0.9 ->
                ReconciliationEvent.Reconciled(...)
            amountDiff <= tolerance && proposal.confidence >= 0.7 ->
                ReconciliationEvent.PartiallyReconciled(...)
            else ->
                ReconciliationEvent.ValidationFailed(...)
        }
    }
}

この段階で、3層アーキテクチャの「型」と「責務分離」は完成していますが、マッチング精度は正規表現の限界に縛られています。

Phase 2: パース処理のAI化(Edge層の部分的知能化)

次に、最も表記揺れが激しい「明細パース」部分だけをAI化してみます。

@Agent(description = "銀行振込明細のテキストを解析して構造化データに変換")
@Component
class BankStatementParserAgent(
    private val ai: Ai  // Embabelが注入するAIインターフェース
) {
    @AchievesGoal(description = "振込明細テキストをBankStatementオブジェクトに変換")
    @Action(description = "...")
    fun parseBankStatement(statementText: String): BankStatement {
        return ai.withDefaultLlm().createObject(
            """
            以下の銀行振込明細テキストを解析してください。

            明細テキスト: "$statementText"

            様々なフォーマットに対応:
            - 日付: "2024-01-15", "2024/01/15", "2024年1月15日"
            - 金額: "100000", "100,000", "10万円"
            - 振込人: "株式会社XYZカンパニー", "カ)XYZカンパニー", "XYZカンパニー"
            """.trimIndent(),
            BankStatement::class.java  // 型安全な変換
        )
    }
}

この段階で、「2024/01/15 10万円 カ)XYZカンパニー」 のような多様な入力に対応できるようにしています。

Phase 3: 全処理のAI化(ReconciliationAgentによる統合)

さらに進んで、パース・顧客検索・候補絞り込み・提案生成の全4ステップをAI化しました。

@Agent(description = "銀行振込明細を解析し、未払い請求書とマッチング")
@Component
class ReconciliationAgent(
    private val ai: Ai,
    private val customerRepository: CustomerRepository,
    private val invoiceRepository: InvoiceRepository
) {
    // Action 1: 明細パース
    @Action(description = "...")
    fun parseBankStatement(statementText: String): BankStatement { ... }

    // Action 2: 顧客検索(AI化)
    @Action(description = "...")
    fun findMatchingCustomer(payerName: String): Customer? {
        val allCustomers = customerRepository.findAll()

        // LLMに全顧客リストを渡して最適なマッチを選択させる
        val prompt = """
        振込人名: "$payerName"
        顧客リスト: [...]

        判定基準:
        1. 表記ゆれを考慮(「株式会社」「カ)」等の接頭辞を無視)
        2. エイリアス(別名)とのマッチングも考慮
        3. 部分一致や略称も考慮
        """

        data class CustomerMatch(val customerId: String?)
        val match = ai.withDefaultLlm().createObject(prompt, CustomerMatch::class.java)
        return match.customerId?.let { id -> allCustomers.find { it.id == id } }
    }

    // Action 3: 候補請求書絞り込み(AI化)
    @Action(description = "...")
    fun findCandidateInvoices(bankStatement: BankStatement, customer: Customer?): List<Invoice> {
        // LLMに金額フィルタリングを依頼(振込手数料差や端数処理を考慮)
        ...
    }

    // Action 4: マッチング提案生成(AI化)
    @Action(description = "...")
    fun generateMatchProposal(
        bankStatement: BankStatement,
        customer: Customer?,
        candidates: List<Invoice>
    ): MatchProposal? {
        // LLMが信頼度(0.0〜1.0)とマッチング理由を生成
        ...
    }
}

Shell層では、ReconciliationAgentの4つのActionを順次呼び出します。

private fun processWithReconciliationAgent(text: String, ...): ReconciliationEvent {
    val bankStatement = reconciliationAgent.parseBankStatement(text)
    val customer = reconciliationAgent.findMatchingCustomer(bankStatement.payerName)
    val candidates = reconciliationAgent.findCandidateInvoices(bankStatement, customer)
    val proposal = reconciliationAgent.generateMatchProposal(bankStatement, customer, candidates)

    // Core層で最終検証
    return ReconciliationCore.calculateReconciliation(proposal, snapshotId, Instant.now())
}

この実装により、「カ)XYZカンパニー」 のような略称も、「99,500円」 のような端数(振込手数料差)も、LLMの柔軟な判断でマッチできるようになりました。

Phase 4: GOAP自動連携(最適な手続き選択)

最後の進化が、GOAP (Goal-Oriented Action Planning) です。

Phase 3では、Shell層が4つのActionを「手動で順次呼び出し」していました。しかし、Embabelには 「目標(Goal)を指定すれば、必要なActionを自動的に発見・実行する」 GOAP機能が備わっています。

ReconciliationAgentに統合Actionを追加します。

@Agent(description = "...")
@Component
class ReconciliationAgent(...) {
    // ... 既存の4つのAction

    /**
     * GOAP用の統合Action: String → MatchProposal
     * GOAPが自動的にこのメソッドを選択し、内部で全4ステップを実行
     */
    @AchievesGoal(description = "明細テキストから直接マッチング提案を生成")
    @Action(description = "...")
    fun reconcileFromText(statementText: String): MatchProposal? {
        val bankStatement = parseBankStatement(statementText)
        val customer = findMatchingCustomer(bankStatement.payerName)
        val candidates = findCandidateInvoices(bankStatement, customer)
        return generateMatchProposal(bankStatement, customer, candidates)
    }
}

Shell層では、AgentInvocation APIを使って入力型と出力型だけを指定します。

private fun processWithGoapAgent(text: String, ...): ReconciliationEvent {
    logger.info("🚀 GOAP: Requesting MatchProposal")

    // 入力(String)と出力型(MatchProposal)だけを指定
    // Embabel GOAPが必要なActionを自動的に発見・実行
    val proposal = agentInvocationService.invokeAgent(
        input = text,
        outputClass = MatchProposal::class.java
    )

    // Core層で検証
    return ReconciliationCore.calculateReconciliation(proposal, ...)
}

実行ログを見ると、GOAPの動作が分かります。

21:39:46 [main] DEBUG - ✅ Plan found for goal: ReconciliationAgent.reconcileFromText
21:39:46 [main] DEBUG -   Actions: [reconcileFromText]
21:40:05 [task-1] INFO  - Found plan to goal: reconcileFromText
21:40:05 [task-1] INFO  - executing action reconcileFromText
21:40:05 [task-1] DEBUG - 🎯 GOAP Integration: reconcileFromText called
21:40:05 [task-1] DEBUG -   → Parsed: 株式会社ログラス, ¥100000
21:40:05 [task-1] DEBUG -   → Customer: 株式会社ログラス
21:40:05 [task-1] DEBUG -   → Candidates: 1 invoices
21:40:05 [task-1] DEBUG -   → Proposal: 2024-0001
21:40:19 [task-1] INFO  - executed action reconcileFromText in PT14.339S
21:40:19 [task-1] DEBUG - ✅ Process completed, achieving goal in 14 seconds
21:40:19 [tomcat-handler-0] INFO  - ✅ GOAP Success: invoice 2024-0001

Embabel GOAPは、A*アルゴリズム(グラフ探索アルゴリズムの一つ)により初期状態 {String=TRUE, MatchProposal=FALSE} からゴール {MatchProposal=TRUE} への最短パスを自動で発見し、自律的に実行しています。

Shell層は「何をするか(What)」だけを指定し、「どうやるか(How)」はEmbabel GOAPが最適な手続きを判断します。

第1層: Edge(非決定的エッジ)— 可能性を生成し、証跡を残す

Edge層の役割は、1つの答えを決め打ちせず、複数の可能性を生成し、その根拠(Provenance)を必ず残すことです。

エンタープライズの基幹業務システムにおいては「AIが何を考えてその判断をしたのか」が監査上の重要要件となっていくでしょう。例えば「〇〇年〇〇月〇〇日時点でのシステムの判断根拠」を再現できる状態をつくることが肝要となります。

ReconciliationAgentの各Actionは、以下の構造で証跡を返します。

data class EdgeOutput<T>(
    val result: T?,              // AIによる提案(nullの場合もある)
    val provenance: Provenance   // 証跡:なぜそう判断したか
)

data class Provenance(
    val modelName: String,           // 使用したモデル(例: "claude-sonnet-4-5")
    val prompt: String,              // LLMに送ったプロンプト
    val rawResponse: String,         // LLMの生成文
    val steps: List<ReasoningStep>,  // 推論ステップ
    val timestamp: Instant
)

たとえば、findMatchingCustomer の実行結果には、振込人名の正規化、顧客リストとの照合、信頼度スコアリングといった推論ステップが含まれ、「なぜそのマッチングが選ばれたのか」 が完全に追跡可能になります。

EdgeOutput(
    result = Customer(id = "CUST001", name = "株式会社ログラス"),
    provenance = Provenance(
        modelName = "claude-sonnet-4-5",
        prompt = "振込人名: \"カ)ログラス\" ...",
        rawResponse = "表記ゆれを考慮した結果、顧客ID: CUST001が最も一致します。",
        steps = listOf(
            ReasoningStep(1, "振込人名の正規化", "\"カ)ログラス\"\"ログラス\""),
            ReasoningStep(2, "顧客リストとの照合", "3件の候補を発見"),
            ReasoningStep(3, "信頼度スコアリング", "CUST001が最高スコア0.95")
        ),
        timestamp = Instant.now()
    )
)

この証跡により、AIの「ブラックボックス性」を、エンジニアリングの規律で透明化できます。

第2層: Shell(決定的シェル)— 可能性を「事実」に固定化し、副作用を管理する

Shell層の役割は、非決定的な可能性を、Content-Addressed Storageにより不変のスナップショット(事実)として固定化することです。

Content-Addressedとは、同じ入力には必ず同じID(ハッシュ)が割り当てられる仕組みで、システムの再現性と追跡可能性を保証します。

@Service
class SnapshotManager(
    private val snapshotRepository: SnapshotRepository
) {
    fun <T> createSnapshot(data: T, contentType: String): Snapshot {
        // JSON正規化
        val normalizedJson = normalizeJson(data)

        // Blake3でIDを生成(Content-Addressed)
        val hash = computeBlake3Hash(normalizedJson)

        // ID計算にはデータ本体(normalizedJson)のみを使用し、時刻は属性として保持する
        return Snapshot(
            id = hash,
            payload = normalizedJson,
            contentType = contentType,
            createdAt = Instant.now()
        )
    }

    fun saveSnapshot(snapshot: Snapshot): Snapshot {
        // 同じIDが既に存在する場合は既存のものを返す(冪等性)
        return if (snapshotRepository.exists(snapshot.id)) {
            snapshotRepository.findById(snapshot.id)!!
        } else {
            snapshotRepository.save(snapshot)
        }
    }
}

ReconciliationServiceでは、入力をスナップショット化(不変化)し、各明細を処理(Edge → Core)した後、スナップショットを保存します。

fun analyzeAndReconcile(statements: List<String>, ...): ReconciliationSnapshot {
    // 1. 入力をスナップショット化(不変化)
    val inputData = statements.joinToString("\n")
    val baseSnapshot = snapshotManager.createSnapshot(inputData, "bank-statements")
    logger.info("スナップショット作成: ${baseSnapshot.id}")

    // 2. 各明細を処理(Edge → Core)
    val events = statements.mapIndexed { index, text ->
        processStatement(text, baseSnapshot.id, index)
    }

    // 3. スナップショット保存
    snapshotManager.saveSnapshot(baseSnapshot)

    return ReconciliationSnapshot(id, inputData, events, ...)
}

一度スナップショットとして保存された「事実」は、Content-Addressedな性質により、もう二度と変わりません。AIの「揺らぎ」が、この瞬間に 「システムが受理した履歴」 へと固定化されます。

また、Shell層は副作用の実行も担当します。

@Service
class EffectExecutor(private val invoiceRepository: InvoiceRepository) {
    fun execute(event: ReconciliationEvent): EffectResult {
        return when (event) {
            is ReconciliationEvent.Reconciled -> {
                // DB更新(副作用)
                val invoice = invoiceRepository.findById(event.invoiceId)
                invoice.status = InvoiceStatus.PAID
                invoiceRepository.save(invoice)
                EffectResult.Success("Invoice ${event.invoiceId} marked as PAID")
            }
            else -> EffectResult.Skipped("No effect")
        }
    }
}

重要なのは、Core層が「何をすべきか(What)」を決定し、Shell層が「実際に実行する(How)」 という責務分離です。

第3層: Core(純粋関数的コア)— ビジネスロジックを事前条件で守る

Core層の役割は、副作用を一切持たず、純粋な関数として事前条件を検証することです。

副作用(DB、API、時刻)が混入すると、テストにモックが必要になり、バグの温床となります。そのため、テスタビリティ、再現性、予測可能性を最大化することを目指します。

object ReconciliationCore {
    fun calculateReconciliation(
        proposal: MatchProposal,
        snapshotId: String,
        reconciledAt: Instant
    ): ReconciliationEvent {
        val invoice = proposal.invoice
        val bankStatement = proposal.bankStatement
        val confidence = proposal.confidence

        // 事前条件1: 請求書が未決済か?
        if (invoice.status != InvoiceStatus.UNPAID) {
            return ReconciliationEvent.ValidationFailed(
                reason = ValidationFailureReason.INVALID_INVOICE_STATUS,
                details = "Invoice ${invoice.invoiceNumber} is already ${invoice.status}"
            )
        }

        // 事前条件2: 金額が許容範囲内か?
        val amountDiff = (invoice.amount - bankStatement.amount).abs()
        val tolerance = invoice.amount.multiply(BigDecimal("0.01"))  // 1%

        // 事前条件3: 信頼度が基準を満たすか?
        return when {
            amountDiff == BigDecimal.ZERO && confidence >= 0.9 -> {
                ReconciliationEvent.Reconciled(...)  // 完全一致
            }
            amountDiff <= tolerance && confidence >= 0.7 -> {
                ReconciliationEvent.PartiallyReconciled(...)  // 端数差あり
            }
            else -> {
                ReconciliationEvent.ValidationFailed(...)  // 基準未達
            }
        }
    }
}

Core層は、同じ入力には必ず同じ出力を返す完全な決定論性、DBアクセスや時刻取得などの副作用ゼロ、モックなしで単体テスト可能なテスト容易性、そしてsealed classで全ケースを網羅する型安全性を備えています。

どれほどAIがもっともらしい提案(Edge)をしても、業務ルールを定義した「関数型コア(Core)」の検閲をパスしない限り、DBが更新されることはありません。AIの柔軟性と、システムの厳格性を両立させるのが、この責務分離です。

アーキテクチャの「関所」:3つの境界変換

この設計の肝は、層の間に設けられた厳格な「境界」です。

1. Edge → Shell の境界(非決定性の固定化)

LLMの出力には本質的な「揺らぎ」があります。同じ入力を与えても、毎回異なる結果を返す可能性があるのです。これは生成AIの特性である一方、エンタープライズシステムでは致命的な問題になり得ます。

この課題を解決するために、Content-Addressed Storageによるスナップショット化という手法を採用しました。入力をハッシュ化してIDとし、このIDで保存されたスナップショットは不変とすることで、同じ入力には同じIDが割り当てられ、同じスナップショットが生成される冪等性を保証します。

この仕組みにより、システムが受理した提案は永久に記録されます。たとえば「2024年1月15日にシステムが受理した提案」という情報は、ハッシュIDと共にDBに保存され、後日の監査においても「その時点でのAIの判断」を完全に再現できます。さらに、同じ明細を再処理した場合でも、スナップショットIDが同じであれば重複保存されることはありません。Content-Addressedな性質により、システムの冪等性が自然に保たれるのです。

2. Shell → Core の境界(副作用の分離)

ビジネスロジックに副作用(DB、API、時刻)が混入すると、システムは急速にテストが困難になり、バグの温床となります。この問題を避けるため、ShellとCoreの間には厳格な責務分離を設けています。

Shellがすべてのデータを取得し、Coreに「差し入れる」というパターンを採用しました。Shell層でデータを取得し、Core層に渡すことで副作用をなくし、Core層の判断に基づいてShell層が副作用を実行します。

// Shell層でデータを取得
val invoice = invoiceRepository.findById(proposal.invoice.id)
val reconciledAt = Instant.now()

// Core層に渡す(副作用なし)
val event = ReconciliationCore.calculateReconciliation(
    proposal = proposal,        // Edge層の出力
    snapshotId = snapshotId,    // Shell層で生成したID
    reconciledAt = reconciledAt  // Shell層で取得した時刻
)

// Core層の判断に基づき、Shell層が副作用を実行
if (executeEffects) {
    effectExecutor.execute(event)
}

Core層は「数学的な正しさ」だけを返します。これにより、テストではモックDBが不要になり、純粋関数として検証可能になります。またビジネスルールの変更がDBアクセス層に影響することもなく、関心の分離が完璧に保たれます。

3. GOAP による境界の抽象化(最適な手続き選択)

Shell層が「どのActionをどの順序で呼ぶか」を手動で記述すると、複雑度が増大し、Actionの追加・削除に対して脆弱なコードになってしまいます。この課題に対する解が、Embabelが提供するGoal-Oriented Action Planning(GOAP)です。

GOAPでは「何をしたいか(What)」だけを指定すれば、「どうやるか(How)」はフレームワークが自動的に判断します。Phase 3では手動で4ステップを呼び出していましたが、Phase 4ではゴールだけを指定することで、Embabel GOAPが内部でA*アルゴリズムにより最適パスを探索します。

// Before (Phase 3): 手動で4ステップ呼び出し
val bankStatement = agent.parseBankStatement(text)
val customer = agent.findMatchingCustomer(bankStatement.payerName)
val candidates = agent.findCandidateInvoices(bankStatement, customer)
val proposal = agent.generateMatchProposal(bankStatement, customer, candidates)

// After (Phase 4): ゴールだけ指定
val proposal = agentInvocationService.invokeAgent(
    input = text,
    outputClass = MatchProposal::class.java
)

Shell層のコードは「What」だけを記述する簡潔なものになります。Actionの追加・削除は、GOAPによって自動的に最適化されるため、システムの拡張性が大幅に向上します。システムが「どうやるか」に対する最適な手続きを選択・判断することで、メンテナンス性とスケーラビリティが飛躍的に改善されます。

GOAPは単なる「便利機能」ではなく、AIの知能を「手続き的な制御」から解放し、宣言的な目標達成へと昇華させるための本質的なアーキテクチャパターンです。

終わりに:AIを「エンジニアリング」の制御下に置く

AI Agentを活用したアプリケーションは、ともすればブラックボックス性が高く、なぜその結果が出たのかの説明責任を果たすのが難しい実装になりがちですが、そのような実装はエンタープライズ向けの基幹業務アプリケーションにおいては致命的になり得ます。

Embabelが提供する「非決定性と決定性の接合」というコンセプトの上に、この3層アーキテクチャという「規律」を乗せる。そうすることで、AIの持つ「知能」を、システムの「決定論的なワークフローのパーツ」として安全に組み込むことができます。

今回の実装では、Embabelのフレームワークと3層アーキテクチャのアイデアを組み合わせることで、ルールベースから部分AI化、完全AI化、そしてGOAP自律化へと段階的な進化が可能であることを示しました。Edge(可能性生成と証跡)、Shell(固定化と副作用)、Core(純粋検証)という責務の明確化により透明性が確保され、GOAPによる最適な手続き選択がメンテナンス性とスケーラビリティを向上させます。そしてProvenance(証跡)とContent-Addressed Storageにより、監査可能性、再現性、テスタビリティというエンタープライズグレードの保証が実現できることを実証しました。

この3層アーキテクチャは、非決定的なAIを決定論的なシステムに組み込むための一つのパターンとして機能します。Edge層で「可能性」を生成し、Shell層で「事実」に固定化し、Core層で「ビジネスルール」を守る。この3つの責務を厳格に分離することで、AIの知能とシステムの厳格性を両立させることができます。

この記事が、AIをエンタープライズシステムに組み込もうとしているエンジニアの皆さんにとって、一つの道標となれば幸いです。技術的な知見を共有し、コミュニティ全体でより良い設計・実装を追求していくことで、「良い景気を作ろう。」の一助となれば幸いです。

参考リンク

株式会社ログラス テックブログ

Discussion