Tech

生成AI × Go:LLMをプロダクトに組み込む設計パターン

最終更新:2026.06.10

GoでLLMのAPIを叩いてみたことはありますか?数十行のコードで動くプロトタイプができて、最初は感動します。でも、それを実際のプロダクトに組み込もうとすると、別の問題が次々と出てきます。

レスポンスが遅すぎてユーザーが離脱する、プロンプトがコードベースに散らばって管理できなくなる、ある日突然タイムアウトしてエラーログが流れる、会話履歴が育ちすぎてトークン上限に当たる——。「APIを呼ぶ」ことと「プロダクトに組み込む」ことは、別の技術課題なんです。

この記事は、そういう「動いてはいるけど不安な状態」を抜け出したいGoエンジニア向けに書いています。 J.B.Goodeで実際のプロダクト開発を通じて見えてきた4つの設計パターンと、それぞれの「なぜそう設計するのか」という意図を紹介します。

GoとHTTP APIの基礎的な読み書きができれば読めます。LLMの深い知識は不要です。この記事を読み終わると、ストリーミング・プロンプト管理・エラー処理・会話管理の4つで、「とりあえず動く実装」から「本番で動き続ける設計」への具体的な道筋が見えるはずです。

Pattern 01: ストリーミングを前提に設計する

解決する問題
LLMのレスポンス生成には数秒〜十数秒かかります。応答が完全に生成されるまで何も表示しない実装だと、ユーザーは「壊れてるのかな」と思って離脱します。体感速度の問題です。

設計の意図
UXの問題をアーキテクチャで解きます。ChatGPTやClaudeで文字が少しずつ流れてくる体験は、実は待ち時間を「待ちではなく進行中」に変えるUI設計です。これをGoで実装するとき、goroutineとチャンネルが自然にはまります。

Anthropic APIはServer-Sent Events(SSE)形式のストリーミングをサポートしていて、生成が進むたびにテキストの断片(チャンク)を受信できます。bufio.Scanner でレスポンスボディを行単位に読んで、goroutineとチャンネルで結果を渡す構成が自然です。

// llm/client.go
package llm

import (
    "bufio"
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "strings"
)

type Client struct {
    apiKey     string
    model      string
    httpClient *http.Client
}

func NewClient(apiKey, model string) *Client {
    return &Client{
        apiKey:     apiKey,
        model:      model,
        httpClient: &http.Client{},
    }
}

// Chunk はストリームから受け取る1単位のデータ
type Chunk struct {
    Text string
    Err  error
}

type requestBody struct {
    Model     string    `json:"model"`
    MaxTokens int       `json:"max_tokens"`
    Stream    bool      `json:"stream"`
    Messages  []message `json:"messages"`
}

type message struct {
    Role    string `json:"role"`
    Content string `json:"content"`
}

type streamEvent struct {
    Type  string `json:"type"`
    Delta *struct {
        Type string `json:"type"`
        Text string `json:"text"`
    } `json:"delta,omitempty"`
    Error *struct {
        Type    string `json:"type"`
        Message string `json:"message"`
    } `json:"error,omitempty"`
}

// Stream はAnthropicのSSEストリームをチャンネルに変換する
func (c *Client) Stream(ctx context.Context, userMessage string) <-chan Chunk {
    ch := make(chan Chunk, 1)

    go func() {
        defer close(ch)

        body, err := json.Marshal(requestBody{
            Model:     c.model,
            MaxTokens: 1024,
            Stream:    true,
            Messages:  []message{{Role: "user", Content: userMessage}},
        })
        if err != nil {
            ch <- Chunk{Err: fmt.Errorf("marshal request: %w", err)}
            return
        }

        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.anthropic.com/v1/messages",
            bytes.NewReader(body),
        )
        if err != nil {
            ch <- Chunk{Err: fmt.Errorf("create request: %w", err)}
            return
        }

        req.Header.Set("x-api-key", c.apiKey)
        req.Header.Set("anthropic-version", "2023-06-01")
        req.Header.Set("content-type", "application/json")

        resp, err := c.httpClient.Do(req)
        if err != nil {
            ch <- Chunk{Err: fmt.Errorf("http: %w", err)}
            return
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            ch <- Chunk{Err: fmt.Errorf("unexpected status: %d", resp.StatusCode)}
            return
        }

        scanner := bufio.NewScanner(resp.Body)
        for scanner.Scan() {
            line := scanner.Text()
            if !strings.HasPrefix(line, "data: ") {
                continue
            }

            var event streamEvent
            if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event); err != nil {
                continue // パースできないイベントはスキップ
            }

            switch event.Type {
            case "content_block_delta":
                if event.Delta != nil && event.Delta.Type == "text_delta" {
                    select {
                    case ch <- Chunk{Text: event.Delta.Text}:
                    case <-ctx.Done():
                        return
                    }
                }
            case "message_stop":
                return
            case "error":
                if event.Error != nil {
                    ch <- Chunk{Err: fmt.Errorf("api error: %s", event.Error.Message)}
                }
                return
            }
        }

        if err := scanner.Err(); err != nil {
            ch <- Chunk{Err: fmt.Errorf("scan: %w", err)}
        }
    }()

    return ch
}

