SSEによるAI自動生成自己紹介入力処理を実装
SSE を用いた自動生成「自己紹介文」を Swift でストリーミング実装する
本記事では、SSE(Server-Sent Events)でサーバーから逐次送られてくるテキスト(例:LLMの生成トークン)を、Swift(iOS)側で受信して画面にリアルタイム表示する実装例を紹介します。
「自己紹介文を自動生成する」ようなユースケースでは、生成が完了するまで待つよりも、生成途中から文章が伸びていく体験のほうが分かりやすく快適です。そこで SSE によるストリーミングが役立ちます。
Server-Sent Events (SSE)とは?
SSEは、サーバーからアプリ(iOS/Androidなどのクライアント)へ、HTTP接続を開いたままにしてデータを一方向(サーバー → アプリ)でリアルタイム配信する仕組みです。
WebSocketのような双方向通信(アプリ → サーバーの同一コネクション上での送信)はできませんが、HTTPの仕組みだけで実現できるため実装が比較的シンプルなのが特徴です。
生成AIチャットアプリでは、AIの回答を“ストリーミング表示”(文字単位・文単位で順次表示)する用途でよく使われます。

想定する構成
- iOSアプリ(Swift / SwiftUI)
- サーバー:
text/event-streamで SSE を返すエンドポイント- 例:
POST /api/introduction/stream - リクエスト:プロフィール情報(名前、職種、スキル等)
- レスポンス:SSE で
data:行として生成途中のテキスト断片を送る
- 例:
APIレスポンス
サーバー側からはこのようなレスポンスが返ってくるとします。HTTP接続を開いたままにしているので、status でin_progress(いま生成途中か)/completed(完了したか)をクライアントに通知できます。
クライアントはcompletedを受け取った時点でストリーミング表示を止めます。
data: {"content":"","status":"in_progress","error":null}
data: {"content":"こんにちは","status":"in_progress","error":null}
data: {"content":"!","status":"in_progress","error":null}
data: {"content":"今日は","status":"in_progress","error":null}
data: {"content":"どんな","status":"in_progress","error":null}
data: {"content":"ことを","status":"in_progress","error":null}
data: {"content":"お","status":"in_progress","error":null}
data: {"content":"話","status":"in_progress","error":null}
data: {"content":"ししましょう","status":"in_progress","error":null}
data: {"content":"か","status":"in_progress","error":null}
data: {"content":"?","status":"in_progress","error":null}
data: {"content":"","status":"completed","error":null}
コード
上記のレスポンスを受け取るため、URLSessionの場合とAlamofireの場合のSSEのコードを以下に記します。
リクエスト部分(共通)
import Foundation
public struct SSERequest {
public var url: URL
public var method: String
public var headers: [String: String]
public var body: Data?
public init(
url: URL,
method: String = "GET",
headers: [String: String] = [:],
body: Data? = nil
) {
self.url = url
self.method = method
self.headers = headers
self.body = body
}
}
public struct SSEEvent {
public var id: String?
public var event: String?
public var data: String
public var retry: Int?
public init(id: String? = nil, event: String? = nil, data: String, retry: Int? = nil) {
self.id = id
self.event = event
self.data = data
self.retry = retry
}
}
public protocol SSETransport {
func events(for request: SSERequest) -> AsyncThrowingStream<SSEEvent, Error>
}
URLSessionの場合
URLSession Transport
import Foundation
public final class URLSessionSSETransport: SSETransport {
public enum SSEError: Error {
case invalidResponse
case badStatusCode(Int)
}
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func events(for request: SSERequest) -> AsyncThrowingStream<SSEEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
var req = URLRequest(url: request.url)
req.httpMethod = request.method
req.httpBody = request.body
// SSE推奨ヘッダ(呼び出し側指定を優先)
var headers = request.headers
headers["Accept"] = headers["Accept"] ?? "text/event-stream"
headers["Cache-Control"] = headers["Cache-Control"] ?? "no-cache"
headers.forEach { req.setValue($0.value, forHTTPHeaderField: $0.key) }
let (bytes, response) = try await session.bytes(for: req)
guard let http = response as? HTTPURLResponse else {
throw SSEError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw SSEError.badStatusCode(http.statusCode)
}
var buffer = ""
for try await rawLine in bytes.lines {
// CRLF(\r\n)対策:\r が残って空行判定を失敗するのを防ぐ
let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r"))
// SSEは空行でイベント確定
if line.isEmpty {
if let data = Self.extractData(from: buffer) {
continuation.yield(.init(data: data))
}
buffer = ""
continue
}
buffer += line + "\n"
}
// 最後が空行なしで切断された時の取りこぼし防止
if let data = Self.extractData(from: buffer) {
continuation.yield(.init(data: data))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
private static func extractData(from raw: String) -> String? {
let lines = raw.split(separator: "\n", omittingEmptySubsequences: true)
let dataLines = lines.compactMap { line -> String? in
guard line.hasPrefix("data:") else { return nil }
return line.dropFirst(5).trimmingCharacters(in: .whitespaces)
}
guard !dataLines.isEmpty else { return nil }
return dataLines.joined(separator: "\n")
}
}
使う時
let transport = URLSessionSSETransport()
IntroductionStreamView(transport: transport) {
let body = try? JSONSerialization.data(withJSONObject: [
"name": "山田太郎",
"role": "iOS Engineer",
"skills": ["Swift", "SwiftUI", "SSE"]
])
return SSERequest(
url: URL(string: "https://example.com/api/introduction/stream")!,
method: "POST",
headers: [
"Content-Type": "application/json",
"Accept": "text/event-stream"
],
body: body
)
}
Alamofireの場合
Alamofire版も、SSERequest から URLRequest を組み立てて httpBody を載せることで、冒頭の前提(プロフィール情報をPOSTする)と整合させます。
SSE Parser
SSEは空行(
\n\n/\r\n\r\n)でイベントが確定します。
そのためクライアント側では空行を境界としてdata:を取り出しています。
import Foundation
public final class SSEParser {
private var buffer = Data()
public init() {}
public func feed(_ chunk: Data) -> [SSEEvent] {
buffer.append(chunk)
let lf = Data("\n\n".utf8)
let crlf = Data("\r\n\r\n".utf8)
var events: [SSEEvent] = []
while true {
let r1 = buffer.range(of: lf)
let r2 = buffer.range(of: crlf)
// どちらか早く出現した方を採用
let range: Range<Data.Index>?
switch (r1, r2) {
case (nil, nil):
range = nil
case (let a?, nil):
range = a
case (nil, let b?):
range = b
case (let a?, let b?):
range = (a.lowerBound < b.lowerBound) ? a : b
}
guard let range else { break }
let blockData = buffer.subdata(in: 0..<range.lowerBound)
buffer.removeSubrange(0..<range.upperBound)
let block = String(decoding: blockData, as: UTF8.self)
.replacingOccurrences(of: "\r\n", with: "\n")
if let ev = parseEventBlock(block) {
events.append(ev)
}
}
return events
}
private func parseEventBlock(_ block: String) -> SSEEvent? {
let trimmed = block.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil } // keep-alive 等
var id: String?
var event: String?
var retry: Int?
var dataLines: [String] = []
for rawLine in block.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(rawLine)
if line.hasPrefix(":") { continue } // comment
let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
let field = String(parts[0])
var value = ""
if parts.count == 2 {
value = String(parts[1])
if value.hasPrefix(" ") { value.removeFirst() }
}
switch field {
case "id": id = value
case "event": event = value
case "retry": retry = Int(value)
case "data": dataLines.append(value)
default: break
}
}
let data = dataLines.joined(separator: "\n")
return SSEEvent(id: id, event: event, data: data, retry: retry)
}
}
Alamofire Transport
import Foundation
import Alamofire
public final class AlamofireSSETransport: SSETransport {
private let session: Session
// private let parser = SSEParser() // ★削除: parser を共有すると複数接続でバッファが混ざる
public init(session: Session = .default) {
self.session = session
}
public func events(for request: SSERequest) -> AsyncThrowingStream<SSEEvent, Error> {
AsyncThrowingStream { continuation in
let parser = SSEParser() // ★追加: 接続ごとに parser を作る(安全)
// URLRequestを組み立て(bodyも送れるようにする)
var urlRequest = URLRequest(url: request.url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
// SSE推奨ヘッダ(呼び出し側指定を優先)
var headers = request.headers
headers["Accept"] = headers["Accept"] ?? "text/event-stream"
headers["Cache-Control"] = headers["Cache-Control"] ?? "no-cache"
headers.forEach { urlRequest.setValue($0.value, forHTTPHeaderField: $0.key) }
let streamRequest = session.streamRequest(urlRequest)
.validate(statusCode: 200..<300)
streamRequest.responseStream { stream in // ★変更: self をキャプチャしない
switch stream.event {
case .stream(let result):
switch result {
case .success(let chunkData):
// ★変更: self.parser ではなく、接続専用 parser を使う
for ev in parser.feed(chunkData) {
continuation.yield(ev)
}
case .failure(let error):
continuation.finish(throwing: error)
}
case .complete(let completion):
if let error = completion.error {
continuation.finish(throwing: error)
} else {
continuation.finish()
}
}
}
continuation.onTermination = { _ in
streamRequest.cancel()
}
}
}
}
使う時
let transport = AlamofireSSETransport()
IntroductionStreamView(transport: transport) {
let body = try? JSONSerialization.data(withJSONObject: [
"name": "山田太郎",
"role": "iOS Engineer",
"skills": ["Swift", "SwiftUI", "SSE"]
])
return SSERequest(
url: URL(string: "https://example.com/api/introduction/stream")!,
method: "POST",
headers: [
"Content-Type": "application/json",
"Accept": "text/event-stream"
],
body: body
)
}
モデル(共通)
data: の中身(JSON)をデコードするためのモデルです。
import Foundation
public struct IntroductionChunk: Decodable {
public let content: String
public let status: Status
public let error: String?
public enum Status: String, Decodable {
case in_progress
case completed
}
}
ViewModel(共通)
Task が self を強参照し続けないように weak self を使い、連打時に古いTaskがUI状態を上書きしないよう streamID でガードします。
import Foundation
@MainActor
public final class IntroductionStreamViewModel: ObservableObject {
@Published public private(set) var text: String = ""
@Published public private(set) var isStreaming: Bool = false
@Published public private(set) var lastError: String?
private let transport: SSETransport
private var task: Task<Void, Never>?
private var streamID = UUID()
public init(transport: SSETransport) {
self.transport = transport
}
public func start(request: SSERequest) {
streamID = UUID()
let currentID = streamID
task?.cancel()
task = nil
text = ""
lastError = nil
isStreaming = true
task = Task { [weak self, transport] in
guard let self else { return }
defer {
if self.streamID == currentID {
self.isStreaming = false
}
}
do {
let decoder = JSONDecoder()
for try await ev in transport.events(for: request) {
guard self.streamID == currentID else { return }
let raw = ev.data.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { continue }
let chunk = try decoder.decode(IntroductionChunk.self, from: Data(raw.utf8))
if let error = chunk.error, !error.isEmpty {
self.lastError = error
break
}
self.text += chunk.content
if chunk.status == .completed {
break
}
}
} catch is CancellationError {
return
} catch {
guard self.streamID == currentID else { return }
self.lastError = String(describing: error)
}
}
}
public func stop() {
streamID = UUID()
task?.cancel()
task = nil
isStreaming = false
}
deinit {
task?.cancel()
}
}
UI部分(SwiftUI)(共通)
import SwiftUI
public struct IntroductionStreamView: View {
@StateObject private var vm: IntroductionStreamViewModel
private let makeRequest: () -> SSERequest
public init(
transport: SSETransport,
makeRequest: @escaping () -> SSERequest
) {
_vm = StateObject(wrappedValue: IntroductionStreamViewModel(transport: transport))
self.makeRequest = makeRequest
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if let err = vm.lastError {
Text(err)
.foregroundStyle(.red)
.font(.footnote)
}
ScrollView {
Text(vm.text.isEmpty ? "(ここにストリーミング表示)" : vm.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding()
}
private var header: some View {
HStack {
Button(vm.isStreaming ? "Stop" : "Generate") {
vm.isStreaming ? vm.stop() : vm.start(request: makeRequest())
}
.buttonStyle(.borderedProminent)
if vm.isStreaming { ProgressView() }
Spacer()
}
}
}
transport は画面の外側で組み立てて、画面に注入します。
struct IntroductionScreen: View {
// ここでURLSession版かAlamofire版を指定
private let transport: SSETransport = URLSessionSSETransport()
var body: some View {
IntroductionStreamView(
transport: transport,
makeRequest: {
let body = try? JSONSerialization.data(withJSONObject: [
"name": "山田太郎",
"role": "iOS Engineer",
"skills": ["Swift", "SwiftUI", "SSE"]
])
return SSERequest(
url: URL(string: "https://example.com/api/introduction/stream")!,
method: "POST",
headers: [
"Content-Type": "application/json",
"Accept": "text/event-stream"
],
body: body
)
}
)
}
}
まとめ
生成AIが当たり前になった今、ユーザーは「結果を待つ」よりも「生成が進んでいることが見える」体験を期待するようになりました。特に自己紹介文のような文章生成では、完成品だけでなく途中経過が見えることで安心感が増し、入力フォームの心理的ハードルも下がります。本記事のようにストリーミング表示を前提に設計しておくと、AI機能を“単発のAPI呼び出し”ではなく、プロダクト体験として自然に組み込めるようになります。
最後に
助太刀では一緒に開発してくれるメンバを募集してます!
少しでもご興味を持っていただけたら下記よりお気軽にご連絡ください!
Discussion