☰
SwiftUI + Agent + Skill:构建协议驱动的智能代理App架构
2026/10/20 5:28:12 网站建设 项目流程

在做一些日常工具类小项目的时候,我越来越明显感觉到一个变化:大家讨论的不再是"这个App的界面怎么做",而是"这个App能不能帮我处理点事"。具体来说,就是SwiftUI负责把界面搞定,Agent负责理解意图,Skill负责真正去执行任务。这三样东西叠在一起,就是"SwiftUI-Agent-Skill"这个项目最核心的命题——在一个苹果生态的原生UI框架里,塞进一个带技能库的智能代理运行时。

这个项目听起来很玄,但实际落地后你会发现它并没有多高不可攀。它解决的痛点很具体:传统App的功能都是点击触发的,用户得自己找按钮、自己记流程,而代理型App可以通过自然语言或者状态变化,直接把任务路由到对应的技能模块上,UI只负责展示过程和结果。对于想接AI能力但又不想把整个界面交给WebView的团队来说,这类方案是性价比很高的选择。这篇文章会把从架构设计到代码实现再到排错的过程全部拆开,适合已经会用SwiftUI做基础界面、想更进一步接触代理模式的开发者参考。

1. 项目整体设计与思路拆解

1.1 为什么SwiftUI项目需要Agent与Skill的分层

先说结论:SwiftUI本身只解决"视图怎么描述、状态怎么绑定"的问题,它不负责"该做什么"和"怎么做"。当你把App改造成代理驱动时,最忌讳的就是把业务逻辑全部塞进ViewModel或者View里,那样不出三个版本,整个文件就会膨胀到没法维护。

Agent层的价值在于"意图管理",它接收用户的输入、当前上下文、系统状态,然后决定下一步应该调用哪个能力;Skill层的价值在于"能力封装",它把每一个具体操作——比如读取日程、创建提醒、汇总文本、查询天气——统一成一个带输入输出约定的模块。这两层一旦分离开,UI就退化成纯粹的"渲染层",它只需要观察Agent的状态变化,然后把结果展示出来。

这个思路和传统MVC有明显区别。传统MVC里Controller经常要同时干"判断用户要什么"和"直接操作数据模型"两件事,而Agent-Skill体系里,Controller的位置被Agent取代,数据操作被Skill取代,View几乎可以变成无逻辑的空壳。带来的直接好处就是可测试性大幅提升——你可以在不打开App的情况下,单独喂给Agent一串假指令,验证技能路由是否正确。

1.2 方案选型:为什么是协议驱动而不是继承体系

在设计Skill模块时,我首选Swift协议而不是抽象类。原因很简单:Swift的协议可以通过extension提供默认实现,一个类型可以同时遵守多个协议,这对技能这种天然需要多重身份(比如一个技能既可以执行、又可以被记录、还可以被撤销)的模块来说非常合适。

具体做法是定义了一个Skill协议,它包含技能的唯一标识、描述信息、以及执行方法。还有配套的SkillContext,用来传递参数、取消标志和依赖服务。每个具体技能只需要实现自己最核心的那部分逻辑,剩下的注册、调度、错误处理都由框架代劳。这样从上到下就形成了一条干净的分界线:Agent只认识协议,不认识具体类型。

这背后还有一个工程上的考量:如果技能列表是动态的,比如从远程配置中心下发,那么使用继承体系就会非常痛苦——每新增一个技能,你都得去改父类或者注册表;而协议驱动配合类型擦除,可以在运行时轻松地把新技能注入技能仓库,Agent不感知任何差异。实测下来,这种设计在技能数量从十个扩展到五十个的时候,代码改动量几乎为零。

1.3 代理状态与SwiftUI数据流怎么配合

SwiftUI的状态管理是声明式的,而Agent的执行过程是异步且多步骤的,这两者如果不做桥接,很容易出现"界面显示的东西和真实状态不符合"的怪异问题。我的处理方式是把Agent本身变成一个ObservableObject,内部暴露一个发布状态,视图通过@StateObject或者@ObservedObject去订阅。

这里有一个细节值得注意:不要在Agent的@Published属性里塞整个快照,而是只发布一个轻量的枚举状态,比如.idle、.thinking、.executing(skillId:)、.finished(result:)、.failed(error:)。原因很简单,SwiftUI的视图更新成本与发布属性的复杂度直接相关。如果你把整个上下文对象放进@Published,那每次Agent内部哪怕只有一个毫秒级的状态变化,整个界面都会被强制刷新,这在复杂界面下会造成可感知的卡顿。

