Go + クリーンアーキテクチャで AI エージェント基盤を再設計した話【前編】

に公開

AIエージェントの開発を一気に行っていたのでそのまとめとなる記事を書いてみようと思います。
エージェント開発のSDKやノーコードツールは充実してきていますが、MVPとしてバックエンドとして使ってる Go でライブラリなどをあまり使わず開発を進めた結果、ほぼ自前で実装することになったので一からのAIエージェント開発ということで参考になるかと思います。

1. はじめに:なぜ Go で AI エージェント基盤を作るのか

AI エージェントの実装といえば、一般には Python が選ばれます。
LangChain / LangGraph や LlamaIndex など、強力なエージェントフレームワークのサポートが充実しているからです。
ではなぜ筆者は、Go で、しかも(ほぼ)自前の AI エージェント基盤を一から構築したのか?
その理由は以下の 3 点に集約されます。

既存のバックエンドが Go で統一されていた

まずビジネス的背景として、アプリケーション全体のバックエンドが Go で構築されていました。
MVP開発としては、新しくPythonコンテナ立ち上げてデプロイやレビュー体制のコストが増えるよりは、まず Go で完結させようと考えました。

また、Go にはAIエージェント基盤を構築するのに向いている特性が意外とあることにも気づきました。

  • シンプルな並行処理(goroutine)
  • SSE / WebSocket / gRPC の実装が軽い
  • 単一バイナリで運用が楽
  • interface による抽象化が明快
  • テスタビリティが高い
    Pythonよりも実装コストが少し上がりますが、その分のメリットもあるのかなと思われます。

なぜ LangGraph や LlamaIndex ではなく自前か?

これもMVPで一旦開発始めちゃったということが大きな理由なのですが、以前のアプリでAIエージェントを構築したとき、LangGraph Platoform を使った結果メッセージのエクスポートがサポートされていなかったり、フレームワークに合わせて実装する必要があったりと意外と自由度が低かったという理由もあります。
一般的にフレームワークは便利ですが、以下のような問題があります:

  • 内部状態管理がブラックボックス化しやすい
  • データ永続化やスレッド管理をフレームワーク都合に合わせる必要がある
  • LLM やツールを独自仕様に縛られる
  • 「部分的にだけ使う」という運用がしにくい
  • エクスポート・ロギング・監査に制約がある

AIエージェントを作るとなったときに意外とそのフレームワークの中で使って便利だと思うものは限られているという経験から、自前でも問題ないしブラックボックスができるよりはすこし工数かけて長期的に管理が楽なものにしたいと考えました。

Go × クリーンアーキテクチャが AI エージェントと相性が良い

AIエージェントは変更が激しい領域になると思います。

  • 開発中にモデル(GPT / Claude / Gemini)が頻繁に変わる
  • ツールの追加・削除が日常的
  • メモリ戦略(Buffer / Summary / DB)が変化
  • ストリーミング仕様を SSE→WebSocket→gRPC と変えたい
  • チェーン / DAG / Planner-Executor など手法がどんどん更新される

ここに Clean Architecture が刺さると思っています。具体的には:

  • Model:LLM 実装(OpenAIModel / AnthropicModel / LocalModel)
  • Memory:履歴管理(InMemory / DB / Redis)
  • Tool:外部 API を抽象化(検索・DB 取得・計算)
  • Agent:エージェントの振る舞い(ReAct / Workflow)
  • Streaming:SSE / WebSocket / gRPC を interface で抽象化

これらを interface に切ることで、

  • Model を OpenAI→Claude に差し替えても Application が壊れない
  • Tool の追加・削除が 1 ファイルで収まる
  • Memory を DB 永続化に変えても Agent のコードが変わらない
  • Streaming を SSE から WebSocket に変えても同じ Usecase が流用できる

という 変更容易性・テスタビリティ・保守性を最大化 できると考えています。

2. クリーンアーキテクチャと AI エージェントの前提知識

AI エージェント基盤の再設計を理解するために、まずはこの記事で扱う 2 つの要素を整理します:

  • クリーンアーキテクチャ(Clean Architecture)
  • AIエージェント(LLM-based Agent)

これらの共通点は「抽象化と依存方向を制御することで、変化に強いシステムを作る」というところにあります。
クリーンアーキテクチャに関しては以下の記事がわかりやすいです。
https://zenn.dev/sre_holdings/articles/a57f088e9ca07d

2-1. クリーンアーキテクチャの基本と、Go との相性

クリーンアーキテクチャは、「システムを同心円状のレイヤに分割し、依存方向を内向きに限定する」アーキテクチャスタイルです。
典型的には次の 4 層に分割されます:

  • Presentation Layer
    HTTP や CLI、gRPC など I/O の責務
  • Application Layer
    ユースケースやワークフロー制御。外部 API や DB への直接依存は持たない
  • Domain Layer
    ビジネスルール、エンティティ、振る舞いの中心
  • Infrastructure Layer
    DB・外部 API クライアント・フレームワーク依存の実装

