ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SwiftUI-Agent-Skill:构建可注册编排的智能体技能框架

SwiftUI-Agent-Skill:构建可注册编排的智能体技能框架

第一次听到 SwiftUI-Agent-Skill 这个词,我脑子里闪过的画面是:在工程里塞了一大堆模型训练代码,然后用 SwiftUI 画几个界面把结果展示出来。真把一个叫这名字的框架搭起来之后,发现它的价值不在于某个单独的模型,而是把智能体能力拆成了可注册、可编排、可替换的技能块,让 UI 层只做一件事:等待技能的结果并展示。这个思路对客户端出身的人特别友好,因为我们本来就不擅长从头训练一套大型模型,但很擅长把一个流程串起来。

这篇文章会从一个可运行的 iOS 工程角度讲清楚它是什么:它把 Agent(智能体)的对话调度和 Skill(技能)的执行逻辑解耦,UI 层用 SwiftUI 声明式绑定,模型训练可以放在本地或服务端,最终都通过技能注册中心进入业务链路。适合三类人看:想在 App 里接入 AI 能力但不想碰服务端的小团队,需要给现有 SwiftUI 应用加一个可扩展指令面板的 iOS 开发,以及单纯想把智能体应用的代码结构做干净一点的同学。

1. 项目定位与设计思路:SwiftUI-Agent-Skill 到底在做什么

1.1 需求拆解:从聊天框到技能框架

先把需求倒推一遍。一个智能体 App 的最小闭环是什么?用户说一句话,后端理解意图,返回结果,界面展示。但如果你开发的不是 Demo,而是真的要承载多个业务场景,这个闭环会迅速变形:用户可能想查天气、记账、生成图片、调用某个硬件接口。每种能力都需要单独的参数体系、审批逻辑、错误处理。如果全部堆在聊天回调里,代码会变成一坨无法维护的 if-else。

SwiftUI-Agent-Skill 最初的定位就是在 UI 和模型之间加一层技能层。每个能力被抽象成一个 Skill 对象,自带输入描述、执行入口和返回结果。调度器根据用户意图选定技能,UI 只知道技能被调用了,状态在流转,结果出来了。这套设计最早是从模拟项目 X 里提炼出来的,当时项目里同时有文本生成、图片处理和表单填写三类需求,如果不做技能层,状态管理基本会失控。

另外,用户的使用习惯也决定了不能只做聊天框。很多人对智能体的预期是"直接告诉它我要什么",而不是先打个招呼再等回复。把能力封装成技能,配合快捷指令和快捷键,体验会比纯对话顺很多。所以技能框架的核心价值就在于让功能可以被发现、被唤起、被组合,而不是被埋藏在对话逻辑里。

1.2 为什么选择 SwiftUI 而不是 UIKit

这应该是很多人第一个会问的问题。UIKit 发展了这么多年,生态成熟,为什么新项目还要用 SwiftUI?我的理由不复杂:智能体场景的状态变化太频繁了,等待输入、处理中、成功、失败,每一个状态都需要同步到界面。UIKit 里做这件事传统上依赖 delegate、通知或者 KVO,链路很长,状态一多就容易漏更新。SwiftUI 的数据驱动机制天然就是把状态当作唯一事实来源,状态变了,界面自动刷新,不需要手动 reload。

我用过一段时间的 UITableViewController 做过类似的技能列表,搭配 Combine 去监听状态,也能跑,但代码量明显更多。最难受的是单元格复用和异步回调打架,用户快速切换技能的时候,界面上偶尔会出现上一次请求的结果覆盖当前内容。SwiftUI 的 View 是状态函数,数据绑定关系简单直接,很少出现这种错乱。

如果你还在犹豫,可以看这个对比:

对比项UIKit 方案SwiftUI 方案
状态同步delegate / 通知 / KVO,链路长@Published 自动双向绑定
界面刷新reloadData / 手动更新状态变更驱动视图重新计算
异步处理回调嵌套,容易漏 main 线程切换async/await + .task 修饰符
多状态场景每个状态手动维护标志位枚举状态 + Published 天然支持

SwiftUI 也有它的代价:对最低系统版本有要求,复杂交互的灵活性不如 UIKit。但智能体技能这种模块化、消息流驱动的界面,恰好是它最擅长的区域。所以新项目选 SwiftUI 不是盲目追新,是这套 UI 模型的特性跟业务模型正好匹配。