视图层则根据Agent状态决定渲染什么。.thinking的时候显示一个推进动画,.executing的时候显示当前技能的执行进度卡片,.finished的时候把结构化结果格式化呈现。整个过程中,视图不会直接调用任何技能,它只会向Agent发送一个"触发意图"的消息,剩下的事情由Agent内部完成。

2. 核心细节解析与实操要点

2.1 Skill协议的定义:输入、输出、错误边界

技能能不能顺畅扩展,全看协议定义得好不好。我最后定稿的协议大概长这样:

protocol Skill: Identifiable { var id: String { get } var displayName: String { get } var description: String { get } var inputSchema: SkillInputSchema { get } func execute( with input: SkillInput, in context: SkillContext ) async throws -> SkillOutput }

看起来简单,但里面有几个容易踩坑的地方。首先是inputSchema,它描述了这个技能接受哪些参数、每个参数的类型和约束,这个字段最直接的用途是给Agent做参数校验——Agent在调用技能之前先跑一遍Schema校验,避免把类型错误传进去,省去很多无用的执行开销。

其次是execute方法返回的是SkillOutput,而不是直接返回View或者String。这是为了让所有技能保持同一种输出契约,UI层再进行二次格式化。举个例子,天气预报技能返回一个结构化的ForecastOutput,文本总结技能返回一个SummarizedTextOutput,它们在职责上都是SkillOutput,但视图层可以根据具体类型做不同的展示。

错误边界也要在协议层就固定下来。我给每个技能执行包了一层统一的错误处理:超时、取消、参数错误、依赖服务失败,全部映射到具体的SkillError枚举。这样Agent在捕获到错误时,才能给用户展示有意义的信息,而不是一句干巴巴的"执行失败"。

2.2 技能注册中心:类型安全的依赖注入

技能的注册不能写成一个大数组,那样拿不到具体的输入输出类型。我在项目里实现了一个带泛型的SkillRegistry,它内部维护一个字典,键是技能的id,值是一个类型擦除的执行包装器。注册时,通过泛型方法把技能关联的输入类型和输出类型绑定起来。

final class SkillRegistry { private var storage: [String: AnySkillExecutor] = [:] func register<S: Skill>(_ skill: S) { storage[skill.id] = TypedSkillExecutor(skill: skill) } func executor(for id: String) -> AnySkillExecutor? { storage[id] } }

这里的TypedSkillExecutor是核心,它把协议里泛型化的输入输出转换成了具体的、可类型安全的调用入口。每个技能在注册时,还可以同时注册它依赖的服务——比如一个"发送日程邀请"的技能,它可能依赖日历服务和联系人服务。这些依赖通过初始化器注入,而不是在技能内部硬编码,方便测试时替换成模拟服务。

这一块还有一个实操上很重要的点:注册时机。尽量在App启动早期完成所有静态技能的注册,动态技能可以等远程配置加载之后再补注册。不要在技能执行到一半的时候突然往Registry里塞新技能,因为Agent在并发环境下可能会有正在更新的技能列表,容易产生不可预期的竞态。

2.3 Agent调度策略:串行还是并行

Agent在执行多个技能时,最简单的策略是串行处理,这也是我推荐大多数团队从串行起步的原因。串行意味着状态流转清晰、问题容易复现、日志友好,而且用户的指令通常也有严格的先后关系——比如"先查一下日程,再根据日程创建提醒",这就是天然的串行依赖。

但有些场景必须上并发:比如用户问"把所有未读邮件的关键词提取出来,同时把今天的天气也查了",这两个技能之间没有依赖关系,串行执行会白白浪费时间。我的做法是给Agent的意图分析结果加上一个ExecutionPlan,它可以描述多个步骤之间的依赖拓扑,然后由一个调度器根据拓扑决定哪些步骤并行、哪些步骤等待。

并行执行的坑主要出在共享状态上。两个技能如果同时改写同一个UserDefaults键,可能会产生竞争条件。所以我在调度器里做了一层隔离——每个技能执行时拿到的是只读快照和独立的写通道,技能之间的通信必须通过Agent的消息总线。这样即便并行,也不会出现数据互相踩踏的问题。

3. 实操过程与核心环节实现