本質は「上位のルールが、下位の詳細に依存しないようにする」ことです。
有名な図👇
クリーンアーキテクチャの図

Go とクリーンアーキテクチャは相性が良いです。
Go は interface ベースで抽象化が容易なうえ、依存が明確で interface -> struct の依存置き換えが自然にかけるなどの特徴があり、クリーンアーキテクチャと非常に相性がいいです。

特に AI エージェント基盤は、

  • Model(OpenAI/Claude/ローカル LLM)
  • Memory(In-memory / DB / Redis)
  • Tool(任意の外部 API)
  • Agent(ReAct / Workflow / Planner-Executor)

など、「実装を差し替える箇所」が多いため、Go の interface による抽象化が活きます。

2-2. AIエージェントとは何か

AIエージェントは、単なるチャットボットではなく、複数の能力を持った実行主体として設計されます。
典型的な構成要素は以下の通りです:

(1) LLM(推論エンジン)

GPT や Claude、Gemini などの大規模言語モデル。
プロンプトとコンテキストを元に、Toolの呼び出しやReAct(Reason and Action)などを行って回答を返します。

(2) Tool / Function Calling

LLM が外界にアクセスするための手段です。Tool は取得情報の粒度や使用目的などの明確化が重要になると思います。
例:

  • API 呼び出し
  • DB 検索
  • 計算
  • Web スクレイピング
  • RAG 用ベクトル検索

LLM が Tool 使用の指示を出し、実際の実行はアプリ側が担当します。(MCPなどの話もありますが今回は割愛します。)

(3) Memory(会話・状態管理)

AI エージェントが複数ターンの会話やタスク実行を扱うためには”状態管理”が必要です。
よくある種類:

  • ConversationBuffer(直近メッセージ保持)
  • WindowMemory(N件だけ保持)
  • SummaryMemory(要約して長期メモリ化)
  • DB Memory(永続化して履歴を残す)

AIエージェントは LLM の限られたコンテキストウィンドウの中にいかに質が良くて無駄のない情報を詰め込めるかの勝負な気がするので、メモリの設計が品質に直結します。

(4) 外部データアクセス(RAG / DB / API)

現実のタスクでは、LLM 内部の知識だけでは処理できません。
Tool と関わってきますが、RAG(Retrieval Augmented Generation)や DB アクセスを通じて外部データと連携する必要があります。
この「データとのインターフェース」も抽象化しておくと LLM 変更に強くなります。

2-3. クリーンアーキテクチャ × AI エージェントが強力な理由

AI エージェント開発は、実装の変化が激しい領域です。

  • モデルが頻繁に更新される(GPT → Claude → Gemini → ローカル LLM…)
  • RAG の構造が変わる
  • ツールが追加・削除される
  • ストリーミング手段が API によって異なる
  • メモリ戦略を試行錯誤する

これらの“変更”に耐えられるようにするには、
AI エージェントのコア概念(Model / Memory / Tool / Agent)を抽象化し、外部への依存を Infrastructure に閉じ込めることが重要
というのがこの記事全体の主張です。

ここまでで前提がそろったので、次章では「実際にどう設計したか」「Before/After で何が変わるのか」を詳しく解説していきます。

3. Before:/api/ai/chat 一枚岩アーキテクチャの限界

まずは、最初に動いていた 「とりあえず動く」AI エージェント API の構成を振り返ります。
当時はとにかく MVP として素早く価値を出したかったので、

  • エンドポイントは /api/ai/chat の 1 本だけ(テキトーすぎ)
  • Handler の中でプロンプトを組み立ててそのまま OpenAI に投げる
  • 返ってきたレスポンスを SSE でフロントに流す

という、超シンプルな構成からスタートしました。
結論からいうと、この構成は「最初の一歩」としては十分役に立ちましたが、継続的にエージェントを育てていくには厳しい構造でした。

3-1. 当初の構成:1本のエンドポイントに全部詰め込む

ざっくり図にすると、最初の構成はこんなイメージです。

POST /api/ai/chat
    ↓
[Presentation]
    ChatHandler
      ├─ リクエストバインド
      ├─ MessageBuilder(プロンプト構築)
      ├─ ToolRegistry(ツール登録)
      └─ ReactAgent(実行)
    ↓
[Application]
    ReactAgent(具体実装)
      ├─ ChatUsecase(OpenAI SDK 直呼び)
      ├─ ToolRegistry(具体実装)
      ├─ MessageBuilder(具体実装)
      └─ AgentState(状態管理)
    ↓
[Domain]
    PromptService / ContextFetcher 等
    (ビジネスロジック + AI 用の補助処理)
    ↓
[Infrastructure]
    OpenAI Client(config/openai.go)
    PostgreSQL Repository(アプリ本体用)