1.3 整体架构:三层分离的模块划分

SwiftUI-Agent-Skill 的整体架构可以拆成三层。UI 层就是一个个 SwiftUI View,负责展示输入、状态、结果;状态层用 ObservableObject 或者 @Observable 持有当前会话信息;核心层包含三个组件:技能注册中心、意图调度器、模型服务。模型服务负责调用本地 Core ML 模型或请求远端推理接口,但它不直接跟 UI 通信,所有结果都通过技能对象返回。

核心层不依赖 SwiftUI,甚至可以单独打包复用。也就是说,如果把技能层抽出来放到一个 Swift Package 里,以后在 App Clip、Widget 甚至命令行工具里都能用同一套逻辑。这是我在模拟项目 X 里坚持三层分离的主要原因。实际写下来,UI 层大概只占工程代码量的三成,剩下的都是技能定义和调度逻辑。

技术栈方面,原生能力就够了:Swift 的 async/await 做异步,Combine 做简单的状态流,Core ML 做本地模型推理,UserDefaults 或文件目录做配置存储。不需要一开始就引入重型的第三方框架。等技能数量多到一定程度,再考虑加依赖注入和更复杂的路由方案。

2. 核心细节解析:技能协议与数据流设计

2.1 技能协议的定义与注册机制

技能的核心是一段协议,做好了这个框架就立住了一半。我设计的AgentSkill协议长这样:

public protocol AgentSkill { var name: String { get } var displayName: String { get } var inputSchema: SkillInputSchema { get } func run(context: SkillContext) async throws -> SkillResult }

name是注册中心里查表用的唯一标识,displayName是给用户看的。inputSchema描述这个技能需要什么参数,比如查天气技能需要城市名称和日期,这个信息既可以用在 UI 自动生成表单,也可以用来做意图调度时的参数校验。run方法被定义为 async throws,意味着技能内部可以做耗时操作,也可以把错误抛给上层统一处理。

注册机制我建议做成显式注册,而不是运行时自动扫描。自动扫描听着省事,但 Swift 的类型检查在这种场景下容易变慢,而且隐式发现的问题非常难排查。显式注册虽然每次加新技能都要手动写一行,但好处是启动过程一目了然,哪个技能注册失败了,日志里直接就能看到。

注册中心实现起来不复杂:

final class SkillRegistry { private var skills: [String: any AgentSkill] = [:] func register(_ skill: any AgentSkill) { skills[skill.name] = skill } func skill(for name: String) -> (any AgentSkill)? { skills[name] } var allSkills: [any AgentSkill] { Array(skills.values) } }

因为注册基本发生在 App 启动阶段,并发写入的概率极低,所以我自己没有给注册中心加锁。如果你的技能支持运行时动态注册,那就需要把skills字典放到一个 actor 里,避免数据竞争。

2.2 状态管理:模型训练数据如何流入视图层

技能执行会经历多个阶段:开始等待、执行中、成功、失败。这些状态需要从模型层一路传到 SwiftUI 视图。我用一个AgentViewModel作为枢纽,它持有当前状态码和最终结果消息。核心代码如下:

@MainActor final class AgentViewModel: ObservableObject { enum Phase { case idle case processing case success(String) case failure(String) } @Published private(set) var phase: Phase = .idle private let registry = SkillRegistry() func runSkill(named name: String, input: String) async { phase = .processing guard let skill = registry.skill(for: name) else { phase = .failure("技能不存在") return } do { let result = try await skill.run(context: SkillContext(input: input)) phase = .success(result.output) } catch { phase = .failure(error.localizedDescription) } } }

这里有一个很多人踩过的坑:在 async 方法里直接给 @Published 赋值。如果你把AgentViewModel标上@MainActor,所有属性更新都会自动回到主线程,SwiftUI 就能稳定刷新。不加这个标记,网络回调往往在后台线程,界面上就会偶发性地不更新,控制台还会冒出一堆主线程检查警告。

视图层只需要绑定phase:

struct StatusView: View { let phase: AgentViewModel.Phase var body: some View { switch phase { case .idle: Text("等待指令") case .processing: ProgressView("技能执行中") case .success(let text): Text(text) case .failure(let message): Text("执行失败:\(message)") .foregroundColor(.red) } } }

这样的好处是,新增一个状态类型只需要在枚举里加 case,视图的 switch 会强制你处理所有情况,编译器就帮你兜底了一部分错误。

