☰
SwiftUI集成Agent:可扩展技能架构落地实践
2026/10/14 8:47:34 网站建设 项目流程

1. 项目思路拆解:SwiftUI 里塞一个 Agent,到底图什么

先说结论:这个项目不是又一个聊天界面 demo,而是一个把“可扩展智能代理”跑进 SwiftUI 可观测状态体系里的实战探索。标题里的三个词拆开看非常有代表性——SwiftUI 是 Apple 生态里目前最主流的声明式 UI 框架,Agent 表示这是一个带决策循环的智能体工程,Skill 则是把 Agent 的能力做成了可插拔的模块化技能。三者组合起来的核心命题是:能不能在一个苹果原生应用里,既保住 SwiftUI 的响应式开发体验,又搭出一套可以持续挂载新能力的 Agent 架构。

我最初做这个项目,是因为手上有一个工具类 App,功能越加越多,业务逻辑和 UI 状态搅在一起,每次新增一个功能都要动原来的代码,改着改着就出回归 bug。后来接触到 Agent 的架构理念,发现它解决问题的思路完全不一样:把每个独立能力封装成 Skill(技能),由 Agent 统一受理任务、解析意图、路由分发。这样一来,新增能力就是在技能列表里加一个模块,UI 层、路由层完全不用动。这才是真正的可扩展。

这个项目适合三类人参考:一是想在 iOS/Mac 上做 Agent 类应用但没有现成架构思路的开发者,二是对 SwiftUI 状态管理有痛点、想看看观测驱动和代理模式怎么结合的人,三是纯好奇 Agent 的 Skill 体系在客户端侧到底能落实到什么程度的同学。整个项目核心代码量不大,但架构分层和协议设计花了相当多心思,下面我把关键决策和落地过程完整写出来。

2. 整体架构:Agent、Skill、Registry 三件套怎么协作

2.1 Skill 协议怎么设计才不后悔

Skill 是这个项目里最重要的抽象,协议设计好了,后面所有事情都顺。我第一版把协议写得太胖,既有canHandle、execute,又塞了onProgress、onCancel,结果每个 Skill 实现都要背一堆回调,写起来非常痛苦。后来重构了一遍,砍到最小闭环:一个 Skill 只需要回答两个问题——这个请求归不归我管(canHandle),以及真管的话怎么执行(execute)。