ざっくりいうと、

  • HTTP レイヤ(Handler)から見て「ReactAgent という箱」を叩くだけ
  • ReactAgent の中で
    • プロンプト構築
    • ツール登録
    • LLM 呼び出し
    • ストリーミング
      までを全部やっている

という構造です。
とりあえず動かすには最高で、コード量も少なく、1つのファイルを追えば全体の挙動が分かり、デバッグもしやすいというメリットがありました。

一方で、「AI エージェントをちゃんとプロダクトの一機能として育てていく」となると、
どこに何の責務を持たせるのかが曖昧になってきます。

3-2. 実際に直面した問題たち

この一枚岩構成を続けていると、具体的に次のような問題が見えてきました。

(1) OpenAI SDK に強く依存していた

Application 層のChatUsecaseが OpenAI SDK に直依存しており、モデルを変えたい(gpt-4o → claude-3.5 やローカル LLM)と思っても、

  • Application 層
  • Agent 実装
  • エラーハンドリング

など、広い範囲に影響が出てしまいます。
「Model を差し替える」というより、システムに OpenAI が埋め込まれている 状態でした。

(2) Tool が具体実装とベタ結合していた

ToolRegistryと各 Tool が Application 層に密結合しており、Tool の実装内で直接 Repository を触ったり、LLM 用の型に依存していたりしていました。
その結果:

  • Tool 単体のユニットテストを書きづらい
  • 別のエージェントで再利用しにくい
  • MCP や他のツールプロトコルに載せ替えるのも難しい

のような問題がありました。

(3) SSE 前提のストリーミングで拡張しづらい

実装が最初から SSE 前提で書かれていたため、
「WebSocket で返したい」、「gRPC のストリーミングに乗せたい」となったときに、Handler 〜 Agent までのコードをかなり触る必要があります。(まだそのような要件はありませんが...)
とにかく、ストリーミングの詳細がアーキテクチャ層の中まで染み込んでしまっている状態でした。

(4) テストが書きづらい

Application 層が OpenAI SDK / Tool 実装 / メモリ実装にベタ依存していたので、何か1つモック化しようとしても、他の依存が芋づる式についてきます。
結果として、結合テスト中心になりがちで、Agent の振る舞いを小さく検証するのが難しいという問題が発生します。
エージェントのロジック自体は複雑なので、本来はModel / Memory / Tool を完全にモックして挙動だけ確認したいのですが、その構造になっていませんでした。

3-5. 次に目指したゴール:エージェントをクリーンアーキテクチャに“乗せ替える”

一枚岩の /api/ai/chat から得られた一番の学びは、LLMを叩くコードそのものより、Model / Memory / Tool / Agent / Streaming といった構成要素をどう分解するか、そしてそれらをどのレイヤに置き、何に依存させるかが重要だということです。
次章では、この一枚岩アーキテクチャをどのようにクリーンアーキテクチャ上に再配置し、

  • Model 抽象
  • Memory 抽象
  • Tool 抽象
  • Agent 抽象
  • Streaming 抽象

をどのように pkg/ai と各レイヤに分割していったのかを、実際の構成やコード断片を交えながら解説していきます。

4. After:クリーンアーキテクチャで再設計した AI エージェント基盤

ここからは、実際にどうやって「クリーンアーキテクチャに乗せ替えたか」を、コードを交えながら見ていきます。

  • pkg/ai に AI コアの抽象を集約し、
  • Application / Domain / Infrastructure / Presentation にきれいに分離した

ディレクトリの構成のイメージは以下のとおりです:

pkg/ai/           … AI 用の共通ライブラリ(抽象+一部の汎用実装)
  agents/         … Agent 抽象 & ReactAgent
  memory/         … Memory 抽象
  models/         … Model 抽象
  openai/         … OpenAI 向け Model 実装
  prompts/        … 汎用 Prompt 抽象
  streaming/      … StreamEvent / StreamWriter 抽象
  tools/          … Tool / ToolRegistry 抽象
  types.go        … Message / ToolCall / TokenUsage など

application/
  ai/
    tools/        … アプリ固有の Tool 群(SearchGoals など)
  usecases/     … AgentRunUsecase / ThreadUsecase / ThreadChatUsecase

infra/
  ai/
    memory/       … PostgreSQLMemory(DB バックエンド)
    tools/        … DefaultToolRegistry
    streaming/    … SSEWriter など

presentation/
  handlers/ai/
    thread_handler.go  … /api/v1/ai/threads/:id/chat などの HTTP ハンドラ

その他依存注入(DI)やdomain/modelsなど

以降では、

  1. pkg/ai のコア抽象
  2. OpenAI 実装
  3. Memory の永続化
  4. Tool / ToolRegistry
  5. ReactAgent(ReAct パターン)
  6. Usecase / Handler からの呼び出し

という順に見ていきます。