2.3 异步任务与视图生命周期的衔接

SwiftUI 的.task修饰符会在视图消失时自动取消关联的异步任务,但这个取消能不能生效,取决于技能内部有没有响应取消。很多技能函数一进入就闷头执行,根本不理会Task.isCancelled,结果用户都已经返回上一个页面了,后台还在跑模型推理。

我一般在技能内部做几个取消检查点:进入业务逻辑前检查一次,每轮循环结束后检查一次,最后写缓存前再检查一次。如果检测到取消,直接抛出CancellationError,这样上层 ViewModel 就能捕获并切换到闲置状态,不会出现已离开页面但状态还在processing的诡异问题。

另外,SkillContext里我会带一个sessionID用于追踪整条调用链。多技能串联或者需要写日志的时候,这个字段特别有用。没有它,排查问题就只能靠时间戳硬猜是哪一次调用的日志,非常痛苦。

3. 实操过程:从零搭一个最小可运行版本

3.1 准备工程与运行环境

新建一个 iOS App 工程,最低系统版本建议 iOS 16。主要因为NavigationStack和async/await在这个版本才足够稳定,@Observable也可以选 iOS 17 以上再体验,不强求。工程不需要引入任何第三方依赖,第一版把链路跑通时,外部库越少越容易定位问题。

我用 Xcode 自带的模板生成工程后,只做了几件事:关闭了 main actor 隔离的默认设置检查里过于严格的告警级别,给 Info.plist 加上本地网络权限说明(如果你要调用远端推理接口),然后在工程里建了三个目录:Models、Services、Views。Models 放协议和数据结构,Services 放注册中心和模型服务,Views 放界面。目录名字不一定要一模一样,但职责边界要清楚。

3.2 实现技能注册中心

按照前面给的SkillRegistry代码建文件。我建议在 App 启动入口处统一注册,比如在init()里执行:

let registry = SkillRegistry() registry.register(GreetingSkill()) registry.register(ExpenseTrackerSkill())

这样做的另一个好处是,以后做技能开关和动态配置时,只需要在启动逻辑里加判断,不需要改动每个技能内部代码。

如果你预感技能数量会超过 20 个,可以再加一层分组注册:按业务线拆成多个注册器,每个注册器内部负责一组技能,最外层注册中心再汇总。分组注册在日志里特别好用,看启动输出就知道哪个业务线的技能加载了多少个,哪个失败了。

3.3 编写一个最简单的问候技能

写一个最小的技能,用来验证整个链路。问候技能只需要接收用户输入,然后拼接一句回复。别小看这个极简实现,它能把协议、注册、调度和 UI 绑定全部串起来。

struct GreetingSkill: AgentSkill { let name = "greeting" let displayName = "问候" var inputSchema: SkillInputSchema { SkillInputSchema(parameters: [ .text(key: "name", label: "你的名字") ]) } func run(context: SkillContext) async throws -> SkillResult { try Task.checkCancellation() let name = context.parameters["name"] ?? "朋友" return SkillResult(output: "你好,\(name)!") } }

SkillInputSchema和SkillResult的具体字段不必照搬,关键是理解设计意图:输入参数统一从 context 里取,不直接访问全局状态;输出统一是SkillResult,这样上层不用关心技能内部是调了模型还是查了数据库。

3.4 把智能体响应绑定到 SwiftUI 视图

视图部分就是前面给的StatusView,再加一个输入框和按钮。这一步的核心是测试整个闭环:用户输入文本,点击按钮,ViewModel 找到技能,技能执行,状态更新,界面展示结果。

我实际跑的时候发现一个挺容易忽略的点:按钮点击之后要立刻禁用,防止用户连续点触发同一个技能多次。ViewModel 里 的phase如果在.processing,按钮就disabled。这个逻辑放在视图层写很简单,但如果你有多个入口都能调用技能,最好在 ViewModel 里也加一层防护,比如用一个isProcessing的 getter 判断当前 phase,避免绕过 UI 保护。

第一版跑通之后,建议把关键步骤打印到控制台,或者加一个最小日志系统。当时我就因为没打日志,花了一个多小时排查一个"技能明明注册了却找不到"的问题,最后发现是不同 target 导致的模块名冲突。这个坑放到后面的常见问题里细说。

4. 模型训练与技能编排的落地细节

