LESSON 40分

ストーリー

佐藤CTO
プロンプトで”JSON形式で回答して”と指示しても、LLMは時々壊れたJSONを返す
佐藤CTO
本番システムではパース失敗は障害だ。LLMの出力を確実に構造化データとして取得し、バリデーションまで行う仕組みが必要だ。JSON Mode、Function Calling、そしてZodによるスキーマバリデーション。今日はこれらを使いこなせるようになろう

JSON Modeの活用

OpenAI JSON Mode

import { OpenAI } from 'openai';

const openai = new OpenAI();

// JSON Mode を使用した構造化出力
async function getStructuredResponse(query: string): Promise<{
  answer: string;
  confidence: string;
  sources: number[];
}> {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    response_format: { type: 'json_object' },
    messages: [
      {
        role: 'system',
        content: `質問に対してJSON形式で回答してください。
以下のスキーマに従ってください:
{
  "answer": "回答テキスト",
  "confidence": "high" | "medium" | "low",
  "sources": [出典番号の配列]
}`,
      },
      { role: 'user', content: query },
    ],
  });

  const content = response.choices[0].message.content;
  if (!content) throw new Error('Empty response');

  return JSON.parse(content);
}

Structured Outputs(OpenAI)

OpenAIのStructured Outputsは、JSONスキーマを指定して確実に構造化された出力を得る機能です。

// Structured Outputs を使用
async function getTypedResponse(query: string, context: string) {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    response_format: {
      type: 'json_schema',
      json_schema: {
        name: 'rag_response',
        strict: true,
        schema: {
          type: 'object',
          properties: {
            answer: { type: 'string', description: '質問への回答' },
            confidence: {
              type: 'string',
              enum: ['high', 'medium', 'low'],
              description: '回答の確信度',
            },
            sources: {
              type: 'array',
              items: { type: 'integer' },
              description: '参照した出典番号',
            },
            followUpQuestions: {
              type: 'array',
              items: { type: 'string' },
              description: '関連する追加質問の候補',
            },
          },
          required: ['answer', 'confidence', 'sources', 'followUpQuestions'],
          additionalProperties: false,
        },
      },
    },
    messages: [
      { role: 'system', content: 'コンテキストに基づいて質問に回答してください。' },
      { role: 'user', content: `コンテキスト:\n${context}\n\n質問: ${query}` },
    ],
  });

  return JSON.parse(response.choices[0].message.content!);
}

Function Calling / Tool Use

Function Callingのパターン

LLMに「関数の引数」として構造化データを生成させるパターンです。

// Function Calling を使った情報抽出
async function extractMetadata(query: string): Promise<{
  category: string;
  technologies: string[];
  timeRange: string | null;
  intent: string;
}> {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    tools: [
      {
        type: 'function',
        function: {
          name: 'classify_query',
          description: 'ユーザークエリからメタデータを抽出する',
          parameters: {
            type: 'object',
            properties: {
              category: {
                type: 'string',
                enum: ['tech-doc', 'incident', 'adr', 'general'],
                description: 'クエリの対象ドキュメントカテゴリ',
              },
              technologies: {
                type: 'array',
                items: { type: 'string' },
                description: '言及されている技術名',
              },
              timeRange: {
                type: 'string',
                nullable: true,
                description: '時間範囲(例: "先月", "2024年Q3")',
              },
              intent: {
                type: 'string',
                enum: ['how_to', 'cause_analysis', 'comparison', 'definition', 'troubleshooting'],
                description: 'クエリの意図',
              },
            },
            required: ['category', 'technologies', 'timeRange', 'intent'],
          },
        },
      },
    ],
    tool_choice: { type: 'function', function: { name: 'classify_query' } },
    messages: [
      { role: 'user', content: query },
    ],
  });

  const toolCall = response.choices[0].message.tool_calls?.[0];
  if (!toolCall) throw new Error('No tool call in response');

  return JSON.parse(toolCall.function.arguments);
}

Zodによるバリデーション

スキーマ定義と実行時バリデーション

import { z } from 'zod';

// RAG応答のスキーマ定義
const RAGResponseSchema = z.object({
  answer: z.string().min(1).max(2000),
  confidence: z.enum(['high', 'medium', 'low']),
  sources: z.array(z.number().int().positive()).min(0),
  followUpQuestions: z.array(z.string()).max(3).optional(),
  caveats: z.string().optional(),
});

type RAGResponse = z.infer<typeof RAGResponseSchema>;

// バリデーション付きのLLM呼び出し
async function getValidatedResponse(
  query: string,
  context: string,
  maxRetries: number = 3,
): Promise<RAGResponse> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        response_format: { type: 'json_object' },
        messages: [
          {
            role: 'system',
            content: `質問に対してJSON形式で回答してください。

スキーマ:
- answer (string, 必須): 回答テキスト
- confidence (string, 必須): "high", "medium", "low" のいずれか
- sources (number[], 必須): 参照した出典番号の配列
- followUpQuestions (string[], 任意): 関連する追加質問(最大3つ)
- caveats (string, 任意): 注意事項や前提条件`,
          },
          {
            role: 'user',
            content: `コンテキスト:\n${context}\n\n質問: ${query}`,
          },
        ],
      });

      const content = response.choices[0].message.content;
      if (!content) throw new Error('Empty response');

      const parsed = JSON.parse(content);
      const validated = RAGResponseSchema.parse(parsed);
      return validated;
    } catch (error) {
      if (error instanceof z.ZodError) {
        console.warn(`Validation failed (attempt ${attempt + 1}):`, error.errors);
        if (attempt === maxRetries - 1) {
          throw new Error(`Failed to get valid response after ${maxRetries} attempts: ${error.message}`);
        }
        continue;
      }
      throw error;
    }
  }

  throw new Error('Unreachable');
}

