Zod スキーマで LLM レスポンスの「契約」を定義する
Zodは TypeScript 向けの実行時バリデーションライブラリで、スキーマ定義から自動的にTypeScript型を推論できます。LLMレスポンスの「あるべき構造」をZodスキーマとして宣言し、それをコンパイル時の型チェックと実行時のバリデーションの両方に使います。
// src/schemas/article.ts
import { z } from "zod";
// 記事分析結果のスキーマ定義
export const ArticleAnalysisSchema = z.object({
title: z.string().min(1).max(200).describe("記事のタイトル"),
summary: z.string().min(50).max(500).describe("記事の要約(50〜500文字)"),
sentiment: z.enum(["positive", "negative", "neutral"]).describe("記事全体の感情傾向"),
topics: z.array(z.string()).min(1).max(10).describe("主要トピック(1〜10個)"),
readingTimeMinutes: z.number().int().positive().describe("推定読了時間(分)"),
keyEntities: z.array(
z.object({
name: z.string().describe("固有名詞"),
type: z.enum(["person", "organization", "technology", "location"]).describe("エンティティの種類"),
relevance: z.number().min(0).max(1).describe("記事との関連度(0〜1)"),
})
).max(20).describe("記事中の重要なエンティティ"),
});
// スキーマからTypeScript型を自動推論
export type ArticleAnalysis = z.infer<typeof ArticleAnalysisSchema>;
// この型は以下と同等:
// {
// title: string;
// summary: string;
// sentiment: "positive" | "negative" | "neutral";
// topics: string[];
// readingTimeMinutes: number;
// keyEntities: { name: string; type: "person" | ...; relevance: number; }[];
// }
ポイントは、z.describe() でフィールドごとに説明を追加していることです。この説明はGemini APIのStructured Outputに渡すJSONスキーマにも反映され、LLMがより正確な構造を出力する助けになります。
Zod → JSON Schema 変換レイヤーの構築
Gemini APIのStructured OutputはJSON Schema形式でレスポンスの構造を指定します。Zodスキーマを直接渡すことはできないため、変換レイヤーが必要です。
// src/utils/schema-converter.ts
import { z } from "zod";
import { Type } from "@google/genai";
/**
* ZodスキーマをGemini API互換のJSON Schemaに変換する
* Gemini APIはJSON Schema Draft 2020-12のサブセットをサポート
*/
export function zodToGeminiSchema(schema: z.ZodType): Record<string, unknown> {
if (schema instanceof z.ZodObject) {
const shape = schema.shape;
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
properties[key] = zodToGeminiSchema(value as z.ZodType);
if (!(value instanceof z.ZodOptional)) {
required.push(key);
}
}
return {
type: Type.OBJECT,
properties,
required,
};
}
if (schema instanceof z.ZodString) {
const result: Record<string, unknown> = { type: Type.STRING };
if (schema.description) result.description = schema.description;
// enum値の抽出
const checks = (schema as any)._def.checks || [];
return result;
}
if (schema instanceof z.ZodNumber) {
const result: Record<string, unknown> = { type: Type.NUMBER };
if (schema.description) result.description = schema.description;
return result;
}
if (schema instanceof z.ZodBoolean) {
return { type: Type.BOOLEAN };
}
if (schema instanceof z.ZodEnum) {
return {
type: Type.STRING,
enum: schema.options,
description: schema.description,
};
}
if (schema instanceof z.ZodArray) {
return {
type: Type.ARRAY,
items: zodToGeminiSchema(schema.element),
description: schema.description,
};
}
if (schema instanceof z.ZodOptional) {
return { ...zodToGeminiSchema(schema.unwrap()), nullable: true };
}
// フォールバック
return { type: Type.STRING };
}
この変換関数により、Zodスキーマを一箇所で定義するだけで、TypeScript型・実行時バリデーション・Gemini APIスキーマの三つの型情報を自動的に同期できます。スキーマの修正漏れによるバグを根本的に防ぐ設計です。
型安全な Gemini API クライアントの実装
ここまでの部品を組み合わせて、ジェネリクスで型パラメータを受け取る汎用AIクライアントを構築します。
// src/client/typesafe-gemini.ts
import { z } from "zod";
import { ai, DEFAULT_MODEL } from "../config";
import { zodToGeminiSchema } from "../utils/schema-converter";
interface GenerateOptions<T extends z.ZodType> {
prompt: string;
schema: T;
model?: string;
temperature?: number;
systemInstruction?: string;
maxRetries?: number;
}
interface GenerateResult<T> {
data: T;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
latencyMs: number;
}
/**
* 型安全なGemini APIリクエスト
* - Zodスキーマから自動的にStructured Output設定を生成
* - レスポンスをバリデーションしてから返却
* - リトライロジック内蔵
*/
export async function generateTyped<T extends z.ZodType>(
options: GenerateOptions<T>
): Promise<GenerateResult<z.infer<T>>> {
const {
prompt,
schema,
model = DEFAULT_MODEL,
temperature = 0.7,
systemInstruction,
maxRetries = 3,
} = options;
const geminiSchema = zodToGeminiSchema(schema);
const startTime = Date.now();
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await ai.models.generateContent({
model,
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
temperature,
responseMimeType: "application/json",
responseSchema: geminiSchema,
...(systemInstruction && { systemInstruction }),
},
});
const text = response.text;
if (!text) {
throw new Error("Empty response from Gemini API");
}
// JSON パース
const rawData = JSON.parse(text);
// Zod バリデーション(実行時型チェック)
const validated = schema.parse(rawData);
const latencyMs = Date.now() - startTime;
const usage = response.usageMetadata;
return {
data: validated,
usage: {
promptTokens: usage?.promptTokenCount ?? 0,
completionTokens: usage?.candidatesTokenCount ?? 0,
totalTokens: usage?.totalTokenCount ?? 0,
},
latencyMs,
};
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// バリデーションエラーの場合、リトライで改善する可能性がある
if (error instanceof z.ZodError && attempt < maxRetries) {
console.warn(
`Validation failed (attempt ${attempt}/${maxRetries}):`,
error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
);
continue;
}
// レート制限エラーは指数バックオフ
if (isRateLimitError(error) && attempt < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
console.warn(`Rate limited. Retrying in ${delay}ms...`);
await sleep(delay);
continue;
}
throw lastError;
}
}
throw lastError ?? new Error("All retry attempts exhausted");
}
function isRateLimitError(error: unknown): boolean {
if (error instanceof Error) {
return error.message.includes("429") || error.message.includes("RESOURCE_EXHAUSTED");
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
使用例を見てみましょう。
// src/examples/analyze-article.ts
import { generateTyped } from "../client/typesafe-gemini";
import { ArticleAnalysisSchema, type ArticleAnalysis } from "../schemas/article";
async function analyzeArticle(articleText: string): Promise<ArticleAnalysis> {
const result = await generateTyped({
prompt: `以下の記事を分析してください:\n\n${articleText}`,
schema: ArticleAnalysisSchema,
temperature: 0.3, // 分析タスクは低温度で安定させる
systemInstruction: "あなたはコンテンツ分析の専門家です。正確かつ簡潔に分析してください。",
});
// result.data は ArticleAnalysis 型として完全に型推論される
console.log(`分析完了: ${result.data.title}`);
console.log(`トークン使用量: ${result.usage.totalTokens}`);
console.log(`レイテンシ: ${result.latencyMs}ms`);
return result.data;
}
// 期待する出力例:
// 分析完了: Gemini 2.5 Proの新機能を実例で整理
// トークン使用量: 1523
// レイテンシ: 2340ms
generateTyped の呼び出し元では、result.data が自動的に ArticleAnalysis 型として推論されます。IDEの補完も完全に効き、存在しないフィールドへのアクセスはコンパイル時にエラーになります。
ストリーミングレスポンスの型安全な処理
Gemini APIのストリーミング機能を使うと、LLMの出力をチャンク単位でリアルタイムに受け取れます。ただし、ストリーミング中はJSONが不完全な状態で届くため、部分パースとプログレッシブバリデーションの仕組みが必要です。
// src/client/streaming.ts
import { z } from "zod";
import { ai, DEFAULT_MODEL } from "../config";
import { zodToGeminiSchema } from "../utils/schema-converter";
interface StreamOptions<T extends z.ZodType> {
prompt: string;
schema: T;
model?: string;
systemInstruction?: string;
onPartialData?: (partial: string) => void;
}
/**
* 型安全なストリーミングレスポンス処理
* - チャンクを逐次受信してUIに反映可能
* - 最終結果はZodでバリデーション済み
*/
export async function generateTypedStream<T extends z.ZodType>(
options: StreamOptions<T>
): Promise<z.infer<T>> {
const {
prompt,
schema,
model = DEFAULT_MODEL,
systemInstruction,
onPartialData,
} = options;
const geminiSchema = zodToGeminiSchema(schema);
let accumulated = "";
const response = await ai.models.generateContentStream({
model,
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
responseMimeType: "application/json",
responseSchema: geminiSchema,
...(systemInstruction && { systemInstruction }),
},
});
for await (const chunk of response) {
const text = chunk.text;
if (text) {
accumulated += text;
onPartialData?.(accumulated);
}
}
// 全チャンク受信後にパース&バリデーション
const rawData = JSON.parse(accumulated);
return schema.parse(rawData);
}
フロントエンドとの連携パターン(Server-Sent Events)
ストリーミングレスポンスをフロントエンドに中継する場合、Server-Sent Events(SSE)が最もシンプルです。以下はNext.js App Routerでの実装例です。
// app/api/analyze/route.ts(Next.js Route Handler)
import { generateTypedStream } from "@/lib/streaming";
import { ArticleAnalysisSchema } from "@/schemas/article";
export async function POST(request: Request) {
const { text } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const result = await generateTypedStream({
prompt: `以下のテキストを分析してください:\n\n${text}`,
schema: ArticleAnalysisSchema,
onPartialData: (partial) => {
// 部分データをSSEイベントとして送信
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ partial })}\n\n`)
);
},
});
// 最終結果(バリデーション済み)を送信
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ result, done: true })}\n\n`)
);
controller.close();
} catch (error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: "Analysis failed" })}\n\n`)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
エラーハンドリングの階層設計
本番環境では、さまざまなレイヤーでエラーが発生します。各レイヤーに適切なエラーハンドリングを配置する階層設計が重要です。
// src/errors/ai-errors.ts
/**
* AIアプリケーション専用のエラー階層
* TypeScriptのdiscriminated unionで型安全にハンドリング可能
*/
export class AIError extends Error {
constructor(
message: string,
public readonly code: AIErrorCode,
public readonly retryable: boolean,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = "AIError";
}
}
export type AIErrorCode =
| "SCHEMA_VALIDATION_FAILED"
| "JSON_PARSE_FAILED"
| "EMPTY_RESPONSE"
| "RATE_LIMIT_EXCEEDED"
| "QUOTA_EXCEEDED"
| "MODEL_OVERLOADED"
| "CONTENT_FILTERED"
| "NETWORK_ERROR"
| "TIMEOUT"
| "UNKNOWN";
/**
* Gemini APIエラーを構造化されたAIErrorに変換
*/
export function classifyError(error: unknown): AIError {
if (error instanceof AIError) return error;
const message = error instanceof Error ? error.message : String(error);
// レート制限
if (message.includes("429") || message.includes("RESOURCE_EXHAUSTED")) {
return new AIError(message, "RATE_LIMIT_EXCEEDED", true);
}
// クォータ超過
if (message.includes("quota") || message.includes("QUOTA")) {
return new AIError(message, "QUOTA_EXCEEDED", false);
}
// コンテンツフィルタ
if (message.includes("SAFETY") || message.includes("blocked")) {
return new AIError(message, "CONTENT_FILTERED", false);
}
// モデル過負荷
if (message.includes("503") || message.includes("overloaded")) {
return new AIError(message, "MODEL_OVERLOADED", true);
}
// タイムアウト
if (message.includes("timeout") || message.includes("DEADLINE_EXCEEDED")) {
return new AIError(message, "TIMEOUT", true);
}
return new AIError(message, "UNKNOWN", false);
}
Result型パターンで例外を排除する
Go言語やRustの影響を受けたResult型パターンを導入すると、例外を使わずに型安全なエラーハンドリングが可能になります。
// src/utils/result.ts
export type Result<T, E = AIError> =
| { success: true; data: T }
| { success: false; error: E };
export function ok<T>(data: T): Result<T, never> {
return { success: true, data };
}
export function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
// src/client/safe-generate.ts
import { z } from "zod";
import { generateTyped } from "./typesafe-gemini";
import { classifyError, AIError } from "../errors/ai-errors";
import { Result, ok, err } from "../utils/result";
/**
* 例外を投げない型安全なAPI呼び出し
* 呼び出し元はResult型でエラーを明示的にハンドリングする
*/
export async function safeGenerate<T extends z.ZodType>(
options: Parameters<typeof generateTyped<T>>[0]
): Promise<Result<z.infer<T>, AIError>> {
try {
const result = await generateTyped(options);
return ok(result.data);
} catch (error) {
return err(classifyError(error));
}
}
// 使用例
async function processArticle(text: string) {
const result = await safeGenerate({
prompt: `記事を分析してください: ${text}`,
schema: ArticleAnalysisSchema,
});
if (!result.success) {
// error は AIError 型として推論される
if (result.error.retryable) {
console.log("リトライ可能なエラー:", result.error.code);
// キューに入れて後でリトライ
} else {
console.error("回復不能なエラー:", result.error.code);
// アラート送信
}
return;
}
// data は ArticleAnalysis 型として推論される
console.log(result.data.title);
console.log(result.data.topics);
}
この設計の利点は、エラーのハンドリングを強制できることです。result.success をチェックしないと result.data にアクセスできないため、エラー処理の漏れをコンパイル時に検出できます。
スキーマバージョニングと後方互換性
本番環境では、AIの出力スキーマを段階的に変更する必要があります。フィールドの追加・削除をデプロイと同期させるため、スキーマのバージョニング戦略が不可欠です。
// src/schemas/versioned.ts
import { z } from "zod";
// v1: 初期スキーマ
const ProductReviewV1 = z.object({
productName: z.string(),
rating: z.number().min(1).max(5),
summary: z.string(),
});
// v2: フィールド追加(後方互換を維持)
const ProductReviewV2 = ProductReviewV1.extend({
pros: z.array(z.string()).default([]),
cons: z.array(z.string()).default([]),
recommendationScore: z.number().min(0).max(100).optional(),
});
// v3: フィールドの型変更(マイグレーション関数が必要)
const ProductReviewV3 = z.object({
productName: z.string(),
rating: z.object({
overall: z.number().min(1).max(5),
categories: z.record(z.string(), z.number().min(1).max(5)),
}),
summary: z.string(),
pros: z.array(z.string()),
cons: z.array(z.string()),
recommendationScore: z.number().min(0).max(100),
});
// マイグレーション関数
type V2Data = z.infer<typeof ProductReviewV2>;
type V3Data = z.infer<typeof ProductReviewV3>;
function migrateV2toV3(v2: V2Data): V3Data {
return {
productName: v2.productName,
rating: {
overall: v2.rating,
categories: {},
},
summary: v2.summary,
pros: v2.pros,
cons: v2.cons,
recommendationScore: v2.recommendationScore ?? v2.rating * 20,
};
}
// 現在のバージョンをエクスポート
export const ProductReviewSchema = ProductReviewV3;
export type ProductReview = z.infer<typeof ProductReviewV3>;
export { migrateV2toV3 };
.default() や .optional() を使えば、新しいフィールドを追加しても既存のレスポンスが壊れない設計が可能です。破壊的変更が必要な場合は、マイグレーション関数を用意して段階的に移行します。
テスト戦略 — LLM レスポンスのモック化と プロパティベーステスト
AIアプリケーションのテストでは、LLMの非決定性と向き合う必要があります。ここでは、バリデーションロジックのユニットテストと、スキーマの堅牢性を検証するプロパティベーステストの両方を紹介します。
// src/__tests__/schema-validation.test.ts
import { describe, it, expect } from "vitest";
import { ArticleAnalysisSchema } from "../schemas/article";
describe("ArticleAnalysisSchema", () => {
it("正しい構造のデータを受け入れる", () => {
const validData = {
title: "テスト記事",
summary: "これはテスト用の記事です。50文字以上の要約テキストが必要なので、このように長めに書いています。",
sentiment: "positive",
topics: ["AI", "TypeScript"],
readingTimeMinutes: 5,
keyEntities: [
{ name: "Google", type: "organization", relevance: 0.9 },
],
};
const result = ArticleAnalysisSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it("不正なsentiment値を拒否する", () => {
const invalidData = {
title: "テスト記事",
summary: "x".repeat(50),
sentiment: "excited", // enum外の値
topics: ["AI"],
readingTimeMinutes: 5,
keyEntities: [],
};
const result = ArticleAnalysisSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain("sentiment");
}
});
it("空のtopics配列を拒否する", () => {
const invalidData = {
title: "テスト記事",
summary: "x".repeat(50),
sentiment: "neutral",
topics: [], // min(1)に違反
readingTimeMinutes: 5,
keyEntities: [],
};
const result = ArticleAnalysisSchema.safeParse(invalidData);
expect(result.success).toBe(false);
});
});
プロパティベーステストでスキーマの堅牢性を検証
// src/__tests__/property-test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { zodToGeminiSchema } from "../utils/schema-converter";
import { ArticleAnalysisSchema } from "../schemas/article";
describe("zodToGeminiSchema 変換の一貫性", () => {
it("Zodスキーマで有効なデータは変換後のJSON Schemaでも有効", () => {
// 100回のランダムデータ生成テスト
for (let i = 0; i < 100; i++) {
const randomData = {
title: `Title ${Math.random().toString(36).slice(2, 12)}`,
summary: "a".repeat(50 + Math.floor(Math.random() * 450)),
sentiment: ["positive", "negative", "neutral"][
Math.floor(Math.random() * 3)
],
topics: Array.from(
{ length: 1 + Math.floor(Math.random() * 5) },
(_, j) => `topic-${j}`
),
readingTimeMinutes: 1 + Math.floor(Math.random() * 30),
keyEntities: [],
};
const zodResult = ArticleAnalysisSchema.safeParse(randomData);
expect(zodResult.success).toBe(true);
}
});
it("変換されたGemini Schemaが必須フィールドを正しく含む", () => {
const geminiSchema = zodToGeminiSchema(ArticleAnalysisSchema);
expect(geminiSchema).toHaveProperty("properties");
expect(geminiSchema).toHaveProperty("required");
const required = geminiSchema.required as string[];
expect(required).toContain("title");
expect(required).toContain("summary");
expect(required).toContain("sentiment");
});
});
本番環境でのパフォーマンス最適化
型安全性を維持しながら、パフォーマンスも最適化するためのテクニックを紹介します。
コンテキストキャッシュとの統合
大量のドキュメントを繰り返し分析する場合、Gemini APIのコンテキストキャッシュを使えば、コストとレイテンシを大幅に削減できます。
// src/client/cached-generate.ts
import { z } from "zod";
import { ai } from "../config";
import { zodToGeminiSchema } from "../utils/schema-converter";
interface CachedGenerateOptions<T extends z.ZodType> {
schema: T;
systemInstruction: string;
contextDocuments: string[];
cacheTTLSeconds?: number;
}
/**
* コンテキストキャッシュ付きの型安全な生成
* 同じドキュメントセットに対する複数クエリのコストを最大90%削減
*/
export async function createCachedGenerator<T extends z.ZodType>(
options: CachedGenerateOptions<T>
) {
const { schema, systemInstruction, contextDocuments, cacheTTLSeconds = 3600 } = options;
// キャッシュを作成
const cache = await ai.caches.create({
model: "gemini-2.5-flash",
config: {
systemInstruction,
contents: contextDocuments.map((doc) => ({
role: "user",
parts: [{ text: doc }],
})),
ttl: `${cacheTTLSeconds}s`,
},
});
const geminiSchema = zodToGeminiSchema(schema);
// キャッシュを使ったクエリ関数を返す
return async function query(prompt: string): Promise<z.infer<T>> {
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
cachedContent: cache.name,
responseMimeType: "application/json",
responseSchema: geminiSchema,
},
});
const rawData = JSON.parse(response.text ?? "{}");
return schema.parse(rawData);
};
}
// 使用例: 100件のドキュメントに対して繰り返しクエリ
// const analyzer = await createCachedGenerator({
// schema: ArticleAnalysisSchema,
// systemInstruction: "文書分析の専門家として回答してください",
// contextDocuments: documents,
// cacheTTLSeconds: 7200,
// });
// const result1 = await analyzer("主要トピックを抽出してください");
// const result2 = await analyzer("感情分析を実施してください");
バッチ処理パイプライン
大量のリクエストを効率的に処理するバッチパイプラインも型安全に構築できます。
// src/client/batch-pipeline.ts
import { z } from "zod";
import { safeGenerate } from "./safe-generate";
import { Result } from "../utils/result";
import { AIError } from "../errors/ai-errors";
interface BatchOptions<T extends z.ZodType> {
items: string[];
schema: T;
promptTemplate: (item: string) => string;
concurrency?: number;
onProgress?: (completed: number, total: number) => void;
}
/**
* 型安全なバッチ処理パイプライン
* - 同時実行数を制御してレート制限を回避
* - 個別の失敗が全体を止めない
*/
export async function batchGenerate<T extends z.ZodType>(
options: BatchOptions<T>
): Promise<Result<z.infer<T>, AIError>[]> {
const { items, schema, promptTemplate, concurrency = 5, onProgress } = options;
const results: Result<z.infer<T>, AIError>[] = [];
let completed = 0;
// セマフォによる同時実行数制御
const semaphore = new Semaphore(concurrency);
const tasks = items.map(async (item) => {
await semaphore.acquire();
try {
const result = await safeGenerate({
prompt: promptTemplate(item),
schema,
temperature: 0.3,
});
results.push(result);
} finally {
semaphore.release();
completed++;
onProgress?.(completed, items.length);
}
});
await Promise.all(tasks);
return results;
}
// シンプルなセマフォ実装
class Semaphore {
private queue: (() => void)[] = [];
private current = 0;
constructor(private max: number) {}
acquire(): Promise<void> {
if (this.current < this.max) {
this.current++;
return Promise.resolve();
}
return new Promise((resolve) => this.queue.push(resolve));
}
release(): void {
this.current--;
const next = this.queue.shift();
if (next) {
this.current++;
next();
}
}
}
観測 — スキーマを通過した「もっともらしい誤り」を運用で捕まえる
Zod が構造を保証しても、内容の正しさまでは保証しません。本番で無人運用するパイプラインでは、構造的には妥当なのに意味的に誤った出力——空に近い要約、桁が一つずれた数値、前回と矛盾する分類——が最も危険です。これらは parse を通過するため、例外ベースの監視には一切引っかかりません。
そこで、バリデーションの「結果そのもの」を計測対象に格上げします。成功・失敗だけでなく、スキーマを通過した出力に対して軽量な不変条件(invariant)を後段でもう一度確かめ、その通過率を時系列で観測します。
// src/observability/validation-metrics.ts
type ValidationOutcome = "ok" | "parse_fail" | "invariant_fail";
interface Counter { inc(labels: Record<string, string>): void; }
// 不変条件: 構造ではなく「内容の最低限の妥当性」を表明する
type Invariant<T> = { name: string; holds: (value: T) => boolean };
export function recordValidation<T>(
schemaName: string,
parsed: { success: true; data: T } | { success: false },
invariants: Invariant<T>[],
counter: Counter,
): ValidationOutcome {
if (!parsed.success) {
counter.inc({ schema: schemaName, outcome: "parse_fail", invariant: "-" });
return "parse_fail";
}
for (const inv of invariants) {
if (!inv.holds(parsed.data)) {
// 構造は正しいが内容が破綻している — 静かな劣化の正体
counter.inc({ schema: schemaName, outcome: "invariant_fail", invariant: inv.name });
return "invariant_fail";
}
}
counter.inc({ schema: schemaName, outcome: "ok", invariant: "-" });
return "ok";
}
不変条件は、ドメイン知識を最小限のコードに落とし込むものです。たとえば要約タスクなら「元テキストの 5% 未満の長さは要約とは呼べない」、分類タスクなら「信頼度が 0.4 未満なら採用しない」といった、型では表現しきれない最低ラインを置きます。
// 要約スキーマ用の不変条件の例
const summaryInvariants: Invariant<{ summary: string; sourceLength: number }>[] = [
{
name: "summary_not_degenerate",
holds: (v) => v.summary.length >= v.sourceLength * 0.05,
},
{
name: "summary_within_bounds",
holds: (v) => v.summary.length <= v.sourceLength,
},
];
私が個人開発で複数サイトの自動生成を運用してきて学んだのは、「invariant_fail の比率」こそが早期警戒として一番効くということでした。parse_fail はデプロイ直後など分かりやすい場面で跳ね上がりますが、invariant_fail はモデル更新やプロンプト微修正のあとに、誰にも気づかれないまま静かに増えていきます。この比率を週次で見るだけで、劣化を事故になる前に拾えるようになりました。
まずは「採用しない」だけを実装し、invariant_fail を記録してフォールバック(再生成・既定値・人手レビュー行き)へ流すところから始めると、無理なく観測の土台を作れます。
次の一歩
型安全なAIアプリケーション設計の要点は、Zodで「スキーマ→型→バリデーション→API仕様」を一元化し、Result型で例外に頼らずに失敗を扱い、さらにバリデーションの結果そのものを観測して意味的な劣化まで捕まえる——この三層を一つの流れとして組むことにあります。
最初の一歩としては、いま手元にある最も重要な1つのレスポンスに対してだけ Zod スキーマを定義し、recordValidation で通過率の記録を始めてみてください。小さく始めて、invariant_fail が見えるようになった時点で、初めて不変条件を一つずつ足していく——この順序が、過剰設計に陥らずに型安全性を育てる近道だと考えています。