3.1 快速搭建一个完整的最小可运行Demo

与其空谈架构,不如直接动手。我建议你新建一个SwiftUI工程,然后按三个步骤搭出骨架:第一步定义技能协议和注册中心,第二步实现一个最简单的Agent,第三步在ContentView里加上一个输入框和结果展示区。下面给的是一个精简但可跑的示例。

Agent最小实现的核心代码:

@MainActor final class Agent: ObservableObject { enum State { case idle, thinking, executing(String), finished(String), failed(String) } @Published private(set) var state: State = .idle private let registry: SkillRegistry init(registry: SkillRegistry) { self.registry = registry } func handle(intent: String) async { state = .thinking do { let matchedSkillId = try await route(intent: intent) guard let executor = registry.executor(for: matchedSkillId) else { throw AgentError.skillNotFound(matchedSkillId) } state = .executing(matchedSkillId) let output = try await executor.execute(input: intent) state = .finished(await format(output)) } catch { state = .failed(error.localizedDescription) } } private func route(intent: String) async throws -> String { // 这里接入意图匹配逻辑,可以先用关键词规则 if intent.contains("日程") || intent.contains("提醒") { return "calendar_reminder" } if intent.contains("总结") { return "text_summarizer" } throw AgentError.unrecognizedIntent(intent) } }

上面的route方法先用关键词规则做意图路由,这是最稳妥的起步方式。等你有余力时,再把它替换成本地语义匹配或者远程LLM调用。注意类上面标注了@MainActor,这是为了确保@Published state的更新全部发生在主线程,避免视图层从后台线程订阅到中间状态。

SwiftUI视图层直接订阅Agent的状态:

struct ContentView: View { @StateObject private var agent: Agent @State private var inputText: String = "" init() { let registry = SkillRegistry() registry.register(CalendarReminderSkill()) registry.register(TextSummarizerSkill()) _agent = StateObject(wrappedValue: Agent(registry: registry)) } var body: some View { VStack { TextField("输入指令", text: $inputText) .textFieldStyle(.roundedBorder) Button("执行") { Task { await agent.handle(intent: inputText) } } switch agent.state { case .idle: Text("等待指令") case .thinking: ProgressView("解析中") case .executing(let skillId): ProgressView("执行技能: \(skillId)") case .finished(let result): Text(result) case .failed(let error): Label(error, systemImage: "exclamationmark.triangle") } } .padding() } }

3.2 写一个真实技能:日历提醒的完整链路

日历提醒这个技能很适合用来演示完整链路,因为它涉及权限申请、数据读取、本地通知,属于"接口完整、边界清晰"的典型技能。

技能的定义大概是这样的:输入参数是"提醒内容"和"提醒时间",输出是一个确认信息。拿到输入后,先通过EventStore读取用户授权状态,如果未授权要抛出一个明确的授权错误,然后再把事件写入日历,并安排本地通知作为二次提醒。

struct CalendarReminderInput: Codable { var content: String var dueDate: Date } struct CalendarReminderOutput: Codable { var eventID: String var scheduledAt: Date var message: String } struct CalendarReminderSkill: Skill { var id: String { "calendar_reminder" } var displayName: String { "日历提醒" } var description: String { "在日历中创建一条提醒事项" } func execute(with input: SkillInput, in context: SkillContext) async throws -> SkillOutput { guard let input = input as? TypedSkillInput<CalendarReminderInput>? let payload = try input.decoded(CalendarReminderInput.self) let eventID = try await calendarService.createEvent(content: payload.content, date: payload.dueDate) try await notificationService.schedule(content: payload.content, date: payload.dueDate) return TypedSkillOutput(value: CalendarReminderOutput( eventID: eventID, scheduledAt: payload.dueDate, message: "已创建提醒:\(payload.content)" )) } }

在真实项目中,输入参数往往不是固定的,你需要一个SkillInputSchema来描述"content是字符串、dueDate是ISO8601格式的日期字符串",这样Agent才能从自然语言里抽取字段。这块可以先用正则和AI辅助抽取,后续再细化成一套小的NER工具集。我实测中比较有效的做法是:先让用户自由输入,然后把整段文本交给意图解析器,让它顺带产出结构化参数,Agent根据解析结果决定调不调用这个技能。

3.3 技能执行进度与SwiftUI动画的联动

技能执行如果超过两三秒,界面不能干等着。我建议是给技能协议增加一个可选方法:

extension Skill { func progressUpdates() -> AsyncStream<Double> { AsyncStream { _ in } } }

每个技能可以把自己内部的进度抛出来,Agent订阅这些进度并映射到视图层的进度条上。这里强烈的建议是:进度值应该经过平滑处理,不要实时更新UI,否则滑动条会跳来跳去。一种简单的做法是每0.2秒从进度流里抽样一次,然后附带动画过渡。

对于那种没有明确进度的技能,比如"文本总结",我会改用阶段提示:读取文本、预处理、生成摘要、渲染结果,每个阶段对应一个不同的图标和文案。用户对阶段提示的感知远比对精确百分比要好,因为阶段提示说明"系统在往前走",而进度条长时间停在80%会让人产生"卡住了"的错觉。

4. 常见问题与排查技巧实录

4.1 视图不更新:@Published状态与Actor隔离的常见冲突

我在项目开发中最常遇到的问题,就是状态已经变了,但SwiftUI视图纹丝不动。排查下来,大部分原因出在Actor隔离上——如果Skill内部是在后台线程更新一个普通的@Published属性,SwiftUI的objectWillChange事件会从后台线程发出,系统虽然不强制崩溃,但更新会不可靠。

对策很明确:所有Agent状态更新都必须通过@MainActor标注的方法完成;技能执行完的结果先传回主Actor,再由Agent统一发布。如果你发现自己不得不在后台线程里改状态,那基本可以断定是设计出了问题,而不是SwiftUI的bug。另一个容易遗漏的点是视图树里使用了不正确的属性包装器,比如用@State持有Agent而不是@StateObject,这样Agent每次重建视图树时都会被重置。

4.2 技能并发导致的数据竞争

由于Agent可以并行执行多个技能,我碰到过两个技能同时写入同一个UserDefaults键,导致一个数据被另一个覆盖的翻车现场。排查的时候日志看起来毫无头绪,因为两个技能都认为自己的写入是对的。

后来我把所有共享存储的操作收敛到一个StorageService,它内部用一个串行队列统一处理写操作。技能的并发策略也改成:凡是声明为"写型"的技能,自动获得互斥锁;声明为"读型"的技能,可以自由并行。这个策略在Agent调度器里实现,不侵入具体技能。凡是涉及跨技能共享的内存字典,一律禁止直接使用,必须走Service层。

4.3 长期运行的技能如何支持取消

用户在Agent执行一个很长的任务时,经常会点"取消"按钮。如果技能不支持取消,这个按钮就是摆设。Swift提供了Task和CancellationError,但技能内部如果用了同步阻塞操作,取消是很难生效的。

我的做法是分两种取消级别:协作式和强制式。协作式要求技能在执行循环的每个阶段检查Task.checkCancellation(),一旦发现取消就清理已分配的资源并抛错;强制式则是在超时后由调度器直接取消整个Task树,并把技能标记为"已中断"。实测中,你有80%的技能用协作式就够了,剩下20%的高耗时技能才需要强制取消兜底。

4.4 模拟器与真机行为差异

技能类应用在模拟器和真机上经常表现不一致,最典型的是通知权限和日历权限。模拟器对于权限弹窗的响应速度非常快,几乎不会触发系统级延迟,导致很多权限申请的边界情况没有暴露。我建议所有涉及系统服务的技能,都要在真机上跑一遍从拒绝授权到中途撤销授权的全流程。

另外,App进入后台后,Agent持有的Task会被系统挂起。如果你的Agent在App进入后台时还在执行技能,恢复后会出现状态和实际结果不一致。处理办法是监听生命周期事件,在进入后台前记录Agent的挂起快照,回前台后从快照恢复,而不是强行继续执行。

4.5 按问题速查表整理一份排查清单

现象可能原因首选排查动作
状态更新但UI不刷新后台线程修改@Published在所有状态修改点标注@MainActor
技能执行返回nil注册时key与路由key不一致打印注册中心所有技能id
进度条卡在固定值技能未实现进度流确认进度更新是否在主线程发送
取消按钮无响应技能阻塞在同步操作改造成异步并定期检查取消标志
分享状态被覆盖多个技能并发写同一存储收敛到串行StorageService
真机权限弹窗异常未声明权限用途文案检查Info配置中权限说明字段

5. 工具选型与依赖管理:哪些库值得引入

5.1 意图解析与结构化参数抽取

如果只是做Demo,关键词规则完全够用。但一旦进入生产,用户表达意图的方式千奇百怪,比如"明天下午三点提醒我开会"和"我明天下午有个会,记得提醒",这两句话包含的语义完全一样,关键词规则无法覆盖。

我建议引入本地语义匹配方案,比如基于嵌入向量的相似度检索:把用户意图编码成向量,然后在预设意图库中找最接近的向量。这样即使表达方式变化,只要语义相似就能命中。至于参数抽取,可以先尝试用正则处理时间、日期、数量这类简单实体,复杂的实体再交给远程大模型补充。这样做的原因是本地抽取快、零延迟、不依赖网络,优先保证基本体验。

5.2 网络层与技能数据源的统一封装

技能免不了要访问网络数据。我强烈建议不要在技能内部直接使用URLSession,原因有二:一是每个技能都写一遍网络错误处理,代码重复严重;二是调试困难,你无法统一追踪请求日志和超时策略。

封装一个APIClient,内部统一处理超时、重试、鉴权、日志,技能只关心业务数据。规范上,我把所有技能的远程调用都限定为"通过APIClient间接访问",这样后续如果要引入缓存层或者流量监控,只需要改一个文件。你希望技能可以单测,那就要求APIClient可以被替换成MockAPIClient。

5.3 技能的可测试性与调试面板

代理型App的调试场景和传统App差别很大,你需要看到"Agent当前认为用户在说什么"、“它选择了哪个技能”、“技能的参数是什么”。我建议在Debug模式下提供一个可视化面板,实时展示Agent的意图解析中间结果、技能注册表快照、最近N条执行记录。

这个面板用SwiftUI可以很快搭出来,注册中心里把所有技能列出来,点击任意技能可以模拟一次带假参数的执行。这个能力在排查"技能返回错误格式"时特别有用——你不必反复手动输入一整句话,直接构造一个参数运行一遍,问题就复现了。

6. 经验沉淀与扩展方向

6.1 设计时的三条铁律

第一条,技能必须无状态。这是所有技能模块设计的核心原则。技能之间不能共享可变状态,否则Agent的调度会变成一场灾难。

第二条,Agent必须先定计划再执行。没有计划就开始执行,会让用户感觉代理不受控制。我的做法是执行前先给用户展示一段意图确认文案,即"我将为你执行以下操作",确认后再动手。

第三条,可观测性优先。从第一天就记录日志,不要等出问题再补。尤其是技能路由决策的日志,等出问题时补日志根本来不及,因为问题已经发生过了。

6.2 如何把同一个技能体系扩展到watchOS与macOS

这套设计天然跨平台,因为Skill层完全不依赖UIKit或SwiftUI。我实际验证过同一套技能注册中心可以直接在watchOS上运行,只是UI层需要单独写一套紧凑布局。在macOS上,技能可以直接复用APIClient和注册中心,只需额外写一个菜单栏控制器来调用Agent。

跨平台时唯一需要处理的是系统能力差异。有些技能在iOS上有日历权限,但在watchOS上权限模型不完全一样,所以技能内部要学会检测平台能力,如果能力不可用就返回一个明确的错误,不要假装执行成功。

6.3 基于这套架构还能继续扩展的三个方向

第一个方向是技能市场,把技能做成可插拔的模块,用户按需下载,这要求你进一步把技能二进制化或者脚本化,让远程下发的技能动态注册到Registry中。

第二个方向是多代理协作。当前Agent是单兵作战,但你可以定义多个不同职责的代理,比如一个负责日程,一个负责邮件,SkillRegistry成为它们共同的后端,通过消息总线协作。

第三个方向是技能自学习。让Agent记录用户的高频指令模式,当某个技能被反复调用时,自动优化路由逻辑,甚至自动填充常用参数。初期可以只做一个基于频次的推荐,技术门槛不高,但带来的体验提升非常明显。

我个人在做这个项目时最大的体会是:SwiftUI和Agent结合的关键不在于UI做得多酷,而在于把"能力边界"想清楚。Skill协议像是对能力边界的约束,它逼着你把每一个功能模块化、契约化,长远来看,这是在为整个App的复杂度买单。如果你正在计划给现有App增加代理能力,建议从今天起就试着把一个最小的技能放进去,先跑通,再谈优化。

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

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

立即咨询