1. 从标题拆解 SwiftUI-Agent-Skill 的真实意图
第一次看到 “SwiftUI-Agent-Skill” 这个组合词,我的直觉是:它不是一个单纯的 UI 框架教程,也不是一个泛泛的 AI 工具介绍,而是把三个不同维度的东西拧在了一起——SwiftUI 的声明式界面能力、Agent 的任务编排与自主决策能力、以及Skill 的可复用封装思想。这三者放在同一个标题里,说明作者想表达的是一套“让界面自己动起来、让智能体有章法地干活、让技能可以像积木一样拼装”的工程思路。
我先把这三个词拆开说清楚,再讲它们为什么值得放在一起。
SwiftUI是苹果生态里做界面的声明式框架。它的核心特点是:你描述“界面应该长什么样”,而不是一步步告诉系统“怎么画”。状态变了,界面自动刷新。这个特性在传统 App 开发里已经很香了,但放到 Agent 场景里,它的价值会被放大——因为 Agent 的输出本身就是动态的、不确定的,界面需要跟着 Agent 的状态实时变化。
Agent这个词这两年很热,但很多人对它的理解停留在“会调用工具的聊天机器人”。实际上,一个合格的 Agent 至少包含四件事:感知输入、规划任务、执行动作、根据反馈调整。它和普通程序最大的区别在于:普通程序的执行路径是写死的,Agent 的执行路径是运行时决定的。
Skill则是把 Agent 的能力模块化。一个 Skill 就是一件“它会做的事”,比如“读取日历”“生成摘要”“调用某个本地能力”。Skill 设计得好,Agent 就能像搭乐高一样扩展能力;设计得差,就会变成一堆互相耦合、难以维护的散装函数。
把这三者合在一起,SwiftUI-Agent-Skill 想解决的核心问题是:如何用 SwiftUI 构建一个能实时反映 Agent 状态、并且能动态加载和组合 Skill 的交互界面。这个问题在移动端和桌面端都有真实需求,尤其是那些需要“边思考边展示”的场景,比如智能助手、自动化工作流、本地知识管理工具等。
适合读这篇内容的人有三类:一是有 SwiftUI 基础、想往 Agent 方向延伸的 iOS/macOS 开发者;二是做过 LLM 应用、但对原生界面和状态管理不熟的工程师;三是想理解“Agent + Skill”这套架构到底怎么落地、不想只看概念的人。下面我会按我实际做这类项目时的思路,把设计取舍、核心细节、实操过程和踩坑经验完整讲一遍。
2. 整体架构设计与方案选型思路
2.1 为什么用 SwiftUI 而不是 UIKit 来承载 Agent 界面
做 Agent 类应用,界面最大的特点是状态多且变化频繁。Agent 可能在“思考中”“调用工具中”“等待用户确认”“输出流式中”“执行失败重试中”这些状态之间来回切换。如果用 UIKit,你得手动管理一大堆 UI 更新逻辑,稍不注意就会出现状态和界面对不上、主线程卡顿、列表刷新错位这些问题。
SwiftUI 的声明式模型天然适合这种场景。你把 Agent 的状态定义成一个可观察对象,界面只负责根据状态渲染。状态一变,界面自动重算。我实测下来,用 SwiftUI 做 Agent 状态面板,代码量大概只有 UIKit 方案的三分之一,而且不容易出状态不一致的 bug。
但 SwiftUI 也不是没有代价。它的刷新机制比较“激进”,如果状态对象设计得不好,会导致整个视图树频繁重建,性能反而更差。所以选 SwiftUI 的前提是:你得把状态粒度切细,用@State、@Observable、@Environment这些工具做好分层。这一点在后面讲状态管理时会展开。
2.2 Agent 层为什么独立于界面层
我见过不少项目把 Agent 逻辑直接写在 View 里,结果就是:界面代码和业务逻辑缠在一起,测试没法写,Skill 没法复用,换个界面就得重写一遍。正确的做法是让 Agent 层完全独立,它只负责“接收输入、规划、执行、产出结果”,不关心界面长什么样。
Agent 层对外暴露的应该是一组清晰的状态和事件,比如当前任务、当前步骤、已完成的 Skill 调用、待确认的动作、错误信息等。界面层订阅这些状态,做展示和交互。这样做的另一个好处是:Agent 层可以脱离界面单独测试,你可以用命令行或者单元测试直接驱动它,验证 Skill 编排是否正确。
2.3 Skill 为什么要做成协议而不是基类
Skill 的封装方式有很多种,我最终选择用Protocol + 结构体而不是继承基类。原因有三个。
第一,Swift 是值语义优先的语言,结构体配合协议更符合语言习惯,也更容易做组合。第二,协议可以让每个 Skill 只声明自己需要的能力,比如有的 Skill 需要网络,有的需要文件访问,有的需要用户确认,用协议拆分比塞进一个大基类干净得多。第三,协议更容易做 mock,测试时可以轻松替换成假实现。
我通常会把 Skill 定义成类似这样的结构:一个name和description用于让 Agent 理解它能干什么,一个inputSchema描述输入参数,一个execute方法真正干活。Agent 在规划阶段只需要看 name 和 description 就能决定要不要调用,执行阶段再传具体参数。这种“描述与执行分离”的设计,是让 Agent 能自主选择 Skill 的关键。
2.4 三者如何串起来:一次完整的数据流
把三层串起来看,一次典型的交互是这样的:
- 用户在 SwiftUI 界面输入一个请求。
- 界面把请求交给 Agent 层。
- Agent 根据当前可用的 Skill 列表做规划,决定调用哪些 Skill、按什么顺序。
- 每调用一个 Skill,Agent 更新自己的状态,界面实时反映“正在执行第几步”。
- Skill 执行结果返回给 Agent,Agent 判断是否需要继续、是否需要用户确认。
- 最终结果流式或一次性返回界面展示。
这条链路里,SwiftUI 负责“看得见的状态”,Agent 负责“想得清的决策”,Skill 负责“干得成的动作”。三者职责清晰,才能各自演进而不互相拖累。
3. 核心细节解析与实操要点
3.1 Agent 状态机的设计要点
Agent 最容易出问题的地方就是状态管理。我建议一开始就把状态定义成一个枚举,而不是用一堆布尔值。比如:
enum AgentState { case idle case planning case executing(skillName: String, step: Int, total: Int) case waitingForConfirmation(action: String) case streamingOutput(partial: String) case finished(result: String) case failed(error: String) }用枚举的好处是:状态互斥,不会出现“既在规划又在执行”这种矛盾情况。界面只需要对这个枚举做 switch,就能覆盖所有展示分支。
注意:不要用多个
@Published布尔值来表示状态,比如isPlanning、isExecuting同时为 true 的情况一旦出现,界面就会显示错乱,而且很难排查。
3.2 Skill 协议的最小定义
一个 Skill 协议不需要很复杂,但几个关键字段不能少:
protocol AgentSkill { var name: String { get } var description: String { get } var requiresConfirmation: Bool { get } func execute(input: [String: Any]) async throws -> SkillResult }name和description是给 Agent 规划用的,描述要写得像“给同事交代任务”一样清楚,不要写“处理数据”这种模糊表述,而要写“读取指定日期的日程并返回时间冲突列表”。requiresConfirmation用于标记那些有副作用的 Skill,比如写文件、发请求,执行前需要用户点头。execute用 async 是因为大部分 Skill 都涉及 IO,同步阻塞会卡住整个 Agent。
3.3 SwiftUI 中如何订阅 Agent 状态
Agent 层我通常做成一个@Observable的类,界面用@State持有它,子视图通过@Environment或者直接传引用来读取。关键点是:只把界面需要的状态暴露出去,不要把 Agent 内部的规划细节、中间变量全暴露给界面。界面知道的越少,耦合越低。
流式输出是个特殊场景。Agent 在生成内容时,界面需要逐字或逐段展示。我的做法是让 Agent 维护一个streamingText属性,每次追加内容时更新它,界面用Text绑定这个属性即可。SwiftUI 对这种高频更新有优化,但如果你发现卡顿,可以把更新频率限制在每秒 20 次左右,人眼看起来依然流畅。
3.4 Skill 的动态注册与发现
Skill 不应该写死在 Agent 里。我一般会做一个SkillRegistry,启动时把可用的 Skill 注册进去。Agent 规划时只拿到注册表里的 Skill 描述列表,执行时再按名字查找具体实现。
这样做的好处是:新增一个 Skill 只需要写一个结构体并注册,不用改 Agent 的核心代码。对于需要按场景启用不同 Skill 集合的情况,还可以做多套注册表,比如“办公场景”只注册日历和文档相关 Skill,“开发场景”只注册代码相关 Skill。
实操心得:Skill 的 description 质量直接决定 Agent 选得对不对。我踩过的坑是描述写得太简略,Agent 经常选错 Skill。后来我把每个 description 都写成“动作 + 对象 + 返回什么”的格式,选择准确率明显提升。
4. 实操过程与核心环节实现
4.1 项目结构搭建
我习惯把项目分成三个顶层目录:Agent、Skills、UI。Agent放状态机、规划器、执行器;Skills放各个 Skill 的实现和注册表;UI放 SwiftUI 视图。三者之间只通过明确定义的接口通信,不互相 import 内部实现。
这种结构一开始看起来有点“过度设计”,但等项目长到十几个 Skill、界面有五六个页面时,你会发现它救了你。改一个 Skill 不会影响界面,换一套界面不会动 Agent 逻辑。
4.2 实现一个最小可用的 Agent 执行循环
Agent 的核心是一个循环:规划 → 执行 → 观察结果 → 决定下一步。最小实现大概是这样:
func run(task: String) async { state = .planning let plan = await planner.makePlan(task: task, availableSkills: registry.allDescriptions) for (index, step) in plan.steps.enumerated() { state = .executing(skillName: step.skillName, step: index + 1, total: plan.steps.count) guard let skill = registry.skill(named: step.skillName) else { state = .failed(error: "未找到技能:\(step.skillName)") return } if skill.requiresConfirmation { state = .waitingForConfirmation(action: skill.name) let approved = await waitForUserApproval() guard approved else { state = .failed(error: "用户取消了操作") return } } do { let result = try await skill.execute(input: step.input) context.append(result) } catch { state = .failed(error: error.localizedDescription) return } } state = .finished(result: context.summary) }这段代码不复杂,但把 Agent 的关键环节都覆盖了:规划、逐步执行、确认、错误处理、结果汇总。实际项目里规划器可能更复杂,但执行循环的骨架就是这样。
4.3 流式输出的实现细节
流式输出是 Agent 类应用体验的关键。用户不想等十秒钟才看到结果,他们希望看到内容一点点冒出来。实现上,我让 Skill 的 execute 方法支持一个回调,每产生一段内容就回调一次:
func execute(input: [String: Any], onPartial: ((String) -> Void)?) async throws -> SkillResultAgent 把这个回调接到自己的streamingText上,界面绑定这个属性。需要注意的是,回调可能来自后台线程,更新 UI 状态前要切回主线程。SwiftUI 的@Observable在多数情况下能处理,但为了保险,我习惯在回调里显式await MainActor.run。
4.4 参数计算与选择:超时和重试怎么定
Agent 调用 Skill 时,超时和重试策略不能拍脑袋。我的经验值是:本地 Skill(读文件、算数据)超时设 5 秒,网络类 Skill 设 30 秒,涉及大模型生成的设 60 秒。重试次数默认 1 次,也就是失败后再试一次,因为很多失败是瞬时的。但涉及副作用的 Skill(比如写操作)不要自动重试,否则可能重复执行。
这些参数我一般做成可配置的,放在 Skill 的元信息里,而不是写死在执行器里。不同 Skill 的容忍度不一样,统一配置反而会出问题。
4.5 界面层的状态映射
界面层要做的事其实很单纯:把 AgentState 映射成用户能看懂的东西。比如.executing(skillName: "读取日程", step: 2, total: 3)映射成“正在读取日程(第 2/3 步)”。.waitingForConfirmation映射成一个带确认和取消按钮的弹窗。
我建议给每个状态都设计一个明确的视觉表现,不要出现“状态变了但界面没反应”的情况。用户对 Agent 的信任,很大程度来自“我知道它现在在干什么”。
5. 常见问题与排查技巧实录
5.1 界面不刷新或刷新错乱
这是 SwiftUI 最常见的坑。原因通常是状态对象没有被正确观察。如果你用的是@Observable,确保视图里是通过@State或@Environment持有的,而不是普通属性。如果用的是ObservableObject,确保用了@StateObject或@ObservedObject,而不是直接持有实例。
另一个常见原因是状态更新没有发生在主线程。Agent 的执行循环通常在后台,更新状态前要切回主线程。我一般把状态更新封装成一个MainActor方法,所有地方都通过它改状态,避免遗漏。
5.2 Agent 选错 Skill
这个问题我在前面提过,根因通常是 description 写得不清楚。排查方法是:把 Agent 的规划结果打出来,看它为什么选了这个 Skill。如果发现它理解偏了,就改 description,加上更明确的动作词和返回说明。
还有一种情况是 Skill 太多,Agent 在相似选项之间摇摆。这时候可以给 Skill 加分类或标签,规划时先按类别筛选,再在类别内选择,能明显降低误选率。
5.3 Skill 执行卡住不返回
异步 Skill 如果没有正确处理超时,可能会一直挂着。我的做法是在执行器层面加一个超时包装,不管 Skill 内部怎么实现,超过设定时间就抛超时错误。这样即使某个 Skill 写得有问题,也不会拖垮整个 Agent。
注意:超时后不要直接丢弃任务,要尽量取消底层操作,否则可能留下悬空的网络请求或文件句柄。
5.4 流式输出导致界面卡顿
高频更新文本会让 SwiftUI 频繁重算布局。解决办法有两个:一是限制更新频率,把多次小更新合并成一次;二是把流式文本放在独立的子视图里,避免它影响整个页面的布局。我实测下来,把流式区域隔离成单独视图后,即使每秒更新几十次,整体也很流畅。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 界面不刷新 | 状态对象未被观察 | 检查属性包装器 | 改用 @State/@Observable |
| 状态错乱 | 多布尔值表示状态 | 检查状态定义 | 改用枚举 |
| 选错 Skill | description 模糊 | 打印规划结果 | 重写描述,加分类 |
| 执行卡住 | 缺少超时 | 检查异步调用 | 加超时包装 |
| 流式卡顿 | 更新过于频繁 | 观察刷新频率 | 限频 + 视图隔离 |
| 重复执行 | 副作用 Skill 被重试 | 检查重试策略 | 副作用 Skill 禁用自动重试 |
5.6 几个我踩过的坑
第一个坑是过早优化。一开始我就想做多 Agent 协作、做复杂的规划器,结果基础的状态管理都没理顺,界面天天出问题。后来退回来先把单 Agent + 几个 Skill 跑通,再逐步加复杂度,反而更快。
第二个坑是Skill 粒度过粗。我一开始把“处理文档”做成一个 Skill,结果它内部逻辑太复杂,Agent 根本没法灵活组合。后来拆成“读取文档”“提取要点”“生成摘要”三个 Skill,Agent 就能根据任务自由编排了。
第三个坑是忽略用户确认。有些操作一旦执行就不好撤销,比如覆盖文件。我早期没做确认机制,测试时误操作了好几次。后来给所有有副作用的 Skill 加上requiresConfirmation,体验和安全性都好了很多。
6. 关于扩展方向和个人体会
这套 SwiftUI-Agent-Skill 的结构跑通之后,扩展空间其实很大。我目前尝试过的几个方向:一是把 Skill 做成可持久化的,用户可以在界面上启用或禁用某些 Skill,Agent 的规划范围随之变化;二是给 Agent 加记忆,把历史交互存下来,规划时作为上下文参考;三是做 Skill 的组合,把几个常用 Skill 打包成一个“宏技能”,Agent 调用一次就能完成一串操作。
我个人在实际操作中的体会是:这类项目最难的不是写代码,而是把职责边界划清楚。界面就管展示,Agent 就管决策,Skill 就管执行,任何一方越界都会让项目变难维护。我见过太多项目把三者混在一起,最后改一处崩三处。如果你正准备做类似的东西,我的建议是先把接口定义好,哪怕一开始实现很粗糙,只要边界清晰,后面怎么迭代都不会乱。
最后再分享一个小技巧:给 Agent 的每一步执行都打上日志,包括它选了什么 Skill、传了什么参数、返回了什么结果。这些日志在调试时比断点还好用,尤其是当 Agent 的决策不符合预期时,翻日志能很快定位是规划错了还是 Skill 实现错了。这个习惯我从第一个版本就保持到现在,省下的排查时间难以估量。