☰
SwiftUI + Agent + Skill:构建iOS智能助手的三层架构
2026/10/14 8:56:31 网站建设 项目流程

前阵子我打算做一个小项目,目标特别朴素:在 iOS 上用自然语言触发几个工具能力,比如查天气、建日程、记一笔账。一开始我以为难点在界面,后来发现真正把项目撑起来的,是 SwiftUI-Agent-Skill 这三个词的组合方式。SwiftUI 提供声明式的界面层,Agent 负责理解和调度请求,Skill 则把每一个具体能力封装成可插拔的模块。做完之后我的结论是:这套结构非常适合做工具聚合类的智能助手,也适合想给现有 App 增加“技能化能力层”的开发者参考。如果你会 SwiftUI 基础、知道 async/await 大概怎么用,这篇文章可以直接照着落地一个可运行的最小版本。

1. 拆解 SwiftUI-Agent-Skill:这三层到底解决什么问题

1.1 Agent 与 Skill 在 iOS 客户端中的定位

很多人在标题里看到 Agent 就觉得是大模型、是推理引擎,其实在客户端场景里,Agent 更适合被理解成一个“调度大脑”。它不负责具体干活,只负责理解用户的请求、判断该调用什么能力、把结果整理好再回到界面。Skill 才是真正干活的模块——查天气是一个 Skill、建日程是另一个 Skill、记账又是一个 Skill。每个 Skill 只暴露统一的输入输出,内部怎么实现,Agent 完全不关心。

我习惯用店面和员工来类比这套结构:SwiftUI 是店面,用户看到的所有对话气泡、按钮、列表都是店面装修;Agent 是店长,客人进来说“我要点一杯少糖的茶”,店长负责理解这句话并决定找谁;Skill 是后厨员工,有人做茶、有人做咖啡、有人做甜品,每名员工只精通自己那一类产品。店长不需要知道茶是怎么泡的,员工也不需要知道客人是怎么走进店里的。这个分工让整个项目变得非常干净:UI 层、调度层、能力层三者各管各的,互不污染。

那为什么需要这样的分层?如果直接把所有工具逻辑写在一个 ViewModel 里,项目一旦加功能就会失控。比如要新增“翻译”技能,你就得去改对话处理函数、改按钮状态、改错误处理;而用 Skill 机制之后,新能力就是往注册表里塞一个对象,Agent 调度逻辑一行都不用改。这个从“功能堆叠”到“能力插拔”的转变,是这套架构最核心的价值。

1.2 为什么选择 SwiftUI 而不是 UIKit

标题既然带着 SwiftUI,那技术选型基本就定了。但这里我还是想说清楚理由,因为很多人做这类项目时会纠结:要不要用 UIKit?要不要干脆上跨端框架?

SwiftUI 最大的优势是状态驱动界面。Agent 在执行任务时一定会经历多个状态:空闲、思考中、执行某个技能、正在返回结果。如果我用 UIKit,需要手动在各种回调里刷新 UI,还要小心避免对同一个控件的重复更新;但 SwiftUI 里,我只要把 Agent 的状态定义成一个可观察对象,界面就会自动跟随变化。举个例子:

enum AgentState { case idle case thinking case executing(skillID: String) }

当我把@Published var state: AgentState放到 Store 里时,SwiftUI 的视图就能在状态切换时自动重绘,完全不需要手动调用reloadData()之类的方法。这个开发体验上的差距,在做状态机类项目时尤其明显。

SwiftUI 的异步支持也是一个重要原因。Agent 调用 Skill 大概率是异步的,可能是网络请求、可能是系统日历写入、也可能是文件读取。SwiftUI 从 iOS 15 开始搭配 Swift Concurrency 用起来非常顺手,Task、async/await配合@MainActor,可以让所有 UI 状态更新都安全地发生在主线程。这样的组合写出来的代码量远小于 UIKit 的 delegate 回调,而且逻辑更清晰。

当然 SwiftUI 也有坑,尤其是版本差异。部分新版 API 需要 iOS 17 才能用,但很多真实项目还在兼容 iOS 16。我的做法是:把基线定在 iOS 16,用ObservableObject作为状态管理方案,这样代码在 iOS 17 上也能正常运行。等团队里所有人都统一到新版本,再考虑迁移到@Observable也不迟。

2. 核心模型设计:Skill 协议、注册表与 Agent 调度器

2.1 Skill 协议定义与能力注册

整个架构的核心是一个极其简单的协议。我先定义一个统一的输入输出结构,再让所有技能实现同一个接口:

