ある朝、通知系の小さなツールが「成功」を返しているのに、実際には何も送られていない事象に出会いました。
原因は引数でした。私のコードは status に "done" が来る前提で分岐していたのに、Claude はその日だけ "完了" という日本語の値を渡していたのです。スキーマ上は status: string としか書いておらず、モデルから見れば "完了" は完全に妥当な文字列。バリデーションも素通り。ログには tool executed とだけ残り、静かに分岐が外れていました。
個人開発で複数のアプリをまわしながら、通知やAdMobの集計といった小さな処理をClaudeのツール呼び出しに委ねていると、この種の「型は正しいが意味が違う」ズレが、忘れた頃に効いてきます。実行時バリデーションで弾くのは対症療法にすぎません。渡ってくる引数そのものを本番で観測し、スキーマを後から締め直す。この記事は、私自身がその運用にたどり着くまでの記録です。
緩いスキーマは「意味のドリフト」を生む
string や number としか書かれていない引数は、モデルにとって解釈の自由度が高すぎます。
Claude は文脈に応じて "done" と書く日も "完了" と書く日もあり、priority に "high" を入れる日も 3 を入れる日もあります。どれも JSON としては妥当。だからこそ、受け手のコードが暗黙に期待している「閉じた集合」との差分が、型検査をすり抜けて本番に届きます。
実行時バリデーションだけで守ろうとすると、常に後手に回ります。弾いた後にリトライさせ、また別の表記で返ってくる。バリデーション例外のログは増えるのに、根本の「モデルに許した表現の広さ」は縮まりません。
締めるべきは入口です。スキーマに enum を書けば、モデルはその集合の中からしか選べなくなります。制約はプロンプトの指示より強く効きます。
まず、引数を丸ごと記録する
締め直すには、まず実際に何が渡ってきているかを知る必要があります。ツール呼び出しの入力ブロックを、そのまま台帳に落とす薄いラッパを挟みます。
import Anthropic from "@anthropic-ai/sdk";
import { appendFile } from "node:fs/promises";
const client = new Anthropic();
type ToolCallRecord = {
ts: string;
model: string;
tool: string;
input: Record<string, unknown>;
};
async function logToolCalls(model: string, message: Anthropic.Message) {
const rows: ToolCallRecord[] = [];
for (const block of message.content) {
if (block.type === "tool_use") {
rows.push({
ts: new Date().toISOString(),
model,
tool: block.name,
input: block.input as Record<string, unknown>,
});
}
}
if (rows.length === 0) return;
// 1行1レコードのJSONL。集計しやすく、追記で壊れにくい
const jsonl = rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
await appendFile("tool_calls.jsonl", jsonl);
}
const res = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
tools: myTools,
messages,
});
await logToolCalls("claude-sonnet-5", res);
ポイントは、値を加工せず生のまま残すことです。正規化してから記録すると、まさに観測したい「表記の揺れ」が消えてしまいます。個人情報を含む引数だけは、記録前にフィールド単位でマスクしておきます。
分布から制約を導く
数日から数週間ぶんの JSONL がたまったら、フィールドごとに値の分布を見ます。ここで判断したいのは三つです。閉じた集合になっているか(→ enum)、形式が一定か(→ pattern)、数値の範囲が収束しているか(→ minimum / maximum)。
import { readFileSync } from "node:fs";
type FieldStats = {
count: number;
values: Map<string, number>;
numeric: number[];
};
function analyze(path: string, tool: string) {
const stats = new Map<string, FieldStats>();
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line) continue;
const rec = JSON.parse(line);
if (rec.tool !== tool) continue;
for (const [k, v] of Object.entries(rec.input)) {
const s = stats.get(k) ?? { count: 0, values: new Map(), numeric: [] };
s.count++;
const key = String(v);
s.values.set(key, (s.values.get(key) ?? 0) + 1);
if (typeof v === "number") s.numeric.push(v);
stats.set(k, s);
}
}
for (const [field, s] of stats) {
const distinct = s.values.size;
const suggestion: string[] = [];
// 出現値が少数に収束していればenum候補
if (distinct <= 8 && s.numeric.length === 0) {
suggestion.push(`enum: [${[...s.values.keys()].map((x) => `"${x}"`).join(", ")}]`);
}
if (s.numeric.length > 0) {
suggestion.push(`min=${Math.min(...s.numeric)} max=${Math.max(...s.numeric)}`);
}
console.log(`${field}: distinct=${distinct} count=${s.count} => ${suggestion.join(" / ") || "制約なし"}`);
}
}
analyze("tool_calls.jsonl", "send_notification");
出力例はこうなります。
status: distinct=3 count=1840 => enum: ["done", "完了", "failed"]
priority: distinct=4 count=1840 => min=1 max=5
channel: distinct=2 count=1840 => enum: ["push", "email"]
status に日本語が混ざっていることが、ここで初めて数字で見えます。私の場合、全体の約 6% が "完了" で、残りが "done"。UI 文言がそのまま漏れていたのが原因でした。
段階的に締める
分布が見えても、いきなり enum をハードに効かせるのは危険です。観測期間に現れなかった正当な値が本番に存在するかもしれず、締めすぎるとモデルが値を選べずに空のツール呼び出しや拒否に転びます。三段階で進めます。
- 警告のみ: スキーマは変えず、期待集合の外の値が来たらログに
schema_candidate_violation として記録するだけにします。1〜2週間、偽陽性が出ないかを見ます。
- ソフト制約:
description に「必ず次のいずれかを使用: done / failed」と明示します。強制力はありませんが、これだけで表記揺れの多くが収束します。この段階でも警告ログは継続します。
- ハード制約: 警告が十分に静かになったら
enum を入れます。同時に、想定外を安全に受ける逃げ道の値(例: "unknown")を一つ残し、受け手のコードでそれを既定分岐に流します。
締めた後のツール定義はこうなります。
const sendNotification = {
name: "send_notification",
description: "通知を送る。statusは必ずenumのいずれか。判断できない場合はunknownを使う。",
input_schema: {
type: "object" as const,
properties: {
status: { type: "string", enum: ["done", "failed", "unknown"] },
priority: { type: "integer", minimum: 1, maximum: 5 },
channel: { type: "string", enum: ["push", "email"] },
},
required: ["status", "channel"],
},
};
"完了" はここで構造的に消えます。プロンプトで「英語で書いて」とお願いする必要はありません。集合の外は、そもそも生成できないからです。
実測: 締め直しの前後
同じ通知ツールを、緩いスキーマとハード制約とで各 2,000 呼び出し観測した結果です。無効呼び出し率は、受け手コードの期待集合から外れた引数の割合。リトライ率は、その場でバリデーション再試行が走った割合です。
| 指標 | 緩いスキーマ | enum締め直し後 |
| 無効な引数の割合 | 6.1% | 0.2% |
| リトライ率 | 4.3% | 0.1% |
| ツール定義のトークン数 | 約 180 | 約 120 |
| 空のツール呼び出し | 0% | 0.05% |
トークン数がむしろ減った点は見落とされがちです。description に長い注意書きを並べて表記を揃えようとするより、enum で集合を宣言するほうが短く、そして確実に効きます。逃げ道の値を残したことで、空のツール呼び出しはほぼ増えませんでした。
公式に書かれていない運用知見
enum は「短い指示」として二重に効く。制約であると同時に、許容値の列挙がそのままモデルへのヒントになります。長い散文の注意書きを削れる場面が多く、締めると軽くなることがしばしばあります。
締めすぎは拒否や空呼び出しに化ける。観測されなかった正当値まで排除すると、モデルは「選べない」状態に追い込まれます。逃げ道の値を必ず一つ残し、受け手で既定分岐に流すのが安全です。
UI 文言・ロケール由来の値は必ず混ざる。日本語アプリを運用していると、"完了" のような表示文字列が引数に漏れます。テレメトリを取らなければ、この 6% は永遠に見えません。
分布はモデル更新で動く。モデルを乗り換えると引数の癖も変わります。締め直しは一度きりではなく、記録を回し続けて四半期ごとに見直す前提で組みます。
状況別の推奨
| 状況 | 推奨 |
| 呼び出しが少ない小さなツール | テレメトリのみ。締め直しは労力に見合わない |
| 高頻度・分岐が値に依存するツール | enumハード制約 + 逃げ道の値を必須で |
| ユーザー入力が引数に流れ込む経路 | pattern制約 + 受け手側の二重チェックを併用 |
| 複数モデルを使い分ける運用 | モデル名つきで記録し、モデル別に分布を比較 |
型が正しいだけでは、意味は守れません。渡ってくる値を測り、集合を宣言し、逃げ道を一つ残す。この三つで、静かに外れる分岐の多くは入口で防げます。
型の正しさに安心して意味のズレを見逃していた、あの朝の自分への戒めでもあります。同じ静かな失敗を、どこかの誰かが一つでも減らせたなら、書いた甲斐があります。