2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

2
Posted at

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はその「すべて」の範囲を確かに広げてくれましたが、それを信頼できる形でプロダクトに実装する責任は、変わらずエンジニアにあります。

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


J.B.Goode Inc.に所属しています。良ければフォローお願いします!

J.B.Goode Inc.のウェブサイトでは、技術記事の他にも技術ナレッジや日々の気づき等を配信しています。
https://www.jbgoode.jp/

カジュアル面談も実施中です。お気軽にお問い合わせください。
https://www.jbgoode.jp/recruit/

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?