import Foundation struct SkillInput { let query: String let context: [String: Any] } struct SkillOutput { let text: String let data: [String: Any]? } protocol Skill: AnyObject { var id: String { get } var name: String { get } var description: String { get } func execute(input: SkillInput) async throws -> SkillOutput }

这里有几个细节值得注意。第一,protocol Skill: AnyObject意味着技能必须是类对象,这样可以配合weak做引用管理,避免后面出现内存泄漏。第二,execute是异步且可以抛错的,因为任何真实能力都可能失败——网络超时、权限拒绝、数据格式异常,都要通过throws往外抛。第三,输入输入用统一的SkillInput/SkillOutput,这样 Agent 不需要知道技能内部的细节。

有了协议之后,还需要一个注册中心来管理所有技能。我用一个简单的字典结构:

final class SkillRegistry { static let shared = SkillRegistry() private var skills: [String: any Skill] = [:] func register(_ skill: any Skill) { skills[skill.id] = skill } func skill(for id: String) -> (any Skill)? { skills[id] } var allSkills: [any Skill] { Array(skills.values) } }

注册表用一个字典以id为 key 保存技能,查找复杂度是 O(1),而且天然保证了同一个技能不会被重复注册。技能可以单独写一个小类,比如做一个EchoSkill用来验证链路:

final class EchoSkill: Skill { let id = "echo" let name = "复读" let description = "原样返回输入内容,用来测试链路" func execute(input: SkillInput) async throws -> SkillOutput { SkillOutput(text: input.query, data: nil) } }

然后在地图里SkillRegistry.shared.register(EchoSkill())。到这里,一个最小技能闭环就有了。也许你会觉得这太简单,但这正是这套架构的精髓:先用最简单的方式把链路跑通,后面加真实技能都是照葫芦画瓢。

2.2 Agent 状态机与技能编排

有了技能,Agent 的调度器就可以做正经事了。Agent 首先要维护一个状态机,我把它定义成枚举,因为枚举在 SwiftUI 里做switch分支非常舒服:

enum AgentPhase { case idle case thinking case executing(skillID: String) case responding }

调度的标准流程是这样的:用户输入文本后,Agent 从idle先进入thinking;接着根据输入匹配一个技能,进入executing,把skillID带在枚举里;技能执行完毕后,Agent 拿到文本结果,进入responding状态,等界面渲染完对话气泡,再回到idle。整个状态迁移可以浓缩成一段代码:

func send(_ text: String) async { phase = .thinking defer { phase = .idle } guard let skill = matchSkill(for: text) else { await appendMessage("没有匹配到可用技能") return } phase = .executing(skillID: skill.id) do { let output = try await skill.execute( input: SkillInput(query: text, context: currentContext) ) await appendMessage(output.text) } catch { await appendMessage("技能 \(skill.name) 执行失败:\(error.localizedDescription)") } }

这段代码里matchSkill(for:)是整个项目的决策核心。实际项目中你可以在里面做关键词匹配,也可以接意图识别模型,但在 Demo 阶段我建议先用一个简单的规则表:比如输入包含“天气”就去匹配天气技能,包含“日程”就去匹配日程技能。这样不需要外部依赖,项目在任何环境下都能跑通。至于上下文currentContext,可以简单理解成最近的对话历史,用数组存起来传给技能,让技能能参考用户之前说过的话。

调度器还可以进一步扩展出“技能编排”能力。比如用户说“明天北京适合跑步吗”,这个过程其实需要两个技能协作:先查天气,再结合天气数据给出运动建议。我可以在调度层加入一个plan(skills:input:)方法,让它按顺序执行多个技能并把前一个技能的输出当作后一个技能的输入。这就是最早的技能链,也是整个 Agent 最性感的扩展点。

2.3 插拔式设计带来的三个直接好处

这套设计落地之后,最直接的好处有三个。

第一个是新技能零成本接入。只要实现Skill协议、注册到SkillRegistry,Agent 和 SwiftUI 界面几乎不用改。你可以把新技能比作乐高积木,新积木只要卡口对得上,就能直接拼进现有系统。这个特性在团队协作里很实用——每人负责维护一个 Skill 文件,互不影响。

第二个是单技能独立测试。因为每个 Skill 不依赖 Agent 层,我可以单独写单元测试来验证它:给定一个输入,确认输出是否符合预期。如果一个技能出问题,不需要把整个界面跑起来,只需在测试里直接调用execute方法。这种可测试性对长期维护非常重要。

