😇

# Streamlit記事生成エンジンを永続安定化するための完全実践マニュアル ─ `KeyError`連鎖とタイムアウト地獄から抜け出す最

に公開

はじめに

PythonだけでWebアプリが構築できるStreamlitは、データ可視化からAIデモまで爆速で作れる便利さと引き換えに、ウィジェットとsession_stateの扱いを誤ると想像以上に深刻なトラブルを呼び込みます。本稿では、実際に発生した「記事生成ステップがKeyError連発で停止→リトライ暴走→タイムアウト」という典型的な障害を題材に、原因の深層構造を解体しながら再発しないコード設計・デバッグ戦略・運用フローを余すところなく解説します。


1. 障害の全体像を正確に把握する

1.1 表面症状

  • 記事生成フロー中にKeyError: 'h1'が発生し、修正後もKeyError: 'h2'が続発
  • アプリ側で定義したリトライ処理が無制限に稼働し、実質的に無限ループ
  • 外部API(Claudeなど)へのリクエストが雪だるま式に増加し、タイムアウトまたは最大リトライ上限に達して強制終了
  • ユーザー視点では生成が完了せず画面が固まったように見える

1.2 影響範囲と潜在的リスク

  • セッションに壊れたアウトラインデータが残存し、次回生成にも悪影響
  • 外部API従量課金のコストが爆発的に増大
  • タイムアウト後の例外がUIレスポンスを阻害し、UXを大幅に劣化
  • 最悪の場合、サーバー側プロセスがメモリリークでクラッシュ

2. 原因の深層を掘り下げる

2.1 データ契約の崩壊

  • 記事生成サービスは{"h1": str, "h2": List[str]}というごくシンプルな辞書を期待
  • 実際には{"title": "見出し", "subsections": [{"title": "小見出しA"}]}のような多段ネストが未処理で送信
  • 途中でh1キーやh2キーが空・欠落・辞書になり、解析時点でKeyError

2.2 Streamlit特有の罠 ─ session_state競合

  • ウィジェット描画後に同一キーを直接書き換えるとStreamlitAPIException
  • ユーザーがキーワードを再入力→即st.session_state.keyword = new_val実行→裏でレンダリング済みウィジェットと衝突

2.3 密結合ロジックと低可観測性

  • アウトライン整形とAPI呼び出しが同関数内で行われ、責務が不明瞭
  • ログ粒度が粗く、どの段階で壊れたか特定に時間を要した

3. 適用した二大修正策

3.1 ペンディング方式で安全にキーワード更新

3.1.1 実装手順

  1. 「追加キーワードで再検索」ボタン押下→st.session_state.pending_keyword_updateに新キーワードを退避

  2. main()関数冒頭でペンディング変数を検査

  3. 存在すれば以下を実行

    • st.session_state.keywordへ安全に代入
    • has_searchedoutline_dataなど関連ステートを初期化
    • 処理後pending_keyword_updateを削除してst.rerun()不要化

3.1.2 得られた効果

  • ウィジェット競合による例外を完全排除
  • 再検索もワンクリックでシームレスに遷移

3.2 アウトライン正規化ロジックの強化

3.2.1 正規化仕様

section_data = {
    "h1": str,          # 大見出し文字列
    "h2": List[str]     # 小見出し文字列リスト(空でもリストで保持)
}

3.2.2 変換アルゴリズム

  • titleキーがあればh1へ昇格、無ければ既存h1を使用
  • subsectionsh2が辞書リストなら各titleを抽出しリスト化
  • 小見出しが皆無でも必ず[]をセットしNull参照を回避
  • 大見出し欠損時はフォールバックで「タイトル不明」を挿入しKeyError防止

4. 安定稼働のための設計指針

4.1 データ契約を境界で保証

  • 受け取った瞬間に「正規形」へ変換し、下流では一切形状が変わらない保証を作る
  • 変換関数にユニットテストを付与し、CIで常時バリデーション

4.2 session_stateをイベント駆動で扱う

  • ウィジェット生成後は同名キーを安易に書き替えない

  • 値を変える必要がある場合は

    • ペンディング変数+初期化判定
    • on_changeコールバックで更新
    • 入力フォームを別画面として分離

4.3 観測可能性を高めるログ設計

  • 外部API呼び出し単位でtrace_idを生成し、全レイヤで共通ログ
  • リトライ回数・実行時間をメトリクスとして集計し、しきい値超過で警告

5. 実戦的デバッグフロー

5.1 再現性の高い最小ケースを作成

  • 壊れたアウトラインと同じJSONをハードコードし、生成関数だけ実行
  • UI・APIを切り離してロジック層を単体テスト

5.2 データ形状を逐次ダンプ

  • 各ステージでjson.dumps(data, ensure_ascii=False, indent=2)をログ出力
  • 欠損キーや型違いを即座に視覚確認

5.3 バイナリサーチ的ロールバック

  • コミット単位で過去に戻し、正常動作する境界を確定
  • 境界コミット間の差分を精査しバグ挿入箇所を特定

5.4 自動回帰テストの導入

  • pytest+Streamlit Testingを用い、代表的な入力パターンで生成成功をCI検証
  • 将来仕様追加で壊れてもプルリク時点で検知

6. 運用フェーズでの再発防止策

6.1 リトライポリシーの見直し

  • 外部API失敗時は指数バックオフ+最大試行数を小さく設定
  • 同一アウトラインで連続エラーなら自動的に「失敗セクションをスキップ」モードへ移行

6.2 タイムアウトとメモリリーク監視

  • 生成ロジック単体の最大実行時間を設定し、超過時は優雅にキャンセル
  • メモリ使用量をプロセス単位で定期送信し、異常値で自動リスタート

6.3 障害時のUX維持

  • 生成失敗時に「部分生成記事+エラー詳細」をダウンロード可能にし、ユーザーに作業エビデンスを残す
  • 再試行ボタンは非同期で実行し、UIをブロックしない設計へ変更

7. まとめ ─ 再度同じ罠に落ちないために

  • データ契約は「境界で正規化」「ユニットテストで常時保証」
  • session_stateはイベント駆動+ペンディング戦略で競合排除
  • リトライ・タイムアウト・メモリを数値で監視し、自動復旧も視野に入れる

これらを組み込むことで、記事生成エンジンは高負荷環境でも安定動作し、無駄なAPIコストやユーザー離脱を最小限に抑えられます。初期フェーズから「例外を例外視しない」堅牢設計を心掛け、Streamlitが持つ爆速開発の恩恵を最大化しましょう。

Discussion