
很多人拿到新项目标题的第一反应是上网搜教程、看别人的轮子但我习惯先拆名字。SwiftUI-Agent-Skill 这三个词放在一起其实已经把一套 App 的核心架构讲完了SwiftUI 管界面层Agent 管决策和调度Skill 是一堆可以被统一调用的具体能力。我去年用这套思路重构了一个内部工具类 Demo把原来一团乱的业务模块梳理成了界面、调度、技能三层都清晰的结构。这篇文章把完整设计思路、可复用的代码骨架、以及我实际踩过的坑一次性讲透。适合想在 SwiftUI 项目里引入模块化架构的开发者刚入门的新人和写过几个小 App 的老手都能从中找到可以直接抄作业的部分。1. 项目整体设计与思路拆解1.1 为什么要把 App 拆成 Agent 和 Skill传统 SwiftUI 项目的通病是 ViewModel 越来越大。首页要请求数据、要做权限判断、要跳转、要缓存十几个方法堆在同一个文件里加一个新功能先改动外层协调器再在基类里塞方法最后 View 里还要加一堆 if-else。代码不是不能跑是改起来越来越心虚。Agent-Skill 的思路刚好相反把每个具体能力封装成一个独立的 Skill包括入口参数、执行逻辑、返回结果全部自包含Agent 只负责“知道有哪些技能、现在该调哪个、调完之后把结果交给界面”。这么拆的好处很直接。其一新增功能不再需要改老代码写一个新 Skill 注册进去就行开闭原则在架构上天然满足。其二Agent 可以独立测试因为它不依赖具体业务只依赖 Skill 协议。其三界面层只面向 Agent 的顶层状态编程ViewModel 不再承担具体业务分支。我实际测下来这套模式在功能数量超过 20 个的 App 里优势最明显。功能少的时候你会觉得多了一层壳功能一多这层壳反而是把复杂度控制住的关键。1.2 SwiftUI 里做 Agent 的特殊性很多人一听到 Agent 就想到复杂的人工智能调度其实在 SwiftUI 场景里Agent 可以很轻量。它就是“接收请求、选出技能、执行技能、返回结果”这个闭环的协调者。但 SwiftUI 对 Agent 有一个独特要求所有 UI 状态更新必须在主线程而技能执行通常要放到后台。这意味着 Agent 不能只是一个普通的类它必须同时理解 Swift 并发模型和 SwiftUI 的刷新机制。我的方案是Agent 调度核心用 Swift 的 actor 实现保证技能注册表在多线程环境下安全。面向视图的暴露层用MainActor修饰的 ViewModel。技能本身全部走async/await通过Task把结果抛回主线程。这个组合的好处是技能模块自己不需要关心线程问题写作者只需要按顺序写出执行步骤线程切换由 Agent 和 ViewModel 这一层统一消化业务代码不会被DispatchQueue碎碎叨叨的换线程代码污染。1.3 这套结构和 MVD、分层架构的关系很多老项目用的是 MVP 或者分层模块Agent-Skill 和它们不冲突反而是互补。我在实际项目里界面层照样用 SwiftUI 原生的 State/Environment基础的网络、存储还是单独的公共服务层。Agent-Skill 替代掉的是原来那张盘根错节的“业务调用图”以前是 AViewModel 直接调 BService再在回调里改 CViewModel 的状态现在所有跨模块调用都收敛成“Agent 调度 Skill”。你可以把它理解成给楼层之间加了一个电梯以前大家互相走楼梯串门现在都走统一电梯电梯坏了也只需要修一个地方。2. Skill 协议设计把能力变成标准接口2.1 Skill 协议到底应该长什么样设计 Skill 协议是整套架构最重要的一步少一个字段后面都会难受。我改了三版最后稳定下来的是这样protocol AppSkill { /// 全局唯一标识注册表用它来索引 var id: String { get } /// 给日志和调试信息用的人类可读名称 var name: String { get } /// 技能的能力描述Agent 可以据此做选择 var capabilities: [String] { get } /// 执行入口 func execute(context: SkillContext) async throws - SkillResult }SkillContext负责把这次调用的所有输入打包struct SkillContext { var input: [String: Any] var metadata: [String: String] /// 透传的日志器技能写日志不直接 print var logger: SkillLogger } struct SkillResult { var output: [String: Any] var message: String var duration: TimeInterval }这个设计有意识地放弃了很多东西。比如我没有在协议里定义progress回调因为 SwiftUI 的进度展示完全可以通过AsyncSequence或 Actor 的流式属性来实现塞进协议接口只会让每个技能实现者都要写一套自己用不上的代码。也没有定义优先级字段优先级是 Agent 的决策逻辑不应该由技能自己声明否则两个技能会吵起来。2.2 为什么用协议而不是类继承Swift 的协议在这里比类继承优势大得多。第一个原因是值语义的自由度Skill 可以是 struct、enum也可以是 actor协议只约定行为不约束存储方式。我的几个轻量技能直接用 struct内部改用了 actor完全不影响 Agent 调用。第二个原因是协议可以组合。我后来做了一个组合型技能它自己不实现业务而是代理给几个子技能struct PipelineSkill: AppSkill { let id: String let name: String let capabilities: [String] let steps: [any AppSkill] func execute(context: SkillContext) async throws - SkillResult { var finalOutput: [String: Any] [:] var totalTime: TimeInterval 0 for step in steps { let start Date() let result try await step.execute(context: context) totalTime result.duration finalOutput.merge(result.output) { _, new in new } } return SkillResult(output: finalOutput, message: 流水线完成, duration: totalTime) } }类继承如果想做到同样的事要么引入多继承Swift 本身也不支持要么靠组合模式硬套代码远没有协议组合干净。用协议还有一个隐形好处测试替身好写。只要满足协议我可以做一个什么都不干的 MockSkill 塞进 Agent 里测试调度逻辑完全不需要改 Agent 本身。2.3 SkillContext 和 SkillResult 的参数选择很多人问为什么input和output要用[String: Any]这不是类型安全被丢了吗。我的回答是类型安全发生在协议边界之外。每个 Skill 的执行入口默认收到的是字典但实现者第一行就应该把它解包成自己的强类型参数结构解包失败就抛出SkillError.invalidInput。这样既保护了协议的统一性又不牺牲实现层面的类型安全。metadata字段是后期才加进去的。它专门用来透传链路信息比如调用来源是深链还是用户手动点击、是否需要静默执行等。这类“环境信息”如果也塞进input业务参数和环境参数就混在一起了排查问题的时候非常痛苦。3. 实操过程从零搭一个 SwiftUI Agent-Skill 骨架3.1 先把 SkillManager 写好Agent 的核心是一个注册表我用 Swift actor 来做隔离避免后续多个界面并发调用时出现数据竞争actor SkillManager { static let shared SkillManager() private var registry: [String: any AppSkill] [:] private init() {} func register(_ skill: any AppSkill) { registry[skill.id] skill } func unregister(id: String) { registry[id] nil } func skills(for capability: String) - [any AppSkill] { registry.values.filter { $0.capabilities.contains(capability) } } func dispatch(id: String, context: SkillContext) async throws - SkillResult { guard let skill registry[id] else { throw SkillError.notFound(id: id) } let start Date() let result try await skill.execute(context: context) return SkillResult( output: result.output, message: result.message, duration: Date().timeIntervalSince(start) ) } }dispatch里那个Date()计时是我故意加的。每个技能的耗时在排查性能问题时是第一手证据比事后单独写采样代码省事得多。注册表用单例是权衡后的选择。App 生命周期内技能集合相对稳定单例方便在任何界面层快速拿到 Manager。如果你做的是组件化大型工程建议改成通过依赖注入容器持有实例避免全局状态膨胀。3.2 Agent 调度逻辑怎么决定调哪个技能最简单的 Agent 可以做成指定 id 的直通调度但这没有发挥 Agent 的决策价值。我实现了一个按 capability 选择技能的方案struct UserRequest { var intent: String var parameters: [String: Any] var preferredSkillID: String? } actor Agent { let manager: SkillManager func fulfill(_ request: UserRequest) async throws - SkillResult { if let preferred request.preferredSkillID { let context SkillContext(input: request.parameters, metadata: [:], logger: ConsoleLogger()) return try await manager.dispatch(id: preferred, context: context) } // 意图匹配按 capability 检索取第一个满足条件的 let candidates manager.skills(for: request.intent) guard let best candidates.first else { throw SkillError.noSkillForIntent(request.intent) } let context SkillContext(input: request.parameters, metadata: [:], logger: ConsoleLogger()) return try await manager.dispatch(id: best.id, context: context) } }实际项目里我还在这个环节加了技能权重和最近使用记录让 Agent 在多个同类技能里选性价比更高的那个。对于大多数 App 来说先做到“按意图匹配”就够了过度规划只会让你的 Agent 变成一个不必要的算法实验场。3.3 和 SwiftUI 视图对接的正确姿势Agent 调度是异步的视图层需要根据任务状态渲染不同 UI。我用一个MainActor的 ViewModel 把状态机封装起来MainActor final class AgentViewModel: ObservableObject { enum ViewState { case idle case executing(skillID: String) case success(SkillResult) case failure(String) } Published private(set) var state: ViewState .idle private let agent: Agent init(agent: Agent) { self.agent agent } func run(_ request: UserRequest) async { state .executing(skillID: request.intent) do { let result try await agent.fulfill(request) guard !Task.isCancelled else { return } state .success(result) } catch { guard !Task.isCancelled else { return } state .failure(error.localizedDescription) } } }一个非常重要但容易被忽略的细节是在do-catch之外还要判断Task.isCancelled。用户如果在技能执行途中关闭了页面SwiftUI 的.task修饰符会自动帮我们取消任务这时你不能把取消当成错误展示给用户所以要先做一次取消检查。视图侧用法非常朴素struct ContentView: View { StateObject private var viewModel AgentViewModel(agent: Agent(manager: .shared)) State private var requestText var body: some View { VStack(spacing: 16) { TextEditor(text: $requestText) .frame(height: 120) Button(执行) { let request UserRequest( intent: summarize, parameters: [text: requestText], preferredSkillID: nil ) Task { await viewModel.run(request) } } statusView } .padding() } ViewBuilder private var statusView: some View { switch viewModel.state { case .idle: Text(等待输入) case .executing(let skillID): ProgressView(正在执行 \(skillID)…) case .success(let result): Text(result.message) case .failure(let reason): Text(失败\(reason)) .foregroundStyle(.red) } } }代码规范检查的时候要留意一个原则View 永远不直接调SkillManager只通过 ViewModel 跟 Agent 对话。一旦有人贪图方便直接从 View 里Task { try await SkillManager.shared.dispatch(...) }架构的边界就破了后续状态不同步的 bug 会成倍涌出来。3.4 写一个真实可用的示例技能空谈架构没意思这里写一个实际用到的“文本摘要”技能它把输入文本按段落切分返回前几个段落真实项目里可以替换成调用本地的某种机器学习能力。struct SummarizeSkill: AppSkill { let id skill.summarize let name 文本摘要 let capabilities [summarize, text] func execute(context: SkillContext) async throws - SkillResult { guard let text context.input[text] as? String, !text.isEmpty else { throw SkillError.invalidInput(text 参数缺失) } let paragraphs text .split(separator: \n) .map(String.init) .prefix(3) .joined(separator: \n) return SkillResult( output: [summary: paragraphs], message: paragraphs, duration: 0.01 ) } }在 App 启动入口注册它main struct MyApp: App { init() { let manager SkillManager.shared Task { await manager.register(SummarizeSkill()) // 其它技能注册 } } var body: some Scene { WindowGroup { ContentView() } } }这里有个容易踩的坑SkillManager.shared.register是 actor 方法必须在异步上下文里 await。把注册放在init里没问题但注意别用Task后马上就去dispatch因为注册任务不一定抢先完成。注册所有技能后再加载主视图或者给主视图加一个“技能就绪”的环境状态都是稳妥的做法。4. 常见问题与排查技巧实录4.1 并发问题最容易翻车的 SwiftUI 主线程更新几乎所有第一次使用这套架构的人都会踩同一个坑在技能内部执行耗时操作后直接修改了Published属性。这在开启严格并发检查Swift 6 语言模式的工程里编译阶段就会被圈出来但在旧工程里往往编译通过、运行崩溃或者状态不刷新。原因很简单技能执行体跑在任意 executor 上而Published的写操作必须在主 actor 上。正确做法是技能只通过返回值或异步序列传递数据由 ViewModel 在主线程统一消化// 错误写法技能里直接改 UI 状态 func execute(context: SkillContext) async throws - SkillResult { await MainActor.run { self.somePublishedProperty new // 这会让技能持有 UI 状态职责混乱 } } // 正确写法技能只返回数据 func execute(context: SkillContext) async throws - SkillResult { let value try await loadData() return SkillResult(output: [data: value], message: ok, duration: 0.1) }我早期为了调 UI 方便在技能里用await MainActor.run直接改界面状态短期看是省事了长期看技能变成了 UI 逻辑的一部分替换和复用都成了空话。我自己后来是把所有带MainActor.run的代码全部重构掉了经验是技能代码里不应该出现任何与视图对象相关的引用一旦出现就该怀疑设计出了问题。4.2 任务取消处理得不好会看到“卡住”的执行状态用户快速点击两次执行按钮或者中途切页SwiftUI 会为我们发起取消。但如果你在技能里写了不可取消的同步阻塞代码或者一直没检查Task.isCancelled那个执行中的转圈状态就会一直挂着。排查思路是给每个技能加打印点在入口和出口各输出一行。如果入口打印了而出口没有多半是函数内部死等或者被系统挂起如果两个打印都有但界面没恢复问题在 ViewModel 的取消检查上。我踩过最无语的一个坑是技能内部调用一个老的同步网络接口那个接口内部自己开了信号量做等待。信号量不响应任务取消导致整个技能挂了几十秒。修复方式是换成支持协程的异步接口或者在等待期间显式调withTaskCancellationHandler处理取消回调绝不能放任一个不可取消的阻塞点藏在技能深处。4.3 技能注册冲突和被覆盖问题actor 注册表本身是线程安全的但业务上会有逻辑漏洞两个技能用了同一个 id 注册后者会把前者覆盖掉。这个问题平时很隐蔽因为它们名称不同只有运行到某个分支时才惊觉行为不对。我在注册表里加了一个检查注册时如果发现 id 已存在且不是同一个实例就打印一条告警日志。这个方法不阻断覆盖但至少让你在开发期就能发现。另一个更根本的解决方案是禁止手动管理 id改成编译器校验/// 用类型名做天然唯一 id extension AppSkill { var id: String { String(reflecting: Self.self) } }这种方案有一点副作用一个技能类型只能注册一个实例。如果你真的需要同一类型的不同配置实例那只能退回手动 id 的路子但绝大多数场景下“类型即 id”已经够用而且彻底消灭了冲突问题。4.4 排查问题速查表现象大概率原因排查建议技能执行完界面没更新ViewModel 被从非主线程修改或Task.isCancelled提前 return检查 ViewModel 是否有MainActor在状态赋值前打断点确认线程技能互相串数据SkillContext被多个并发的调度共享并修改确认 Context 是值语义每次 dispatch 创建新实例不要在技能里透传同一个可变对象执行状态卡死技能内部有不可取消的阻塞点找信号量锁、dispatchSemaphore换成协程内 await注册后调不到注册 Task 没跑完就开始了 dispatch启动时先 await 注册闭包再创建主视图崩溃但无日志技能内部抛出了非 Swift 错误或强转失败在协议execute实现里套一层do-catch统一转成SkillError再往上抛排查这套架构的问题核心方法论是分层。先确认 View 收到了状态再确认 Agent 有没有收到请求最后看技能是否真的执行。跨层问题永远先怀疑边界不要一头扎进技能内部逻辑里浪费半小时。5. 经验与可继续扩展的方向5.1 这套结构后续还能怎么长Agent-Skill 骨架稳定之后扩展性非常自由。我目前已经在项目里加了几个变形一个是技能链。前面的PipelineSkill还在演进我给它加了一个中断机制某个子技能抛错时可以决定是终止整条链还是跳过继续这在数据清洗和巡检类任务里非常有用。另一个是基于 Swift Observation 框架做一个技能状态广播。技能执行过程中产生进度事件通过AsyncStream暴露给 ViewModel界面可以做实时进度条。这个不改变 Skill 协议本身只是在SkillContext里注入一个进度回调对象技能在耗时段落主动调用。还有一个偏技巧的玩法利用 SwiftUI 的 Scene 机制做桌面端的“技能面板”。技能即服务的架构天然适合每个技能占据一个面板用户拖拽组合成自己的工作流。Agent 变成了工作流编排器但这已经是另一个架构拓扑了适合有桌面端布局需求的场景。5.2 两条对我帮助最大的实操经验第一协议设计不要贪多。Skill 协议每多一个方法所有实现者都会多一分负担。我在重构时砍掉的方法包括validateInput、progressCallback、canHandle前两个事实证明可以通过上下文和流式机制替代第三个应该是 Agent 的决策范围。第二一定要做一个技能沙箱或者演示仪表板。我在项目里做了一个调试页面把所有注册进来的技能列出来传入参数点按钮执行。这个东西成本不高但对架构演进的帮助出奇的大每新增一个技能都可以在沙箱里独立验证不用开整个 App 走一遍业务流程。调试体验好了大家才愿意遵守架构。说实话这个架构不是银弹。如果你的 App 只有五六个页面所有逻辑都堆在一个 Service 层里也没问题引入 Agent 体系反而是过度设计。一旦你发现自己开始写“根据类型判断执行不同方法”的调度器或者组件间互相直接调用越写越多再把这套东西请出来也来得及。架构重构的时机很重要而 SwiftUI 加上这套轻量 Agent-Skill 模式给了你在复杂度到达临界点时一个相对清爽的出口。我个人每次回头读这套代码最舒心的一点是新增功能时从一个新技能文件开始也从它结束不再需要在一堆旧文件的边缘修修补补。