第三个是故障隔离。天气 Skill 因为网络问题挂掉了,不会影响日程 Skill 正常使用;Agent 调度层只需要捕获异常,然后把错误信息展示在对话里,整个 App 不会崩溃。我在做业务时遇到过最头疼的情况,就是一个功能崩了导致整个模块不可用,而插拔式设计从源头上解决了这种脆弱性。

3. SwiftUI 实操落地:从零搭建一个可运行的最小项目

3.1 工程结构与环境准备

我建议把工程文件按职责拆成五块,而不是全部塞进ContentView.swift:

  • App:App 入口,负责初始化注册表;
  • Models:Message、SkillInput、SkillOutput等数据模型;
  • Skills:所有具体技能,一个技能一个文件;
  • Agent:SkillRegistry、AgentStore等调度层;
  • Views:ContentView、技能面板、消息气泡等 UI 组件。

环境方面,我使用 Xcode 15 配合 iOS 16 模拟器,不需要引入任何第三方依赖。第三方库虽然能省一些代码,但这类 Agent 项目链路复杂,先保持纯净反而更容易排查问题。SwiftUI 的基础能力完全够用,真正需要扩展时再引入对应工具也不迟。

启动入口要做一件很重要的事:在App的init里完成技能注册。比如:

init() { SkillRegistry.shared.register(EchoSkill()) SkillRegistry.shared.register(WeatherSkill()) }

这样保证 AgentStore 被创建时,技能已经全部就位。如果你把注册写在视图的onAppear里,会存在时序问题——界面可能已经在空列表状态下渲染过一次了。

3.2 主界面与技能面板构建

界面层我拆成了三个部分:上半部分是技能面板,展示所有已注册技能;中间是消息列表;底部是输入框和发送按钮。技能面板的作用不只是展示,它还可以让用户直接点击某个技能来触发操作,方便在没有自然语言理解能力的时候也能快速尝试验证。

struct ContentView: View { @StateObject private var store = AgentStore() @State private var input: String = "" var body: some View { NavigationStack { VStack(spacing: 0) { SkillPanel(skills: SkillRegistry.shared.allSkills) { skill in Task { await store.executeSkill(id: skill.id, query: "帮我执行\(skill.name)") } } Divider() ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(store.messages) { message in MessageBubble(message: message) } } .padding() } .onChange(of: store.messages.count) { _, _ in if let last = store.messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } Divider() HStack { TextField("输入你想做的事", text: $input) .textFieldStyle(.roundedBorder) Button("发送") { let text = input Task { await store.send(text) } input = "" } .buttonStyle(.borderedProminent) } .padding() } .navigationTitle("技能助手") } } }

这里的关键点是onChange(of: store.messages.count)。消息列表在 LazyVStack 中滚动,如果不主动调用scrollTo,新消息不会出现在视野里,用户就必须手动往下滑。加了这个逻辑之后,每次发送消息都能自动滚动到底部,体感上很顺畅。

技能面板我用了最普通的ScrollView加横向排列,每个技能是一个按钮。它背后的交互逻辑是:点击技能后直接调用store.executeSkill(id:query:),跳过自然语言匹配,直接指定技能执行。这个操作在调试阶段非常有用,相当于给每个技能都配了一个手动测试入口。

3.3 用 ObservableObject 打通 Agent 与 UI

状态管理是 SwiftUI 和 Agent 结合最核心的部分。我用一个被@MainActor标注的AgentStore来统一管理所有 UI 状态:

@MainActor final class AgentStore: ObservableObject { enum Phase { case idle case thinking case executing(skillID: String) } @Published var phase: Phase = .idle @Published var messages: [Message] = [] @Published var currentSkillID: String? func appendMessage(_ text: String) { messages.append(Message(role: .agent, content: text)) } func send(_ text: String) async { messages.append(Message(role: .user, content: text)) phase = .thinking guard let skill = matchSkill(for: text) else { messages.append(Message(role: .agent, content: "没有匹配到可用技能")) phase = .idle return } phase = .executing(skillID: skill.id) currentSkillID = skill.id do { let output = try await skill.execute( input: SkillInput(query: text, context: [:]) ) messages.append(Message(role: .agent, content: output.text)) } catch { messages.append(Message(role: .agent, content: "技能执行失败:\(error.localizedDescription)")) } phase = .idle currentSkillID = nil } }

