AVOCAD

Gmail ツールの N+1 を Go の並列処理で 1659ms→310ms に(Python との比較)

LLMAWSGoPython

概要

LINE で動く AI アシスタントの Gmail ツールで、メール一覧の取得が遅いのが気になっていました。原因は Gmail API の仕様からくる N+1 です。同じ処理を Python(順次取得)と Go(goroutine で並列取得)で書いてベンチマークを取ったところ、10 件の一覧取得が 平均 1659.2ms → 310.1ms(5.4 倍) になりました。

一方で、1 件の詳細取得は 154.9ms → 147.9ms とほぼ変わらず。速くなった理由は「Go だから」ではなく「並列 I/O にしたから」でした。この記事では、計測の結果と、Go 版を本番の Python エージェントに組み込むまでをまとめます。

結論

項目 結果
list_emails(N+1 を並列化) 5.4 倍高速化(1659.2ms → 310.1ms)
get_email(単発リクエスト) ほぼ変わらず(154.9ms → 147.9ms)
速くなった理由 goroutine による並列 I/O
言語そのものの速さの差 ネットワーク待ちが主の処理では、ほぼ出ない

なぜ Gmail ツールが遅いのか

Gmail API の messages.list は、メッセージ ID の一覧しか返しません。件名・送信者・日付が欲しければ、ID ごとに messages.get を呼ぶ必要があります。

messages.list      → [id1, id2, id3, ..., id10]
messages.get(id1)  → {subject, from, date, ...}
messages.get(id2)  → {subject, from, date, ...}
...
messages.get(id10) → {subject, from, date, ...}

10 件取るなら 11 回の API 呼び出し。典型的な N+1 です。元の Python 実装は for ループで 1 件ずつ順に取りに行っていたので、1 回あたり約 150ms の待ちが 11 回積み重なっていました。

比較したツールと計測方法

ツール 見たいこと
list_emails N+1(10 件を順次取得)を Go で並列化するとどうなるか
get_email 1 回の API 呼び出し + 本文のデコード・HTML 除去。言語そのものの差が出るか
  • 各ツールを ウォームアップ 1 回 + 計測 50 回 実行
  • 同じ Gmail アカウント・同じネットワーク環境(ローカル)
  • avg / min / max / p50 / p90 を JSON に保存

ベンチマーク一式はこんな構成にしました。

benchmarks/
├── auth.py                 # Google OAuth2 認証(初回だけブラウザで認証 → refresh_token を保存)
├── gmail_bench_python.py   # Python 版ベンチマーク
├── gmail_bench_go/
│   ├── gmail.go            # Go 版 list_emails / get_email
│   └── main.go             # ベンチマーク実行
├── run_benchmark.sh        # 両方を実行して比較を表示
└── results/                # 結果の JSON

Python 側で認証して保存したトークンを Go 側でも読むので、bash benchmarks/run_benchmark.sh 1 本で両方が走ります。

実装の違い

Python 版:順次取得

既存のツール(agent/tools/google_gmail.py)と同じロジックです。

results = (
    service.users()
    .messages()
    .list(userId="me", labelIds=[label], maxResults=max_results)
    .execute()
)

messages = results.get("messages", [])
emails = []
for msg_ref in messages:  # 1 件ずつ順番に取得
    msg = (
        service.users()
        .messages()
        .get(userId="me", id=msg_ref["id"], format="metadata", metadataHeaders=["Subject", "From", "Date"])
        .execute()
    )
    headers = _parse_email_headers(msg.get("payload", {}).get("headers", []))
    emails.append({...})

Go 版:goroutine で並列取得

sync.WaitGroup で全件を同時に取りに行きます。下は本番に入れた版で、ベンチマーク版からエラー処理を足しています。結果は emails[idx] に入れるので、並列でも順番は崩れません。

emails := make([]Email, len(messages))
errs := make([]error, len(messages))
var wg sync.WaitGroup

