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 層防御」のパイプラインに辿り着きました。この記事はその設計記録です。
2014 年から個人で AdMob 系のアプリ事業を続けて 11 年、累計 5,000 万ダウンロードを超えてきましたが、AI を本番フローに組み込むようになって痛感したのは、確率的に動く出力を「決定的に動くシステム」にどう繋ぎ込むかが個人開発の生死を分けるということでした。本記事の 4 層防御は、その答えのひとつです。
プレフィルだけで 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 — 自動修復で 80% を救う
ここが本番効果が一番大きい層です。私の計測では、Layer 1 → Layer 2 だけで失敗する 0.66% のうち、約 5/6(つまり全体の 0.55% 相当)は Layer 3 の機械的修復で救えていました。
3a: 構文修復(json5 とブラケット閉じ)
不完全な JSON を救うには、まず標準 JSON.parse よりも寛容な json5 を試します。トレイリングコンマや単一引用符を許してくれます。それでも通らない場合は、ブラケットの数を数えて足りないものを補う「ナイーブな閉じ補完」を最後に試します。
// 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) ナイーブなブラケット閉じ補完(max_tokens 切れのケース A 対応)
const opens = (raw.match(/[{[]/g) || []).length;
const closes = (raw.match(/[}\]]/g) || []).length;
if (opens > closes) {
let candidate = raw;
// 文字列リテラルの途中で切れている場合は閉じる
const lastQuote = candidate.lastIndexOf('"');
const beforeQuote = candidate.slice(0, lastQuote);
const escapedQuotes = (beforeQuote.match(/\\"/g) || []).length;
const realQuotes = (beforeQuote.match(/"/g) || []).length - escapedQuotes;
if (realQuotes % 2 === 1) candidate += '"';
// 末尾コンマを除去
candidate = candidate.replace(/,\s*$/, "");
// ブラケットを必要数だけ閉じる
const need = opens - closes;
candidate += "}]".repeat(0); // placeholder
for (let i = 0; i < need; i++) candidate += "}";
try {
JSON.parse(candidate);
return candidate;
} catch {}
}
return null;
}このコードは見た目は素朴ですが、ケース A の「途中で切れた応答」を 80% 以上救えます。json5 は GitHub での star も多く、私の本番では 6 ヶ月で 1 度もパッケージ起因のエラーが出ていません。
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 の機械修復に相当する処理をワンライナーで提供してくれます。私の本番では Python 側の Layer 3a を全てこのライブラリに任せています。
本番運用の 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 修復呼び出し)
数字以上に大きかったのは、深夜アラートが減って自分の睡眠時間が安定したことです。個人開発で AI を本番に乗せると、運用品質が直接「自分が休めるかどうか」に効きます。離れて暮らす子どもたちと過ごせる時間を守るためにも、こうした防御層は必要なのだと、設計を通して静かに確信しています。
注意したい設計判断
最後に、自分が踏んだ落とし穴を 3 点だけ共有します。
- Layer 3b の修復に Sonnet を使わない: コスト効率が悪いだけでなく、修復タスクは「壊れた JSON を直す」だけのシンプルなタスクなので、Haiku の方がむしろ余計なことをしない傾向があります。
- Schema を変更したら Layer 3b のプロンプトも同期する: Schema 文字列をプロンプトに埋め込んでいるので、ここを忘れると修復が無意味な構造を作って戻してきます。私は Schema を
pricing.tsのように一元管理しています。 - Layer 4 のフォールバック値は『空』ではなく『安全な既定値』にする: 検索系なら空配列でいいですが、計算系で 0 を返すと UI が「無料」と表示する、みたいな副作用が起きます。ドメインに応じてフォールバック値を設計してください。
関連して、JSON 出力そのものの設計を深めたい方には、Claude API の構造化出力を実務で使いこなす と Claude API JSON 出力のパースエラー対処 がそれぞれの観点で参考になります。プロンプトキャッシングと組み合わせる場合は、Claude API のプロンプトキャッシングでコスト最適化 もあわせて読むと、Layer 3b の修復コストをさらに抑える工夫が見えてきます。
次に試すこと
この設計をご自身のプロダクトに移植する最初の一歩として、まず Layer 1 + Layer 2 だけを 1 つの抽出処理に組み込み、1 週間ログを取ってみてください。失敗率の実数を把握することで、Layer 3 が本当に必要か、3a だけで十分か、3b まで要るかが具体的に判断できます。私自身、この計測なしに Layer 3b を入れていたら過剰設計になっていました。最初は計測、それから防御、という順番が結局のところ一番安く済みます。
実装の同じ課題に取り組んでいる方の参考になれば幸いです。お読みいただき、ありがとうございました。