0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EinoでGoのReActエージェント実装、MaxIterations超過で回答が消える

0
Posted at

はじめに

対象読者は、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-flash5 リクエスト/分(実測)。ツール 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 点です。

  1. Go 側の型がそのまま活きる: utils.InferTool が構造体からスキーマを起こすので、ツールは「普通の Go 関数」として書けます。Python 版フレームワークからの移植で一番面倒なスキーマ定義が、Go の型システムに吸収されているのは素直に楽でした。
  2. イベントイテレータ型 API は運用と相性が良い: agent.Run が返すのは最終結果ではなくイベント列なので、途中経過のログ・メトリクス・ストリーミング配信を後付けしやすい構造です。今回の対照実験も、イベントを数えるだけで「どこで止まったか」が測れました。
  3. 上限超過の設計は呼び出し側の責任: MaxIterations は暴走を止める安全弁ですが、止まった後の後始末(部分的に実行済みのツール副作用、ユーザーへの返答)は何も面倒を見てくれません。ツールに副作用がある場合は、上限に当たった前提でリカバリー経路を用意しておくべきだと感じました。

なお今回触れたのは安定版の v0.9.13 です。リポジトリでは v0.10.0 系の alpha も並行して開発されているため、ADK 周辺の API は今後変わる可能性があります。

関連情報

おわりに

Eino は Go でエージェントを組むときの現実的な選択肢でした。ReAct エージェント自体は 86 行で動き、ツール定義も Go の型で完結します。一方で MaxIterations の超過は「エラーのみが返り、最終回答は消える・副作用は残る」という挙動なので、本番で使うなら上限値の設定と超過時のリカバリーをセットで設計しておく必要があります。Go のサービスに LLM エージェントを埋め込む予定があるなら、まず MaxIterations を小さくして意図的に超過させ、自分のコードがどう振る舞うか確かめてみることをおすすめします。

関連記事

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?