Upgrade to Pro — share decks privately, control downloads, hide ads and more …

LLMの出力を"いい感じに"する技術 / Taming LLM Output: AI Agen...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Shinichi Nakagawa Shinichi Nakagawa PRO
August 20, 2026
130

LLMの出力を"いい感じに"する技術 / Taming LLM Output: AI Agent Design Patterns with FastAPI

PyCon JP 2026(広島)の発表資料です。

LLM をアプリに組み込むと必ずぶつかる「遅い」「出力が安定しない」の2つを、
FastAPI で作った野球解説 AI Agent「The Scouter 3」を題材に、
どう設計で解いたかを話しました。

■ 課題1「遅い」
SSE で、一括生成したレポートをセクション単位で配信する。
LLM を待たなくていい Stats は先に出す。
速さそのものは変えられないが、"待たせない体験" は設計できる。

■ 課題2「不安定」
LLM は気分屋で、頼んだ形式で返してくれない。
・減らす … 計算・翻訳・突合は、渡す前に Python で済ませる
・縛る … プロンプトを「お願い」ではなく仕様書として書く
・守る … 出口で自分でパースし、型に通してから返す

■ Framework は必要になってから入れる
LangChain を採用しなかった理由と、入れる条件を、要件と時点の意思決定として。

デモでは、対戦したことがない選手同士の対戦を AI が言語化する「仮想対戦」も
お見せしています。大谷翔平 VS 大谷翔平、どちらが勝つのか。

題材は野球ですが、設計パターンはドメインを問わず使えます。

技術構成: Next.js / FastAPI / Google Gemini / Cloud Run

※ 当日ライブデモでお見せした画面は、本資料ではスクリーンショットで掲載しています。

Avatar for Shinichi Nakagawa

Shinichi Nakagawa PRO

August 20, 2026

More Decks by Shinichi Nakagawa

