📱

SwiftでOpenAIのAPIのStreaming Responseを外部ライブラリなしでハンドリングする

に公開

ChatUI

はじめに

OpenAIのChat Completions APIは、レスポンスをリアルタイムに受け取れるストリーミング形式に対応しています。これにより、応答が生成される様子を逐次ユーザーに表示でき、よりインタラクティブなチャット体験を提供できます。iOSアプリケーションでこのストリーミングレスポンスを利用する場合、外部ライブラリを利用する選択肢もありますが、Swiftの非同期処理機能(Async/Await)とURLSessionを活用することで、外部ライブラリに依存せずとも実装が可能です。

本記事では、iOS 16以降を対象とし、SwiftUIを用いたチャットUIを構築しながら、OpenAI APIのストリームレスポンスを外部ライブラリなしで実装する方法を解説します。

実装の全体像

今回実装するチャット機能は、以下の主要な要素で構成されます。

  1. APIリクエスト/レスポンスモデル: OpenAI APIとの通信に必要なデータの構造を定義します。
  2. APIClient: APIへのリクエスト送信と、ストリーミングレスポンスの受信・パースを行います。
  3. ViewModel: UIの状態管理、APIクライアントとの連携、ストリームデータの処理を行い、Viewに表示するデータを準備します。
  4. View (SwiftUI): ViewModelのデータを監視してユーザーインターフェースを表示し、ユーザー操作をViewModelに伝えます。

1. APIリクエスト/レスポンスモデル

OpenAI APIとの通信に使用するデータモデルをCodableに準拠したstructとして定義します。これらのモデルは、リクエストの送信 (Request, Message, Role) と、ストリーミングレスポンスの受信 (CompletionChunkResponse, Choice, Delta) に利用されます。

特にストリーミングレスポンスでは、通常のレスポンスとは異なる形式でデータが送られてきます。各データチャンクはCompletionChunkResponse構造体としてデコードされ、その中のchoices配列の最初の要素にあるdeltaプロパティに含まれるcontentに、レスポンスの断片(テキストチャンク)が含まれています。

import Foundation

// リクエストモデル
struct Request: Codable {
    var model: String = "gpt-4.1-nano"
    var stream: Bool = true
    var messages: [Message]
}

struct Message: Codable {
    var role: Role
    var content: String
}

enum Role: String, Codable {
    case user
    case assistant
    case system
}

// レスポンスモデル
struct CompletionChunkResponse: Codable {
    var id: String
    var object: String
    var model: String
    var choices: [Choice]
}

struct Choice: Codable {
    var delta: Delta?
}

struct Delta: Codable {
    var content: String
}

これらの構造体定義は、APIとのデータ交換をSwiftの型安全な方法で行うための基盤となります。

2. APIクライアントの実装

APIClientクラスは、URLSessionを使用してOpenAI APIと通信し、ストリーミングデータを受信・パースする役割を担います。

非同期ストリームの取得:

iOS 15以降で利用可能なURLSession.shared.bytes(for: request)メソッドを使うことで、HTTPレスポンスボディを非同期バイトシーケンス(URLSession.AsyncBytes)として取得できます。この非同期シーケンスをfor awaitループで処理することで、ストリーミングデータを逐次処理することが可能になります。

sendStreamingRequestメソッドでは、このURLSession.AsyncBytesを取得し、それを処理するparseStreamingResponseメソッドに渡します。このメソッドは、パースされたテキストチャンクを非同期ストリームとして提供するためにAsyncThrowingStreamを返します。

import Foundation