このチャンネルをHTTPハンドラーで受け取って、クライアントにSSEとして流します。

// handler/stream.go
package handler

import (
    "fmt"
    "net/http"
    "strings"

    "yourproject/llm"
)

func HandleStream(client *llm.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        query := r.URL.Query().Get("q")
        if query == "" {
            http.Error(w, "q is required", http.StatusBadRequest)
            return
        }

        flusher, ok := w.(http.Flusher)
        if !ok {
            // nginxやALBの設定によってはここに到達する
            http.Error(w, "streaming not supported", http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "text/event-stream")
        w.Header().Set("Cache-Control", "no-cache")
        w.Header().Set("Connection", "keep-alive")

        for chunk := range client.Stream(r.Context(), query) {
            if chunk.Err != nil {
                fmt.Fprintf(w, "event: error\ndata: %s\n\n", chunk.Err.Error())
                flusher.Flush()
                return
            }
            // SSE仕様: テキスト内に改行がある場合、各行に "data: " を付ける
            lines := strings.ReplaceAll(chunk.Text, "\n", "\ndata: ")
            fmt.Fprintf(w, "data: %s\n\n", lines)
            flusher.Flush()
        }
    }
}

http.Flusher のチェックを忘れると、nginxやALB経由でバッファリングされてストリーミングの意味がなくなります。ユーザーがタブを閉じるなど context がキャンセルされた場合も、goroutine側の ctx.Done() がちゃんと反応してクリーンに終了してくれます。

Pattern 02: プロンプトをコードとして扱う

解決する問題
機能が増えるにつれて、fmt.Sprintf("あなたは%sです。質問: %s", role, query) のようなインライン文字列がコードベースに散らばります。どこで使われているか追えない、変更の影響範囲がわからない、テストが書けない——コードで言えばマジックナンバーが散在している状態です。

設計の意図
プロンプトはLLMの振る舞いを決定する、コードと同等に重要な成果物です。Goの text/template を使うことで、プロンプトを「バリデーション可能・テスト可能・一箇所で管理できる」ものにします。

// prompt/builder.go
package prompt

import (
    "bytes"
    "errors"
    "fmt"
    "strings"
    "text/template"
)

// テンプレートはパッケージレベルで一度だけパースする
const supportTmpl = `あなたは{{.ProductName}}のサポートAIです。
ユーザーの質問に対して、簡潔かつ丁寧に答えてください。

【ユーザーの質問】
{{.UserQuery}}`

// SupportData はプロンプト生成に必要な入力値
type SupportData struct {
    ProductName string
    UserQuery   string
}

type Builder struct {
    tmpl *template.Template
}

func NewBuilder() (*Builder, error) {
    tmpl, err := template.New("support").Parse(supportTmpl)
    if err != nil {
        return nil, fmt.Errorf("template parse: %w", err)
    }
    return &Builder{tmpl: tmpl}, nil
}

// Build はバリデーション後にプロンプト文字列を生成する
func (b *Builder) Build(data SupportData) (string, error) {
    if strings.TrimSpace(data.UserQuery) == "" {
        return "", errors.New("UserQuery is required")
    }
    if strings.TrimSpace(data.ProductName) == "" {
        return "", errors.New("ProductName is required")
    }

    var buf bytes.Buffer
    if err := b.tmpl.Execute(&buf, data); err != nil {
        return "", fmt.Errorf("template execute: %w", err)
    }
    return buf.String(), nil
}

