1. 这个标题到底在说什么
第一次看到“SwiftUI-Agent-Skill”这个组合词,我脑子里蹦出来的第一反应是:这大概率不是一个单纯的 UI 框架教程,也不是一个孤立的 AI 智能体项目,而是把三件事拧在了一起——SwiftUI 的声明式界面能力、Agent 的任务编排能力、以及 Skill 的模块化封装能力。说白了,它想解决的是“怎么让一个智能体在苹果生态里真正动手干活,而不是只会在聊天框里回话”。
我先把这三个词拆开讲清楚,不然后面没法聊。
SwiftUI 是苹果在 2019 年推出的声明式 UI 框架,用状态驱动界面,写起来比 UIKit 少很多样板代码。它的核心思想是“你描述界面长什么样,系统负责渲染和更新”,而不是你手动去改每一个控件。这个特性对 Agent 类应用特别友好,因为 Agent 的输出往往是动态的、流式的、状态多变的,用 SwiftUI 做展示层,天然契合。
Agent 这个词这两年被用得很泛,但在工程语境里,它指的是一套能感知输入、做决策、调用工具、返回结果的循环系统。它不只是“调一次大模型接口”,而是包含意图识别、任务拆解、工具选择、结果整合这几个环节。一个合格的 Agent 应该能自己判断“这个问题我该查资料还是该算数还是该调某个本地能力”。
Skill 则是 Agent 的能力单元。你可以把它理解成 Agent 的“工具箱里的每一把工具”。比如“读取日历”“发送通知”“解析图片”“执行本地脚本”,每一个都可以封装成一个 Skill。Skill 的价值在于解耦:Agent 负责决策,Skill 负责执行,两边通过统一接口通信,新增能力时不用动 Agent 的核心逻辑。
把这三者放在一起,SwiftUI-Agent-Skill 这个项目标题指向的,就是一套在苹果平台上构建“有界面、能决策、可扩展”的智能体应用的工程范式。它适合谁看?我觉得有三类人:一是已经在做 iOS/macOS 开发、想把自己的 App 接入智能能力的开发者;二是做 AI 应用、但苦于移动端落地体验差的工程师;三是想理解“Agent 工程化”到底长什么样、不想只停留在调 API 层面的技术爱好者。
我下面会按“整体设计思路 → 核心细节拆解 → 实操落地 → 问题排查”这条线,把我在类似项目里踩过的坑和总结出来的做法完整讲一遍。内容会偏工程实践,不会停留在概念层面。
2. 整体架构设计与选型思路
2.1 为什么是 SwiftUI 而不是 UIKit
很多人做 Agent 类 App 的第一反应是用 UIKit,因为觉得“控制力更强”。我一开始也这么想,后来发现这个判断在 Agent 场景下是反的。
Agent 的输出有三个特点:流式、不确定、多状态。流式意味着文字是一个字一个字蹦出来的;不确定意味着你事先不知道它会返回文本、图片还是结构化数据;多状态意味着同一个界面要在“思考中”“调用工具中”“等待确认”“输出完成”之间来回切换。
UIKit 做这套东西,你需要手动管理大量状态和 diff,代码会迅速膨胀。SwiftUI 的@State、@Observable、ViewBuilder这套机制,天生就是为“状态变了界面自动变”设计的。你只需要把 Agent 的状态机映射成几个枚举,界面跟着枚举走就行。
我实测下来,同样一个带流式输出和工具调用提示的对话界面,SwiftUI 的代码量大概是 UIKit 的 40% 左右,而且状态同步的 bug 明显更少。这不是说 UIKit 不行,而是在 Agent 这个特定场景下,SwiftUI 的抽象层级刚好对上了。
2.2 Agent 层为什么独立于 UI 层
这是我在项目里坚持的一条原则:Agent 的核心逻辑绝对不能写在 View 里。
我见过太多项目,把网络请求、工具调用、状态判断全塞在ViewModel甚至View的body里,结果就是界面一改,逻辑就崩;想换个 UI 框架,整个 Agent 得重写。正确的做法是把 Agent 做成一个独立的、不依赖任何 UI 框架的模块,它只负责“输入消息 → 决策 → 调用 Skill → 产出事件流”,至于这些事件怎么展示,交给上层。
这样做的好处很直接:你可以在命令行里跑 Agent 做测试,可以用单元测试覆盖决策逻辑,可以在未来把同一套 Agent 复用到 watchOS 甚至服务端。UI 只是 Agent 的一个消费者,不是它的宿主。
2.3 Skill 的接口设计是整个项目的命门
Skill 设计得好不好,直接决定这个 Agent 能走多远。我的经验是,Skill 接口要满足四个条件:输入输出可序列化、执行可中断、失败可解释、权限可声明。
可序列化是为了让 Agent 的决策层和执行层能跨进程甚至跨设备通信;可中断是因为有些 Skill 执行时间长,用户可能中途取消;失败可解释是指 Skill 返回错误时要带上“为什么失败”,而不是一个冷冰冰的 error code,这样 Agent 才能决定是重试还是换方案;权限可声明是因为很多 Skill 涉及敏感能力(比如读取相册、访问日历),必须在调用前让系统或用户知情。
我一般会定义一个这样的协议骨架:
protocol AgentSkill { associatedtype Input: Codable associatedtype Output: Codable var identifier: String { get } var description: String { get } var requiredPermissions: [SkillPermission] { get } func execute(_ input: Input) async throws -> Output }这个协议看起来简单,但它把 Skill 的边界划得很清楚。Agent 只需要知道identifier和description就能决定要不要调用它,具体怎么执行是 Skill 自己的事。
2.4 三者如何串起来
整体数据流是这样的:用户在 SwiftUI 界面输入内容,界面把输入交给 Agent 核心;Agent 核心把输入和当前上下文一起送给决策模块(通常是一个大模型调用);决策模块返回“我要调用某个 Skill,参数是这些”;Agent 核心找到对应的 Skill 并执行;执行结果再回传给决策模块,由它决定是继续调用还是生成最终回复;最终回复以事件流的形式推回 SwiftUI 界面。
这条链路里,SwiftUI 只负责“展示事件”和“收集输入”,Agent 负责“决策和编排”,Skill 负责“干活”。三层各司其职,任何一层出问题都容易定位。
3. 核心细节拆解与实操要点
3.1 Agent 状态机的设计
Agent 不是简单的“请求-响应”,它内部有一个状态机。我一般会定义这几个状态:idle(空闲)、thinking(决策中)、callingSkill(调用工具中)、awaitingConfirmation(等待用户确认)、responding(生成回复中)、failed(出错)。
为什么要显式定义状态机?因为界面需要根据状态给出不同反馈。用户在thinking时看到的是“正在思考”,在callingSkill时看到的是“正在查询日历”,在awaitingConfirmation时看到的是“是否允许发送这条消息”。如果状态是隐式的,界面就没法准确表达。
状态切换要遵循一个原则:任何状态都必须有超时和取消路径。我踩过的坑是,某个 Skill 卡住不返回,Agent 永远停在callingSkill,用户只能杀进程。后来我给每个状态加了超时,超时后自动进入failed并给出可读的错误信息。
3.2 流式输出的处理
Agent 的回复通常是流式的,这对 SwiftUI 来说既是优势也是挑战。优势是 SwiftUI 的@Observable能自动响应数据变化;挑战是如果每来一个字就触发一次界面刷新,性能会很差。
我的做法是在 Agent 和界面之间加一个缓冲层:Agent 产出的是 token 流,缓冲层按固定时间间隔(比如 50ms)合并一次,再推给界面。这样既保证了“打字机效果”的流畅感,又不会让界面刷新过于频繁。
另外,流式输出要处理好“中途插入工具调用”的情况。比如 Agent 正在输出文字,突然决定要调用一个 Skill,这时候文字流要暂停,界面要切换到工具调用提示,等 Skill 返回后再继续输出。这个切换逻辑如果处理不好,用户会看到文字闪烁或者顺序错乱。
3.3 Skill 的参数校验与错误处理
Skill 执行前必须做参数校验。我见过太多项目,Agent 传过来的参数格式不对,Skill 直接崩溃。正确的做法是在 Skill 的execute方法入口处做严格校验,校验失败就抛出一个带明确原因的SkillError。
错误处理要分三类:可重试错误(比如网络超时)、不可重试错误(比如参数非法)、需要用户介入的错误(比如权限不足)。Agent 拿到错误后要根据类型决定下一步:可重试的就重试,不可重试的就换方案或告知用户,需要用户介入的就弹出确认界面。
这里有个细节:错误信息要面向 Agent 可读,而不是面向人类可读。因为最终决定怎么处理错误的是 Agent 的决策模块,所以错误信息要包含足够的结构化信息,比如errorType、retryable、suggestion。
3.4 权限与安全边界
Skill 能调用的能力越多,安全风险越大。我的原则是:每个 Skill 必须显式声明它需要什么权限,Agent 在调用前必须检查权限是否已授予。
在苹果生态里,权限通常通过系统弹窗授予。但 Agent 场景下,用户可能不希望每次调用都弹窗。我的做法是引入一个“权限预授权”机制:用户在设置里可以预先授权某些 Skill 的某些权限,Agent 调用时先查预授权,没有再走系统弹窗。
另外,涉及敏感操作的 Skill(比如发送消息、删除数据)应该默认进入awaitingConfirmation状态,让用户确认后再执行。这个确认不是不信任 Agent,而是给用户一个“刹车”的机会。
注意:权限声明不要写成“这个 Skill 需要所有权限”,而要精确到具体能力。粒度越细,用户越愿意授权,Agent 的可用性反而越高。
4. 完整实操流程与关键环节
4.1 环境准备与项目结构
我一般会这样组织项目结构:
AgentCore/ # 纯 Swift 包,不依赖 UI Agent.swift # 状态机与编排逻辑 Decision.swift # 决策模块 Event.swift # 事件定义 Skills/ # Skill 实现 CalendarSkill.swift NotificationSkill.swift ... AppUI/ # SwiftUI 界面 ChatView.swift SkillStatusView.swift ...AgentCore 和 Skills 做成独立的 Swift Package,AppUI 依赖它们。这样 AgentCore 可以单独测试,Skills 可以单独复用。
环境上,需要 Xcode 15 以上,Swift 5.9 以上,因为要用到async/await和@Observable。如果要做本地模型推理,还需要考虑设备算力,老设备上建议走云端决策。
4.2 Agent 核心的初始化
Agent 初始化时要注入三样东西:决策模块、Skill 注册表、事件回调。决策模块通常是一个大模型客户端,Skill 注册表是一个[String: AnyAgentSkill]的字典,事件回调是 Agent 向界面推送事件的通道。
final class Agent { private let decision: DecisionMaking private let skills: [String: AnyAgentSkill] private let onEvent: (AgentEvent) -> Void init(decision: DecisionMaking, skills: [AnyAgentSkill], onEvent: @escaping (AgentEvent) -> Void) { self.decision = decision self.skills = Dictionary(uniqueKeysWithValues: skills.map { ($0.identifier, $0) }) self.onEvent = onEvent } }这里用AnyAgentSkill做类型擦除,是因为不同 Skill 的Input和Output类型不同,但 Agent 需要统一管理它们。
4.3 一次完整对话的执行流程
假设用户输入“帮我看看明天下午三点有没有空,有空就发个提醒”。
第一步,Agent 把用户输入和当前上下文(时间、用户偏好等)打包,送给决策模块。决策模块返回一个结构化指令:调用CalendarSkill,参数是{date: "明天", timeRange: "15:00-16:00"}。
第二步,Agent 从注册表里找到CalendarSkill,检查权限,执行。CalendarSkill返回{available: true}。
第三步,Agent 把 Skill 结果回传给决策模块。决策模块返回下一个指令:调用NotificationSkill,参数是{time: "明天14:50", content: "下午三点有安排"}。
第四步,NotificationSkill执行成功,返回{scheduled: true}。
第五步,Agent 把最终结果回传给决策模块,决策模块生成自然语言回复:“明天下午三点你目前是空的,我已经帮你设了一个两点五十的提醒。”
整个过程中,Agent 通过onEvent回调向界面推送了多次事件:thinking、callingSkill(CalendarSkill)、callingSkill(NotificationSkill)、responding。界面根据这些事件切换展示。
4.4 Skill 的具体实现示例
以NotificationSkill为例,它的实现大概是这样的:
struct NotificationSkill: AgentSkill { typealias Input = NotificationInput typealias Output = NotificationOutput let identifier = "notification.schedule" let description = "在指定时间发送一条本地通知" let requiredPermissions: [SkillPermission] = [.notifications] func execute(_ input: NotificationInput) async throws -> NotificationOutput { guard input.time > Date() else { throw SkillError.invalidInput(reason: "通知时间必须晚于当前时间") } let content = UNMutableNotificationContent() content.title = "提醒" content.body = input.content let trigger = UNCalendarNotificationTrigger( dateMatching: Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: input.time ), repeats: false ) let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) return NotificationOutput(scheduled: true, scheduledTime: input.time) } }这个实现里,参数校验、权限检查、错误抛出都齐了。Agent 调用它时不需要关心内部细节,只需要知道identifier和description。
4.5 SwiftUI 界面的事件消费
界面层用一个@Observable的 ViewModel 来消费 Agent 事件:
@Observable final class ChatViewModel { var messages: [ChatMessage] = [] var currentState: AgentState = .idle var activeSkillName: String? func handle(_ event: AgentEvent) { switch event { case .stateChanged(let state): currentState = state case .skillStarted(let name): activeSkillName = name case .skillFinished: activeSkillName = nil case .tokenReceived(let token): appendToken(token) case .messageCompleted(let message): messages.append(message) } } }界面根据currentState和activeSkillName展示不同的状态提示。比如callingSkill时显示“正在使用:日历”,responding时显示流式文字。
4.6 参数计算与超时设置
超时时间不能拍脑袋定。我的经验值是:决策模块调用超时 30 秒,Skill 执行超时根据类型定——本地 Skill 5 秒,网络 Skill 15 秒,涉及用户确认的 Skill 不设超时(等用户操作)。
重试次数也要区分:网络类 Skill 最多重试 2 次,本地 Skill 不重试(本地失败通常是逻辑问题,重试没用)。重试间隔用指数退避,第一次 1 秒,第二次 2 秒。
这些参数不是固定的,要根据实际运行数据调整。我一般会在 Agent 里加一个简单的统计模块,记录每个 Skill 的平均执行时间和失败率,跑一段时间后再优化超时和重试策略。
5. 常见问题与排查技巧实录
5.1 流式输出卡顿或闪烁
这是最常见的问题。原因通常是界面刷新频率过高,或者状态切换时没有做过渡处理。
排查思路:先看是不是每个 token 都触发了界面刷新。如果是,加缓冲层合并。再看状态切换时是不是直接替换了整个视图树,如果是,用withAnimation或者保持视图结构稳定,只改内容。
我踩过的一个坑是,流式文字用Text拼接时,每次拼接都会重新计算布局,长文本下性能很差。后来改成用一个Text显示完整字符串,只是字符串在增长,性能就好了很多。
5.2 Skill 调用后 Agent 不继续
有时候 Skill 执行完了,Agent 却停在那里不动了。这通常是事件回调没有正确触发,或者决策模块没有收到 Skill 结果。
排查步骤:第一,确认 Skill 的execute是否真的返回了;第二,确认 Agent 是否把结果回传给了决策模块;第三,确认决策模块是否返回了下一个指令。我一般会在这三个节点加日志,很快就能定位。
另一个可能的原因是状态机死锁。比如 Agent 在callingSkill状态等待 Skill 返回,但 Skill 内部又调用了 Agent 的某个方法,导致循环等待。这种情况要避免 Skill 反向依赖 Agent。
5.3 权限弹窗不出现或反复出现
权限弹窗不出现,通常是因为权限已经被拒绝过,系统不再弹窗。这时候要引导用户去设置里手动开启。反复出现则可能是因为每次调用都检查权限,而权限检查逻辑没有缓存结果。
我的做法是在 Agent 启动时做一次权限盘点,把已授予的权限缓存起来,调用 Skill 前先查缓存,缓存没有再走系统检查。用户修改权限后,通过系统通知更新缓存。
5.4 决策模块返回格式错误
大模型返回的指令格式偶尔会不符合预期,比如 JSON 少了个括号,或者字段名拼错了。这时候 Agent 不能直接崩溃,要有容错机制。
我的做法是:第一,决策模块的 prompt 里明确要求返回固定格式;第二,Agent 解析时用宽松模式,能修就修;第三,修不了就返回一个“我理解不了这个指令”的回复,让用户重新表述。绝对不能让格式错误导致整个 Agent 挂掉。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 流式输出卡顿 | 刷新频率过高 | 检查 token 触发频率 | 加缓冲层合并刷新 |
| Agent 停止响应 | 事件回调未触发 | 检查 Skill 返回和状态切换 | 加日志定位断点 |
| 权限弹窗异常 | 权限缓存未更新 | 检查权限检查逻辑 | 启动时盘点并缓存 |
| 指令解析失败 | 模型返回格式错误 | 检查原始返回内容 | 宽松解析加兜底回复 |
| Skill 执行超时 | 超时设置过短 | 查看 Skill 平均耗时 | 按类型调整超时值 |
| 界面状态错乱 | 状态切换无过渡 | 检查状态机切换逻辑 | 保持视图结构稳定 |
5.6 几个我踩过的坑
第一个坑是把 Agent 逻辑写在了 View 的task修饰符里。一开始觉得方便,后来发现界面一重建,Agent 状态就丢了。正确做法是把 Agent 做成单例或者注入到环境里,生命周期独立于视图。
第二个坑是Skill 之间互相调用。有个 Skill 为了复用逻辑,直接调了另一个 Skill,结果依赖关系变得很乱。后来我规定 Skill 之间不能互相调用,需要复用的逻辑抽成独立的工具函数。
第三个坑是忽略设备性能差异。在模拟器上跑得很流畅的流式输出,到了老设备上卡成幻灯片。后来我加了动态缓冲策略:设备性能好就刷新快一点,性能差就慢一点。
第四个坑是错误信息直接展示给用户。Agent 返回的SkillError里包含技术细节,直接展示用户看不懂。后来加了一层错误翻译,把技术错误转成用户能理解的话。
6. 后续扩展与个人体会
这套架构跑通之后,扩展方向其实很多。比如可以把 Skill 做成可插拔的插件,用户自己写 Skill 上传;可以把决策模块换成端侧模型,减少网络依赖;可以把 Agent 的事件流做成可回放的,方便调试和审计。
我个人在实际操作中的体会是,Agent 类项目最难的不是模型调用,而是状态管理和错误处理。模型调用有现成的 SDK,但状态机怎么设计、错误怎么分类、权限怎么管理,这些没有标准答案,只能根据具体场景一点点磨。我建议刚开始做的时候,先把状态机画清楚,把 Skill 接口定死,后面再改成本会很高。
另外一个小技巧是,Agent 的决策模块 prompt 里最好加上“当前可用的 Skill 列表和它们的描述”,这样模型才知道自己有哪些工具可以用。这个列表要动态生成,不要写死,否则新增 Skill 时容易漏掉。
最后再分享一个调试技巧:我会在 Agent 里加一个“回放模式”,把一次对话的所有事件按时间顺序记录下来,出问题时可以逐帧回放,看是哪一步出了问题。这个功能在排查偶发 bug 时特别有用,比加日志高效得多。