T-CREATOR

gpt-oss で JSON 構造化出力を安定させる:スキーマ提示・検証リトライ・自動修復

gpt-oss で JSON 構造化出力を安定させる:スキーマ提示・検証リトライ・自動修復

AI モデルから確実に JSON を取得したい。そんな開発者の願いに応えるべく、OpenAI は gpt-oss などのモデルで構造化出力機能を提供しています。しかし、実際には「余計なコメントが混入する」「途中でオブジェクトが切れる」といった問題が頻発しているのです。

本記事では、gpt-oss で JSON 構造化出力を安定させるための 3 つの柱——スキーマ提示検証リトライ自動修復について、実践的なコード例とともに詳しく解説します。TypeScript と Zod を使った型安全な実装から、エラーハンドリングまで、確実に動く仕組みを構築しましょう。

背景

gpt-oss とは

gpt-oss は OpenAI が開発したオープンソースの言語モデルです。Ollama や Hugging Face、Fireworks AI などのプラットフォームで利用でき、ローカル環境でも動作させられる点が大きな特徴となっています。

20B(200 億パラメータ)という比較的コンパクトなサイズながら、高度な推論能力を持ち、さまざまなタスクに対応できます。

JSON 構造化出力が求められる理由

AI モデルの出力をプログラムで扱うには、決まった形式のデータが必要です。特に以下のようなケースでは、JSON による構造化出力が不可欠でしょう。

  • Web API との連携: バックエンドが期待する型に合わせたレスポンス
  • データベース保存: スキーマに準拠したオブジェクト
  • UI 表示: フロントエンドコンポーネントで扱いやすい形式
  • 自動処理パイプライン: 後続の処理で型チェックやバリデーションを行う

従来は自然言語の回答をパースする必要があり、正規表現や文字列操作で無理やり抽出していました。構造化出力機能を使えば、この手間を大幅に削減できます。

以下の図は、従来の方式と構造化出力の違いを示したものです。

mermaidflowchart LR
  user["開発者"] -->|プロンプト| model["AI モデル"]
  model -->|自然言語| parse["パース処理"]
  parse -->|正規表現・分割| extract["データ抽出"]
  extract -->|不安定| app["アプリケーション"]

  user2["開発者"] -->|スキーマ + プロンプト| model2["構造化出力<br/>モデル"]
  model2 -->|JSON| validate["スキーマ検証"]
  validate -->|型安全| app2["アプリケーション"]

  style model fill:#ffcccc
  style parse fill:#ffcccc
  style extract fill:#ffcccc
  style model2 fill:#ccffcc
  style validate fill:#ccffcc

従来方式では複数の処理ステップが必要で、各段階でエラーが発生しやすくなっています。一方、構造化出力では最初から JSON として取得できるため、パイプラインがシンプルかつ安定します。

gpt-oss における構造化出力の現状

OpenAI の公式 API では response_format パラメータで JSON Schema を指定できますが、gpt-oss ではいくつかの制約があるのです。

Ollama や vllm などのランタイムでは、完全な Structured Outputs 機能がまだサポートされていません。そのため、以下のような問題が報告されています。

#問題具体例
1余計なコメントの混入JSON の前後に説明文が付く
2不完全なオブジェクトトークン制限で途中で切れる
3スキーマ違反必須フィールドが欠落
4Boolean 値の誤りtrue の代わりに True
5推論トレースの挿入Harmony 形式で思考過程が混入

これらの問題に対処するには、スキーマ提示・検証リトライ・自動修復という 3 つのアプローチを組み合わせる必要があります。

課題

課題 1: スキーマを提示しても守られない

response_format で JSON Schema を指定しても、gpt-oss は必ずしもそれに従いません。

以下は典型的な失敗例です。

typescript// スキーマを指定しているのに...
const schema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    age: { type: 'number' },
  },
  required: ['name', 'age'],
};

期待する出力は以下のような JSON です。

json{
  "name": "田中太郎",
  "age": 30
}

しかし、実際には以下のようなレスポンスが返ってくることがあります。

textこの質問にお答えします。

{
  "name": "田中太郎",
  "age": 30
}

以上が回答です。

JSON 以外のテキストが混入しているため、JSON.parse() が失敗してしまいます。これは gpt-oss が「ユーザーに親切に説明しよう」とする傾向があるためです。

