SwiftUI 实战:从零构建双引擎 LLM 聊天客户端(Ollama + DeepSeek)

一、项目背景

在日常开发中,我经常需要在本地运行 Ollama 跑开源模型做代码审查、技术问答,同时也依赖 DeepSeek 的云端能力处理复杂推理任务。来回切换终端和 Web 页面效率很低,于是决定用 SwiftUI 写一个 iOS 客户端,统一管理这两条链路。

这篇文章将围绕核心技术选型 → 架构设计 → 关键链路实现三个维度展开,完整复盘这个项目的设计思路与实现细节。


二、核心技术选型

维度 选型 选型理由
UI 框架 SwiftUI 声明式 UI,状态驱动,天然适配聊天应用的增量更新场景
架构模式 MVVM(无框架) 轻量、无第三方依赖,ObservableObject + @Published 足够覆盖状态管理
本地 LLM 接入 Ollama HTTP API + SSE 流式 Ollama 默认端口 11434,api/generate 端点原生支持 stream: true
远程 LLM 接入 DeepSeek Chat Completions API 支持 thinking + reasoning_effort 深度推理,按需切换 stream
网络层 URLSession.shared(原生) 零第三方网络库依赖;流式读取用 bytes(for:) AsyncSequence
敏感信息存储 Keychain(Security 框架) API Key 不能明文落盘,Keychain 是 iOS 唯一推荐方案
聊天历史持久化 UserDefaults + JSONEncoder 聊天数据量小(文本为主),不需要 CoreData/SQLite
联网增强 Open-Meteo + DuckDuckGo API 免费、无需 API Key,覆盖天气查询和通用搜索两大高频场景

核心取舍:

  • 不用 MVVM 框架(如 TCA/Combine):项目规模小,引入框架反而增加概念负担
  • 不用 CoreData :聊天记录是 [ChatSession] 数组,JSON 序列化 + UserDefaults 性能足够,且数据结构变更时不需要 Migration
  • 不用 AlamofireURLSession.shared.bytes(for:) 原生支持逐行流式读取,无需第三方

三、架构设计

3.1 整体分层

scss 复制代码
┌──────────────────────────────────────┐
│              App Entry                │
│         OllamaAppApp.swift            │
│         WindowGroup → ContentView     │
└────────────────┬─────────────────────┘
                 │
┌────────────────▼─────────────────────┐
│           View Layer                  │
│  ContentView.swift                    │
│  ├── ContentView       (主导航+历史)  │
│  ├── OllamaChatView    (Ollama 聊天)  │
│  ├── DeepSeekChatView  (DeepSeek聊天) │
│  ├── ChatMessageRow    (消息气泡)     │
│  └── DeepSeekSettingsView (Key 管理)  │
└────────────────┬─────────────────────┘
                 │ @ObservedObject
┌────────────────▼─────────────────────┐
│         ViewModel Layer               │
│  OllamaManager.swift                  │
│  @MainActor class OllamaManager       │
│  ├── @Published messages              │
│  ├── @Published isGenerating          │
│  ├── @Published chatHistory           │
│  ├── generate()                       │
│  ├── resend()                         │
│  ├── stopGenerating()                 │
│  └── saveHistory() / loadHistory()    │
└──────┬──────────────────┬────────────┘
       │                  │
┌──────▼──────┐   ┌───────▼──────────┐
│ Ollama API  │   │  DeepSeek API    │
│ 127.0.0.1   │   │  + KeychainStore │
│ :11434      │   │  + Open-Meteo    │
│ stream=true │   │  + DuckDuckGo    │
└─────────────┘   └──────────────────┘

3.2 数据模型设计

swift 复制代码
// 消息模型 --- 极简设计,只保留核心字段
struct ChatMessage: Identifiable, Equatable, Codable {
    var id = UUID()
    let role: MessageRole      // .user | .model
    var content: String        // 流式场景下可变,所以用 var

    enum MessageRole: String, Codable {
        case user, model
    }
}

// 会话模型 --- 以 id 为主键,支持增删改查
struct ChatSession: Identifiable, Codable, Equatable {
    var id: String             // UUID 字符串
    var messages: [ChatMessage]
    var title: String          // 取最后一条 user 消息的前 15 字
    var date: Date
}

