2026 年の 3 月、個人 SaaS の本番ログに 0:47 のタイムスタンプで Unexpected end of JSON input が出ていました。Claude API から返ってきた応答を JSON.parse に流したところ、最後の閉じ括弧が来る前に文字列が終わっていたのです。発火率は 1,200 リクエスト中 8 件、率にして 0.66%。許容範囲に見えますが、課金フローで起きた 1 件は決済処理を巻き戻す羽目になりました。
公式ドキュメントの「Use prefilled responses」のページには、「アシスタント側に { を入れておけば JSON が返ります」と書かれています。私もそれを信じて実装していました。けれど本番で月数千リクエスト捌くようになると、{ だけでは足りない場面に何度もぶつかります。max_tokens 到達でちぎれてしまいます。レアケースで前置きが入ります。stop_sequence を併用すると tool_use と干渉します。その全部を本番で踏み抜いて、最終的に「プレフィル + 4 層防御」のパイプラインに辿り着きました。この記事はその設計記録です。
個人開発で AI を本番フローに載せて痛感したのは、確率的に振る舞う出力を決定的に動くシステムへ繋ぎ込む部分が、いちばん薄く作られがちだということでした。プロンプトの工夫は記事になりやすく、その後段でどう受け止めるかはあまり語られません。本記事の 4 層防御は、その受け止め側の設計記録です。
なお、この記事を見直すにあたって、当時掲載した修復コードを手元で測り直しました。結果として、私が「80% 以上救えます」と書いていた関数が、配列を含む応答を 1 件も救えていなかったことが分かりました。該当箇所は実測値とともに書き直してあります。誤ったコードをそのまま置いていた期間について、まずお詫びいたします。
プレフィルだけで JSON が壊れる 3 つの本番ケース
プレフィル(Prefilling)は、API リクエストの messages 配列の末尾に role: "assistant" のメッセージを置き、Claude にそこから続きを書かせる手法です。{ "result": まで書いておけば、Claude は確かに高確率で JSON を続けてくれます。けれど「高確率」と「100%」の間には、本番運用では天と地ほどの差があります。
ケース A: max_tokens 到達による途中切れ
私のプロダクトでは、ドキュメントから 30 件前後のフィールドを抽出する処理がありました。max_tokens を 2,048 に設定していたのですが、ある日の入力で出力が 2,049 トークン目で , の途中で止まりました。stop_reason は max_tokens。プレフィルで { を入れていたので構造は始まっているのに、閉じる前に終わったのです。JSON.parse は当然失敗します。
ケース B: tool_use ブロックとの干渉
tools を有効にして同時にプレフィルで { を渡すと、Claude が tool_use ブロックを使うべきか JSON を続けるべきか迷うことがあります。公式は「プレフィルと tool_use を併用するときは挙動が変わる」とサラッと書いていますが、実際には「迷った結果、プレフィルした { を無視して tool_use を返す」というケースに私は何度か遭遇しました。レスポンスの content[0].type が text ではなく tool_use になり、後続のパース処理が undefined.text で落ちます。0.1% 未満の頻度ですが、本番では 0 にしたい類いです。
ケース C: stop_sequence の競合
「} が来たら止めたい」と考えて stop_sequences: ["}"] を入れた時期があります。これは中身に { "k": "v" } のようなネストが一つでもあると、最初の } で打ち切られてトップレベルが閉じない、という事故を起こします。プレフィルの効果を信じすぎて停止条件を雑に設計した結果でした。
この 3 ケースを「Prefill だけでは防げない」と認めたところから、4 層防御の設計が始まりました。
4 層防御パイプラインの全体像
設計は次の 4 層で構成されます。各層は独立してフォールバックでき、上位層で失敗しても下位層が拾う形です。
- Layer 1 — Prefill による形式の固定: アシスタントメッセージのプレフィルで JSON 開始を確定させる
- Layer 2 — JSON Schema による事前検証: 受け取った文字列を JSON Schema で構造検証する
- Layer 3 — 自動修復: 構文エラーを
json5/ 部分パーサで救済し、ダメなら Claude に「これを修復して」と返す - Layer 4 — グレースフル撤退: それでも通らない場合は、運用上のフォールバック値とアラートを返す
各層の責任を分ける理由は、確率的に失敗する処理を確率的に救うよりも、決定的なルールで救えるところは決定的に救う方が運用が読みやすくなるからです。障害が起きたときに「どの層で落ちたか」が一意に決まると、調査の入口が一つに絞られます。私はこの読みやすさを、成功率そのものより重く見ています。
Layer 1 — Prefill で形式を固定する
まず最も基本のプレフィル実装を、TypeScript で書いておきます。
// extract.ts — Layer 1: プレフィルで JSON を始めさせる
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function extractWithPrefill(input: string) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 4096, // ケース A 対策で広めに取る
system: "あなたは厳密な JSON 抽出機です。指定スキーマ通りに出力してください。",
messages: [
{ role: "user", content: `次の文章から構造化データを抽出してください:\n\n${input}` },
// ↓ ここがプレフィル。アシスタント側に途中まで書いておく
{ role: "assistant", content: '{\n "result":' },
],
});
// プレフィルした `{\n "result":` がレスポンスから抜け落ちる仕様なので、自分で前置する
const block = response.content[0];
if (block.type !== "text") {
throw new Error(`Unexpected content block type: ${block.type}`); // ケース B 検出
}
const raw = '{\n "result":' + block.text;
return raw; // この時点ではまだ JSON.parse しない
}ポイントは 2 つあります。第一に、プレフィル文字列は応答に含まれません。Anthropic SDK の戻り値は「Claude が書いた続きの部分」だけなので、自分で連結する必要があります。第二に、max_tokens はケース A 対策で狭めず広めに取ります。トークン課金が気になりますが、後段の修復コストの方が高いので、ここをケチると逆に高くつきます。
stop_sequences についてはケース C を踏まえて、設定しないを初期値にしました。「最初の } で止める」のは一見賢そうですが、ネストを許す JSON では危険です。代わりに、後段のバリデーションで「閉じきっているか」を確認します。
Layer 2 — JSON Schema で事前検証する
Layer 1 の出力を受けて、まず構文として通るかをチェックし、通ったら Schema で構造検証します。Python では pydantic、TypeScript では zod が安定です。
// validate.ts — Layer 2: 構文 + 構造の検証
import { z } from "zod";
const ResultSchema = z.object({
result: z.object({
title: z.string().min(1),
tags: z.array(z.string()).max(20),
score: z.number().min(0).max(1),
}),
});
export type ValidatedResult = z.infer<typeof ResultSchema>;
export function validateRaw(raw: string): { ok: true; data: ValidatedResult } | { ok: false; reason: "syntax" | "schema"; detail: string } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
return { ok: false, reason: "syntax", detail: (e as Error).message };
}
const r = ResultSchema.safeParse(parsed);
if (!r.success) return { ok: false, reason: "schema", detail: r.error.message };
return { ok: true, data: r.data };
}reason を 2 種類に分ける理由は、後段の Layer 3 でリカバリ手段を変えるためです。syntax エラー(構文壊れ)は機械的に直せる可能性が高く、schema エラー(構造違反)は Claude に直してもらう必要があります。両方を雑に「失敗」とまとめると、無駄な再呼び出しが増えてコストが膨らみます。
Layer 3 — 機械修復はどこまで救えるのか(実測して書き直しました)
ここが本番効果の最も大きい層です。そして、当初この記事に載せていたコードが誤っていた層でもあります。
3a: 構文修復を実測し直す
不完全な JSON を救うには、まず標準 JSON.parse より寛容な json5 を試します。トレイリングコンマや単一引用符を許容してくれます。それでも通らない場合に、ブラケットの数を数えて足りない分を補う「閉じ補完」を試す — というのが当初の設計でした。
その閉じ補完を、公開したままの実装で測り直しました。記事のスキーマに沿った JSON(result の下に title / 20 要素の tags / score / 6 要素の sections、全 1,101 文字)を用意し、20 文字目から末尾直前まで 1 文字ずつ切断して 1,081 通りの「途中で切れた応答」を作ります。max_tokens 到達がどの位置で起きても不思議はないので、切断位置は等しく扱いました。
結果は次のとおりです。同じ 1,081 件を 3 つの手法に通し、(a) JSON.parse が通るか、(b) 通った上でスキーマ検証(title が 1 文字以上・tags が文字列配列・score が 0〜1 の数値)に通るか、の 2 段で数えています。
| 手法 | 構文として回復 | スキーマまで通過 |
|---|---|---|
| 当初この記事に載せていた閉じ補完 | 0 / 1,081(0.0%) | 0 / 1,081(0.0%) |
| スタックで括弧の種類を追う閉じ補完(修正版) | 1,026 / 1,081(94.9%) | 588 / 1,081(54.4%) |
json_repair 0.62.0(Python) | 1,081 / 1,081(100.0%) | 643 / 1,081(59.5%) |
0.0% でした。「80% 以上救えます」と書いていた関数は、この形の JSON を 1 件も救えていません。原因は 2 つあります。
第一に、開いた括弧の種類を見ずに } だけを補っていたことです。tags や sections のような配列の内側で切れた応答には ] が必要ですが、旧実装は不足数だけ } を積んでいました。今回の 1,081 通りのうち 1,022 通りが配列の内側での切断で、そこは原理的に救えません。
第二に、文字列が閉じているかの判定が反転していたことです。旧実装は最後の " より前にある引用符を数えて、奇数なら " を足していました。ところが文字列の途中で切れた応答では、その最後の " こそが開き引用符なので、前方の引用符は必ず偶数になります。閉じるべき場面でこそ閉じない、という判定でした。配列を含まない 59 通りの切断でも回復が 0 件だったのは、こちらが理由です。
書き直した実装が次です。括弧をスタックで持ち、開いた種類に対応する括弧で閉じます。
// repair.ts — Layer 3a: 構文修復(スタック方式・修正版)
import JSON5 from "json5";
export function repairSyntax(raw: string): string | null {
// (1) JSON5 で再試行(トレイリングコンマ・コメント等を許容)
try {
return JSON.stringify(JSON5.parse(raw));
} catch {}
// (2) 開いた括弧をスタックで追い、対応する括弧で閉じる
const stack: string[] = [];
let inString = false;
let escaped = false;
for (const ch of raw) {
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === "{" || ch === "[") stack.push(ch);
else if (ch === "}" || ch === "]") stack.pop();
}
let candidate = raw;
if (inString) candidate += '"'; // 文字列の途中で切れていたら閉じる
candidate = candidate.replace(/[,\s]+$/, "");
// 値が来ないまま切れたキーを落とす("key": / "key" の両方)
candidate = candidate.replace(/,\s*"(?:[^"\\]|\\.)*"\s*:\s*$/, "");
candidate = candidate.replace(/\{\s*"(?:[^"\\]|\\.)*"\s*:\s*$/, "{");
candidate = candidate.replace(/,\s*"(?:[^"\\]|\\.)*"$/, "");
candidate = candidate.replace(/[,\s]+$/, "");
// 開いた順の逆から、対応する括弧で閉じる
for (let i = stack.length - 1; i >= 0; i--) {
candidate += stack[i] === "{" ? "}" : "]";
}
try {
JSON.parse(candidate);
return candidate;
} catch {
return null;
}
}この実装で 94.9% まで回復します。json_repair の 100% に届かないのは、値のないキーの落とし方など細かい整形の差です。Python 側なら json_repair をそのまま使うのが確実で、TypeScript でこの層を自前で持つなら上のスタック方式が最小の実装になります。
そして、この実測でいちばん効いたのは回復率そのものではなく、構文回復 94.9% とスキーマ通過 54.4% の間にある 40 ポイントの差でした。閉じ括弧を補えば JSON.parse は通ります。けれど切れた位置より後ろのフィールドは存在しないままです。今回の内訳では score の欠落が 438 件で最多でした(score が長い tags 配列の後ろに置かれているためです)。Layer 3a を通した文字列は、必ず Layer 2 へ戻して検証し直す必要があります。後述の統合コード(run.ts)が修復後に validateRaw を再実行しているのは、この 40 ポイントを取りこぼさないためです。
構文は通ったがスキーマに届かなかった分は、Layer 3b で Claude に埋め直してもらいます。
3b: Claude に修復させる(re-ask)
3a で救えなかった場合、Claude 自身に「壊れた JSON を直して」と依頼します。プロンプトは短く、具体的に、です。
// repair.ts — Layer 3b: Claude による修復
export async function repairWithClaude(raw: string, schema: string): Promise<string | null> {
const response = await client.messages.create({
model: "claude-haiku-4-5", // 修復はコストの安い Haiku で十分
max_tokens: 4096,
system: "あなたは壊れた JSON を厳密に修復する整形機です。コメントや前置き文は一切書かず、有効な JSON のみを返してください。",
messages: [
{
role: "user",
content: `次の壊れた JSON を、以下の Schema に合わせて修復してください。\n\n[Schema]\n${schema}\n\n[壊れた JSON]\n${raw}`,
},
{ role: "assistant", content: "{" },
],
});
const block = response.content[0];
if (block.type !== "text") return null;
return "{" + block.text;
}ここで Haiku 4.5 を使うのは、修復は再生成より単純なタスクで、Sonnet で 1 回呼び直すより Haiku で 2 回呼び直す方がコスト効率が良いためです。私の本番計測では、Haiku 修復の成功率は 92% で、コストは 1 回あたり約 0.0008 USD(2026 年 5 月時点)でした。
Layer 4 — グレースフル撤退とアラート設計
Layer 1 〜 3 の全てを通過しても失敗する 0.05% 未満のケースに備えて、グレースフル撤退の経路を設計します。ここで重要なのは、ユーザーに見えるエラーと、運用で見たいエラーを分けることです。
// run.ts — 4 層防御の統合
import { extractWithPrefill } from "./extract";
import { validateRaw } from "./validate";
import { repairSyntax, repairWithClaude } from "./repair";
const SCHEMA_TEXT = JSON.stringify({
type: "object",
properties: { result: { type: "object", required: ["title", "tags", "score"] } },
required: ["result"],
});
export async function run(input: string) {
// Layer 1
const raw = await extractWithPrefill(input);
// Layer 2
let v = validateRaw(raw);
if (v.ok) return { ok: true, data: v.data, layer: 1 };
// Layer 3a (機械修復)
if (v.reason === "syntax") {
const fixed = repairSyntax(raw);
if (fixed) {
v = validateRaw(fixed);
if (v.ok) return { ok: true, data: v.data, layer: "3a" };
}
}
// Layer 3b (Claude 修復)
const repaired = await repairWithClaude(raw, SCHEMA_TEXT);
if (repaired) {
const r2 = validateRaw(repaired);
if (r2.ok) return { ok: true, data: r2.data, layer: "3b" };
}
// Layer 4: グレースフル撤退
await alertOps({ raw, lastDetail: v.ok ? "" : v.detail });
return { ok: false, layer: 4, fallback: defaultFallback() };
}
function defaultFallback() {
return { result: { title: "", tags: [], score: 0 } };
}
async function alertOps(payload: unknown) {
// 個人開発では Slack Webhook + Sentry breadcrumb で十分
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `:warning: JSON 4-layer fallback used` }),
});
}「ユーザーには空配列を返してアプリは止めない、運用には Slack で即時通知する」が私の運用ルールです。深夜のアラートで起こされる頻度が、Layer 4 導入前は週 2 〜 3 回でしたが、導入後は月 1 回以下になりました。
Python 版の実装スケッチ
参考までに、Python 版の Layer 1 + Layer 2 を載せておきます。pydantic と json-repair の組み合わせが手堅いです。
# extract.py — Python 版の Layer 1 + Layer 2
from anthropic import Anthropic
from pydantic import BaseModel, ValidationError, Field
import json
import json_repair
client = Anthropic()
class Inner(BaseModel):
title: str = Field(min_length=1)
tags: list[str] = Field(max_length=20)
score: float = Field(ge=0.0, le=1.0)
class Result(BaseModel):
result: Inner
def extract(input_text: str):
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system="あなたは厳密な JSON 抽出機です。",
messages=[
{"role": "user", "content": f"次の文章から構造化データを抽出してください:\n\n{input_text}"},
{"role": "assistant", "content": '{\n "result":'},
],
)
block = resp.content[0]
if block.type != "text":
raise RuntimeError(f"unexpected block: {block.type}")
raw = '{\n "result":' + block.text
try:
return Result.model_validate_json(raw)
except (json.JSONDecodeError, ValidationError):
# json_repair は破損 JSON を修復する PyPI ライブラリ
repaired = json_repair.repair_json(raw)
return Result.model_validate_json(repaired)json_repair ライブラリ(GitHub)は、Layer 3a の機械修復に相当する処理をワンライナーで提供してくれます。先ほどの 1,081 件の切断サンプルでは 0.62.0 が構文回復 100.0% でした。私の本番では Python 側の Layer 3a を全てこのライブラリに任せています。TypeScript 側だけ自前実装が残っているのは、依存を増やしたくなかったからですが、今回の測定を経て、自前で持つなら回復率も自分で測る責任がついてくると考えを改めました。
本番運用の Before / After
私のプロダクト(個人 SaaS、月間 4,200 リクエスト前後)での実測値を共有します。
- 導入前(Prefill のみ)
- パース成功率: 99.34%(28 件 / 4,200 が失敗)
- 月次運用アラート: 11 回
- フェイルオーバー時間: 平均 14 分(ユーザーが報告 → 私が手動修復)
- 導入後(4 層防御)
- パース成功率: 99.98%(1 件 / 4,200 が Layer 4 撤退、それも空配列で UX は維持)
- 月次運用アラート: 1 回以下
- フェイルオーバー時間: 0 分(自動撤退)
- 追加コスト: 月 850 円程度(Layer 3b の Haiku 修復呼び出し)
数字以上に大きかったのは、失敗の届き方が変わったことです。以前はユーザーからの報告で初めて気づき、深夜に手を動かしていました。今は Layer 3a の使用回数がダッシュボードで少し盛り上がるだけで、翌朝に落ち着いて中身を見に行けます。個人開発で AI を本番へ乗せるとき、運用品質はそのまま自分の可処分時間に跳ね返ってきます。
注意したい設計判断
最後に、自分が踏んだ落とし穴を 3 点だけ共有します。
- Layer 3b の修復に Sonnet を使わない: コスト効率が悪いだけでなく、修復タスクは「壊れた JSON を直す」だけのシンプルなタスクなので、Haiku の方がむしろ余計なことをしない傾向があります。
- Schema を変更したら Layer 3b のプロンプトも同期する: Schema 文字列をプロンプトに埋め込んでいるので、ここを忘れると修復が無意味な構造を作って戻してきます。私は Schema を
pricing.tsのように一元管理しています。 - Layer 4 のフォールバック値は『空』ではなく『安全な既定値』にする: 検索系なら空配列でいいですが、計算系で 0 を返すと UI が「無料」と表示する、みたいな副作用が起きます。ドメインに応じてフォールバック値を設計してください。
- 修復関数は「自分のスキーマの形」で必ず測る: 今回いちばん堪えた教訓です。閉じ補完の実装は、対象の JSON が配列を含むかどうかで回復率が 0% にも 94.9% にもなりました。ライブラリを入れるにせよ自前で書くにせよ、実際のスキーマから切断サンプルを作って通してみるまでは、回復率を書かない方が安全です。測り直しは 20 行ほどのスクリプトで済みます。
同じ「多層で受け止める」考え方を、呼び出しの手前に置いたのが Claude API の 400/422/529 を呼ぶ前に消す5層プリフライト設計 です。本記事が応答を受け取った後の防御なのに対し、あちらは送る前の防御にあたります。出力の内容そのものを疑う必要がある場合は ハルシネーションを本番で抑える多層防御アーキテクチャ が近い設計です。Layer 3b の修復呼び出しのコストが気になってきたら、プロンプトキャッシュで月額コストを半分にした実装メモ の考え方をそのまま持ち込めます。
次に試すこと
この設計をご自身のプロダクトに移植する最初の一歩として、まず Layer 1 + Layer 2 だけを 1 つの抽出処理に組み込み、1 週間ログを取ってみてください。失敗率の実数を把握することで、Layer 3 が本当に必要か、3a だけで十分か、3b まで要るかが具体的に判断できます。私自身、この計測なしに Layer 3b を入れていたら過剰設計になっていました。最初は計測、それから防御、という順番が結局のところ一番安く済みます。
実装の同じ課題に取り組んでいる方の参考になれば幸いです。お読みいただき、ありがとうございました。