課題 2: トークン制限による切断

gpt-oss には最大出力トークン数の制限があります。長い JSON を生成する際、途中で切れてしまうことがあるのです。

json{
  "users": [
    { "id": 1, "name": "太郎" },
    { "id": 2, "name": "花子" },
    { "id": 3, "name": "次郎" },
    { "id": 4, "nam

このような不完全な JSON は当然パースできません。配列やオブジェクトの閉じ括弧が欠けているため、構文エラーとなります。

課題 3: 型の不一致

スキーマで number を指定しても、文字列で返されることがあります。

json{
  "age": "30"
}

期待していたのは数値型ですが、文字列として返ってきました。後続の計算処理でエラーが発生する原因となります。

課題 4: 必須フィールドの欠落

スキーマで required を指定しても、フィールドが省略されることがあるのです。

json{
  "name": "田中太郎"
}

age フィールドが欠けています。これをそのままアプリケーションで使おうとすると、undefined エラーが発生する可能性があります。

以下の図は、これらの課題がどのように発生するかを示したものです。

mermaidsequenceDiagram
    participant Dev as 開発者
    participant API as OpenAI API
    participant Model as gpt-oss
    participant App as アプリケーション

    Dev->>API: スキーマ + プロンプト
    API->>Model: 生成リクエスト
    Model->>API: 余計なテキスト付きJSON
    API->>App: レスポンス
    App->>App: JSON.parse() 失敗
    App-->>Dev: エラー: Unexpected token

    Dev->>API: 再リクエスト
    API->>Model: 生成リクエスト
    Model->>API: トークン制限で途中切断
    API->>App: 不完全なJSON
    App->>App: JSON.parse() 失敗
    App-->>Dev: エラー: Unexpected end

このように、複数の要因でパースエラーが発生します。単純にスキーマを渡すだけでは不十分で、検証とリトライの仕組みが必要です。

解決策

これらの課題を解決するには、以下の 3 つのステップを実装します。

解決策 1: スキーマの明示的な提示

まず、JSON Schema を API に渡すだけでなく、プロンプト内にもスキーマを明記します。これによりモデルが期待される形式を理解しやすくなるのです。

基本的なスキーマ提示

typescriptimport OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://api.fireworks.ai/inference/v1',
  apiKey: process.env.FIREWORKS_API_KEY,
});

Fireworks AI を経由して gpt-oss にアクセスする設定です。baseURL を変更することで、他のプロバイダーでも利用できます。

typescriptconst schema = {
  type: 'json_schema',
  json_schema: {
    name: 'person_schema',
    schema: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        city: { type: 'string' },
        profession: { type: 'string' },
      },
      required: ['name', 'city', 'profession'],
    },
  },
};

JSON Schema を定義しています。required 配列で必須フィールドを明示することが重要です。

typescriptconst systemPrompt = `You are a helpful assistant designed to output JSON.
You MUST respond with valid JSON only, without any additional text or explanation.
The JSON must conform to this schema:
{
  "name": "string",
  "city": "string",
  "profession": "string"
}`;

システムプロンプトで「JSON のみを返す」ことを強調しています。スキーマも再度提示することで、モデルの理解を深めます。

typescriptconst completion = await client.chat.completions.create({
  model: 'accounts/fireworks/models/gpt-oss-20b',
  messages: [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      content: '東京在住のエンジニアについて教えてください',
    },
  ],
  response_format: schema,
});

response_format パラメータでスキーマを指定します。これにより、モデルは JSON 形式で出力しようと試みます。

typescriptconst content = completion.choices[0].message.content;
console.log(content);

取得した内容を表示します。この時点ではまだパースしていません。

この方法により、スキーマ違反のリスクが減少しますが、完全ではありません。次のステップで検証を行います。

解決策 2: Zod によるスキーマ検証とリトライ

次に、Zod を使って厳密な型チェックを行い、エラー時にはリトライする仕組みを構築します。

Zod のインストール

bashyarn add zod openai zod-to-json-schema

必要なパッケージをインストールします。zod-to-json-schema は Zod スキーマを JSON Schema に変換するライブラリです。

Zod スキーマの定義

typescriptimport { z } from 'zod';

const PersonSchema = z.object({
  name: z.string().describe('人物の名前'),
  city: z.string().describe('居住都市'),
  profession: z.string().describe('職業'),
});