for i, msgRef := range messages {
	wg.Add(1)
	go func(idx int, id string) {
		defer wg.Done()
		msg, err := svc.Users.Messages.Get("me", id).
			Format("metadata").
			MetadataHeaders("Subject", "From", "Date").
			Context(ctx).
			Do()
		if err != nil {
			errs[idx] = err
			return
		}
		headers := parseHeaders(msg.Payload.Headers)
		emails[idx] = Email{
			ID:      msg.Id,
			Subject: defaultStr(headers["subject"], "(件名なし)"),
			From:    headers["from"],
			Date:    headers["date"],
			// ...
		}
	}(i, msgRef.Id)
}
wg.Wait()

for _, e := range errs {
	if e != nil {
		return nil, e
	}
}
return emails, nil

エラーはスライスで受けて、全件が終わってから 1 つでもあれば返す形にしました。goroutine ごとにスライスの別の添字に書くので、ロックは要りません。

結果

ツール 言語 avg p50 p90 min max
list_emails Python 1659.2ms 1637.6ms 1744.5ms 1573.3ms 2061.7ms
list_emails Go 310.1ms 310.0ms 327.4ms 283.8ms 334.8ms
get_email Python 154.9ms 154.1ms 161.7ms 143.3ms 167.0ms
get_email Go 147.9ms 148.4ms 156.8ms 137.7ms 160.1ms

Go 版は max と min の差も小さく、ばらつきが少ないのも地味にうれしいところです。

結果の読み方

list_emails:5.4 倍

タイムラインで見ると、違いはひと目で分かります。

list API  [====]
msg 1          [====]
msg 2               [====]
msg 3                    [====]
...
msg 10                                         [====]
|---------------- 約 150ms × 11 回 ----------------|
list API  [====]
msg 1          [====]
msg 2          [====]
...                    ← 10 件を同時に
msg 10         [====]
|-- 約 150ms × 2 回 --|

Python は 10 件を 1 件ずつ直列に待つので、約 150ms × 11 回。Go は最初の list のあと 10 件を同時に投げるので、待ちは実質 2 回分です。計測結果(1659.2ms と 310.1ms)も、この見積もりにほぼ合っています。件数が増えるほど、直列の Python 側だけが伸びていく構造です。

get_email:ほぼ同じ

get_email は API 呼び出しが 1 回だけで、待ちのほとんどがネットワーク(約 150ms)です。本文のデコードや HTML の除去はこれに比べて小さく、言語の速さの差は埋もれてしまいました。

:::note info Go が速かったのは、Go のランタイムが速いからではありません。10 件の API 呼び出しを同時に飛ばせたから です。get_email で差が出なかったのがその裏付けです。 :::

Python でも並列化はできる

concurrent.futures.ThreadPoolExecutor や asyncio を使えば、Python でも同じ並列化はできます。なので正確には「Go に書き換えれば速くなる」ではなく、「並列 I/O の形に変えれば速くなる」です。Go の良さは、それが go func() と WaitGroup だけで素直に書けるところだと思います。

本番に組み込む

ベンチマークの結果を受けて、Gmail・カレンダーなどの一部のツールを Go に移しました。ただエージェント本体は Python のままなので、Python から Go のバイナリを呼ぶ形 にしています。

JSON を標準入出力でやり取りする

Go 側は 1 つのバイナリに複数のコマンドをまとめ、標準入力で {"command": ..., "params": ...} を受け取って、結果を標準出力に JSON で返します。

func main() {
	req, err := ioproto.ReadRequest()
	if err != nil {
		ioproto.WriteError(err.Error())
	}

	switch req.Command {
	case "gmail.list-emails":
		cmd.ListEmails(req.Params)
	case "gmail.search-emails":
		cmd.SearchEmails(req.Params)
	case "gmail.get-email":
		cmd.GetEmail(req.Params)
	// Calendar / Habit も同じ形
	default:
		ioproto.WriteError("unknown command: " + req.Command)
	}
}

Python 側は subprocess.run で呼ぶだけです。

