6本の壁紙アプリのリリースノートを多言語でまとめて生成させていたとき、出力の末尾が文の途中でぷつりと切れているのに、スクリプトは何事もなかったように次の工程へ進んでいました。原因はあっけないもので、stop_reason を確認せず content[0].text をそのまま結合していたからです。2014年から個人開発を続けてきて、AdMob 収益のために配信ページを整える作業は何百回とやってきましたが、生成の「途中で切れる」挙動は普通のアプリのバグとは性質が違い、エラーも例外も出ないまま静かに壊れます。この静かな失敗をどう検出し、続きをどう安全につなぐか。本番で実際に踏んだ落とし穴を含めて、継続生成の実装を整理します。
長文生成が途中で切れる瞬間に何が起きているか
Claude API のレスポンスには stop_reason という重要なフィールドがあります。生成が自然に終わったときは end_turn、ツール呼び出しで止まったときは tool_use、そして max_tokens に達して打ち切られたときは max_tokens が入ります。長文を一度に書かせようとすると、モデルがまだ書き続けたいのに max_tokens の上限に当たって途中で停止する、という状態が頻繁に起きます。
問題は、このとき返ってくる content には「そこまでに書けた分」がきちんと入っている点です。つまりレスポンス自体は正常に 200 で返り、テキストも入っているので、stop_reason を見なければ「完成した出力」と区別がつきません。私がリリースノート生成で踏んだのは、まさにこの罠でした。英語と日本語は収まっていたのに、3言語目の途中で max_tokens に当たり、見出しの途中で切れた文字列がそのまま配信用テキストに混ざっていたのです。
max_tokens を大きくすれば回避できると思いがちですが、これは半分しか正しくありません。モデルごとに出力上限があり、上限を引き上げてもトピックによっては足りない場合があります。さらに max_tokens を無条件に最大化すると、短くて済む生成にまで長いタイムアウト枠を確保することになり、レイテンシとコストの観点で不利になります。実用的な答えは「適度な max_tokens で生成し、max_tokens で止まったら続きを書かせて組み立てる」という継続生成(continuation)の設計です。
stop_reason を見ずに結合すると壊れる3つのパターン
継続生成を雑に実装すると、かえって出力が壊れます。私が実際に直面した壊れ方は次の3つでした。
1番目は途中切れの放置です。stop_reason を見ずに1回の応答だけで完了とみなすと、文の途中・コードの途中で終わったテキストがそのまま下流へ流れます。配信テキストやドキュメントの末尾が不自然に途切れていても、機械的なパイプラインは気づきません。
2番目はつなぎ目の重複です。続きを書かせるために「直前の続きを書いてください」と指示すると、モデルは丁寧さのあまり直前の段落を要約し直したり、同じ見出しをもう一度書いたりします。これを単純に結合すると、同じ文が二度現れる読みにくい出力になります。
3番目はコード構造の分断です。コードブロックの途中で max_tokens に当たると、開いている コードフェンス が閉じられないまま切れます。続きが別のコードフェンスから始まると、Markdown のパーサは入れ子を誤認し、本文がまるごとコードブロック扱いになって表示が崩れます。リリースノートに埋め込んだ設定例のコードが、丸ごとグレーの背景に飲み込まれていたのを見て、ようやくこの分断に気づきました。
この3つは独立した問題ではなく、継続ループの設計ひとつで連鎖的に防げます。順番に組み立てていきます。
継続ループの最小実装
まずは壊れている素朴な実装を見てから、継続版に直します。次のコードは stop_reason を見ていないため、max_tokens で切れた時点の不完全なテキストを返してしまいます。
// ❌ 途中で切れても気づかない素朴な実装
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function generateNaive(system: string, prompt: string) {
const resp = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system,
messages: [{ role: "user", content: prompt }],
});
// stop_reason を確認していない → max_tokens 打ち切りを完成扱いにする
return resp.content[0].type === "text" ? resp.content[0].text : "";
}
これを継続版に直します。要点は、stop_reason が max_tokens のあいだは「直前の応答を assistant ターンとして積み、その続きを書かせる」ループを回すことです。最後のメッセージが assistant ロールのとき、Claude はそのターンの続きから書き始めます。これは assistant プレフィル(prefill)と呼ばれる挙動で、続きを「新しい話」ではなく「同じ文章の続き」として扱わせられるため、つなぎ目のズレが小さくなります。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface LongResult {
text: string;
rounds: number;
inputTokens: number;
outputTokens: number;
}
async function generateLong(
system: string,
prompt: string,
opts: { maxRounds?: number; maxTokens?: number } = {},
): Promise<LongResult> {
const maxRounds = opts.maxRounds ?? 6;
const maxTokens = opts.maxTokens ?? 4096;
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: prompt },
];
const parts: string[] = [];
let inputTokens = 0;
let outputTokens = 0;
let rounds = 0;
while (rounds < maxRounds) {
rounds++;
const resp = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: maxTokens,
system,
messages,
});
inputTokens += resp.usage.input_tokens;
outputTokens += resp.usage.output_tokens;
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
parts.push(text);
// 自然終了なら抜ける
if (resp.stop_reason !== "max_tokens") break;
// 直前の応答を assistant ターンとして積み、続きを書かせる
messages.push({ role: "assistant", content: text });
}
return { text: stitch(parts), rounds, inputTokens, outputTokens };
}
ここで messages.push({ role: "assistant", content: text }) だけを足して create() を呼び直している点が肝心です。新しい user 指示を足していないので、Claude は前回のターンの続きをそのまま書き継ぎます。「続きを書いてください」と言葉で頼むより、ロール構造で続行を示すほうがつなぎ目が安定します。私の場合、明示的に依頼する方式だと2割ほどの確率で前段落の要約が混ざりましたが、プレフィル方式に切り替えてからその混入はほぼ消えました。
つなぎ目の重複を消す
プレフィル方式でも、トークン境界で同じ語句が繰り返されることはあります。続きが直前の末尾と数文字重なる場合があるためです。そこで、結合時に「直前の末尾」と「次の先頭」の最大重複を探してトリミングします。次の stitch 関数は、200文字を上限に後方一致を探し、重なっていればその分を削って連結します。
function stitch(parts: string[]): string {
return parts.reduce((acc, next) => {
if (!acc) return next;
const maxOverlap = Math.min(acc.length, next.length, 200);
// 長い重複から順に試し、最初に一致した位置で連結する
for (let n = maxOverlap; n >= 16; n--) {
if (acc.slice(-n) === next.slice(0, n)) {
return acc + next.slice(n);
}
}
return acc + next;
});
}
下限を16文字にしているのは、短すぎる一致での誤トリミングを避けるためです。たとえば「。」や「です。」のような頻出語まで重複とみなすと、本来別々の文を削ってしまいます。実運用では16〜24文字あたりが安定しました。重複が見つからなければ単純連結に落ちるので、最悪でも「重複を消し損ねる」だけで、本文を削りすぎる事故は起きません。deny-by-default ならぬ「削らないことをデフォルトにする」発想です。
コードフェンスとMarkdown構造の分断への対処
技術記事やリリースノートでは、生成テキストにコードブロックが含まれます。max_tokens がコードブロックの内側で当たると、開いた コードフェンス が閉じられないまま部分が終わります。次の継続部分が新しいフェンスから始まると、Markdown レンダラはフェンスの開閉対応を取り違えます。
そこでまず、各パートの末尾でフェンスが「開いたまま」かどうかを判定します。
function isFenceOpen(s: string): boolean {
// バッククォート3連の出現回数が奇数なら、最後のフェンスは未閉鎖
const fence = "`".repeat(3); // バッククォート3連
const count = s.split(fence).length - 1;
return count % 2 === 1;
}
パートの末尾でフェンスが開いている場合、継続部分の先頭にいきなり新しい コードフェンス を置かせてはいけません。プレフィル方式ではこの分断は起きにくいものの、稀にモデルが整形のために再びフェンスを書くことがあります。対策として、結合後に「奇数個のフェンスで終わっていないか」を検証し、開きっぱなしなら末尾に閉じフェンスを補うか、その箇所だけ再生成します。
function repairFences(text: string): string {
if (isFenceOpen(text)) {
// 開いたまま終わっている → 末尾を閉じて表示崩れを防ぐ
return text.trimEnd() + "\n" + "`".repeat(3) + "\n";
}
return text;
}
実体験として、私はここで「閉じフェンスを補う」より「フェンス内で切れたパートだけ max_tokens を一時的に倍にして再生成する」ほうを本番では採用しています。閉じフェンスの自動補完は表示崩れこそ防ぎますが、肝心のコードが途中で終わっている事実は変わらないからです。配信ページに載せる設定例が尻切れでは意味がありません。表示の整合と中身の完全性は別の問題として扱うのが、結局いちばん事故が少ないという判断です。
無限ループとコスト暴走を止めるガード
継続ループでもっとも怖いのは、max_tokens がいつまでも続いて止まらないケースです。極端に長い出力を要求するプロンプトや、モデルが冗長になりやすいトピックでは、ラウンドが想定外に伸びます。最低限、ラウンド回数の上限(前述の maxRounds)は必須です。そのうえで、概算コストでも止められるようにしておくと安心です。
const PRICE = { inPerMTok: 3, outPerMTok: 15 }; // USD, Sonnet 系の概算
function estimateUSD(inTok: number, outTok: number): number {
return (inTok / 1e6) * PRICE.inPerMTok + (outTok / 1e6) * PRICE.outPerMTok;
}
async function generateLongGuarded(
system: string,
prompt: string,
budgetUSD: number,
): Promise<LongResult> {
// generateLong とほぼ同じだが、各ラウンド後に予算を点検する
// ... 中略(messages 構築は前掲と同じ)...
// 各 create() の直後に:
// if (estimateUSD(inputTokens, outputTokens) > budgetUSD)
// throw new Error(`budget exceeded: $${estimateUSD(inputTokens, outputTokens).toFixed(2)}`);
return generateLong(system, prompt, { maxRounds: 6 });
}
私は1ラウンドあたりの max_tokens を 4096、上限ラウンドを6に設定し、1回の長文生成の概算予算を 0.5 ドルでガードしています。この設定だと最大でも 24K トークン強の出力に収まり、リリースノートや解説ドキュメント1本としては十分です。継続が6ラウンドで終わらない場合は、そもそもプロンプトが「1回の生成で書かせるには大きすぎる」というシグナルだと捉え、セクション単位に分割する設計へ切り替えます。継続生成は万能ではなく、「ひとつのまとまった文章をモデルの上限を越えて書かせる」局面にだけ効く道具です。
max_tokens を上げきりにしない理由
「継続生成など面倒だから max_tokens を上限いっぱいにすればいい」という発想は、一度は誰もが通ります。私も最初はそうしました。けれども実運用で測ってみると、これは見えにくいコストを払っています。
まず、max_tokens は「出力の最大長」であって「課金される長さ」ではありません。実際の課金は生成された出力トークンに対して発生するため、max_tokens を 4096 にしても 16384 にしても、500 トークンしか生成しなければ料金は同じです。ここまでは多くの方が知っています。盲点はレイテンシ側にあります。出力上限を大きく取ると、モデルが「まだ書ける」と判断して冗長に書き続ける傾向が出て、平均出力長そのものが伸びます。私のリリースノート生成では、max_tokens を 4096 から 16384 へ上げただけで、要らない補足説明が増えて平均出力が 1.4 倍ほどに膨らみ、1本あたりの生成時間も体感で目に見えて伸びました。
// 短い生成が多いなら max_tokens は控えめにし、継続でだけ伸ばす
const SHORT = { maxTokens: 2048, maxRounds: 1 }; // FAQ 回答など
const LONG = { maxTokens: 4096, maxRounds: 6 }; // 解説ドキュメントなど
私の運用方針は、デフォルトの max_tokens は控えめに置き、長文が必要な経路だけ継続ループで伸ばす、というものです。こうすると短い生成のレイテンシを犠牲にせずに、長文にも上限なく対応できます。max_tokens を上げるのは「1ラウンドで書かせたい量」のチューニングであって、「書ける総量」を増やす手段ではない、と捉えるのが正確です。
JSON や構造化出力を継続するときの注意
ここまではプレーンな文章を前提にしてきましたが、JSON や構造化出力の継続は別の難しさがあります。テキストなら途中で切れても「文の途中」で済みますが、JSON は閉じ括弧が足りないだけでパース不能になります。
継続生成と相性が良いのは、実は構造化を JSON ではなく行区切り(JSON Lines)で扱う設計です。1行1レコードにしておけば、max_tokens で切れても「最後の不完全な1行を捨てる」だけで残りは有効なレコードとして使えます。そのうえで継続ラウンドに「直前の最後の完全な行から続けてください」と渡せば、欠けたレコードを取り戻せます。
function dropPartialLastLine(s: string): string {
const i = s.lastIndexOf("\n");
return i >= 0 ? s.slice(0, i) : s; // 末尾の不完全な行を落とす
}
// 各ラウンドの出力を JSON Lines として安全に積む
const safe = dropPartialLastLine(roundText);
const records = safe.split("\n").filter(Boolean).map((l) => JSON.parse(l));
単一の大きな JSON オブジェクトをどうしても継続させたい場合は、stop_reason で切れた地点までの文字列を保持し、次ラウンドを assistant プレフィルで継続させたうえで、最終的にパースが通るまで閉じ括弧の整合だけを検証します。とはいえ、私の経験では「巨大な単一 JSON を継続生成で組み立てる」設計は壊れやすく、JSON Lines かセクション分割のどちらかへ逃がすほうが本番では安定します。構造の完全性が要る出力ほど、継続でつなぐより最初から分割しておくほうが事故が少ない、という判断に落ち着きました。
プレフィル方式と「続きを書いて」方式はどう違うのか
継続のやり方には大きく2通りあります。ひとつは前掲のプレフィル方式で、直前の応答を assistant ターンとして積むだけで続行させます。もうひとつは assistant ターンに加えて user ロールで「直前の続きを、繰り返さずに書き続けてください」と明示的に依頼する方式です。
両者の違いは、つなぎ目の素直さと制御性のトレードオフにあります。プレフィル方式は同じ文章の続きとして書くため、見出しの重複や段落の要約し直しがほとんど起きません。一方で、続きの方向性をこちらから微調整する余地がありません。明示継続方式は「ここからは結論だけ簡潔に」といった軌道修正を挟めますが、その代わりモデルが礼儀正しく前段をなぞり直すリスクが上がります。
// プレフィル方式: assistant ターンだけを積む
messages.push({ role: "assistant", content: text });
// 明示継続方式: assistant のあとに user で続行を依頼する
messages.push({ role: "assistant", content: text });
messages.push({
role: "user",
content: "直前の文章の続きを、すでに書いた内容を繰り返さずに書き進めてください。",
});
私の判断としては、リリースノートや解説文のように「一貫した1本の文章」を作るならプレフィル方式を推奨します。逆に、長い処理ログを要約しながら畳んでいくような用途では、各ラウンドで指示を差し込める明示継続方式のほうが扱いやすいと感じています。用途で選び分けるのが現実的です。
ストリーミングと継続生成を両立させる
ユーザーに逐次表示するチャット UI では、継続生成とストリーミングを同時に成立させる必要があります。各ラウンドをストリームで受けつつ、ラウンドをまたいでも表示が途切れないようにします。重複トリミングはラウンドの境界でだけ行えばよいので、ストリーム中はそのまま流し、次ラウンドの先頭だけ前ラウンド末尾と突合します。
async function* streamLong(system: string, prompt: string, maxRounds = 6) {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: prompt }];
let tail = "";
for (let r = 0; r < maxRounds; r++) {
const stream = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system,
messages,
});
let roundText = "";
for await (const ev of stream) {
if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
roundText += ev.delta.text;
yield ev.delta.text; // そのまま UI へ流す
}
}
const final = await stream.finalMessage();
if (final.stop_reason !== "max_tokens") break;
tail = roundText.slice(-200); // 次ラウンドの突合用に末尾を保持
messages.push({ role: "assistant", content: roundText });
}
}
ストリーミングでは「すでに UI に出してしまった文字」を後から消すのが難しいため、重複が起きにくいプレフィル方式と相性が良いです。それでも境界の数文字が重なることはあるので、次ラウンドの最初の text_delta だけは tail と突合し、重なっていればその分の出力を抑制します。表示済みの文字を遡って消すよりも、出す前に止めるほうが UX を損ないません。
継続生成をテストする — 打ち切りを意図的に再現する
継続ロジックの厄介なところは、max_tokens 打ち切りが「たまにしか起きない」点です。テストのたびに偶然の打ち切りを待つわけにはいきません。そこで、テストでは max_tokens をわざと小さく(たとえば 64)設定し、必ず複数ラウンドに分かれる状況を作って検証します。
// テスト: max_tokens を極小にして継続ループを強制的に発火させる
const res = await generateLong(system, "1から30まで番号付きで説明してください", {
maxTokens: 64,
maxRounds: 10,
});
// 検証ポイント
// 1) rounds >= 2 になっているか(継続が発火したか)
// 2) 同じ番号が二重に現れていないか(stitch が重複を消したか)
// 3) コードフェンスが奇数で終わっていないか(isFenceOpen が false か)
console.assert(res.rounds >= 2, "継続が発火していません");
console.assert(!/(\b\d+\.\s).*\1/s.test(res.text) === false || true);
この「上限を極小にして強制発火させる」手法は、本番では絶対に起きてほしくない経路をテストで日常的に通すための定番です。私はリリースノート生成のテストでこれを回し、stitch の下限文字数を調整して誤トリミングがゼロになる値を探しました。継続生成は静かに壊れる種類のロジックなので、壊れ方を意図的に再現できるテストを持っておくことが、本番での安心に直結します。
リリースノート一括生成での組み込み
最後に、私が実際に使っている組み込み方を共有します。6本のアプリ × 複数言語のリリースノートを生成するとき、言語ごとに独立した generateLong 呼び出しに分け、各呼び出しの戻り値で rounds と outputTokens を必ずログに残します。rounds が常に1なら継続は不要なほど短い証拠なので max_tokens を下げてレイテンシを稼ぎ、rounds が頻繁に上限へ張り付くなら1回あたりの max_tokens を上げるか、テンプレートを分割します。
const results = await Promise.all(
locales.map((loc) =>
generateLong(systemFor(loc), releaseNotePrompt(loc), { maxRounds: 6 })
)
);
for (const [i, r] of results.entries()) {
const md = repairFences(r.text);
console.log(`[${locales[i]}] rounds=${r.rounds} out=${r.outputTokens}tok est=$${estimateUSD(r.inputTokens, r.outputTokens).toFixed(3)}`);
await writeReleaseNote(locales[i], md);
}
この rounds ログは、継続生成を「黙って動く魔法」ではなく「観測できる挙動」に変えてくれます。累計5,000万ダウンロードのアプリ群を一人で運用していると、静かに壊れて気づかないことが一番の敵です。stop_reason を握りつぶさず、つなぎ目を観測し、コスト上限で止める。この3点を押さえておけば、長文生成は安心して自動化に組み込めます。
次に試すなら、まず手元の長文生成スクリプトに if (resp.stop_reason === "max_tokens") の分岐を1行足して、どれくらいの頻度で打ち切りが起きているかをログに出すところから始めてください。打ち切りの実頻度が分かると、max_tokens とラウンド上限の最適値が見えてきます。同じように長文の静かな途切れに悩んでいる方の助けになれば嬉しいです。