type Person = z.infer<typeof PersonSchema>;

Zod でスキーマを定義します。describe() メソッドで各フィールドの説明を追加でき、これが JSON Schema に反映されます。

z.infer で TypeScript の型を自動生成できるため、型安全性が向上します。

JSON Schema への変換

typescriptimport { zodToJsonSchema } from 'zod-to-json-schema';

const jsonSchema = zodToJsonSchema(
  PersonSchema,
  'person_schema'
);

Zod スキーマを OpenAI API が理解できる JSON Schema 形式に変換します。

リトライ機能付き補完関数

typescriptasync function completionWithRetry(
  prompt: string,
  maxRetries: number = 3
): Promise<Person> {
  let lastError: Error | null = null;

  for (let i = 0; i < maxRetries; i++) {
    try {
      // API リクエストを実行
      const response = await client.chat.completions.create(
        {
          model: 'accounts/fireworks/models/gpt-oss-20b',
          messages: [
            { role: 'system', content: systemPrompt },
            { role: 'user', content: prompt },
          ],
          response_format: {
            type: 'json_schema',
            json_schema: jsonSchema,
          },
        }
      );

      return response;
    } catch (error) {
      lastError = error as Error;
      console.log(`試行 ${i + 1} 失敗: ${error.message}`);

      // エクスポネンシャルバックオフ
      await new Promise((resolve) =>
        setTimeout(resolve, Math.pow(2, i) * 1000)
      );
    }
  }

  throw lastError;
}

この関数はリトライロジックを実装しています。失敗時には指数バックオフで待機時間を増やし、API の負荷を分散させます。

次に、取得した JSON を検証する部分を実装します。

typescriptasync function getValidatedPerson(
  prompt: string
): Promise<Person> {
  const response = await completionWithRetry(prompt);
  const content = response.choices[0].message.content;

  // JSON パース
  let parsed: unknown;
  try {
    parsed = JSON.parse(content);
  } catch (error) {
    throw new Error(`JSON パースエラー: ${error.message}`);
  }

  // Zod で検証
  const result = PersonSchema.safeParse(parsed);

  if (!result.success) {
    throw new Error(
      `スキーマ検証エラー: ${JSON.stringify(
        result.error.issues
      )}`
    );
  }

  return result.data;
}

safeParse() を使うことで、検証失敗時に例外をスローせず、エラー情報を取得できます。これにより、詳細なエラーメッセージをログに記録できるのです。

使用例

typescripttry {
  const person = await getValidatedPerson(
    '大阪在住のデザイナーについて教えてください'
  );

  console.log(person.name);
  console.log(person.city);
  console.log(person.profession);
} catch (error) {
  console.error('エラー:', error.message);
}

この実装により、型安全な形でデータを取得できます。検証エラーが発生した場合は、エラー内容を確認してプロンプトやスキーマを調整しましょう。

解決策 3: 自動修復メカニズム

最後に、パースエラーや検証エラーが発生した際に、AI に自己修正を促す仕組みを実装します。

gpt-json ライブラリによる自動修復

gpt-jsonzod-gpt といったライブラリは、自動修復機能を提供しています。ここでは zod-gpt の仕組みを参考に、自前の修復ロジックを構築します。

typescriptasync function completionWithAutoRepair(
  prompt: string,
  maxAttempts: number = 3
): Promise<Person> {
  let messages: Array<{ role: string; content: string }> = [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: prompt },
  ];

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const response = await client.chat.completions.create({
      model: 'accounts/fireworks/models/gpt-oss-20b',
      messages,
      response_format: {
        type: 'json_schema',
        json_schema: jsonSchema,
      },
    });

    const content = response.choices[0].message.content;

    // 次のブロックに続く
    return await validateAndRepair(
      content,
      messages,
      attempt
    );
  }

  throw new Error('最大試行回数を超えました');
}

この関数は会話履歴を保持しながら、複数回の試行を行います。エラーが発生した場合は、その情報を次のリクエストに含めます。

