🎁 新規 無料登録、10回の呼び出しを進呈。最大 $1、カード不要。
LLM チャットボット構築:streaming、コンテキスト圧縮、メモリ

LLM チャットボット構築:streaming、コンテキスト圧縮、メモリ

チャットボット構築を手順別に解説。モデル選択機能、プロバイダーまで停止要求を届けるStop、実測に基づくコンテキスト予算、履歴をメモリへ圧縮する処理、ゲートウェイ経由のWeb検索を順に実装し、各機能の連携まで確認します。

推奨モデル

リアルタイム価格
モデル 評価 価格
Gemini 3.6 Flash 高速で、実効1Mコンテキスト(972Kでニードルを再現)を備え、推論をminimalに設定すると、チャット形式の単一ステップタスクで実測の呼び出し単価が91-97%削減されます。 デフォルトに最適 $1.5/100万〜
DeepSeek V4 Flash このページでチャットに対応する選択肢のうち最安で、表内でキャッシュ読み取りの割引率が最も高いため、長い履歴の再読み込みコストはほぼかかりません。また、MVPのデフォルト要約モデルです。 低予算向け $0.138/100万〜
Claude Sonnet 5 4つの中でペルソナと文章の一貫性が最も高いモデルです。コスト上の注意点:同一テキストでもSonnet 4.6よりトークン数が41%多くなるため、表示価格ではなくトークン数を比較してください。 品質重視 $2/100万〜
GLM-5.2 ウォーム状態のツール呼び出しターンでは、claude-opus-4-8の$0.0051に対して$0.0009でした。ウォームターンのレイテンシ中央値は6.6sだったため、ユーザーが待ち時間を想定しているフローに限定してください。 低コストのツール呼び出し $1.4/100万〜
目次
  1. MVP のチャットボットに必要な機能は何か?
  2. どのような構成か?
  3. picker にはどの model を入れるか?
  4. 各 turn で model が実際に見るものは何か?
  5. prompt caching はどう動き、どの model が対応するか?
  6. コンテキスト上限への接近をどう判定するか?
  7. 上限に達すると何が起きるか?
  8. session をまたいで memory を残すには?
  9. ユーザーが Stop を押すと何が起きるか?
  10. turn が失敗すると何が起きるか?
  11. code 側で tool loop を書かずに Web 検索を動かすには?
  12. streaming の途中で Markdown を壊さず render するには?
  13. 1 conversation のコストはいくらか?
  14. すべての設定項目はどこにあるか?
  15. 省略したものと、追加する場所は?
  16. 関連記事

このガイドでは、約 2 分でローカル実行でき、半日あればコード全体を読めるチャットボットを完成させます。構成は FastAPI server、静的ページ 1 枚、database なし、build step なしです。各 subsystem の設計、turn ごとの処理順序、開発中に遭遇した障害パターンまで扱います。全 source は github.com/synthorai-io/use-caseschatbot/ にあります。すべての request が単一の OpenAI 互換 endpoint を通るため、picker の各 model は別々の integration ではなく dropdown で切り替えられます。

git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env    # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

会話中のデモ画面。top bar に model picker と Web 検索 pill、chat の上に session 合計と分割表示されたコンテキスト bar、折りたたまれた activity trail、source chip 付きの Markdown 応答、conversation sidebar が表示されています

MVP のチャットボットに必要な機能は何か?

必要なのは 8 つです。どれも、機能がない場合に起きる具体的な障害を防ぎます。

機能防げる障害実装箇所
conversation の永続化(一覧、検索、名前変更)reload で全履歴を忘れる chat は tool ではなく demo にすぎないstorage.py
会話途中でも切り替えられる model 選択最難関の質問向け model を固定すると、簡単な質問すべてに過剰な料金を払うことになるconfig.py
preset 付きの編集可能な personasystem prompt は product そのものなので、変更のたびに deploy させてはいけないpresets/
provider まで届く Stop を備えた streaming最初の token まで無反応だと故障に見える。見せかけの Stop では課金が続くserver.py
上限到達時に圧縮するコンテキスト予算すべての model に window 上限がある。無言で上限に達すると bot が突然「頭を悪くした」ように見えるcontext.py
長期メモリsession ごとに自己紹介をやり直すのは、ユーザーが最も強く感じるチャットボットの不満点context.py + .data/memory.md
Web 検索と Web 取得model の知識は学習時点で止まる一方、chat の質問は最新情報に偏るtools.py
turn 単位と session 単位のコスト表示履歴が増えるほど料金も増える。見えなければ調整できないserver.py

