SwiftUI + Agent + Skill:构建协议驱动的智能代理App架构

📅 发布时间:2026/10/20 5:28:15
SwiftUI + Agent + Skill:构建协议驱动的智能代理App架构
在做一些日常工具类小项目的时候我越来越明显感觉到一个变化大家讨论的不再是这个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 registerS: 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? TypedSkillInputCalendarReminderInput? 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() - AsyncStreamDouble { 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增加代理能力建议从今天起就试着把一个最小的技能放进去先跑通再谈优化。