最近在做一个小项目,项目名就叫SwiftUI-Agent-Skill。名字拆开看其实就是一个技术栈组合:SwiftUI 负责界面层,Agent 是智能调度核心,Skill 则是可以无限扩展的技能库。我的需求很简单,想在 iOS 和 macOS 应用里塞一个“能用工具”的 AI 助手,但又不想每次从零写 Prompt 编排、工具调用和状态同步,于是就把这套思路沉淀成了一个可复用的项目。如果你也在折腾苹果端的 Agent 应用,或者想给自己的 App 加一个能查天气、能算数据、能连接系统能力的“插件大脑”,这篇内容应该能给你一个相对完整的参考。
1. 项目定位:SwiftUI 如何承载 Agent 与 Skill
先把项目定位说清楚。SwiftUI-Agent-Skill不是某一个大而全的产品,而是一个偏向工程落地的架构方案。它的核心思路是:用户通过 SwiftUI 界面发出指令,Agent 收到指令后理解意图,然后在注册好的 Skill 列表里找到对应的技能,执行它,最后把结果返回给界面。整个过程像是一个轻量级的插件系统,只是这个系统里既有 UI 交互,又有智能决策,还有工具调用。
1.1 标题拆解:三部分各自的职责
很多人看到这类项目名会下意识觉得是“又一个 AI 聊天框”,其实不是。SwiftUI、Agent、Skill 这三个词分别代表了三条完全不同的技术线。
SwiftUI 负责的是“呈现”。所有用户看到的列表、按钮、聊天记录、技能配置页,都是通过声明式语法搭出来的。它的好处是状态驱动界面,界面会跟着数据自动变化。比如技能状态从“未注册”变成“运行中”,界面上只需改变一个@Published属性,整个视图就跟着更新,不需要手动去操作 cell。
Agent 负责“调度”。它是一个中心化的枢纽,接收用户输入后,判断应该调用哪个技能,并且把参数从自然语言中抽取出来。这里可以做得简单,也可以做得复杂。简单的方案就是关键词匹配或规则路由,复杂的可以接入大模型做意图识别和参数抽取。我在实际项目里用的是折中方案:优先走规则匹配,匹配不到再交给模型判断,这样既快又省钱。
Skill 负责“执行”。每一个技能都是一个独立的功能模块,比如天气查询、计算器、发送通知、读取剪贴板。它们都遵循同一个协议接口,注册到 Agent 里之后,Agent 完全不关心技能内部是怎么实现的,只关心它能不能处理当前意图。这种设计让技能成了可以插拔的外挂设备,新增一个能力只需要新写一个 Skill,不用动主工程。
这三部分组合起来,项目就变成了一个“有界面的智能工具台”。用户看到的是一个正常的 iOS 应用,背后却有一套能理解任务、调用工具的完整链路。
1.2 为什么选 SwiftUI 而不是传统 UIKit 或跨端方案
我最早其实犹豫过要不要用 UIKit 来做。UIKit 固然成熟,但它的代码量太大了。一个简单的技能列表需要在UITableViewDataSource里写一堆代理方法,状态稍微一变就要手动刷新。而 SwiftUI 不用,它的List直接绑定一个数组,数据变了视图自己会更新,配合@ObservableObject或者 Swift 5.9 以后的@Observable,写起来非常顺手。
当时也考虑过 Flutter 和 React Native。它们跨端能力确实优秀,但问题是 Agent 和 Skill 这种模块需要大量调用系统私有能力,比如EventKit、CoreLocation、App Intents,这些跨端框架要么不支持,要么封装得不完整。而且苹果生态内 SwiftUI 和async/await配合得最好,Agent 的核心逻辑往往是异步的,要执行网络请求、数据库读写、权限申请,一套Task机制下来比任何回调式框架都清晰。
还有一点很实际:SwiftUI 从 iOS 14 开始基本可以商用,到了现在的系统版本,性能也已经很稳定。苹果自家的很多系统应用都开始用 SwiftUI 重写,说明这条路不会断。
2. 技能机制设计:让 Agent 学会新能力的核心
这个项目里最核心的部分其实不是 Agent,而是 Skill 的抽象机制。Agent 可以写得简单,但 Skill 机制如果设计得不够好,后面每加一个技能就会变成一场灾难。我在初版时把技能直接写死在switch分支里,结果加了几个技能之后,Agent 类膨胀到几百行,连阅读都困难。后来才下定决心把技能全部协议化,搞了一个注册表。
2.1 什么是 Skill,如何抽象成一个协议
Skill 就是一个“能力单元”。从语义上来说,它应该包含 ID、名称、描述、执行方法,可能还需要一个参数 schema,用来告诉 Agent 需要哪些输入。
我在项目里定义了一个基础协议,简化后大概长这样:
protocol Skill { var id: String { get } var name: String { get } var description: String { get } func execute( context: AgentContext, input: SkillInput ) async throws -> SkillOutput }这里面最关键的是execute方法的异步设计。为什么必须是异步?因为技能执行时大概率要做网络请求、文件读写、权限申请这类耗时操作。如果做成同步接口,主线程会被卡死,界面直接白屏。把它定义成async throws,就可以在 Agent 里用Task统一调度,还能自然处理失败情况。
AgentContext是给技能提供运行环境用的,比如当前用户 ID、App 的偏好设置、是否允许访问系统能力。SkillInput是参数容器,不纠结具体类型,一般用字典或者 Codable 结构,这样 Agent 可以灵活传参。
2.2 Skill 注册表与动态加载
有了协议之后,下一步就是要把所有技能收集到一个地方统一管理。我使用了注册表模式,核心就是一个字典:
final class SkillRegistry { private var skills: [String: any Skill] = [:] func register(_ skill: any Skill) { skills[skill.id] = skill } func skill(for id: String) -> (any Skill)? { skills[id] } var allSkills: [any Skill] { Array(skills.values) } }这段代码看着简单,但有一个关键点:为什么用any Skill存放在字典里,而不是用泛型?因为注册表需要装不同类型的技能,它们都实现了同一个协议,但具体类型不同。Swift 里这种情况必须用存在类型any Skill,否则编译器会报类型不匹配。
动态加载其实是这套机制最大的优势。项目启动时,我只需要把所有写好的技能实例register进去,Agent 就会自动拥有这些能力。以后想加一个新技能,比如“汇率换算”,就新建一个ExchangeRateSkill类,实现协议,然后在 App 启动代码里加一行注册代码。核心 Agent 和 UI 层完全不用改。
2.3 Skill 与 Agent 的调用关系
Agent 调用技能的标准流程,我在项目里分成了四步:
- 接收用户输入。
- 把输入和每个技能的
description做匹配,确定要调用哪个技能。 - 从输入里抽取参数,生成
SkillInput。 - 调用技能,拿到
SkillOutput,再转成界面能展示的内容。
匹配这一步值得多说。最简单的是用String.contains做关键词判断,比如用户说“现在几点”,就去找名字里有“时间”的技能。但是这种匹配太脆弱,换成“目前时间”“当前钟点”就会失效。
后来我改成了向量相似度匹配。每个技能在注册时带一个轻量级的 embedding 描述,用户输入也做 embedding,然后计算余弦相似度,取分数最高的技能。这一步不需要外部服务,直接用本地模型就能跑,速度非常快。不过引入模型也会让包体变大,对于轻量项目我还是保留了关键词兜底。
需要强调一点:Agent 和 Skill 之间不能有强依赖。Agent 只通过协议和注册表访问技能,它不需要知道技能内部的实现细节。这样技能内部就算把整个底层框架换掉,Agent 也不会受影响。这就是依赖倒置原则在这个项目里的现实价值。
3. 从零搭建一个最小可用的 Agent-Skill Demo
概念说得再多,不如一段能跑的代码。这一节我会带你把整个链路搭起来,实现两个简单的技能:一个查时间,一个做加减乘除。通过这条路,你会发现原来把 Agent 和技能拆开,代码可以写得这么清爽。
3.1 环境与项目结构
我用的环境是 Xcode 15 以上,Swift 5.9,目标平台 iOS 17。因为要用到@Observable和一些新特性,太低版本跑起来会很别扭。
项目结构我习惯这样分:
SwiftUI-Agent-Skill/ ├── App/ │ ├── SwiftUIAgentApp.swift │ └── ContentView.swift ├── Agent/ │ ├── Agent.swift │ ├── SkillRegistry.swift │ └── AgentContext.swift ├── Skills/ │ ├── TimeSkill.swift │ └── CalculatorSkill.swift └── Models/ ├── SkillInput.swift └── SkillOutput.swift把 Agent、Skill、UI 分开,项目会非常容易扩展。所有的技能都放在Skills目录里,一眼就能看到当前有哪些能力。
3.2 SwiftUI 界面的实现要点
界面不需要花哨,一个输入框、一个发送按钮、一个技能列表,再加一个结果展示区域就够。用 SwiftUI 写起来大概是这样:
struct ContentView: View { @State private var agent = Agent() @State private var inputText = "" var body: some View { VStack(spacing: 16) { List(agent.skills) { skill in HStack { Text(skill.id) Spacer() Text(skill.status == .ready ? "可用" : "执行中") } } ScrollView { Text(agent.lastOutput ?? "等待指令") .padding() } HStack { TextField("输入指令", text: $inputText) .textFieldStyle(.roundedBorder) Button("发送") { Task { await agent.handle(input: inputText) } } .disabled(agent.isBusy) } } .padding() .task { await agent.setupDefaultSkills() } } }这里有个细节:agent.skills是一个数组,里面是技能包装类型,所以能满足List的Identifiable要求。agent.handle是异步方法,所以在点击发送时用Task包一层。
3.3 Agent 核心服务与 Skill 调度代码
Agent 是这个项目的指挥中枢。我把核心逻辑写得尽量短,因为它只是一个路由,不应该塞入业务判断:
@Observable final class Agent { var skills: [SkillItem] = [] var isBusy = false var lastOutput: String? private let registry = SkillRegistry() private let router = SkillRouter() func setupDefaultSkills() { register(TimeSkill()) register(CalculatorSkill()) } func register(_ skill: any Skill) { registry.register(skill) skills = registry.allSkills.map { SkillItem(skill: $0) } } func handle(input: String) async { isBusy = true defer { isBusy = false } guard let skillID = router.match(input: input, skills: registry.allSkills) else { lastOutput = "没有找到能处理这条指令的技能" return } guard let skill = registry.skill(for: skillID) else { return } do { let output = try await skill.execute( context: AgentContext(), input: SkillInput(text: input) ) lastOutput = output.message } catch { lastOutput = "技能执行失败:\(error.localizedDescription)" } } }SkillRouter负责意图匹配,简化版就是遍历技能描述,做一个关键词打分:
struct SkillRouter { func match(input: String, skills: [any Skill]) -> String? { skills .compactMap { skill in let score = input.contains(skill.name) ? 1 : 0 return score > 0 ? (skill.id, score) : nil } .max(by: { $0.score < $1.score })? .id } }虽然简单,但已经足够跑通链路。
3.4 让 Skill 支持用户自定义
作为插件系统,如果技能只能由开发者写死,价值就打折扣了。我还做了一个很小的配置化能力:把技能描述写进 JSON,然后在运行时动态注册。
比如 JSON 可以这样:
{ "id": "emoji-reply", "name": "表情回复", "template": "用户说“开心”,回复一个笑脸符号" }应用启动时读这个文件,生成一个GenericTemplateSkill,它执行的逻辑就是根据模板返回固定文案。这样不懂 Swift 的用户也能通过改 JSON 来定义简单技能。
不过要提醒的是,不要把动态技能做成任意代码执行。真正能跑的“代码技能”涉及安全沙箱,复杂度会呈指数上升。大多数场景下,模板化技能已经足够了。
4. 实战踩坑:SwiftUI 状态管理与并发安全
这个项目看起来简单,但真正写的时候有几个坑让我卡了很久。尤其是 SwiftUI 和异步的碰撞,处理不好就会出现界面卡顿、数据丢失,甚至崩溃。下面是我踩完坑后的总结。
4.1 @MainActor 与 Agent 后台任务
Agent的handle方法里执行了技能调用,技能内部如果做网络请求,那就必须在后台线程跑。但Agent本身如果使用@Observable并直接绑定 UI,它的属性更新必须发生在主线程。
我在初版把Agent整个标记为@MainActor,结果技能里的网络请求也变成了主线程执行,一卡一卡的。后来改成这样:Agent 的调度方法保持主线程安全,技能执行则放到非隔离的异步上下文里:
func handle(input: String) async { isBusy = true defer { isBusy = false } let output = await Task.detached(priority: .userInitiated) { try await selectedSkill.execute(...) }.value }Task.detached不是万能药,但确实能在不阻塞 UI 的情况下做真正的后台执行。关键点是不要在后台任务里直接修改@Published或@Observable属性,应该把结果返回到主线程后再赋值。
4.2 Skill 结果回传与 UI 刷新
SwiftUI 的刷新机制有时候会让新手摸不着头脑。比如技能返回一个数组,我用@Published var items: [Item]存它,界面却忘了刷新。问题通常出在对象身份上:如果你把items赋值成一个全新的数组,SwiftUI 能识别;如果你只append,而没有触发 objectWillChange,它可能不会更新。
我用的是@Observable宏,按理说属性访问自动追踪,但要注意:只有视图的 body 里访问了agent.lastOutput,它才会被追踪。如果你把Agent对象传到一个子视图里,子视图的 body 没有读取属性,那界面自然就不变。
所以在设计 UI 时,我尽量让所有展示数据都在顶层视图里读取,子视图只展示传进来的值,这样追踪链路不会断。
4.3 Skill 注册时的类型擦除问题
很多人在写注册表时会被 Swift 的类型系统卡住。例如SkillInput如果是个协议,就不能直接存储any SkillInput的数组,除非你显式写[any SkillInput]。我在项目中统一采用了值类型结构:
struct SkillInput: Codable { var text: String var parameters: [String: String] = [:] }这样不管技能接收什么参数,都塞字典里,不需要为每个技能单独定义输入类型。虽然少了一点类型安全,但换来了极大的灵活性。对动态扩展技能来说,这个取舍值得。
另外,Skill 的id一定要稳定。不要在get里动态生成,否则注册表会出现重复注册。最好的做法是let id = "time"写在结构体常量里。
4.4 别让技能变成“什么都做的万能类”
我踩过最大的坑,是写了一个CommonSkill,想把所有逻辑都塞进去。比如既处理天气又处理日历还处理计算,结果它内部堆满了if else,最终难以维护。后来我强制自己遵守一个原则:一个技能只做一件事。天气是天气,日历是日历,如果两个技能需要共享逻辑,就把逻辑抽成独立的服务,而不是做成“大杂烩技能”。
这样做的直接收益是:测试变简单了,UI 上技能的状态展示变清晰了,后续再加技能也不会互相干扰。
5. 这个项目后续还能怎么扩展
SwiftUI-Agent-Skill做成之后,我一直把它当成一个可复制的底座,每次开新 App 都会把 Agent 和注册表拖过去,只换一套 Skill。下面这几个方向我觉得最有价值,适合接着深入。
5.1 接入更多系统能力
苹果生态给开发者提供了很多系统能力,比如EventKit可以读写日历,Core Location可以获取位置,App Intents可以联动快捷指令。这些能力都可以封装成 Skill。我试过把日历创建事件封装成一个技能,用户对 Agent 说“明天下午三点开会”,Agent 自动匹配日历技能,然后去创建日程。这种体验比手动点操作方便很多。
用SKill封装系统能力时,需要注意权限申请时机。不要在技能尚未执行时就弹权限弹窗,用户会觉得很突兀。我的做法是在execute方法里临时申请,申请通过后才继续执行。
5.2 让技能列表可远程配置
本地技能再多,也需要更新的通道。我把技能注册表改造成支持远程下发 JSON 配置,服务端返回一份技能清单,App 启动时拉取并注册可用的技能。这种方式在需要灰度测试新功能时特别好用。
不过远程配置牵扯到安全问题。至少要校验服务端返回的 JSON 结构,并且不要允许远程代码执行。就我的经验来说,技能描述和 UI 展示的文案可以远程下发,但技能的实际业务逻辑还是应该在客户端里写好,远程配置只负责“开/关”和“排序”,这样既灵活又安全。
5.3 一点个人体会
这个项目折腾下来,我最大的体会是:用协议和注册表去管理 AI 技能,比直接在业务代码里写if else舒服太多了。初次写会觉得多了一层抽象,但每次新增能力时都会省很多事。尤其是当你的技能数量超过十几个时,这种模块化的价值会非常明显。
如果你打算在下一个项目里试这个思路,我建议先把 Skill 的边界画清楚,不要一开始就想着做一个无所不能的 Agent。从两三个技能跑通链路,再把注册表、持久化、远程配置一个个加进去。等你把这套流程跑顺,再回看最初那堆纠缠在一起的代码,大概率会把它们全部重构成 SwiftUI + Agent + Skill 的结构。