typescriptasync function validateAndRepair(
  content: string,
  messages: Array<{ role: string; content: string }>,
  attempt: number
): Promise<Person> {
  // まず JSON として抽出を試みる
  const extracted = extractJSON(content);

  let parsed: unknown;
  try {
    parsed = JSON.parse(extracted);
  } catch (parseError) {
    // JSON パースエラー時の修復プロンプト
    const repairPrompt = `前回の出力が無効な JSON でした:
${content}

エラー: ${parseError.message}

有効な JSON のみを出力してください。`;

    messages.push(
      { role: 'assistant', content },
      { role: 'user', content: repairPrompt }
    );

    throw new Error('JSON パース失敗、修復を試行');
  }

  // 次のブロックに続く
  return await validateSchema(parsed, content, messages);
}

JSON の抽出とパース処理を行います。エラー時には、エラー内容を含めた修復プロンプトを生成します。

typescriptfunction extractJSON(text: string): string {
  // ``` で囲まれている場合
  const codeBlockMatch = text.match(
    /```(?:json)?\s*\n?([\s\S]*?)\n?```/
  );
  if (codeBlockMatch) {
    return codeBlockMatch[1].trim();
  }

  // { で始まる部分を抽出
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (jsonMatch) {
    return jsonMatch[0];
  }

  // そのまま返す
  return text.trim();
}

余計なテキストを除去し、JSON 部分だけを抽出します。コードブロックや説明文が混入している場合にも対応できます。

typescriptasync function validateSchema(
  parsed: unknown,
  content: string,
  messages: Array<{ role: string; content: string }>
): Promise<Person> {
  const result = PersonSchema.safeParse(parsed);

  if (!result.success) {
    // スキーマ検証エラー時の修復プロンプト
    const errors = result.error.issues
      .map(
        (issue) =>
          `- ${issue.path.join('.')}: ${issue.message}`
      )
      .join('\n');

    const repairPrompt = `前回の出力がスキーマに適合しませんでした:
${content}

エラー:
${errors}

以下のスキーマに完全に適合する JSON を出力してください:
${JSON.stringify(PersonSchema.shape, null, 2)}`;

    messages.push(
      { role: 'assistant', content },
      { role: 'user', content: repairPrompt }
    );

    throw new Error('スキーマ検証失敗、修復を試行');
  }

  return result.data;
}

スキーマ検証を行い、エラー時には具体的なエラー箇所を示して修正を促します。どのフィールドがどう間違っているかを明示することで、モデルが適切に修正しやすくなります。

Boolean 値の自動修正

gpt-oss は true の代わりに True を返すことがあります。これを自動で修正する関数を追加しましょう。

typescriptfunction fixBooleanValues(jsonString: string): string {
  return jsonString
    .replace(/:\s*True\b/g, ': true')
    .replace(/:\s*False\b/g, ': false');
}

正規表現で Python スタイルの Boolean 値を JavaScript 形式に変換します。

typescriptasync function validateAndRepair(
  content: string,
  messages: Array<{ role: string; content: string }>,
  attempt: number
): Promise<Person> {
  let extracted = extractJSON(content);

  // Boolean 値を修正
  extracted = fixBooleanValues(extracted);

  // 以降は前述の処理
  // ...
}

抽出後すぐに修正を適用することで、パースエラーを防ぎます。

トークン切断への対処

JSON が途中で切れた場合、閉じ括弧を補完します。

typescriptfunction repairTruncatedJSON(jsonString: string): string {
  let fixed = jsonString.trim();

  // { の数と } の数をカウント
  const openBraces = (fixed.match(/\{/g) || []).length;
  const closeBraces = (fixed.match(/\}/g) || []).length;

  // 不足分を補完
  const missingBraces = openBraces - closeBraces;
  if (missingBraces > 0) {
    fixed += '\n' + '}'.repeat(missingBraces);
  }

  // [ の数と ] の数をカウント
  const openBrackets = (fixed.match(/\[/g) || []).length;
  const closeBrackets = (fixed.match(/\]/g) || []).length;

  // 不足分を補完
  const missingBrackets = openBrackets - closeBrackets;
  if (missingBrackets > 0) {
    fixed += '\n' + ']'.repeat(missingBrackets);
  }

  return fixed;
}

括弧の数を数えて、不足分を追加します。完璧ではありませんが、多くのケースで有効です。

typescriptasync function validateAndRepair(
  content: string,
  messages: Array<{ role: string; content: string }>,
  attempt: number
): Promise<Person> {
  let extracted = extractJSON(content);
  extracted = fixBooleanValues(extracted);
  extracted = repairTruncatedJSON(extracted);

  // 以降は前述の処理
  // ...
}

