ストーリー
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 Mode | LLMにJSON形式での出力を強制。基本的な構造化に有効 |
| Structured Outputs | JSONスキーマを指定して厳密な構造化出力を保証 |
| Function Calling | 関数引数として構造化データを取得。分類・抽出に最適 |
| Zodバリデーション | 実行時のスキーマ検証。リトライ戦略と組み合わせる |
チェックリスト
- JSON ModeとStructured Outputsの違いを理解した
- Function Callingのパターンを理解した
- Zodによるバリデーションの実装を理解した
- ストリーミング構造化出力のパターンを理解した
次のステップへ
構造化出力の設計を学びました。次のセクションでは、プロンプトのバージョン管理とA/Bテストについて学びます。
LLMの出力は「テキスト」ではなく「構造化データ」として扱う。バリデーションは必須。
推定読了時間: 40分