4.1 轻量模型的本地训练方案

技能执行的核心逻辑很多来自模型推理。我的经验是,在 SwiftUI 客户端里直接做深度模型训练不太现实,更合理的路子是:数据准备和训练放在开发阶段完成,导出成.mlmodel文件,运行时只做推理。

具体操作分三步。第一步,收集一批意图样本。以模拟项目 X 为例,当时的样本格式就是两列:一句话的文本,加上这句话对应的意图标签。比如"帮我记一笔账"对应expense_record,"生成一张猫的图片"对应image_generate。第二步,用系统自带的 Create ML 工具训练一个分类器,输入是文本,输出是意图标签。第三步,导出.mlmodel拖进 Xcode,用 Core ML 的框架在技能层调用。

这种方案适合绝大多数轻量级技能识别场景。样本量不需要太大,几千条就能得到一个可用的意图分类器。真正花时间的反而是样本标注:同一个意思要收集尽量多的表达方式,否则用户换一种说法,模型就认不出来了。

如果你的场景必须要用服务端的大型模型,那本地模型的作用就从"最终决策者"降级为"入口路由"。可以先让本地轻量模型判断意图,再决定要不要把请求发给远端推理接口。这样既保证了大部分简单指令的响应速度,又保留了复杂任务的能力上限。

4.2 技能编排与意图路由

模型输出的是概率最高的意图标签,调度器要做的是把这个标签映射到某个技能名。我维护了一张 JSON 映射表:

{ "intent_greeting": "greeting", "intent_create_image": "image_generator", "intent_record_expense": "expense_tracker" }

调度器从模型拿到intent_create_image,查表得到image_generator,再从注册中心取出对应技能执行。这个查表逻辑看着简单,但有两点值得注意。第一,映射表不要硬编码在代码里,建议放到配置文件中,最好支持热更新。这样技能上线、下线、改名都不需要发版。第二,当模型置信度太低时,不要强行路由。设定一个阈值,低于阈值就返回一个兜底技能,让用户重新表达意图,而不是给它一个错误的能力。

技能编排的进阶玩法是串联。比如用户说"把上周的账单生成一张图表",这其实涉及两个技能:查询账单和图表生成。我的做法是增加一个PipelineSkill,它内部按顺序调用多个子技能,并把上一个技能的输出作为下一个技能的输入。这个能力一开始不用做,等业务确实需要了再加,否则接口会膨胀得很厉害。

4.3 数据存储与缓存策略

技能执行往往会涉及模型文件和用户数据。模型文件体积不小,首次下载要有明确的进度提示,下载完成后再加载到内存里。存放位置建议放Application Support目录而不是Documents,因为Documents会被 iCloud 同步,模型文件动辄几十上百兆,同步起来既费流量体验也差。

配置类数据我习惯用 JSON 文件存,不用数据库。技能列表、意图映射、开关状态这些数据,读写频率不高,JSON 文件足够。真正的高频数据,比如聊天记录、技能调用历史,用数据库更合适。第一版可以简单点,全部塞UserDefaults,但要注意别把大字符串往里面丢,有一段时间我把技能返回的长文本直接写了进去,导致启动读取慢了几百毫秒。

缓存也是技能层绕不开的问题。像查询天气这类技能,结果一小时过期,就可以按城市加时间戳缓存。像图片生成这类技能,结果一样的情况下可以直接复用文件,不需要重新推理。我的原则是:无状态技能优先,能不全量缓存就不要全量缓存,只缓存对用户可见的结果数据。

5. 常见问题与排查技巧实录

5.1 视图不刷新:最大的经典坑

界面不刷新是 SwiftUI 接入技能框架时最高频的问题。我遇到过三种原因:第一,属性忘了加@Published,状态变了但视图不知道;第二,在后台线程直接给@Published赋值,主线程没得到通知;第三,用普通 class 当数据模型,没有遵循ObservableObject,或者没有用@StateObject持有 ViewModel。

排查思路也很简单:先在状态赋值的地方打日志,然后用DispatchQueue.main.async包一层看是否能恢复。如果包一层就好了,说明是线程问题,给 ViewModel 加@MainActor就能根治。如果是属性没加@Published,编译器不会报错,只能靠检查属性声明。

5.2 模型推理卡顿和内存飙升