public protocol AgentSkill: Sendable, Identifiable { var id: String { get } var displayName: String { get } var category: SkillCategory { get } /// 判断该请求是否由本技能处理 func canHandle(_ request: AgentRequest) async -> Bool /// 执行技能逻辑,返回结构化结果 func execute(_ request: AgentRequest) async throws -> SkillResult }

这里有几个看起来不起眼但重要的设计点。首先是Sendable,因为 Agent 的调度循环和 Skill 执行会跨并发域,不标记 Sendable 的话到了 Swift 6 严格并发检查阶段会非常痛苦,早加早安心。其次是execute返回SkillResult而不是直接返回字符串,因为有些技能的结果是结构化数据,比如天气技能可能返回温度加风速加天气代码,统一塞进一个枚举或者Codable结构里,后面渲染层才能灵活处理。

还有一个细节是协议里没有放init依赖注入。每个 Skill 应该是无状态的,至少对外表现要无状态,这样注册和复用都简单。如果某个技能需要用到外部服务(比如网络请求的 APIClient),我建议通过初始化方法把依赖传进来,但要保证这个依赖本身是线程安全的,不要在里面直接持有 UI 对象。

2.2 SkillRegistry:注册中心与路由分发

有了协议,下一步是注册中心。这个角色负责收集和管理所有技能,同时承担路由器的工作——给定一个AgentRequest,从技能列表里按优先级找出最合适的那个。我用了一个非常朴素的做法:SkillRegistry内部维护一个按优先级排序的数组,分发时顺序调用canHandle,只要有一个技能表示能处理,就直接交给它。

final class SkillRegistry: Sendable { private struct Entry: Sendable { let skill: any AgentSkill let priority: Int } private var entries: [Entry] = [] private let lock = NSLock() func register(_ skill: any AgentSkill, priority: Int = 0) { lock.lock() defer { lock.unlock() } entries.append(Entry(skill: skill, priority: priority)) entries.sort { $0.priority > $1.priority } } func resolveSkill(for request: AgentRequest) async -> (any AgentSkill)? { let snapshot = lock.withLock { entries } for entry in snapshot { if await entry.skill.canHandle(request) { return entry.skill } } return nil } }

为什么resolveSkill要先把entries快照出来再遍历?因为注册和分发可能发生在不同队列,直接在锁内做异步调用会引发死锁风险。快照虽然有一定的空间开销,但换来的是锁粒度极小,安全性和性能都更好。

路由策略我想强调一点:不要在canHandle里做重计算。这个方法在每次分发时会对所有技能顺序调用,如果里面写了大段关键词分析甚至网络请求,分发延迟会肉眼可见。我后来把所有技能的关键词表都提前编译成Set<String>,canHandle只做集合交集判断,实测下来每次路由基本都是微秒级。

2.3 Agent 主循环:状态机与上下文

Agent 本身我设计成一个@MainActor @Observable的类,因为它要驱动 SwiftUI 的界面状态,本质上就是整个应用的“大脑”。它维护一个精简的状态机:idle、thinking、executing(skillId)、waitingForUser。每个状态对应界面上的不同表现——thinking的时候显示思考动画,executing的时候显示当前技能的名称和进度,waitingForUser的时候禁用输入框。

enum AgentState: Equatable { case idle case thinking case executing(skillId: String) case waitingForUser } @MainActor @Observable final class AgentBrain { private(set) var state: AgentState = .idle private(set) var messages: [AgentMessage] = [] private(set) var activeSkillID: String? private let registry: SkillRegistry private var context: AgentContext init(registry: SkillRegistry, context: AgentContext) { self.registry = registry self.context = context } func dispatch(userInput: String) async { let request = AgentRequest(text: userInput, context: context) state = .thinking guard let skill = await registry.resolveSkill(for: request) else { messages.append(.agent("抱歉,目前没有技能能够处理这个请求。")) state = .idle return } state = .executing(skillId: skill.id) activeSkillID = skill.id do { let result = try await skill.execute(request) let reply = render(result) messages.append(.agent(reply)) context.remember(userInput: userInput, agentReply: reply) } catch { messages.append(.agent("技能执行失败:\(error.localizedDescription)")) } state = .idle activeSkillID = nil } }

上下文管理这部分我吃了不少亏。一开始没有做AgentContext,Agent 就是个“短期失忆”的哑巴,用户前脚问完天气后脚问“明天呢”,Agent 根本不知道“明天”指什么。后来加了一个很轻量的上下文对象,只保留最近 N 轮对话的关键信息,用Codable持久化到 UserDefaults。注意不要无脑把整段对话历史塞进上下文,客户端场景内存和持久化空间都有限,我实测下来保留最近 10 轮就够用,再多反而会让关键词匹配和意图识别变迟钝。

3. SwiftUI 与 Agent 层的桥接:可观测驱动的界面实践

3.1 为什么选 Observation 而不是 ObservableObject

这个项目是在 iOS 17 之后开发的,所以状态管理直接用@Observable宏,没有再碰 Combine 时代的ObservableObject。两者的区别用一句话概括:ObservableObject是“整个对象变了通知大家”,@Observable是“哪个属性变了只通知依赖它的视图”。对 Agent 这种高频状态更新的场景,这个区别非常大。Agent 的state属性经常切换,如果用ObservableObject,每次切换都会触发依赖任意属性的视图刷新,即使某个视图只读messages,也会跟着重建。用@Observable后,只有真正读state的视图才刷新,性能提升很明显。

不过@Observable有个坑:它只能在类内部使用宏展开,所以不能在扩展里给类补@Observable。如果你要把 Agent 拆成多个文件管理,所有属性必须集中在主类声明里,或者用@Observable套一层包装类。我实际项目里把AgentBrain的主类保持得很薄,复杂的计算逻辑全部下沉到别的类型里。

3.2 消息列表与输入栏的实现细节

消息列表是 Agent 应用的门面,这里有一些 SwiftUI 实现上的细节值得说。我用List而不是ScrollView+LazyVStack,因为List自带复用和滚动优化,消息多了以后内存占用更可控。列表的数据源是AgentBrain.messages,每一项消息有唯一的id,这样在监听新消息时可以精确滚动到底部。

struct ChatView: View { @State private var brain: AgentBrain @State private var inputText = "" var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in List(brain.messages) { message in MessageRow(message: message) .id(message.id) } .listStyle(.plain) .onChange(of: brain.messages.count) { _, _ in if let lastID = brain.messages.last?.id { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(lastID, anchor: .bottom) } } } } HStack(alignment: .bottom, spacing: 8) { TextField("输入指令,例如:计算本周的工作时长", text: $inputText, axis: .vertical) .lineLimit(1...4) .padding(10) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) Button { send() } label: { Image(systemName: "arrow.up.circle.fill") .font(.title2) } .disabled(brain.state == .thinking || inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 8) } } private func send() { let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } Task { await brain.dispatch(userInput: text) } } }