// Provider 枚举 --- 策略模式的基础
enum LLMProvider: String, Codable, CaseIterable, Identifiable {
    case ollama
    case deepseek
    var id: String { rawValue }
}

设计要点:

  • ChatMessage.content 使用 var 而非 let,因为流式输出场景下需要增量拼接内容
  • ChatSession.id 使用 StringUUID().uuidString),避免 UUID 类型在某些 Codable 场景下的兼容问题
  • LLMProvider 同时遵循 CaseIterableIdentifiable,方便未来扩展更多 Provider

3.3 多 Provider 架构

项目的核心设计决策之一是 Ollama 和 DeepSeek 各自维护独立的状态实例

swift 复制代码
struct ContentView: View {
    // 两个独立的 StateObject,各自维护消息列表和历史
    @StateObject private var ollamaManager = OllamaManager(provider: .ollama)
    @StateObject private var deepSeekManager = OllamaManager(provider: .deepseek)
    // ...
}

为什么不用单例 + 切换模式?

  1. 状态隔离:用户在 Ollama 和 DeepSeek 之间切换时,各自的对话上下文不会丢失
  2. 并行对话:同时开启两个页面分别对话,互不干扰
  3. 独立持久化UserDefaults key 按 provider 区分(ChatHistory_ollama / ChatHistory_deepseek),历史记录天然隔离

每个 OllamaManager 实例内部通过 provider 属性在 generate() 方法中做策略分发:

swift 复制代码
func generate(prompt: String, model: String, webEnabled: Bool = false) async {
    // ...
    currentTask = Task {
        switch self.provider {
        case .ollama:
            try await self.generateWithOllama(prompt: prompt, model: model)
        case .deepseek:
            try await self.generateWithDeepSeek(model: model, webEnabled: webEnabled, query: prompt)
        }
    }
}

四、关键链路实现

4.1 流式输出(Ollama SSE 逐行解析)

Ollama 的 /api/generate 端点支持 stream: true,返回格式为 NDJSON(每行一个完整的 JSON 对象),每一帧包含 response 字段(增量文本)和 done 字段(是否结束):

json 复制代码
{"model":"phi3","created_at":"...","response":"Hello","done":false}
{"model":"phi3","created_at":"...","response":" World","done":false}
{"model":"phi3","created_at":"...","response":"!","done":true}

SwiftUI 下最优的流式消费方式是利用 URLSession.shared.bytes(for:) 返回的 AsyncSequence

swift 复制代码
private func generateWithOllama(prompt: String, model: String) async throws {
    let ollamaRequest = OllamaRequest(model: model, prompt: prompt, stream: true)
    // ...
    let (result, response) = try await URLSession.shared.bytes(for: request)

    // result 是 URLSession.AsyncBytes,遵循 AsyncSequence
    for try await line in result.lines {
        if Task.isCancelled { break }           // 响应取消
        if let data = line.data(using: .utf8),
           let ollamaResponse = try? JSONDecoder().decode(OllamaResponse.self, from: data) {
            // 直接拼接增量文本到 ViewModel,SwiftUI 自动触发 UI 更新
            self.messages[lastIndex].content += ollamaResponse.response
        }
    }
    self.isGenerating = false
    self.updateCurrentSession()
}

关键细节:

  • result.linesAsyncLineSequence,按 \n 分割,天然适配 NDJSON 格式
  • 因为 OllamaManager 标记了 @MainActor,对 @Published 属性的修改自动在主线程执行,UI 更新线程安全
  • Task.isCancelled 检查确保用户点击"停止"后立即中断流式读取

为什么用 127.0.0.1 而不是 localhost

swift 复制代码
// iOS 模拟器可能将 localhost 解析为 IPv6 (::1)
// 而 Ollama 默认只监听 IPv4,导致 connection refused
private let baseURL = "http://127.0.0.1:11434/api/generate"

4.2 Task 取消机制(停止生成)

得益于 Swift Concurrency 的协作式取消模型,停止生成实现非常简洁:

swift 复制代码
func stopGenerating() {
    currentTask?.cancel()    // 取消当前正在执行的 Task
    currentTask = nil
    isGenerating = false
    updateCurrentSession()   // 保存当前已生成的内容到历史
}

