Slide 1

Slide 1 text

LLMの出力を"いい感じに"する技術 FastAPIで仕上げるAI Agent設計パターンを野球AIで実践した話 Shinichi Nakagawa(@shinyorke) / PyCon JP 2026

Slide 2

Slide 2 text

免責事項 発表は個人の見解であり、所属組織を代表するものではありません コンテンツは自作およびAIとの共作です、必要に応じて出典を明記しております 本業の話については一切しません(転職先についても回答を差し控えます) PyCon JP 2026 | @shinyorke 2

Slide 3

Slide 3 text

自己紹介 Shinichi Nakagawa(@shinyorke) AI PlatformのSRE(でした) 9月からまた違うAI関係のエンジニア職 個人として野球AIエンジニア 推し: 万波中正、ニック・カーツ PyCon JP 2026 | @shinyorke 3

Slide 4

Slide 4 text

この発表を見てほしい・聴いてほしい人 LLMをWebアプリに組み込みたい人すべて 、特にFastAPIで何かやってる人 「LangChain使うべき?」など、手段と設計でお悩みの人 鈴木誠也選手をはじめとしたメジャーリーガーの話で一緒に盛り上がってくれる人 PyCon JP 2026 | @shinyorke 4

Slide 5

Slide 5 text

TL;DR ⾒えるものから、⾒せる(SSE) LLMの出⼒は、標準化で安定させる(減らす×縛る×守る) Frameworkは必要になってから⼊れる PyCon JP 2026 | @shinyorke 5

Slide 6

Slide 6 text

野球解説をするAI Agent、作ってます まずは、動いているものを見てください PyCon JP 2026 | @shinyorke 6

Slide 7

Slide 7 text

実際の画面 当日はライブデモでお見せした画面です 掲載画面のデータ: 最新試合日 2026-07-30(米国時間)/ 取得 2026-08-01(JST)

Slide 8

Slide 8 text

AI解説: 鈴木誠也 PyCon JP 2026 | @shinyorke 打撃Statsを元に解説文を作成 8

Slide 9

Slide 9 text

鈴木誠也: 成績と球種別の相性 PyCon JP 2026 | @shinyorke セイバー指標のみならず、球種別成績を表示 9

Slide 10

Slide 10 text

鈴木誠也: 打球分布 打球位置を結果別にプロット。長打がどこに飛んでいるかが見える PyCon JP 2026 | @shinyorke 10

Slide 11

Slide 11 text

対戦モード: 鈴木誠也 vs ミシオロフスキー PyCon JP 2026 | @shinyorke 対戦相手を指定すると、そのカードに絞った分析ができる 11

Slide 12

Slide 12 text

誠也 vs ミシオロフスキー: 投球位置と成績 PyCon JP 2026 | @shinyorke この対戦だけの投球位置・球種別成績まで絞り込める 12

Slide 13

Slide 13 text

AI解説: 山本由伸 PyCon JP 2026 | @shinyorke 投球Statsと変化量(後述)を元に解説文を作成 13

Slide 14

Slide 14 text

山本由伸: 球種別の変化量 PyCon JP 2026 | @shinyorke 1球ずつリーグ標準の4シームと比べ、球種ごとに順位まで出す 14

Slide 15

Slide 15 text

山本由伸: 球速推移 PyCon JP 2026 | @shinyorke 球種ごとの球速を、投球数順と試合日順の2軸で追える 15

Slide 16

Slide 16 text

野球解説AI Agent "The Scouter 3" Statcast(トラッキングデータ) × LLM のMLB分析AI Agent 解説および分析レポートを、人間の解説者っぽい自然言語で生成 開発コード(プロジェクト名)は Betts で、ドジャースのあの人が由来 トラッキングデータをLLMが解説して言語化する、ライトなAI Agentです PyCon JP 2026 | @shinyorke 16

Slide 17

Slide 17 text

Architecture フロントは Next.js、バックエンドは FastAPI、LLM は Google Gemini Stats(成績) は Zobrist API(Statcast データを配信する自作の API)から取得 今日話すのは、Next.js 〜 FastAPI 〜 Gemini の間で起きた事件 PyCon JP 2026 | @shinyorke 17

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

やってることは、シンプル レポートの文章化は Gemini、計算・整形・検証は FastAPI 側 バックエンドは FastAPI 一本 構成は至ってシンプルだが 2つの課題と考慮すべきことがあった PyCon JP 2026 | @shinyorke 19

Slide 20

Slide 20 text

"いい感じ"になるまでの道のり — 2つの課題と技術選定 課題 1 課題 2 考慮すべきこと 遅い 不安定 Framework LLM出⼒は時間がかかる レポート⽣成に最⼤数⼗秒 LLMが"気分屋"で⾟いです ⾒出しゆらぎ・⾔語混在 LangChain等を使わない理由 技術選定のポイントの話 ※ 画像は「いらすとや」および Python 公式ロゴより引⽤ PyCon JP 2026 | @shinyorke 20

