Python

StreamlitでチャットボットのUIを作る手順|st.chat_input・session_stateで会話履歴を保持する実装

StreamlitでチャットボットのUIを作る手順|st.chat_input・session_stateで会話履歴を保持する実装

Streamlitでチャット画面を作ること自体は、専用ウィジェットが3つ用意されているので短時間で終わります。つまずくのはその先です。Streamlitは操作のたびにスクリプトを先頭から実行し直すため、素直に書くと直前の発言が毎回消えます。ここではStreamlit 1.60.0(2026年7月リリース)を前提に、チャット用APIの役割分担、session_stateでの会話履歴の持ち方、Amazon Bedrock Converse APIの応答をストリーミング表示につなぐ変換、そしてsession_stateでは足りなくなる境界までを実装コード付きで整理します。

まとめ

チャットUIの実装で押さえる点は4つあります。描画はst.chat_message、入力はst.chat_input、逐次表示はst.write_streamで分担する。会話履歴はst.session_stateにリストで持ち、毎回の再実行でそのリストを先頭から描き直す。履歴はConverse APIのmessages形式のまま積むと、API送信時の変換コードが要りません。そしてBedrockのconverse_streamが返すイベント辞書はst.write_streamがネイティブ解釈しないため、テキスト差分だけを取り出すジェネレータを1つ挟みます。以降で、それぞれの根拠と落とし穴を見ていきましょう。

Streamlitのチャット用API3種と役割分担

API 役割 押さえる引数
st.chat_message 発言バブルの描画 name、avatar、width
st.chat_input 画面下部に固定される入力欄 placeholder、accept_file、file_type、max_upload_size、accept_audio、submit_mode
st.write_stream 逐次出力のタイプライター表示 stream、cursor

st.chat_message(name, *, avatar=None, width="stretch") の第1引数には任意の文字列を渡せますが、user、assistant、ai、human の4つだけはアバターとスタイルのプリセットが効きます。独自のロール名を使うとプリセットが外れるので、内部的な役割名を使いたい場合でも表示上はこの4つに寄せるのが無難です。戻り値はコンテナなので、with ブロックで囲む書き方と、変数に受けて .write() を呼ぶ書き方の両方が使えます。公式ドキュメントは入れ子にしないよう明示しているため、バブルの中にさらにバブルを置く構成は避けてください。

st.chat_input は 1.60.0 時点で placeholder、key、max_chars、max_upload_size、accept_file、file_type、accept_audio、audio_sample_rate、disabled、submit_mode、on_submit、args、kwargs、width、height を受け取ります。ファイル添付は accept_file に True か multiple か directory を渡すだけで有効になるため、添付対応のために別途 st.file_uploader を並べる必要はありません。受け付ける拡張子は file_type、1ファイルあたりの上限は max_upload_size で絞ります。

submit_mode は3値の意味が異なるので、実ソースの定義どおりに使い分けます。既定の submit はスクリプト実行中でも送信を受け付けます。disable は送信後に入力欄を自動で無効化し、実行が終わると再び有効に戻る挙動で、生成中の追い送信を防ぐ用途です。stop は送信ボタンが停止ボタンに変わり、押すとスクリプトの実行そのものを止めます。長い生成を読者側で打ち切らせたいなら stop、二重送信だけ防ぎたいなら disable を選んでください。

session_stateによる会話履歴の保持

スクリプト再実行モデルとsession_stateの関係

Streamlitはウィジェットが操作されるたびにスクリプトを先頭から実行し直します。公式ドキュメントはこの挙動について、Session Stateを使わない限り「no variables are shared between runs」と書いています。つまりローカル変数に履歴を溜めても、次のメッセージを送った瞬間に初期化されます。チャットが1往復しか続かないという症状の原因は、ほぼこれです。

対処は st.session_state にリストを1本置き、存在チェック付きで初期化することです。毎回の再実行では、そのリストを先頭から回して過去の発言を描き直します。画面上は履歴が残っているように見えますが、実際には毎回全部を再描画しています。この前提を理解しておくと、履歴が数百件に育ったときに描画が重くなる理由も、表示件数を打ち切る対処が効く理由も説明がつきます。

session_stateの基本操作と初期化の作法

st.session_state は辞書アクセスと属性アクセスのどちらでも読み書きでき、st.session_state["messages"] と st.session_state.messages は同じものを指します。初期化を if "messages" not in st.session_state: で囲むのは、再実行のたびに空リストで上書きしてしまうのを防ぐためです。この1行を落とすと履歴が毎回消えるので、チャット実装で最初に疑うべき箇所になります。