複雑なスキーマの例

// クエリ分析の結果スキーマ
const QueryAnalysisSchema = z.object({
  intent: z.enum([
    'factual',
    'procedural',
    'analytical',
    'troubleshooting',
    'comparison',
  ]),
  complexity: z.enum(['simple', 'moderate', 'complex']),
  targetDocTypes: z.array(
    z.enum(['tech-doc', 'incident-report', 'adr', 'runbook']),
  ),
  entities: z.object({
    technologies: z.array(z.string()),
    services: z.array(z.string()),
    people: z.array(z.string()),
  }),
  filters: z.object({
    dateRange: z.object({
      start: z.string().nullable(),
      end: z.string().nullable(),
    }).optional(),
    team: z.string().nullable().optional(),
    severity: z.enum(['P1', 'P2', 'P3', 'P4']).nullable().optional(),
  }),
  decomposedQueries: z.array(z.string()).optional(),
});

type QueryAnalysis = z.infer<typeof QueryAnalysisSchema>;

ストリーミング構造化出力

部分的なJSONのパース

// ストリーミングで構造化データを段階的に処理
async function* streamStructuredResponse(
  query: string,
  context: string,
): AsyncGenerator<Partial<RAGResponse>> {
  const stream = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    response_format: { type: 'json_object' },
    stream: true,
    messages: [
      {
        role: 'system',
        content: `JSON形式で回答: {"answer": "...", "confidence": "...", "sources": [...]}`,
      },
      {
        role: 'user',
        content: `コンテキスト:\n${context}\n\n質問: ${query}`,
      },
    ],
  });

  let accumulated = '';

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta?.content ?? '';
    accumulated += delta;

    // 部分的なJSONをパース試行
    try {
      const partial = partialJsonParse(accumulated);
      if (partial) yield partial;
    } catch {
      // パース失敗は無視して蓄積を続ける
    }
  }

  // 最終的な完全なJSONをパース
  const final = JSON.parse(accumulated);
  yield RAGResponseSchema.parse(final);
}

// 不完全なJSONから安全にフィールドを抽出
function partialJsonParse(text: string): Partial<RAGResponse> | null {
  // answerフィールドが途中まで来ているかチェック
  const answerMatch = text.match(/"answer"\s*:\s*"([^"]*)/);
  if (answerMatch) {
    return { answer: answerMatch[1] };
  }
  return null;
}
エラーハンドリングのベストプラクティス
class StructuredOutputError extends Error {
  constructor(
    message: string,
    public readonly rawOutput: string,
    public readonly validationErrors?: z.ZodError,
  ) {
    super(message);
    this.name = 'StructuredOutputError';
  }
}

class RobustStructuredOutput<T extends z.ZodType> {
  constructor(
    private readonly schema: T,
    private readonly llm: OpenAI,
    private readonly config: {
      model: string;
      maxRetries: number;
      fallbackBehavior: 'throw' | 'default' | 'partial';
      defaultValue?: z.infer<T>;
    },
  ) {}

  async generate(messages: ChatMessage[]): Promise<z.infer<T>> {
    let lastError: Error | null = null;

    for (let i = 0; i < this.config.maxRetries; i++) {
      try {
        const response = await this.llm.chat.completions.create({
          model: this.config.model,
          response_format: { type: 'json_object' },
          messages,
        });

        const content = response.choices[0].message.content;
        if (!content) throw new Error('Empty response');

        const parsed = JSON.parse(content);
        return this.schema.parse(parsed);
      } catch (error) {
        lastError = error as Error;

        if (error instanceof SyntaxError) {
          // JSONパース失敗: リトライ
          console.warn(`JSON parse failed (attempt ${i + 1})`);
        } else if (error instanceof z.ZodError) {
          // バリデーション失敗: プロンプトにエラー情報を追加してリトライ
          messages.push({
            role: 'user',
            content: `前の回答はスキーマに合致しませんでした。以下のエラーを修正してください:\n${error.errors.map(e => `- ${e.path}: ${e.message}`).join('\n')}`,
          });
        } else {
          throw error;
        }
      }
    }

    if (this.config.fallbackBehavior === 'default' && this.config.defaultValue) {
      return this.config.defaultValue;
    }

    throw new StructuredOutputError(
      `Failed after ${this.config.maxRetries} attempts`,
      '',
      lastError instanceof z.ZodError ? lastError : undefined,
    );
  }
}

まとめ

ポイント内容
JSON ModeLLMにJSON形式での出力を強制。基本的な構造化に有効
Structured OutputsJSONスキーマを指定して厳密な構造化出力を保証
Function Calling関数引数として構造化データを取得。分類・抽出に最適
Zodバリデーション実行時のスキーマ検証。リトライ戦略と組み合わせる

チェックリスト

  • JSON ModeとStructured Outputsの違いを理解した
  • Function Callingのパターンを理解した
  • Zodによるバリデーションの実装を理解した
  • ストリーミング構造化出力のパターンを理解した

次のステップへ

構造化出力の設計を学びました。次のセクションでは、プロンプトのバージョン管理とA/Bテストについて学びます。

LLMの出力は「テキスト」ではなく「構造化データ」として扱う。バリデーションは必須。


推定読了時間: 40分