このアプローチ、良いところが3つあります。バリデーションをプロンプト生成の前段に置けること、テストBuild() を呼ぶだけで書けること、そしてテンプレートの変更が一箇所に集まること。

テストもこんなふうに素直に書けます。

// prompt/builder_test.go
func TestBuilder_Build(t *testing.T) {
    b, err := prompt.NewBuilder()
    if err != nil {
        t.Fatal(err)
    }

    got, err := b.Build(prompt.SupportData{
        ProductName: "パ・リーグウォーク",
        UserQuery:   "歩数はいつリセットされますか?",
    })
    if err != nil {
        t.Fatal(err)
    }
    if !strings.Contains(got, "パ・リーグウォーク") {
        t.Error("ProductName not found in prompt")
    }
    if !strings.Contains(got, "歩数はいつリセットされますか?") {
        t.Error("UserQuery not found in prompt")
    }
}

Pattern 03: 失敗を設計に組み込む

解決する問題
LLMはネットワーク越しの外部サービスで、しかも確率的なシステムです。レート制限(HTTP 429)、タイムアウト、サーバーエラー(5xx)はいつでも起きます。ここで「とりあえず3回リトライ」と書くと、リトライしても無意味なエラー(認証失敗・不正なリクエスト)にまで再試行してしまい、コストと時間を無駄にします。

設計の意図
Goの明示的なエラー型を活かして、「リトライすべき失敗」と「すべきでない失敗」を型レベルで区別します。判断ロジックをエラー型に閉じ込めることで、呼び出し元が失敗の種類を知らなくても正しく動きます。

// resilience/retry.go
package resilience

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

// LLMError はAPI呼び出し時のエラーを表す
type LLMError struct {
    StatusCode int
    Message    string
}

func (e *LLMError) Error() string {
    return fmt.Sprintf("llm api error (status %d): %s", e.StatusCode, e.Message)
}

// NewLLMError はステータスコードからLLMErrorを生成する
func NewLLMError(statusCode int, message string) *LLMError {
    return &LLMError{StatusCode: statusCode, Message: message}
}

// IsRetryable はリトライ対象かどうかを判定する
// 429(レート制限)と5xx(サーバーエラー)のみリトライ対象
func IsRetryable(err error) bool {
    var llmErr *LLMError
    if errors.As(err, &llmErr) {
        return llmErr.StatusCode == http.StatusTooManyRequests ||
            llmErr.StatusCode >= http.StatusInternalServerError
    }
    return false
}

type RetryConfig struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
}

// WithRetry はリトライ可能なエラーに対して指数バックオフでリトライする
// キャンセルやリトライ対象外のエラーは即座に返す
func WithRetry(ctx context.Context, cfg RetryConfig, fn func(context.Context) error) error {
    var lastErr error

    for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
        if attempt > 0 {
            // 指数バックオフ: 1s → 2s → 4s → ...
            delay := cfg.BaseDelay * time.Duration(1<<uint(attempt-1))
            if delay > cfg.MaxDelay {
                delay = cfg.MaxDelay
            }
            select {
            case <-time.After(delay):
            case <-ctx.Done():
                return fmt.Errorf("cancelled while waiting for retry: %w", ctx.Err())
            }
        }

        // 各試行ごとにタイムアウトを設ける
        callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
        lastErr = fn(callCtx)
        cancel()

        if lastErr == nil {
            return nil
        }
        if !IsRetryable(lastErr) {
            return lastErr // リトライ対象外はすぐに返す
        }
    }

    return fmt.Errorf("all %d attempts failed, last error: %w", cfg.MaxAttempts, lastErr)
}

使い方はこんな感じです。

cfg := resilience.RetryConfig{
    MaxAttempts: 3,
    BaseDelay:   time.Second,
    MaxDelay:    10 * time.Second,
}

err := resilience.WithRetry(ctx, cfg, func(ctx context.Context) error {
    return callLLM(ctx, prompt)
})
if err != nil {
    // 3回試みてもダメだった、あるいはリトライ対象外のエラー
    return err
}

「リトライするかどうか」の判断をエラー型の知識として持たせることで、呼び出し元にロジックが散らばらずに済みます。

Pattern 04: コンテキスト予算を意識した会話管理