複数の修復処理を組み合わせることで、さまざまなエラーに対応できます。

以下の図は、自動修復のフローを示したものです。

mermaidflowchart TD
  start["API レスポンス取得"] --> extract["JSON 抽出"]
  extract --> fixBool["Boolean 値修正"]
  fixBool --> fixTrunc["切断修復"]
  fixTrunc --> parse["JSON.parse()"]
  parse --> validate["Zod 検証"]

  parse -->|パース失敗| repair1["修復プロンプト生成"]
  validate -->|検証失敗| repair2["修復プロンプト生成"]

  repair1 --> retry["再試行"]
  repair2 --> retry

  validate -->|成功| done["データ返却"]

  retry --> start

  style parse fill:#ffffcc
  style validate fill:#ffffcc
  style repair1 fill:#ffcccc
  style repair2 fill:#ffcccc
  style done fill:#ccffcc

この図が示すように、エラー時には具体的な修復プロンプトを生成し、再試行することで成功率を高めます。

具体例

実際のアプリケーションでこれらの技術を組み合わせた例を見ていきましょう。

具体例 1: ユーザープロフィール抽出

SNS の投稿文からユーザー情報を抽出するケースです。

スキーマ定義

typescriptimport { z } from 'zod';

const UserProfileSchema = z.object({
  name: z.string().describe('ユーザー名'),
  age: z.number().min(0).max(150).describe('年齢'),
  location: z.string().describe('居住地'),
  interests: z.array(z.string()).describe('興味・関心'),
  isActive: z.boolean().describe('アクティブかどうか'),
});

type UserProfile = z.infer<typeof UserProfileSchema>;

配列やブール値を含む、やや複雑なスキーマです。min()max() でバリデーションルールも定義しています。

抽出関数

typescriptasync function extractUserProfile(
  text: string
): Promise<UserProfile> {
  const prompt = `以下のテキストからユーザープロフィールを抽出してください:

${text}

JSON 形式で出力してください。`;

  return await completionWithAutoRepair(prompt);
}

先ほど実装した completionWithAutoRepair を使うことで、自動修復機能が有効になります。

使用例

typescriptconst post = `
こんにちは!東京在住の田中太郎です。
28歳のエンジニアで、最近は機械学習とWeb開発に興味があります。
毎日GitHubにコミットしているアクティブユーザーです。
`;

try {
  const profile = await extractUserProfile(post);

  console.log('名前:', profile.name);
  console.log('年齢:', profile.age);
  console.log('居住地:', profile.location);
  console.log('興味:', profile.interests.join(', '));
  console.log('アクティブ:', profile.isActive);
} catch (error) {
  console.error('抽出失敗:', error.message);
}

期待される出力は以下のとおりです。

text名前: 田中太郎
年齢: 28
居住地: 東京
興味: 機械学習, Web開発
アクティブ: true

型安全に各フィールドにアクセスできます。

具体例 2: エラー情報の構造化

ログメッセージからエラー情報を構造化するケースです。

スキーマ定義

typescriptconst ErrorInfoSchema = z.object({
  errorCode: z.string().describe('エラーコード'),
  errorMessage: z.string().describe('エラーメッセージ'),
  severity: z
    .enum(['low', 'medium', 'high', 'critical'])
    .describe('深刻度'),
  affectedComponent: z
    .string()
    .describe('影響を受けたコンポーネント'),
  possibleCauses: z
    .array(z.string())
    .describe('考えられる原因'),
  suggestedFixes: z
    .array(z.string())
    .describe('推奨される修正方法'),
});

type ErrorInfo = z.infer<typeof ErrorInfoSchema>;

z.enum() で固定値の選択肢を定義しています。これにより、型安全性がさらに向上します。

抽出関数

typescriptasync function analyzeError(
  logMessage: string
): Promise<ErrorInfo> {
  const prompt = `以下のエラーログを分析し、構造化された情報を抽出してください:

${logMessage}

深刻度は low, medium, high, critical のいずれかを選択してください。
JSON 形式で出力してください。`;

  return await completionWithAutoRepair(prompt);
}

プロンプト内で enum の選択肢を明示することで、モデルが適切な値を返しやすくなります。