技能执行前要先加载模型。第一次加载模型的时候,界面经常卡住,因为默认在调用线程里同步加载。后来我把模型加载动作往前挪,改成启动时预加载,或者在技能第一次调用时用后台线程加载并显示进度。加载完毕之后,真正的推理反而很快,复杂度高一点的模型也就几百毫秒。

内存飙升的情况一般是模型预测结果没释放,或者循环里不断创建新的输入输出对象。处理办法是复用预测输入输出的 buffer,不要每次调用都创建。还有一次遇到 Core ML 崩溃,查了很久发现是模型文件路径中包含中文,加载器虽然没报错但行为异常,改成英文路径后就正常了。这算是一个比较冷门的坑。

5.3 多技能并发调用时的数据竞争

技能编排里如果同时触发多个技能,它们之间如果共享了可变缓存,就会出现数据竞争。我最初为了省事,在技能层放了一个全局的缓存字典,结果并发一高就偶发性崩溃。后面把所有共享状态收进一个 actor,问题立刻消失。

还有一个相关的问题:多个技能同时写同一个文件,互相覆盖。解决方式可以是每个技能独享一套缓存文件,文件名带上技能名和会话 ID,避免互相干扰。看起来会浪费一点磁盘空间,但胜在稳定。

下面是个速查表,整理了我遇到的高频问题和对应处理方案:

现象直接原因处理建议
视图时灵时不灵地刷新后台线程更新 @PublishedViewModel 标 @MainActor
技能报"不存在"注册顺序或模块名冲突启动日志打印已注册技能名
首调用卡顿模型文件启动未预加载启动时后台预加载模型
崩溃且堆栈不清共享缓存数据竞争共享状态迁入 actor
缓存文件互相覆盖多技能共用文件名文件名包含技能名与会话 ID
切换页面后仍在跑技能未响应取消关键步骤检查 Task.isCancelled

6. 个人实操心得与后续扩展方向

6.1 几个值得坚持的设计习惯

先把协议定清楚,再去写实现。AgentSkill协议里有一个run方法就够了,不要往里面塞太多的生命周期回调。我曾经加过willStart、didFinish、progress这些方法,看着功能强大,实际用起来又重又难维护。技能本身就是业务逻辑的封装,UI 层的进度展示通过 ViewModel 的状态更新就够了。

技能文件的命名和目录结构要统一。我现在的做法是一个技能一个文件,文件名跟name保持一致,比如GreetingSkill.swift。查找某个技能的注册位置时,直接搜类名比翻注册中心效率高很多。目录上,我把所有技能都放在同一个Skills目录下,不按业务线分文件夹,理由是这个目录只是用来浏览的,真正的路由在配置表里。

日志一定要从第一天就加上。技能调用的入参、耗时、结果、异常,这些信息在你 debug 的时候价值无法估量。我见过太多项目,开始图省事不写日志,出了问题只能靠断点调试,效率低得吓人。SWiftUI 项目的日志方案不复杂,print加时间戳,再加一个开关控制输出级别,就够了。

6.2 后续可以怎么扩展

技能框架稳定之后,可以往两个方向扩展。第一个方向是系统集成,把技能封装成快捷指令的入口,让用户在主屏幕上直接唤起某个技能。SwiftUI 的 Widget 和 App Intent 正好能接上这套技能层,只不过入口从聊天框变成了桌面小组件。第二个方向是技能动态下发,把注册中心的配置搬到远端,用户不更新 App 也能获得新技能。这个方向对模型文件管理的要求比较高,需要处理版本兼容和增量下载。

另外,技能之间的组合也可以做得更深。现在我的编排还是线性的,一个接一个执行。后续可以考虑做成有向无环图,技能之间支持并行和条件分支。比如用户同时要"生成图片"和"保存到相册",这两个技能可以并行执行,而不是排队等。Graph 编排的复杂度会上一个台阶,但一旦技能数量多了,带来的性能收益和灵活性都值回票价。

我自己在模拟项目 X 里把整套框架跑通之后,最大的感受是:SwiftUI 加智能体技能这套组合,最适合的不是去搞一个重塑行业的大模型应用,而是把已有的东西全部模块化、配置化。你可能不需要把每个技能都做成模型训练的大工程,但多一个清晰的抽象层,业务扩展时真的会轻松很多。先从一个最简单的问候技能开始,把链路跑通,再一步步加意图分类、缓存和编排,这是我认为最不容易翻车的路线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进