
第一次看到SwiftUI-Agent-Skill这个项目名我第一反应是这不就是把三个流行词拼在一起吗等真在模拟项目X里动手实现了一版我才意识到这个命名其实非常诚实——SwiftUI、Agent、Skill 分别代表了三层不同的东西界面层、调度层、能力层。这篇文章我会把整套实现思路从头讲清楚从 Skill 协议怎么设计开始到注册中心、Agent 调度器、SwiftUI 会话界面最后聊聊 Swift 6 严格并发下我踩过的几个坑。如果你正在做工具类 App或者想把一个功能越来越多的单体视图模型拆成可插拔的模块这篇文章应该能给你一份可以直接参考的代码骨架。1. 为什么在 SwiftUI 里引入 Agent 架构巨型ViewModel的教训1.1 一个能跑但没法扩展的典型视图模型先说我为什么会对这个方向感兴趣。模拟项目X最初是一个个人助手型应用功能很常规查提醒事项、创建日程、记笔记、打开网页、查天气。第一版我把所有逻辑都塞进了一个MainViewModel里面一排方法fetchWeather()、addReminder()、createNote()、openURL()……每个方法里又有权限判断、参数校验、UI 状态切换。功能少的时候确实爽加一个方法就能跑通一条链路。但功能到第十个的时候问题开始扎手。任何一个技能加了新状态比如天气查询支持多城市我得在MainViewModel里再改几个属性然后所有调用它的视图都要跟着调整。更麻烦的是测试MainViewModel的初始化参数越来越多依赖了网络层、本地存储、日历权限单测里 Mock 什么都很痛苦。真正让我下决心重构的是一次崩溃排查——一个和日程创建完全无关的天气接口超时居然因为共享的可变状态污染了整个视图模型的状态机导致 UI 卡死。1.2 把App拆成中枢和插件两层SwiftUI-Agent-Skill的思路是把 App 分成两个层次Agent 是一个中枢负责接收用户的请求、判断该叫哪个能力、协调执行顺序Skill 是具体能力独立打包、独立测试。App 启动时只需要注册所有 Skill之后 Agent 面对任何输入都走同一条路径——识别、路由、执行、回传。SwiftUI 视图只跟 Agent 打交道根本不关心背后是哪个 Skill 在干活。这个结构很像电话客服中心用户打进电话客服专员先听需求再转接到对应部门用户不需要知道后端有几个部门、每个部门怎么分工。对 SwiftUI 来说好处是视图层的对话流永远只有一套代码新增一个 Skill 既不改 ChatView也不改 Agent 的核心逻辑只要往注册表里塞一个新对象就行。经过几周的实践我认为对于功能数量会持续增长的 App 来说这种中枢 插件的划分比继续维护巨型 ViewModel 要划算得多。1.3 单体架构与Agent-Skill架构的直观对比维度单体ViewModelAgent-Skill架构新增功能改ViewModel、改视图、加Mock依赖新增一个Skill类型注册即可状态管理所有功能共享一份可变状态每个Skill内部自治Agent只转发结果测试需要构造大量前置依赖Skill可独立单测Agent用假Skill测试权限处理散落在各方法里Skill执行前统一走Agent确认流程视图层每个功能一套分支逻辑一套会话UI适配所有Skill这个表不是我拍脑袋总结的是模拟项目X重构前后的真实对照。后面几节会给出每个要素的具体代码而不是停留在概念层面。2. Skill协议与注册中心模块边界是第一道设计决策2.1 Skill协议要能听明白也要能干完活协议是整个架构的地基。设计协议时我想得很清楚每个 Skill 至少要回答两类问题——这个输入是不是找我的如果是我该怎么执行前者是意图识别后者是行为执行。在 Swift 里我把它拆成了两个方法struct SkillRequest: Sendable { let rawText: String let locale: Locale let date: Date let source: RequestSource } struct SkillMatch: Sendable { let action: any SkillAction let confidence: Double } protocol Skill: Sendable { var id: String { get } var displayName: String { get } var summary: String { get } func parse(_ request: SkillRequest) - SkillMatch? func execute(_ action: any SkillAction, in context: SkillContext) async throws - SkillResult }SkillRequest是一个值类型包含了当前输入、语言地区、时间等信息。这样设计是因为同一个文本在不同的时区或日期下可能产生不同语义比如提醒我明天早上开会里的明天必须依赖date字段才能算准。所有字段都要求Sendable这是为 Swift 6 严格并发提前铺路后面我会专门讲为什么。parse返回的是SkillMatch?里面包含具体的SkillAction和置信度。返回可选类型而不是直接抛错是刻意为之——一个 Skill 没识别出来不代表输入有问题可能只是这个请求不属于它Agent 需要继续问下一个 Skill。置信度则给 Agent 提供了当多个 Skill 同时命中时的裁决依据。2.2 注册中心所有能力统一登记有了协议之后下一步是让 Agent 能看见所有 Skill。我用了一个很简单的注册中心核心就是字典加两个方法final class SkillRegistry: Sendable { private var skills: [String: any Skill] [:] func register(_ skill: any Skill) { skills[skill.id] skill } func allSkills() - [any Skill] { Array(skills.values) } func skill(for id: String) - (any Skill)? { skills[id] } }有开发者会觉得这个注册中心多余直接在 Agent 里放一个数组不就行了但注册中心的价值在于UI 层也能通过它拿到所有 Skill 的元信息。比如对话页需要一个技能列表卡片区我只需要registry.allSkills()就能渲染全部能力如果未来要做设置页让用户开关某个 Skill也是在注册中心层面操作。集中登记、统一发现这是 Agent 调度和 UI 自动扩展共同的前提。2.3 两个用来验证协议的Skill协议和注册中心设计完我立刻写了两个最简单的 Skill 来验证设计是否够用。第一个是提醒事项struct ReminderSkill: Skill { let id reminder let displayName 提醒 let summary 创建提醒事项 func parse(_ request: SkillRequest) - SkillMatch? { guard request.rawText.contains(提醒) || request.rawText.contains(remind) else { return nil } let action ReminderAction( text: request.rawText, createdAt: request.date ) return SkillMatch(action: action, confidence: 0.8) } func execute(_ action: any SkillAction, in context: SkillContext) async throws - SkillResult { guard let action action as? ReminderAction else { throw SkillError.typeMismatch } // 写入本地存储返回结果 try await context.reminderStore.add(action) return .success(message: 已创建提醒\(action.text)) } }第二个是网页摘要。用户输入包含 URL 时它去抓取网页标题和描述。这个 Skill 的意义在于验证执行阶段含网络 IO的场景确保协议能撑住异步、错误处理等真实情况。写完这两个之后我基本满意但马上又暴露了一个问题parse太粗糙。提醒事项里如果混着别的意图比如帮我查一下明天天气顺便提醒我开会两个 Skill 都会命中。这个矛盾正是 Agent 调度器要解决的下一节展开讲。3. Agent调度器意图识别、参数匹配与执行安全阀3.1 让所有Skill都先听一遍而不是靠关键词硬匹配调度器的第一版实现我犯了个老毛病用关键词优先级决定谁先执行。比如提醒命中就直接走提醒流程根本不给其他 Skill 机会。结果就是提醒我明天看天气这种请求被错误地创建成提醒而不是触发天气查询。后来我把调度改成广播式——所有 Skill 对同一份输入分别parse收集所有SkillMatch再按置信度和业务规则选最佳候选人actor Agent { private let registry: SkillRegistry func handle(_ text: String) async - AgentResponse { let request SkillRequest(rawText: text, locale: .current, date: .now) var bestMatch: (skill: any Skill, match: SkillMatch)? for skill in registry.allSkills() { guard let match skill.parse(request) else { continue } if let current bestMatch, current.match.confidence match.confidence { continue } bestMatch (skill, match) } guard let best bestMatch else { return .needsClarification(message: 我没理解你的意思试试说提醒我...) } do { let result try await best.skill.execute(best.match.action, in: context) return .result(result) } catch { return .failure(error) } } }广播式的核心价值是多候选竞争而非单点拦截。每个 Skill 的confidence可以基于内部规则精细化——比如某个 Skill 识别到明确动词时可以给到 0.9识别到模糊上下文时只给 0.4。这样即便一个输入同时命中多个技能Agent 也能做出相对合理的裁决而不是依赖注册顺序这种脆弱逻辑。3.2 SkillResult 的三种形态成功、失败、需要确认执行结果如果只有成功/失败两种在工具类 App 里是不够的。很多操作是不可逆的比如发送消息、删除文件、修改设置。我设计了三种结果的SkillResultenum SkillResult: Sendable { case success(message: String, payload: [String: Any]? nil) case failure(SkillError) case needsConfirmation(message: String, action: any SkillAction) }当某个 Skill 的execute内部发现这个动作必须用户确认时它返回needsConfirmation而不是直接执行。调度器拿到这个结果后会把它原样传给 UI 层UI 层弹确认框用户点了确认再把action原封不动地交还给 Agent 重新执行。这里的关键是Skill 自己决定哪些动作需要确认Agent 只做转发不越权替 Skill 做决定。这样既保证了安全又保持了每个 Skill 的自治性。3.3 超时、取消与调度器的自我保护Agent 调度器还有一个容易忽略的职责保护自己不被慢 Skill 拖死。网络请求可能长时间挂起第三方能力可能死循环。我在调度器里给每次执行套上了超时控制func executeWithTimeout(_ action: any SkillAction) async throws - SkillResult { try await withThrowingTaskGroup(of: SkillResult.self) { group in group.addTask { try await self.context.makeExecutor().execute(action) } group.addTask { try await Task.sleep(for: .seconds(15)) throw SkillError.timeout } guard let first try await group.next() else { throw SkillError.timeout } group.cancelAll() return first } }这个模式很实用。两个子任务竞争谁先完成谁赢慢的一方会被cancelAll()取消。注意取消不是强杀Skill 内部如果做了长时间占用 CPU 的同步操作仍然需要自己响应Task.checkCancellation()否则超时只是看起来结束了线程还在卡着。4. SwiftUI会话界面技能卡片、确认弹层与状态流4.1 从 AgentState 到视图模型让 Agent 与 UI 单向通信协议和调度器都在业务层SwiftUI 这边需要一个稳定的状态容器。我用Observable宏做了一个AgentStore持有 Agent 的轻量状态所有视图都从它读取MainActor Observable final class AgentStore { enum ConversationState { case idle case thinking case waitingConfirmation(String, any SkillAction) case displaying(SkillResult) } private(set) var messages: [ChatMessage] [] private(set) var state: ConversationState .idle func send(_ text: String) async { messages.append(.user(text)) state .thinking let response await agent.handle(text) switch response { case .result(let result): messages.append(.assistant(result)) state .displaying(result) case .needsClarification(let message): messages.append(.assistant(message)) state .idle case .failure(let error): messages.append(.assistant(出错了\(error.localizedDescription))) state .idle } } }MainActor标注很关键。Agent 的handle可能在后台执行但 UI 状态只能在主线程更新。我在实践中发现如果把 Agent 内部也标成MainActor网络请求就会被主线程阻塞体验很差。所以正确的分工是Agent 调度器不要锁主线程只在结束回传时切回主线程更新状态。4.2 技能卡片UI 跟随注册表自动扩展要让 UI 层自动认识新 Skill我让 Skill 协议增加一个 UI 描述层。考虑到 Swift 协议里放ViewBuilder属性会牵扯泛型复杂度我用了一个简单的折中方案Skill 提供卡片标题和摘要SwiftUI 侧统一渲染。struct SkillCardView: View { let skill: any Skill var body: some View { HStack { Image(systemName: iconName(for: skill.id)) VStack(alignment: .leading) { Text(skill.displayName).font(.headline) Text(skill.summary).font(.subheadline).foregroundStyle(.secondary) } } .padding() .background(.thinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } }对话页顶部放一个横向滚动的卡片区每张卡片代表一个已注册的 Skill。新增功能时UI 自动多出一张卡片一行代码都不用改。这个特性在演示时特别加分但实际上它倒逼了一个约束Skill 的元信息必须足够简洁规范不能为了视觉效果随意加定制字段。4.3 确认弹层让不可逆操作先过一道用户关卡当AgentStore.state变成waitingConfirmation时我在视图层弹出一个对话框。用户确认后重新把同一个 action 交给 Agent 执行。视图层只负责展示和收集用户意图不直接调用 Skill.confirmationDialog( 确认执行这个操作吗, isPresented: $showConfirmation, presenting: store.pendingAction ) { action in Button(确认) { Task { await store.confirmAndExecute(action) } } Button(取消, role: .cancel) {} } message: { action in Text(即将执行\(String(describing: action))) }这里有个细节容易踩坑不要在waitingConfirmation状态里直接存any SkillAction的引用而是作为AgentStore的一个独立属性保存。因为 SwiftUI 的弹层绑定是双向的状态一旦被重置待确认的 action 可能变成悬空引用。我把待确认 action 单独存放在pendingAction和state解耦这样弹层消失不会影响后续执行。4.4 结果展示SkillResult 到消息列表的映射执行结果最终要落到消息列表。我对SkillResult做了一个简单的映射成功时把message显示成气泡失败时显示错误文案。如果 payload 里有结构化数据比如天气 Skill 返回温度和城市我会用一个小型ResultPayloadView渲染成表格或标签而不是让 Skill 自己扔一段 Markdown。这个设计背后是我反复测试后的体会Skill 的结果展示最好由 UI 层统一约束而不是每个 Skill 自由发挥。如果允许每个 Skill 传自定义视图界面风格很快就会失控而且 UI 层会被 Skill 的具体类型侵入失去插拔的意义。保持结果 消息 可选结构化数据的约定能让十个 Skill 和一白个 Skill 的对话页长得一样有秩序。5. 实测中绕不开的坑Swift 6严格并发、技能隔离与可测试性5.1 Swift 6 严格并发下协议和跨境对象必须显式 Sendable模拟项目X升级到 Swift 6 编译模式后第一轮编译错误全出现在 Skill 协议链路上。原因很直接Agent是 actorSkill.execute的入参和返回值都要跨 actor 边界传输Swift 编译器要求这些类型显式Sendable。我最初在SkillResult.success里放了[String: Any]payload编译直接报错——Any不满足Sendable。解决办法是把 payload 改成[String: Sendable]并且杜绝放入Any。具体的值类型比如Double、String、Date都是天然的Sendable但一旦有复杂的自定义类型就必须让它显式遵守协议。这算是一次被迫正确的约束反而让我重新检查了所有跨界数据少了很多隐性的数据竞争风险。5.2 技能之间为什么必须隔离以及 SkillContext 的正确用法早期版本里我在 Skill 内部直接访问一个全局的DataStore.shared希望所有技能共享同一个存储。结果在并发执行时出现了两个 Skill 同时写同一份数据的情况偶尔触发主线程崩溃。后来我把共享存储收进SkillContext它是一个值类型但内部引用了统一的并发存储对象。struct SkillContext: Sendable { let reminderStore: ReminderStore let noteStore: NoteStore let userPreferences: UserPreferences }每个 Skill 只能拿到context中允许它访问的那几个 store。比如提醒 Skill 拿不到笔记存储笔记 Skill 拿不到提醒存储。这个隔离不是我一开始就想到的是被一次数据错乱事故逼出来的。隔离之后的收益很直接某个 Skill 出 BUG 时排查范围被限制在它自己的存储域内不会波及整个 App 的数据。5.3 用假 Skill 测试 Agent用真参数测试 Skill最后聊聊测试。Agent 的调度逻辑必须用固定输出的假 Skill 来测否则每次网络结果不同断言没法写。我写了一个FakeSkillparse永远返回固定置信度的SkillMatchexecute永远返回固定结果。这样测试handle(test)就能准确判断 Agent 是否选对了 Skill、超时逻辑是否正确。而真实 Skill 的测试则聚焦在parse的意图识别和execute的参数处理上。以提醒 Skill 为例我针对不同句式写了好几个测试提醒我明天9点开会 应命中并提取时间帮我查天气 不应该命中提醒删除所有提醒事项 应该返回needsConfirmation而不是直接执行。这些测试不仅在重构时保护了我也反过来帮我发现了parse里几个边界条件的遗漏。比如带相对时间的句子如果不结合request.date计算就会把明天当成字符串关键字根本拿不到正确的时间参数。测试驱动在这里不是口号是真的能逼着你把协议和边界想清楚。最后再分享一个实际操作中的小经验SwiftUI-Agent-Skill这类架构的落地不建议一上来就全量重构现有 App。正确做法是先挑一个最独立、最边缘的功能比如网页摘要抽成第一个 Skill跑通 注册 → 调度 → 执行 → 确认 → 展示 的完整链路再逐步把核心功能迁移进去。我当初就是贪快第一周就想把日历、提醒、笔记全部技能化结果状态流交叉出各种问题。先从一个小而完整的闭环开始等协议稳定了再扩张这条路会顺得多。