ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SwiftUI Agent-Skill架构:从零搭建模块化App骨架

SwiftUI Agent-Skill架构:从零搭建模块化App骨架

很多人拿到新项目标题的第一反应是上网搜教程、看别人的轮子,但我习惯先拆名字。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 模式,给了你在复杂度到达临界点时一个相对清爽的出口。我个人每次回头读这套代码,最舒心的一点是:新增功能时,从一个新技能文件开始,也从它结束,不再需要在一堆旧文件的边缘修修补补。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进