ウィジェットに key を渡すと、その値が同名のキーで自動的にsession_stateへ入ります。会話をリセットするボタンを置く場合は del st.session_state["messages"] でキーごと削除するか、空リストを代入し直します。ウィジェットに紐づいたキーを削除すると次の再実行でウィジェット側の値も初期化されるため、リセット処理は履歴用のキーと入力欄のキーを分けて考えると事故が減ります。

Converse API形式のまま履歴を積む設計

履歴の要素をどんな形にするかで、後段のコード量が変わります。表示用に {"role": ..., "text": ...} のような独自形式で持つと、API送信のたびに詰め替えが必要です。最初からBedrock Converse APIのmessages形式、つまり content をブロックのリストにした形で積んでおけば、送信時は st.session_state.messages をそのまま渡すだけで済みます。画像やドキュメントのブロックを後から足すときも、この形なら構造を変えずに拡張できます。

import streamlit as st

if "messages" not in st.session_state:
    st.session_state.messages = []

for m in st.session_state.messages:
    with st.chat_message(m["role"]):
        for block in m["content"]:
            if "text" in block:
                st.markdown(block["text"])

if prompt := st.chat_input("質問を入力してください"):
    st.session_state.messages.append(
        {"role": "user", "content": [{"text": prompt}]}
    )
    with st.chat_message("user"):
        st.markdown(prompt)

描画側で content のブロックを走査しているのは、画像ブロックを足したときにテキスト決め打ちのコードが壊れないようにするためです。ここまでで、入力から履歴の蓄積と再描画までが動きます。

Bedrock Converse APIと接続してチャットボットとして動かす最小実装

Converse APIはモデルごとのリクエスト差を吸収する統一インターフェースで、boto3では bedrock-runtime クライアントの converse と converse_stream を呼びます。IAMアクション名やTool Use、レスポンス構造といったAPI側の詳細はAmazon Bedrock Converse APIの使い方|boto3実装・ConverseStream・Tool Use・IAM権限【2026年最新】にまとめてあるので、ここではStreamlitに組み込む際の要点に絞ります。

Streamlit固有の注意点は、クライアント生成を再実行のたびに走らせないことです。スクリプトは操作ごとに先頭から動くため、素朴に書くとメッセージを送るたびにboto3クライアントが作り直されます。st.cache_resource で包めばセッションをまたいで再利用され、初回以降は生成処理が走りません。モデルIDはリージョンと契約状況で使える値が変わるので、コードに直書きせず環境変数から読みます。未設定のまま起動するとトレースバックがブラウザにそのまま出てしまうため、読み取りに失敗した時点で st.error と st.stop で止めるほうが親切です。

import os

import boto3
import streamlit as st

MODEL_ID = os.environ.get("BEDROCK_MODEL_ID")
if not MODEL_ID:
    st.error("環境変数 BEDROCK_MODEL_ID が設定されていません")
    st.stop()

@st.cache_resource
def get_client():
    return boto3.client("bedrock-runtime", region_name="ap-northeast-1")

def ask(messages):
    response = get_client().converse(modelId=MODEL_ID, messages=messages)
    return response["output"]["message"]["content"][0]["text"]

この ask に st.session_state.messages を渡し、戻ってきたテキストを {"role": "assistant", "content": [{"text": ...}]} の形で履歴に追記すれば、多ターンの会話が成立します。Converse APIはサーバー側に会話状態を持たないので、過去のやり取りは毎回クライアントから送る必要があります。session_stateに履歴を持つ設計は、この仕様と噛み合っています。

converse_streamをst.write_streamへつなぐジェネレータ変換

st.write_streamがネイティブ解釈するストリームの範囲

回答を一括表示から逐次表示に変えるとき、Bedrockでは1手間が増えます。st.write_stream(stream, *, cursor=None) は公式ドキュメントの記載どおり、OpenAIのChat Completions・Responses APIとLangChainのストリームをネイティブに解釈します。裏を返すと、Bedrockのconverse_streamが返すイベント辞書は解釈対象に含まれていません。そのまま渡すと非文字列チャンクとして扱われ、テキストではなく辞書がJSONのまま描画されます。

converse_stream の応答は response["stream"] にイベント列が入り、種別は messageStart、contentBlockStart、contentBlockDelta、contentBlockStop、messageStop、metadata です。表示したいテキスト差分は contentBlockDelta の中の delta.text だけなので、そこだけを取り出して yield するジェネレータを1つ挟みます。

変換ジェネレータの実装と履歴への追記