def call_go_tool(command: str, params: dict) -> dict | None:
    binary = _find_binary()
    if binary is None:
        return None  # バイナリが無ければ Python 版を使う

    request = json.dumps({"command": command, "params": params})
    try:
        result = subprocess.run(
            [binary], input=request, capture_output=True, text=True, timeout=30,
            env={**os.environ},
        )
    except subprocess.TimeoutExpired:
        logger.warning("Go tool timed out: %s", command)
        return None
    # ... JSON として読めなければ None、status が error なら例外
    _last_backend.set("go")
    return response.get("data")

Python 版をフォールバックとして残す

ツール側は「まず Go を呼び、だめなら今までの Python 版」という形にしました。バイナリが無いローカル環境でも、Go 側でエラーが起きても、ツール自体は動き続けます。

@tracked_tool("gmail")
def list_emails(label: str = "INBOX", max_results: int = 10) -> str:
    # Go ブリッジ (goroutine 並列取得で高速)
    try:
        go_result = call_go_tool("gmail.list-emails", {
            "access_token": get_access_token(),
            "label": label,
            "max_results": max_results,
        })
        if go_result is not None:
            return json.dumps(go_result, ensure_ascii=False)
    except Exception:
        logger.warning("Go bridge failed for list_emails, falling back to Python", exc_info=True)

    # Python フォールバック
    service = _get_service()
    # ...(上の順次取得のコード)

Go の構造体には Python 版と同じ JSON のキー(thread_id・label_ids など)を付けて、どちらが答えても呼び出し元から見た形が変わらないようにしています。

コンテナにはマルチステージで入れる

エージェントはコンテナで動かしているので、Dockerfile をマルチステージにして、Go のビルド結果だけを Python のイメージにコピーしました。

FROM --platform=linux/arm64 golang:1.25-bookworm AS go-builder
WORKDIR /build
COPY gotools/ .
RUN go mod download && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o agent-tools .

FROM --platform=linux/arm64 ghcr.io/astral-sh/uv:python3.13-bookworm-slim
# ...(Python の依存を入れる)
COPY . .
COPY --from=go-builder /build/agent-tools /app/gotools/agent-tools

どちらで動いたかを記録する

今回の数字はローカルでのベンチマークなので、本番でも同じだけ効くかは別に確かめる必要があります。そこで、ツールごとに「実行時間」と「Go と Python のどちらで動いたか」を記録するデコレータを作り、全ツールの @tool を置き換えました。

def tracked_tool(agent_name: str):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            reset_backend()
            start = time.perf_counter()
            status = "ok"
            try:
                return func(*args, **kwargs)
            except Exception:
                status = "error"
                raise
            finally:
                duration_ms = (time.perf_counter() - start) * 1000
                _record_async(  # DynamoDB に別スレッドで書く
                    tool_name=func.__name__, agent_name=agent_name,
                    duration_ms=duration_ms, backend=get_backend(), status=status,
                )
        return tool(wrapper)
    return decorator

call_go_tool が成功したときだけ ContextVar の値が "go" になるので、フォールバックした回は "python" として残ります。

:::note warn 本番では、ツールを呼ぶたびに Go のプロセスを起動するぶんの時間が上乗せされます。この記事の数字はローカルのベンチマークのもので、本番の環境での効果はまだ測っていません。 :::

まとめ

  • Gmail の一覧取得は、API の仕様上 N+1 になる
  • 10 件の取得を goroutine で並列にすると、平均 1659.2ms → 310.1ms(5.4 倍)になった
  • 1 件だけの取得はほぼ変わらない(154.9ms → 147.9ms)。速さの理由は言語ではなく並列 I/O
  • 本番では Python から Go のバイナリを JSON でやり取りして呼び、失敗したら Python 版に戻る形にした

「重い処理を Go で書き直す」の前に、まず「待ちが直列になっていないか」を見るのが大事だな、と改めて思いました。

← ブログ一覧へ戻る