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はその「すべて」の範囲を確かに広げてくれましたが、それを信頼できる形でプロダクトに実装する責任は、変わらずエンジニアにあります。
サンプルコードは説明のために簡略化しています。本番投入の際は、ロギング・メトリクス収集・適切なシークレット管理を追加してください。