// APIクライアント
class APIClient {
    // ストリーミングリクエスト
    func sendStreamingRequest(_ messages: [Message]) async throws -> AsyncThrowingStream<String, Error> {
        
        // リクエストの作成
        let url = URL(string: "https://api.openai.com/v1/chat/completions")!
        let token = "<YOUR_API_KEY>" // APIキーを設定
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = [
            "Content-Type": "application/json",
            "Authorization": "Bearer \(token)",
            "Accept": "*/*"
        ]
        
        let requestBody = Request(messages: messages)
        request.httpBody = try JSONEncoder().encode(requestBody)
        
        // ストリーミングレスポンスを処理
        return AsyncThrowingStream { continuation in
            let task = Task {
                do {
                    let (asyncBytes, _) = try await URLSession.shared.bytes(for: request)
                    
                    // ストリーミングレスポンスをパース
                    try await parseStreamingResponse(asyncBytes, continuation: continuation)
                } catch {
                    continuation.finish()
                }
            }
            
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }
    
    // ストリーミングレスポンスをパース
    private func parseStreamingResponse(_ asyncBytes: URLSession.AsyncBytes, continuation: AsyncThrowingStream<String, Error>.Continuation) async throws {
        var buffer = ""
        
        for try await line in asyncBytes.lines {
            if line.isEmpty { continue }
            
            // バッファにデータを追加
            let jsonStrings = "\(buffer)\(line)"
                .trimmingCharacters(in: .whitespacesAndNewlines)
                .components(separatedBy: "data:")
                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
                .filter { !$0.isEmpty }
            
            buffer = ""
            
            // JSON文字列を処理
            for (_, jsonString) in jsonStrings.enumerated() {
                if jsonString.isEmpty { continue }
                if jsonString == "[DONE]" {
                    continuation.finish()
                    return
                }
                
                guard let jsonData = jsonString.data(using: .utf8) else { continue }
                
                let chunk = try JSONDecoder().decode(CompletionChunkResponse.self, from: jsonData)
                continuation.yield(chunk.choices[0].delta?.content ?? "")
            }
        }
        continuation.finish()
    }
}

parseStreamingResponseメソッドの実装が、外部ライブラリを使わないパース処理の肝となります。受信したデータを一行ずつ処理し、data:プレフィックスを持つ行からJSONデータを抽出します。[DONE]という特別な行が現れたら、ストリームの終了と判断します。抽出したJSONデータはJSONDecoderでデコードし、レスポンスの断片(delta?.content)を取り出して、continuation.yieldを使ってAsyncThrowingStreamのサブスクライバー(ViewModel)に渡しています。バッファリングの処理は、data:で区切られたデータが複数行に分割されて届く可能性に対応するためです。

3. ViewModelの実装

ViewModelは、SwiftUIのObservableObjectとして定義され、チャットのメッセージリストやストリーミングの状態 (isStreaming, currentResponse) を管理します。ユーザーからの入力やUIイベントを受け取り、APIClientを呼び出してレスポンスを取得し、その結果をUIに反映させます。

メッセージ送信とストリーム処理:

ユーザーがメッセージを送信すると、sendMessageメソッドが呼ばれます。このメソッドは、既存のタスクをキャンセルし、新しいユーザーメッセージを内部のメッセージリストに追加します。その後、非同期タスク内でAPIClientsendStreamingRequestメソッドを呼び出し、返されるストリームを処理します。

import SwiftUI

struct ChatMessage: Identifiable {
    let id = UUID()
    let content: String
    let role: Role
}

class ViewModel: ObservableObject {
    @Published var isStreaming: Bool = false
    @Published var messages: [ChatMessage] = []
    @Published var currentResponse: String = ""
    
    private let apiClient: APIClient
    private var currentTask: Task<Void, Never>?
    
