在 iOS 上做 AI Agent,很多人第一步想到的是接一个大模型 API,让聊天框能自动回复就算完工。但真实需求从来不是“能聊天”,而是“能干活”——用户说一句“五分钟后提醒我取快递”,助手得解析意图、识别参数、启动计时器、在通知中心挂一条提醒,再把结果同步到界面上。这中间每一步都有状态流转,每一步都涉及 UI 刷新,稍不留神就会陷入“回调地狱”。SwiftUI-Agent-Skill 这个项目,就是我从零用 SwiftUI 搭的一套 Agent 框架,核心思路是把“Agent 大脑”和“Skill 技能包”彻底解耦,让助手能像搭积木一样不断长出新能力。这篇文章会从架构设计、Skill 协议定义、状态绑定、并发处理几个关键点展开,也把实操中踩过的坑一并交代清楚,适合已经入门 SwiftUI、正准备往智能交互方向深入的开发者。
1. 整体设计思路:为什么 Agent 和 SwiftUI 是天生一对
1.1 先想清楚:Agent 在 App 里到底是个什么角色
很多人把 Agent 等同于“聊天机器人”,这是第一个误区。在我的设计里,Agent 是 App 内部的一个意图处理中枢,它接收用户的自然语言输入,把它翻译成结构化的任务,再分发到具体的能力模块去执行,最后把执行结果转成界面可以表达的状态。它更像餐厅里那个记单的服务员,而不是只会背菜单的迎宾员。
所以 Agent 必须回答三个问题:用户想干什么、我有什么工具能干这件事、干完之后怎么告诉用户。SwiftUI-Agent-Skill 项目的整个架构都是围绕这三个问题展开的。第一版我偷懒,把所有逻辑塞进一个 ViewModel,结果意图解析、技能执行、状态管理全混在一起,加一个新技能要改三处代码。后来才痛下决心重构,把“理解”和“执行”彻底分层,UI 层只负责展示 Agent 抛出来的状态,不关心具体哪个技能在处理。
1.2 SwiftUI 声明式模型与 Agent 状态的天然契合
SwiftUI 最核心的心智模型是“状态驱动视图”:状态变了,UI 自动跟着变。这恰恰和 Agent 的工作方式完美匹配。Agent 在任何时刻都处于一个确定的状态——空闲、理解中、执行中、等待补充参数、出错,这些状态直接映射到 UI 就是输入框的可用性、加载动画的显隐、错误提示的展示。
用 SwiftUI 做这件事,我最大的感受是不用再手动维护“Agent 内部状态”和“界面显示状态”两份数据。以前用 UIKit 的时候,Agent 回调里改了数据模型,还要手动去刷新列表、更新按钮;现在只需要把 Agent 状态做成@Observable或者ObservableObject,界面上所有依赖它的视图自动响应。这个“单一数据源”的好处我深有体会:有一次 Agent 在后台线程完成了技能执行,我忘记切回主线程刷新 UI,界面卡住不动,排查半天才发现是数据更新和界面同步脱节了。换成声明式绑定后,这类问题从根上少了一大半。
1.3 Skill 体系:把能力当成可插拔的积木
所谓 Skill,就是 Agent 能执行的一个具体能力单元。它可以是一个定时器、一次天气查询、一个文件重命名操作,甚至是一段复杂的多步骤工作流。每一类能力封装成一个独立的 Skill 对象,遵循统一协议,注册到 Agent 身上。
我做这套设计时参考了插件系统的思路。所有 Skill 都遵循相同的协议,Agent 根本不关心某个技能内部怎么实现,它只关心这个技能“能不能处理当前意图”和“怎么调用”。这样带来的直接好处是扩展成本极低——新加一个技能,只需要写一个新的 Skill 结构体,遵循协议,在 Agent 启动时注册进去,其余代码一行都不用改。我实际测试过,给这套系统新增一个“闹钟管理”技能,从写代码到跑通,大约只花了一个小时,其中大部分时间还是在查系统 API。
用生活化的类比来说:Agent 是餐厅的服务员,Skill 是后厨的各个档口。服务员不关心厨师的刀工和火候,只需要把客人点的菜写进小票递给对应档口,再把档口做好的菜端上桌。档口可以随时增加,服务员不需要改变自己的工作方式。
2. 核心架构拆解:Agent 大脑与 Skill 执行器的职责边界
2.1 Agent 状态机:从理解到执行的全流程
Agent 的工作流程可以用一个紧凑的状态机来概括。我定义了五种状态:
| 状态 | 含义 | 对应 UI 表现 |
|---|---|---|
idle | 空闲,等待用户输入 | 输入框可用,无加载动画 |
understanding | 正在解析意图、匹配技能 | 输入框禁用,显示“正在理解” |
executing | 技能正在执行 | 显示技能进度或加载动画 |
awaitingInput | 技能缺少必要参数,等待用户补充 | 弹出参数填写表单 |
failed | 执行失败 | 显示错误信息,输入框恢复可用 |
状态机是整个 Agent 的骨架,它保证了任何时刻 Agent 的行为都是可预期的。我写过一个常量:所有状态转移必须发生在主 Actor 上。为什么要这样?因为 UI 直接绑定状态,如果状态在后台线程被修改,SwiftUI 会在下一个 RunLoop 周期用不确定的数据渲染视图,轻则闪烁,重则崩溃。
状态机还解决了一个很实际的问题:重复点击。用户在 Agent 执行技能时疯狂点发送按钮,如果状态还是idle,我们就直接忽略新的输入;只有等状态回到idle才接受下一条指令。这个防重入机制花了我五分钟写,但省掉了后面大量关于并发指令冲突的排查时间。
2.2 Skill 协议:能力的标准接口
Skill 协议是我这套框架的基石,几乎所有扩展都围绕它展开。先看代码:
public protocol Skill { /// 全局唯一标识,用于注册和检索 var id: String { get } /// 展示名称,会出现在技能面板中 var displayName: String { get } /// 一段自然语言描述,供意图匹配和调试日志使用 var description: String { get } /// 技能声明自己能处理的关键词 var intentKeywords: [String] { get } /// 执行时需要的参数定义,缺参数时会触发 awaitingInput var requiredParameters: [SkillParameter] { get } /// 判断是否能够处理某条用户指令 func canHandle(intent: String) -> Bool /// 执行技能的核心方法 func execute(context: SkillContext) async throws -> SkillResult }逐个拆解每个属性的设计意图。
id是技能的身份证,技能注册表用它在集合中查找、移除、更新技能。我踩过一个坑:一开始没有id字段,用displayName做标识,结果两个技能重名,后注册的把先注册的顶掉了,查了半天才知道原因。
intentKeywords是最粗粒度的匹配依据。用户说“帮我倒计时十分钟”,我们会把这个字符串小写化,然后逐个技能检查它们的关键词列表里有没有“倒计时”“计时”“定时”这些词。这里的匹配不追求精确,只追求快速缩小候选范围,真正精细的匹配在后面的评分环节做。
requiredParameters是关键设计。如果用户说“设个闹钟”,但没有说几点钟,Agent 就应该进入awaitingInput状态,告诉用户“需要补充时间参数”。这个字段的存在让技能自己声明“我需要什么”,而不是由 Agent 写死每种技能需要什么参数,这样新增技能时 Agent 完全不用跟着改。
execute是异步方法,用async throws是因为技能内部几乎必然有耗时操作,比如网络请求、文件读写、系统 API 调用。统一异步接口也方便 Agent 在后台并发执行多个技能任务。
2.3 调度匹配:怎么让 Agent 找到对的技能
有了 Skill 协议,下一步就是实现意图匹配。我使用的是“关键词触发 + 评分排序”的两阶段策略。
第一阶段是快速过滤。遍历所有已注册技能,逐个调用canHandle(intent:),返回true的进入候选池。这个阶段用最简单粗暴的包含判断:用户输入的小写字符串是否包含技能某个关键词。复杂度是 O(技能数 × 关键词数),技能一多还是有点吃紧,但实际场景下一般注册 10 来个技能完全没有性能压力。
第二阶段是得分排序。当多个技能都能处理同一条指令时,就要选出最合适的那一个。我给每个候选技能打分,分数由两个因素决定:关键词命中的数量、关键词在输入中的覆盖长度。举个例子,“设置一个三十分钟的定时器”这句话,定时器技能命中了“设置”“定时器”,闹钟技能命中了“设置”,显然定时器技能的得分更高。
private func matchIntent(_ input: String) -> (any Skill)? { let normalized = input.lowercased() let candidates = skills.filter { $0.canHandle(intent: normalized) } guard !candidates.isEmpty else { return nil } return candidates.max { lhs, rhs in score(skill: lhs, input: normalized) < score(skill: rhs, input: normalized) } } private func score(skill: any Skill, input: String) -> Int { skill.intentKeywords.reduce(0) { partial, keyword in input.contains(keyword) ? partial + keyword.count : partial } }这段代码最大的意义是:匹配逻辑和业务逻辑完全分离。以后想升级匹配算法——比如接入本地语义模型做意图分类——只需要替换matchIntent一个方法,所有技能模块都不受影响。
3. 从零实操:搭一套可直接复用的 Agent-Skill 框架
3.1 工程结构与准备工作
我的项目要求 iOS 17+,因为要用@Observable宏和 Swift 语言的新并发特性。如果你还在用 iOS 16 或者更早的版本,ObservableObject也能实现类似效果,但需要自己处理更多的@Published标记。
先看工程目录结构,这是我整理过的推荐形态:
AgentApp/ ├── App/ │ └── AgentApp.swift ├── Core/ │ ├── Agent/ │ │ ├── AgentManager.swift │ │ ├── AgentState.swift │ │ └── SkillContext.swift │ └── Skills/ │ └── Skill.swift ├── Skills/ │ ├── TimerSkill.swift │ ├── WeatherSkill.swift │ └── CalculationSkill.swift └── Views/ ├── ChatView.swift ├── InputBarView.swift └── SkillPanelView.swiftCore目录装的是框架本身,Skills目录装具体技能,Views目录是 SwiftUI 界面层。我强烈建议在项目一开始就分好这个结构。第一版我把所有文件都放在根目录下,结果技能越写越多,找文件全靠翻,重构成本直线上升。
依赖上我尽量靠近系统库,不引入额外第三方框架。网络请求用系统URLSession,数据序列化用Codable,状态管理用 SwiftUI 自带的@Observable。少一个依赖,以后就少一分维护负担,这个决定后来帮我省了不少事。
3.2 AgentManager 的核心实现
AgentManager是整个框架的心脏,它负责维护技能注册表、处理用户输入、协调状态流转。
@MainActor @Observable final class AgentManager { private(set) var state: AgentState = .idle private(set) var skills: [any Skill] = [] private(set) var conversationHistory: [ConversationMessage] = [] func register(_ skill: any Skill) { skills.removeAll { $0.id == skill.id } skills.append(skill) } func send(userInput: String) async { guard state == .idle else { return } state = .understanding defer { if case .understanding = state { state = .idle } } let trimmedInput = userInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedInput.isEmpty else { return } conversationHistory.append(.user(text: trimmedInput)) guard let matchedSkill = matchIntent(trimmedInput) else { conversationHistory.append(.agent(text: "抱歉,我还没有掌握这个技能。")) return } state = .executing(skillID: matchedSkill.id) do { let context = SkillContext(input: trimmedInput) let result = try await matchedSkill.execute(context: context) conversationHistory.append(.agent(text: result.summary)) state = .idle } catch { state = .failed(error.localizedDescription) } } }这里有三个细节值得解释。
第一,@MainActor注解是整个类的入口约束,保证所有方法调用都在主线程执行。这省掉了大量手动DispatchQueue.main.async的样板代码,也杜绝了状态被后台线程修改的隐患。
第二,conversationHistory是界面数据的唯一来源。聊天记录不散落在各技能内部,而是统一由 Agent 持有,这样聊天界面只需要观察一个数组对象,新增消息时自动刷新。我最初设计时让每个技能自己往界面上塞消息,结果界面同时收到多个数据源推送,顺序乱成一团。
第三,defer块保证了状态必然回归。如果匹配不到技能,直接在返回前把understanding重置为idle,用户输入框立刻恢复可用,不会出现“一直转圈”的尴尬局面。这个小细节是我测试的时候被用户吐槽“卡死了”之后才补上的。
3.3 两个实战 Skill:定时器与天气查询
先说定时器技能。这个技能最大的难点在于从自然语言里提取时长。用户可能说“倒计时三十分钟”“十分钟后提醒我”“每隔两小时提醒一次”,这三句话的句式完全不同,但都需要提取一个时间数值。
struct TimerSkill: Skill { var id: String { "timer.set" } var displayName: String { "定时器" } var description: String { "设置倒计时、定时提醒" } var intentKeywords: ["计时", "定时", "倒计时", "提醒", "分钟", "秒"] var requiredParameters: [SkillParameter] = [ SkillParameter(title: "时长", unit: "分钟") ] func canHandle(intent: String) -> Bool { intentKeywords.contains { intent.contains($0) } } func execute(context: SkillContext) async throws -> SkillResult { let duration = try extractDuration(from: context.input) // 用系统 Timer 接口触发本地通知 try await scheduleLocalNotification(after: duration) return SkillResult(summary: "好的,已为您设置 \(Int(duration)) 秒倒计时") } private func extractDuration(from input: String) throws -> TimeInterval { let pattern = #"(\d+)\s*(秒|分钟|小时)"# let regex = try Regex(pattern) guard let match = input.firstMatch(of: regex) else { throw SkillError.missingParameter(parameter: "时长") } let value = Int(match.1) ?? 0 let unit = String(match.2) switch unit { case "秒": return Double(value) case "分钟": return Double(value * 60) case "小时": return Double(value * 3600) default: throw SkillError.missingParameter(parameter: "时长") } } }这里的关键点是正则表达式。正则提取参数是我试过多种方案后最稳的一种,因为它可以精确匹配“数字 + 单位”这种高度结构化的信息。虽然字符串分词加关键词匹配也能达到类似效果,但遇到“十分钟零三十秒”这种复合表达就很容易翻车。正则配合捕获组,几行代码就能处理绝大多数情况。
如果用户没说时长,extractDuration会抛错,Agent 捕获后进入awaitingInput状态,界面弹出参数填写框。这就是requiredParameters声明的作用——技能自己告诉 Agent “我需要时长这个参数”,Agent 按需向用户追问。
天气查询技能稍微复杂,因为它涉及网络请求。为了方便测试和演示,我把网络层抽象成一个闭包注入,真正开发时你只需要替换成实际的天气服务接口。
struct WeatherSkill: Skill { var id: String { "weather.query" } var displayName: String { "天气查询" } var description: String { "查询指定城市的天气状况" } var intentKeywords: ["天气", "气温", "下雨", "温度"] var fetchWeather: (String) async throws -> WeatherInfo func execute(context: SkillContext) async throws -> SkillResult { let city = extractCity(from: context.input) ?? "深圳" let weather = try await fetchWeather(city) return SkillResult(summary: "\(city) 当前 \(weather.description),气温 \(weather.temperature)℃") } }这里有个小技巧:fetchWeather作为存储属性注入,让技能结构体可以脱离真实网络环境单独测试。单元测试里我可以注入一个返回固定数据的 mock 闭包,测试意图提取逻辑,而不用真的发起网络请求。等到需要在 App 里跑真实数据时,再传入一个走URLSession的闭包即可。
3.4 SwiftUI 界面接入与状态绑定
界面层是这套框架的“门面”,也是最能体现 SwiftUI 声明式优势的地方。
struct ChatView: View { @Environment(AgentManager.self) private var agent var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(agent.conversationHistory) { message in MessageBubble(message: message) } } .id(agent.conversationHistory.count) } .onChange(of: agent.conversationHistory.count) { _, _ in withAnimation { proxy.scrollTo(agent.conversationHistory.count - 1, anchor: .bottom) } } } InputBarView() } } }ScrollViewReader+onChange的组合是我实现自动滚到底部的最优解。监听conversationHistory.count的变化,一旦有新消息,就滚动到最后一条。withAnimation让滚动动作平滑流畅,用户体验上更像一个成熟的聊天应用,而不是生硬跳转。
输入栏的禁用状态同样直接绑定 Agent 状态:
struct InputBarView: View { @Environment(AgentManager.self) private var agent @State private var draftText = "" var body: some View { HStack { TextField("输入指令...", text: $draftText) .textFieldStyle(.roundedBorder) .disabled(agent.state != .idle) Button("发送") { let text = draftText draftText = "" Task { await agent.send(userInput: text) } } .disabled(agent.state != .idle || draftText.isEmpty) } .padding() } }disabled(agent.state != .idle)这个一行代码就把“状态非空闲时禁止输入”的规则写清楚了。以前用 UIKit 我得在state的didSet里手动去inputBar.isEnabled = ...,写多了就乱,现在声明式绑定自动同步,完全不用担心漏改。
技能面板稍微进阶一点。我做了个SkillPanelView,把已注册的技能以宫格形式展示,点击某个技能卡片会自动填充预设样例指令。这个面板不需要额外传数据,直接读agent.skills数组,每个技能都自带displayName和description,天然就能渲染:
struct SkillPanelView: View { @Environment(AgentManager.self) private var agent var body: some View { ScrollView(.horizontal) { HStack { ForEach(agent.skills, id: \.id) { skill in VStack { Text(skill.displayName) .font(.headline) Text(skill.description) .font(.caption) .foregroundStyle(.secondary) } .padding() .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) } } } } }这种“技能自带界面表达”的设计很舒服——Agent 不需要维护一套技能图标和文案的映射表,技能自己就是 UI 数据源,展示和逻辑天然一致。
3.5 单元测试与验证方法
整套框架的测试策略,我优先保证两条核心链路:意图匹配准确性和状态流转正确性。
意图匹配测试相对直接,核心就是验证不同输入文本能不能被正确路由到对应技能:
func testTimerSkillMatching() { let agent = AgentManager() agent.register(TimerSkill()) let expectation = XCTestExpectation(description: "timer matched") Task { await agent.send(userInput: "倒计时30分钟") XCTAssertEqual(agent.state, .idle) XCTAssertEqual(agent.conversationHistory.last?.text, "好的,已为您设置 30 分钟倒计时") expectation.fulfill() } wait(for: [expectation], timeout: 5) }状态流转测试则更关注边界情况。比如在 Agent 还在执行时重复发送消息,应该被忽略;缺少参数时应该进入awaitingInput而不是直接崩溃。这类测试写起来稍微麻烦,但一定要做,因为状态机一旦出 bug,用户感知非常强烈——最常见的表现就是按钮一直转圈、界面卡死。
还有一个我特别推荐的验证方法:日志标记。在AgentManager的每个状态转移处打印日志,包括当前技能、用户输入、匹配得分、耗时。实际开发时我经常靠日志还原 Agent 在某个时刻到底做了什么,比断点调试高效得多,尤其是在真机跑的时候。
4. 常见问题与排查技巧实录
4.1 状态不同步:异步任务和主线程的纠缠
我踩过最大的坑,就是忘记 Agent 的异步执行环境。execute是async方法,但 SwiftUI 的视图更新必须在主线程。如果技能内部有网络请求,网络回调默认在后台线程,这时候直接改AgentManager的状态,轻则界面延迟、闪烁,重则数据竞争崩溃。
我的解法是给AgentManager加@MainActor,把整个类的所有属性访问和方法调用都限定在主线程。这个注解的好处是编译器会在编译期就报错,而不是等项目跑到一半崩溃。如果你不想用@MainActor,那就记得在技能执行完后的回调里手动await MainActor.run { ... },但这种方法容易漏,还是建议直接用@MainActor省心。
// 推荐做法:整个 AgentManager 都是 MainActor @MainActor final class AgentManager { ... }4.2 并发场景下的数据竞争
多个技能同时执行时,技能内部的局部状态通常没问题,但一旦技能要访问共享数据——比如同一个本地存储的文件、同一个网络入口——就可能出现竞争。
我在框架层定了一条规矩:技能之间不共享任何可变状态。每个技能执行时传入独立的SkillContext,里面只包含当前这次调用的输入和参数,不持有全局对象。如果某个技能确实需要访问共享存储(比如用户偏好设置),我把它设计成通过协议注入,并且保证底层读写在专用 actor 上执行。
这条规矩让我少处理了 90% 的并发 bug。举个具体例子:定时器技能和闹钟技能如果同时访问系统通知中心,可能会出现通知被覆盖的情况。但如果它们各自持有独立的UNUserNotificationCenter使用点,互不干扰,问题就不存在。
4.3 界面刷新时序与动画丢失
SwiftUI 的动画有时很“任性”。我在状态流转时用withAnimation包裹状态变更,但技能执行完的一瞬间,动画经常不触发,看起来像是卡了一下。
排查后发现问题出在动画时机。state从executing变回idle的过程里,界面同时要刷新聊天记录和输入栏状态,两个视图的动画互相竞争。解决方式是把状态变更和内容更新拆成两个动画块:
withAnimation(.easeInOut(duration: 0.25)) { state = .idle } withAnimation(.spring(duration: 0.4)) { conversationHistory.append(message) }一个控制 UI 控件状态的动画,一个控制内容插入的动画,各管各的,表现效果明显更稳定。记住一个原则:动画的粒度越小越可控,别指望一个大动画块管理所有界面变化。
4.4 调试技能匹配的实用技巧
技能一多,最头疼的问题是“用户说了这句话,Agent 为什么选了那个技能”。我做了两个辅助工具来解决。
第一个是匹配日志。AgentManager每次匹配都会打印候选技能列表、每个技能的得分详情。这样当用户反馈“为什么打开的是定时器,我说的是设置闹钟”,我一看日志就知道定时器得分更高,因为“设置”这个关键词同时出现在两个技能里,而“闹钟”“定时器”的区分度不够。
第二个是可视化技能面板。把已注册技能渲染成卡片后,我还额外给每张卡片增加了一个“测试”按钮,点击后就用预设样例指令直接调用send。这样不用打字也能快速验证每个技能是否正常工作。我实际用下来,这种方法比写一堆 CLI 测试脚本直观得多,毕竟 Agent 的交互场景本身就是多模态的。
另外强烈建议在做匹配算法时维护一个“负面样例集”。我专门收集那些会导致错误匹配的句子,比如“现在几点钟”有时候会匹配到定时器技能,因为“几点”里面有个“点”。把这些句子喂进测试套件里,每次改动匹配逻辑就能立刻发现回归。
写在最后的几句体会
这套 SwiftUI-Agent-Skill 框架我前后迭代了大半年,最大的收获不是技术上实现了什么,而是真正理解了模块化设计的价值。一开始我也觉得接个大模型 API 就万事大吉,结果做出来的东西只是个“会说话的界面”,根本称不上 Agent。后来把状态机、技能协议、意图匹配这些基础设施一个个补齐,才发现“能干活”和“能聊天”之间差了不止一个量级。
如果你也想做类似的方向,我建议别一上来就追求大而全的多技能体系。先实现三四个核心技能把整条链路打通,把状态流转、参数提取、UI 绑定这些地基打牢,再逐步往上加能力。框架设计上多花一点时间,后面几乎每天都能体会到复利效应。这套代码我后续还会继续扩展,比如加入更智能的对话上下文管理、技能间的组合编排,以及适配 Apple Watch 的轻量交互形态,到时候有新的踩坑经验再来分享。