一、项目背景
在日常开发中,我经常需要在本地运行 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 - 不用 Alamofire :
URLSession.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使用String(UUID().uuidString),避免UUID类型在某些 Codable 场景下的兼容问题LLMProvider同时遵循CaseIterable和Identifiable,方便未来扩展更多 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)
// ...
}
为什么不用单例 + 切换模式?
- 状态隔离:用户在 Ollama 和 DeepSeek 之间切换时,各自的对话上下文不会丢失
- 并行对话:同时开启两个页面分别对话,互不干扰
- 独立持久化 :
UserDefaultskey 按 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.lines是AsyncLineSequence,按\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.isCancelled 后 break,然后在 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",方便后续扩展更多密钥类型- 设置时先
SecItemDelete再SecItemAdd,覆盖已有值而非报错
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 |
| 数据持久化 | JSONEncoder → UserDefaults,按 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。