Web 検索と取得は、MVP の機能一覧で最初に削られがちな組み合わせなので詳しく説明します。chat model の知識は学習 cutoff で止まっており、現在より数か月古い状態です。一方、chat の質問は価格、version、release、「X はもう Y に対応したか」など最新情報に偏ります。学習データだけで答えるチャットボットは、性能が少し落ちるのではありません。自信を持って誤答し、ユーザーにはどの回答が古いのか判別できません。問題は事実の欠落だけではありません。開発中、日付の anchor がない model は cutoff を「現在」とみなし、自分の検索 query に古い年を入れることまで確認しました。retrieval はチャットボットを強化する追加機能ではありません。過去の snapshot を操作する interface と assistant を分ける機能です。

tool が 1 つではなく 2 つ必要なのは、検索と取得が別の問いに答えるためです。検索は数百文字程度の snippet を返し、結果同士が食い違うことも珍しくありません。「何が見つかるか」を確認するためのものです。取得は 1 ページを全文取得し、「そのページに何と書かれているか」に答えます。正確な数値を確定するときはこちらが必要です。一方が発見し、もう一方が検証します。どちらを使うかは model が turn ごとに判断します。不要な turn では追加コストが発生せず、必要な場合も利用回数の上限で抑えられます。

ほかの基本機能も揃えています。Regenerate、streaming の途中でも壊れない Markdown rendering、障害ごとに名前の付いた復旧経路です。以下では request が code を流れる順序に沿って、表の上からほぼ順番に説明します。

どのような構成か?

構成要素は 3 つです。静的ページ、小さな Python file 6 個、gateway です。ページが message を POST すると、server がコンテキストを組み立て、必要なら圧縮し、server-sent events で completion を streaming し、実測 usage を記録します。conversation は cat で読める JSON file です。

static/index.html   the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py           routes; the per-turn pipeline: project → compress → stream → record
context.py          token budgeting, compression call, memory file, message assembly
tools.py            the /v1/messages transport used by tool-enabled turns
storage.py          one JSON file per conversation under .data/ (settings included)
config.py           env-driven settings: model lineup, budgets, prompts
presets/            system-prompt presets, one .txt each

理解しておくべき中心部分は server.py の turn ごとの pipeline です。ユーザーが送るすべての message は同じ 5 step を通ります。各 step は後続の section に対応しています。

user message


[1] project the next request's size     last measured prompt_tokens
    │                                   + estimate(new message, pessimistic)
    │                                   + completion reserve

[2] over budget? ──yes──▶ compress      old turns ──▶ rolling summary
    │                                       └───────▶ durable facts ──▶ memory.md

[3] assemble and send                   [persona][memory][summary][date][history]
    │                                   cache mark after the system blocks

[4] stream SSE back to the page         delta / reasoning / search / fetch /
    │                                   compression notice / warning / error

[5] record measured usage               usage.prompt_tokens becomes step [1]'s
                                        input on the next turn

この loop は自己完結しています。step 5 の実測値を次の turn の step 1 が使います。そのため予算はローカル推定ではなく、API が実際に数えた値を常に基準にします。

最初に押さえるべき architecture 上の分岐があります。通常の turn は /v1/chat/completions、Web 検索または取得を有効にした turn は /v1/messages に送ります。この分離は style の問題ではありません。tool の section で説明する実測上の挙動により必要になります。

picker にはどの model を入れるか?

.env には 7 model が含まれ、picker 上では tier 別(fast & cheap、balanced、frontier)に分類されています。Settings から gateway が提供する任意の id を追加でき、追加した id は .data/models.json に永続化されます。lineup より重要なのは default の選択です。この MVP は reasoning dial を最低に固定した Gemini 3.6 Flash から始まります。chat の応答は single-step の処理です。single-step task では reasoning_effort: "minimal" にすると、読者が違いを判別できない出力品質のまま、default と比べて call あたりの実測コストが 91-97% 下がりました。すべての model がこの parameter を受け付けるわけではないため、model ごとに config で指定します。

# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
    "gemini-3.6-flash": {"reasoning_effort": "minimal"},
}

DeepSeek V4 Flash は lineup で 2 つの役割を持ちます。picker の低コスト選択肢であり、圧縮時の default summarizer でもあります。要約も single-step で、多少の品質差を許容できる処理だからです。文章品質が product の中心なら Claude Sonnet 5 を選びます。表示価格ではなく token 数で予算を見積もってください。同じ text でも Sonnet 4.6 より token 数が 41% 多くなるためです。GLM-5.2 は tool を多用する flow 向けです。warm な tool-call turn の実測値は Claude Opus 4.8 の $0.0051 に対して $0.0009 でした。ただし warm turn の median は 6.6s なので、ユーザーが待ち時間を想定している flow に限定します。

model ごとの capability は推測せず実測します。差がないことにせず、code 側で分岐します。この lineup では、処理過程を reasoning_content として streaming するのは DeepSeek と GLM だけです。Claude 系 model は thinking parameter を指定しても、この gateway 経由では thinking block を返しません。そのため UI は実際に表示可能な場合だけ live の「Thinking」panel を出します。

会話途中で model を切り替えても、構造上のコストはありません。履歴は provider 非依存の {"role", "content"} message なので、同じ conversation を低コスト model で始め、難しい質問が来たときだけ高品質 model に切り替えられます。実際に発生するコストは見えにくい部分です。model を切り替えると前の model の prompt cache は使えなくなり、切り替え後の最初の turn ではコンテキスト全体を cold price で読み直します。

各 turn で model が実際に見るものは何か?

prompt は layer 構造で、1 か所で意図した順序に組み立てます。最も安定した内容を先頭に置きます。

