AIレビューの出力が途中で切れる——件数と並びの宣言をどこに置くか
AIレビューの出力が途中で切れる——件数と並びの宣言をどこに置くか
レビュー出力が途中で終わっていて、何件あったはずなのかが分からない。原因を差分の大きさに帰す前に、受け取った側が切れを判定できるかを見る。指摘8件を5欄で書き起こして同じバイト数で切ると、JSON では2000バイトまで読める指摘が0件で、見出しを固定したテキストでは1600バイトで7件読めた。どちらの形式でも、総件数を末尾に置くと宣言そのものが落ちて、何件のうち何件が来たのかを判定できない。
この記事の提案を使うのは、1つの差分に対する指摘が10件を超えるとき、または出力の長さが環境の上限に当たるときである。
指摘8件を書き起こす
題材は TypeScript のモジュールで、差分は8ファイル・追加25行・削除16行である(スキーマで出力を固定する記事で定義したものと同じ)。仕込んだ欠陥8件を、同じ5欄(ファイル・行番号・深刻度・主張・引用)で書き起こし、深刻度の高い順に並べた。
深刻度は、約束への違反が実行時の値に出るかどうかで付けた。深刻度そのものの決め方はこの記事では扱わない。ここで要るのは、並びが宣言どおりかを受け取り側が確かめられることだけである。
2つの形式を作る。形式J は JSON の配列で、形式T は見出しを固定したテキストである。
## 指摘 1/8
- 場所: src/quote.ts:5
- 深刻度: high
- 主張: 割引率が比率に変わったのに百分率の 10 を渡している。roughQuote(8000) が -72000 になる
- 引用: return applyDiscount(base, 10);1件あたりの長さは次のとおりである。単位は文字とバイトで、トークン数は測っていない。この環境ではトークナイザを呼べないので、以下の数値はバイト数で書く。実際の上限はトークンで決まるため、手元で当てる場合は自分の環境のトークナイザで数え直す必要がある。
| 測ったもの | 最短 | 中央 | 最長 |
|---|---|---|---|
| 形式J の1件 | 146 文字 / 188 バイト | 174 文字 / 225 バイト | 194 文字 / 247 バイト |
8件ぶんの全体では、形式J が 1792 文字 / 2192 バイト、形式T が 1225 文字 / 1825 バイトになった。形式J が長いのは、欄の名前と括弧と引用符が1件ごとに付くためである。
同じバイト数で切ったときに読めるもの
両方の形式を、総件数と並びの宣言を先頭に置いた版と末尾に置いた版で作り、5通りのバイト数で切った。切った文字列から、(1) 宣言された総件数が読めるか、(2) 5欄がそろった指摘が何件読めるか を数えた(2026-10-12 に数えた。モデルは走らせていない)。
| 打ち切り | 形式J 先頭 | 形式J 末尾 | 形式T 先頭 | 形式T 末尾 |
|---|---|---|---|---|
| 400 バイト | 総件数8 / 0件 | 総件数— / 0件 | 総件数8 / 1件 | 総件数— / 2件 |
| 800 バイト | 総件数8 / 0件 | 総件数— / 0件 | 総件数8 / 3件 | 総件数— / 3件 |
| 1200 バイト | 総件数8 / 0件 | 総件数— / 0件 | 総件数8 / 5件 | 総件数— / 5件 |
| 1600 バイト | 総件数8 / 0件 | 総件数— / 0件 | 総件数8 / 7件 | 総件数— / 7件 |
| 2000 バイト | 総件数8 / 0件 | 総件数— / 0件 | 総件数8 / 8件 | 総件数8 / 8件 |
| 2400 バイト | 総件数8 / 8件 | 総件数8 / 8件 | 総件数8 / 8件 | 総件数8 / 8件 |
形式J の読める件数は、全体が収まる 2400 バイトで 0 から 8 へ跳ねる。中間が無い。全体が 2192 バイトなので 2000 バイトで切ると閉じ括弧が足りず、json.loads が通らない。公式ドキュメントも同じ状態を挙げている(Structured outputs、https://platform.claude.com/docs/en/build-with-claude/structured-outputs、取得日 2026-10-11)。
The output may be incomplete and not match your schema
ただし、総件数を先頭に置いた版では「8件あるはずだ」という情報が取れた。壊れた JSON を構文として読むことはできないが、"total": 8 という文字列は先頭にあるので、文字列として取り出せる。末尾に置いた版ではこれも落ちる。
形式T は、完結した指摘が件数ぶん読める。1件あたり5行なので、途中で切れた最後の1件を落とせば残りはそのまま使える。先頭に宣言を置いた版が400バイトで1件、末尾に置いた版が2件なのは、先頭の宣言が2行ぶんのバイト数を使っているためである。差が出るのは2000バイトの行で、末尾に置いた版はそこまで来てはじめて総件数が読める。 それより手前で切れた場合、7件で終わったのか8件だったのかを受け取り側で区別できない。
8件を超える場合の長さを、1件あたりの中央値から計算した。実測は8件までである。
| 件数 | 形式J | 形式T |
|---|---|---|
| 8 件 | 約 1800 バイト | 約 1825 バイト |
| 20 件 | 約 4500 バイト | 約 4562 バイト |
| 40 件 | 約 9000 バイト | 約 9125 バイト |
成果物
件数と並びを先頭に宣言させる2行
出力の最初の2行を固定する。件数の上限を書く場合も、上限より先に総件数と並びを置く。
## 出力の先頭
出力の1行目と2行目を次の形に固定する。指摘の本体は3行目から書く。
総件数: <見つけた指摘の総数。上限で絞る前の数>
並び: 深刻度の高い順(high → medium → low)。同じ深刻度の中はファイル名の順
上限を設ける場合、総件数には上限で絞る前の数を書く。
上限で落とした指摘については、場所(ファイル:行)だけを最後にまとめて挙げる。
## 出力の形式
指摘1件ごとに次の5行をこの順で書く。行の頭の文字列は変えない。
## 指摘 <通し番号>/<総件数>
- 場所: <ファイルのパス>:<行番号>
- 深刻度: high / medium / low のいずれか1語(小文字)
- 主張: <何が問題か。一文>
- 引用: <その行の実物を1行だけ写す>通し番号に総件数を併記すると、切れた位置が1件ごとに分かる。## 指摘 7/8 まで読めて終わっていれば、8件目が落ちたことが本体からも読める。先頭の2行が落ちた場合の保険になる。
続きを取る2回目の指示
切れを検出したら、同じ入力に対して2回目を走らせる。1回目の出力を全部渡し直すのではなく、読めた分の場所だけを渡す。
1回目の出力は <読めた件数> 件で終わっている。宣言された総件数は <総件数> 件である。
すでに出た指摘の場所は次のとおりで、これらは再掲しない。
<ファイル:行 の一覧を1行ずつ>
残りの <総件数 - 読めた件数> 件を、同じ形式で <通し番号の続き> から書く。
並びは1回目と同じ(深刻度の高い順)とする。場所の一覧だけを渡すのは、1回目の出力全体を渡すと入力が倍になるためである。この題材では、8件の場所を1行ずつ並べた一覧が 146 バイトで、出力全体の 1825 バイトに対して1割弱である。
構造化出力を使う場合
形式J で切れると指摘が0件になるので、総件数をスキーマの最初の欄に置き、受け取り側では構文解析の前に stop_reason を見る。max_tokens に達していた場合、本文を構文として読む前に総件数を文字列として取り出して2回目へ回す。スキーマを渡せない環境では形式T を使い、## 指摘 n/N の行を数えて切れを判定する。
宣言を先頭に置くと何を使うか
払うのはパスの数である。切れが起きた場合に2回目が1回増える。この題材の規模(8件・約1800バイト)では上限に当たらないが、20件で約4500バイト、40件で約9000バイトになるので、出力の上限が小さい設定では2回目が常に発生する。2回目の入力は、差分と判定基準をもう一度渡すぶんだけ1回目とほぼ同じ量になる。固定部をキャッシュできる環境なら、増えるのは出力の側だけである。
出力項目は増えない。先頭の2行は指摘1件あたりではなく出力全体に1回なので、件数が増えても2行のままである。通し番号に総件数を併記する形は1件あたり数バイト増えるだけである。
この宣言を落としていい条件は、出力が上限に当たらないことを測ってあるときである。 1件あたりの中央値(この形式で225バイト)に見込みの件数を掛けて、上限の半分より小さいなら切れは起きない。測らずに「たぶん足りる」で進めると、切れた出力を受け取ったときに何件落ちたかを後から言えない。測るのは1回でよく、形式を変えたときだけ測り直す。
指示文の点検
- 総件数と並びを、出力の先頭の2行に固定したか
- 総件数を「上限で絞る前の数」と定義したか
- 指摘の通し番号に総件数を併記させているか
- 上限で落とした指摘の場所だけを挙げさせる形にしたか
- 1件あたりの長さを測って、見込みの件数を掛けたか
- 測った単位(バイトかトークンか)を自分の環境に合わせたか
- 構造化出力を使う場合、構文解析の前に
stop_reasonを見ているか - 切れを検出したときの2回目の指示を用意したか
- 2回目に渡すものを、1回目の出力全体ではなく場所の一覧に絞ったか
- 形式を変えたときに1件あたりの長さを測り直したか
おわりに
指摘8件を5欄で書き起こして同じバイト数で切ると、JSON の形式では2000バイトまで読める指摘が0件で、見出しを固定したテキストでは1600バイトで7件が読めた。JSON は閉じ括弧が足りないかぎり構文として読めないので、切れたときに残るのは文字列として取り出せる先頭の宣言だけである。
総件数を末尾に置いた版では、その宣言も落ちた。受け取り側は7件で終わったのか8件だったのかを区別できない。先頭に置けば、壊れた出力からでも総件数が取れて、2回目で残りを取りにいける。
次に取る行動を1つ挙げる。手元のレビュー出力を1件取って、指摘1件ぶんの長さを数える。その値に、いつも返ってくる件数を掛けて、出力の上限と比べる。上限の半分を超えていたなら、先頭の2行を足す理由がその場で立つ。