🐥

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