这里有几个实践上的注意点。第一,Button的disabled条件里包含brain.state == .thinking,这个看起来简单,但非常关键——防止用户在 Agent 执行技能时重复提交任务,造成状态机错乱。第二,TextField用了axis: .vertical和lineLimit(1...4),让输入框可以多行输入,而不是单行长条,这对输入稍微复杂的指令很重要。第三,onChange(of: messages.count)监听的是条数变化而不是数组整体变化,因为如果只是编辑消息内容,不应该触发滚动。

3.3 技能面板:让 Agent 的能力可见

一个 Agent 应用如果没有技能面板,用户根本不知道它能做什么,只能靠瞎试。我专门做了一屏“技能列表”,实时读取SkillRegistry中的全部技能,按category分组展示。这不仅是用户体验问题,还是架构验证——注册中心的数据要能反哺 UI,否则这套体系就是单向不可见的。

struct SkillPaletteView: View { let skills: [any AgentSkill] var body: some View { List { ForEach(SkillCategory.allCases, id: \.self) { category in let filtered = skills.filter { $0.category == category } if !filtered.isEmpty { Section(category.rawValue) { ForEach(filtered, id: \.id) { skill in SkillRow(skill: skill) } } } } } .navigationTitle("能力清单") } }

这里遇到一个 Swift 类型系统的小麻烦:ForEach要求数据源是Identifiable,但any AgentSkill作为不透明类型,Swift 编译器不一定能稳定推断出id的类型。我的解决办法是在SkillRow的初始化参数里直接用any AgentSkill,具体渲染时读取skill.id和skill.displayName,实测没有任何问题。另外这个SkillRow我做了个小心机——右侧显示“已就绪”的绿色圆点,对应技能上次执行成功与否的状态,这样用户能直观看到哪些技能是稳定可用的,哪些最近出过问题。这个状态数据也来自 Agent 的执行记录,算是把运行日志反哺到 UI 的一个例子。

4. 核心技能的实现示例:从任务拆分到结果渲染

4.1 一个完整的实用技能:智能提醒工具

光有框架不行,得有拿得出手的具体技能验证。我实现的第一个“正经技能”是一个提醒事项创建工具:用户用自然语言说“明天早上 9 点提醒我交周报”,Agent 解析出时间和内容,调用系统的 EventKit 创建提醒。这个技能看起来简单,但涵盖了 Skill 实现的所有典型环节:意图识别、参数抽取、外部系统交互、错误处理。

struct ReminderSkill: AgentSkill { let id = "reminder.creator" let displayName = "提醒创建" let category: SkillCategory = .productivity private let reminderStore: ReminderStoring func canHandle(_ request: AgentRequest) async -> Bool { let keywords: Set<String> = ["提醒", "闹钟", "记得", "别忘"] let matched = keywords.contains { request.text.contains($0) } let hasTimeReference = request.text.contains("点") || request.text.contains("明天") || request.text.contains("后天") return matched && hasTimeReference } func execute(_ request: AgentRequest) async throws -> SkillResult { let parser = TimeExpressionParser() guard let parsed = parser.parse(from: request.text) else { throw SkillError.unableToParseTime } let reminder = Reminder( id: UUID().uuidString, title: parsed.content, fireDate: parsed.fireDate ) try await reminderStore.add(reminder) return .text("已创建提醒:\(parsed.content),时间为\(formatDate(parsed.fireDate))") } }