解決する問題
マルチターンの会話では、APIに渡す履歴が会話のたびに増え続けます。これをそのまま渡し続けると、トークンコストが線形に増加し、最終的にモデルのコンテキストウィンドウ上限(claude-haiku-4-5 なら200,000トークン)でエラーになります。「動いてたのに突然落ちた」というパターンの典型です。

設計の意図
コンテキストウィンドウを「使い放題のメモリ」ではなく「有限の予算」として意識的に扱います。TokenEstimator をインターフェースにしておくことで、最初は文字数ベースの簡易推定から始め、精度が必要になったタイミングで正確なトークナイザーに差し替えられます。

// conversation/manager.go
package conversation

type Role string

const (
    RoleSystem    Role = "system"
    RoleUser      Role = "user"
    RoleAssistant Role = "assistant"
)

type Message struct {
    Role    Role
    Content string
    tokens  int // 推定トークン数(非公開フィールド)
}

// TokenEstimator はトークン数を推定するインターフェース
// 正確なカウントには tiktoken 互換のライブラリを推奨
// 例: https://github.com/pkoukk/tiktoken-go
type TokenEstimator interface {
    Estimate(text string) int
}

// SimpleEstimator は文字数ベースの簡易推定(あくまで目安)
// 日本語は概ね1文字≒1〜2トークン、英語は約4文字≒1トークン
type SimpleEstimator struct{}

func (s SimpleEstimator) Estimate(text string) int {
    // []rune で正確な文字数を数える(マルチバイト文字対応)
    return len([]rune(text))
}

type Manager struct {
    messages  []Message
    budget    int // 保持するトークン数の上限
    estimator TokenEstimator
}

func NewManager(budget int, estimator TokenEstimator) *Manager {
    return &Manager{
        budget:    budget,
        estimator: estimator,
    }
}

// Add はメッセージを追加し、必要に応じて古い履歴を削除する
func (m *Manager) Add(role Role, content string) {
    m.messages = append(m.messages, Message{
        Role:    role,
        Content: content,
        tokens:  m.estimator.Estimate(content),
    })
    m.evict()
}

// evict はトークン予算を超えた古いメッセージを削除する
// システムメッセージは常に保護する
func (m *Manager) evict() {
    for m.totalTokens() > m.budget && len(m.messages) > 1 {
        // システムメッセージが先頭にある場合はその次から削除
        start := 0
        if m.messages[0].Role == RoleSystem {
            if len(m.messages) == 1 {
                break // システムメッセージだけなら削除しない
            }
            start = 1
        }
        m.messages = append(m.messages[:start], m.messages[start+1:]...)
    }
}

func (m *Manager) totalTokens() int {
    total := 0
    for _, msg := range m.messages {
        total += msg.tokens
    }
    return total
}

// Messages はAPIに渡す形式のメッセージ一覧を返す
func (m *Manager) Messages() []Message {
    return m.messages
}

使い方はこんなふうになります。

manager := conversation.NewManager(
    4000, // 予算: 4,000トークン相当
    conversation.SimpleEstimator{},
)

manager.Add(conversation.RoleSystem, "あなたは親切なサポートAIです。")
manager.Add(conversation.RoleUser, "パスワードを忘れました")
// ... LLMの返答をRoleAssistantとして追加
manager.Add(conversation.RoleAssistant, "パスワードのリセット手順をご案内します...")

// 次のAPI呼び出しに渡す
msgs := manager.Messages()

おわりに — LLMを「インフラ」として扱う

4つのパターンに共通しているのは、LLMを「特別なもの」として扱わないという視点です。

ストリーミングは io.Reader の問題、プロンプトはテンプレートの問題、失敗はエラーハンドリングの問題、会話履歴はバジェット管理の問題——LLMを外部の非同期サービスとして捉えて、Goにある既存の道具立てで向き合う。そのシンプルな視点が、本番環境で動き続けるシステムにつながっていくと思います。

ソフトウェアでできること、すべて。生成AIはその「すべて」の範囲を確かに広げてくれましたが、それを信頼できる形でプロダクトに実装する責任は、変わらずエンジニアにあります。


サンプルコードは説明のために簡略化しています。本番投入の際は、ロギング・メトリクス収集・適切なシークレット管理を追加してください。