SwiftUI + Agent + Skill:构建iOS智能助手的三层架构 前阵子我打算做一个小项目目标特别朴素在 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协议、注册到SkillRegistryAgent 和 SwiftUI 界面几乎不用改。你可以把新技能比作乐高积木新积木只要卡口对得上就能直接拼进现有系统。这个特性在团队协作里很实用——每人负责维护一个 Skill 文件互不影响。第二个是单技能独立测试。因为每个 Skill 不依赖 Agent 层我可以单独写单元测试来验证它给定一个输入确认输出是否符合预期。如果一个技能出问题不需要把整个界面跑起来只需在测试里直接调用execute方法。这种可测试性对长期维护非常重要。第三个是故障隔离。天气 Skill 因为网络问题挂掉了不会影响日程 Skill 正常使用Agent 调度层只需要捕获异常然后把错误信息展示在对话里整个 App 不会崩溃。我在做业务时遇到过最头疼的情况就是一个功能崩了导致整个模块不可用而插拔式设计从源头上解决了这种脆弱性。3. SwiftUI 实操落地从零搭建一个可运行的最小项目3.1 工程结构与环境准备我建议把工程文件按职责拆成五块而不是全部塞进ContentView.swiftAppApp 入口负责初始化注册表ModelsMessage、SkillInput、SkillOutput等数据模型Skills所有具体技能一个技能一个文件AgentSkillRegistry、AgentStore等调度层ViewsContentView、技能面板、消息气泡等 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 的对齐方式。链路通了后面加一百个技能都是抄作业的事链路没通再多高级设计都只是纸上谈兵。