ある朝、自動投稿パイプラインのログを開くと、記事メタデータの組み立てが一件だけ止まっておりました。
原因は単純でした。Claude に返させた JSON が、tags 配列の途中で途切れていたのです。前日まで数百件を問題なく処理していた同じプロンプト、同じモデル。たった一件の途切れた出力が、後続のバリデーションを巻き込んで全体を落としていました。
構造化出力は「ほぼ」正しく返ります。問題は、この「ほぼ」が個人開発の自動運用では命取りになることです。私自身、一日に何百回も走る処理を個人で回していますが、0.5% の失敗でも毎日数件のエラーになります。手で直す前提の設計は、そこで破綻します。
ここからは、個人開発で4サイトの記事生成パイプラインを回しながら、構造化出力を信頼できるところまで持っていった設計を、コード付きで共有いたします。鍵は「壊れない前提」を捨て、「壊れたときに自動で立て直す」前提で組むことです。
構造化出力が「たまに」壊れる3つのパターン
まず、何が起きているのかを切り分けます。本番で観測した破綻は、大きく三つに分かれました。
一つ目は 途中切れ。max_tokens に達して JSON が閉じる前に終わる場合です。配列やオブジェクトの途中で止まり、パースが即座に失敗します。長いタグ列や本文要約を返させると起きやすくなります。
二つ目は 形式の逸脱。JSON としては有効でも、こちらが期待する型と違う場合です。level に "beginner-intermediate" のような想定外の値が入る、数値であるべき場所に文字列が来る。パースは通るのに、後続のロジックが静かに壊れます。これが一番たちが悪い種類です。
三つ目は 混入。JSON の前後に「以下が生成した結果です」といった説明文が付く場合です。モデルに「JSON だけ返して」と指示しても、温度設定やプロンプトの組み方によっては前置きが混ざります。
この三つは、対処法がそれぞれ違います。一つの防御で全部を塞ごうとすると、どこかに穴が残ります。層で守るのが正解です。
第一防衛線 — tool useで出力形式を強制する
混入を消す最も確実な方法は、自由記述で JSON を書かせるのをやめることです。Claude の tool use を使い、ツールの入力スキーマとして構造を定義し、tool_choice でそのツールの呼び出しを強制します。
こうするとモデルは「ツールへの引数」として構造化データを組み立てるため、前置きや後置きの説明文が原理的に入りません。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const articleMetaTool = {
name: "emit_article_meta",
description: "記事のメタデータを構造化して返す",
input_schema: {
type: "object",
properties: {
title: { type: "string", maxLength: 60 },
level: { type: "string", enum: ["beginner", "intermediate", "advanced"] },
tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5 },
premium: { type: "boolean" },
},
required: ["title", "level", "tags", "premium"],
},
} as const;
async function generateMeta(source: string) {
const res = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
tools: [articleMetaTool],
tool_choice: { type: "tool", name: "emit_article_meta" },
messages: [{ role: "user", content: `次の記事からメタデータを抽出してください。\n\n${source}` }],
});
const block = res.content.find((b) => b.type === "tool_use");
if (!block || block.type !== "tool_use") {
throw new Error("tool_use block not returned");
}
return block.input; // ここはまだ「型が保証されていない」点に注意
}
ここで強調したいのは、最後のコメントです。input_schema に enum や minItems を書いても、API はそれを保証してくれません。スキーマはモデルへのヒントであって、バリデータではないのです。公式ドキュメントはツール入力スキーマの書式を説明しますが、「返り値がスキーマに必ず合致するわけではない」という運用上の含意までは強調していません。私はここで一度痛い目を見ました。
tool use は混入を消し、途中切れを大幅に減らします。けれども形式の逸脱は依然として通り抜けます。だから次の層が要ります。
第二防衛線 — スキーマ検証ゲート
返ってきた input を、実行時に必ず検証します。私は Zod を使っています。型定義と検証が一本化でき、通過後は TypeScript の型として安全に扱えるからです。
import { z } from "zod";
const ArticleMeta = z.object({
title: z.string().min(1).max(60),
level: z.enum(["beginner", "intermediate", "advanced"]),
tags: z.array(z.string().min(1)).min(2).max(5),
premium: z.boolean(),
});
type ArticleMeta = z.infer<typeof ArticleMeta>;
function validate(raw: unknown): { ok: true; value: ArticleMeta } | { ok: false; issues: string } {
const parsed = ArticleMeta.safeParse(raw);
if (parsed.success) return { ok: true, value: parsed.data };
// 修復ループに渡すため、何がどう違反したかを文章化する
const issues = parsed.error.issues
.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`)
.join("\n");
return { ok: false, issues };
}
要点は、検証失敗時に 「何がどう違反したか」を人間が読める文章にしておくことです。これが次の修復ループの燃料になります。エラーを true/false だけで握りつぶすと、自動修復の精度が上がりません。
第三防衛線 — 一度だけの修復ループ
検証に落ちたら、同じモデルに「ここが違反していたので直して」と差分情報を渡して、もう一度だけ呼びます。
設計上の判断が二つあります。一度だけにすること。そして 元の出力と違反内容の両方を渡すことです。
無限にリトライさせると、壊れた出力に対してコストだけが膨らみます。私の実測では、一度の修復で通らなかったものを二度三度回しても、成功率はほとんど上がりませんでした。二回目で駄目なら、それは構造の問題ではなく内容の問題であることが多いのです。
async function generateMetaReliable(source: string): Promise<ArticleMeta> {
const first = await generateMeta(source);
const check = validate(first);
if (check.ok) return check.value;
// 修復は一度だけ。違反内容と元の出力を両方渡す
const repair = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
tools: [articleMetaTool],
tool_choice: { type: "tool", name: "emit_article_meta" },
messages: [
{ role: "user", content: `次の記事からメタデータを抽出してください。\n\n${source}` },
{ role: "assistant", content: [{ type: "tool_use", id: "prev", name: "emit_article_meta", input: first }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "prev", content:
`この出力は次の制約に違反しています。違反箇所のみ修正して、もう一度ツールを呼んでください。\n${check.issues}` }] },
],
});
const block = repair.content.find((b) => b.type === "tool_use");
if (block?.type === "tool_use") {
const recheck = validate(block.input);
if (recheck.ok) return recheck.value;
}
// ここまで来たら構造ではなく内容の問題 — フォールバックへ
return degrade(source, check.issues);
}
tool_result として違反内容を返す形にしているのは、会話の文脈を自然に保つためです。モデルは「自分が直前に出した tool_use の結果がこうだった」という流れで違反を受け取り、修正対象を絞りやすくなります。
第四防衛線 — 劣化フォールバック
修復しても通らない一件は、必ず残ります。そこでパイプライン全体を落とすのではなく、安全側に倒した既定値で先に進める設計にします。
function degrade(source: string, issues: string): ArticleMeta {
// 違反したフィールドだけを安全な既定値で埋め、残りは決定的ロジックで補う
const fallback: ArticleMeta = {
title: source.split("\n")[0].slice(0, 60) || "未題",
level: "intermediate",
tags: ["claude", "api"],
premium: false, // 不確実なときは無料側に倒す(誤って課金壁を作らない)
};
console.warn(`[meta] degraded to fallback. issues:\n${issues}`);
return fallback;
}
ここでの方針が運用の質を分けます。不確実なときにどちらへ倒すかを明示的に決めておくことです。私の場合、premium は迷ったら false にします。読めるべき記事を誤って課金壁の向こうに置く事故のほうが、無料で出してしまうより取り返しがつかないからです。フォールバックは「とりあえず動かす」ためのものではなく、「最悪のときの振る舞いを設計する」ためのものだと考えています。
そして console.warn を必ず残します。劣化が起きた頻度こそ、次に潰すべき問題の在りかを教えてくれるからです。
実測 — 失敗率を運用で潰していく
導入順に効果を測りました。自由記述で JSON を返させていた初期は、パース失敗と形式逸脱を合わせて およそ 2.1% が後続処理で落ちていました。一日数百件の処理では、毎日10件前後の手当てが発生していた計算です。
tool use を第一防衛線に置いた段階で、混入と途中切れが消え、失敗は 0.6% 程度まで下がりました。残ったのは主に形式の逸脱です。
スキーマ検証ゲートと一度きりの修復ループを足したところ、最終的にフォールバックへ落ちる割合は 0.1% を下回りました。修復ループの一発成功率は、観測した範囲で 8割超。残りはフォールバックで吸収し、パイプラインが止まることはなくなりました。
数字以上に効いたのは、console.warn で残した劣化ログです。どのフィールドが落ちやすいかが見えると、input_schema の description を一文足すだけで再発が止まる、という小さな改善が回り始めます。信頼性は一度の設計ではなく、ログを起点にした反復で育ちます。
公式ドキュメントが強調しないこと
最後に、運用で気づいた点を三つ残します。
input_schema の enum や maxLength は 検証ではなくヒントです。守られる確率を上げる効果はありますが、守られることを前提にコードを書いてはいけません。実行時検証は省略できない層です。
修復ループは モデルを変えないほうが安定しました。一度目と二度目で別モデルを使うと、二度目が一度目の意図を取り違える場面があります。同じモデルに同じ会話文脈で直させるのが、最も素直に通ります。
そして max_tokens は 構造化出力では気持ち多めに取ります。途中切れの多くは、本文要約や長いタグ列で出力が予想以上に伸びたときに起きていました。コストは入力側が支配的なので、出力の上限を少し上げる代償は小さいです。
次の一歩として、まずは自分のパイプラインのどこか一箇所で構いません。自由記述の JSON を tool use + 実行時検証に置き換え、フォールバック時に warn を一行残してみてください。一週間そのログを眺めるだけで、自分の出力がどこで壊れているかが、推測ではなく事実として見えてきます。
同じように自動運用と向き合っている方の役に立てば幸いです。