在流式循环中检查 Task.isCancelledbreak,然后在 generate() 方法的 catch 分支追加 [Stopped] 标记。整个链路无需信号量、锁、或手动管理线程。

4.3 重新发送(Resend)机制

重新发送的本质是截断对话上下文 + 复用原始 prompt 重新请求

swift 复制代码
func resend(messageId: UUID, model: String, webEnabled: Bool = false) async {
    guard !isGenerating else { return }
    guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }

    let trimmedPrompt = messages[index].content
    isGenerating = true

    // 核心:截断到该消息位置(包含该 user 消息),丢弃之后的对话
    messages = Array(messages.prefix(index + 1))

    // 追加新的空 model 消息作为生成容器
    messages.append(ChatMessage(role: .model, content: ""))

    // 重新走标准生成链路
    switch self.provider {
    case .ollama:
        try await self.generateWithOllama(prompt: trimmedPrompt, model: model)
    case .deepseek:
        try await self.generateWithDeepSeek(model: model, webEnabled: webEnabled, query: trimmedPrompt)
    }
}

设计要点 :截断后直接复用 generateWithOllama / generateWithDeepSeek,不做第二种代码路径,保证生成逻辑的一致性。

4.4 DeepSeek API Key 安全存储

API Key 存储在 Keychain 中,使用 Security 框架的原生 API 封装:

swift 复制代码
final class KeychainStore {
    static let shared = KeychainStore()

    func set(_ value: String, service: String, account: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,   // bundleIdentifier
            kSecAttrAccount as String: account    // "deepseek_api_key"
        ]
        SecItemDelete(query as CFDictionary)      // 先删后增,避免重复
        var attributes = query
        attributes[kSecValueData as String] = value.data(using: .utf8)!
        return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
    }
    // get() / delete() 类似...
}

安全考量:

  • service 使用 Bundle.main.bundleIdentifier,避免不同 App 之间的 Keychain 冲突
  • account 使用固定标识 "deepseek_api_key",方便后续扩展更多密钥类型
  • 设置时先 SecItemDeleteSecItemAdd,覆盖已有值而非报错

4.5 DeepSeek Base URL 容错与 Fallback

由于不同用户可能对接不同的 DeepSeek 兼容端点(官方 API、自建代理、第三方中转),Base URL 格式各异。实现了一套自动候选 URL 生成机制:

swift 复制代码
private func deepSeekChatCompletionsURLs(fromBase base: String) -> [URL] {
    var normalized = base
    while normalized.hasSuffix("/") { normalized.removeLast() }

    var bases: [String] = [normalized]
    // 自动补全 /v1 变体
    if normalized.hasSuffix("/v1") {
        bases.append(String(normalized.dropLast(3)))
    } else {
        bases.append(normalized + "/v1")
    }

    // 对每个 base 尝试 /chat/completions 和 /v1/chat/completions
    var urls: [URL] = []
    for candidateBase in bases {
        if let url = URL(string: candidateBase + "/chat/completions") { urls.append(url) }
        if let url = URL(string: candidateBase + "/v1/chat/completions") { urls.append(url) }
    }

    // 去重
    var seen = Set<String>()
    return urls.filter { seen.insert($0.absoluteString).inserted }
}

Fallback 逻辑:生成请求中按顺序尝试候选 URL,404 时自动 fallback 到下一个:

swift 复制代码
for (index, url) in urls.enumerated() {
    // ... 发请求 ...
    if httpResponse.statusCode == 200 { /* 成功,返回 */ }
    if httpResponse.statusCode == 404, index < urls.count - 1 {
        continue   // 404 且还有候选,继续尝试
    }
    break
}

4.6 联网增强:天气 + 搜索双通道

DeepSeek 聊天页的联网开关会触发两步增强流程:

Step 1 --- 意图识别:通过关键字匹配判断用户是否在查天气

swift 复制代码
private func extractLocationForWeather(query: String) -> String? {
    let triggers = ["天气", "气温", "温度", "体感", "湿度", "风速", "weather", "temperature"]
    guard triggers.contains(where: { lower.contains($0) }) else { return nil }
    // 提取触发词之前的地名部分(如 "北京天气" → "北京")
    // 清理掉"的"、"今天"、"查询"等噪音词
}

