SwiftでOpenAIのAPIのStreaming Responseを外部ライブラリなしでハンドリングする
はじめに
OpenAIのChat Completions APIは、レスポンスをリアルタイムに受け取れるストリーミング形式に対応しています。これにより、応答が生成される様子を逐次ユーザーに表示でき、よりインタラクティブなチャット体験を提供できます。iOSアプリケーションでこのストリーミングレスポンスを利用する場合、外部ライブラリを利用する選択肢もありますが、Swiftの非同期処理機能(Async/Await)とURLSession
を活用することで、外部ライブラリに依存せずとも実装が可能です。
本記事では、iOS 16以降を対象とし、SwiftUIを用いたチャットUIを構築しながら、OpenAI APIのストリームレスポンスを外部ライブラリなしで実装する方法を解説します。
実装の全体像
今回実装するチャット機能は、以下の主要な要素で構成されます。
- APIリクエスト/レスポンスモデル: OpenAI APIとの通信に必要なデータの構造を定義します。
- APIClient: APIへのリクエスト送信と、ストリーミングレスポンスの受信・パースを行います。
- ViewModel: UIの状態管理、APIクライアントとの連携、ストリームデータの処理を行い、Viewに表示するデータを準備します。
- 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
メソッドが呼ばれます。このメソッドは、既存のタスクをキャンセルし、新しいユーザーメッセージを内部のメッセージリストに追加します。その後、非同期タスク内でAPIClient
のsendStreamingRequest
メソッドを呼び出し、返されるストリームを処理します。
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
から受け取ったAsyncThrowingStream
をfor 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で
AsyncThrowingStream
をfor try await
ループで購読し、テキストチャンクをリアルタイムに処理。 -
@Published
プロパティ (currentResponse
,isStreaming
) を介してViewにストリーミング状態と部分的なレスポンスをリアルタイムに反映。 - Async/Awaitの
Task
とcancel()
を使用して、非同期処理の管理と中断を実現。
このアプローチは、アプリケーションの依存関係をシンプルに保ちつつ、OpenAI APIのストリーミング機能を効果的に活用できます。特にiOS 15以降で導入されたURLSession.AsyncBytes
は、ネットワークストリーム処理において強力な機能を提供します。今回はiOS 16以降を前提とすることで、Async/AwaitやSwiftUIとの連携もスムーズに行えるコードとなっています。
ご自身のiOSプロジェクトでOpenAI APIなどのストリーミング機能を実装する際の参考にしていただければ幸いです。
参考
Discussion