4-1. pkg/ai:AI 向けコア抽象レイヤ

最初にやったのは、AI に関する関心ごとだけを 1 つのパッケージに集約することでした。

  • モデル(Model)
  • メモリ(Memory)
  • ツール(Tool / ToolRegistry)
  • エージェント(Agent)
  • ストリーミング(StreamEvent / StreamWriter)

といった概念を pkg/ai にまとめておくことで、

  • Application 層からは この抽象だけを見る
  • OpenAI / Anthropic / ローカル LLM などへの依存は pkg/ai/openai などに閉じ込める

という構造になります。
このレイヤの役割は LangChain に近いイメージです。
https://tmc.github.io/langchaingo/docs/concepts/architecture

4-2. Model 抽象とpkg/ai/openaiの役割

まずは LLM への依存を Model interface で隠します。

pkg/ai/models/model.go
package models

import (
    "context"

    "github.com/~/pkg/ai"
    "github.com/~/pkg/ai/streaming"
    "github.com/~/pkg/ai/tools"
)

// ModelFactory は Model を生成するファクトリです。
// (どのプロバイダを使うかはここで差し替えられる)
type ModelFactory interface {
    NewModel(registry tools.ToolRegistry) Model
}

// Response は LLM からの応答を表します。
type Response struct {
    Content      string
    ToolCalls    []ai.ToolCall
    FinishReason string
    Usage        ai.TokenUsage
}

// Model は LLM とのやりとりを抽象化します。
type Model interface {
    Generate(
        ctx context.Context,
        messages []ai.Message,
        opts ...ai.ModelOption,
    ) (*Response, error)
}

// StreamingModel はストリーミング対応の Model です。
type StreamingModel interface {
    Model
    GenerateStream(
        ctx context.Context,
        messages []ai.Message,
        opts ...ai.ModelOption,
    ) (<-chan streaming.StreamEvent, error)
}

ポイントは:

  • Application / Agent はModelインターフェースだけを見る
  • ModelFactoryによって、使うプロバイダ(OpenAI / Anthropic / ローカル LLM)を差し替えられる
  • ストリーミング版はStreamingModelとして別インターフェースに

OpenAI実装はpkg/ai/openaiに分離

OpenAI SDK に依存するコードは、すべてpkg/ai/openaiに隔離しました。

pkg/ai/openai/model.go
pkg/ai/openai/model.go
package openai

import (
    "context"
    "encoding/json"
    "fmt"

    openai "github.com/openai/openai-go/v3"

    "github.com/~/pkg/ai"
    "github.com/~/pkg/ai/models"
    "github.com/~/pkg/ai/streaming"
    "github.com/~/pkg/ai/tools"
)

// Model は OpenAI 用の Model 実装です。
type Model struct {
    client       openai.Client
    toolRegistry tools.ToolRegistry
}

func NewModel(client openai.Client, registry tools.ToolRegistry) *Model {
    return &Model{
        client:       client,
        toolRegistry: registry,
    }
}

// Generate は非ストリーミングな Chat Completion を実行します。
func (m *Model) Generate(
    ctx context.Context,
    messages []ai.Message,
    opts ...ai.ModelOption,
) (*models.Response, error) {
    cfg := ai.DefaultModelConfig()
    for _, opt := range opts {
        opt(&cfg)
    }

    // ai.Message → OpenAI 用メッセージに変換
    openAIMessages := m.convertMessages(messages)

    modelName := cfg.Model
    if modelName == "" {
        modelName = "gpt-5.1"
    }

    params := openai.ChatCompletionNewParams{
        Model:    openai.ChatModel(modelName),
        Messages: openAIMessages,
    }

    // 温度やトークン数などのオプションを設定
    if cfg.Temperature > 0 {
        params.Temperature = openai.Float(float64(cfg.Temperature))
    }
    if cfg.MaxTokens > 0 {
        params.MaxCompletionTokens = openai.Int(int64(cfg.MaxTokens))
    }

    // Tool 定義を OpenAI に渡す
    if m.toolRegistry != nil {
        tools := m.toolRegistry.List()
        // tools → params.Tools に変換(JSON Schema ベース)
        // (詳細は省略)
        _ = tools
    }

    resp, err := m.client.Chat.Completions.New(ctx, params)
    if err != nil {
        return nil, fmt.Errorf("failed to call OpenAI API: %w", err)
    }

    // OpenAI の Response → models.Response に詰め直す
    r := &models.Response{
        Content: resp.Choices[0].Message.Content,
    }

    // Tool 呼び出しがあれば ai.ToolCall に変換
    // (実装は省略)
    return r, nil
}

// StreamingModel も実装している
func (m *Model) GenerateStream(
    ctx context.Context,
    messages []ai.Message,
    opts ...ai.ModelOption,
) (<-chan streaming.StreamEvent, error) {
    // ChatCompletion の Streaming API を叩いて
    // streaming.StreamEvent のチャネルに変換する
    // (実装は省略)
    return nil, nil
}

