☰
SwiftUI+Agent+Skill:苹果生态智能体工程化实战
2026/10/14 9:09:30 网站建设 项目流程

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 时特别有用,比加日志高效得多。

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

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

立即咨询