这里最值得注意的就是@MainActor这个标注。整个 Store 默认运行在主线程,所有@Published的赋值操作都不会触发“后台线程更新 UI”的崩溃。SwiftUI 视图在使用时只需要把AgentStore作为@StateObject放进去,然后调用await store.send(text)即可。

有的同学可能会担心:所有逻辑都跑主线程会不会卡顿?其实不会。因为关键的耗时操作发生在skill.execute内部,而 Skill 的execute可以自己决定是否切换到后台线程。比如网络请求使用URLSession的异步方法,默认就在后台执行;全部执行完毕之后,控制权自然回到@MainActor的 Store 中。所以整个设计既保证了 UI 线程安全,又不会造成明显卡顿。

Message模型也要注意设计成Identifiable。我用了UUID作为id,避免 ForEach 刷新时出现索引错乱。对于消息气泡,我按照role区分左右布局,用户消息靠右、Agent 消息靠左,视觉上是标准的聊天界面:

struct Message: Identifiable { let id = UUID() enum Role { case user case agent } let role: Role let content: String }

这套链路跑通之后,你会获得一个非常稳定的基础版本:输入文字、匹配技能、执行技能、展示结果。后续所有高级功能都围绕这条链路展开。

4. 实战排障:SwiftUI 与 Agent 异步一起使用时踩过的坑

4.1 主线程更新 UI 的正确姿势

用 SwiftUI 做异步 Agent 时,最常见的崩溃就是“在主线程更新 UI 的约束被打破”。很多人会写出类似这样的代码:

DispatchQueue.global().async { let output = try await skill.execute(input: input) DispatchQueue.main.async { self.messages.append(...) } }

这种写法在项目初期能用,但嵌套越来越多后很容易出问题:外层DispatchQueue捕获了self,如果self在处理过程中被释放,就会造成循环引用的隐患;如果忘记切回主队列,界面要么卡死、要么崩溃。我的做法是彻底放弃手动派发队列,把 Store 标注为@MainActor,让所有状态变更都默认在主线程执行。这样代码更短,也从根本上规避了线程问题。

如果你确实需要把一个重度计算技能放到后台执行,正确做法是在 Skill 内部自己处理。不要在 Store 层去创建后台队列,否则你很快就会陷入“哪个函数在哪个线程”的泥潭里。

4.2 列表项标识缺失导致的刷新错乱

SwiftUI 的ForEach要求数据项有稳定且唯一的标识。一开始我在Message里只写let id = UUID(),其实这样已经足够。但如果偷懒用ForEach(store.messages.indices, id: \.self),就会遇到一个经典问题:列表项位置改变或删除时,界面状态错乱。尤其是在聊天记录里插入 Agent 回复时,索引本身不携带内容信息,SwiftUI 会复用错误的视图。

我建议所有展示类数据都显式采用Identifiable协议,哪怕只是暂时不会删除列表项,也把UUID加上。另一个需要注意的点是:不要在ForEach的 body 里依赖index去取数组元素,在异步更新时这会造成数据与视图不同步。

4.3 技能执行超时与重试机制

Agent 技能执行最讨厌的问题是“挂起”。某些技能可能因为网络请求迟迟没有返回,导致 UI 一直停留在“正在执行”的状态。最简单的超时方案是给execute加一层包裹:

func executeWithTimeout(_ skill: any Skill, input: SkillInput, timeout: TimeInterval) async throws -> SkillOutput { try await withThrowingTaskGroup(of: SkillOutput.self) { group in group.addTask { try await skill.execute(input: input) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) throw TimeoutError() } let result = try await group.next()! group.cancelAll() return result } }

这个方案的作用是:谁先完成谁说话。如果技能在超时时间内没执行完,第二个子任务就会抛错,整个任务组被取消,UI 界面上就能收到一个可展示的错误。我还会在错误信息里提供“重试”按钮,让用户不必重新输入整段话。

4.4 新旧观察框架混用引发的编译问题

iOS 17 之后 SwiftUI 引进了@Observable宏,很多教程都在推新写法。但如果你在一个项目里同时使用ObservableObject和@Observable,很容易出现风格不统一的问题:有的视图用@StateObject,有的试图用@State,最后的状态管理就变得混乱。我在迁移过程中就曾经因为混用导致界面无法响应状态变化。

我的结论是:iOS 16 兼容阶段就干脆全部用ObservableObject,不要中途混合。这样代码风格一致,遇到问题也好排查。等彻底放弃 iOS 16 之后,再统一迁移到新观察框架,一次性改干净。