var _ models.StreamingModel = (*Model)(nil)
  • pkg/ai/modelsはあくまで抽象のみ
  • OpenAI SDK に依存するのはpkg/ai/openaiだけ
  • ToolRegistryから JSON Schema を取得してparams.Toolsに詰めることで、「Tool 抽象 ↔ OpenAI function calling」 の橋渡しをしている

という分離にしています。

4-3. Memory 抽象と PostgreSQL バックエンド

会話履歴はpkg/ai/memoryで抽象化し、実装を後ろに隠します。

pkg/ai/memory/memory.go
package memory

import (
    "context"

    "github.gom/~/pkg/ai"
)

// Memory は会話履歴の管理を抽象化するインターフェースです。
type Memory interface {
    LoadHistory(ctx context.Context, opts ...ai.MemoryLoadOption) ([]ai.Message, error)
    Save(ctx context.Context, msg ai.Message) (*ai.StoredMessageInfo, error)
    Clear(ctx context.Context) error
}

PostgreSQL 実装:infra/ai/memory/PostgreSQLMemory

実際の永続化ロジックは Infrastructure 層に追い出しています。

infra/ai/memory/postgresql_memory.go
infra/ai/memory/postgresql_memory.go
package memory

import (
    "context"
    "time"

    "github.com/~/domain/models"
    "github.com/~/domain/repositories"
    "github.com/~/pkg/ai"
    memory "github.com/~/pkg/ai/memory"
)

// PostgreSQLMemory は Postgres を使った Memory 実装です。
type PostgreSQLMemory struct {
    threadID              string
    agentID               *string
    messageRepo           repositories.MessageRepository
    userMessageVisibility models.MessageVisibility
}

func NewPostgreSQLMemory(
    threadID string,
    agentID *string,
    repo repositories.MessageRepository,
) memory.Memory {
    return &PostgreSQLMemory{
        threadID:              threadID,
        agentID:               agentID,
        messageRepo:           repo,
        userMessageVisibility: models.MessageVisibilityVisible,
    }
}

// LoadHistory は Thread のメッセージ履歴を ai.Message に変換して返す。
func (m *PostgreSQLMemory) LoadHistory(
    ctx context.Context,
    opts ...ai.MemoryLoadOption,
) ([]ai.Message, error) {
    cfg := ai.DefaultMemoryLoadConfig()
    for _, opt := range opts {
        opt(&cfg)
    }

    dbMessages, err := m.messageRepo.FindByThreadID(ctx, m.threadID)
    if err != nil {
        return nil, err
    }

    msgs := make([]ai.Message, 0, len(dbMessages))
    for _, dbMsg := range dbMessages {
        if dbMsg.Role == "system" {
            continue // system は毎回動的に差し込むので除外
        }

        toolCallID := ""
        if dbMsg.ToolCallID != nil {
            toolCallID = *dbMsg.ToolCallID
        }

        msgs = append(msgs, ai.Message{
            Role:       dbMsg.Role,
            Content:    dbMsg.Content,
            ToolCalls:  dbMsg.ToolCalls,
            ToolCallID: toolCallID,
        })
    }

    if cfg.LimitMessages > 0 && len(msgs) > cfg.LimitMessages {
        return msgs[len(msgs)-cfg.LimitMessages:], nil
    }

    return msgs, nil
}

// Save は ai.Message を domain.Message に変換して DB に保存する。
func (m *PostgreSQLMemory) Save(
    ctx context.Context,
    msg ai.Message,
) (*ai.StoredMessageInfo, error) {
    index, err := m.messageRepo.GetNextMessageIndex(ctx, m.threadID)
    if err != nil {
        return nil, err
    }

    var toolCallID *string
    if msg.ToolCallID != "" {
        toolCallID = &msg.ToolCallID
    }

    visibility := models.MessageVisibilityVisible
    if msg.Role == "system" || msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) {
        visibility = models.MessageVisibilityInternal
    } else if msg.Role == "user" {
        visibility = m.userMessageVisibility
    }

    dbMsg := &models.Message{
        ThreadID:     m.threadID,
        AgentID:      m.agentID,
        Role:         msg.Role,
        Content:      msg.Content,
        MessageIndex: index,
        ToolCalls:    msg.ToolCalls,
        ToolCallID:   toolCallID,
        Visibility:   visibility,
        CreatedAt:    time.Now(),
    }

    saved, err := m.messageRepo.Create(ctx, dbMsg)
    if err != nil {
        return nil, err
    }

    return &ai.StoredMessageInfo{
        ID:           saved.ID,
        ThreadID:     saved.ThreadID,
        Role:         saved.Role,
        MessageIndex: saved.MessageIndex,
        Visibility:   string(saved.Visibility),
        CreatedAt:    saved.CreatedAt,
    }, nil
}

