Share
Embed
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
※ 当日ライブデモでお見せした画面は、本資料ではスクリーンショットで掲載しています。
Shinichi Nakagawa PRO
August 20, 2026
Transcript
77 slides
-
野球解説AI Agent "The Scouter 3" Statcast(トラッキングデータ) × LLM のMLB分析AI Agent
解説および分析レポートを、人間の解説者っぽい自然言語で生成 開発コード(プロジェクト名)は Betts で、ドジャースのあの人が由来 トラッキングデータをLLMが解説して言語化する、ライトなAI Agentです PyCon JP 2026 | @shinyorke 16 -
Architecture フロントは Next.js、バックエンドは FastAPI、LLM は Google Gemini Stats(成績) は Zobrist
API(Statcast データを配信する自作の API)から取得 今日話すのは、Next.js 〜 FastAPI 〜 Gemini の間で起きた事件 PyCon JP 2026 | @shinyorke 17 -
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 -
やってることは、シンプル レポートの文章化は Gemini、計算・整形・検証は FastAPI 側 バックエンドは FastAPI 一本 構成は至ってシンプルだが 2つの課題と考慮すべきことがあった
PyCon JP 2026 | @shinyorke 19 -
"いい感じ"になるまでの道のり — 2つの課題と技術選定 課題 1 課題 2 考慮すべきこと 遅い 不安定
Framework LLM出⼒は時間がかかる レポート⽣成に最⼤数⼗秒 LLMが"気分屋"で⾟いです ⾒出しゆらぎ・⾔語混在 LangChain等を使わない理由 技術選定のポイントの話 ※ 画像は「いらすとや」および Python 公式ロゴより引⽤ PyCon JP 2026 | @shinyorke 20 -
出力は出せるものから LLM の生成は 数秒〜十数秒(通常のAPIレスポンスより時間がかかる) 「全てのコンテンツを読み込むまで画面が真っ白」が一番つらい 結論: LLM を待たずに出せるデータを先に返し、AIレポートは SSE で後続配信
PyCon JP 2026 | @shinyorke 22 -
代表的な実現方式 ポーリング — 一定間隔で「できた?」と聞きに行く WebSocket — 常時接続で双方向にやりとりする SSE — サーバーから一方通行で送り続ける
PyCon JP 2026 | @shinyorke 23 -
結論: SSEを採用 ポーリング WebSocket SSE ✓ 通信⽅向 通信⽅向 通信⽅向 プロトコル
プロトコル プロトコル 実装 実装 実装 総評 総評 総評 Client → Server を繰り返し http / https 結果の紐づけが要る △ 都度問い合わせが⾮効率 PyCon JP 2026 | @shinyorke 双⽅向 ws / wss 常時接続の管理が要る △ 双⽅向通信が無く過剰実装 Server → Client ⽚⽅向 追加ライブラリなし ◎ 要件を満たすかつ容易 24 -
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 -
設計判断: 「一括生成 → セクション単位で配信」 トークン逐次ストリーム ⼀括⽣成 → セクション配信 ✓ LLM
LLM ## 強み と課題\n**バ ⚠ 途中のMarkdownは常に壊れている ⚠ セクション途中でパース不能 ⚠ 崩れた描画がユーザーに⾒える 完成したレポート event: section ① =「選⼿スタイル」 event: section ② =「強みと課題」 event: section ③ 実装もUXも不安定 PyCon JP 2026 | @shinyorke =「総合評価」 完成品を分割して流す = UXも実装も安定 27 -
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 -
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 -
実例: 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 -
運用Tips: SSE と Cloud Run の timeout SSE の接続中も、Cloud Run
はリクエスト時間としてカウントする timeout は既定 300s・上限 3600s — レポート生成(数十秒)は余裕で収まる それでも既定値には頼らず、frontend / backend とも timeout = "300s" を明示 PyCon JP 2026 | @shinyorke 31 -
まとめ: 「見えるものから、見せる」 SSE ⼀括⽣成 → セクション単位で配信 先出し Stats は LLM
を待たない ⾒えるものから、⾒せる 速さは変えられない。"待たせない体験" は設計できる PyCon JP 2026 | @shinyorke 32 -
LLMは"気分屋" — 頼んだ形式で返してくれない 📝 LLMに期待している出⼒ 💥 実際の出⼒ 強み ① ⾒出しは「##」で書いてね(選⼿レポート)
強みと課題 → ⾒出しが太字に化ける ② JSONだけを返してね(⽇本語検索) → 余計なフェンス付き。json.loads()が死ぬ ③ モデルの切り替えで⾒出しが変わった(仮想対戦) プロンプトは1⽂字も変えていない Gemini 2.5 flash(変更前) 予想される対戦シナリオ モデル変更 Gemini 3.5 flash(変更後) シナリオ :投手が… プロンプトは"お願い"であって、"保証"ではない PyCon JP 2026 | @shinyorke 36 -
作戦: 減らす → 縛る → 守る 減らす 縛る 守る 計算・翻訳・突合はPythonで
形式・規約を"仕様"に 出⼒を信じない LLMに渡すのは"解釈"だけ プロンプトは"お願い"でなく仕様書 検証 + フォールバックで受ける LLMは"気分屋"さん。我々が出⼒をエスコートしましょう。 PyCon JP 2026 | @shinyorke 37 -
減らす①: 指標は"渡す前に"確定させる # 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 -
減らす②: 計算・翻訳・突合も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 -
縛る①: 出力形式は"仕様"として書く PLAYER_REPORT_PROMPT = """あなたは野球データアナリストです。(中略) **重要**: - 各セクションは必ず「##」見出しで始めてください。 見出し名は上記の通り正確に使用してください -
指定された文数を超えないでください - 回答はMarkdown形式で記述してください - 全体で{token_budget}トークン以内に収めてください""" 事例①「見出しが化ける」を入口で縛る。見出し名はパーサのアンカー PyCon JP 2026 | @shinyorke 40 -
計算させていないのに、計算する km/h で渡した球速を mph に戻し、渡していない順位を作る パーサは通る。日本語も自然 — だから気づけない PyCon JP
2026 | @shinyorke 41 -
縛る②: 失敗駆動の生成規約 # 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 -
守る①: 構造化は自分でやる # 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 -
守る②: フォールバックを必ず用意 # 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 -
守る③: 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 -
"気分屋" の出力をどうやって安定させた? 事象 計算・数字問題 見出しが太字化 JSON出力が壊れる モデル変更による破壊 減らす ◯ ‐
‐ ‐ 縛る ◯ ◯ ◯ ‐ 事象に応じて「減らす・縛る・守る」を組み合わせて対処 PyCon JP 2026 | @shinyorke 守る ‐ ◯ ◯ ◯ 46 -
まとめ: 減らす・縛る・守るを標準化する 計算 減らす 翻訳 縛る 解釈 検証 式で答えが決まるもの Python:
数値→野球語 整形 + 突合 ⾔葉にするだけ 構造化・フォールバック LLMに計算させない 翻訳も突合もPythonで 解釈だけ任せる 出⼝で守る セイバー指標・物理量 LLM: Gemini 守る パーサ + Pydantic 出⼒の安定化を追求した結果の設計(まだ進化中) PyCon JP 2026 | @shinyorke 47 -
LangChainを採用しなかった理由: 今の要件では必要なかった 野球解説AI Agent要件(2026年時点) ・やることは generate(prompt) 1本 ・マルチモデルは検討していない ・sub agent
を呼ぶ想定もない ・観測は別途基盤で対応できる LangChain 採⽤に傾く要件 ・マルチプロバイダを切り替えたい ・チェーン/sub agent を編成したい ・観測をエコシステムに寄せたい ・RAG などの部品を再利⽤したい → 今の要件では、どれも該当しない プロダクト要件に合わせて最短距離で実現できる⽅法を選択 PyCon JP 2026 | @shinyorke 49 -
Tips: LangChainで置き換えた時の影響範囲 計算 式で答えが決まるもの セイバー指標・物理量 減らす 翻訳 Python: 数値→野球語 整形
+ 突合 縛る 解釈 LLM: Gemini ⾔葉にするだけ 守る 検証 パーサ + Pydantic 構造化・フォールバック LangChain が巻き取るのは、ここ 課題1・2 を解いた設計 ̶ そのまま残る Framework が及ぼす影響は限定的 PyCon JP 2026 | @shinyorke 50 -
AI Agentだからこその機能「仮想対戦」 「対戦したことがない」選手同士の対戦をシミュレートする機能 両者の Stats を元に「仮に対戦したらどうなる」を AIが言語化 解説だけでなく、有利不利の可視化、攻略法などを提示 「大谷翔平 vs
山本由伸」みたいな同チーム対戦等の組合せをシミュレーション可能 PyCon JP 2026 | @shinyorke 53 -
仮想対戦の価値 データ⼗分 わずか4打数 2試合以上 巡ってくる対戦 誠也 vs ミシオロフスキー 実対戦 微対戦
0打席 未対戦 誠也 vs 投⼿・⼤⾕ 空想科学読本の世界 実現不可能な対戦 投⼿・⼤⾕ vs 打者・⼤⾕ 実対戦データ分析で対応可能な領域 仮想対戦が対応可能な領域 少ないデータの中から 価値ある情報を⾒出し⾔語化 現実にできないIfを AIが推論・⾔語化 AI・LLMとセイバーメトリクスの組み合わせで微対戦・未対戦そして⼤⾕VS⼤⾕を⾔語化 PyCon JP 2026 | @shinyorke 60 -
まとめ: 2つの課題と技術選定、それぞれの答え 課題1「遅い」への答え ⾒えるものから、⾒せる ⼀括⽣成をセクション単位で配信 ・ Stats は先出し 課題2「不安定」への答え LLMの出⼒は、標準化で安定させる
減らす(前処理)× 縛る(プロンプトは仕様書)× 守る(パーサ + Pydantic) 「LangChain、使わないんですか?」への答え Frameworkは必要になってから⼊れる 最初は早く動かして検証できる実装を⽬指す。Frameworkは必要になったら⼊れる。 PyCon JP 2026 | @shinyorke 62 -
今後やるべきこと 検証基盤(eval) — 推論の記録 → 人手評価 → 回帰検証。設計済み、実装はこれから AI Agentの高度化
— より実践的な機能を実現するためMulti Agent化 先発投手 VS 相手打線9名の解説 同じ選手の年度別比較、etc... Framework 再検討 — 上の2つが現実になったとき「( 入れる条件」の回収) PyCon JP 2026 | @shinyorke 63 -
LangChainでやる場合の pros / cons ✅ 得るもの ⚠ 負うもの ・LCEL の宣⾔的な書き味
・プロンプト整形の定型が減る ・エコシステム(観測・agent・RAG) ・依存が増える ̶ +14pkg / +10MB ・使わない langsmith も漏れなく同梱 それでも、パーサも検証も⾃前のまま ̶ 課題を解いた設計は置き換わらない PyCon JP 2026 | @shinyorke 67 -
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 -
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 -
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 -
SSE をフロントエンドまで透過させる backend(FastAPI)で SSE を実装しておしまい、ではない frontend(Next.js) まで届いて、はじめて完了する 中継点(Next.js API Route)が
body を読み切る・作り直すと、SSE は固まる 対処: response.body をそのまま Response に包んで返す(バッファしない) PyCon JP 2026 | @shinyorke 71 -
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 -
シークレットと設定の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 -
リポジトリの 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 -
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 -
変化量の物理モデル — 反実仮想の基準点(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 -
参考文献 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