はじめに
対象読者は、Python 以外(Go)で LLM エージェントを実装したい、あるいは既存の Go サービスにエージェントを組み込みたい開発者 です。
エージェントフレームワークというと LangChain / LangGraph のような Python 製が定番ですが、Go にも ByteDance が公開している Eino があります。公式ドキュメント(Eino User Manual)では「Golang ベースの AI アプリケーション開発フレームワーク」と説明されており、ADK(Agent Development Kit)・Chain/Graph によるオーケストレーション・ChatModel などのコンポーネント群を持ちます。
今回は Eino v0.9.13 と Gemini を組み合わせて ReAct エージェントを実際に動かし、そのうえで MaxIterations を絞ったときに何が返ってくるか を対照実験で測りました。結論から言うと、上限に当たったエージェントは「途中まで動いた結果」を残したままエラーで終わり、最終回答は返ってきません。
TL;DR
- Eino v0.9.13 +
eino-extの Gemini コンポーネントで、ツール呼び出し付き ReAct エージェントが 86 行 で動きました(実測 3.0 秒 / ツール 3 回 / イベント 7 件) -
MaxIterationsの既定値は 20。超過するとエラーで終了し、最終アシスタントメッセージは生成されません(実測: イベント 4 件・ツール 2 回・final_textは空) - 超過エラーは
NodeRunErrorにラップされますがerrors.Is(err, adk.ErrExceedMaxIterations)で判別できます。API エラー(429 等)と切り分けてリトライ設計ができます - ハマりどころ: 検証時の Gemini 無料枠は
gemini-2.5-flashで 5 リクエスト/分(実測)。ツール 3 回の ReAct 1 回で 4 リクエスト消費するため、連続実行すると 2 回目で 429 になります
検証環境
すべて Linux コンテナ上で実際に実行したものです。
| 項目 | バージョン |
|---|---|
| Go | 1.24.7 |
| github.com/cloudwego/eino | v0.9.13 |
| github.com/cloudwego/eino-ext/components/model/gemini | v0.1.33 |
| google.golang.org/genai | v1.36.0 |
| モデル | gemini-2.5-flash |
セットアップは Go モジュールを取得するだけです。
go mod init einotest
go get github.com/cloudwego/eino@latest
go get github.com/cloudwego/eino-ext/components/model/gemini@latest
eino 本体は依存が軽く、go get 一発で v0.9.13 が入りました。モデルプロバイダは本体ではなく eino-ext 側に分かれており、Gemini を使う場合は google.golang.org/genai が一緒に入ります。
最小構成の ReAct エージェント
Eino の ADK には adk.NewChatModelAgent があり、ChatModel とツールを渡すだけで ReAct ループ(モデル → ツール呼び出し → モデル)が組み上がります。為替レートを返す偽ツールを 1 つ用意しました。
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/cloudwego/eino-ext/components/model/gemini"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"google.golang.org/genai"
)
type rateInput struct {
Base string `json:"base" jsonschema_description:"base currency code"`
Quote string `json:"quote" jsonschema_description:"quote currency code"`
}
func main() {
ctx := context.Background()
client, err := genai.NewClient(ctx, &genai.ClientConfig{APIKey: os.Getenv("GEMINI_API_KEY")})
if err != nil {
log.Fatalf("genai.NewClient failed: %v", err)
}
cm, err := gemini.NewChatModel(ctx, &gemini.Config{Model: "gemini-2.5-flash", Client: client})
if err != nil {
log.Fatalf("gemini.NewChatModel failed: %v", err)
}
rateTool, err := utils.InferTool("get_rate", "get the fixed exchange rate between two currencies",
func(ctx context.Context, in *rateInput) (string, error) {
log.Printf("[tool] get_rate called: base=%s quote=%s", in.Base, in.Quote)
return fmt.Sprintf("1 %s = 155.42 %s", in.Base, in.Quote), nil
})
if err != nil {
log.Fatalf("InferTool failed: %v", err)
}
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "rate_agent",
Description: "currency rate agent",
Instruction: "You are a helper. Use get_rate when asked about exchange rates. Answer in Japanese, one sentence.",
Model: cm,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{Tools: []tool.BaseTool{rateTool}},
},
})
if err != nil {
log.Fatalf("NewChatModelAgent failed: %v", err)
}
start := time.Now()
iter := agent.Run(ctx, &adk.AgentInput{
Messages: []adk.Message{{Role: schema.User, Content: "1ドルは何円ですか?"}},
})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
log.Fatalf("agent.Run failed: %v", ev.Err)
}
msg, mErr := ev.Output.MessageOutput.GetMessage()
if mErr != nil {
log.Fatalf("GetMessage failed: %v", mErr)
}
log.Printf("event: role=%s tool_calls=%d content=%q", msg.Role, len(msg.ToolCalls), msg.Content)
}
log.Printf("elapsed=%s", time.Since(start).Round(time.Millisecond))
}
ツール定義に注目してください。utils.InferTool は Go の構造体タグ(jsonschema_description)から JSON Schema を自動生成するので、スキーマを手書きする必要がありません。ツールの実体はただの Go 関数なので、既存のリポジトリ層やクライアントをそのまま渡せます。
実行結果です。
event: role=assistant tool_calls=1 content=""
[tool] get_rate called: base=USD quote=JPY
event: role=tool tool_calls=0 content="1 USD = 155.42 JPY"
event: role=assistant tool_calls=0 content="1ドルは155.42円です。"
elapsed=1.773s
モデルがツールを選び、結果を受け取って日本語で答えるまで 1.8 秒でした。agent.Run は結果を一括で返さず イベントのイテレータ を返すので、途中経過をそのままログやストリームに流せます。
対照実験: MaxIterations を絞ると何が返るか
adk.ChatModelAgentConfig には MaxIterations があり、ソースコード上のコメントには「ChatModel の生成サイクルの上限。超過するとエラーで終了する。既定値 20」と書かれています。実際に何が返るのかを、同じプロンプト・同じツールで 2 条件比較しました。
- 依頼: 「USD/JPY、EUR/JPY、GBP/JPY のレートをそれぞれ調べて、最後にまとめて教えてください」(ツール 3 回+まとめ = モデル 4 サイクル必要)
- 条件 A:
MaxIterations未指定(既定 20) - 条件 B:
MaxIterations: 2
| 条件 | イベント数 | ツール実行回数 | エラー | 最終回答 | 所要時間 |
|---|---|---|---|---|---|
| A(既定 20) | 7 | 3 | なし | 3 通貨ペアの要約が返る | 3.015 秒 |
B(MaxIterations: 2) |
4 | 2 | exceeds max iterations |
空文字列 | 2.527 秒 |
条件 B のログはこうなりました。
[maxIter=2] event 1: role=assistant tool_calls=1 content_len=0
[maxIter=2] event 2: role=tool tool_calls=0 content_len=18
[maxIter=2] event 3: role=assistant tool_calls=1 content_len=0
[maxIter=2] event 4: role=tool tool_calls=0 content_len=18
[maxIter=2] event error: [NodeRunError] run node[ChatModel] pre processor fail: exceeds max iterations
node path: [node_1, ChatModel] (is ErrExceedMaxIterations=true)
RESULT maxIter=2 events=4 tool_invocations=2 final_text=""
見落としやすいのは、途中まで進んだ副作用がそのまま残ることです。ツールは 2 回実行済みで、もしそれが外部 API への書き込みだったら、その書き込みは成立したまま呼び出し側にはエラーだけが返ります。上限は 打ち切りであってロールバックではありません。
もうひとつ、エラーの見分け方です。超過エラーは NodeRunError にラップされて届きますが、errors.Is は貫通しました。
if ev.Err != nil {
if errors.Is(ev.Err, adk.ErrExceedMaxIterations) {
// 上限超過。リトライしても同じなので、上限を上げるかタスクを分割する
} else {
// API エラー等。バックオフして再試行する余地がある
}
}
上限超過は「リトライしても無意味」、API エラーは「待てば通る」なので、この 2 つを取り違えるとリトライ設計が壊れます。文字列マッチではなく errors.Is で判定できるのは実装上ありがたい挙動でした。
ハマりどころ: 無料枠は ReAct 1 回で使い切る
条件 A と条件 B を 1 つのプログラムで連続実行したところ、条件 B の 1 回目が即座に 429 で落ちました。
[NodeRunError] send message fail: Error 429, Message: You exceeded your current quota ...
* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests,
limit: 5, model: gemini-2.5-flash
Please retry in 35.161993329s.
Gemini API のレート制限ドキュメント にあるとおり、無料枠には分あたりのリクエスト上限があります。ただし公式ページは固定の数値表ではなく、利用ティアやアカウント状況に依存するため Google AI Studio のダッシュボードで確認する、という案内になっています。具体的な数値はドキュメントからではなく、実際に返ってきた 429 のメッセージから読み取るのが確実です。今回受け取ったエラーには limit: 5, model: gemini-2.5-flash と表示されていました(検証時点・筆者のプロジェクトでの実測値)。ここで見落としやすいのは、ReAct エージェントの 1 回の実行が 1 リクエストではない ことです。条件 A はツール 3 回+最終回答で 4 リクエストを消費しています。つまり無料枠では「エージェント実行 1 回で分の枠をほぼ使い切る」計算になります。
80 秒待って条件 B だけを単独実行し直したところ、上表のとおり正常に上限超過の挙動を観測できました。ローカル検証でエージェントのループを回すときは、モデル呼び出し回数 = イテレーション数 を前提にレート制限を見積もっておくと無駄な 429 を踏まずに済みます。
著者の所見
実際に手を動かして感じたのは次の 3 点です。
-
Go 側の型がそのまま活きる:
utils.InferToolが構造体からスキーマを起こすので、ツールは「普通の Go 関数」として書けます。Python 版フレームワークからの移植で一番面倒なスキーマ定義が、Go の型システムに吸収されているのは素直に楽でした。 -
イベントイテレータ型 API は運用と相性が良い:
agent.Runが返すのは最終結果ではなくイベント列なので、途中経過のログ・メトリクス・ストリーミング配信を後付けしやすい構造です。今回の対照実験も、イベントを数えるだけで「どこで止まったか」が測れました。 -
上限超過の設計は呼び出し側の責任:
MaxIterationsは暴走を止める安全弁ですが、止まった後の後始末(部分的に実行済みのツール副作用、ユーザーへの返答)は何も面倒を見てくれません。ツールに副作用がある場合は、上限に当たった前提でリカバリー経路を用意しておくべきだと感じました。
なお今回触れたのは安定版の v0.9.13 です。リポジトリでは v0.10.0 系の alpha も並行して開発されているため、ADK 周辺の API は今後変わる可能性があります。
関連情報
おわりに
Eino は Go でエージェントを組むときの現実的な選択肢でした。ReAct エージェント自体は 86 行で動き、ツール定義も Go の型で完結します。一方で MaxIterations の超過は「エラーのみが返り、最終回答は消える・副作用は残る」という挙動なので、本番で使うなら上限値の設定と超過時のリカバリーをセットで設計しておく必要があります。Go のサービスに LLM エージェントを埋め込む予定があるなら、まず MaxIterations を小さくして意図的に超過させ、自分のコードがどう振る舞うか確かめてみることをおすすめします。