func (m *PostgreSQLMemory) Clear(ctx context.Context) error {
    return m.messageRepo.DeleteByThreadID(ctx, m.threadID)
}
  • Agent から見ると あくまで Memory インターフェース
  • 「DB にどう保存するか」「 visibility をどう扱うか」は Infrastructure に閉じ込められているので、後から Redis や in-memory 実装と差し替えやすい構造になっています。

4-4. Tool / ToolRegistry:外部世界へのインターフェース

Tool 周りも同じくpkg/ai/toolsで抽象化しています。

pkg/ai/tools/tool.go
package tools

import (
    "context"
    "encoding/json"
)

// Tool は AI から呼び出される「関数」を表すインターフェースです。
type Tool interface {
    Name() string
    Description() string
    JSONSchema() map[string]any
    Call(ctx context.Context, args json.RawMessage) (any, error)
}

// ToolRegistry は Tool の登録・検索・実行を担います。
type ToolRegistry interface {
    Register(tool Tool) error
    Get(name string) (Tool, error)
    List() []Tool
    Execute(ctx context.Context, name string, args json.RawMessage) (any, error)
}

DefaultToolRegistry(infra層)でRegistry実装

infra/ai/tools/default_tool_registry.go
infra/ai/tools/default_tool_registry.go
package tools

import (
    "context"
    "encoding/json"
    "fmt"
    "sync"

    aito "github.com/~/pkg/ai/tools"
)

type DefaultToolRegistry struct {
    tools map[string]aito.Tool
    mu    sync.RWMutex
}

func NewDefaultToolRegistry() *DefaultToolRegistry {
    return &DefaultToolRegistry{
        tools: make(map[string]aito.Tool),
    }
}

func (r *DefaultToolRegistry) Register(tool aito.Tool) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.tools[tool.Name()]; exists {
        return fmt.Errorf("tool already registered: %s", tool.Name())
    }
    r.tools[tool.Name()] = tool
    return nil
}

func (r *DefaultToolRegistry) Get(name string) (aito.Tool, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    t, ok := r.tools[name]
    if !ok {
        return nil, fmt.Errorf("tool not found: %s", name)
    }
    return t, nil
}

func (r *DefaultToolRegistry) List() []aito.Tool {
    r.mu.RLock()
    defer r.mu.RUnlock()

    res := make([]aito.Tool, 0, len(r.tools))
    for _, t := range r.tools {
        res = append(res, t)
    }
    return res
}

func (r *DefaultToolRegistry) Execute(
    ctx context.Context,
    name string,
    args json.RawMessage,
) (any, error) {
    t, err := r.Get(name)
    if err != nil {
        return nil, err
    }
    return t.Call(ctx, args)
}

-> アプリ固有の Tool をapplication層で行う

このように、

  • Tool の抽象は pkg/ai/tools
  • ToolRegistry の実装は infra/ai/tools
  • アプリ固有の Tool 本体は application/ai/tools

に分けることで、Tool 自体は Application / Domain に寄せて書きつつ、OpenAI との function calling 部分は pkg/ai/openai に任せる、という分担ができています。

4-5. ReactAgent:ReAct パターンを Clean Architecture 上に載せる

エージェント本体はpkg/ai/agentsに置いています。

pkg/ai/agents/agent.go
package agents

import (
    "context"

    "github.com/~/pkg/ai/streaming"
)

// Agent は自律的な AI エージェントを抽象化したものです。
type Agent interface {
    Run(ctx context.Context, input string) (string, error)
    RunStream(ctx context.Context, input string, w streaming.StreamWriter) error
    Name() string
}

ReactAgent のコンストラクタ

pkg/ai/agents/react_agent.go
package agents

import (
    "context"
    "time"

    "github.com/~/pkg/ai"
    "github.com/~/pkg/ai/memory"
    "github.com/~/pkg/ai/models"
    "github.com/~/pkg/ai/streaming"
    "github.com/~/pkg/ai/tools"
)

type ReactAgent struct {
    model    models.Model
    registry tools.ToolRegistry
    memory   memory.Memory
    config   *AgentConfig
    // 停止条件などの状態は別 struct に切り出し
}

func NewReactAgent(
    model models.Model,
    registry tools.ToolRegistry,
    mem memory.Memory,
    cfg *AgentConfig,
) *ReactAgent {
    // iteration 上限 / timeout / tool 呼び出し回数などの停止条件もここで構成
    return &ReactAgent{
        model:    model,
        registry: registry,
        memory:   mem,
        config:   cfg,
    }
}

func (a *ReactAgent) Name() string {
    return "react_agent"
}

// RunStreamを書いていく...

RunStreamはかなり長いので、流れだけ抜粋します。

