💡

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

に公開

前編記事はこちら👇
https://zenn.dev/iyusuke/articles/b06400ce2b66c9

前編では、

  • Model / Memory / Tool / Agent / Streaming を pkg/ai に切り出す
  • OpenAI などのモデル実装を pkg/ai/openai に閉じ込める
  • クリーンアーキテクチャで Application / Domain / Infrastructure / Presentation を分離する
  • といった「設計の話」を中心に書きました。

後編では、その設計を実際にコードベースに落としていったときの

  • Agents / Threads / Messages の DB 設計
  • Memory の DB 永続化
  • Usecase / Handler / Routes のつなぎ込み
  • 実装中に出てきたハマりどころと落としどころ

をまとめていきます。

1. Agents / Threads / Messages を用意して「スレッド型チャット」にする

まずやったのは、チャットを「単発のリクエスト」ではなく、

  • Agent(エージェント定義)
  • Thread(会話スレッド)
  • Message(スレッド内のメッセージ)

の3つで扱うことでした。

1-1. Domain モデルの定義

Domain 層にはざっくり次の3つのエンティティを置きます。

domain/models/agent.go
type Agent struct {
    ID            string
    Name          string
    Description   string
    Mode          string              // "agent", "roadmap_generation" など
    ThinkingDepth string              // "normal" / "deep"
    EnabledTools  []string            // 許可されたツール名
    Model         string              // "gpt-4o" など
    Temperature   float32
    MaxTokens     int
    TimeoutSec    int
    Metadata      map[string]any
    CreatedAt     time.Time
    UpdatedAt     time.Time
}
domain/models/thread.go
type Thread struct {
    ID                   string
    OrganizationMemberID string // マルチテナント用
    AgentID              *string
    Title                string
    Status               string    // "idle" / "running" など
    Values               map[string]any
    Metadata             map[string]any
    LastMessageAt        *time.Time
    CreatedAt            time.Time
    UpdatedAt            time.Time
}
domain/models/message.go
type Message struct {
    ID           string
    ThreadID     string
    AgentID      *string
    Role         string                 // "user" / "assistant" / "system" / "tool"
    Content      map[string]any         // テキスト+構造化を許容
    MessageIndex int                    // スレッド内の順序
    ToolCalls    []ai.ToolCall          // function calling ログ
    ToolCallID   *string
    ModelVersion *string
    TokenCountIn  int
    TokenCountOut int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

ここではまだ「どこに保存するか」は意識せず、ビジネス的に必要な情報だけをシンプルに表現しています。

1-2. Repository インターフェースと PostgreSQL 実装

Domain 層からは「どこに保存されるか」を知らなくて良いように、Repository インターフェースを定義します。

domain/repositories/agent_repository.go
type AgentRepository interface {
    FindByID(ctx context.Context, id string) (*models.Agent, error)
    FindAll(ctx context.Context) ([]*models.Agent, error)
}
domain/repositories/thread_repository.go
type ThreadRepository interface {
    Create(ctx context.Context, thread *models.Thread) (*models.Thread, error)
    FindByID(ctx context.Context, id, userID string) (*models.Thread, error)
    SearchByUser(ctx context.Context, userID string, limit, offset int) ([]*models.Thread, error)
    Update(ctx context.Context, thread *models.Thread) (*models.Thread, error)
    Delete(ctx context.Context, id, userID string) error
}
domain/repositories/message_repository.go
type MessageRepository interface {
    Create(ctx context.Context, msg *models.Message) (*models.Message, error)
    FindByThreadID(ctx context.Context, threadID string) ([]*models.Message, error)
    GetNextMessageIndex(ctx context.Context, threadID string) (int, error)
    DeleteByThreadID(ctx context.Context, threadID string) error
}

Infrastructure 層では、これらを PostgreSQL にマッピングします。
ORM を使うかどうかは好みですが、自分はinfra/postgres/*.go

  • DB 用の構造体(gorm / sqlc 用)
  • Domain モデルとの変換ロジック
  • Repository 実装

をまとめる形にしました。

1-3. マイグレーション

テーブル構成はシンプルです。実際にはほぼ次のような SQL を流しました。

-- agents テーブル
CREATE TABLE agents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    mode VARCHAR(50) NOT NULL,
    thinking_depth VARCHAR(50) NOT NULL DEFAULT 'normal',
    enabled_tools JSONB,
    model VARCHAR(100) NOT NULL DEFAULT 'gpt-4o',
    temperature NUMERIC(3,2) DEFAULT 0.7,
    max_tokens INTEGER DEFAULT 8000,
    timeout_seconds INTEGER DEFAULT 300,
    metadata JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- threads テーブル
CREATE TABLE threads (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_member_id UUID NOT NULL,
    agent_id UUID,
    title VARCHAR(255),
    status VARCHAR(50) NOT NULL DEFAULT 'idle',
    values JSONB,
    metadata JSONB,
    last_message_at TIMESTAMP,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- messages テーブル
CREATE TABLE messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    thread_id UUID NOT NULL,
    agent_id UUID,
    role VARCHAR(50) NOT NULL,
    content JSONB NOT NULL,
    message_index INTEGER NOT NULL,
    tool_calls JSONB,
    tool_call_id VARCHAR(255),
    model_version VARCHAR(100),
    token_count_in INTEGER DEFAULT 0,
    token_count_out INTEGER DEFAULT 0,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

ポイントは、message_index をテーブル側で持っていることです。

  • 作成日時ではなく index で順序を管理できる
  • 「直近 N 件だけ Memory にロードする」といった最適化がやりやすい
  • 将来メッセージ編集や挿入を実装したときも扱いやすい

というのが理由です。

2. Memory を DB 永続化に差し替える

前編で出てきたMemoryインターフェースは、

pkg/ai/memory/memory.go
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
}

という形にしてありました。
MVP では最初、InMemory 実装で動かしていたのですが、会話履歴をちゃんと残したかったので PostgreSQL 版を実装しました。

infra/ai/memory/postgresql_memory.go
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,
    }
}

履歴の読み込みは「Thread に紐ずくメッセージを index 順にロードする」だけです。

infra/ai/memory/postgresql_memory.go
func (m *PostgreSQLMemory) LoadHistory(ctx context.Context, opts ...ai.MemoryLoadOption) ([]ai.Message, error) {
	// オプション設定を適用(将来の拡張用)
	cfg := ai.DefaultMemoryLoadConfig()
	for _, opt := range opts {
		opt(&cfg)
	}

	// DBから全メッセージを取得(message_index順)
	dbMessages, err := m.messageRepo.FindByThreadID(ctx, m.threadID)
	if err != nil {
		return nil, err
	}

	// domain.Message → ai.Message に変換(systemメッセージは除外)
	messages := make([]ai.Message, 0, len(dbMessages))
	for _, dbMsg := range dbMessages {
		// システムメッセージはスキップ(動的に生成されるため)
		if dbMsg.Role == "system" {
			continue
		}

		// ToolCallIDの処理(nilチェック)
		toolCallID := ""
		if dbMsg.ToolCallID != nil {
			toolCallID = *dbMsg.ToolCallID
		}

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

	// オプションによるフィルタリング(将来の拡張)
	if cfg.LimitMessages > 0 && len(messages) > cfg.LimitMessages {
		// 直近N件のみ返す
		return messages[len(messages)-cfg.LimitMessages:], nil
	}

	return messages, nil
}

保存時には

  • message_index を自動採番
  • role に応じて visibility を決定(user / assistant の最終回答は visible、tool や中間メッセージは internal)

という処理を挟んでいます。

infra/ai/memory/postgresql_memory.go
func (m *PostgreSQLMemory) Save(ctx context.Context, msg ai.Message) (*ai.StoredMessageInfo, error) {
	// message_indexを自動採番
	index, err := m.messageRepo.GetNextMessageIndex(ctx, m.threadID)
	if err != nil {
		return nil, err
	}

	// ToolCallIDの処理
	var toolCallID *string
	if msg.ToolCallID != "" {
		toolCallID = &msg.ToolCallID
	}

	// Visibility自動判定
	visibility := models.MessageVisibilityVisible
	if msg.Role == "user" {
		// ユーザーメッセージは設定されたvisibilityを使用
		visibility = m.userMessageVisibility
	} else if msg.Role == "system" {
		// システムプロンプトは内部専用
		visibility = models.MessageVisibilityInternal
	} else if msg.Role == "tool" {
		// ツール実行結果は内部専用
		visibility = models.MessageVisibilityInternal
	} else if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
		// ツール呼び出しを含むassistantメッセージは内部専用
		visibility = models.MessageVisibilityInternal
	}

	// ai.Message → domain.Message に変換
	dbMessage := &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(),
	}

	// DBに保存
	savedMsg, err := m.messageRepo.Create(ctx, dbMessage)
	if err != nil {
		return nil, err
	}

	// StoredMessageInfoに変換して返却
	return &ai.StoredMessageInfo{
		ID:           savedMsg.ID,
		ThreadID:     savedMsg.ThreadID,
		Role:         savedMsg.Role,
		MessageIndex: savedMsg.MessageIndex,
		Visibility:   string(savedMsg.Visibility),
		CreatedAt:    savedMsg.CreatedAt,
	}, nil
}

こうしておくと、後から

  • 「ユーザー向け UI には visible だけ出す」
  • 「開発者向けログ画面では internal も含めて全部出す」

といった絞り込みが簡単になります。

3. Usecase / Handler / Routes で 4 層をつなぐ

次に、Domain / Infrastructure の上に Application / Presentation を載せます。

3-1. Usecase 層:Agent 実行のまとまりを定義する

エージェントまわりのユースケースはざっくりこんな分割にしました。

  • AgentUsecase …… エージェント定義の CRUD
  • ThreadUsecase …… スレッドの CRUD とメッセージ一覧
  • ThreadChatUsecase …… 既存スレッドでのチャット実行
  • AgentRunUsecase …… 実際にエージェントを起動するロジック

AgentRunUsecase は「Application 層からpkg/aiを使う場所」です。

application/usecases/ai/agent_run_usecase.go
type AgentRunUsecase struct {
    modelFactory  models.ModelFactory
    // ... 省略(ツール用の Repository 達)
}

func (uc *AgentRunUsecase) RunStream(
    ctx context.Context,
    input string,
    writer streaming.StreamWriter,
    memory memory.Memory,
    config *agents.AgentConfig,
    // ... その他アプリ固有のもの
) error {
    // userID から所属組織などを解決するなどの処理
    // ...

    // ToolRegistry を組み立て
    registry := infraTools.NewDefaultToolRegistry()
    _ = registry.Register(appTools.NewSearchGoalsTool(uc.objectiveRepo, userID, orgID))
    _ = registry.Register(appTools.NewGetGoalDetailsTool(uc.objectiveRepo))
    // ... 必要なツールを登録

    // Model を生成(OpenAI や将来の別モデルにはここだけ依存)
    model := uc.modelFactory.NewModel(registry)

    // ReactAgent を作って実行
    agent := agents.NewReactAgent(model, registry, memory, config)
    return agent.RunStream(ctx, input, writer)
}

Usecase 層は

  • Repository から必要な情報を引き出し
  • ToolRegistry・Memory・AgentConfig を組み立て
  • pkg/aiの Agent に渡しているだけ

という構造にしてあります。

3-2. Handler / Routes:HTTP だけを見る薄い層

Presentation 層では、Gin を使って HTTP API を切っています。
エージェント周りのエンドポイントはこんな構成です。

presentation/routes/api.go
ai := auth.Group("/ai")
{
    // Agent 管理
    ai.GET("/agents", agentHandler.List)
    ai.GET("/agents/:id", agentHandler.Get)

    // Thread 管理
    ai.POST("/threads", threadHandler.Create)
    ai.GET("/threads", threadHandler.List)
    ai.GET("/threads/:id", threadHandler.Get)
    ai.PATCH("/threads/:id", threadHandler.Update)
    ai.DELETE("/threads/:id", threadHandler.Delete)
    ai.GET("/threads/:id/messages", threadHandler.GetMessages)

    // チャット(SSE)
    ai.POST("/threads/:id/chat", threadHandler.Chat)
}

チャットの Handler は極力薄くしておきます。

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
    }

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

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

    // あとは Usecase に委譲
    if err := h.threadChatUsecase.Chat(
        c.Request.Context(),
        threadID,
        userID,
        &req,
        writer,
    ); err != nil {
        _ = c.Error(err)
    }
}

HTTP の細かい話(認証や JSON バインディング)は Handler が持ち、
AI エージェントに関する知識は Usecase / pkg/ai に閉じ込める、という分担にしています。

4. OpenAI 依存をpkg/ai/openaiに閉じ込める

前編でも触れたとおり、モデルまわりは LangChain っぽいノリで

  • pkg/ai/models …… 抽象インターフェース
  • pkg/ai/openai …… OpenAI 版の実装

という分割にしました。

4-1. ModelFactory のインターフェース

pkg/ai/models/model.go
type ModelFactory interface {
    NewModel(registry tools.ToolRegistry) Model
}

type Model interface {
    Generate(ctx context.Context, messages []ai.Message, opts ...ai.ModelOption) (*Response, error)
}

type StreamingModel interface {
    Model
    GenerateStream(ctx context.Context, messages []ai.Message, opts ...ai.ModelOption) (<-chan streaming.StreamEvent, error)
}

4-2. OpenAI 実装側

Application 層からは、APIキーだけ渡せば初期化できるようにしました。

pkg/ai/openai/factory.go
func NewModelFactory(apiKey string) models.ModelFactory {
    client := openai.NewClient(apiKey)
    return &Factory{client: client}
}

type Factory struct {
    client openai.Client
}

func (f *Factory) NewModel(registry tools.ToolRegistry) models.Model {
    return NewModel(f.client, registry)
}

Usecase 側は、OpenAI の SDK を一切知らずに済みます。

cfg := config.Get()
modelFactory := openai.NewModelFactory(cfg.AI.OpenAIAPIKey)

// あとは AgentRunUsecase に modelFactory を渡すだけ

このあたりは、Python の LangChain を Go で自前実装している感覚に近いです。

5. 実装中にハマったポイントと落としどころ

実際に MVP を動かしてみる中で出てきた問題と、その落としどころを少し。

5-1. ユーザーメッセージが二重に保存される

最初の実装では、

  • ThreadChatUsecase で user メッセージを保存
  • その後に呼ばれる ReactAgent の中でも Memory.Save を呼ぶ

という構成になっていて、同じメッセージが 2 回 DB に入ってしまう問題が出ました。
最終的には、

  • user / assistant / tool など「エージェントが扱うメッセージ」はすべて Agent 側で保存
  • Usecase 側からは Memory.Save を叩かない

という責務分担に整理して解決しました。

5-2. ストリーミング中の assistant メッセージが保存されない

もうひとつの罠は、SSE でトークンを流していると「最終回答のテキスト」をどこから取るかです。
OpenAI のストリーミングをそのまま流しているだけだと、DB 側には一切保存されません。

そこでReactAgent側で

  • EventTypeTextDeltaを受け取るたびにcollectedTextに追記
  • ツール呼び出しがなく、ストリームが終了したタイミングでcollectedTextを1つの assistant メッセージとして Memory.Save

という処理を入れました。

var collectedText string

for event := range eventChan {
    if event.Type == streaming.EventTypeTextDelta {
        if delta, ok := event.Data["delta"].(string); ok {
            collectedText += delta
        }
        // クライアントには逐次送る
        _ = writer.Write(ctx, event)
    }
}

// ツール呼び出しがなく、コンテンツがある場合は最終回答として保存
if hasContent {
    assistantMsg := ai.Message{
        Role: "assistant",
        Content: map[string]interface{}{
            "text": collectedText,
        },
    }
    _, err := a.memory.Save(ctx, assistantMsg)
    if err != nil {
        logger.Error(ctx, err, "最終回答メッセージの保存に失敗", nil)
    }
}

6. Before / After 比較:何が楽になったのか

ここまで書いた内容を、元の実装と比較してざっくり振り返ります。

6-1. 依存関係・変更容易性・テスト容易性の比較

Before(単発チャット + 直書き)

  • ハンドラの中で直接 OpenAI SDK を叩いていた
  • System Prompt / Tool 呼び出しロジック / メモリ管理が HTTP ハンドラにべったり
  • モデルを変えたい、ツールを足したい、メモリを DB にしたい、となるたびに
  • ハンドラのロジックに手を入れる必要があった
  • テストは「HTTP 経由で統合テストする」か、「ほぼモックだらけのテスト」かの二択になりがち

After(エージェント基盤 + クリーンアーキテクチャ)

  • Model / Memory / Tool / Agent / Streaming を pkg/ai に閉じ込めた
  • Application 層は「Thread と Agent の紐付け」「どのツールを使わせるか」を決めるだけ
  • Presentation 層は「HTTP や SSE をどう扱うか」だけを見ればよい
  • Model の差し替え(OpenAI → 別ベンダー)も、Tool の追加・削除も、
  • pkg/ai のインターフェースを守りさえすれば影響範囲が限定される
  • Agent 単位 / Usecase 単位のテストが書きやすくなり、
  • HTTP を通さない形でのユニットテストも現実的になった

開発サイクル的には、

「まず pkg/ai の中で好きなだけ実験し、そのあと Application / Presentation に載せ替える」

という流れが取れるようになったのが大きかったです。

6-2. 採用してよかった点・失敗した点

よかった点:

  • 既存の Go バックエンドと同じ抽象化・設計思想で AI エージェントを扱える
  • LangChain 的な考え方(Model / Memory / Tool / Agent / Chain)を自前のコードベースに素直に落とし込めた
  • モデル乗り換えやツール追加のような「AI 特有の頻繁な変更」に耐えやすい

正直にいえば、失敗・反省もあります。

  • MVP の段階からここまでやると、当然ながら実装コストはそれなりにかかる
  • 「既存フレームワークを使えば 1 日で書けたもの」を、自前でじっくり設計して作り込むので、短期の速度は確実に落ちる
  • メモリ戦略や Tool の粒度など、「そもそも答えがまだ揺れている領域」にモデルをかぶせるので、設計を固くしすぎると逆に動きが鈍くなることもある

結果的には、

「長く運用するプロダクトの一部として AI エージェントを組み込む」

という前提があるなら、Go + クリーンアーキテクチャで「自前の LangChain っぽいレイヤー」を持つのはあり、という感触です。

7. まとめと今後の拡張アイデア(RAG / 複数モデル / MCP 対応など)

最後に、今回の実装を土台にしてどこまで拡張できそうか、簡単に触れておきます。

7-1. RAG(Retrieval Augmented Generation)

今回の設計だと、RAG は次の 2 パターンどちらにも乗せられます。

  • Tool としての RAG
    • SearchDocumentsTool, GetDocumentDetailTool のように、ベクトル検索や全文検索を叩くツールを追加する
    • LLM からは「外部のリソースへの関数呼び出し」として見える
  • Retriever としての RAG
    • Memory.LoadHistoryForAI の中で「スレッド以外の関連情報(ドキュメントなど)」を検索してシステムメッセージに混ぜる
    • Agent 側からは単なる「履歴ロード」に見える

ToolRegistryMemory の両方が抽象化されているので、
後からどちらのパターンでも差し込めるようになっています。

7-2. 複数モデル(マルチプロバイダ)対応

ModelFactory を1つのインターフェースにしておいたことで、

  • OpenAI 用の Factory
  • 別ベンダー用の Factory
  • ローカル LLM 用の Factory

を横に並べて、AgentConfig や Thread 単位で使い分ける、ということがやりやすくなります。
イメージとしては、

type MultiModelFactory struct {
    openaiFactory   models.ModelFactory
    anthropicFactory models.ModelFactory
}

func (f *MultiModelFactory) NewModel(registry tools.ToolRegistry, provider string) models.Model {
    switch provider {
    case "openai":
        return f.openaiFactory.NewModel(registry)
    case "anthropic":
        return f.anthropicFactory.NewModel(registry)
    default:
        // default provider
        return f.openaiFactory.NewModel(registry)
    }
}

のような形で、

  • Agent ごとに「どのプロバイダを使うか」を設定ファイルや DB に持つ
  • 将来的に「ルーティングモデル」で動的に選ぶ

といった拡張も視野に入ります。

9-3. MCP / 外部ツール連携

tools.Tool / ToolRegistry もインターフェースになっているので、
Protocol ベースの外部ツール(MCPなど)を扱う場合も、

  • MCP クライアントを内部に持つ Tool 実装を 1 つ作る
  • もしくは「MCP サーバ側のツール一覧を ToolRegistry に同期する」

という形で取り込めます。
エージェント側から見ると「ツールが増えた」だけで、プロトコルの種類は見えない、という状態を維持できます。

おわりに

ここまでが、Go + クリーンアーキテクチャで AI エージェント基盤を組み直したときの設計〜実装の全体像です。

  • 既存バックエンドが Go で動いている
  • クリーンアーキテクチャ / DDD でサービスを作っている
  • LangChain / LangGraph の思想は好きだが、プロダクションでは依存を増やしたくない

といった状況なら、今回紹介したように「自前で薄いエージェントレイヤーを持つ」のは十分現実的な選択肢だと思います。

あとは、この土台の上に

  • RAG / ナレッジグラフ
  • マルチエージェント
  • ロングタームメモリ
  • 分析用のトレース・メトリクス

を積んでいくフェーズになってくるので、そのあたりはまた別の記事で整理してみるつもりです。

アドネス株式会社 開発部

Discussion