ストリーミング表示にする場合は、先ほどの ask を呼ばず、1つ目のコードの if prompt: 以降を次の形へ差し替えます。with st.chat_message("assistant") を if の内側に置く点が重要です。外側に出すと入力が無い初回ロードでも converse_stream が呼ばれ、空のmessagesでAPIに弾かれるうえ、以降の再実行のたびにモデルを叩き続けます。

def stream_text(response):
    for event in response["stream"]:
        if "contentBlockDelta" in event:
            yield event["contentBlockDelta"]["delta"]["text"]

if prompt := st.chat_input("質問を入力してください"):
    st.session_state.messages.append(
        {"role": "user", "content": [{"text": prompt}]}
    )
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        response = get_client().converse_stream(
            modelId=MODEL_ID,
            messages=st.session_state.messages,
        )
        answer = st.write_stream(stream_text(response))

    st.session_state.messages.append(
        {"role": "assistant", "content": [{"text": answer}]}
    )

st.write_stream の戻り値は、流したものがテキストだけならstr、他の型が混ざればlistです。上の変換では文字列しか yield していないのでstrが返り、そのまま履歴に追記できます。contentBlockDelta 以外のイベントを素通しすると戻り値がlistになり、履歴に辞書が混ざって次回のAPI送信で失敗するので、条件分岐は省略しないでください。

なお messageStop には停止理由が、metadata にはトークン使用量が入っています。利用量をログに出したい場合は、同じループ内でこれらを拾っておくと、応答を2度取得せずに済みます。

session_stateだけでは足りない場面と外部保存の判断基準

session_stateでの履歴保持は、社内向けの検証用アプリなら十分です。一方で、これだけで本番運用に持ち込むと壊れます。公式ドキュメントはSession Stateの寿命を「Session State exists for as long as the tab is open and connected to the Streamlit server. As soon as you close the tab, everything stored in Session State is lost.」と明記し、さらに「Session State is not persisted. If the Streamlit server crashes, then everything stored in Session State gets wiped.」と続けています。タブを閉じれば消え、サーバーが落ちても消えるということです。

したがって、会話履歴を後から参照する要件がある場合、session_stateを持続層として使うべきではありません。具体的には、ユーザーが別端末から続きを見る、問い合わせ内容を後日集計する、応答品質を人手でレビューする、といった要件が1つでもあるなら、確定した発言をDynamoDBなどの外部ストアへ書き、session_stateはあくまで表示用のキャッシュとして扱う構成に切り替えます。判断の基準は「タブを閉じた瞬間に失われて困るデータかどうか」の一点です。

もう1つ、Streamlitのセッションはブラウザのタブ単位で分かれます。認証を挟まないまま社外へ公開すると、履歴は混ざらない代わりに誰でもモデルを呼べる状態になり、Bedrockの利用料が青天井になります。公開範囲の設計は、UIの実装より先に決めておくべき項目です。フレームワーク自体の制約や向き不向きはStreamlitとは?メリット・デメリットと他フレームワーク比較・料金を解説で整理しています。

よくある質問

Streamlitのチャットアプリでブラウザを更新すると会話が消えるのはなぜですか?

session_stateがタブとサーバーの接続に紐づいているためです。公式ドキュメントはタブを閉じた時点で保存内容が失われると明記しており、再読み込みで接続が張り直されると初期状態から始まります。更新をまたいで履歴を残したい場合は、外部ストアへの保存が必要です。

st.chat_inputとst.text_inputはどう使い分けますか?

st.chat_input は画面下部に固定表示され、送信後に自動でクリアされます。加えて accept_file によるファイル添付と accept_audio による音声入力を引数1つで有効化できます。チャット用途ならこちらを使い、フォーム的な単発入力には st.text_input を使い分けてください。

ストリーミング表示にst.write_streamをそのまま使えますか?

OpenAIとLangChainのストリームはそのまま渡せますが、Bedrockの converse_stream は対象外です。contentBlockDelta の delta.text だけを取り出すジェネレータを挟んでください。挟まないと辞書がJSONとして描画されます。

Streamlitを動かすのに必要なPythonのバージョンはいくつですか?

PyPIのメタデータ上、Streamlit 1.60.0 の requires_python は 3.10 以上です。3.9以前の環境ではインストール時に弾かれるため、先にPython側を上げてください。

チャットボットの構築方式はStreamlit以外に何がありますか?

ノーコードツール、既存SaaSの組み込み、受託開発など複数あり、必要な品質と運用体制で選択が変わります。方式ごとの比較はAIチャットボットの作り方|生成AI+RAG・ノーコード・受託開発の方式選定と実装手順にまとめています。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

RELATED POSTS 関連記事

目次