    init(_ apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    // メッセージをクリア
    func clearMessages() {
        cancelCurrentTask()
        messages.removeAll()
        currentResponse = ""
    }
    
    // taskをキャンセル
    func cancelCurrentTask() {
        currentTask?.cancel()
        currentTask = nil
        
        // Save partial response if streaming was in progress
        if isStreaming && !currentResponse.isEmpty {
            finishResponse()
        }
        
        isStreaming = false
    }
    
    // メッセージを送信
    func sendMessage(content: String) {
        // taskをキャンセル
        cancelCurrentTask()
        
        // メッセージをUI向けのメッセージリストに追加
        messages.append(ChatMessage(content: content, role: .user))
        
        // API向けのメッセージリストを作成
        let apiMessages = messages.map { Message(role: $0.role, content: $0.content) }
        
        // リクエストを送信
        isStreaming = true
        currentResponse = ""
        
        currentTask = Task {
            do {
                // ストリーミングレスポンスを処理
                try await handleStreamingResponse(apiMessages)
                
                // レスポンスの処理を終了
                if !Task.isCancelled {
                    await MainActor.run { finishResponse() }
                }
            } catch {
                if !Task.isCancelled {
                    await MainActor.run { isStreaming = false }
                }
            }
        }
    }
    
    // ストリーミングレスポンスを処理
    private func handleStreamingResponse(_ messages: [Message]) async throws {
        let stream = try await apiClient.sendStreamingRequest(messages)
        
        for try await chunk in stream {
            if Task.isCancelled { break }
            await MainActor.run { currentResponse += chunk }
        }
    }
    
    // レスポンスの処理を終了
    private func finishResponse() {
        isStreaming = false
        
        if !currentResponse.isEmpty {
            messages.append(ChatMessage(content: currentResponse, role: .assistant))
            currentResponse = ""
        }
    }
}

handleStreamingResponseメソッド内では、APIClientから受け取ったAsyncThrowingStreamfor try await chunk in streamというループで処理しています。これにより、新しいテキストチャンクがストリームから届くたびにループの本体が実行されます。取得したチャンク (chunk) は、currentResponseプロパティに追加され、この更新が@Publishedを介してViewに通知されます。UIの更新はメインスレッドで行う必要があるため、await MainActor.run { ... }の中で実行しています。

ストリームの処理が終了したとき(自動終了またはキャンセル)、finishResponseメソッドが呼ばれます。これにより、currentResponseに蓄積されたテキストが確定したアシスタントメッセージとしてmessagesリストに追加され、currentResponseはクリアされます。

cancelCurrentTaskメソッドは、ユーザーがチャットの生成中に中断したい場合などに呼ばれ、実行中の非同期タスクをキャンセルします。キャンセル時には、その時点までに生成されていた部分的なレスポンスを確定メッセージとして保存する処理も含まれています。

4. View (SwiftUI) の実装

ContentViewは、SwiftUIでチャット画面のユーザーインターフェースを構築します。@StateObject属性を使用してViewModelのインスタンスを保持し、ViewModelの@Publishedプロパティ(messages, isStreaming, currentResponse)の状態変化を監視し、UIを自動的に更新します。

UIコンポーネントとデータバインディング:

UIは、新しいチャットを開始するボタン、メッセージリストを表示するスクロールビュー、メッセージ入力フィールド、そして送信/停止ボタンで構成されます。

import SwiftUI

struct ContentView: View {
    @State private var prompt: String = ""
    @StateObject private var viewModel = ViewModel(APIClient())
    
    var body: some View {
        VStack(spacing: 0) {
            // ヘッダー
            HStack {
                Button(action: {
                    viewModel.clearMessages()
                }) {
                    HStack(spacing: 8) {
                        Image(systemName: "square.and.pencil")
                        
                        Text("新しいチャット")
                    }
                }
                
                Spacer()
            }
            .padding()
            
            // メッセージ
            ScrollViewReader { scrollView in
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 8) {
                        ForEach(viewModel.messages) { message in
                            MessageBubble(message: message)
                        }
                        
                        // Show streaming message if active
                        if viewModel.isStreaming {
                            MessageBubble(message: ChatMessage(content: viewModel.currentResponse, role: .assistant))
                            
                        }
                    }
                    .padding()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            
            // 入力フィールドと送信ボタン
            HStack(spacing: 8) {
                TextField("質問してみましょう", text: $prompt)
                
                Spacer()
                
                Button(action: {
                    if viewModel.isStreaming {
                        viewModel.cancelCurrentTask()
                    } else {
                        sendMessageWithText(prompt)
                    }
                }) {
                    Image(systemName: viewModel.isStreaming ? "stop.circle.fill" : "arrow.up.circle.fill")
                        .resizable()
                        .frame(width: 32, height: 32)
                        .opacity((!viewModel.isStreaming && prompt.isEmpty) ? 0.5 : 1)
                }
                .disabled(!viewModel.isStreaming && prompt.isEmpty)
            }
            .padding()
        }
    }
    