Step 2 --- 数据获取

  • 天气路径:Open-Meteo Geocoding API(地名 → 经纬度)→ Open-Meteo Forecast API(获取实时温度/湿度/风速/天气描述)
  • 搜索路径:DuckDuckGo Instant Answer API(获取 Abstract + 最多 5 条 RelatedTopics)

Step 3 --- 注入 System Prompt

swift 复制代码
if let weatherContext = try? await fetchWeatherContext(query: query) {
    history.append(.init(
        role: "system",
        content: "You have access to the following real-time data...\n\n\(weatherContext)"
    ))
}

联网结果同时展示给用户(消息内容前缀 【联网结果】),答案前缀 【回答】,透明度可控。

4.7 会话生命周期管理

scss 复制代码
startNewChat()          → messages = [], currentSessionId = nil
                         ↓
generate(prompt:)       → user msg + empty model msg
                         ↓ 流式输出中
isGenerating = true     → 输入框禁用,显示停止按钮
                         ↓ 流式结束 / stopGenerating()
updateCurrentSession()  → 新建或更新 ChatSession
                         ↓
saveHistory()           → JSONEncoder → UserDefaults
                         ↓
loadChat(session)       → 从 chatHistory 恢复 messages

会话标题策略 :取最后一条用户消息的前 15 个字符作为标题,超出加 ...

swift 复制代码
let title = messages.last(where: { $0.role == .user })?.content ?? "New Chat"
let shortTitle = String(title.prefix(15)) + (title.count > 15 ? "..." : "")

五、技术亮点总结

维度 实现要点
流式输出 URLSession.bytes(for:) + AsyncLineSequence,零第三方依赖
线程安全 @MainActor 保证 UI 更新在主线程,Task 协作式取消无需锁
多 Provider 策略模式 + 独立 StateObject 实例,状态隔离、互不干扰
敏感信息 Keychain 存储 API Key,UserDefaults 存储非敏感配置
容错设计 DeepSeek Base URL 自动候选 + 404 fallback;兼容旧版 UserDefaults key
联网增强 Open-Meteo 天气 + DuckDuckGo 搜索,System Prompt 注入,无需额外 API Key
数据持久化 JSONEncoderUserDefaults,按 provider 分 key,历史迁移向前兼容

六、项目结构一览

bash 复制代码
OllamaApp/
├── OllamaApp/
│   ├── OllamaAppApp.swift          # App 入口
│   ├── ContentView.swift           # 所有 UI 视图(~470 行)
│   ├── OllamaManager.swift         # 核心业务逻辑 + 数据模型 + Keychain(~700 行)
│   └── Info.plist                  # NSAllowsArbitraryLoads 配置
├── OllamaApp.xcodeproj
└── Podfile                         # CocoaPods(当前无第三方依赖)

整个项目代码量约 1200 行 Swift 代码,功能完整覆盖多轮对话、流式输出、历史管理、联网增强、安全存储等核心场景,适合作为 SwiftUI + LLM 集成的参考 Demo。


项目链接

相关推荐
●VON18 小时前
鸿蒙 PC Markdown 编辑器错误处理:让失败可恢复而不是只弹提示
华为·架构·编辑器·harmonyos·鸿蒙
heimeiyingwang19 小时前
【架构实战】CI/CD流水线:从手动部署到一键上线
ci/cd·架构
中微极客19 小时前
视频Agent:从一次性生成到多轮编排架构
人工智能·架构·音视频
●VON20 小时前
鸿蒙 PC Markdown 编辑器质量工程:证据驱动的技术验证
华为·架构·编辑器·harmonyos·鸿蒙
人间凡尔赛20 小时前
从服务注册到 AI 注册:Nacos 3.0 MCP Registry 如何重塑 2026 年后端架构
人工智能·架构
guoheng20 小时前
我们用 RealVuln 测试了 AI 代码扫描工具:召回率、精确率与误报对比
安全·架构
吃饱了得干活20 小时前
从0到1实现消息已读未读:从基础设计到高并发架构
java·后端·架构
QN1幻化引擎20 小时前
认知架构调度与语言模型辅助:DalinX V8 Track 1 实验报告
人工智能·语言模型·架构
Ai拆代码的曹操20 小时前
生产排障第一步:架构图、业务图、流程图画法详解
架构·流程图