Go + クリーンアーキテクチャで AI エージェント基盤を再設計した話【後編】
前編記事はこちら👇
前編では、
- 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つのエンティティを置きます。
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
}
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
}
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 インターフェースを定義します。
type AgentRepository interface {
FindByID(ctx context.Context, id string) (*models.Agent, error)
FindAll(ctx context.Context) ([]*models.Agent, error)
}
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
}
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インターフェースは、
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 版を実装しました。
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 順にロードする」だけです。
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)
という処理を挟んでいます。
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を使う場所」です。
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 を切っています。
エージェント周りのエンドポイントはこんな構成です。
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 は極力薄くしておきます。
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 のインターフェース
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キーだけ渡せば初期化できるようにしました。
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 側からは単なる「履歴ロード」に見える
-
ToolRegistry と Memory の両方が抽象化されているので、
後からどちらのパターンでも差し込めるようになっています。
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