[OpenAI]structured outputについて調べてみた

## function callingとresponse_formatの違いは？

### function calling

モデルをアプリケーションの機能やツールに接続する際に使用します。例えば、データベースクエリや UI 操作などのアプリケーション機能をモデルから呼び出す場合に適しています。

### response_format

モデルがユーザーに直接応答する際の出力構造を指定するために使用します。例えば、数学の家庭教師アプリで、モデルの回答を特定の JSON 形式で構造化したい場合に適しています。

## 対応モデル

### Structured Outputs（対応モデル）
OpenAIの最新モデル（gpt-4o-mini-2024-07-18以降）で利用可能な機能です。指定したJSON Schemaに厳密に従った出力を生成します。response_formatパラメータでスキーマを指定することで、型安全性が保証され、必須キーの欠落や無効な列挙値の生成を防ぎます。これにより、出力の検証が簡素化され、より信頼性の高いAPIレスポンスが得られます。

### JSONモード（非対応モデルの代替策）
古いモデル（gpt-4-turbo以前）で使用できる機能です。response_format: { "type": "json_object" }を指定することで、有効なJSON形式の出力を生成します。ただし、厳密なスキーマ準拠は保証されません。システムメッセージやユーザーメッセージで出力構造を説明し、明示的にJSONフォーマットを指示する必要があります。出力の検証とエラー処理が重要となります。

## 実際に送ってみる

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
)

const openaiAPIEndpoint = "https://api.openai.com/v1/chat/completions"

type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type RequestBody struct {
	Model          string    `json:"model"`
	Messages       []Message `json:"messages"`
	ResponseFormat struct {
		Type       string          `json:"type"`
		JSONSchema json.RawMessage `json:"json_schema"`
	} `json:"response_format"`
}

type ResponseChoice struct {
	Message struct {
		Role    string          `json:"role"`
		Content json.RawMessage `json:"content"`
		Refusal *string         `json:"refusal,omitempty"`
	} `json:"message"`
	FinishReason string `json:"finish_reason"`
}

type APIResponse struct {
	Choices []ResponseChoice `json:"choices"`
}

type JSONSchemaWrapper struct {
	Name   string        `json:"name"`
	Schema WeatherSchema `json:"schema"`
}

type WeatherSchema struct {
	Type       string                    `json:"type"`
	Properties map[string]SchemaProperty `json:"properties"`
	Required   []string                  `json:"required"`
}

type SchemaProperty struct {
	Type string   `json:"type"`
	Enum []string `json:"enum,omitempty"`
}

func createWeatherSchema() JSONSchemaWrapper {
	return JSONSchemaWrapper{
		Name: "weather_response",
		Schema: WeatherSchema{
			Type: "object",
			Properties: map[string]SchemaProperty{
				"location":    {Type: "string"},
				"temperature": {Type: "number"},
				"unit": {
					Type: "string",
					Enum: []string{"C", "F"},
				},
				"conditions": {Type: "string"},
			},
			Required: []string{"location", "temperature", "unit", "conditions"},
		},
	}
}

func main() {
	apiKey := os.Getenv("OPENAI_API_KEY")
	if apiKey == "" {
		fmt.Println("Please set the OPENAI_API_KEY environment variable.")
		return
	}

	weatherSchema := createWeatherSchema()
	weatherSchemaJSON, err := json.Marshal(weatherSchema)
	if err != nil {
		fmt.Printf("Error marshalling weather schema: %v\n", err)
		return
	}

	reqBody := RequestBody{
		Model: "gpt-4o-2024-08-06",
		Messages: []Message{
			{
				Role:    "system",
				Content: "You are a helpful assistant designed to output JSON about weather conditions.",
			},
			{
				Role:    "user",
				Content: "What's the weather like in Tokyo today?",
			},
		},
		ResponseFormat: struct {
			Type       string          `json:"type"`
			JSONSchema json.RawMessage `json:"json_schema"`
		}{
			Type:       "json_schema",
			JSONSchema: weatherSchemaJSON,
		},
	}

	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		fmt.Printf("Error marshalling request: %v\n", err)
		return
	}

	req, err := http.NewRequest("POST", openaiAPIEndpoint, bytes.NewBuffer(jsonData))
	if err != nil {
		fmt.Printf("Error creating request: %v\n", err)
		return
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Printf("Error sending request: %v\n", err)
		return
	}
	defer resp.Body.Close()

	// Check the status code
	if resp.StatusCode != http.StatusOK {
		fmt.Printf("API returned non-200 status code: %d\n", resp.StatusCode)
		body, _ := ioutil.ReadAll(resp.Body)
		fmt.Printf("Response body: %s\n", string(body))
		return
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("Error reading response: %v\n", err)
		return
	}

	// Print the raw response for debugging
	fmt.Printf("Raw API response:\n%s\n", string(body))

	var apiResp APIResponse
	err = json.Unmarshal(body, &apiResp)
	if err != nil {
		fmt.Printf("Error parsing response: %v\n", err)
		return
	}

	if len(apiResp.Choices) > 0 {
		choice := apiResp.Choices[0]

		// Handle different finish reasons
		switch choice.FinishReason {
		case "stop":
			fmt.Println("Received structured output. Writing to file...")

			// The content is a JSON string, so we need to unmarshal it twice
			var jsonString string
			err := json.Unmarshal(choice.Message.Content, &jsonString)
			if err != nil {
				fmt.Printf("Error parsing JSON string: %v\n", err)
				return
			}

			var structuredOutput map[string]interface{}
			err = json.Unmarshal([]byte(jsonString), &structuredOutput)
			if err != nil {
				fmt.Printf("Error parsing structured output: %v\n", err)
				return
			}

			// Then, marshal it back to JSON with indentation
			prettyJSON, err := json.MarshalIndent(structuredOutput, "", "  ")
			if err != nil {
				fmt.Printf("Error formatting JSON: %v\n", err)
				return
			}

			// Write to file
			err = ioutil.WriteFile("structured_output.json", prettyJSON, 0644)
			if err != nil {
				fmt.Printf("Error writing to file: %v\n", err)
				return
			}
			fmt.Println("Structured output written to structured_output.json")
		case "length":
			fmt.Println("Warning: The response was truncated due to token limit.")
		case "content_filter":
			fmt.Println("Warning: The response was filtered due to content restrictions.")
		default:
			fmt.Printf("Unexpected finish reason: %s\n", choice.FinishReason)
		}

		// Handle refusals
		if choice.Message.Refusal != nil {
			fmt.Printf("The model refused to generate a response: %s\n", *choice.Message.Refusal)
		}
	} else {
		fmt.Println("No choices in the API response.")
	}
}

```