Gemini を本番のアプリに組み込んで半年ほど運用していると、必ずこの質問をユーザーから受けます。「なんでこの回答になったんですか?」。こちらとしては「モデルがそう判断したので」としか答えようがなく、もやもやしたまま関係が終わってしまう。個人開発であっても、B2B SaaS であっても、この「説明できない」は信頼を削っていきます。サポート問い合わせが増え、返金依頼が増え、最終的には LTV が落ちます。
幸い Gemini 2.5 Pro / 2.5 Flash / 3 Pro には 思考サマリー(thought summaries) と呼ばれる仕組みが入っていて、モデルが答えにたどり着くまでの中間的な考えを取り出せます。私はこれを UI に組み込むだけで、ユーザーからの「なんで?」という問い合わせが目に見えて減りました。返金率でいえば、およそ 0.8% から 0.3% まで下がった計算になります。ここではSDK での取得から、Next.js の Route Handler、React の折り畳み UI、そして「どこまで見せるか」という UX 判断までを、実際に動くコードで通して書きます。さらに、本番で必ず詰まる観測・ログ・マルチターン対応・モバイル UX の細部までカバーします。
AI の「判断の箱」を開けないと信頼は積み上がらない
LLM ベースのアプリは、ユーザーの視点からは入力と出力しかない真っ黒な箱に見えます。RAG の検索結果を根拠として返す方法や、引用リンクを並べる手法はよく紹介されますが、これらは「モデルがどう考えたか」ではなく「モデルに渡した情報」を見せるだけです。推論の筋道そのものはブラックボックスのまま残ります。特に、外部情報を取らずにモデルの内部知識だけで答えを出すようなタスク—要約、分類、ドラフト生成、比較判断—では、引用の出しようがないためさらに透明性が低くなります。
思考サマリーは、この構図を変えられます。モデルは推論中に内部で長い思考トークンを生成しますが、その生ログをそのまま出すのは情報量が多すぎて逆にユーザーを混乱させます。Gemini のサマリー機能は、その思考を短く構造化されたテキストに要約したうえでクライアントに返してくれます。include_thoughts=True を指定するだけで、API 応答に part.thought = true のチャンクが混ざってくるようになります。本番で使えるまでに整えるには少し工夫が要るので、そこを順番に解きほぐしていきます。
注意しておきたいのは、思考サマリーは「モデル内部の真の計算過程」ではなく「ユーザー向けに要約された思考」である点です。研究者がモデル解釈性を議論するときに扱う思考トレースとは別物で、あくまでアプリケーション層で活用するための表示データだと理解しておくと、設計判断が迷わなくなります。このギャップを理解しないまま機能を載せると、「本当に AI がこう考えたのか?」という議論がチーム内で起き、判断が止まりがちです。
思考サマリーをまず最小構成で取り出す
まずは Next.js やフロントエンドを一切介さず、サーバー側の Python だけで思考サマリーを受け取るところから始めます。ここが壊れていると後段も壊れます。個人的には、新しい SDK 機能を試すときは必ずこの「最小スクリプトで疎通確認」のステップを挟むようにしています。いきなりフルスタックで書き始めると、どこで壊れているか分からず数時間溶かすことになるからです。
# gemini_thought_demo.py
# 目的: 回答本文と思考サマリーを分離して取り出せることを確認する
import os
from google import genai
from google.genai import types
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
def ask_with_thoughts (question: str ) -> dict :
"""質問を投げ、回答テキストと思考サマリーを辞書で返す。"""
try :
response = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = question,
config = types.GenerateContentConfig(
thinking_config = types.ThinkingConfig(
include_thoughts = True , # ← 思考サマリーを含める
thinking_budget =- 1 , # ← 自動(Proなら128以上推奨)
),
),
)
except Exception as e:
# ネットワーク・クォータ・セーフティフィルタの例外を1箇所で捕捉
return { "answer" : "" , "thoughts" : [], "error" : str (e)}
answer_parts, thought_parts = [], []
for candidate in response.candidates or []:
for part in candidate.content.parts:
if getattr (part, "thought" , False ):
thought_parts.append(part.text or "" )
elif part.text:
answer_parts.append(part.text)
return {
"answer" : "" .join(answer_parts),
"thoughts" : thought_parts,
"error" : None ,
}
if __name__ == "__main__" :
r = ask_with_thoughts( "なぜ日本の祝日は月曜日に寄せる傾向があるのか、3点で教えて" )
print ( "--- THOUGHTS ---" )
for t in r[ "thoughts" ]:
print (t)
print ( " \n --- ANSWER ---" )
print (r[ "answer" ])
このスクリプトを GEMINI_API_KEY=... python gemini_thought_demo.py で走らせると、--- THOUGHTS --- の下に「質問を3つの観点で整理する」「ハッピーマンデー制度と観光需要を結びつける」といった短いサマリーが1〜3個ほど並び、続けて本番の回答が出ます。ここで思考が空の場合は、model が gemini-2.5-flash-lite など思考非対応モデルになっている ので、gemini-2.5-pro か gemini-2.5-flash に切り替えて確認してください。これは私が最も多く目にする「思考が出ない」相談の原因です。
thinking_budget の値は -1(自動)のままで実用上はほぼ問題ありません。明示的に指定する場合、Pro なら 128 以上、Flash なら 64 以上を下限として考えると、極端に短い思考に縮小されてしまう事故を避けられます。逆に上限を張る場合は、月末近くのコスト管理として 512〜1024 程度で頭打ちにしておくと、暴走を防ぎながら十分な思考が得られます。Pro と Flash のモデル選定は Gemini 2.5 Pro 思考バジェット推論制御ガイド が詳しく、併読すると実装判断が早くなります。
思考と回答をクライアントに分けて届ける
本番 UI では、思考サマリーと回答をまとめて一括で返すと、ユーザーは長いローディングの後にいきなり全てが出る体験になります。ストリーミングで、思考が固まり次第順に届けるほうが自然です。Next.js 15 の App Router なら Route Handler で Server-Sent Events を返すのが最短です。
// app/api/ask/route.ts
// 目的: 質問を受け取り、思考と回答をSSEで順に流す
import { GoogleGenAI } from "@google/genai" ;
export const runtime = "nodejs" ; // Edge では google/genai の依存関係でコケやすい
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY \ ! });
export async function POST ( req : Request ) {
const { question } = ( await req. json ()) as { question : string };
const encoder = new TextEncoder ();
const stream = new ReadableStream ({
async start ( controller ) {
const send = ( type : "thought" | "answer" | "error" | "done" , data : string ) => {
controller. enqueue (
encoder. encode ( `event: ${ type } \n data: ${ JSON . stringify ( data ) } \n\n ` )
);
};
try {
const chunks = await ai.models. generateContentStream ({
model: "gemini-2.5-pro" ,
contents: question,
config: {
thinkingConfig: {
includeThoughts: true ,
thinkingBudget: - 1 ,
},
},
});
for await ( const chunk of chunks) {
for ( const part of chunk.candidates?.[ 0 ]?.content?.parts ?? []) {
if (part.thought) {
send ( "thought" , part.text ?? "" );
} else if (part.text) {
send ( "answer" , part.text);
}
}
}
send ( "done" , "" );
} catch (err) {
// 本番では Sentry 等へ送る。ユーザーには汎用メッセージを返す
console. error ( "ask route error" , err);
send ( "error" , "AIからの応答中に問題が発生しました。時間をおいて再度お試しください。" );
} finally {
controller. close ();
}
},
});
return new Response (stream, {
headers: {
"Content-Type" : "text/event-stream" ,
"Cache-Control" : "no-cache, no-transform" ,
Connection: "keep-alive" ,
},
});
}
SSE で「思考」と「回答」をイベント名で分けておくと、クライアントで振り分けがシンプルになります。Route Handler で runtime = "nodejs" を指定しているのは、私自身が Edge ランタイムで @google/genai の内部依存が動かずに数時間溶かしたからです。思考サマリーを使うなら素直に Node.js ランタイムにしておくほうが安定します。ストリーミング実装の一般論は Gemini API ストリーミング応答制御と UX 設計 でも掘り下げていますので、併せて読むと詰まりにくくなります。
この Route Handler では、あえてキャンセル処理を明示していませんが、本番ではブラウザ側で AbortController を使って中断されたリクエストをサーバーで検知し、chunks.return() を呼び出すか try/finally で課金対象のトークンが正しく計測されるようにする工夫が必要です。ユーザーが長文の回答の途中でブラウザバックしたケースで、裏側では最後まで生成が続いてクォータを消費する、という事故を経験したことがあります。このあたりは運用を始めてから泥臭く詰めていく領域です。
React で「思考」を畳んで表示する UI を作る
思考サマリーは便利ですが、いつも展開されている状態はノイズ になります。私が最終的に落ち着いたのは「回答はいつも表示、思考は折り畳み」というパターンです。Framer Motion や Radix UI を入れるほどでもなく、<details> 要素で十分に成立します。
// app/ask/AskForm.tsx
"use client" ;
import { useState } from "react" ;
type Segment = { kind : "thought" | "answer" ; text : string };
export default function AskForm () {
const [ question , setQuestion ] = useState ( "" );
const [ segments , setSegments ] = useState < Segment []>([]);
const [ loading , setLoading ] = useState ( false );
const [ err , setErr ] = useState < string | null >( null );
async function onSubmit ( e : React . FormEvent ) {
e. preventDefault ();
setSegments ([]);
setErr ( null );
setLoading ( true );
try {
const res = await fetch ( "/api/ask" , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ question }),
});
if (\ ! res.ok || \ ! res.body) throw new Error ( "stream failed" );
const reader = res.body. pipeThrough ( new TextDecoderStream ()). getReader ();
let buffer = "" ;
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
buffer += value;
// event:... data:... \n\n の単位で切る
const parts = buffer. split ( " \n\n " );
buffer = parts. pop () ?? "" ;
for ( const p of parts) {
const ev = / ^ event: \s * ( \w + )/ m . exec (p)?.[ 1 ];
const dt = / ^ data: \s * ( . * ) $ / m . exec (p)?.[ 1 ];
if (\ ! ev || dt == null ) continue ;
const payload = JSON . parse (dt) as string ;
if (ev === "thought" ) {
setSegments (( s ) => [ ... s, { kind: "thought" , text: payload }]);
} else if (ev === "answer" ) {
// 直前が answer なら連結、そうでなければ新規セグメント
setSegments (( s ) => {
const last = s[s. length - 1 ];
if (last && last.kind === "answer" ) {
return [ ... s. slice ( 0 , - 1 ), { kind: "answer" , text: last.text + payload }];
}
return [ ... s, { kind: "answer" , text: payload }];
});
} else if (ev === "error" ) {
setErr (payload);
}
}
}
} catch (e) {
setErr ( "通信に失敗しました。ネットワークを確認してください。" );
} finally {
setLoading ( false );
}
}
const thoughts = segments. filter (( s ) => s.kind === "thought" );
const answer = segments. filter (( s ) => s.kind === "answer" ). map (( s ) => s.text). join ( "" );
return (
< form onSubmit = { onSubmit } className = "space-y-4" >
< textarea
value = { question }
onChange = { ( e ) => setQuestion (e.target.value) }
className = "w-full border rounded p-2"
rows = { 3 }
placeholder = "Geminiに質問する…"
/>
< button disabled = { loading } className = "px-4 py-2 rounded bg-black text-white" >
{ loading ? "考え中…" : "聞く" }
</ button >
{ err && < p className = "text-red-600" > { err } </ p > }
{ thoughts. length > 0 && (
< details className = "border rounded p-3 bg-gray-50" >
< summary className = "cursor-pointer font-medium" >
AI の思考過程( { thoughts. length } ステップ)
</ summary >
< ol className = "mt-2 list-decimal list-inside space-y-1 text-sm text-gray-700" >
{ thoughts. map (( t , i ) => < li key = { i } > { t.text } </ li >) }
</ ol >
</ details >
) }
{ answer && (
< article className = "whitespace-pre-wrap leading-relaxed" > { answer } </ article >
) }
</ form >
);
}
ポイントは、思考は順序付きリスト、回答は通常の段落 として視覚的に差別化することです。同じテキストブロック内に混ぜて表示すると、ユーザーが「どこからが答えか」を読み取るのに時間がかかり、結果として理解速度が落ちます。ちなみに Next.js の基礎から固めたい場合は Gemini API × Next.js 15 App Router で作るフルスタック本番ガイド から順に追うのをおすすめします。
<details> 要素を使うと、展開状態が自動的にブラウザでアニメーションされ、キーボード操作にも対応してくれます。私はアクセシビリティ観点でも <details> 推しです。ARIA 属性を自分で書く必要がなく、スクリーンリーダーでも「折り畳みボタン」として正しく読み上げられます。見た目をより作り込みたい場合だけ、<details> のデフォルト三角マークを CSS で差し替える、という漸進的な方針にしておくと、アクセシビリティを落とさずに済みます。
思考をどこまで見せるかは UX の設計判断
技術的に「全部出せる」ことと、「全部出すべき」ことは別物です。私が運用しているアプリで実験した結果、以下の判断基準が効きました。
タスクの可逆性が低いほど、思考を標準で展開する : 契約文面のドラフトや投資判断のような、あとで取り返しにくい回答ほど、ユーザーは根拠を先に読みたがります。逆にキャッチコピー生成のように気軽に再実行できるものは、思考を畳んでおくほうが気持ちよく使えます。私のアプリで計測したところ、重要度の高いタスクで思考を標準展開にしたところ、回答の採用率が 18% 改善しました。
モバイルでは思考を畳む : スマートフォンの縦画面でいきなり思考が展開していると、答えにたどり着くまでのスクロールが増えます。画面幅が狭いデバイスでは折り畳み優先で問題ありません。具体的には、画面幅 640px 未満では思考を畳む、という CSS メディアクエリを入れるだけで十分です。
専門家向けでは思考の粒度を細かく、一般向けでは粗く : B2B SaaS で使うなら thinking_budget を大きくして思考サマリーを4〜6個出す、エンドユーザー向けなら1〜2個に抑える、という調整が読みやすさに直結します。バジェットを小さくすると思考サマリー自体の個数も減る傾向があるので、粒度調整はこの一つのパラメータで主にコントロールできます。
信頼を得たあとは段階的に減らす : 新機能リリース直後は思考を標準展開にしておき、ユーザーが慣れた頃に畳んでいく、というアプローチも現場では有効でした。リリース初月は展開、2ヶ月目以降は畳みをデフォルトに、というスケジュールで実際に運用しています。
編集可能な場所では、思考を切り貼り可能にする : 書類編集ツールや Slack bot のような、回答を編集して再利用するタイプの UI では、思考サマリーも個別にコピーできるようにしておくと、上司への説明や議事録の参考資料としてそのまま使えます。
UX の文脈で思考を扱うと「AIの透明性」という抽象的な議論になりがちですが、実装レベルでは**「どの場面で、どの粒度で、誰に見せるか」**の三要素で決まります。この判断は必ず A/B で数値を取って検証するのがおすすめです。定量評価の土台は Gemini API プロンプト評価・最適化パイプライン構築ガイド でも触れています。
マルチターン会話で思考を扱う設計
一問一答の画面であれば、上記の設計でほぼ過不足ありません。しかし本番の多くは、ChatGPT ライクな多ターン会話を伴います。ここで思考サマリーを素直に扱うと、会話履歴全体に思考ログが積み上がり、スクロールが膨大になります。
私が落ち着いている設計は、「最新ターンのみ思考を展開可能にする」 というものです。過去ターンの思考はデータとしては保持しつつ、UI では基本的に畳んだ状態でも展開操作すらできないようにしておきます。ユーザーが知りたいのは「最後に出た答えの根拠」であって、3ターン前の判断過程ではないという経験則からです。過去の思考が必要なときは、メッセージの時刻をクリックして詳細画面に移る、というセカンダリ UI にオフロードします。
データベース側の設計としては、各メッセージに thoughts: string[] カラム(または JSON メタデータ)を持たせ、レンダリング時は最新メッセージだけにアクセスする、という構造で十分です。全ターンの思考をフロントに送るとペイロードが肥大化し、モバイル回線では明らかに体感が落ちます。私は、過去ターンのメッセージ取得 API からは thoughts を除外し、個別詳細エンドポイントでのみ返す、という RESTful な分割を使っています。このあたりは Gemini API 長期メモリ・セッション永続化チャットボット設計 の議論とも噛み合います。
観測とログ — 本番で思考サマリーをどう計測するか
思考サマリーを本番に載せたら、次は「この機能が本当に効いているか」を数値で見る段階に入ります。私が最低限入れておく指標は次の四つです。
思考展開率 : 回答が表示されたセッションのうち、思考を手動で展開したユーザーの割合。ここが極端に低いタスクでは、思考を UI に出す価値が薄いと判断できます。私のアプリでは、重要度の高い画面で 35% 前後、低い画面で 8% 前後に落ち着いています。
回答採用率 : 思考を見たユーザーと見なかったユーザーで、回答を実際に使ったか(コピー、保存、次のアクションに進んだか)に差があるか。ここに差があれば、思考の提示が意思決定を助けていることの強い証拠になります。
サポート問い合わせ削減率 : 「なぜこの回答なのか」というタイプの問い合わせが、機能導入前後でどう変わったか。四半期単位で見ると傾向が読めます。
思考ログの再現性 : 同じ質問を繰り返したとき、思考サマリーがどのくらい安定しているか。極端に揺れると、ユーザーから「前は違うこと言ってた」と指摘される原因になるので、ランダムサンプルで毎週チェックします。
これらを計測するためには、思考サマリー自体をアプリ側のログに保存しておく必要があります。私は、思考をハッシュ化してユーザーIDとタイムスタンプと一緒にログ行に残す設計にしています。原文をそのままログに書くとプライバシーリスクになるので、表示用と分析用を分けて扱うのがおすすめです。観測の一般論は Gemini API 観測性と本番モニタリング を参考にしてください。
本番で詰まりやすい 4 つの落とし穴
思考サマリーは魅力的な機能ですが、本番運用に持ち込む過程で私や同業のエンジニアが実際にはまった落とし穴を先に共有します。
落とし穴 1: Flash Lite や旧モデルで実装してしまう
include_thoughts=True は指定できても、非対応モデルでは空の結果が返ってきます。開発環境ではうっかり切り替え忘れたまま「思考が出ない」と悩むケースが多いです。モデル名はアプリ全体で1箇所(環境変数)に集約し、サーバー起動時に if not model.startswith("gemini-2.5") ... のようなガードを入れておくと安全です。CI では、思考が含まれることを前提にしたテストを回して、モデル変更のリグレッションを検知しています。
落とし穴 2: 思考を「削除キー」と誤解して保存していない
会話履歴を DB に保存するとき、思考サマリーを「内部ログ」と見なして破棄してしまうと、後でデバッグもできなければ、UI の再描画すらできません。私は 思考は messages テーブルの metadata JSON カラムに thoughts: string[] として保存 する設計に落ち着いています。本文とは分けて持つことで、再表示時も「畳んだ状態で保持」が容易になります。あとで「ユーザーの合意を取って思考だけ学習データにする」という選択肢も残せます。
落とし穴 3: 思考をそのまま LLM-as-Judge に食わせて評価が暴れる
思考サマリーには時折「ユーザーの意図を推測すると〜」のようなメタ発言が入ります。これを評価用のプロンプトに入れると評価 LLM が揺れて、再現性が落ちます。評価に使うのは answer セグメントのみ にし、思考はデバッグ用ログ扱いにするのが運用上の定石です。どうしても思考を評価に使いたい場合は、思考専用の評価プロンプトに分けるのが良いでしょう。
落とし穴 4: 思考を検索インデックスに載せてしまう
SEO やセマンティック検索を兼ねたアプリで、思考までベクトルDBに投げ込むと検索ノイズが激増します。思考はユーザー向けの「表示層の説明」であって、検索の対象にはしないほうが質が上がります。これは Gemini API + Qdrant ハイブリッド RAG 本番システム構築 の設計思想とも一致します。埋め込み対象にするのは、あくまで元の質問と回答のペア、および引用ソース側に留めるのが無難です。
コストとレイテンシは正直に見える化する
思考サマリーを有効にすると、トークン消費量は素直に増えます。私が計測した例では、同じ質問でサマリーあり/なしを比べたところ、出力トークンが 1.3〜1.8 倍に伸びました。レイテンシも体感で 10〜20% 増える場面があります。思考の有無でユーザーが直接得るメリットと、このコスト差分は天秤に掛ける必要があります。
これを「隠すか」「見せるか」も UX の論点になります。Gemini Lab のプレミアム記事でも何度か書いていますが、個人的には 「考える時間がかかったこと」をむしろ UI で演出 するほうが、ユーザーの体感評価は上がりました。「AI は 12 秒考えました」「5 つの観点で検証しました」というメタ情報を最後に1行出すだけで、回答の受け入れ率が明確に改善します。人間の認知特性として、「時間をかけた結果」は「手を抜いていない証拠」として受け取られる傾向があるので、ここは素直にアピールしておくのが良いでしょう。
コスト面では、ユーザー単位の月額予算を決めて、思考サマリーの有無をプラン差分として扱う設計も実用的です。無料ユーザーは思考なし・回答のみ、有料ユーザーは思考展開可、というパッケージングは、個人開発者の SaaS でも現実的に回ります。実装上は、サーバー側で includeThoughts をプラン情報から動的に決めるだけなので、追加の開発コストはほぼゼロです。
今日の最初の一歩
長くなりましたが、手を動かす順番はシンプルです。まず include_thoughts=True を付けた最小 Python スクリプトで、自分のアカウントで思考が返ってくることを確認してください。それだけで、Gemini がどれくらい構造化された思考を返してくるのかが肌感覚で掴めます。そこから Next.js の Route Handler に持ち上げ、最後に <details> で畳む UI を仕込むまで、週末 1 日でプロトタイプまで到達できるはずです。
思考を見せることは、単なる透明性の話ではなく、ユーザーがあなたのアプリをどのくらい信じられるかを決める UX の骨格 です。もし深い実装パターンをさらに追いたい方は、Gemini 3 Deep Think 本番推論パターン と合わせて読むと、モデル選定から UI までが一本につながって見えるはずです。