Slide 21

Slide 21 text

課題1「遅い」

Slide 22

Slide 22 text

出力は出せるものから LLM の生成は 数秒〜十数秒(通常のAPIレスポンスより時間がかかる) 「全てのコンテンツを読み込むまで画面が真っ白」が一番つらい 結論: LLM を待たずに出せるデータを先に返し、AIレポートは SSE で後続配信 PyCon JP 2026 | @shinyorke 22

Slide 23

Slide 23 text

代表的な実現方式 ポーリング — 一定間隔で「できた?」と聞きに行く WebSocket — 常時接続で双方向にやりとりする SSE — サーバーから一方通行で送り続ける PyCon JP 2026 | @shinyorke 23

Slide 24

Slide 24 text

結論: SSEを採用 ポーリング WebSocket SSE ✓ 通信⽅向 通信⽅向 通信⽅向 プロトコル プロトコル プロトコル 実装 実装 実装 総評 総評 総評 Client → Server を繰り返し http / https 結果の紐づけが要る △ 都度問い合わせが⾮効率 PyCon JP 2026 | @shinyorke 双⽅向 ws / wss 常時接続の管理が要る △ 双⽅向通信が無く過剰実装 Server → Client ⽚⽅向 追加ライブラリなし ◎ 要件を満たすかつ容易 24

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

すぐ出るデータと、時間がかかるデータ Stats(成績) — セイバーメトリクスは計算式が決まっている。LLM 不要ですぐ出る AIレポート — Gemini が生成。数秒〜十数秒かかる 同じ画面に両方ある。まとめて返すと、速いものまで待たされる PyCon JP 2026 | @shinyorke 26

Slide 27

Slide 27 text

設計判断: 「一括生成 → セクション単位で配信」 トークン逐次ストリーム ⼀括⽣成 → セクション配信 ✓ LLM LLM ## 強み と課題\n**バ ⚠ 途中のMarkdownは常に壊れている ⚠ セクション途中でパース不能 ⚠ 崩れた描画がユーザーに⾒える 完成したレポート event: section ① =「選⼿スタイル」 event: section ② =「強みと課題」 event: section ③ 実装もUXも不安定 PyCon JP 2026 | @shinyorke =「総合評価」 完成品を分割して流す = UXも実装も安定 27

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

実例: 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

Slide 31

Slide 31 text

運用Tips: SSE と Cloud Run の timeout SSE の接続中も、Cloud Run はリクエスト時間としてカウントする timeout は既定 300s・上限 3600s — レポート生成(数十秒)は余裕で収まる それでも既定値には頼らず、frontend / backend とも timeout = "300s" を明示 PyCon JP 2026 | @shinyorke 31

Slide 32

Slide 32 text

まとめ: 「見えるものから、見せる」 SSE ⼀括⽣成 → セクション単位で配信 先出し Stats は LLM を待たない ⾒えるものから、⾒せる 速さは変えられない。"待たせない体験" は設計できる PyCon JP 2026 | @shinyorke 32

Slide 33

Slide 33 text

課題2「不安定」

Slide 34

Slide 34 text

辛いです。LLMが気分屋さんだから... PyCon JP 2026 | @shinyorke 出典: いらすとや「護摩行のイラスト」 34

Slide 35

Slide 35 text

出力安定化の基本: LLMに計算を任せない LLM は確率的なモデル。同じことを聞いても、同じ数字が返るとは限らない 一方、野球の一般成績およびセイバーメトリクス指標(OPS、ISO 等)は計算式が決定的 LLM に任せるのではなく、最初から計算用ロジックを用意 決定論で出せるものは実装。LLM に任せるのは "解釈" だけ PyCon JP 2026 | @shinyorke 35

Slide 36

Slide 36 text

LLMは"気分屋" — 頼んだ形式で返してくれない 📝 LLMに期待している出⼒ 💥 実際の出⼒ 強み ① ⾒出しは「##」で書いてね(選⼿レポート) 強みと課題 → ⾒出しが太字に化ける ② JSONだけを返してね(⽇本語検索) → 余計なフェンス付き。json.loads()が死ぬ ③ モデルの切り替えで⾒出しが変わった(仮想対戦) プロンプトは1⽂字も変えていない Gemini 2.5 flash(変更前) 予想される対戦シナリオ モデル変更 Gemini 3.5 flash(変更後) シナリオ :投手が… プロンプトは"お願い"であって、"保証"ではない PyCon JP 2026 | @shinyorke 36

Slide 37

Slide 37 text