使用例

typescriptconst log = `
[ERROR] 2025-01-15 10:23:45
Database connection failed: ECONNREFUSED
Component: UserService
Stack trace:
  at Connection.connect (/app/db/connection.js:42)
  at UserService.fetchUser (/app/services/user.js:18)
`;

try {
  const errorInfo = await analyzeError(log);

  console.log('エラーコード:', errorInfo.errorCode);
  console.log('メッセージ:', errorInfo.errorMessage);
  console.log('深刻度:', errorInfo.severity);
  console.log(
    'コンポーネント:',
    errorInfo.affectedComponent
  );
  console.log('\n考えられる原因:');
  errorInfo.possibleCauses.forEach((cause, i) => {
    console.log(`  ${i + 1}. ${cause}`);
  });
  console.log('\n推奨される修正:');
  errorInfo.suggestedFixes.forEach((fix, i) => {
    console.log(`  ${i + 1}. ${fix}`);
  });
} catch (error) {
  console.error('分析失敗:', error.message);
}

期待される出力例です。

textエラーコード: ECONNREFUSED
メッセージ: Database connection failed
深刻度: high
コンポーネント: UserService

考えられる原因:
  1. データベースサーバーが起動していない
  2. ネットワーク接続の問題
  3. 接続設定の誤り

推奨される修正:
  1. データベースサーバーの起動を確認
  2. 接続文字列の設定を確認
  3. ファイアウォール設定を確認

このように、非構造化されたログから有用な情報を抽出できます。

具体例 3: 複数オブジェクトのバッチ処理

複数のアイテムを一度に処理する場合の実装です。

スキーマ定義

typescriptconst ActionItemSchema = z.object({
  id: z.number().describe('アイテムID'),
  description: z.string().describe('説明'),
  dueDate: z
    .string()
    .nullable()
    .describe('期限(YYYY-MM-DD形式)'),
  owner: z.string().nullable().describe('担当者'),
  priority: z
    .enum(['low', 'medium', 'high'])
    .describe('優先度'),
});

const ActionItemsSchema = z.object({
  items: z
    .array(ActionItemSchema)
    .describe('アクションアイテムのリスト'),
});

type ActionItems = z.infer<typeof ActionItemsSchema>;

ネストした構造のスキーマです。配列の中に複数のオブジェクトが含まれます。

抽出関数

typescriptasync function extractActionItems(
  text: string
): Promise<ActionItems> {
  const prompt = `以下の会議メモからアクションアイテムを抽出してください:

${text}

各アイテムには ID、説明、期限、担当者、優先度を含めてください。
期限が不明な場合は null を使用してください。
JSON 形式で出力してください。`;

  return await completionWithAutoRepair(prompt);
}

null の扱いをプロンプトで明示しています。

使用例

typescriptconst meetingNotes = `
今日の会議で決まったこと:
- 来週までにログイン機能を実装する(担当: 田中)【高優先度】
- デザインのレビューを今月中に完了(担当: 佐藤)【中優先度】
- パフォーマンステストを実施(期限未定)【低優先度】
`;

try {
  const actionItems = await extractActionItems(
    meetingNotes
  );

  console.log(
    `\n抽出されたアクションアイテム: ${actionItems.items.length}件\n`
  );

  actionItems.items.forEach((item) => {
    console.log(`ID: ${item.id}`);
    console.log(`説明: ${item.description}`);
    console.log(`期限: ${item.dueDate || '未定'}`);
    console.log(`担当: ${item.owner || '未割当'}`);
    console.log(`優先度: ${item.priority}`);
    console.log('---');
  });
} catch (error) {
  console.error('抽出失敗:', error.message);
}

期待される出力例です。

text抽出されたアクションアイテム: 3件

ID: 1
説明: ログイン機能を実装する
期限: 2025-01-22
担当: 田中
優先度: high
---
ID: 2
説明: デザインのレビューを完了
期限: 2025-01-31
担当: 佐藤
優先度: medium
---
ID: 3
説明: パフォーマンステストを実施
期限: 未定
担当: 未割当
優先度: low
---

配列を含む複雑なスキーマでも、適切に抽出できます。

具体例 4: エラーハンドリングとログ記録

本番環境で使う際の堅牢なエラーハンドリング例です。

