最近几个月我一直在折腾一个很有意思的项目,项目名叫“SwiftUI-Agent-Skill”。简单来说,它是一套跑在苹果生态里的 AI Agent 客户端框架,用 SwiftUI 搭界面,把 Agent 的“技能(Skill)”做成了可注册、可编排、可复用的模块化能力。做这个项目的起因很实际:我们团队在给某个内部智能助手做重写,原来用 UIKit + React 混合的方式,界面状态和 Agent 的决策流完全割裂,每次技能执行到什么阶段、输入了什么参数、输出怎么渲染,都要靠一堆 delegate 回调硬撑,维护成本高得吓人。后来我干脆用 SwiftUI 重写了一遍,顺手把技能系统也抽成了独立框架,就是现在这个“SwiftUI-Agent-Skill”。
这篇文章不准备讲大道理,就围绕“如何用 SwiftUI 构建一个可上生产环境的 Agent 技能系统”来展开,把我在架构设计、状态管理、异步处理、跨平台适配这几个维度上踩过的坑和沉淀下来的方案完整记录下来。适合三类人看:第一类是正准备用 SwiftUI 做 AI 客户端、但不确定状态流怎么设计的开发;第二类是已经在做 Agent 框架、想在界面层接入技能系统的工程师;第三类纯粹是对 Swift Concurrency 和 SwiftUI 结合方式感兴趣、想看看实际案例的同学。内容会涉及完整的协议设计、状态机实现、代码示例和排查经验,尽量做到能直接参考、能落地复现。
1. 项目定位与技术选型:为什么是 SwiftUI 来做 Agent 技能系统
1.1 SwiftUI 在 Agent 客户端场景里的核心优势
我最早用 SwiftUI 做 Agent 客户端之前,先在 UIKit 里做过一版原型。那版原型让我最头疼的地方就是“状态同步”。Agent 在思考、调用技能、生成回复的过程中,界面需要实时反映这些阶段,比如技能执行中的进度条、等待用户授权的弹窗、大模型输出流式文字的逐字渲染。UIKit 的做法通常是“收到事件 → 手动更新 UI 控件”,或者用 Combine 绑定 ViewModel 的属性,但技能一多、事件一杂,回调嵌套和状态同步就会迅速失控。
SwiftUI 把“状态驱动界面”变成了语言级特性。视图本身是纯函数式的:给定状态,渲染出对应的 UI。所以当 Agent 的状态机定义得足够清晰时,SwiftUI 的视图层几乎不用写“更新逻辑”,只需要把状态结构体直接丢给 View,剩下的由框架自动 diff 刷新。这个特性在处理 Agent 技能执行这种“有阶段、有进度、有中间态”的场景时,优势非常明显。我可以在不引入任何额外 UI 刷新代码的情况下,让技能面板、执行日志、进度指示器全部跟随状态自动变化。
另一个优势是跨平台复用。SwiftUI 从 iOS 到 macOS、甚至 watchOS 和 visionOS 都能跑,这对 Agent 类应用特别重要。我们的技能系统里有很多工具类技能(本地搜索、定时提醒、快捷指令等),这些能力在 iPhone 上和 Mac 上差别不小。用 SwiftUI 的框架可以在很短时间内同时具备 iPhone 端和 Mac 端的界面能力,省掉了大量双端重复开发。
1.2 技能(Skill)抽象模型的设计初衷
做这个项目之前,我先把“技能”到底是什么想清楚了。在我的定义里,Agent 技能不是一段写在聊天回复里的文字,而是一个可以被 Agent 决策系统调用、可以接收结构化输入、可以产出结构化输出、可以上报执行进度、可以被 UI 实时展示的独立功能模块。它至少包含三部分:技能元信息(名称、描述、参数 schema)、执行逻辑(实际去调用本地接口也好、发网络请求也好)、运行时状态(当前是否在执行、进度如何、是否等待输入、最终结果是什么)。
这个抽象模型直接决定了后面所有代码结构。我希望能做到“新增一个技能只需要写一个符合协议的类型”,添加完自动出现在 Agent 的技能注册表里,自动能在技能面板里显示,自动能在执行时被 Agent 选中并调用。这个目标听起来简单,但真要做出来,需要协议设计、类型擦除、依赖注入多个层面的配合。
1.3 方案取舍:为什么没有选择跨端框架
有不少朋友问,为什么不用 React Native 或者 Flutter 来做? Agent 客户端其实对系统能力集成要求很高,尤其是本地技能系统,调用的是 iOS/macOS 的系统 API(通讯录、日历、定位、提醒事项、文件访问)。跨端框架在这些系统 API 的接入上始终隔一层,而且 Swift Concurrency 可以非常优雅地处理流式数据(AsyncStream、AsyncSequence),这个能力用在 Agent 技能执行和模型输出上,体验比任何跨端方案的桥接层都要顺。另外 SwiftUI 的强类型 Model 和 Codable 协议体系,做结构化技能入参出参映射特别自然,省掉了一层又一层的 JSON 手工解析。
所以我的结论是:做 Agent 客户端,原生 SwiftUI 不只是更顺手,而是在架构上更加正确。这套框架天生适合处理“多状态、多阶段、跨平台、强系统交互”的应用形态。
2. 核心模块拆解:技能协议、注册中心与状态机
2.1 技能协议:用 Protocol + Codable 定义技能规范
技能协议是整个框架的地基。我设计了一个AgentSkill协议,所有技能类型都必须遵循。协议里有三块内容:静态元信息、输入的参数 schema、执行方法。元信息包括技能唯一标识、展示名称、描述文本,其中描述文本很关键,Agent 的决策模型就是靠这个描述决定要不要调用当前技能,所以描述要写得让模型容易理解,而不是写给人看。参数 schema 我用的是一个Codable类型的关联类型,具体来说:
protocol AgentSkill: Identifiable { var id: String { get } var displayName: String { get } var description: String { get } associatedtype InputType: Codable associatedtype OutputType: Codable func execute( input: InputType, context: AgentExecutionContext ) async throws -> OutputType }这个协议的好处是:每个技能自带输入输出类型,强类型约束,编译期就能发现错误。在运行时,再把具体技能用类型擦除包装成AnyAgentSkill,统一以 JSON 形式暴露给 Agent 决策层。实际执行时传参,也是通过解析参数 schema 生成一个 JSON 值,再解码成具体的InputType。
这里有一个很关键的细节:技能的描述和参数 schema 要能够输出成大模型能读懂的形式。我额外建了一个反射类,把InputType的 Codable 属性自动转成 JSON Schema 形式的描述,包括属性名、类型、必填性、说明注释(通过 CodingKeys 的注释映射),这样 Agent 决策模型拿到的是一个干净的“工具说明书”。这块我后面会再讲。
2.2 技能注册中心:服务定位与依赖注入
有了协议,接下来要管理所有已注册的技能。我用了一个AgentSkillRegistry单例类来承担注册中心职责。注册中心维护技能字典,提供按 id 查询、按关键词搜索、返回技能列表等方法。技能注册方式有两种:手动注册和自动扫描。自动扫描是运行时通过Mirror机制扫描预置模块里的全部类型,找到符合AgentSkill协议的类型并自动注册,适合小型应用。大型应用我建议手动在入口文件里统一注册,因为技能多到上百个之后,依赖关系还是显式管理更清晰。
注册中心同时承担依赖注入的职责。很多技能需要依赖网络服务、本地数据库、用户偏好设置这样的外部对象。如果技能直接用单例或者全局变量去访问依赖,模块耦合就会非常严重。我的做法是为注册中心增加一个“依赖容器”,在注册技能时,把一个解析闭包传递进去,技能执行时从容器中取出对应的依赖对象。这样技能本身不感知依赖的具体来源,测试时也方便 mock。
protocol AgentDependencyProviding { func resolve<T>(_ type: T.Type) throws -> T } final class AgentSkillRegistry { static let shared = AgentSkillRegistry() private var skills: [String: AnyAgentSkill] = [:] private var dependencyProvider: AgentDependencyProviding? func register<S: AgentSkill>(_ skill: S) { let erased = AnyAgentSkill(skill) skills[erased.id] = erased } func registerDependencyProvider(_ provider: AgentDependencyProviding) { dependencyProvider = provider } func skill(for id: String) -> AnyAgentSkill? { skills[id] } var allSkills: [AnyAgentSkill] { Array(skills.values) } }2.3 Agent 运行状态机:从“空闲”到“等待技能执行”的流转设计
Agent 状态机是整个系统最核心的部分。我把它建模为一个枚举AgentState,包含这些状态:.idle(空闲)、.thinking(思考中,模型正在判断要不要调用技能)、.executingSkill(skillInfo:progress:)(技能执行中)、.awaitingInput(prompt:)(需要用户补充信息)、.streamingOutput(text:)(模型生成回复中)、.completed(本轮结束)、.failed(error:)(出错)。
状态机用 Swift@Observable宏标记,整个 Agent 的 session 对象持有当前状态。UI 层只订阅这一个状态源,根据状态切换界面显示。这个设计解决了我在 UIKit 版本里最痛苦的“多处异步回调更新不同 UI 控件”的问题。现在所有界面变化都来自单一状态源,代码逻辑可以很清晰地沿着“状态变化 → 界面变化”这条线去推导。
更重要的是,技能执行本身也只在状态机的框架内运行。技能开始执行时,Agent 会话把当前状态切换到.executingSkill,并把技能的 id、名称、进度传进去。技能内部通过上下文对象报告进度,这个进度值变化时状态也跟着更新,UI 层自然就刷新了。技能完成后,Agent 把输出结果回传给状态机,状态再切换到.thinking或者.streamingOutput,进入下一轮推理。
3. 实操过程:在 SwiftUI 中落地 Agent 技能调用
3.1 第一步:搭建技能列表与执行面板 UI
UI 层我用了单窗格布局,核心区域分成三块:顶部是 Agent 会话气泡区,中间是技能执行状态面板,底部是输入框。技能面板是这轮项目的重点,我会忠实地把当前执行的技能信息展示出来:技能名称、图标、执行进度条、当前运行状态、输出结果摘要。因为状态驱动,这个面板的代码非常简洁:
struct SkillExecutionPanel: View { @Bindable var session: AgentSession var body: some View { if case .executingSkill(let info, let progress) = session.state { VStack(alignment: .leading, spacing: 8) { Label(info.displayName, systemImage: "gearshape.fill") .font(.headline) ProgressView(value: progress) Text("正在执行...") .font(.caption) .foregroundStyle(.secondary) } .padding() .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) } else if case .awaitingInput(let prompt) = session.state { // 等待用户输入的卡片,展示提示文案和确认按钮 } } }看到没有?没有“主动刷新”代码。只要 session.state 一变化,SwiftUI 自动更新视图。这就是状态驱动 UI 的核心体验。实际实现里面,AgentSession是被@Observable宏修饰的类,所有属性都是可被观察的。这种写法在 iOS 17 / macOS 14 以上可以直接使用,如果还要兼容更老的系统,就得退回ObservableObject+@Published,但整体架构思路不变。
3.2 第二步:用 AsyncStream 串联 Agent 决策与技能执行
Agent 的核心运行循环我用AsyncStream做了实现。决策过程是这样的:用户输入信息 → Agent 把消息提交给大模型 → 模型返回两种结果:要么是一个正常的文字回复,要么是一个结构化的“工具调用”请求。如果是工具调用,Agent 解析出技能 id 和参数,在注册中心找到对应技能,然后执行它,再把技能执行结果以“工具响应”的消息形式再次送回模型循环。模型可能继续调用下一个工具,也可能最终给出回复。
用AsyncStream的好处是,这串决策循环可以像序列一样被遍历,Agent 会话可以逐步消费事件,并且每一步之间可以插入 UI 的状态更新。我的runAgentLoop方法就长这类似结构:
func runAgentSession(input: String) async { state = .thinking var messages: [ChatMessage] = [ChatMessage(role: .user, content: input)] for await event in agentEngine.streamResponse(messages: messages) { switch event { case .text(let text): state = .streamingOutput(text: text) case .toolCall(let toolCall): state = .executingSkill(skillInfo: toolCall.skillInfo, progress: 0) let result = await executeSkill(toolCall) messages.append(ChatMessage(role: .tool, content: result.jsonString())) state = .thinking case .done: state = .completed } } }这段代码看起来简单,但背后有几个容易踩坑的细节。第一,executeSkill期间界面要能响应,因此整个 run 方法是在Task.detached里执行的,UI 依然可以通过状态对象更新。第二,AsyncStream的取消传播要注意:如果用户在技能执行中点击了“停止”,我需要在上下文对象里抛一个CancellationError,技能的异步执行才能及时中断,否则技能会在后台继续挂起,很浪费资源。
3.3 第三步:把技能状态映射成 SwiftUI 视图状态
技能执行状态映射到视图状态,关键在于不要直接把技能内部状态塞给视图,而是通过统一的状态机输出。我定义了一个SkillRuntimeState结构体,包含技能执行阶段(准备中、正在执行、已完成、失败)、进度百分比、附加文本信息。技能上下文对象持有一个SkillRuntimeState,每当内部逻辑更新这个结构体时,就通过回调同步给 AgentSession,由 AgentSession 更新全局 state。这样一个技能无论如何内部实现(网络请求、本地文件处理、调用第三方 SDK),它的 UI 表现都完全由技能自己控制,但通过统一通道上传进度。
实际经验来看,技能内部不要直接持有 UI 层的 view 或 view model,否则会把技能重用性破坏掉。上下文对象只提供“上报进度”和“检查是否被取消”这两个能力,其他一概不暴露。
3.4 第四步:调试与可视化技能执行链路
Agent 技能系统最容易出问题的环节是“大模型乱调用技能”和“技能参数解析失败”。为了高效调试这两个问题,我加了一个执行链路查看器。它记录每一轮 Agent 收到的模型消息、发起的工具调用、技能执行耗时、参数 JSON、结果摘要,以时间线形式展示在开发者预视窗口。线上环境可以关闭这个面板,但开发模式强制展示。
链路记录的实现思路很简单:AgentSession 增加一个SkillCallRecord数组,每次工具调用和技能执行完成时,往数组里追加设备,再通过 SwiftUI 的TimelineView渲染。我建议每个开发者都做这个功能,因为它能帮你快速判断是 Agent 决策问题导致技能调用错误,还是技能本身执行有 bug。前者要调提示词和技能描述,后者才需要调技能代码,没有链路记录的话,排查方向很容易跑偏。
4. 工程化经验:跨 iOS / macOS 复用的几个注意事项
4.1 平台差异处理:快捷键、菜单命令与窗口行为
SwiftUI 虽然跨平台,但键盘快捷键、菜单命令和窗口行为在 iOS 和 macOS 上差别很大。我整理了一张对照表,方便参考。
| 能力 | iOS | macOS | 建议处理方式 |
|---|---|---|---|
| 技能执行快捷键 | 不支持系统级 | 支持 Command + 数字键 | 用.keyboardShortcut修饰符,只在 macOS 下挂载 |
| 技能拖拽导入 | 受限 | 大力支持 | 统一封装 DragDelegate,平台内适配 |
| 窗口关闭行为 | 不存在 | 需要显式处理 | 用WindowGroup生命周期回调区分 |
| 菜单显示 | 无 | 有 | macOS 下使用Commands构建菜单栏 |
| 屏幕常亮 | 需要请求 | 不需要 | 用UIApplication.shared.isIdleTimerDisabled,仅 iOS |
这部分经验是我做双端适配时最大的工作量来源。如果一开始就打算双端,建议在协议层把“能力差异”抽象成可查询的能力集接口,比如supportsKeyboardShortcut、supportsDragAndDrop,这样视图代码只需根据能力集显示或隐藏功能,不会写出一堆#if os(macOS)的烂摊子。
4.2 热重载与状态连续性:SwiftUI 预览中的技能状态
开发过程中经常会遇到“技能执行到一半时改代码,预览面板刷新后状态丢失”的问题。这是因为 SwiftUI 预览默认每次刷新都会重建视图和状态对象。我的应对方法是:为 AgentSession 单独建一个“预览种子对象”,在预览里用一个固定的过程状态(比如技能执行到 60%)来渲染,这样改界面样式时不会依赖真实执行流程。
真正跑真机时,要注意 app 进入后台再回前台时,Agent 状态是否保持了连续性。用@Observable的状态对象不会自动持久化,我额外在技能执行的每个关键节点(开始、阶段切换、结束)把状态快照写入一个轻量级存储。这样即使 App 被系统杀掉,重新打开后也可以通过快照提示用户“技能执行因中断被暂停”,而不是直接丢给用户一个空白界面。
4.3 权限与安全边界:沙盒环境下的技能调用
macOS 的沙盒限制、iOS 的隐私权限,都很容易让技能执行失败。比如一个本地文件搜索技能,在 macOS 沙盒下如果没有读取目录权限,直接返回一个权限错误,技能 UI 上也没有任何提示。我建议技能系统最外层统一包装一个权限探测层,所有技能执行前先检查依赖的权限是否具备,如果缺失,直接把状态机切换到“等待授权”状态,并且给出系统权限申请引导。
还有一个安全边界的问题:Agent 技能调用的参数里经常包含用户隐私信息。我规定技能协议里每个输入字段都必须声明“敏感级别”,敏感字段在链路日志中脱敏打码,防止开发调试时无意间记录明文隐私。这个机制虽然简单,但在真实产品里很重要,建议各位在自己的技能系统里加上。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 技能列表里找不到刚注册的技能 | 类型没有被扫描到,或者 id 冲突 | 检查注册方法是否是显式注册,打印注册中心的全部技能 id |
| Agent 从不调用某个技能 | 技能描述写得模糊,或者参数 schema 定义错误 | 重新组织描述,加入调用场景例子,把参数 schema 精简到最少必填项 |
| 技能执行后 UI 不刷新 | 状态对象没有标记为可观察,或者视图订阅的是局部副本 | 确认 AgentSession 是否使用了 @Observable,视图是否引用了完整的 session 实例 |
| 技能执行很慢 | 没有超时控制,或者多次重试网络请求 | 给技能执行增加超时时间和重试策略 |
| 取消操作无效 | 技能没有响应 CancellationError | 在异步方法中定期检查 Task.isCancelled |
| macOS 下技能执行后窗口卡住 | 主线程被阻塞,技能在同步做耗时操作 | 确保技能执行是异步的,必要时移到后台 actor |
5.2 排查思路:UI 不刷新与技能卡死的定位方法
UI 不刷新,我排查的第一站永远是“状态到底变没变”。我会把 AgentSession 的 state 打印到控制台,或者加一个只有调试才能看到的激动态悬浮窗,实时显示当前 state 的枚举值和技能进度。如果 state 没变,那就是状态更新流程有问题;如果 state 变了但 UI 没变,那才是视图绑定层的问题。按这个顺序排查,百分之八十的情况出在“技能执行上下文没有把进度回调到 AgentSession”上,而不是 SwiftUI 本身。
技能卡死,我一般用两个手段。第一,给技能执行时间加一个上限,默认 30 秒,超时后就强制结束,并把错误原因记录到执行链路。第二,在技能上下文里加一个“心跳计时器”,技能内部每执行一段较长的计算就调用一次。如果 AgentSession 同时运行多个技能,心跳机制还能帮你看清楚卡点究竟在哪一步。
5.3 避坑心得:协议设计、并发控制与技能描述
协议设计上我吃过一个亏:最初把技能的输入类型定义得太过宽泛(用[String: Any]),导致每个技能内部都要做一遍类型转换,还容易出现运行时错误。后来强行改成强类型泛型关联类型,虽说协议的类型擦除变得复杂了一些,但每个技能的代码质量和调试体验提升了一个档次。这里强烈建议不要为了省事用字典传参。
并发控制上,Agent 决策循环和技能执行是串行还是并发,要想清楚。我在通信场景里,技能间是串行的:一个技能执行完,结果才能作为新的模型输入。但有些场景(比如同时调用多个搜索技能再汇总)需要并发执行,这时就要用到taskGroup。如果用错并发模型,轻则出现“技能执行顺序错乱”,重则遇到 actor 隔离数据竞争。建议在 AgentSession 里显式维护一把执行序列锁,保证同一时刻技能执行上下文是串行的,这样早期原型跑通后再考虑并发优化。
技能描述,我最后再强调一次。模型决定要不要调用技能、怎么填参数,完全依赖你写的描述和参数 schema。花时间把描述写得具体、准确,比在代码里做任何 hack 都有效。我后来形成了一个固定模板:技能名称用简洁动词开头(比如“创建提醒事项”),描述写清楚“什么情况下调用、能做什么、不能做什么”,参数说明里给五分钟内能看懂的示例值。测试下来,工具调用的准确率能提升一截,少了很多无效调用。
最后再分享一个小技巧:技能执行链路记录里,给每次调用加一个时间戳和耗时统计。迭代到后期,分析这些耗时数据能帮你发现哪些技能是高频率、高耗时需要优化,哪些技能几乎从没被调用过可以下掉。一个良好的 Agent 技能系统不是一蹴而就的,它是在一次次真实调用、维护、删减中慢慢变稳定的。希望这份记录能帮你少踩一些坑,你做的 Agent 技能系统也能早日稳定上线。