作戦: 減らす → 縛る → 守る 減らす 縛る 守る 計算・翻訳・突合はPythonで 形式・規約を"仕様"に 出⼒を信じない LLMに渡すのは"解釈"だけ プロンプトは"お願い"でなく仕様書 検証 + フォールバックで受ける LLMは"気分屋"さん。我々が出⼒をエスコートしましょう。 PyCon JP 2026 | @shinyorke 37

Slide 38

Slide 38 text

減らす①: 指標は"渡す前に"確定させる # 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

Slide 39

Slide 39 text

減らす②: 計算・翻訳・突合も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

Slide 40

Slide 40 text

縛る①: 出力形式は"仕様"として書く PLAYER_REPORT_PROMPT = """あなたは野球データアナリストです。(中略) **重要**: - 各セクションは必ず「##」見出しで始めてください。 見出し名は上記の通り正確に使用してください - 指定された文数を超えないでください - 回答はMarkdown形式で記述してください - 全体で{token_budget}トークン以内に収めてください""" 事例①「見出しが化ける」を入口で縛る。見出し名はパーサのアンカー PyCon JP 2026 | @shinyorke 40

Slide 41

Slide 41 text

計算させていないのに、計算する km/h で渡した球速を mph に戻し、渡していない順位を作る パーサは通る。日本語も自然 — だから気づけない PyCon JP 2026 | @shinyorke 41

Slide 42

Slide 42 text

縛る②: 失敗駆動の生成規約 # 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

Slide 43

Slide 43 text

守る①: 構造化は自分でやる # 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

Slide 44

Slide 44 text

守る②: フォールバックを必ず用意 # 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

Slide 45

Slide 45 text

守る③: 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

Slide 46

Slide 46 text

"気分屋" の出力をどうやって安定させた? 事象 計算・数字問題 見出しが太字化 JSON出力が壊れる モデル変更による破壊 減らす ◯ ‐ ‐ ‐ 縛る ◯ ◯ ◯ ‐ 事象に応じて「減らす・縛る・守る」を組み合わせて対処 PyCon JP 2026 | @shinyorke 守る ‐ ◯ ◯ ◯ 46

Slide 47

Slide 47 text

まとめ: 減らす・縛る・守るを標準化する 計算 減らす 翻訳 縛る 解釈 検証 式で答えが決まるもの Python: 数値→野球語 整形 + 突合 ⾔葉にするだけ 構造化・フォールバック LLMに計算させない 翻訳も突合もPythonで 解釈だけ任せる 出⼝で守る セイバー指標・物理量 LLM: Gemini 守る パーサ + Pydantic 出⼒の安定化を追求した結果の設計(まだ進化中) PyCon JP 2026 | @shinyorke 47

Slide 48

Slide 48 text

「LangChain、使わないんですか?」

Slide 49

Slide 49 text

LangChainを採用しなかった理由: 今の要件では必要なかった 野球解説AI Agent要件(2026年時点) ・やることは generate(prompt) 1本 ・マルチモデルは検討していない ・sub agent を呼ぶ想定もない ・観測は別途基盤で対応できる LangChain 採⽤に傾く要件 ・マルチプロバイダを切り替えたい ・チェーン/sub agent を編成したい ・観測をエコシステムに寄せたい ・RAG などの部品を再利⽤したい → 今の要件では、どれも該当しない プロダクト要件に合わせて最短距離で実現できる⽅法を選択 PyCon JP 2026 | @shinyorke 49

Slide 50

Slide 50 text

Tips: LangChainで置き換えた時の影響範囲 計算 式で答えが決まるもの セイバー指標・物理量 減らす 翻訳 Python: 数値→野球語 整形 + 突合 縛る 解釈 LLM: Gemini ⾔葉にするだけ 守る 検証 パーサ + Pydantic 構造化・フォールバック LangChain が巻き取るのは、ここ 課題1・2 を解いた設計 ̶ そのまま残る Framework が及ぼす影響は限定的 PyCon JP 2026 | @shinyorke 50

Slide 51

Slide 51 text

Framework(LangChain)を入れる条件 このプロダクトでは、検証基盤を入れるときに初めて再検討する 観測・評価を、自前で作り込まずエコシステムに乗せるとき 1本の生成が、複数の呼び出しの編成になるとき(Agentの高度化) Frameworkは必要になってから入れる PyCon JP 2026 | @shinyorke 51

Slide 52

Slide 52 text

また野球の話をします

Slide 53

Slide 53 text

AI Agentだからこその機能「仮想対戦」 「対戦したことがない」選手同士の対戦をシミュレートする機能 両者の Stats を元に「仮に対戦したらどうなる」を AIが言語化 解説だけでなく、有利不利の可視化、攻略法などを提示 「大谷翔平 vs 山本由伸」みたいな同チーム対戦等の組合せをシミュレーション可能 PyCon JP 2026 | @shinyorke 53