这个示例想说明两件事。第一,canHandle里做的是粗筛,execute里做的是细解析,这个分层很关键。粗筛要快,宁可误报也不要漏报,因为后面有执行失败兜底;细解析要准,因为用户输入千变万化,时间表达式解析失误率不低。第二,对外部系统的访问全部通过协议抽象,ReminderStoring是我自己定义的接口,底层可以接 EventKit,也可以接模拟实现,这样单元测试时不需要真实权限。

4.2 技能执行结果的多态渲染

之前的协议里提到SkillResult是一个枚举,这个枚举在设计时比表面看起来更重要,因为它决定了 UI 层怎么根据不同类型结果做差异化展示。

enum SkillResult: Sendable { case text(String) case structured([String: AnyCodable]) case attachment(AttachmentPayload) case error(SkillError) }

比如天气技能返回structured([:]),里面包含temperature、conditionCode、humidity等字段。UI 层拿到这个结果后,会渲染成一个带图标和温度数字的卡片,而不是把字典硬拼成一句话。这个做法让技能模块完全掌控自己的数据形态,UI 层只负责匹配渲染模板。好处是技能写起来自由,坏处是渲染层要写一堆 case 分支。我的建议是:如果技能多了,可以考虑按category分配合适的渲染容器,比如productivity类技能统一用列表样式,weather类用卡片样式,但每个技能仍然可以覆盖默认模板。

结果渲染这块还有个很实用的技巧:把SkillResult的渲染逻辑写成一个@ViewBuilder函数,放在单独的SkillResultView.swift文件里,用switch result { ... }分发。这样消息列表的每个MessageRow只需要按角色区分气泡样式,然后把SkillResult传给渲染函数,整个代码结构非常清爽。

4.3 技能的测试与调试入口

客户端 Agent 最大的痛点之一是不好调试,你没法像后端那样 curl 一个接口看返回。我为此做了一个开发者工具页:直接把所有技能列出来,每个技能旁边有一个“测试”按钮。点击后弹出一个输入框,预填该技能的典型触发语句(比如提醒技能预填“明天下午 3 点提醒我开周会”),然后走完整的 Agent 分发链路,在调试区展示canHandle的命中情况、执行耗时、返回结果、错误日志。

这个工具页本身不值钱,但它对 Skill 开发的效率提升是巨大的。没有它的时候,我每次测试一个技能都要在聊天界面手动输入触发语,还得盯着日志面板看有没有匹配上。有了它,一秒定位到“是路由没命中还是执行报错”。我强烈建议任何做 Agent 应用的人把类似的调试入口做成标配,而不是等到出 bug 才临时加。

5. 踩坑实录:并发、路由和状态的那些事

5.1 Swift 6 严格并发检查下的改造

这个项目开发时已经切到了 Swift 6 的严格并发模式(Swift 语言模式设为 6),最初不少代码编译直接报错。踩得最深的坑是SkillRegistry的并发访问。第一版我用final class存储技能数组,在resolveSkill里entries.forEach遍历,编译报“rarity of concurrent access”。后来改用了NSLock加快照遍历,问题解决。这里分享一个心得:在 Swift 6 下,不要迷信actor能解决一切并发问题——如果resolveSkill是 actor 方法,里面做await skill.canHandle(request)会阻塞整个 actor 的其它调用,一旦某个技能执行慢,Agent 的其他请求全部会被卡住。快照加锁的做法反而更灵活。

还有一个坑是@Observable类默认是@MainActor的,而SkillRegistry最好是非主线程安全的,因为注册动作可能来自任意线程。我最终的做法是:AgentBrain标注@MainActor,SkillRegistry不标注任何 actor 隔离,但内部用锁保护,调用方在跨线程操作时自行保证正确性。这样职责边界很清楚:调度状态归主线程管,技能注册中心归锁管。

5.2 技能路由误判的排查思路

项目做到中期,出现了一个很诡异的问题:用户输入“提醒我晚上 8 点喝药”,居然被天气技能拦截了。排查了半天,发现是我在天气技能的关键词集合里无脑加了“点”字,用来匹配“现在几点天气怎样”这种问法,结果“晚上 8 点”里的“点”也命中。这个问题让我反思了canHandle的设计原则——关键词匹配要从“包含某个词”升级到“词性+语境”的弱组合判断。

