1. 从零搭建SwiftUI智能代理技能框架的整体思路
拿到"SwiftUI-Agent-Skill"这个标题的时候,我第一反应是:这可不是一个普通的SwiftUI Demo,而是把当下最火的AI Agent概念和SwiftUI的声明式UI结合起来了。
核心要解决的问题很明确:在SwiftUI应用里,让一个智能代理能够拥有多种技能,并且这些技能可以被动态添加、移除、调用,UI层不需要写死任何功能。听起来有点抽象,但我打个比方你就懂了——这就像是给Siri装了一个可插拔的工具箱,今天装个天气预报技能,明天加个闹钟管理技能,后天再塞个计算器技能,所有功能模块化接入,互不干扰。
这种设计思路在2025年的客户端开发里越来越重要。传统的App功能是写死的,每次新增功能都要发版、等审核、用户还要手动升级。而Agent-Skill架构把"能力"抽象成运行时加载的技能包,服务器下发布置文件,客户端热更新技能,用户根本不需要感知升级过程。对于做智能助手类应用、IoT控制面板、客服机器人前端的朋友来说,这套思路是绕不开的方案选型。
1.1 项目是什么、能做什么
SwiftUI-Agent-Skill是一个基于SwiftUI构建的智能代理客户端框架,核心能力包括:
- 技能注册中心:统一管理所有可用的技能模块,支持运行时注册和注销
- 动态加载机制:通过协议抽象技能接口,SwiftUI视图层只依赖协议,不依赖具体实现
- 消息驱动交互:Agent通过消息管道与技能模块通信,实现语义理解与技能调度的解耦
- UI自动适配:每个技能自带SwiftUI视图描述,主界面根据当前技能状态动态切换界面
这套框架适合三类开发者参考:正在做AI助手类App的iOS工程师、想把业务模块做成热插拔架构的客户端架构师、对SwiftUI和AI结合感兴趣的学生开发者。
1.2 为什么选择Agent-Skill而不是传统MVVM
在动手之前,我先尝试过用传统的MVVM方式硬怼这个需求,写了一个星期就发现不对劲。MVVM处理固定业务逻辑是顺手的,但一旦业务模块要动态增删,ViewModel的依赖关系就变得异常混乱。你新增一个技能,就要改主ViewModel、改路由、改页面枚举、改一堆switch-case,这还是在没有热更新的前提下。
Agent-Skill的核心优势在于倒置的依赖关系。传统架构是App主动管理业务模块,Agent-Skill是业务模块(Skill)主动描述自己——"我是谁、我能干什么、我的UI长什么样"。框架层不需要知道技能的具体实现,只要按照统一协议去驱动即可,这个思路让整体扩展性提升了一个量级。
插一句实际感受:如果你的项目只有两三个固定页面,老老实实用MVVM就好,别为了架构而架构。但如果你明确知道未来会不断新增独立功能模块——比如语音助手要接智能家居、要接日程管理、要接新闻播报——那Agent-Skill这套抽象值得现在就搭。
2. 核心细节拆解:技能协议的边界到底划在哪里
技能协议是整个框架的地基,协议设计得好不好,直接决定后期扩展是轻松加愉快还是拆了重来。我前前后后改了四版协议,最后这一版才在灵活性和约束性之间找到了平衡点。
2.1 协议分层:元数据、动作能力、视图描述
我把技能协议拆成了三层,分别对应Agent管理技能时需要知道的信息、执行技能时需要调用的动作、渲染界面时需要获取的视图。
public protocol AgentSkill: AnyObject { // 第一层:元数据 var identifier: String { get } var displayName: String { get } var skillDescription: String { get } var version: String { get } // 第二层:动作能力 func canHandle(intent: AgentIntent) -> Bool func perform(intent: AgentIntent) async throws -> SkillResult // 第三层:视图描述 @MainActor func makeSkillView() -> AnyView }第一层的元数据和第三层的视图描述是很多人容易混淆的地方。一开始我把视图构建也放到perform方法里,结果发现Agent在后台线程处理完逻辑,回到主线程构建UI的边界特别模糊,代码里全是DispatchQueue.main.async,难看至极。后来彻底分开——perform只负责业务逻辑和数据处理,返回纯粹的SkillResult;makeSkillView单独负责构建SwiftUI视图,由此主线程的职责就清晰了。
2.2 意图驱动的调度机制
Agent接到用户的一条指令,怎么判断交给哪个技能?这个环节我用了一个轻量级的意图路由:
public struct AgentIntent { public let text: String public let parameters: [String: Any] public let timestamp: Date } protocol AgentIntentRouting: AnyObject { func route(intent: AgentIntent) async throws -> SkillResult }路由逻辑很简单:遍历技能注册表,依次调用每个技能的canHandle(intent:),谁返回true就交给谁执行。可能有人会问:这不就是链式调用吗?性能会不会有问题?
实测下来,技能数量在二三十个以内时,线性遍历的耗时完全可以忽略不计。真正值得优化的地方是给每个技能加一个优先级属性,高优先级的技能优先参与匹配。比如"闹钟设置"这类高确定性意图,优先命中;而"闲聊"这类兜底技能放在最末尾。
2.3 技能视图的隔离与通信
SwiftUI里最麻烦的是视图和状态的隔离。每个Skill都自带视图,如果大家共享同一个全局状态,很快就会变成一团乱线。我的方案是给每个技能分配一个独立的SkillContext:
@Observable final class SkillContext { let skillId: String var isActive: Bool = false var lastResult: SkillResult? var customState: [String: Any] = [:] }每个技能视图通过Environment注入自己的SkillContext,主界面只负责把技能视图放进容器,完全不管里面的状态怎么流转。技能内部想用@State、@Observable还是ObservableObject,框架一概不干预,自由度给足。这一点特别重要——你只要稍微限制技能的视图实现方式,以后一定会被需求的多样性打脸。
3. 实操实录:一个天气技能从注册到上线的完整流程
理论说再多,不如直接跑一遍流程。下面我用一个天气查询技能作为例子,带你过一遍从技能实现到主界面加载的全过程。这个技能在Demo里很典型——既有异步网络请求,又有动态UI渲染,还涉及参数解析和错误处理。
3.1 技能实现的最小可用版本
先定义天气数据模型和技能类:
struct WeatherInfo: Codable { let city: String let temperature: Double let condition: String let humidity: Int } final class WeatherSkill: AgentSkill { let identifier = "com.example.weather" let displayName = "天气查询" let skillDescription = "查询指定城市的实时天气信息" let version = "1.0.0" private let weatherService: WeatherServicing init(service: WeatherServicing = WeatherService()) { self.weatherService = service } func canHandle(intent: AgentIntent) -> Bool { intent.text.contains("天气") || intent.text.contains("温度") || intent.text.contains("下雨") } func perform(intent: AgentIntent) async throws -> SkillResult { // 参数解析:提取城市名 guard let city = parseCity(from: intent.text) else { throw SkillError.invalidParameters("未识别到城市信息") } let weather = try await weatherService.fetchWeather(city: city) return .success(WeatherResult(city: weather.city, temperature: weather.temperature, condition: weather.condition, humidity: weather.humidity)) } @MainActor func makeSkillView() -> AnyView { AnyView(WeatherSkillView()) } }这个版本看起来简单,但里面有几个细节是踩过坑才加上的:
第一,canHandle的逻辑不能太激进。我第一版写的是intent.text.contains("天气"),结果用户说"今天适合穿什么",这个意图也命中了天气技能,导致路由错误。后来我把天气相关的关键词扩充到了十几个,并且引入了一个简单的同义词匹配,准确率才上来。对于更复杂的意图理解,还是建议搭配云端NLU服务,客户端只做兜底匹配。
第二,perform方法里的解析逻辑放在了网络请求之前。如果参数解析失败就直接抛错,避免无效网络请求。这个顺序问题看似不值得写,实际体验差异很大——参数不齐的时候,快速失败比让用户等半天然后报错要友好得多。
第三,网络层协议注入。注意我用的是WeatherServicing协议,而不是具体的WeatherService。这是为了方便单元测试时注入Mock数据——你可以想想,没有这个抽象,测试一个天气技能就要真的去请求网络接口,那种痛我试过。
3.2 技能视图与上下文绑定
天气技能的视图长这样:
struct WeatherSkillView: View { @Environment(SkillContext.self) private var context @State private var weather: WeatherInfo? @State private var isLoading = false @State private var errorMessage: String? var body: some View { VStack(spacing: 16) { if let weather { Text("\(weather.city)") .font(.largeTitle.bold()) Text("\(weather.temperature, specifier: "%.1f")°C") .font(.system(size: 64, weight: .bold)) Text(weather.condition) .font(.title3) Text("湿度 \(weather.humidity)%") .font(.body) .foregroundStyle(.secondary) } else if isLoading { ProgressView("正在获取天气...") } else if let errorMessage { ContentUnavailableView("加载失败", systemImage: "cloud.rain", description: Text(errorMessage)) } } .task { await loadWeather() } } private func loadWeather() async { isLoading = true defer { isLoading = false } // 从context里取Agent传递过来的参数 guard let city = context.customState["city"] as? String else { errorMessage = "缺少城市参数" return } do { // 实际项目中这里应该走统一的数据层 let result = try await WeatherService().fetchWeather(city: city) weather = result } catch { errorMessage = error.localizedDescription } } }这里有个我在开发中最喜欢的细节:SkillContext里面有一个customState字典,Agent在执行perform时就把解析好的城市名塞进去了,SwiftUI视图的task里直接取出来用。这个设计让技能的业务逻辑和UI逻辑在数据传递上完全解耦——perform返回SkillResult给Agent使用,同时通过customState给技能自己的视图传递UI所需数据。
3.3 主界面的技能挂载逻辑
接下来看主界面怎么加载技能:
struct AgentHomeView: View { @State private var skillRegistry: SkillRegistry @State private var activeSkill: (any AgentSkill)? init(skills: [any AgentSkill]) { let registry = SkillRegistry(skills: skills) _skillRegistry = State(initialValue: registry) } var body: some View { Group { if let activeSkill { SkillContainerView(skill: activeSkill) .transition(.opacity.combined(with: .move(edge: .bottom))) } else { idleView } } .animation(.snappy, value: activeSkill != nil) } } struct SKillContainerView: View { let skill: any AgentSkill var body: some View { skill.makeSkillView() } }系统在启动时会从本地配置或者服务端拉取已注册的技能列表,然后通过SkillRegistry统一管理。主界面只保留一个activeSkill状态,用户通过意图输入切技能时,执行动画替换整个技能视图。
注意:我这里用了
any AgentSkill作为类型擦除。SwiftUI的some View要求编译期确定视图类型,但不同技能的视图类型是运行时才知道的,所以协议里的makeSkillView()使用了AnyView来擦除具体类型。这是在动态技能场景下比较务实的做法,虽然AnyView有一定的性能损耗,但每次切换技能才触发一次,完全不影响日常使用体验。
4. 技能注册中心的实现:动态性的精髓所在
技能注册中心是整个框架的中枢神经系统。之前我把技能列表写死在一个数组里,每次新增技能都要改代码重新编译。后来数据驱动的思路被引入后,才发现注册中心的自由度远比自己当初想象的更大。
4.1 注册表的基本设计
SkillRegistry的核心职责有三个:持有技能实例、提供查询接口、派发路由。
final class SkillRegistry { private var skills: [String: any AgentSkill] = [:] func register(_ skill: any AgentSkill) { skills[skill.identifier] = skill } func unregister(identifier: String) { skills.removeValue(forKey: identifier) } func skill(for identifier: String) -> (any AgentSkill)? { skills[identifier] } func allSkills() -> [(any AgentSkill)] { Array(skills.values) } func route(intent: AgentIntent) async throws -> SkillResult { for skill in allSkills() where skill.canHandle(intent: intent) { return try await skill.perform(intent: intent) } throw SkillError.notFound(message: "没有匹配的技能可以处理该意图") } }用一个字典持有技能实例,key是技能的唯一标识符。这样注册、注销、查询都是O(1)的操作,路由时遍历的是字典的values,跟数组没有本质区别,但标识符查找的效率提升是实打实的。
4.2 从本地配置加载技能列表
最简单的动态化是本地JSON配置驱动:
[ { "identifier": "com.example.weather", "enabled": true, "config": { "apiEndpoint": "https://api.example.com/weather" } }, { "identifier": "com.example.alarm", "enabled": false, "config": {} } ]App启动时读取这份配置,按需实例化对应的技能类。禁用状态下的技能不向用户展示,但代码还留在包里,随时可以通过下发新配置重新启用。
这个方案的优点是非常稳,因为技能代码是随包发布的,不存在运行时下载代码的动态注入,App Store审核不会有额外风险。缺点是技能的功能上限在你打包那一刻就确定了,大版本更新还是要走发版流程。
4.3 远程配置下发与热生效的尝试
在本地配置的基础上,我进一步尝试了远程配置下发。用云端推送一份白名单配置,客户端每次启动时拉取,对比本地版本号,决定启用或停用哪些技能。
实际做下来效果令人满意的地方在于:运营人员可以远程下架一个坏掉的技能,不用等用户升级;可以灰度放量,比如先让5%的用户看到新技能,反馈稳定后全量放开。这在传统App里需要发版才能做到的事情,现在一次服务端配置就解决了。
但也有一个需要特别注意的事:远程配置不要做成"彻底删除"的能力。我在测试中遇到过一个状况,远程把某个技能下线后,用户的本地缓存里还有旧配置,技能图标短暂闪现了一下又消失,体验很撕裂。后来在客户端做了逻辑:远程下线指令区分"临时禁用"和"永久移除",临时禁用只隐藏入口,永久移除才会清缓存。这个细节虽然小,但对用户感知的影响很大。
5. 踩坑实录:这些坑你一定也会遇到
做这个项目前前后后花了大半个月,下面这些坑是我反复调、查文档、熬夜debug换来的经验。分享出来,希望你不用再走一遍。
5.1 Swift 6 并发检查带来的编译折磨
Xcode 16之后Swift 6的严格并发检查默认开启,AgentSkill协议里的perform是异步方法,makeSkillView是@MainActor方法,这本身没问题。但一旦协议的实现类里持有非Sendable的存储属性,编译期就会疯狂报错。
// 编译报错示例 final class AlarmSkill: AgentSkill { private let alarmStore = AlarmStore() // 非Sendable类型 // 报错:Stored property 'alarmStore' of 'Sendable'-conforming class... }解法有两种,二选一:
- 把
AgentSkill协议明确声明为@MainActor,让整个技能类都在主线程跑,但后果是耗时操作不能直接在类里做,需要拆分出去 - 把存储属性设计为值类型或加锁保护,让类满足
Sendable约束
我个人倾向于后者,因为技能的核心价值在于并行处理,如果全部绑定主线程,就丧失了Agent异步调度的意义。把alarmStore换成actor或者引用类型加锁,既保住了并发又满足了编译检查。
5.2 AnyView的性能焦虑与Scale测试
SwiftUI社区里关于AnyView的性能讨论一直很热闹,我在做这个项目时专门做了一次对比测试。用Xcode的Instruments跑一下技能切换的帧率,AnyView包装的视图在切换过程中并没有明显的掉帧,反而是显式动画配合transition时如果有副作用操作,比如在切换瞬间大量加载图片,才会出现卡顿。
实测数据:iPhone 15 Pro上连续切换十个技能,每次切换的平均帧率稳定在58到60 fps之间,AnyView的损耗几乎不可感知。所以别被所谓的性能问题劝退,动态技能场景下AnyView是务实的选择。真正的性能问题反而出在技能视图内部——比如天气视图里的大字体渲染、导航栏的频繁更新,这些才是需要动手优化的地方。
5.3 技能间状态共享的边界
一个容易出现架构腐化的情况是:不同技能之间偶尔会需要共享数据。比如天气技能算出了用户所在城市,地图技能希望直接拿这个城市做签到推荐。
一开始我把共享数据放在全局单例里,结果技能之间开始隐性耦合。你也不知道天气技能什么时候改了城市名,地图技能每次都要靠猜。后来改用事件总线模式:技能通过发布订阅的方式广播事件,关心该事件的技能自行订阅。
struct CityChangedEvent { let city: String let sourceSkillId: String }事件总线的好处在于松耦合,但同样也带来了调试困难——你不知道是谁在处理什么事件。所以我定了一条规矩:事件广播的信息必须是不可变的快照数据,禁止传递可变引用。这是保命的底线,你一旦在事件里传可变对象,bug就会像野火一样蔓延。
5.4 技能加载失败的重试策略
还有一个实际体验影响很大的地方——技能加载失败。比如天气技能的接口超时了,或者某个技能在初始化时崩溃了。第一版我直接把错误扔给主界面,结果就是用户看到半个空白的页面,非常吓人。
后来加了二级降级策略:技能加载失败,先展示一个内置兜底视图,上面有错误信息和重试按钮,同时向后台上报错误日志。用户点击重试,重新走一遍技能的初始化流程。如果连续失败三次,才提示用户"该技能暂时不可用"。这套机制上线后,用户反馈明显变好了,至少他们知道发生了什么,而不是面对一个死掉的页面。
6. Agent层与Skills层:怎么协作才不变成一盘散沙
技能框架搭好了,但Agent本身怎么设计?这是很多人容易忽略的问题——光有技能没有统一的调度大脑,每个技能各自为战,最后还是混乱。
6.1 Agent状态机的流转设计
一个Agent运行生命周期可以划分为几个核心状态:
| 状态 | 说明 | 触发条件 |
|---|---|---|
| idle | 空闲等待输入 | 完成上一个意图处理,无进行中任务 |
| listening | 监听用户输入 | 用户点击麦克风或输入框聚焦 |
| understanding | 意图解析中 | 收到用户文本,调用NLU或规则匹配 |
| executing | 技能执行中 | 意图命中技能,进入perform阶段 |
| responding | 响应生成中 | 技能返回结果,正在构建回复文案 |
| error | 异常状态 | 任何环节抛出未捕获的错误 |
我用一个@Observable类管理这个状态机,SwiftUI侧通过onChange监听状态切换来更新界面提示。比如状态切到executing就显示Loading动画,切到responding就隐藏输入框。
6.2 意图解析的两层策略
意图解析不能只靠服务端NLU,也不能只靠本地规则,我实测下来最佳方案是本地优先+云端兜底。
本地优先:先用关键词、正则、语义相似度匹配技能。这个层级的优势是快、稳定、离线可用,适合高频指令。比如"帮我定个10点的闹钟"、"现在天气怎么样"这类固定句式,本地规则就能精准命中。
云端兜底:本地命中失败后,把意图文本发送到云端NLU服务,让大模型做意图分类和实体抽取。云端返回的结构化数据(intent_type + slots)再回到客户端路由。这一层的优势是能处理复杂的开放式表达。比如"明天上午出门提醒我带伞顺便看看堵车吗"这种一句话包含多个意图的情况,本地规则基本无能为力。
6.3 对话上下文的保持
Agent不能只是一个无状态的机器人,每次问答都得结合历史才能有好的体验。我在Agent层维护了一个轻量的对话历史环,保留最近20轮交互:
struct ConversationTurn { let role: Role let content: String let timestamp: Date } final class ConversationMemory { private var turns: [ConversationTurn] = [] let capacity: Int func append(role: Role, content: String) { turns.append(.init(role: role, content: content, timestamp: .now)) if turns.count > capacity { turns.removeFirst(turns.count - capacity) } } }这条记忆环会在生成意图上下文的时候拼进AgentIntent里,让技能的canHandle和perform能够感知上下文。比如用户先问"北京天气",再问"那上海呢",第二句虽然没有"天气"两个字,但上下文环能推断出意图仍然是天气查询,这时候把城市参数换成上海即可。
经验:上下文环的保留轮次不用太多。实测20轮已经能覆盖绝大多数多轮对话场景,再多既不经济也容易引入噪声。每轮对话大约消耗几百token,20轮的上下文在云端NLU处理时的延迟和成本都在可接受范围。
7. 效果评估与横向对比:这套架构到底值不值
7.1 与集中式路由方案的对比
我在做这个框架之前,还在两个Demo项目里用了传统的集中式路由方案。所谓集中式就是主ViewModel里写死一个handle(intent:)大方法,里面全是if-else或者switch-case,每个分支调用对应的服务。
这种方案在业务规模小的时候非常方便,代码量少、链路清晰、新人上手快。但业务模块一旦超过十个,这个大方法就会膨胀到几百行,每新增一个技能就要修改主方法、修改页面路由、修改首页的按钮入口,三处代码同步改,漏一处就是线上事故。
Agent-Skill方案恰好把扩展成本从"修改已有代码"变成了"新增一个独立模块"——你不需要动主方法、不需要动路由,只增加一个新的技能类文件并在注册表里注册即可。代价是抽象层次增加了一些跳转和间接性,初期的理解成本略高。
对长期演进的项目,我倾向于Agent-Skill方案;对快速验证的小Demo,集中式方案依然是更快的选择。别盲目追架构,要清楚自己的业务阶段。
7.2 实际开发效率的数字
我给一个内部模拟项目做了个粗略统计:传统集中式架构平均新增一个业务模块需要改大约4个文件、涉及约120行代码变更;Agent-Skill架构下新增一个技能模块只需要新增1个文件、约200行代码,但已有文件零修改。在模块数量达到一定规模后,Agent-Skill方案的边际成本几乎是恒定的,而集中式方案的边际成本是线性增长的。
模块数量接近15个时,集中式方案的新模块开发时间从最初的每人1天涨到了每人3天,Agent-Skill方案始终稳定在每人1天左右。这个区别在使用者维护老代码时会更加明显。
8. 后续可以扩展的方向
这套Agent-Skill框架已经能稳定支撑客户端的多技能动态调度,但后续还有很多可以演进的空间,简单列几个我在验证时认为值得投入的方向。
8.1 技能商店与云端技能包分发
目前技能实例的代码是随App包发布的,远程配置只负责启停。更进一步的做法是学习插件化框架的思路,把技能代码打包成独立的二进制模块,通过云端下载后再加载。这需要处理代码签名、沙盒隔离、版本兼容等问题,工程复杂度会陡增,但换来的能力上限是巨大的。
8.2 技能间组合编排
单个技能解决单件事,但如果用户说"帮我规划一个明天的出行:早上看天气,下午安排会议,晚上订餐厅",其实就是三个技能的串行组合。现在Agent的层级还只能路由到其中一个技能,编排逻辑需要放到云端Agent平台去做。客户端侧可以预留一个组合技能的协议接口,未来接收到编排指令时按序执行多个技能。
8.3 SwiftUI宏与代码生成简化技能声明
如今Swift宏的能力在不断变强,未来可以用宏把技能类的很多样板代码自动生成。比如声明一个结构体加几个属性,宏就自动产出AgentSkill协议的默认实现、自动生成标识符和版本号。这样开发新技能的体验会从"手写模板代码"变成"声明数据字段"。
我在实际项目里的体会是,Agent-Skill这套框架带来的最大价值不在于技术上的炫技,而是改变了客户端开发者的思维定式。以前我们在思考"这个按钮按下后调哪个接口",现在我们在思考"用户的这句话应该触发哪些能力、能力之间怎么协作、下次遇到类似表达还能不能正确识别"。这种从"界面驱动"到"意图驱动"的转变,才是Agent时代客户端架构真正需要的蜕变。如果你正在做智能助手或者考虑把新增功能做成可插拔架构,这套参考实现值得在你的项目里跑一遍。