    // メッセージ送信
    private func sendMessageWithText(_ text: String) {
        let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedText.isEmpty && !viewModel.isStreaming else { return }
        
        self.prompt = ""
        viewModel.sendMessage(content: trimmedText)
    }
}

// メッセージバブル
private struct MessageBubble: View {
    let message: ChatMessage
    
    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            switch message.role {
            case .user:
                Spacer()
                
                Text(message.content)
                    .padding()
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(alignment: .trailing)
                    .background(Color(.systemGray6))
                    .cornerRadius(9999)
            case .assistant, .system:
                Image(systemName: "sparkles")
                
                Text(message.content)
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(alignment: .leading)
            }
        }
    }
}

メッセージリストを表示するScrollView内では、viewModel.messagesに含まれる確定済みのメッセージだけでなく、viewModel.isStreamingがtrueの場合にはviewModel.currentResponse(ストリーミング中の未確定レスポンス)も表示しています。これにより、APIから新しいチャンクが届くたびにcurrentResponseが更新され、画面上のアシスタントメッセージがリアルタイムに追記されていくように見えます。

送信ボタンの表示 (Image(systemName:)) やアクション (Button(action:)) は、viewModel.isStreamingの状態によって動的に切り替わります。ストリーミング中は停止ボタンとして機能し、それ以外は送信ボタンとして機能します。また、入力フィールドが空でなく、かつストリーミング中でない場合のみボタンが有効になるように制御しています。

MessageBubbleというViewは、ユーザーメッセージとアシスタントメッセージの見た目を整形するために使われます。ChatMessageモデルのroleプロパティに基づいて、メッセージの配置や背景色などを変えています。

まとめ

本記事では、OpenAI APIのストリームレスポンスを、外部ライブラリに依存せずSwiftの標準機能とSwiftUIを活用して実装する方法を紹介しました。

主なポイント:

  • URLSession.shared.bytes(for:)URLSession.AsyncBytesを使用して、HTTPレスポンスを非同期ストリームとして取得。
  • AsyncThrowingStreamを活用し、カスタムパース処理の結果を非同期シーケンスとして提供。
  • data: プレフィックスを持つOpenAIストリーミングデータ形式を、components(separatedBy: "data:")などを用いて手動でパース。
  • ViewModelでAsyncThrowingStreamfor try awaitループで購読し、テキストチャンクをリアルタイムに処理。
  • @Publishedプロパティ (currentResponse, isStreaming) を介してViewにストリーミング状態と部分的なレスポンスをリアルタイムに反映。
  • Async/AwaitのTaskcancel()を使用して、非同期処理の管理と中断を実現。

このアプローチは、アプリケーションの依存関係をシンプルに保ちつつ、OpenAI APIのストリーミング機能を効果的に活用できます。特にiOS 15以降で導入されたURLSession.AsyncBytesは、ネットワークストリーム処理において強力な機能を提供します。今回はiOS 16以降を前提とすることで、Async/AwaitやSwiftUIとの連携もスムーズに行えるコードとなっています。

ご自身のiOSプロジェクトでOpenAI APIなどのストリーミング機能を実装する際の参考にしていただければ幸いです。

参考

https://blog.stackademic.com/swift-streaming-openai-api-response-chunked-encoding-transfer-48b7f1785f5f
https://github.com/nate-parrott/openai-streaming-completions-swift
https://platform.openai.com/docs/guides/streaming-responses?api-mode=responses

Discussion