我的修正方案是给每个技能增加一个“负向关键词”黑名单,在canHandle里先做排除再决定是否接管。比如天气技能的负向关键词包含“提醒”“闹钟”“计时”,提醒技能的负向关键词包含“温度”“下雨”。这个方案朴素但有效,实测路由准确率从 80% 出头提到了 95% 以上。如果以后接入真正的 LLM 做意图识别,这个路由层可以替换成模型推理,但负向过滤依然可以作为第一道粗筛保留,减少无意义的模型调用。

5.3 UI 卡顿与任务取消的实战处理

Agent 执行某个技能时如果耗时长(比如联网请求),用户侧最怕的就是界面卡住或者无法中断。这里有几个层面的处理经验。首先,所有技能执行的网络请求、文件读写、数据库操作,都必须用async/await封装,绝不能在技能里用同步阻塞调用,否则 SwiftUI 主线程会被拖死。其次,要给长耗时技能提供进度反馈。我在AgentSkill协议里没有设计进度回调,但实际实现时,我让执行耗时的技能主动调用一个ProgressReporting协议,把阶段进度发出来,UI 层显示进度环。

关于任务取消,我一开始做得不好。用户发了一个长请求之后想反悔,界面上没有取消按钮,只能干等。后来我在AgentBrain里加了cancelCurrentTask()方法,持有当前Task句柄,取消时调用task.cancel(),同时把状态机切回idle。技能侧需要在轮询循环里检查Task.isCancelled,及时抛CancellationError。这里有个容易忽略的细节:SwiftUI 的.task修饰符自带取消机制,但如果你在Button动作里直接创建Task,这个Task不受视图生命周期管理,必须自己持有并手动取消。我目前的做法是把Task挂在AgentBrain上,统一管理。

6. 实测下来的经验总结与后续扩展

6.1 这个架构的实际工作边界

整个项目做完后,我用它做了两个多月的日常生产力工具,整体感受是:对“规则明确、触发清晰”的任务,这个架构效率极高,比如提醒创建、快捷计算、日程查询、本地文件整理,响应快、可预测,用户不会有等待 AI 思考的焦虑感。但对“开放域、需要推理”的任务,纯 Skill 路由的方式就力不从心了,因为它本质是预定规则匹配,遇到没见过的说法就会漏判。

换句话说,这个项目定位不是大模型替代品,而是 App 内智能任务的工程化骨架。它提供的价值是把能力模块化、可插拔、可观测,至于每个技能内部是用正则解析还是接大模型,完全由技能自己决定。这个边界想清楚后,整个项目的方向就会非常明确,不会再纠结“为什么 Agent 不够聪明”。

6.2 如果你想复刻这套方案,建议从这三个方向入手

给准备动手做类似项目的朋友三个具体建议。第一,先把协议定义做到最薄,只保留必须的语义,不要预先设计一堆用不上的回调接口——我第一版协议就是因为过度设计而被迫重构的。第二,从两个最简单的技能开始验证架构(比如计算器技能和文本格式化技能),不要一上来就做复杂的外部系统对接,先确认路由和状态机跑通,再扩展技能。第三,调试能力一定要走在技能开发的前面,开发工具页的优先级应该高于任何具体技能。

关于后续扩展,我目前正在做的是把 AgentContext 升级成一个小型的“记忆卡片”系统,让 Agent 能跨会话记住用户的偏好和常用意图,而不是每次冷启动都从零开始。另一条线是给技能加“并发协作”能力,让一个复杂的任务可以拆分给多个技能并行执行,最后合并结果。这个方向如果跑通,Agent 就不只是单技能调度器,而是真正的任务编排器了。

最后再分享一个我个人在实操中最受益的细节:所有技能的执行耗时、命中率、错误率,我都用统一日志结构记录,然后简单聚合到开发者工具页展示。这套埋点帮我发现了两个平时根本不会注意到的坑——一个是某个网络技能在弱网环境下超时率高达 30%,另一个是某个技能的关键词集合因为重复注册导致命中率虚高。如果没有这些数据,我可能根本不会朝着这两个方向去查。数据驱动的思维,在这类客户端 Agent 项目里一样好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询