Transcript

  1. 野球解説AI Agent "The Scouter 3" Statcast(トラッキングデータ) × LLM のMLB分析AI Agent

    解説および分析レポートを、人間の解説者っぽい自然言語で生成 開発コード(プロジェクト名)は Betts で、ドジャースのあの人が由来 トラッキングデータをLLMが解説して言語化する、ライトなAI Agentです PyCon JP 2026 | @shinyorke 16
  2. Architecture フロントは Next.js、バックエンドは FastAPI、LLM は Google Gemini Stats(成績) は Zobrist

    API(Statcast データを配信する自作の API)から取得 今日話すのは、Next.js 〜 FastAPI 〜 Gemini の間で起きた事件 PyCon JP 2026 | @shinyorke 17
  3. Architecture blueprint betts-vpc User frontend(公開) 🔒 OAuth2 Google 認証 backend

    Next.js LLM FastAPI Google Gemini API Key認証のみ インターネット経由(Cloud NAT) 今⽇の主戦場⚡ この間で起きた事件の話 PyCon JP 2026 | @shinyorke Zobrist(別プロジェクト・別VPC) API Gateway API Key認証 Zobrist API Go BigQuery ※ Workload(frontend / backend / Zobrist API)はすべて Cloud Run 18
  4. "いい感じ"になるまでの道のり — 2つの課題と技術選定 課題 1 課題 2 考慮すべきこと 遅い 不安定

    Framework LLM出⼒は時間がかかる レポート⽣成に最⼤数⼗秒 LLMが"気分屋"で⾟いです ⾒出しゆらぎ・⾔語混在 LangChain等を使わない理由 技術選定のポイントの話 ※ 画像は「いらすとや」および Python 公式ロゴより引⽤ PyCon JP 2026 | @shinyorke 20
  5. 結論: SSEを採用 ポーリング WebSocket SSE ✓ 通信⽅向 通信⽅向 通信⽅向 プロトコル

    プロトコル プロトコル 実装 実装 実装 総評 総評 総評 Client → Server を繰り返し http / https 結果の紐づけが要る △ 都度問い合わせが⾮効率 PyCon JP 2026 | @shinyorke 双⽅向 ws / wss 常時接続の管理が要る △ 双⽅向通信が無く過剰実装 Server → Client ⽚⽅向 追加ライブラリなし ◎ 要件を満たすかつ容易 24
  6. SSE(Server-Sent Events)のしくみ Client (Next.js) Backend POST /api/v1/report/player event: section data:

    {"section": "style", ...} (FastAPI) text/event-stream event: section data: {"section": "stats", ...} event: done data: {"generationTime": 8.2} event: error(失敗時) レポート本⽂を「スタイル」「成績」などのセクション単位で送信。完了は done、失敗は error PyCon JP 2026 | @shinyorke 25
  7. 設計判断: 「一括生成 → セクション単位で配信」 トークン逐次ストリーム ⼀括⽣成 → セクション配信 ✓ LLM

    LLM ## 強み と課題\n**バ ⚠ 途中のMarkdownは常に壊れている ⚠ セクション途中でパース不能 ⚠ 崩れた描画がユーザーに⾒える 完成したレポート event: section ① =「選⼿スタイル」 event: section ② =「強みと課題」 event: section ③ 実装もUXも不安定 PyCon JP 2026 | @shinyorke =「総合評価」 完成品を分割して流す = UXも実装も安定 27
  8. SSEはただの文字列 # core/sse.py(抜粋) import json def format_sse_event(event: str, data: dict)

    -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" ensure_ascii=False PyCon JP 2026 | @shinyorke でログや DevTools がそのまま読める(送信の必須条件ではない) 28
  9. StreamingResponseに渡すだけ # api/report.py(抜粋) async def event_stream(): sections, generation_time = await

    generate_report(llm, ...) for section_name, content in sections.items(): yield format_sse_event( "section", {"section": section_name, "content": content} ) yield format_sse_event("done", {"generationTime": generation_time}) return StreamingResponse(event_stream(), media_type="text/event-stream") PyCon JP 2026 | @shinyorke 29
  10. 実例: Stats だけ先に出す # api/predict.py(抜粋) # Calculate probabilities (no LLM,

    always fresh) probabilities = calculate_probabilities(body.pitcher_stats, body.batter_stats) async def event_stream(): # Always send probabilities first (instant, no LLM) yield format_sse_event("probabilities", probabilities.model_dump(by_alias=True)) LLMを呼ぶ前に、決定論で計算できる Stats(例: 打率、奪三振、打球速度 etc...)を即送信 PyCon JP 2026 | @shinyorke 30
  11. 運用Tips: SSE と Cloud Run の timeout SSE の接続中も、Cloud Run

    はリクエスト時間としてカウントする timeout は既定 300s・上限 3600s — レポート生成(数十秒)は余裕で収まる それでも既定値には頼らず、frontend / backend とも timeout = "300s" を明示 PyCon JP 2026 | @shinyorke 31
  12. まとめ: 「見えるものから、見せる」 SSE ⼀括⽣成 → セクション単位で配信 先出し Stats は LLM

    を待たない ⾒えるものから、⾒せる 速さは変えられない。"待たせない体験" は設計できる PyCon JP 2026 | @shinyorke 32
  13. LLMは"気分屋" — 頼んだ形式で返してくれない 📝 LLMに期待している出⼒ 💥 実際の出⼒ 強み ① ⾒出しは「##」で書いてね(選⼿レポート)

    強みと課題 → ⾒出しが太字に化ける ② JSONだけを返してね(⽇本語検索) → 余計なフェンス付き。json.loads()が死ぬ ③ モデルの切り替えで⾒出しが変わった(仮想対戦) プロンプトは1⽂字も変えていない Gemini 2.5 flash(変更前) 予想される対戦シナリオ モデル変更 Gemini 3.5 flash(変更後) シナリオ :投手が… プロンプトは"お願い"であって、"保証"ではない PyCon JP 2026 | @shinyorke 36
  14. 作戦: 減らす → 縛る → 守る 減らす 縛る 守る 計算・翻訳・突合はPythonで

    形式・規約を"仕様"に 出⼒を信じない LLMに渡すのは"解釈"だけ プロンプトは"お願い"でなく仕様書 検証 + フォールバックで受ける LLMは"気分屋"さん。我々が出⼒をエスコートしましょう。 PyCon JP 2026 | @shinyorke 37
  15. 減らす①: 指標は"渡す前に"確定させる # services/player_report.py — プロンプトは"穴あき"の器 PLAYER_REPORT_PROMPT = """あなたは野球データアナリストです。(中略) ##

    セイバーメトリクス指標 {sabr_stats_text}""" def format_sabr_stats(stats: PlayerStats) -> str: return ( # 計算済みの値を、人が読める形に整形するだけ f"ISO: {stats.iso:.3f}, BABIP: {stats.babip:.3f}\n" f"K%: {stats.k_rate:.1%}, BB%: {stats.bb_rate:.1%}" ) prompt = PLAYER_REPORT_PROMPT.format(sabr_stats_text=format_sabr_stats(stats), ...) response_text = await llm.generate(prompt) LLM に渡すのは計算結果。計算そのものは渡さない PyCon JP 2026 | @shinyorke 38
  16. 減らす②: 計算・翻訳・突合もPythonで - Sweeper(393球, 平均 136.8 km/h / リーグ平均 134.6

    km/h): - 到達点: リーグ標準の4-seamと比べてグラブ側に53cm・29cm低い(落ちる) - リーグの平均的なSweeperと比べて: グラブ側に3cm・6cm高い、球速は+2.2 km/h - リーグ内順位(規定20球以上の右投手のSweeper 185人中): 変化量合計 104位・横の曲がり 66位・落差 138位 - 結果: AB:85, AVG:0.129(リーグ 0.204), Whiff%:37.1%(リーグ 29.5%) 平均・km/h 換算・順位・突合まで済ませて渡す。LLM は一度も計算していない ※ 検証時の別投手データによる例(Design Doc の検証記録より) PyCon JP 2026 | @shinyorke 39
  17. 縛る①: 出力形式は"仕様"として書く PLAYER_REPORT_PROMPT = """あなたは野球データアナリストです。(中略) **重要**: - 各セクションは必ず「##」見出しで始めてください。 見出し名は上記の通り正確に使用してください -

    指定された文数を超えないでください - 回答はMarkdown形式で記述してください - 全体で{token_budget}トークン以内に収めてください""" 事例①「見出しが化ける」を入口で縛る。見出し名はパーサのアンカー PyCon JP 2026 | @shinyorke 40
  18. 縛る②: 失敗駆動の生成規約 # services/player_report.py(抜粋) # Generation rules embedded verbatim in

    the prompt. PoC testing showed that # WITHOUT these rules the LLM spontaneously converts km/h <-> mph — do not drop. PITCH_ANALYSIS_RULES = { "ja": """… - 球速に言及するときは必ず「km/h」を明記すること - 変化量と結果の関係は「〜という傾向がある」のような相関の表現で記述し、 因果(「曲がるから打たれない」等)と断定しない - 順位に言及するときは必ず「◯人中◯位」の母数付きで記述し、 渡していない順位・割合を作らない - 同球種比較を「軌道」「到達点」と呼んではならない …(全9規約)""", } 規約の1行1行に、実際にやらかした失敗が対応している PyCon JP 2026 | @shinyorke 42
  19. 守る①: 構造化は自分でやる # services/virtual_matchup.py _parse_report()(抜粋) scenario_pattern = r"##\s*予想される対戦シナリオ[\s::]*\n+(.*?)(?=^##\s|\Z)" for key,

    pattern in patterns.items(): match = re.search(pattern, response_text, re.DOTALL | re.MULTILINE) sections[key] = match.group(1).strip() if match else "" ## 見出しパターンで dict 化。 (?=^##\s|\Z) — ### を区切りと誤認しない(事例③の修正) PyCon JP 2026 | @shinyorke 43
  20. 守る②: フォールバックを必ず用意 # services/player_matcher.py _parse_response()(抜粋) # ① コードブロック優先 json_blocks =

    re.findall(r"```json\n?(.*?)\n?```", response_text, re.DOTALL) if json_blocks: return json.loads(json_blocks[0]) # ② 裸のJSONオブジェクト json_match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", response_text, re.DOTALL) if json_match: return json.loads(json_match.group()) # ③ どちらも見つからなければ安全なデフォルト return {"matched_players": [], "confidence": 0.0, "reasoning": "Parse failed"} # ※ json.loads() の失敗は呼び出し元の try/except で捕捉(抜粋では省略)→ success=False 事例② — 「JSON以外の文字は出力しないでください」と縛ってある。それでも付く JSON候補を2形式から抽出し、解析失敗は呼び出し元で安全に処理する PyCon JP 2026 | @shinyorke 44
  21. 守る③: LLM由来の値は、型を通してから返す # services/player_matcher.py(抜粋)— 守る②で解析した結果を、型に通してから返す entries = [ PlayerMatchEntry( #

    型に合わない値はここで例外 — 黙って通さない player_name=m["player_name"], match_score=min(max(float(m.get("match_score", 0.0)), 0.0), 1.0), # 0〜1に固定 ) for m in matched ] return SearchResponse(success=True, players=entries, input_name=name) # api/players.py — endpoint の戻り値も型で宣言(FastAPI が response を検証) async def search_players(...) -> SearchResponse: ... LLM 由来の値は、型を通ってからしか外に出られない PyCon JP 2026 | @shinyorke 45
  22. "気分屋" の出力をどうやって安定させた? 事象 計算・数字問題 見出しが太字化 JSON出力が壊れる モデル変更による破壊 減らす ◯ ‐

    ‐ ‐ 縛る ◯ ◯ ◯ ‐ 事象に応じて「減らす・縛る・守る」を組み合わせて対処 PyCon JP 2026 | @shinyorke 守る ‐ ◯ ◯ ◯ 46
  23. まとめ: 減らす・縛る・守るを標準化する 計算 減らす 翻訳 縛る 解釈 検証 式で答えが決まるもの Python:

    数値→野球語 整形 + 突合 ⾔葉にするだけ 構造化・フォールバック LLMに計算させない 翻訳も突合もPythonで 解釈だけ任せる 出⼝で守る セイバー指標・物理量 LLM: Gemini 守る パーサ + Pydantic 出⼒の安定化を追求した結果の設計(まだ進化中) PyCon JP 2026 | @shinyorke 47
  24. LangChainを採用しなかった理由: 今の要件では必要なかった 野球解説AI Agent要件(2026年時点) ・やることは generate(prompt) 1本 ・マルチモデルは検討していない ・sub agent

    を呼ぶ想定もない ・観測は別途基盤で対応できる LangChain 採⽤に傾く要件 ・マルチプロバイダを切り替えたい ・チェーン/sub agent を編成したい ・観測をエコシステムに寄せたい ・RAG などの部品を再利⽤したい → 今の要件では、どれも該当しない プロダクト要件に合わせて最短距離で実現できる⽅法を選択 PyCon JP 2026 | @shinyorke 49
  25. Tips: LangChainで置き換えた時の影響範囲 計算 式で答えが決まるもの セイバー指標・物理量 減らす 翻訳 Python: 数値→野球語 整形

    + 突合 縛る 解釈 LLM: Gemini ⾔葉にするだけ 守る 検証 パーサ + Pydantic 構造化・フォールバック LangChain が巻き取るのは、ここ 課題1・2 を解いた設計 ̶ そのまま残る Framework が及ぼす影響は限定的 PyCon JP 2026 | @shinyorke 50
  26. 仮想対戦の価値 データ⼗分 わずか4打数 2試合以上 巡ってくる対戦 誠也 vs ミシオロフスキー 実対戦 微対戦

    0打席 未対戦 誠也 vs 投⼿・⼤⾕ 空想科学読本の世界 実現不可能な対戦 投⼿・⼤⾕ vs 打者・⼤⾕ 実対戦データ分析で対応可能な領域 仮想対戦が対応可能な領域 少ないデータの中から 価値ある情報を⾒出し⾔語化 現実にできないIfを AIが推論・⾔語化 AI・LLMとセイバーメトリクスの組み合わせで微対戦・未対戦そして⼤⾕VS⼤⾕を⾔語化 PyCon JP 2026 | @shinyorke 60
  27. まとめ: 2つの課題と技術選定、それぞれの答え 課題1「遅い」への答え ⾒えるものから、⾒せる ⼀括⽣成をセクション単位で配信 ・ Stats は先出し 課題2「不安定」への答え LLMの出⼒は、標準化で安定させる

    減らす(前処理)× 縛る(プロンプトは仕様書)× 守る(パーサ + Pydantic) 「LangChain、使わないんですか?」への答え Frameworkは必要になってから⼊れる 最初は早く動かして検証できる実装を⽬指す。Frameworkは必要になったら⼊れる。 PyCon JP 2026 | @shinyorke 62
  28. 今後やるべきこと 検証基盤(eval) — 推論の記録 → 人手評価 → 回帰検証。設計済み、実装はこれから AI Agentの高度化

    — より実践的な機能を実現するためMulti Agent化 先発投手 VS 相手打線9名の解説 同じ選手の年度別比較、etc... Framework 再検討 — 上の2つが現実になったとき「( 入れる条件」の回収) PyCon JP 2026 | @shinyorke 63
  29. LangChainでやる場合の pros / cons ✅ 得るもの ⚠ 負うもの ・LCEL の宣⾔的な書き味

    ・プロンプト整形の定型が減る ・エコシステム(観測・agent・RAG) ・依存が増える ̶ +14pkg / +10MB ・使わない langsmith も漏れなく同梱 それでも、パーサも検証も⾃前のまま ̶ 課題を解いた設計は置き換わらない PyCon JP 2026 | @shinyorke 67
  30. LangChain比較PoCの詳細 観点 google-genai SDK 依存 25pkg / 29.2MB import(正味) 0.48s

    generate 7.00s stream TTFT / 完了 6.0s / 7.0s 構造化出力 正規表現の2形式抽出 5.1s LangChain +14pkg / +10.0MB(langsmith 2.7MB含む) 0.68s(+0.2s) 6.79s 6.5s / 7.5s with_structured_output 37.8s(n=1) 計測条件: 同一プロンプト / gemini-3.5-flash / global。import のみ中央値5回、他は n=1 PyCon JP 2026 | @shinyorke 68
  31. FastAPI: LLMクライアントはどこに置く? @asynccontextmanager async def lifespan(app: FastAPI): # 起動時に1度だけ走る settings

    = _startup_settings app.state.settings = settings app.state.cache = create_cache(settings) # キャッシュ app.state.zobrist = ZobristClient(settings) # 野球データAPI app.state.llm = GoogleAI(settings) # LLM async with app.state.zobrist: try: yield # ← この間アプリが動く finally: await app.state.llm.aclose() # 終了時に後始末 答え: 起動時に1度だけ作って app.state に置く。リクエストのたびに作らない PyCon JP 2026 | @shinyorke 69
  32. FastAPI: SSEのエラー設計 # ① REST: 例外ハンドラで統一エラーJSON(main.py) @app.exception_handler(AppError) async def app_error_handler(request,

    exc): return JSONResponse( status_code=exc.status_code, content={"error": {"code": exc.code, "message": exc.message}}, ) # ② SSE: ストリーム開始後はステータス変更不可 → errorイベント(api/report.py) except Exception as e: yield format_sse_event("error", {"code": "LLM_ERROR", "message": str(e)}) ストリームを開いたら、もう500は返せない — エラー処理は REST と SSE の2系統 PyCon JP 2026 | @shinyorke 70
  33. SSE をフロントエンドまで透過させる backend(FastAPI)で SSE を実装しておしまい、ではない frontend(Next.js) まで届いて、はじめて完了する 中継点(Next.js API Route)が

    body を読み切る・作り直すと、SSE は固まる 対処: response.body をそのまま Response に包んで返す(バッファしない) PyCon JP 2026 | @shinyorke 71
  34. Architecture deep dive: VPC と ingress betts-vpc frontend(公開) User OAuth2

    Proxy + Next.js ✗ VPC Connector経由 VPC Connector backend(FastAPI) 🔒 API Keyはここだけ保持 BigQuery Cloud NAT ※ Workload(frontend / backend / Zobrist API)はすべて Cloud Run PyCon JP 2026 | @shinyorke Private Google Access 経由 INGRESS_INTERNAL_ONLY 外部から backend は直接叩けない betts → Zobrist は インターネット経由・API Key認証のみ Google Gemini Zobrist(別プロジェクト・別VPC) API Gateway API Key認証 Zobrist API Go 72
  35. シークレットと設定の3分類 分類 例 経路 シークレット 外部APIキー など Terraform → Secret

    Manager → Cloud Run インフラ依存 GCPプロジェクトID・バケット名 など Terraform → Cloud Run アプリ設定 モデル名・temperature など コードに既定値、環境変数で上書き 既定値 なし 原則なし あり Settings は frozen dataclass で起動時に確定。 os.getenv は settings.py の1ファイルだけ PyCon JP 2026 | @shinyorke 73
  36. リポジトリの directory 構成 betts-webapp/ ├── frontend/ # Next.js 16 +

    TypeScript ├── backend/ # FastAPI(LLM Backend) ├── prototype/ # HTML/CSS/JS — UI/UXデザイン検証の"正" ├── terraform/ # GCP(Cloud Run, Secret Manager, VPC...) ├── docs/ │ └── design-docs/ # ISSUE番号_日付_説明.md ├── scripts/ # pre-commitフック用 ├── poc/ # PoCアプリ(marimo)のモジュール ※参照用として git submodule で配置 └── CLAUDE.md # AI向けプロジェクト憲法(→次ページ) prototype がデザインの正 — 先に prototype で検証してから frontend へ移植する PyCon JP 2026 | @shinyorke 74
  37. Claude Code 周りの設定 CLAUDE.md # プロジェクト憲法: 技術スタック・命名規則・ # デザイン資産ルール・禁止事項・Design Doc運用

    .claude/skills/ # 自作スキル11個(定型作業のプロンプト化) ├── deps-update # 依存を最新化して lint/test まで検証 ├── prototype-test # prototype変更時の Playwright E2E(184本)実行 ├── release-prd # バージョン入力・tag付与をガイド ├── docs-consistency # ドキュメント間の整合性チェック ├── design-review # Design Doc レビュー └── ...(deps-check / cicd-setup / gcp-manual-setup 等) .pre-commit-config.yaml # 変更コンポーネントのCIをローカルで強制 # (backend: ruff+ty+pytest / frontend: lint+build) CLAUDE.md および Claude Skills をゲートにすることでプロジェクト規約・品質を安定化 PyCon JP 2026 | @shinyorke 75
  38. 変化量の物理モデル — 反実仮想の基準点(0,0) 同じリリース・同じ初速で投げたら 捕⼿視点の到達⾯(cm)̶ 球種は Curveball 右投⼿ 横から⾒る ̶

    縦の変化(例: Curveball) リリース 基準: リーグ標準 4-seam(点線) 縦のズレ(落ち) 上から⾒る ̶ 横の変化(例: 右投⼿の Slider) リリース ← 腕側 左投⼿ グラブ側 47.5 グラブ側 47.5 グラブ側 → ← グラブ側 落ち 76.0 腕側 → 落ち 76.0 基準: リーグ標準 4-seam(点線) 横のズレ(グラブ側) 縦も横も「基準からのズレ(cm)」で測る 同じ Curveball でも絶対座標では左右が鏡写し → 基準点 ★(0,0) は利き腕別に較正して、同じ物差しにする このズレ(cm)が「変化量」̶ 本編「球種別の変化量」の散布図・順位・AI解説はすべてこの座標系 PyCon JP 2026 | @shinyorke 76
  39. 参考文献 FastAPI 公式ドキュメント — https://fastapi.tiangolo.com/ 自分のブログ(AI Agent) — https://shinyorke.hatenablog.com/entry/ai-agent-for-all-baseball 自分のブログ(仮想対戦)

    — https://shinyorke.hatenablog.com/entry/shohei-vs-mune Baseball Savant 公式仕様 — https://baseballsavant.mlb.com/csv-docs PyCon JP 2026 | @shinyorke 77