typescriptimport * as fs from 'fs/promises';

interface CompletionLog {
  timestamp: string;
  prompt: string;
  response: string;
  attempts: number;
  success: boolean;
  error?: string;
}

ログの型定義です。デバッグやモニタリングに活用できます。

typescriptasync function logCompletion(
  log: CompletionLog
): Promise<void> {
  const logEntry = JSON.stringify(log) + '\n';
  await fs.appendFile('completions.log', logEntry);
}

NDJSON 形式でログファイルに追記します。

typescriptasync function safeCompletion<T>(
  prompt: string,
  schema: z.ZodType<T>,
  options: {
    maxRetries?: number;
    onError?: (error: Error, attempt: number) => void;
  } = {}
): Promise<T> {
  const startTime = Date.now();
  let lastError: Error | null = null;
  let attempts = 0;

  try {
    const result = await completionWithAutoRepair(
      prompt,
      options.maxRetries
    );

    await logCompletion({
      timestamp: new Date().toISOString(),
      prompt,
      response: JSON.stringify(result),
      attempts: attempts + 1,
      success: true,
    });

    return result;
  } catch (error) {
    lastError = error as Error;

    if (options.onError) {
      options.onError(error as Error, attempts);
    }

    await logCompletion({
      timestamp: new Date().toISOString(),
      prompt,
      response: '',
      attempts: attempts + 1,
      success: false,
      error: error.message,
    });

    throw error;
  }
}

成功・失敗を問わずログを記録し、エラー時にはコールバックを実行します。

typescript// 使用例
try {
  const profile = await safeCompletion(
    '東京在住のエンジニアについて',
    UserProfileSchema,
    {
      maxRetries: 5,
      onError: (error, attempt) => {
        console.warn(
          `試行 ${attempt} 失敗: ${error.message}`
        );
        // アラート送信やメトリクス記録など
      },
    }
  );

  console.log('成功:', profile);
} catch (error) {
  console.error('最終的に失敗:', error.message);
  // フォールバック処理
}

このように、本番環境では詳細なログとエラーハンドリングが不可欠です。

以下の図は、エラーハンドリングとログ記録のフローを示したものです。

mermaidflowchart TD
  start["補完リクエスト開始"] --> attempt["API 呼び出し"]
  attempt --> success{成功?}

  success -->|Yes| log_success["成功ログ記録"]
  log_success --> return["データ返却"]

  success -->|No| callback["エラーコールバック実行"]
  callback --> log_error["エラーログ記録"]
  log_error --> check{リトライ可能?}

  check -->|Yes| wait["バックオフ待機"]
  wait --> attempt

  check -->|No| throw["例外スロー"]

  style success fill:#ffffcc
  style log_success fill:#ccffcc
  style log_error fill:#ffcccc
  style return fill:#ccffcc
  style throw fill:#ffcccc

この図が示すように、各ステップで適切なログを記録し、リトライ可能な場合は再試行、不可能な場合は例外をスローします。

まとめ

gpt-oss で JSON 構造化出力を安定させるには、以下の 3 つの柱が重要です。

1. スキーマの明示的な提示

  • response_format パラメータで JSON Schema を指定
  • システムプロンプト内にもスキーマを明記
  • プロンプトで「JSON のみを返す」ことを強調

2. Zod によるスキーマ検証とリトライ

  • Zod で型安全なスキーマを定義
  • safeParse() で検証エラーを取得
  • エクスポネンシャルバックオフでリトライ
  • 会話履歴を保持して段階的に修正

3. 自動修復メカニズム

  • JSON 部分の抽出(コードブロックや説明文の除去)
  • Boolean 値の自動修正(Truetrue
  • トークン切断時の括弧補完
  • 検証エラー時の修復プロンプト生成

これらを組み合わせることで、gpt-oss の構造化出力の成功率を大幅に向上させられます。特に本番環境では、詳細なログ記録とエラーハンドリングを実装することが不可欠です。

gpt-oss はまだ発展途上のモデルで、Ollama や vllm などのランタイムでの完全な Structured Outputs サポートは今後の課題となっています。しかし、本記事で紹介した技術を活用すれば、現時点でも十分実用的なシステムを構築できるでしょう。

型安全で信頼性の高い AI アプリケーション開発に、ぜひこれらのテクニックを活用してください。

関連リンク