電車で移動しながら自分のアプリを触っていて、生成中の文章が途中で止まりました。ホームに入った瞬間です。アプリは黙って最初からやり直し、同じ書き出しがもう一度流れてきました。
そのとき引っかかったのは待たされたことよりも、いま消えた 600 トークン分の文章にも、おそらく料金は発生しているという事実でした。ユーザーには一度も届かないまま、二度目の生成が始まっています。
Google が公開した東南アジアのレポートでは、Gemini アプリへのリクエストのおよそ 75% がモバイルからで、音声・写真・動画を伴うものも 40% を超えていました。回線が揺れる前提で作らなければならない、という話が数字として出てきたことになります。個人開発でモバイル向けに Gemini を組み込んでいる立場からすると、切断は例外ではなく通常運転の一部です。
なぜ切れるのかの切り分けはストリーミングが本番で途中で切れるときの原因切り分けの順序に譲ります。ここで扱うのは、切れた後に何を選ぶかという一点です。
切れたストリームは「どこまで課金されたか」が分からない
streamGenerateContent の usageMetadata は、チャンク列の終盤で届きます。最後まで受け取れた場合はそこに入力・出力・思考のトークン数が入っています。
逆に言えば、途中で切れた試行については、この数字が手元に来ません。何トークン分を生成したところで切れたのか、クライアントからは確定できないということです。
そして、クライアントが接続を落とした時点でサーバ側の生成がどう扱われるか、課金がどこで止まるかは、公開ドキュメントに明示されていません。
ここで「たぶん課金されない」と仮定してしまうと、設計が崩れます。私自身は、受信済みチャンク分は課金されたものとして扱う前提で組んでいます。分からないものを楽観側に倒さない、というだけの話です。本番運用で効いてくる注意点は、この不確かさが再試行のたびに積み上がることのほうです。
| 状況 | usageMetadata | 実際の課金 |
| 最後まで受信 | 取得できる | 数字どおりに把握できる |
| 途中で切断 | 届かない | 不明(悲観側に見積もる) |
| クライアント側で中止 | 届かない | 不明(同上) |
計測できない支出があるなら、せめて発生回数だけは数えておきます。切断イベントに受信済み文字数を添えてログに落とすだけでも、後で効いてきます。
引き直しと継続では、費用の形が違う
記号を置きます。プロンプトのトークン数を P、完成までに必要な出力を O、切れるまでに受け取った出力を R とします。入力単価を Ci、出力単価を Co とします。
切れた試行のぶん(Ci×P + Co×R)は、どちらを選んでも既に出ていきます。差が出るのはその後です。
| 方式 | 二度目の入力 | 二度目の出力 | 捨てるもの |
| 引き直す | P | O | 受信済みの R トークン |
| 続きから書かせる | P + R | O − R | 継ぎ目の滑らかさ |
差額を出すと、こうなります。
引き直し − 継続
= (Ci×P + Co×O) − (Ci×(P+R) + Co×(O−R))
= Co×R − Ci×R
= R × (Co − Ci)
Gemini の価格表では出力単価が入力単価より高く置かれています。つまり Co − Ci は正で、継続は常に名目上は安くなるという結論になります。
ただし式をよく見ると、節約額は R に比例します。R が 60 トークンなら、浮くのは 60 × (Co − Ci) です。実装の複雑さと釣り合う額ではありません。
費用だけを見て「常に継続」と決めるのは、この式の読み違えです。効いてくるのは R が大きいとき、つまり長い文章を書かせている最中に落ちたときだけです。
思考トークンは継続で二度発生する
もうひとつ、式に入っていない項があります。thinking を有効にしているモデルでは、継続リクエストでも推論からやり直しになります。
継続は出力の続きを書かせているのであって、前回の思考過程を引き継いでいるわけではありません。thinkingBudget を大きく取っている構成では、R × (Co − Ci) の節約を思考トークンの再発生が上回ることが普通に起きます。
私はこの点があるので、thinking を厚く使う経路では継続を諦めて引き直しに寄せています。この場合は、深く考えさせる処理をストリーミングの経路から外してしまうほうが素直でした。継続の可否を悩む前に、待たせる場所に重い推論を置かないことで問題ごと回避できます。
分岐条件は「割合」ではなく受信トークン数で置く
「7 割まで届いていたら継続」といった割合での判定は、実装しやすい反面よく外れます。O は生成が終わるまで確定しないので、割合の分母が推定値のままだからです。
分母を使わずに済ませます。判定に使うのは R、つまり実際に受け取った出力トークン数だけです。
| 条件 | 選ぶ方式 | 理由 |
| responseSchema を指定している | 引き直す | 途中の JSON は継続で閉じられない |
| R が閾値未満(目安 200 トークン) | 引き直す | 節約額が継ぎ目の乱れに見合わない |
| R が閾値以上・自由文 | 続きから書かせる | 捨てる量が無視できない |
| 同一リクエストで 2 回目の切断 | 非ストリーミングに落とす | 回線が持たないと判断する |
閾値の 200 は私の運用で落ち着いた値で、根拠は費用ではなく体感です。これ未満だと引き直しの待ち時間が短く、継ぎ目を気にするより作り直したほうが結果が綺麗でした。皆さんの構成では出力の平均長で変わるはずなので、そのまま持ち込まないでください。
構造化出力を返している経路を無条件に引き直しへ回しているのは、途中で切れた JSON を継続で閉じさせると、閉じ括弧の数が合わない応答が返ってくることがあるためです。部分 JSON を画面に出す話は構造化出力のストリーミングで完成したフィールドから順に出す設計のほうで扱っています。
受信側は「文末」でチェックポイントを置く
継続に使う R は、どこで切っても良いわけではありません。単語の途中で切れたテキストを次のリクエストに渡すと、モデルはその欠けた語を補おうとして妙な書き出しになります。
受け取ったチャンクを、最後の文末記号まで巻き戻してから保存します。Kotlin での受信側はこうなりました。
private val SENTENCE_END = Regex("[。.!?!?\\n]")
class StreamCheckpoint {
private val buffer = StringBuilder()
/** 受信したチャンクを積む。UI にはそのまま流してよい */
fun append(chunk: String) {
buffer.append(chunk)
}
/**
* 継続リクエストに渡せる形へ整える。
* 最後の文末記号までで切り落とし、書きかけの文は捨てる。
* 文末が一度も現れていなければ null(= 引き直す)。
*/
fun committedText(): String? {
val text = buffer.toString()
val lastEnd = SENTENCE_END.findAll(text).lastOrNull()?.range?.last ?: return null
val committed = text.substring(0, lastEnd + 1).trimEnd()
return committed.ifEmpty { null }
}
/** ざっくりしたトークン換算。日本語は 1 トークンあたり 1 文字弱で見ておく */
fun approxTokens(): Int = committedText()?.length ?: 0
}
approxTokens を目安の判定に使い、正確な数字が要るときだけ countTokens を叩きます。切断のたびに countTokens を呼ぶと、それ自体が往復の追加になります。
書きかけの文を捨てるのは損に見えますが、捨てた分は継続で書き直されます。二重に払う対象は数十トークンで、継ぎ目の破綻を防ぐ対価としては安いほうです。
継続リクエストは「直前の応答」として渡す
続きを書かせるとき、部分出力をプロンプトに文字列として埋め込むのは避けます。モデルの発話として contents に積み、その後に短い指示を置きます。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
def continue_generation(model, system_instruction, user_prompt, committed_text):
"""切断時の部分出力を model ターンとして積み、続きだけを書かせる"""
contents = [
types.Content(role="user", parts=[types.Part(text=user_prompt)]),
types.Content(role="model", parts=[types.Part(text=committed_text)]),
types.Content(role="user", parts=[types.Part(
text="直前の文章の続きだけを書いてください。"
"すでに書かれた部分の再掲・要約・前置きは不要です。"
)]),
]
return client.models.generate_content_stream(
model=model,
contents=contents,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.4,
),
)
system_instruction は初回と同じものを渡します。ここを省くと文体が変わり、継ぎ目が読者に見えます。
temperature を初回より少し下げているのは、継続部分で話題が飛ばないようにするためです。続きを書くというタスクは、ゼロから書くより自由度が要りません。
継ぎ目に出る三つの壊れ方
継続を入れた後に残った不具合は、この三つでした。
再掲。指示に「再掲は不要」と書いても、直前の一文をなぞって書き始めることがあります。受信した最初のチャンクが committed_text の末尾と重なっていないかを、末尾 40 文字ほどで照合して、重なっていれば落とします。
段差。段落の途中で切れて継続すると、接続がぎこちなくなります。チェックポイントを文末ではなく段落末(空行)まで巻き戻す設定も試しましたが、捨てる量が増えすぎました。文末で切って、段差は許容しています。
列挙の番号。箇条書きの途中で切れると、継続側が 1 から振り直すことがあります。committed_text の末尾に番号付き行があるときは、指示に「次の番号は N です」と足しています。
どれも継続を選んだ側が引き受けるコストです。ここまで手当てして、ようやく R × (Co − Ci) が手元に残ります。この面倒さこそが、閾値を低く設定しすぎてはいけない理由でもあります。
書き込み側は二重に届く前提で組む
継続を入れると、同じ文章が二度確定するケースが増えます。切断したと判断して継続を投げた後に、元のストリームの残りが遅れて届く、という順序がありうるからです。
生成結果を保存する側に冪等キーを持たせて、後から来たほうを黙って捨てます。この設計は複数サイト運用での冪等性キー設計で扱っているものを、そのまま流用できます。
キーはリクエスト単位で発番し、継続リクエストにも同じキーを引き継ぎます。継続は新しい生成ではなく、一つの生成の続きだからです。
落ち着いた設定と、まだ迷っているところ
現時点の分岐はこうなっています。構造化出力は常に引き直し、自由文は受信 200 トークンを境に継続、二度切れたら非ストリーミングへ退避、thinking を厚く使う経路は継続を使わない。
迷っているのは閾値そのものです。費用の式からは「R が大きいほど得」としか出てこないので、200 という数字は継ぎ目の手当てにかかる複雑さと待ち時間の体感で決めています。もう少し良い決め方があるはずだと感じています。
まず切断イベントに受信済みトークン数を添えてログに残すところから始めてみてください。R の分布が見えると、自分の構成で継続を入れる価値があるのかどうかが、式に数字を入れるだけで判断できます。分布が 100 トークン付近に固まっているなら、この記事の設計は不要だという結論になります。それが分かることにも意味があります。