资讯动态

SwiftUI Agent-Skill架构实践:用技能封装与统一调度重构杂乱App

发布时间:2026/10/14 8:46:44 来源:尧图企业网站定制
如果你的 SwiftUI 项目也开始出现这种苗头——功能入口越加越多交互逻辑散落在十几个视图文件里每次想新增一个能力都要小心翼翼地改动好几个地方那我建议你先停下来看看我这个实验项目是怎么处理的。这个项目叫 SwiftUI-Agent-Skill核心思路一句话就能说清把 App 里的每个能力封装成一个独立“技能Skill”再由一个统一的“代理Agent”负责接收用户指令、路由到对应技能、收集结果并刷新界面。听起来有点 AI Agent 的味道但它不依赖任何云端模型完全跑在本地用 SwiftUI 的状态管理机制就能驱动。适合那些功能模块较多、交互入口分散的工具类 App也适合想在 SwiftUI 里尝试“能力插件化”设计的开发者。我最初是在自己维护的一个记账打卡类 App 上动手改造的。功能倒是不复杂但记账、备忘、打卡、统计几个模块的入口交织在一起按钮回调满天飞加了几个月功能之后就有点吃不消了。把能力抽成 Skill、用 Agent 统一调度之后整个项目的维护体验好了不少。下面我把这个项目的设计思路、核心代码和踩过的坑完整梳理出来。1. 项目动机功能一多界面交互就成了一团乱麻1.1 我从一个记账 App 里看到的坏味道先说项目背景。我的一个个人工具 App 原来结构很传统TabView 挂了四五个 Tab每个 Tab 有自己的视图模型互相之间偶尔还要通过单例或者 NotificationCenter 通信。比如用户在主页面点“记一笔”弹创建页面保存后统计页面的视图模型要手动刷新用户点“备忘”又跳去另一个页面页面之间没有统一的消息通道。功能还少的时候这种写法没问题。等增加到十几个入口之后问题就变得非常具体加一个新功能要在入口页面、路由页面、数据模型、视图模型好几个地方改代码某个页面想复用另一个页面的能力只能复制粘贴逻辑改了一处忘了另一处更头疼的是测试交互链路散在各处写单元测试基本无从下手。我当时的感受是这些功能本质上都是“用户输入一个意图App 执行一个动作”但代码结构却把它们拆得七零八落。那段时间正好在关注 Agent 相关的架构思想突然意识到——如果我把每个能力都看作一个可插拔的“技能”把界面里的各种点击、回调统一收敛成“意图”再让一个引擎去调度整个交互链路就能彻底理清。1.2 Agent-Skill 不是 AI 专属而是一种服务化思维很多人一听“Agent”就想到 AI Agent觉得得有 LLM 才能玩。实际上落到 SwiftUI 项目里Agent 完全可以是一个朴素的本地调度器。它的职责只有三个接收意图、匹配技能、执行并返回结果。Skill 则是“某个具体能力的封装”它知道自己的能力描述、能处理什么指令、怎么执行、怎么返回结果。这个思路有点像把 App 内部做了一层服务化界面层不关心功能怎么实现只负责把用户的动作转成一段指令文本发给 Agent功能层不关心界面长什么样只负责把能力执行完返回结果。中间的解耦靠的是协议而不是具体类型。这就是这个项目叫“Agent-Skill”的原因——Agent 是大脑Skill 是手和脚。我在设计时给 Skill 定的原则是一个技能只做一件事但它必须能清楚地“自我介绍”。因为 Agent 要靠着这份自我介绍来决定什么时候调用它、怎么调用它。这就引出了下面协议设计的话题。1.3 这套架构要解决的三件事改造之前我给自己定了三个目标后来验证下来都实现了新增功能时尽量只动“新增一个 Skill 文件 注册一行代码”不碰已有视图和已有技能。技能的调用方式要统一界面也好、测试也好都通过同一个入口发指令。技能之间不互相依赖需要协作时通过 Agent 层编排而不是直接引用彼此。这三个目标听起来不复杂但真正落地时会遇到不少设计取舍。尤其是“统一入口”这一步直接决定了协议该怎么定义。接下来就要聊核心协议了。2. Skill 协议怎么设计才算“小而美”2.1 协议骨架能力的最小自描述信息协议是整个项目的基石。第一版我写得很激进协议里有十几个方法注册、权限、回调、响应式订阅全塞进去了结果技能实现者叫苦不迭。后来砍到只剩几个核心需求才终于撑起了整个架构。先看我现在用的协议定义import Foundation protocol AgentSkill: Sendable { /// 技能唯一标识注册表用它做索引 var id: String { get } /// 技能展示名日志和调试时用 var displayName: String { get } /// 自然语言描述用于意图路由和界面展示 var description: String { get } /// 触发关键词低成本意图匹配 var keywords: [String] { get } /// 判断是否能处理这段用户指令 func canHandle(_ intent: String) - Bool /// 执行技能 func execute(input: SkillInput, context: SkillContext) async throws - SkillOutput }这里每个字段都不是摆设。id是注册表里的唯一索引类似技能的身份证displayName给人看日志和技能列表里一目了然description写的是“这个技能擅长干什么”未来如果把路由器换成 LLM这段描述直接可以直接作为系统提示词的一部分keywords是本地路由时的关键词表低成本、可调试。canHandle和execute是协议的两个核心方法。前者负责“我能不能接这个活”后者负责“怎么把这个活干完”。canHandle的默认实现我建议放在扩展里做关键词包含匹配但具体的技能也可以重写比如某些技能需要更复杂的规则。这种设计既保留了灵活性又给实现者一个默认的省力方案。2.2 输入输出封装用强类型代替裸字典早期版本里execute的方法签名是execute(input: [String: Any])当时觉得字典够灵活什么参数都能往里塞。用了一段时间后发现这是最大的坑——写技能的人完全不知道字典里该放什么 key运行时才发现取不到值纯靠猜。后来我设计了三个结构体把输入输出收敛成强类型/// 一次技能调用的输入 struct SkillInput: Sendable { /// 用户输入的原始指令文本 let rawText: String /// 从指令中解析出的结构化参数 var arguments: [String: String] /// 附加元数据如来源页面、当前时间等 var metadata: [String: String] } /// 技能执行的返回结果 struct SkillOutput: Sendable { /// 给用户看的一句话摘要 var summary: String /// 详细内容用于展开详情 var detail: String? /// 是否需要通知界面刷新数据源 var shouldRefreshUI: Bool false } /// 技能执行错误 enum SkillError: LocalizedError { case invalidInput(String) case executionFailed(String) case timeout var errorDescription: String? { switch self { case .invalidInput(let msg): return 输入无效\(msg) case .executionFailed(let msg): return 执行失败\(msg) case .timeout: return 技能执行超时 } } }SkillInput里保留了rawText因为参数解析不一定要前置完成技能执行时可以按需解析。arguments是预解析好的结构化参数比如“频率”这种从指令里抽取的关键信息。SkillOutput只要求必须给summary这是 Agent 层用来给用户反馈的文本detail是可选的长内容界面层可以据此决定是弹窗还是跳转。shouldRefreshUI是一个标记位技能执行完如果改了数据源界面可以据此决定要不要主动刷新。2.3 上下文注入技能不应该直接碰 App 全局对象技能如果直接去访问 AppDelegate、全局单例或者某个存储对象那和写死依赖没区别。所以协议里引入了一个SkillContext作为技能和外部环境之间的唯一通道/// 技能执行上下文由 Agent 在调用时构造 struct SkillContext: Sendable { /// 持久化存储技能通过这些接口读写数据 var storage: any DataStoring /// 网络服务技能通过它发起请求 var networkService: any NetworkServing /// 当前用户信息等 var userInfo: [String: String] init(storage: any DataStoring, networkService: any NetworkServing, userInfo: [String: String] [:]) { self.storage storage self.networkService networkService self.userInfo userInfo } }这个设计的价值在于依赖注入。Agent 创建上下文时把当前 App 的存储、网络服务传进去技能层只面对协议接口不知道外面具体是谁。测试时换一个内存存储实现技能代码一行不用改单元测试直接能跑。3. Agent 内核实现注册表、意图路由与执行状态机3.1 引擎状态与消息模型Agent 内核我放在了一个叫AgentEngine的Observable类里。它是整个架构的心脏所有指令都经过它所有状态变化都从它流向视图层。状态模型先定义清楚/// Agent 的对外状态 enum AgentState: Sendable { case idle case processing case waitingForConfirmation case failed(String) } /// 对话消息 struct AgentMessage: Identifiable, Sendable { enum Role: Sendable { case user case agent } let id: UUID let role: Role let content: String let timestamp: Date var skillID: String? }AgentState是驱动界面刷新的核心状态发指令时变为processing执行完要么回idle要么进入failed。AgentMessage用来维护“对话历史”界面层可以直接把它渲染成一个消息列表很像聊天 App 的展示逻辑。3.2 意图路由策略先用关键词再上语义路由是 Agent 最重要的方法。我的实现逻辑很直接按注册顺序遍历技能找到第一个canHandle返回 true 的技能就执行找不到就走 fallback 技能。/// 技能注册与意图调度 MainActor Observable final class AgentEngine { private(set) var skills: [String: any AgentSkill] [:] private(set) var state: AgentState .idle private(set) var messages: [AgentMessage] [] private let context: SkillContext /// 找不到合适技能时的兜底 var fallbackSkill: (any AgentSkill)? init(context: SkillContext) { self.context context } func register(_ skill: any AgentSkill) { skills[skill.id] skill } /// 统一的指令入口 discardableResult func send(_ text: String) async - SkillOutput { let input SkillInput(rawText: text, arguments: [:], metadata: [:]) messages.append(AgentMessage(id: UUID(), role: .user, content: text, timestamp: Date())) state .processing guard let skill route(for: text) else { let output SkillOutput(summary: 没有找到能处理这条指令的技能) messages.append(AgentMessage(id: UUID(), role: .agent, content: output.summary, timestamp: Date())) state .idle return output } do { let output try await executeWithTimeout(skill, input: input, timeout: .seconds(10)) messages.append(AgentMessage( id: UUID(), role: .agent, content: output.summary, timestamp: Date(), skillID: skill.id )) state .idle return output } catch { let output SkillOutput(summary: 技能执行失败\(error.localizedDescription)) messages.append(AgentMessage(id: UUID(), role: .agent, content: output.summary, timestamp: Date())) state .failed(error.localizedDescription) return output } } private func route(for text: String) - (any AgentSkill)? { for skill in skills.values { if skill.canHandle(text) { return skill } } return fallbackSkill } }这里我采用的是“第一个匹配就执行”的策略而不是让所有技能都跑一遍再去选最佳。原因在于移动端场景里用户的意图通常是单一的跨技能、复杂编排是少数场景。先用简单的线性路由把绝大多数场景覆盖掉剩下百分之一二的复杂场景通过 fallbackSkill 和后续的扩展路由器去处理。注册表用字典以id为 key路由时遍历values。字典的好处是注册时天然去重后注册的同名技能会覆盖先注册的调试时不容易出现“莫名触发两个技能”的问题。3.3 执行生命周期状态推进、超时与失败兜底技能执行不能无限等待。我在send里封装了一个带超时的执行器/// 并发执行技能超过超时时间自动取消 private func executeWithTimeout( _ skill: any AgentSkill, input: SkillInput, timeout: Duration ) async throws - SkillOutput { try await withThrowingTaskGroup(of: SkillOutput.self) { group in group.addTask { do { return try await skill.execute(input: input, context: self.context) } catch { throw error } } group.addTask { try await Task.sleep(for: timeout) throw SkillError.timeout } let output try await group.next()! group.cancelAll() return output } }这个做法的核心是“谁先返回听谁的”技能先返回就用技能的结果超时先触发就抛超时错误同时把另一个 task 取消掉。用过withThrowingTaskGroup的人应该清楚它天然就是为了这种竞争场景设计的比手动搞计时器要干净得多。失败兜底同样重要。技能抛出错误后Agent 层统一吞掉并转成SkillOutput把错误信息返回给用户而不是让整个流程崩掉。因为 Agent 是给用户一个“可对话的入口”任何错误都要变成一句能看懂的反馈这才是这种架构该有的体验。4. 第一个技能全流程走读笔记类 Skill 的落地4.1 NoteSkill 的实现与参数提取理论说了不少现在走一遍完整实现。以最常见的“记笔记”技能为例看它怎么接指令、怎么存数据、怎么返回结果。import Foundation /// 笔记技能记录、追加和查询便签内容 struct NoteSkill: AgentSkill { let id note let displayName 笔记 let description 记录、追加和查询便签内容适合处理包含“记一下”“备忘”“note”等关键词的指令。 let keywords [记一下, 备忘, note, 提醒] func canHandle(_ intent: String) - Bool { keywords.contains { intent.lowercased().contains($0.lowercased()) } } func execute(input: SkillInput, context: SkillContext) async throws - SkillOutput { let trimmed input.rawText .replacingOccurrences(of: 记一下, with: ) .replacingOccurrences(of: 备忘, with: ) .replacingOccurrences(of: 提醒, with: ) .trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw SkillError.invalidInput(没有从指令中提取到笔记内容) } let note NoteItem(content: trimmed, createdAt: Date()) try await context.storage.save(note) return SkillOutput( summary: 已记录\(trimmed), detail: note.id.uuidString, shouldRefreshUI: true ) } }参数提取这段我刻意没有引入复杂的 NLP只是把几个关键词从指令里剥掉剩下的就是笔记内容。像“记一下明天下午三点开会”就变成“明天下午三点开会”正好是笔记正文。这个方案在中文指令下特别实用又足够透明——每个技能自己负责理解指令格式Agent 层不需要知道。存储层这里只用了协议接口/// 数据存储抽象 protocol DataStoring: Sendable { func save(_ item: NoteItem) async throws func fetchAllNotes() async throws - [NoteItem] } /// 笔记模型 struct NoteItem: Identifiable, Sendable { let id: UUID var content: String var createdAt: Date init(id: UUID UUID(), content: String, createdAt: Date Date()) { self.id id self.content content self.createdAt createdAt } }真实 App 里DataStoring可以换成 SwiftData 或 Core Data 实现这个项目里用一个内存 List 实现就够测试用。重点是技能代码里完全没有出现任何具体的存储框架类型全部面向协议后来我换过两种存储实现NoteSkill 一行没改。4.2 聊天式 SwiftUI 视图绑定Agent 层定好了界面层就很清爽了。我搭了一个非常省事的对话式界面import SwiftUI /// Agent 对话主界面 struct AgentChatView: View { State private var engine: AgentEngine State private var inputText init(engine: AgentEngine) { _engine State(initialValue: engine) } var body: some View { VStack(spacing: 0) { messageList inputBar } .navigationTitle(智能助手) .task { engine.register(NoteSkill()) } } private var messageList: some View { ScrollViewReader { proxy in List(engine.messages) { message in MessageRow(message: message) .listRowSeparator(.hidden) .id(message.id) } .listStyle(.plain) .onChange(of: engine.messages.count) { if let last engine.messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } } private var inputBar: some View { HStack { TextField(输入指令例如记一下明天下午三点开会, text: $inputText) .textFieldStyle(.roundedBorder) Button { let text inputText inputText Task { await engine.send(text) } } label: { Image(systemName: paperplane.fill) } .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() } } /// 单条消息视图 struct MessageRow: View { let message: AgentMessage var body: some View { HStack { if message.role .user { Spacer(minLength: 40) Text(message.content) .padding(10) .background(Color.accentColor.opacity(0.2)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { Text(message.content) .padding(10) .background(Color.secondary.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 12)) Spacer(minLength: 40) } } } }这里有个小细节值得注意engine用的是State而不是EnvironmentObject。因为AgentEngine标记了MainActor Observable用State持有它就会自动触发视图更新省掉了手动objectWillChange的调用。在 iOS 17 的环境下这是最顺手的做法。列表视图直接绑定engine.messages新消息进来不用写任何手动刷新代码Observable会自动管好。我看了下这个界面跑起来的样子基本就是一个轻量聊天框用户友好度比我原来那种散落按钮的 Tab 布局高不少。4.3 把网络请求包装成 WeatherSkill技能不只能操作本地数据网络请求同样可以封装。我加了一个天气查询技能用来验证“技能带上下文调用网络服务”的链路/// 天气查询技能 struct WeatherSkill: AgentSkill { let id weather let displayName 天气 let description 查询指定城市当前天气适合包含“天气”“气温”“会不会下雨”等关键词的指令。 let keywords [天气, 气温, 下雨] func canHandle(_ intent: String) - Bool { keywords.contains { intent.contains($0) } } func execute(input: SkillInput, context: SkillContext) async throws - SkillOutput { // 简单地从指令里截取城市名真实场景可以让 LLM 来解析 let city extractCity(from: input.rawText) ?? 北京 let response try await context.networkService.fetchWeather(city: city) return SkillOutput( summary: \(city) 当前 \(response.temperature)°C\(response.condition), detail: 体感温度 \(response.feelsLike)°C湿度 \(response.humidity)%, shouldRefreshUI: false ) } private func extractCity(from text: String) - String? { // 这里用正则或 City 名称表匹配略 nil } }天气技能说明了一个重要原则技能内部不要自己创建一个URLSession到处飞请求而是通过SkillContext里注入的NetworkServing统一走网络层。好处是显而易见的——网络层可以做缓存、限流、鉴权技能只关心业务语义。哪天换网络库技能依然不用动。5. 并发与状态管理SwiftUI Agent 最容易踩的五个坑5.1 状态刷新错线程界面半天不动这个坑在我早期版本里真实出现过。技能里为了不阻塞主线程我用Task.detached跑去后台解析数据解析完直接往一个 ObservableObject 的属性里赋值。结果是界面在手机上卡了半秒到一秒才刷新因为赋值不在主线程上UI 更新被系统延迟到了下一个 runloop。解决办法其实早就明确所有会触发 SwiftUI 状态更新的操作必须在主线程上执行。Observable类本身没有强制主线程所以我把AgentEngine标记成了MainActor整个类的属性读写都自动跑到主线程。技能内部的耗时操作可以在后台线程做最后把结果交给 Agent你的 Agent 层在主线程更新消息列表就不会出现在奇怪时机刷新 UI 的问题。5.2 Swift 6 严格并发协议方法的 Sendable 约束升级到 Swift 6 之后编译器对并发安全检查严了很多。早期AgentSkill协议没声明Sendable技能实例在 Task 里跨线程执行时会出现大量 warning 和报错。解决方法已经在协议定义里体现了protocol AgentSkill: SendableSkillInput、SkillOutput也都标记了Sendable。注意SkillContext里的storage和networkService是any DataStoring和any NetworkServing这两个协议同样需要Sendable。在设计协议时就把这张并发安全网补上后面迁移 Swift 6 时才不用大改。这也是为什么AgentSkill的canHandle和execute没有设计成 mutating 方法——技能实例是不可变值类型天然线程安全。如果技能内部需要写状态我建议用 actor 或者独立的状态存储对象而不是把可变属性堆在技能结构体里。5.3 任务取消用户连续发指令时发生什么另一个实际会遇到的问题是用户的连续输入。比如我先发“帮我查天气”执行还没完又发“记一下买牛奶”。如果不做处理两个 Task 会同时在跑后一个的结果可能插到前一个消息前面界面顺序乱掉。我的做法是在send方法入口先做一次任务检查如果上一轮消息还在processing状态直接把旧任务取消掉再开始新任务。实现上用Task句柄存住当前任务新指令进来时调用handle?.cancel()再重新创建。这个逻辑写在send方法里即可我在代码里把它简化成了每次发送前调用基础Task的方式实际项目里建议把agentTask: TaskVoid, Never?作为一个属性维护。5.4 技能内部数据模型与 Identifiable第三个值得提醒的是如果技能返回的数据要直接驱动列表数据模型一定要实现Identifiable否则 SwiftUI 的ForEach无法渲染。我在NoteItem上放了let id: UUID并用Identifiable协议标记这是很多人容易忽略的细节。AgentMessage同样实现了Identifiable所以对话列表才能直接用List(engine.messages)。5.5 调试技巧注册表与调用链日志最后分享一个调试经验。Agent 架构最大的优势是“可观测”因为所以指令都从send进所有结果都从send出中间链路完全可控。我在调试时做了一个简单日志#if DEBUG if let skill route(for: text) { print([Agent] 路由命中技能: \(skill.id)) } else { print([Agent] 未命中任何技能走 fallback) } #endif注册表同样可以打印启动时遍历skills把id、displayName、keywords整齐打出来一眼就能发现注册是否遗漏。这套日志成了我排查问题最重要的工具比在十几个视图文件里写断点效率高太多。6. 调试手段与后续扩展方向6.1 技能路由的可视化面板日志只是基础我还加了可视化的技能列表界面。用一个调试页展示当前注册的所有技能每个技能显示id、description和keywords点击一条可以直接把示例指令填入输入框。这个界面在开发期特别好用——测试新技能时不用记命令点一下就能发出去。struct DebugSkillListView: View { let engine: AgentEngine var body: some View { List(engine.skills.values.map { AnyAgentSkillWrapper($0) }, id: \.id) { wrapper in VStack(alignment: .leading) { Text(wrapper.displayName).font(.headline) Text(wrapper.description).font(.subheadline).foregroundStyle(.secondary) Text(触发词 wrapper.keywords.joined(separator: 、)) .font(.caption) .foregroundStyle(.tertiary) } } .navigationTitle(技能注册表) } }这个AnyAgentSkillWrapper是简单类型擦除包装方便协议列表直接用于 UI。实际开发中如果嫌麻烦也可以直接用[String: any AgentSkill]遍历SwiftUI 的ForEach不能直接遍历它包装一层是最省事的选择。6.2 把 LLM 作为意图路由器项目做到后期我意识到这套架构最大的扩展点在于路由策略。当前canHandle只是关键词匹配但SkillInput里的rawText、arguments、metadata已经够任意路由器使用。如果以后想接一个 LLM 做意图解析完全可以把description字段批量送入语言模型让模型决定调用哪个技能再填充arguments给技能执行。我实际设计过一版混合路由先用关键词快速匹配置信度低时再走 LLM。因为移动端网络环境不稳定优先让低成本方案工作LLM 只是兜底增强。这样即使没有网络App 的核心技能依然能跑体验不至于完全没法用。6.3 技能组合模式与持久化另一个可以扩展的方向是技能组合。比如“记一下明天的会议”可能同时涉及笔记技能和日历技能需要在 Agent 层做编排。我建议不要直接在技能里互相调用而是在 Agent 层做一个“编排技能”它可以调用多个底层技能并把结果汇总。编排技能本身也是一个 Skillid可以是session之类的名字内部execute里再对子技能发送指令。持久化方面目前存储层是配合 SwiftData 实现的。因为已经面向DataStoring协议迁移过去很简单只改了协议实现业务代码全部复用。我真心觉得面向协议做依赖注入是这个项目里性价比最高的一步。最后分享一点个人体会这套 Agent-Skill 架构未必适合所有 SwiftUI 项目但它非常适合交互入口多、功能模块清晰的工具型 App。设计过程中最花精力的不是写代码而是想清楚“边界”——技能和 Agent 的边界、技能和上下文的边界、数据模型和传输对象的边界。边界划定了后面的功能扩展就变成不断往注册表里添加新技能那种体验非常舒服。如果你手头也有这种越改越乱的 SwiftUI 项目不妨试着抽出一个小技能跑通链路再逐步迁移剩余模块。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