Claude Science の発表で、調整役のエージェントが専門エージェントを呼び出し、さらに「引用と計算を検証する専任のエージェント」を挟む、という多段構成が紹介されていました。私が一番目を留めたのは新しいスキルの数ではなく、この「検証を独立した役割として分ける」という発想でした。
私自身、個人開発で一人で複数サイトの自動処理を回しています。ある朝、生成された集計サマリに「前週比 +18%」と書かれていて、けれど手元の生データを電卓で足し直すと実際は +8% でした。モデルはもっともらしい数値と、それらしい出典表記を、驚くほど自然に作ります。そして要約と原データが静かにズレても、要約だけを眺めているかぎり誰も気づきません。この一件以来、私は「生成物の数値と出典を、生成したのと同じ流れの中で信じてしまう」構造そのものを疑うようになりました。
ここからは、生成と検証を明確に分け、数値は決定論的に再計算し、出典は原文と文字列で突き合わせ、1つでも通らなければ全体を却下する——そういう受け入れ前のゲートを、動くコードで組んでいきます。
なぜ「生成の続き」で検証してはいけないのか
失敗が起きるのは、生成したモデルにそのまま「合っていますか」と尋ねてしまうときです。同じ文脈・同じ思考の流れの中で自己点検させると、モデルは自分が直前に出した数値を正しい前提として扱いがちで、ズレを見落とします。人間でいえば、自分の書いた原稿を書いた直後に自分だけで校正するようなものです。
検証を独立させる価値は、次の3点に集約されます。
第一に、文脈の分離です。検証は「生成物」と「一次データ・出典」だけを入力に取り、生成時の思考を引き継ぎません。第二に、判定の決定論化です。数値は言語モデルに再確認させるのではなく、生データから関数で計算し直します。第三に、fail-closedです。検証できなかった主張は「たぶん正しい」ではなく「不合格」として扱い、通せない主張が1つでもあれば成果物ごと止めます。
主張を粒度で取り出す(クレーム台帳)
まず、要約という自由文のままでは検証できません。要約から検証可能な最小単位——数値主張と出典主張——を構造化して取り出します。生成エージェントには、要約と同時にこの「クレーム台帳」を必ず併せて出させます。
// claims.ts — 検証可能な主張の型
export type NumericClaim = {
id: string;
kind: "number";
statement: string; // 人が読む主張文(要約中の該当箇所)
metric: string; // 再計算に使う指標キー
value: number; // モデルが主張した値
tolerance?: number; // 相対許容誤差(省略時は既定値)
};
export type CitationClaim = {
id: string;
kind: "citation";
statement: string;
quote: string; // 出典に存在するはずの引用文字列
sourceId: string; // 突き合わせる原文の識別子
};
export type Claim = NumericClaim | CitationClaim;
export type Artifact = {
summary: string;
claims: Claim[];
};
ここで大切なのは、value を「モデルが主張した値」として保持する点です。検証側はこの値を信じず、あとで自分で計算した値と照合します。台帳は成果物の付属物ではなく、検証の入力そのものになります。
数値は言語モデルに聞かず、関数で計算し直す
数値検証の要点は、言語モデルをいっさい経由させないことです。指標ごとに、生データから答えを一意に導く関数を登録しておき、モデルの主張値と突き合わせます。
// verify-number.ts
import type { NumericClaim } from "./claims";
export type Dataset = Record<string, number[]>;
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
// 指標キー → 生データから値を一意に決める関数
export const metricFns: Record<string, (d: Dataset) => number> = {
"clicks.total": (d) => sum(d.clicks),
"clicks.avgPerDay": (d) => sum(d.clicks) / d.clicks.length,
"wowChangePct": (d) => {
const prev = sum(d.clicksPrevWeek);
const cur = sum(d.clicksThisWeek);
return prev === 0 ? NaN : ((cur - prev) / prev) * 100;
},
};
const DEFAULT_TOLERANCE = 0.005; // 相対0.5%
export function verifyNumber(
claim: NumericClaim,
data: Dataset
): { ok: boolean; reason: string; expected?: number } {
const fn = metricFns[claim.metric];
if (!fn) {
// 未知の指標は「検証不能」= 不合格(fail-closed)
return { ok: false, reason: `未登録の指標: ${claim.metric}` };
}
const expected = fn(data);
if (!Number.isFinite(expected)) {
return { ok: false, reason: "再計算値が有限でない", expected };
}
const tol = claim.tolerance ?? DEFAULT_TOLERANCE;
const denom = Math.abs(expected) || 1;
const relErr = Math.abs(claim.value - expected) / denom;
return relErr <= tol
? { ok: true, reason: "一致", expected }
: {
ok: false,
reason: `主張値 ${claim.value} と再計算値 ${expected.toFixed(2)} が誤差 ${(relErr * 100).toFixed(2)}% で不一致`,
expected,
};
}
冒頭の「+18% のはずが実際は +8%」は、この verifyNumber を通せば wowChangePct の再計算で即座に落ちます。相対許容誤差を入れているのは、丸めや表示桁の差まで不合格にしないためです。ただし既定は0.5%と狭く取り、意味のあるズレは必ず捕まえます。
未登録の指標を ok: false にしているのが fail-closed の肝です。「検証する関数が無い=安全」ではなく「検証できない=通さない」に倒します。
出典は「引用が原文に在るか」を文字列で照合する
出典検証も、モデルに「この引用は正しいですか」と聞いてはいけません。引用文字列が、指定した原文スニペットの中に実在するかを、正規化した上で照合します。
// verify-citation.ts
import type { CitationClaim } from "./claims";
export type Sources = Record<string, string>; // sourceId → 原文スニペット
// 全角/半角・空白・約物のゆらぎを吸収する正規化
function normalize(s: string): string {
return s
.normalize("NFKC")
.replace(/\s+/g, "")
.replace(/[「」『』()()、。,.]/g, "")
.toLowerCase();
}
export function verifyCitation(
claim: CitationClaim,
sources: Sources
): { ok: boolean; reason: string } {
const src = sources[claim.sourceId];
if (!src) {
return { ok: false, reason: `出典が見つからない: ${claim.sourceId}` };
}
const q = normalize(claim.quote);
if (q.length < 8) {
// 短すぎる引用は偶然一致しやすいので不合格
return { ok: false, reason: "引用が短すぎて照合できない" };
}
return normalize(src).includes(q)
? { ok: true, reason: "原文に一致" }
: { ok: false, reason: "引用が原文に存在しない" };
}
正規化を挟むのは、モデルが句読点や全角括弧を微妙に変えて引用しがちだからです。一方で、短すぎる引用(8文字未満)はどんな原文にも偶然当たってしまうので、意図的に不合格にしています。ここでも「照合できない=通さない」です。
検証専任サブエージェントは「決定論では測れない所」だけに使う
数値と出典は上のように決定論で潰せますが、「要約が、台帳に無い数値や出典をこっそり本文に混ぜていないか」は文字列照合だけでは測りにくい領域です。ここだけを検証専任のサブエージェントに任せます。役割を絞ることで、モデルの判断に委ねる面積を最小化できます。
Claude Code なら .claude/agents/ に検証専任のサブエージェントを置き、生成の文脈を持ち込まない独立プロセスとして呼び出せます。
<!-- .claude/agents/claim-auditor.md -->
---
name: claim-auditor
description: 生成された要約が、付属のクレーム台帳に無い数値・出典を本文へ混入させていないかだけを判定する
tools: []
---
あなたは検証専任の監査役です。与えられるのは「要約本文」と「クレーム台帳(idと主張文の一覧)」だけです。
本文に登場する数値・固有の出典表記のうち、台帳のどの主張にも対応しないものを列挙してください。
判断は保守的に行い、台帳で裏付けられない数値・出典が1つでもあれば unverified として報告します。
出力は {"unlisted": string[]} の JSON のみ。生成側の意図を推測して補完してはいけません。
SDK から直接組む場合も、入力を「要約」と「台帳」だけに限定し、生データや生成時のプロンプトを渡さないのが要点です。
// audit-summary.ts(抜粋)
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function auditSummary(
summary: string,
claimStatements: string[]
): Promise<{ unlisted: string[] }> {
const msg = await client.messages.create({
model: "claude-haiku-4-5-20251001", // 監査は軽量モデルで十分
max_tokens: 512,
system:
"要約本文に、与えられた主張一覧のどれにも対応しない数値・出典があれば列挙する。出力は {\"unlisted\": string[]} のJSONのみ。",
messages: [
{
role: "user",
content: `# 要約\n${summary}\n\n# 主張一覧\n${claimStatements.join("\n")}`,
},
],
});
const text = msg.content.find((b) => b.type === "text");
return JSON.parse(text && "text" in text ? text.text : '{"unlisted":[]}');
}
軽量モデルで足りるのは、この監査が「新しい判断」ではなく「台帳との差分検出」という限定タスクだからです。
全部を1枚のゲートに束ねる(Before / After)
最後に、数値検証・出典検証・混入監査をまとめ、1つでも落ちれば成果物ごと却下する関数にします。まずは、やりがちな「そのまま公開」の例です。
// ❌ Before: 生成物の要約をそのまま受け入れて公開する
const artifact = await generateReport(data);
await publish(artifact.summary); // 数値も出典も誰も検証していない
これを、検証を通過した成果物だけが publish に到達する形に変えます。
// ✅ After: 受け入れ前ゲートを必ず経由する
import { verifyNumber } from "./verify-number";
import { verifyCitation } from "./verify-citation";
import { auditSummary } from "./audit-summary";
import type { Artifact, Dataset, Sources } from "./types";
export async function gate(
artifact: Artifact,
data: Dataset,
sources: Sources
): Promise<{ ok: boolean; failures: string[] }> {
const failures: string[] = [];
for (const c of artifact.claims) {
if (c.kind === "number") {
const r = verifyNumber(c, data);
if (!r.ok) failures.push(`[${c.id}] ${r.reason}`);
} else {
const r = verifyCitation(c, sources);
if (!r.ok) failures.push(`[${c.id}] ${r.reason}`);
}
}
const audit = await auditSummary(
artifact.summary,
artifact.claims.map((c) => `${c.id}: ${c.statement}`)
);
for (const u of audit.unlisted) {
failures.push(`[unlisted] 台帳に無い数値/出典: ${u}`);
}
return { ok: failures.length === 0, failures };
}
// 呼び出し側
const artifact = await generateReport(data);
const result = await gate(artifact, data, sources);
if (!result.ok) {
console.error("却下:", result.failures);
// 公開しない。失敗内容をログに残し、再生成か人間確認へ回す
} else {
await publish(artifact.summary);
}
Before と After の違いは一行の追加ではなく、「成果物は既定で信頼しない」という前提の反転です。publish は検証を通った成果物からしか呼ばれません。
運用で効いた小さな判断
数か月この形で本番運用に置いてみて、効いたのは派手な仕組みより細部の判断でした。無人で回すときに最も怖い落とし穴は、静かな数値のズレを検出できないまま公開してしまうことで、このゲートはそれを回避するために置いています。
| 判断ポイント | 採った方針 | 理由 |
| 未登録の指標・見つからない出典 | 不合格にする | 「検証不能」を「安全」と読み替えると事故が漏れる |
| 数値の許容誤差 | 相対0.5%と狭く | 丸め差は許し、意味のあるズレは必ず捕まえる |
| 短い引用 | 8文字未満は不合格 | 偶然一致で「出典あり」と誤判定させない |
| 監査モデル | 軽量モデルで実行 | 差分検出は重い推論を必要としない |
| 却下時の扱い | 失敗理由をログ化して停止 | あとで原因の傾向を読むため。無言で捨てない |
個人的には、いきなり全部を組まず、まず数値の再計算だけでも先に入れることを推奨します。とりわけ却下ログは、あとから「どの指標がよくズレるか」を教えてくれます。私の場合、前週比のような差分指標が最も外れやすく、そこだけ許容誤差をさらに狭めることで、静かなズレをほぼ捕まえられるようになりました。
生成の速さや流暢さは、正しさを保証してくれません。だからこそ、生成とは別の場所に「数値は計算し直す・出典は原文に当たる・裏付けの無いものは通さない」という素朴な関門を置く。派手ではありませんが、無人で回し続けるほど、この一枚のゲートに助けられています。同じように自動処理の成果物を扱っている方の、設計の足がかりになれば幸いです。