4.5 内存泄漏排查

技能对象被SkillRegistry强引用,而技能里的闭包如果捕获了外部对象,就可能造成循环引用。尤其是当技能内部又持有了 Store 或者 ViewModel 时。排查方法很简单:在 Xcode 里使用内存图调试器,执行几次技能调用后看是否有对象没有被释放。修复方式是在闭包里使用[weak self],并明确guard let self。

我遇到的实际案例是:一个技能闭包里直接引用了AgentStore,结果 AgentStore 释放时,SkillRegistry 里的技能还持有它的引用,导致整个 Store 永远驻留在内存里。用weak解决之后,内存占用明显下降。这个坑对 Swift 开发者来说经典但常被忽略,在 Agent 场景里因为异步回调多,更容易中招。

下面整理一个常见问题速查表:

问题现象根本原因推荐解法
崩溃提示在主线程更新 UI在后台队列里给 @Published 赋值Store 标注 @MainActor,所有状态变更默认主线程
聊天列表滚动异常列表项缺少稳定 ID使用 Identifiable + UUID
技能卡住不返回没有超时控制用 withThrowingTaskGroup 实现超时
编译报错但代码逻辑正确混用新旧观察框架统一使用 ObservableObject
内存持续增长闭包强引用 Store 或 ViewModel使用 [weak self] 捕获列表

5. 从 Demo 到产品:Skill 架构的扩展与落地建议

5.1 本地推理与云端接口如何选择

Skill 架构的优点在于它对上层隐藏了数据源。同一个 Skill 接口,内部既可以是本地规则,也可以是云端模型请求。以我做的查询类技能为例,我一开始先用本地关键词匹配,之后再把匹配逻辑换成云端模型接口,整个过程没有改动 UI 和 Store 层。

在真实产品落地时,我建议把“意图识别”和“技能执行”两者分开看待。意图识别可以走云端,因为任务复杂度和计算量都较大;而具体技能执行尽量在本地,比如读写日历、记账、控制开关,这类能力本身不涉及大量计算,放在本地响应更快也更省电。如果某个技能确实需要较大模型支持,才让 Skill 内部发起网络请求。

5.2 权限管理与技能沙盒

技能一旦变多,权限问题就会出现。天气技能可能需要定位权限,日历技能需要日历权限,相机技能需要相机权限。如果不做统一管理,每次技能执行时临时弹窗会让用户体验很差。我的建议是在技能注册表里增加一个requiredPermissions属性,在技能面板上提前展示权限状态,用户点击技能时一次性发起授权请求。

更进一步,可以给技能设计一个“沙盒”概念:每个技能只能访问它声明过的数据和系统能力。这在技术上可以用系统自带的权限机制实现,架构上则需要让 Skill 之间不能直接访问彼此的上下文。我在项目中用了一个简单规则:SkillInput只包含 Agent 整理好的上下文,而不是让技能直接读取整个 App 状态。这样技能之间的数据隔离性会好很多,也方便以后做更严格的安全控制。

5.3 可测试性设计与单元测试思路

最后想聊聊测试。很多 SwiftUI 项目不好测试,因为视图和业务逻辑绑得太紧。但在这套 Skill 架构里,测试非常自然:因为每个技能是独立的类,直接构造SkillInput调用execute就能断言输出。比如给天气技能传入一个包含“北京”的输入,确认输出里包含天气信息;给记账技能传入一个金额,确认产生一条记账结果。

对于 AgentStore,我采用的方式是注入一个假的技能列表,而不是直接使用全局注册表。例如:

final class MockSkill: Skill { let id = "mock" var output = SkillOutput(text: "测试成功", data: nil) func execute(input: SkillInput) async throws -> SkillOutput { output } }

然后把AgentStore设计成支持外部注入技能集合,测试时传入MockSkill,断言用户发送“测试”后消息列表里出现“测试成功”。这种测试方式不依赖界面,跑起来很快,能有效防止未来的改动破坏核心调度逻辑。

做这个模拟项目X的过程中,我踩过不少坑,最大的体会是:不要一开始就追求完美的 Agent 编排和丰富的技能库。先写一个返回固定文本的 EchoSkill,把“输入 -> 调度 -> 执行 -> 展示结果”这条链路完整跑通,再逐步加入真实技能。因为整套架构最大的价值不在技能数量,而在状态流转和 UI 的对齐方式。链路通了,后面加一百个技能都是抄作业的事;链路没通,再多高级设计都只是纸上谈兵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询