pkg/ai/agents/react_agent.go
pkg/ai/agents/react_agent.go
func (a *ReactAgent) RunStream(
    ctx context.Context,
    input string,
    writer streaming.StreamWriter,
) error {
    // 1. これまでの履歴を Memory から取得
    messages, err := a.memory.LoadHistory(ctx)
    if err != nil {
        return fmt.Errorf("failed to load history: %w", err)
    }

    // 2. System Prompt を動的に先頭に差し込む
    if a.config.SystemPrompt != "" {
        system := ai.Message{
            Role: "system",
            Content: map[string]any{
                "text": a.config.SystemPrompt,
            },
        }
        messages = append([]ai.Message{system}, messages...)
    }

    // 3. ユーザーの入力をメッセージとして追加し、Memory に保存
    userMsg := ai.Message{
        Role: "user",
        Content: map[string]any{
            "text": input,
        },
    }
    messages = append(messages, userMsg)
    _, _ = a.memory.Save(ctx, userMsg)

    // 4. ループで ReAct を回す(LLM → Tool → LLM ...)
    for {
        // 停止条件チェック(iteration / timeout / 進捗など)
        // ...

        // モデルが StreamingModel かどうかで分岐
        streamingModel, ok := a.model.(models.StreamingModel)
        if !ok {
            // 非ストリーミング版 Generate の処理(省略)
            // ...
        }

        // 4-1. LLM をストリーミングで呼び出し
        ch, err := streamingModel.GenerateStream(ctx, messages,
            ai.WithModelMaxTokens(a.config.MaxTokens),
            ai.WithTemperature(a.config.Temperature),
            ai.WithModel(a.config.Model),
        )
        if err != nil {
            return fmt.Errorf("failed to generate stream: %w", err)
        }

        var toolCalls []ai.ToolCall
        var collectedText string
        var hasContent bool

        for event := range ch {
            if event.Type == streaming.EventTypeError {
                return fmt.Errorf("stream error: %v", event.Data["error"])
            }

            if event.Type == streaming.EventTypeToolStart {
                // OpenAI 側から送られてきた ToolCall 情報を復元
                // event.Data["tool_calls"] → []ai.ToolCall に変換(省略)
            }

            if event.Type == streaming.EventTypeTextDelta {
                hasContent = true
                if delta, ok := event.Data["delta"].(string); ok {
                    collectedText += delta
                }
                // そのままクライアントにストリーミング
                _ = writer.Write(ctx, event)
            }
        }

        // 4-2. ToolCall がある場合は Tool 実行へ
        if len(toolCalls) > 0 {
            // assistant メッセージとして ToolCall を履歴に追加・保存
            // registry.Execute で各 Tool を呼び出し
            // Tool の結果は role="tool" の ai.Message として履歴に追加・保存
            // ...
            continue
        }

        // 4-3. Tool なしで Content が返ってきたら最終回答として終了
        if hasContent {
            assistant := ai.Message{
                Role: "assistant",
                Content: map[string]any{
                    "text": collectedText,
                },
            }
            messages = append(messages, assistant)
            _, _ = a.memory.Save(ctx, assistant)

            // Agent 終了のイベントを投げて終了
            _ = writer.Write(ctx, streaming.NewAgentEndEvent(/* ... */))
            return nil
        }
    }
}

要点だけまとめると:

  • Agent は Model / Memory / ToolRegistry / StreamWriter だけ に依存
  • 「ReAct のループ」「Tool 実行の重複検知」「停止条件」などは Agent 内部の責務として閉じ込める
  • HTTP や SSE / WebSocket / gRPC には依存しない

という形になっています。

4-6. Usecase / Handler からの利用:クリーンアーキテクチャ上での位置づけ

最後に、「この Agent を実際にどう呼び出しているか」です。

AgentRunUsecase:Application 層でのオーケストレーション

application/usecases/ai/agent_run_usecase.go
package ai

import (
    "context"
    "fmt"

    appTools "github.com/~/application/ai/tools"
    "github.com/~/domain/repositories"
    infraTools "github.com/~/infra/ai/tools"
    "github.com/~/pkg/ai/agents"
    memory "github.com/~/pkg/ai/memory"
    "github.com/~/pkg/ai/models"
    "github.com/~/pkg/ai/streaming"
)

type AgentRunUsecase struct {
    modelFactory          models.ModelFactory
    objectiveRepo         repositories.ObjectiveRepository
    objectiveRelationRepo repositories.ObjectiveRelationRepository
    commentRepo           repositories.CommentRepository
    orgMemberRepo         repositories.OrganizationMemberRepository
}

func NewAgentRunUsecase(
    modelFactory models.ModelFactory,
    objectiveRepo repositories.ObjectiveRepository,
    objectiveRelationRepo repositories.ObjectiveRelationRepository,
    commentRepo repositories.CommentRepository,
    orgMemberRepo repositories.OrganizationMemberRepository,
) *AgentRunUsecase {
    return &AgentRunUsecase{
        modelFactory:          modelFactory,
        objectiveRepo:         objectiveRepo,
        objectiveRelationRepo: objectiveRelationRepo,
        commentRepo:           commentRepo,
        orgMemberRepo:         orgMemberRepo,
    }
}