layer取得元変更頻度
System prompt(persona)presets/*.txt または自由入力 text編集しない限り変わらない
長期メモリ.data/memory.md低い(fact 追加時)
rolling summary圧縮処理圧縮時のみ
日付 anchorserver clock1 日ごと
Tool instructionsSettings。tool turn のみ低い
直近の turnconversationturn ごと

この順序は caching のためです。persona は変わらず、memory はめったに変わらず、summary は圧縮時だけ変わり、history は turn ごとに変わります。各 layer は、自分より変更頻度の低いものの後ろに置きます。

日付 anchor は prompt stack に必要であり、配置も重要です。日付 anchor がない model は学習 cutoff を「現在」とみなします。古い年を含む検索 query を作り、release table の最新 row を現在の情報として読んでしまいます。ただし anchor は timestamp ではなく日付に限定します。prompt prefix に入るため、日単位より細かくすると request ごとに cache が無効になるからです。persona、memory、summary の後ろに置くことで、日付が変わってもそれらの cache は維持できます。

prompt caching はどう動き、どの model が対応するか?

原理は単純です。provider は request の byte 単位で同一な prefix を cache し、初回の書き込みには少額の premium を課し、その後は大幅な割引価格で返します。chat はこの仕組みに最も適した workload です。request ごとに前回の request へ 2 message が追加されるだけなので、過去全体が構造上安定した prefix になります。書き込み premium は次の turn だけで回収できます。Anthropic model の場合、5 分 TTL の書き込みは 1.25x、読み出しは約 0.1x です。書き込み側の採算はこちらで実測していますcache_control marker により、3 つの Claude tier で実測コストが 88-89% 下がりました。実際の memory と summary を持つ conversation では、後半の turn は cold な最初の turn の約 10 分の 1 になります。

ただし「caching」は単一の仕組みではありません。provider は 2 陣営に分かれ、この lineup には両方が含まれます。

Modelcaching 方式実装側の対応hit の報告先
Claude family明示型。cache_control breakpointmark を置くcache_read_input_tokens
DeepSeek V4 Flash暗黙型。prefix を自動判定何もしないprompt_tokens_details.cached_tokens
GLM-5.2暗黙型。prefix を自動判定何もしないprompt_tokens_details.cached_tokens
Gemini 3.6 Flash暗黙型。marker は受理されるが無視される有効な操作はないこの gateway では報告されない

明示型 caching は Anthropic の方式です。breakpoint を指定する必要がありますが、何が起きたかを正確に確認できます。書き込まれた token と読み出された token が別 field で返るため、turn ごとに割引を監査できます。暗黙型 caching は OpenAI 系の方式です。marker は不要で、prefix が繰り返されれば自動的に動きます。ただし判定は provider 任せで、事後に cached_tokens field を見る以外に確認手段がありません。hit の安定性は provider によって大きく異なります。MVP は両方式に同時対応します。prompt を変更頻度の低い順に並べます。これは暗黙型 caching が key にする形です。そのうえで明示的な marker を常に送ります。暗黙型の provider は無害に無視します。request shape は 1 つのままで、各 model が対応可能な caching を利用できます。

明示的な mark の配置は documentation ではなく実測で決めました。この gateway では cache_control は system block 上でのみ有効で、それ以外では無言で無視されます。複数 turn でよく使われる、最新 user message に mark を置いて履歴全体を cache する pattern では、cached token が 0 のまま full price になります。そのため MVP は system section の末尾に mark を置き、persona + memory + summary を cache 可能な prefix にします。これにより 2 つの結果が生じます。まず、prefix が model の最低量である約 1,024 token を超えるまで caching は有効になりません。新規 conversation では何も cache されず、memory と summary が蓄積した conversation では全体が cache されます。次に、prefix の編集にはコストが伴います。system prompt の編集は先頭 byte から cold になり、圧縮は summary を書き換えるため 1 回の cold read が発生します。model を切り替えると以前の model の cache はすべて失われます。

TTL はどこでも同じ考え方で決める config 項目です。default の 5 分 cache は会話中の操作をカバーし、1 時間 tier は一度離席したユーザーの復帰をカバーします。ただし書き込み premium は 2 倍です。CACHE_TTL=1h の 1 行で変更できます。採算が合うかどうかは、ユーザーが実際に 1 時間以内に戻るかで決まります。

コンテキスト上限への接近をどう判定するか?

推測せず測定します。対象 model にとって正しい token 数は、直前の request で API が返した usage.prompt_tokens だけです。ローカル estimator は vendor 間で誤差が出ます。その差は小さくありません。同じ vendor の 2 model でも、同じ英語 text の token 数に 41% の差がありました(実測結果)。vendor が違えばさらに広がります。MVP がローカルで数えるのは、API がまだ見ていないものだけです。つまり現在送ろうとしている message で、推定値は意図的に切り上げます。

# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
    if message_count <= config.KEEP_RECENT_MESSAGES:
        return False  # nothing old enough to fold away
    projected = (
        last_prompt_tokens                    # measured, last response
        + estimate_tokens(pending_text)       # estimated, pessimistic
        + config.MAX_COMPLETION_TOKENS        # worst-case reply
    )
    return projected > (budget or config.CONTEXT_BUDGET_TOKENS)

過大推定では圧縮が 1 turn 早く動き、低コストな要約 call が 1 回増えるだけです。過小推定では window を超え、request が失敗するか、無言で truncate されます。この非対称性から切り上げを選びます。

「測定」にも落とし穴があります。provider によって、prompt_tokens に cached token を含めるかどうかが異なります。この違いは表示上の問題ではありません。この値は予算判定の基準なので、cache 分を過小計上すると圧縮が発動せず、window が無言であふれます。server は値を正規化します。prompt_tokens が報告された cached token 数より小さい場合、prompt 全体を表しているはずがないため、各部分を合算します。さらに自前の推定値を実測値の下限として維持します。provider ごとの usage の内容は別の大きなテーマです。LLM Token Usage Anatomy で説明しています。

default の予算は 102,400 token です。通常の会話では到達しません。これは model の上限ではなく運用予算であり、1 turn のコストが価値を上回る地点に設定します。動作を確認するには Settings を開き、1 つの conversation だけ window を数千 token まで下げてから、長い message を数回貼り付けます。header のコンテキスト bar は実測値から描画され、window を占める persona、memory、summary、history ごとに分割表示されます。

header strip。session 合計、cached 比率、コスト、system、memory、summary、history に分割された 16k 予算のコンテキスト bar が表示されています

上限に達すると何が起きるか?

truncate せず圧縮します。truncate すると会話の冒頭を忘れ、ユーザーには bot の知能が落ちたように見えます。代わりに、直近の message 以外を低コストの summarizer model で rolling summary にまとめます。default では最後の 8 message を残します。summary は system block として以降も渡します。直近 window は原文のまま残るため、短期的な bot の口調は変わりません。lossy になるのは古い履歴だけです。無言で削除することもありません。圧縮時には対象 message 数と summary サイズを含む notice が transcript に表示されます。

1 回の圧縮処理は次のようになります。

before   (projected next request 103.1k > 102.4k budget)

  [persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
                            old enough to fold            last 8, kept

one summary call to deepseek-v4-flash:
  in:   prior summary + m1 ... m34
  out:  {"summary": "one dense paragraph: topics, decisions,
                     open questions, promises",
         "facts":   ["prefers Python", "timezone is UTC+8"]}

after

  [persona][memory + new facts][summary][date][ m35 ....... m42 ]
   unchanged  grows rarely      replaced         verbatim

圧縮 call の信頼性を支える設計上の要点は 3 つです。

  • summary は前回の summary も吸収します。 2 回目の圧縮では、前回の summary と新たに古くなった message をまとめます。これにより無制限に伸びる summary の連鎖ではなく、1 つの rolling paragraph を維持します。
  • 失敗しても turn は失われません。 summary call が error になった場合、server は最古の turn を要約せず削除し、失われた内容をユーザーに明示したうえで message への回答を続けます。不完全な memory でも停止したチャットボットより有用です。demo では一度も通らない error path こそ、本番で page を鳴らします。
  • prompt は設定項目ですが、guard を 1 つ設けます。 summarizer に何を残させるかで conversation の記憶内容が決まるため、圧縮 prompt は conversation ごとに編集できます。ただし {transcript} placeholder を削除した編集は server が拒否します。その prompt では何も要約されず、最初の overflow まで障害が表面化しないためです。COMPRESS_MAX_TOKENS に余裕を持たせる理由も同じです。output 上限は prompt が要求する長さより大きくします。文の途中で切れた summary を以降のすべての turn が引き継ぐことになるからです。

cache の観点では、圧縮によって正確に 1 回の cold read が発生します。summary block が変わるため memory block より後ろは 1 request だけ cold になり、その後は新しく小さくなった prefix が再び cache されます。正確なコスト内訳は、summary call と 1 回の cold read です。その対価として、以降のすべての turn が小さな warm context の料金になります。

session をまたいで memory を残すには?

少し引いて見ると、bot には scope と lifetime の異なる 3 つの memory store があります。この section の設計はすべてこの分割から導かれます。

storescopelifetimewire 上のサイズ
原文のままの直近 turnこの conversation圧縮されるまで最後の 8 message の全文
rolling summaryこの conversationconversation とともに削除200 語未満の 1 paragraph
memory.md の factすべての conversation人間が削除するまで数行

summary と fact は似ていますが、有効期間が違うため分離します。rolling summary は conversation 単位の情報です。decision、open question、assistant が約束した内容などを持ち、conversation と一緒に消えるのが正しい挙動です。一方、「Python を好む」「timezone は UTC+8」のようなユーザーの永続的 fact は翌週も有効であり、conversation と一緒に消すのは無駄です。

圧縮 prompt は両方を同時に要求し、JSON で summary string と facts array を返します。fact は .data/memory.md に追記され、すべての conversation がこの file を system block として読み込みます。抽出を別 pass にせず圧縮時に行うのはコストのためです。圧縮時点では model が古い turn をすでに読み直しているので、追加料金なしで fact も回収できます。1 call で 2 output です。

MVP の重複除去は行の完全一致だけです。この制約はすぐに表面化します。ある圧縮で「User prefers Python over Node.js」を保存し、後の圧縮で「The user prefers Python over Node.js.」を追加する可能性があります。semantic dedup には、各 candidate を embedding するか、store と LLM 比較する必要があります。実コストを伴う本格的な機能なので、MVP は単純な方法を採用し、その制約も明示します。memory file を Markdown にしたのは、人間が読んで整理できるようにするためです。Settings では編集可能な text box として公開され、透明性を確認する panel も兼ねます。bot がユーザーについて知っている内容は、そのまま開ける file です。

ユーザーが Stop を押すと何が起きるか?

provider が生成を停止します。これが機能の本体です。多くの chat UI はここまで実装していません。output を隠すだけで completion が動き続ける Stop では、誰も読まない token に料金を払い続けます。

処理は 3 段階です。reply の streaming 中は Send button が同じ位置の Stop に変わり、確認なしで押せます。click すると browser の fetch が abort されます。server は event の間で disconnect を確認し、検出すると streaming loop を抜けます。これにより upstream connection が閉じ、provider が停止します。すでに届いた text は interrupted marker 付きで永続化され、transcript 上では破線 border で表示されます。tab を閉じた場合や connection が切れた場合も、server 側では同じ event なので同じ処理が動きます。

ここで persistence は exactly-once でなければなりません。code は明示的に分岐しています。正常終了時は streaming loop 内、stream 破損時は exception handler、hard disconnect 時は finally block で保存します。hard disconnect では framework が generator を cancel するため、loop の後ろにある処理は実行されません。関数は idempotent なので、最初に動いた経路だけが有効になります。

Stop は Regenerate と組み合わせます。不要な reply を中断し、再入力せずにやり直せます。Regenerate は中断済みの reply を含む末尾の assistant turn を削除し、conversation の末尾を再び user message に戻して回答し直します。このとき現在選択中の model を使います。model dropdown は次の turn に適用されるため、Stop と Regenerate を組み合わせれば、同じ質問をより強力な model に再回答させることもできます。

turn が失敗すると何が起きるか?

障害は 6 種類に分け、それぞれ専用の復旧経路を用意します。単一の赤い error line にはまとめません。server.pyclassify() が upstream exception を failure kind に変換し、UI が kind ごとに action を割り当てます。

障害ユーザーへの表示復旧方法
network error障害名を明示Retry button
rate limitretry-after があれば待ち時間を表示Retry button
API key 不正修正すべき env var 名を明示.env を編集
content filter「同じ text を retry しても再び拒否される」と表示Edit & resend
stream 途中の破損partial reply を残し、interrupted と表示Retry
圧縮失敗何を削除したかを warning で表示不要。turn は継続

2 つの invariant が大部分を支えます。1 つ目は user message を失わないことです。最初の event より前に request が失敗した場合、server は message を conversation から rollback し、UI は draft を composer に戻します。これにより retry で二重送信されません。text 到着後に stream が壊れた場合は partial reply を残し、interrupted と記録します。2 つ目は、拒否された reply には retry ではなく rewind を使うことです。server は user message を history から戻して composer に返し、編集できるようにします。同じ文面を content filter に何度送っても永久に失敗するためです。

この 2 つを支える、より細かな storage rule もあります。空の assistant turn は保存しません。空の assistant message を upstream に replay すると、一部 provider が要求する user と assistant の交互配置が壊れます。原因となった message ではなく後続 turn で error になるため、debug が非常に困難です。MVP は wire format から空の content を除外し、text のない reply を保存しません。ただし検索や reasoning activity に保存価値がある場合は activity を保存し、送信時に空 text だけを取り除きます。

code 側で tool loop を書かずに Web 検索を動かすには?

gateway が server-side で tool を実行するため、処理を担当する code が変わります。従来の function calling は自分で loop を管理します。model が tool_calls block を返し、code が tool を実行し、tool_result message を追加して conversation 全体を再送します。call ごとにこの処理を繰り返します。server-side tool では loop が gateway 側に移ります。request で synthorai:web_search または synthorai:web_fetch を宣言すると、gateway が LLM と tool provider の間に入り、model の tool call を外部の検索 API や取得 API に中継し、結果を model のコンテキストに戻します。tool は魔法ではありません。検索と取得はそれぞれ外部 API への call であり、利用ごとに課金されるのもそのためです。この codebase に tool_result の round trip はなく、render する event だけが流れてきます。

browser        server (tools.py)     Synthorai gateway       LLM / tool providers
   │ POST /chat    │                     │
   ├──────────────▶│ declare tools +     │
   │               │ budget note         │
   │               ├────────────────────▶│── question + tools ──▶ [LLM]
   │               │                     │◀── tool_use: search ── [LLM]
   │               │                     │── query ─────────────▶ [search API]
   │               │  search results     │◀── results ─────────── [search API]
   │ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
   │               │                     │◀── tool_use: fetch ─── [LLM]
   │               │                     │── URL ───────────────▶ [fetch API]
   │               │  fetch result       │◀── page text ───────── [fetch API]
   │ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
   │ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
   │ SSE: done     │                     │

役割は明確に分かれます。model は検索するかどうか、snippet で足りるかページ取得が必要か、いつ調査を止めて回答を書くかを判断します。gateway は検索または取得 provider を呼び、結果を model に返します。自分の server は流れてくる event を render するだけです。default では両 tool が有効ですが、不要な turn では追加コストがかかりません。そのため計算問題は無料のまま、「現在の……」という質問だけ検索できます。gateway 側の loop にも上限があります。server-side の検索 loop が自身の iteration limit で一時停止した場合(pause_turn)、transport は turn を送り返して再開します。最大 3 回です。

実装時に得た 4 つの知見は happy path より重要です。

  • 見える情報は endpoint で決まります。 どちらの endpoint でも検索が動き、料金も発生します。2026-08-04 に Anthropic と Gemini の両 channel で実測しました。ただし検索内容を返すのは /v1/messages だけです。query と結果 URL が typed block で届き、code から render、保存できます。/v1/chat/completions では同じ検索が見えないまま実行されます。HTTP 200 で「Based on the search results…」から始まる回答が返りますが、streaming と non-streaming のどちらにも citation や annotation はありません。料金を払った検索を監査できない状態は silent degradation です。error は出ず、provenance だけが消える最悪の障害パターンです。tools.py を通常 request の追加 field ではなく別 transport にした理由はこれだけです。
  • model には予算を文章で伝える必要があります。 上限は default で turn あたり検索 3 回、取得 2 回です。gateway が無言で強制するため、model に知らせないと tool が無制限だと想定して計画し、最後の 1 回を思考途中で使い切ります。その結果、「検索してみます……」で turn が終わり、回答が出ません。MVP は予算を説明する 1 文を注入します。文面は実測可能なコスト調整要素です。test では、説明なしだと tool 予算を使い切って文の途中で終了し、厳しすぎる説明では model が諦めてユーザーにページを読むよう案内しました。採用した文面では検索を完全に省き、信頼できる 2 ページだけを取得し、3 pattern 中で最も低コストでした。Settings で編集できるため、変更後は turn ごとの cost line を確認できます。
  • 上限だけが brake です。 両 tool は利用ごとに課金され、何回実行するかは model が決めます。test では上限なしの 1 turn が、最初の output token が課金される前に検索 3 回と取得 2 回を実行しました。
  • 停止せず縮退させます。 2 種類の障害を同じ方法で扱います。tool を外して 1 回だけ retry し、ユーザーに通知します。Web 取得には key の entitlement が必要で、なければ縮退せず request 全体が web_fetch_not_enabled で失敗します。また Gemini は tool 宣言を受理しますが、実際に呼び出した瞬間に error になります(Function call is missing a thought_signature)。optional tool のために user turn 全体を失うのは不適切です。

回答に到達するまでに model が行った処理(思考、検索、閲覧)は、reply の上にある activity trail へ live streaming されます。default では 1 行の薄い表示(「5s 思考 · Web を検索 · 2 ページを閲覧」)に折りたたまれ、展開すると query、結果 domain、reasoning text の timeline を確認できます。trail は message と一緒に保存します。reload で消える trail では、後から回答を監査できないためです。citation は reply の下に番号付き domain chip として表示されます。

reply 上で展開された activity trail。query と 2 件の結果を含む検索 step、ページとサイズを含む取得 step、その下に source chip が表示されています

streaming の途中で Markdown を壊さず render するには?

蓄積した text を、安全に render できる安定 prefix と、まだ不安定な末尾に分けます。閉じていない構造を render すると、次の token で閉じた瞬間に layout が跳ねます。そのため renderer は最後の完全な行で切ります。開始 code fence に対応する終了 fence がまだなければ、その fence 以降は閉じるまで plain text のままにします。bubble は安定 prefix が実際に伸びたときだけ再 render するため、token ごとに DOM を再構築しません。code block には copy button を付けます。renderer は vanilla JS 約 100 行で、library は使いません。これは Markdown library の評価ではなく、MVP に必要な範囲を示しています。

1 conversation のコストはいくらか?

gateway の usage が示す値です。MVP の役割はその値を正しく読むことです。重要な正規化問題が 2 つありました。

  • vendor ごとに field 名が違います。 cached token だけでも、Anthropic model は cache_read_input_tokens、DeepSeek と GLM は prompt_tokens_details.cached_tokens だけを返し、Gemini はどちらも返しません。stats line は存在する field を読み分けるため、「cached」は常に同じ意味になります。
  • cost の欠落は 0 ではありません。 gateway は一部の turn で cost を返し、ほかでは省略します。tool を宣言したが使わなかった turn で再現します。cost field がない turn は 0 として加算せず、件数を数えて「+N unreported」と表示します。一部の turn を無言で除外した合計値を bill のように見せるべきではありません。その値は下限にすぎません。

header には session の累積値(input、output、cached 比率、検索回数、取得回数、cost、turn 数)を表示し、各 reply にも個別の line を付けます。注目すべき値は cached 比率です。前述の caching が自分の conversation でどう動いたかを実測できます。

すべての設定項目はどこにあるか?

1 つの Settings panel にまとめます。参考にすべき設計判断は scope です。ほぼすべての設定は 1 つの conversation に属します。

Settings panel。削除可能な chip と追加 field で構成された model lineup、conversation ごとのコンテキスト window、編集可能な system prompt 上の preset dropdown、その下の memory section が表示されています

global な設定は 2 つだけです。conversation ではなくユーザーに属する情報だからです。1 つは model lineup です。gateway が提供する任意の id を追加でき、.data/models.json に永続化されます。もう 1 つは長期 memory file で、編集可能な text box として表示されます。それ以外は開いている conversation に scope されます。system prompt(presets/ の preset を dropdown で選択可能)、コンテキスト予算、Web 検索と取得の toggle、tool instructions、圧縮 prompt です。これにより、同じ model 群に対して persona、予算、tool が異なる 2 conversation を並行実行できます。このガイドの主張を鵜呑みにせず再現するための設計です。

各 field には小さな info marker があります。hover で一時表示し、click で固定できます。変更に伴うコストを説明するためです。ほとんどの knob には UI から見えない料金が発生します。persona の編集は先頭 byte から cache を無効にするため、1 回の cold turn が必要です。tool instructions の書き換えは turn ごとの料金を変えます。予算を下げると圧縮が早く発動します。top bar の model dropdown だけは即時保存されます。会話途中の model 切り替えは設定変更ではなく、first-class action だからです。

省略したものと、追加する場所は?

意図的に省略した機能と、その追加箇所は次のとおりです。

  • Auth と multi-user — route の前段に session layer を置きます。conversation にはすでに id があるため、ユーザーへの scope 付けは filename prefix で済み、再設計は不要です。それまでは localhost tool として扱ってください。受け付けるすべての request が自分の API key を消費するため、現状のまま public internet に公開してはいけません。
  • Rate limiting — 同じ場所に同じ理由で追加します。shared deployment を保護する機能ですが、現状は shared deployment ではありません。
  • RAG — 取得 document は summary の後、直近 message の前に置きます。変更頻度の高い content を prefix の後方に置けば、cache 済みの persona と memory block を無効にしません。model 側では、1M コンテキストの選択肢が window の価値を発揮する場所です。
  • client-side function calling — streaming loop に tool_calls branch と executor を追加します。GLM-5.2 の解説 では、そこで必要になる provider 間の contract 差を扱っています。前述の gateway-side tool は意図的にこの loop を避けています。
  • semantic memory dedup — candidate fact ごとに embedding し、store と比較して新規のものだけを残します。memory file format は変わりません。
  • Syntax highlighting — 実際によく使われる機能は copy button です。highlighting は本格的な frontend で決める library の問題です。
  • 実 databasestorage.py は少数の関数だけです。SQLite への移植は半日でできます。ユーザーがいない段階では JSON file にすること自体が目的でした。

関連記事

← ユースケース