Slide 54

Slide 54 text

「本人 VS 本人」も言語化可能、例えば... PyCon JP 2026 | @shinyorke ※ChatGPTで作成 54

Slide 55

Slide 55 text

大谷翔平 VS 大谷翔平 #もはや◯ワプロの世界 #空想科学読本かっ PyCon JP 2026 | @shinyorke 55

Slide 56

Slide 56 text

実際の画面 当日はライブデモでお見せした画面です 掲載画面のデータ: 最新試合日 2026-07-30(米国時間)/ 取得 2026-08-01(JST)

Slide 57

Slide 57 text

仮想対戦: 投手・大谷 vs 打者・大谷 PyCon JP 2026 | @shinyorke 現実には絶対に実現しないカード。判定は五分 57

Slide 58

Slide 58 text

大谷 vs 大谷: AI分析レポート PyCon JP 2026 | @shinyorke 同一人物なのに、投手側と打者側を書き分ける 58

Slide 59

Slide 59 text

大谷 vs 大谷: 予想される対戦シナリオ PyCon JP 2026 | @shinyorke 典型的な配球シナリオを1球ずつ書く 59

Slide 60

Slide 60 text

仮想対戦の価値 データ⼗分 わずか4打数 2試合以上 巡ってくる対戦 誠也 vs ミシオロフスキー 実対戦 微対戦 0打席 未対戦 誠也 vs 投⼿・⼤⾕ 空想科学読本の世界 実現不可能な対戦 投⼿・⼤⾕ vs 打者・⼤⾕ 実対戦データ分析で対応可能な領域 仮想対戦が対応可能な領域 少ないデータの中から 価値ある情報を⾒出し⾔語化 現実にできないIfを AIが推論・⾔語化 AI・LLMとセイバーメトリクスの組み合わせで微対戦・未対戦そして⼤⾕VS⼤⾕を⾔語化 PyCon JP 2026 | @shinyorke 60

Slide 61

Slide 61 text

Wrap up

Slide 62

Slide 62 text

まとめ: 2つの課題と技術選定、それぞれの答え 課題1「遅い」への答え ⾒えるものから、⾒せる ⼀括⽣成をセクション単位で配信 ・ Stats は先出し 課題2「不安定」への答え LLMの出⼒は、標準化で安定させる 減らす(前処理)× 縛る(プロンプトは仕様書)× 守る(パーサ + Pydantic) 「LangChain、使わないんですか?」への答え Frameworkは必要になってから⼊れる 最初は早く動かして検証できる実装を⽬指す。Frameworkは必要になったら⼊れる。 PyCon JP 2026 | @shinyorke 62

Slide 63

Slide 63 text

今後やるべきこと 検証基盤(eval) — 推論の記録 → 人手評価 → 回帰検証。設計済み、実装はこれから AI Agentの高度化 — より実践的な機能を実現するためMulti Agent化 先発投手 VS 相手打線9名の解説 同じ選手の年度別比較、etc... Framework 再検討 — 上の2つが現実になったとき「( 入れる条件」の回収) PyCon JP 2026 | @shinyorke 63

Slide 64

Slide 64 text

我思う — この先やりたいこと 「難しい」を「やさしく」 — LLMで"いい感じに"やさしく言語化 迷わせない — 専門知識を適切にオンボーディングすることで"いい感じに"気づきを得る 野球に関わるすべての人のバディとして、AIで貢献していくぞ! PyCon JP 2026 | @shinyorke 64

Slide 65

Slide 65 text

ご清聴ありがとうございました Shinichi Nakagawa(@shinyorke) PyCon JP 2026 | @shinyorke 65

Slide 66

Slide 66 text

Appendix

Slide 67

Slide 67 text

LangChainでやる場合の pros / cons ✅ 得るもの ⚠ 負うもの ・LCEL の宣⾔的な書き味 ・プロンプト整形の定型が減る ・エコシステム(観測・agent・RAG) ・依存が増える ̶ +14pkg / +10MB ・使わない langsmith も漏れなく同梱 それでも、パーサも検証も⾃前のまま ̶ 課題を解いた設計は置き換わらない PyCon JP 2026 | @shinyorke 67

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

SSE をフロントエンドまで透過させる backend(FastAPI)で SSE を実装しておしまい、ではない frontend(Next.js) まで届いて、はじめて完了する 中継点(Next.js API Route)が body を読み切る・作り直すと、SSE は固まる 対処: response.body をそのまま Response に包んで返す(バッファしない) PyCon JP 2026 | @shinyorke 71

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

シークレットと設定の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

Slide 74

Slide 74 text

リポジトリの 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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

変化量の物理モデル — 反実仮想の基準点(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

Slide 77

Slide 77 text

参考文献 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