func (uc *AgentRunUsecase) RunStream(
    ctx context.Context,
    input string,
    writer streaming.StreamWriter,
    mem memory.Memory,
    config *agents.AgentConfig,
    userID string,
) error {
    // 1. ユーザー情報の確認・取得
    // ...

    // 2. ToolRegistry を作成し、アプリ固有の Tool を登録
    registry := infraTools.NewDefaultToolRegistry()

    if err := registry.Register(appTools.NewSearchGoalsTool(uc.objectiveRepo, userID, organizationID)); err != nil {
        return fmt.Errorf("search_goals の登録に失敗しました: %w", err)
    }
    if err := registry.Register(appTools.NewGetGoalDetailsTool(uc.objectiveRepo)); err != nil {
        return fmt.Errorf("get_goal_details の登録に失敗しました: %w", err)
    }
    // ... 他の Tool も同様に登録

    // 3. ModelFactory から Model を生成
    model := uc.modelFactory.NewModel(registry)

    // 4. ReactAgent を組み立てて実行
    agent := agents.NewReactAgent(model, registry, mem, config)
    return agent.RunStream(ctx, input, writer)
}
  • Usecase の責務は「このユースケースで使う Model / Memory / Tool を組み立てること」
  • Agent のロジック自体には踏み込まず、Agent インターフェースを叩くだけ

という役割分担になっています。

ThreadHandler:HTTP エンドポイントとの接続

実際のエンドポイントは/api/v1/ai/threads/:id/chatのような形で定義しています。
スレッドの作成後、そのスレッド内で会話をしているというだけです。

presentation/handlers/ai/thread_handler.go
func (h *ThreadHandler) Chat(c *gin.Context) {
    threadID := c.Param("id")
    if threadID == "" {
        h.RespondWithError(c, http.StatusBadRequest, "Thread ID が必要です")
        return
    }

    var req aiRequests.ThreadChatRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        h.RespondWithError(c, http.StatusBadRequest, "リクエストボディが不正です")
        return
    }

    if err := req.Validate(); err != nil {
        h.RespondWithError(c, http.StatusBadRequest, err.Error())
        return
    }

    // その他認証などの処理
    // ...

    // SSEWriter を生成(StreamWriter 実装)
    writer := streaming.NewSSEWriter(c.Writer, c.Writer.(http.Flusher))

    // ThreadChatUsecase 側で Memory / AgentRunUsecase を組み合わせて実行
    if err := h.threadChatUsecase.Chat(
        c.Request.Context(),
        threadID,
        userID,
        &req,
        writer,
    ); err != nil {
        _ = c.Error(err)
    }
}

Handler から見ると、

  • 認証・バリデーション・エラーレスポンスはここで処理
  • SSEWriter という「Presentation 層の StreamWriter 実装」を生成
  • あとは Usecase に丸投げ

という構造になっており、AI の細かいロジックには一切触れません。

まとめ:この設計で得られたもの

このように、

  • pkg/ai に Model / Memory / Tool / Agent / Streaming の抽象を集約し、
  • OpenAI などのプロバイダ実装は pkg/ai/openai に分離し、
  • Application 層では Usecase の中で「どの Model / Memory / Tool を組み合わせるか」だけを記述し、
  • Presentation 層は HTTP / SSE の責務だけに徹する

という形にしたことで、結果として:

  • モデル切り替え(OpenAI → 他プロバイダ)が ModelFactory の差し替え だけで済む
  • Tool の追加・削除が 1 ファイル単位で完結する
  • Memory を in-memory → PostgreSQL に変えても Agent のコードがそのまま動く
  • SSE → WebSocket → gRPC への拡張も、StreamWriter の実装追加で筋よく対応できる
  • Agent 自体を「1 つの独立したユニット」としてテストしやすくなった

というメリットが得られました。

前編のおわりに

本記事(前編)では、「Go × クリーンアーキテクチャで AI エージェント基盤を自作する」という、ややニッチだけど技術的に価値の高い内容を扱いました。

Python(LangChain / LangGraph)前提がほぼ常識となっている領域ですが、

  • バックエンドが Go のプロダクトでは「そのまま Go に寄せたほうが速い」ケースは多い
  • Go の interface / DI / 抽象レイヤ構成は、逆に AI エージェントとは相性が良い

という可能性もあるかなと思われます。
後半の記事では、プロダクトとして運用できる AI エージェント基盤を仕上げるための

  • DB 設計
  • API 設計
  • Quality 管理
  • 将来の拡張(Workflow / Planner-Executor)

などを実践的に解説していきます👇
https://zenn.dev/iyusuke/articles/11d3a6815cecef

アドネス株式会社 開発部

Discussion