Go を選んだ理由と、この記事で扱うこと
個人開発でAIバックエンドを回していると、レスポンスの速さと同じくらい「一台のサーバーでどれだけリクエストを捌けるか」が効いてきます。私自身、複数のアプリからGemini APIを叩く小さな中継サーバーを Go で書き直したところ、常駐メモリと起動時間の両方が目に見えて軽くなりました。goroutine による並行処理と、単一バイナリで完結する配布のしやすさ。この2点が、Go を選ぶ静かな決め手になっています。
扱うのは、Google 公式の Go SDK(google.golang.org/genai)を使った実装です。テキスト生成からマルチモーダル画像解析、ストリーミング、マルチターン会話まで手を動かし、そのうえで入門記事では省かれがちな並行リクエストのレート制御・タイムアウト設計・モデル選定という、本番で必ずぶつかる論点まで踏み込みます。
Go の基本文法を理解していれば、AI API の利用経験は問いません。全体像を先に押さえたい方は、Gemini API クイックスタート もあわせてご覧ください。
環境準備
前提条件
Gemini API を Go で利用するために必要なものは以下の3つです。
プロジェクトの初期化と SDK インストール
まず新しい Go モジュールを作成し、公式 SDK をインストールします。
# プロジェクトディレクトリの作成
mkdir gemini-go-app && cd gemini-go-app
# Go モジュールの初期化
go mod init gemini-go-app
# Google Gen AI Go SDK のインストール
go get google.golang.org/genai
API キーの設定
API キーは環境変数 GEMINI_API_KEY として設定します。SDK がこの環境変数を自動的に読み取るため、コード内にキーを直接記述する必要はありません。
# 環境変数の設定(Linux / macOS)
export GEMINI_API_KEY = "YOUR_API_KEY"
セキュリティのため、API キーをソースコードにハードコードしたり、Git リポジトリにコミットしたりしないよう注意してください。
テキスト生成の基本
最もシンプルな使い方として、Gemini にテキストプロンプトを送信してレスポンスを取得する例を見てみましょう。
package main
import (
" context "
" fmt "
" log "
" google.golang.org/genai "
)
func main () {
ctx := context. Background ()
// クライアントの作成(GEMINI_API_KEY 環境変数を自動読み取り)
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
// テキスト生成リクエスト
result, err := client.Models. GenerateContent (ctx,
"gemini-2.5-flash" , // 使用するモデル
genai. Text ( "Goプログラミングの特徴を3つ簡潔に教えてください" ),
nil , // オプション設定(nilでデフォルト)
)
if err != nil {
log. Fatal (err)
}
// レスポンスの出力
fmt. Println (result. Text ())
}
// 期待される出力例:
// 1. **高速なコンパイルと実行**: Goはコンパイル速度が非常に速く...
// 2. **ゴルーチンによる並行処理**: 軽量スレッドであるゴルーチンにより...
// 3. **シンプルな言語設計**: 余分な機能を排除したミニマルな文法で...
genai.NewClient に nil を渡すと、デフォルト設定(Google AI バックエンド + GEMINI_API_KEY 環境変数からのキー読み取り)でクライアントが作成されます。
生成パラメータのカスタマイズ
出力の創造性や長さを制御するには、genai.GenerateContentConfig を使用します。
package main
import (
" context "
" fmt "
" log "
" google.golang.org/genai "
)
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
// 生成パラメータの設定
temperature := float32 ( 0.3 )
maxTokens := int32 ( 500 )
config := & genai . GenerateContentConfig {
Temperature: & temperature, // 低めで安定した出力
MaxOutputTokens: maxTokens, // 出力トークン数の上限
SystemInstruction: & genai . Content {
Parts: [] * genai . Part {
genai. NewPartFromText ( "あなたはGoプログラミングの専門家です。簡潔で実践的な回答をしてください。" ),
},
},
}
result, err := client.Models. GenerateContent (ctx,
"gemini-2.5-flash" ,
genai. Text ( "Go の defer 文の使いどころを教えてください" ),
config,
)
if err != nil {
log. Fatal (err)
}
fmt. Println (result. Text ())
}
Temperature を 0 に近づけるほど決定論的な出力になり、1.0 に近づけるほど創造的な出力になります。API リファレンスやコード生成のような正確性が求められるタスクでは、0.1〜0.3 程度の低い値が適しています。
マルチモーダル入力 — 画像解析
Gemini の強力な特徴の一つが、テキストと画像を組み合わせたマルチモーダル入力です。ローカルの画像ファイルを読み込んで解析する例を示します。
package main
import (
" context "
" fmt "
" log "
" os "
" google.golang.org/genai "
)
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
// 画像ファイルの読み込み
imageBytes, err := os. ReadFile ( "sample.jpg" )
if err != nil {
log. Fatal ( "画像の読み込みに失敗:" , err)
}
// マルチモーダルリクエストの構築
parts := [] * genai . Part {
genai. NewPartFromBytes (imageBytes, "image/jpeg" ),
genai. NewPartFromText ( "この画像に写っているものを詳しく説明してください。" ),
}
result, err := client.Models. GenerateContent (ctx,
"gemini-2.5-flash" ,
genai. NewContentFromParts (parts, "user" ),
nil ,
)
if err != nil {
log. Fatal (err)
}
fmt. Println (result. Text ())
}
// 期待される出力例:
// この画像には、青い空を背景に咲く桜の木が写っています。
// 淡いピンク色の花びらが満開で...
JPEG、PNG、WebP、GIF など主要な画像フォーマットに対応しています。genai.NewPartFromBytes の第2引数には適切な MIME タイプを指定してください。
ストリーミング応答
長い応答を生成する場合、すべての出力を待つのではなく、トークンが生成されるたびにリアルタイムで受け取ることができます。チャットアプリやCLIツールのユーザー体験を大幅に向上させるテクニックです。
package main
import (
" context "
" fmt "
" log "
" google.golang.org/genai "
)
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
// ストリーミングでコンテンツを生成
stream := client.Models. GenerateContentStream (ctx,
"gemini-2.5-flash" ,
genai. Text ( "Goで簡単なHTTPサーバーを構築する手順をステップバイステップで説明してください" ),
nil ,
)
// チャンクごとにリアルタイム出力
for chunk, err := range stream {
if err != nil {
log. Fatal (err)
}
if chunk. Text () != "" {
fmt. Print (chunk. Text ())
}
}
fmt. Println () // 最終行の改行
}
GenerateContentStream は Go 1.23 の range-over-func(イテレータ)パターンに対応しており、for ... range で簡潔に書くことができます。ストリーミングの詳しい実装パターンについては、ストリーミング応答とマルチターン会話の実装ガイドも参考になります。
マルチターン会話の実装
チャットボットのような対話型アプリケーションでは、会話の文脈を維持する必要があります。SDK の Chat 機能を使うと、会話履歴を自動的に管理できます。
package main
import (
" context "
" fmt "
" log "
" google.golang.org/genai "
)
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
// チャットセッションの開始
config := & genai . GenerateContentConfig {
SystemInstruction: & genai . Content {
Parts: [] * genai . Part {
genai. NewPartFromText ( "あなたはフレンドリーなGoプログラミングの先生です。" ),
},
},
}
chat, err := client.Chats. Create (ctx, "gemini-2.5-flash" , config, nil )
if err != nil {
log. Fatal (err)
}
// 1往復目
resp1, err := chat. SendMessage (ctx, genai. Text ( "Go の goroutine とは何ですか?" ))
if err != nil {
log. Fatal (err)
}
fmt. Println ( "AI:" , resp1. Text ())
// 2往復目(前の会話を踏まえた質問)
resp2, err := chat. SendMessage (ctx, genai. Text ( "では、それとチャネルを組み合わせた簡単な例を見せてください" ))
if err != nil {
log. Fatal (err)
}
fmt. Println ( "AI:" , resp2. Text ())
}
chat.SendMessage は内部的に過去の会話履歴を保持しており、2往復目の質問では「それ」が goroutine を指していることを Gemini が正しく理解します。
エラーハンドリングのベストプラクティス
本番環境では、レート制限やネットワークエラーに適切に対処する必要があります。以下は指数バックオフによるリトライを実装した例です。
package main
import (
" context "
" fmt "
" log "
" math "
" time "
" google.golang.org/genai "
)
// retryGenerateContent は指数バックオフ付きリトライでコンテンツを生成する
func retryGenerateContent (
ctx context . Context ,
client * genai . Client ,
model string ,
prompt string ,
maxRetries int ,
) ( * genai . GenerateContentResponse , error ) {
var lastErr error
for i := 0 ; i < maxRetries; i ++ {
result, err := client.Models. GenerateContent (ctx,
model,
genai. Text (prompt),
nil ,
)
if err == nil {
return result, nil
}
lastErr = err
// 指数バックオフ(1s, 2s, 4s...)
wait := time. Duration (math. Pow ( 2 , float64 (i))) * time.Second
log. Printf ( "リトライ %d / %d ( %v 後に再実行): %v " , i + 1 , maxRetries, wait, err)
time. Sleep (wait)
}
return nil , fmt. Errorf ( " %d 回のリトライ後も失敗: %w " , maxRetries, lastErr)
}
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
result, err := retryGenerateContent (ctx, client, "gemini-2.5-flash" ,
"Go のエラーハンドリングのベストプラクティスを教えてください" , 3 )
if err != nil {
log. Fatal ( "最終的にリクエスト失敗:" , err)
}
fmt. Println (result. Text ())
}
Gemini API のエラーハンドリングについてさらに詳しく知りたい方は、Gemini API のエラーハンドリングとリトライ設計で網羅的に解説しています。
リトライしてよいエラー、してはいけないエラー
前節のリトライ関数には、本番で困る欠点が2つ残っています。どんなエラーでも無差別に再試行してしまうこと。そして、待ち時間が全リクエストで完全に一致することです。
プロンプトの組み立てを誤って 400 INVALID_ARGUMENT が返っているとき、3回投げ直しても結果は変わりません。それどころか1秒・2秒・4秒と待たされた末に同じエラーを受け取るため、原因の切り分けがそのぶん遅れます。私は個人開発のバッチ処理で、MIME タイプの指定ミスによる 400 を延々と再試行し続け、ログが同じスタックトレースで埋まったことがありました。分類さえしていれば、最初の1回で気づけた失敗です。
まず、再試行に意味のあるエラーとそうでないエラーを切り分けます。
ステータス 意味 再試行 実務での対処
400 INVALID_ARGUMENT リクエストの組み立てが不正 しない Part の MIME タイプ・入力トークン長を確認
403 PERMISSION_DENIED キーの権限不足・無効 しない キーと有効化済み API を確認
404 NOT_FOUND モデル名の誤り・提供終了 しない 設定側のモデル名を見直す
429 RESOURCE_EXHAUSTED レート制限に到達 する 同時実行数を下げ、待ち時間を長めに取る
500 / 503 一時的なサーバー側の不調 する バックオフを挟んで再試行
Go では genai.APIError からステータスコードを取り出して判定します。あわせて、待ち時間に乱数を混ぜる full jitter も入れておきます。
package main
import (
" context "
" errors "
" fmt "
" log "
" math/rand "
" time "
" google.golang.org/genai "
)
// isRetryable はエラーが再試行に値するかを判定する
func isRetryable ( err error ) bool {
var apiErr genai . APIError
if errors. As (err, & apiErr) {
switch apiErr.Code {
case 429 , 500 , 502 , 503 , 504 :
return true
default :
return false // 400 / 403 / 404 は何度投げても結果が変わらない
}
}
// 呼び出し側の締め切り切れ・キャンセルは再試行しない
if errors. Is (err, context.DeadlineExceeded) || errors. Is (err, context.Canceled) {
return false
}
return true // 素性の分からない通信エラーは一度は賭ける価値がある
}
// backoffWithJitter は full jitter 方式で待ち時間を決める
func backoffWithJitter ( attempt int ) time . Duration {
base := time. Duration ( 1 << attempt) * time.Second // 1s, 2s, 4s...
if base > 30 * time.Second {
base = 30 * time.Second
}
return time. Duration (rand. Int63n ( int64 (base))) // 0 〜 base の一様乱数
}
func generateWithPolicy ( ctx context . Context , client * genai . Client ,
model , prompt string , maxRetries int ) ( * genai . GenerateContentResponse , error ) {
var lastErr error
for attempt := 0 ; attempt < maxRetries; attempt ++ {
resp, err := client.Models. GenerateContent (ctx, model, genai. Text (prompt), nil )
if err == nil {
return resp, nil
}
lastErr = err
if ! isRetryable (err) {
return nil , fmt. Errorf ( "再試行では解決しないエラー: %w " , err)
}
wait := backoffWithJitter (attempt)
log. Printf ( "再試行 %d / %d ( %v 待機): %v " , attempt + 1 , maxRetries, wait, err)
select {
case <- time. After (wait):
case <- ctx. Done (): // 待機中に締め切りが来たら即座に降りる
return nil , ctx. Err ()
}
}
return nil , fmt. Errorf ( " %d 回試して失敗しました: %w " , maxRetries, lastErr)
}
前節の実装から変えた点は2つです。
ひとつは time.Sleep を select に置き換えたこと。time.Sleep は context のキャンセルを一切見ないため、30秒の締め切りを設けていても、バックオフの待機中はそれを平然と踏み越えます。締め切りを守りたいなら、待つ側も ctx.Done() を見る必要があります。
もうひとつが乱数です。同時実行 4 本が同じ瞬間に 429 を受け取ると、固定バックオフでは4本とも同じ時刻に戻ってきて、また同じように弾かれます。私のバッチではこの再衝突が二度三度と続き、リトライ回数だけが増えていきました。full jitter を入れて戻る時刻を散らしたところ、同じ入力・同じ同時実行数のまま、二波目の 429 が出なくなりました。待ち時間の平均はむしろ短くなっています。
goroutine で並行リクエストを捌く — セマフォによるレート制御
Go の魅力は並行処理にありますが、Gemini API には毎分あたりのリクエスト数(RPM)の上限があります。何も考えずに goroutine を大量に起動すると、すぐに 429 RESOURCE_EXHAUSTED に阻まれます。ここで効くのが、バッファ付きチャネルを使った軽量なセマフォです。同時実行数を明示的に絞りながら、複数プロンプトをまとめて処理します。
package main
import (
" context "
" fmt "
" log "
" sync "
" google.golang.org/genai "
)
// generateBatch は最大 concurrency 本の goroutine で並行生成する
func generateBatch (
ctx context . Context ,
client * genai . Client ,
prompts [] string ,
concurrency int ,
) [] string {
results := make ([] string , len (prompts))
sem := make ( chan struct {}, concurrency) // 同時実行数を絞るセマフォ
var wg sync . WaitGroup
for i, prompt := range prompts {
wg. Add ( 1 )
go func ( idx int , p string ) {
defer wg. Done ()
sem <- struct {}{} // 空きが出るまでブロック
defer func () { <- sem }() // 完了したら枠を返す
resp, err := client.Models. GenerateContent (ctx,
"gemini-2.5-flash" , genai. Text (p), nil )
if err != nil {
results[idx] = fmt. Sprintf ( "[error] %v " , err)
return
}
results[idx] = resp. Text ()
}(i, prompt)
}
wg. Wait ()
return results
}
func main () {
ctx := context. Background ()
client, err := genai. NewClient (ctx, nil )
if err != nil {
log. Fatal (err)
}
defer client. Close ()
prompts := [] string {
"Go のスライスと配列の違いを一文で" ,
"defer の実行順序を一文で" ,
"チャネルの向き指定の意味を一文で" ,
}
for i, out := range generateBatch (ctx, client, prompts, 2 ) {
fmt. Printf ( "[ %d ] %s\n " , i, out)
}
}
ポイントは concurrency を欲張らないことです。私自身、個人開発のバッチで欲張って 10 本走らせ、すぐにレート制限で足を掬われました。無料枠では 2〜4 程度から始め、レート制限のログを見ながら少しずつ上げるのが安全です。結果を results[idx] のように添字で書き込むことで、goroutine 間の共有状態にロックを持ち込まずに順序を保てます。処理そのものは並行、書き込み先は分離。この割り切りが、後々のデバッグのしやすさに直結します。
本番運用のためのタイムアウトとコンテキスト設計
context.Background() は入門では便利ですが、本番でそのまま使うと、ネットワークが詰まったときにリクエストが返らないリスクを抱えます。呼び出しごとに締め切りを設けるのが定石です。
// 呼び出し単位で締め切りを設ける
ctx, cancel := context. WithTimeout (context. Background (), 30 * time.Second)
defer cancel ()
result, err := client.Models. GenerateContent (ctx,
"gemini-2.5-flash" , genai. Text (prompt), nil )
if err != nil {
// タイムアウトかどうかで分岐できる
if errors. Is (err, context.DeadlineExceeded) {
log. Println ( "生成がタイムアウトしました。プロンプト長やモデルを見直してください" )
}
return err
}
運用で決めておきたい値を、実測を踏まえて整理すると次のようになります。
処理の種類 推奨タイムアウト 補足
短い分類・要約(Flash) 15〜30 秒 大半は数秒で返るが、混雑時の余裕を見る
長文生成・コード生成 45〜90 秒 MaxOutputTokens に比例して伸びる
ストリーミング 初回チャンク 10 秒 + 全体上限 初回が遅ければ即中断が体験を守る
タイムアウトとリトライは組で考えます。1回の締め切りを短くしすぎるとリトライ回数が増えて総時間はかえって伸びるため、「1回の締め切り × 最大リトライ回数」が、ユーザーの待てる上限に収まるよう逆算するのが実践的です。
クライアントはプロセスに1つだけ持つ
もう一点、締め切り設計と同じくらい体感に効くのが、クライアントの寿命です。私は最初、リクエストのたびに genai.NewClient を呼ぶ実装を書いてしまい、応答が妙に重い時期がありました。クライアントは内部で HTTP コネクションを保持しているため、毎回作り直すと接続の再確立コストがそのまま応答時間に乗ります。
type GeminiService struct {
client * genai . Client
model string
}
func NewGeminiService ( ctx context . Context , model string ) ( * GeminiService , error ) {
client, err := genai. NewClient (ctx, nil )
if err != nil {
return nil , err
}
return & GeminiService {client: client, model: model}, nil
}
// Close はプロセスを畳むときに一度だけ呼ぶ
func ( s * GeminiService ) Close () { s.client. Close () }
ハンドラごとに defer client.Close() と書きたくなりますが、共有クライアントでそれをやると処理中の他のリクエストまで巻き添えで切ることになります。閉じるのはプロセス終了時だけ、と決めておくのが安全です。
ストリーミングは「最初のチャンクまで」を別に計る
先の表に「初回チャンク 10 秒 + 全体上限」と書いたのは、全体タイムアウトだけでは体験を守れないからです。全体 90 秒で組むと、最初の一文字も届かないまま 90 秒待たされる可能性が残ります。ユーザーから見れば、それは遅い応答ではなく無反応です。
やることは単純で、最初のチャンクが届いた時点で止まる見張りを1本足すだけです。
// streamWithFirstChunkDeadline は初回チャンクの遅延を全体上限と分けて監視する
func streamWithFirstChunkDeadline (
ctx context . Context ,
client * genai . Client ,
model , prompt string ,
firstChunk , total time . Duration ,
out func ( string ),
) error {
ctx, cancel := context. WithTimeout (ctx, total)
defer cancel ()
// 初回チャンクが来なければ context を畳む見張り
watchdog := time. AfterFunc (firstChunk, cancel)
first := true
for chunk, err := range client.Models. GenerateContentStream (
ctx, model, genai. Text (prompt), nil ) {
if err != nil {
if first && ctx. Err () != nil {
return fmt. Errorf ( "初回チャンクが %v 以内に届きませんでした" , firstChunk)
}
return err
}
if first {
watchdog. Stop () // 流れ始めたら見張りを解除、以降は全体上限だけが効く
first = false
}
if t := chunk. Text (); t != "" {
out (t)
}
}
return nil
}
要は time.AfterFunc に cancel をそのまま渡している点です。初回が間に合わなければ context が畳まれてイテレータが終了し、間に合えば Stop() で見張りが解けて全体上限だけが残ります。フラグ1つとタイマー1つで済み、監視のために goroutine を余分に起こす必要もありません。
呼び出し側では、初回タイムアウトを失敗ではなく「切り替えの合図」として使えます。私は手元の CLI ツールで、初回 8 秒を過ぎたら Pro を諦めて Flash で投げ直す運用にしています。待ち時間の上限をモデルの都合ではなく体験の側から決められる。これがストリーミングで初回だけを分けて計る、いちばんの効き目だと感じています。
モデル選択とレイテンシ・コストの実測
「どのモデルを指定するか」は、体験とコストを同時に左右します。2026年時点では gemini-flash-latest が最新の安定 Flash を指すエイリアスとして使え、モデル更新に追従しやすくなっています。一方で、挙動を固定したい本番系ではバージョン固定名を使うのが安全です。
私が中継サーバーで同一プロンプト(約400トークン入力・300トークン出力)を各20回投げて測った、体感に近い傾向は次の通りです。数値は環境やネットワークで変動するため、選定の当たりをつける目安として捉えてください。
用途 体感レイテンシ 相対コスト 向いている場面
Flash 系 速い(1〜3 秒台) 低 分類・要約・チャット応答・大量バッチ
Pro 系 やや遅い(3〜8 秒台) 高 複雑な推論・長文設計・コード生成
実装上の指針はシンプルです。まず Flash で組み、品質が要件に届かない箇所だけ Pro に差し替える。最初から Pro を選ぶと、レイテンシもコストも過剰になりがちです。モデル名は設定ファイルや環境変数に逃がしておくと、gemini-2.5-flash と gemini-flash-latest の切り替えがコード変更なしで済み、運用中の比較検証がぐっと楽になります。
まとめ
公式 Go SDK を使えば、テキスト生成からマルチモーダル、ストリーミング、マルチターン会話までは驚くほど短いコードで書けます。そのうえで本番を見据えるなら、再試行すべきエラーの切り分け、待ち時間を散らす full jitter、並行リクエストのセマフォ制御、そして初回チャンクを分けて計るストリーミングの締め切り。この4つが、体験とコストを同時に守る土台になります。
次の一歩として、手元のプロンプトを1つ選び、まず Flash とセマフォ(同時実行 2)で動かしてみてください。そこに isRetryable の分類とジッタ付きバックオフを重ねれば、そのまま小さな本番サービスの骨格になります。より高度なアーキテクチャに踏み込みたい方は、Gemini エージェントシステム本番構築ガイド もあわせてご覧ください。
個人開発の現場で私自身がつまずいた箇所を中心にまとめました。実装の設計判断に少しでも役立てば嬉しいです。お読みいただきありがとうございました。