1. 从标题拆解 SwiftUI-Agent-Skill 到底想解决什么问题
第一次看到 “SwiftUI-Agent-Skill” 这个组合词,我的直觉是:它不是一个单纯的 UI 框架教程,也不是一个泛泛的 AI 工具介绍,而是把三个原本分属不同层面的东西强行捏在了一起——SwiftUI 的声明式界面能力、Agent 的任务编排与自主决策能力、以及Skill 所代表的模块化可复用能力封装。这三者放在同一个标题里,说明作者想表达的是一种“让界面自己动起来、让智能体在端侧有手有脚”的思路。
我先把这三个词拆开说清楚,不然后面没法聊。
SwiftUI是苹果生态里的声明式 UI 框架,核心特点是“状态驱动视图”。你不再手动去改某个按钮的文字,而是改一个@State变量,框架自动帮你重绘。这个特性在传统 App 开发里已经很好用了,但它的真正潜力在于:当状态来源不是用户点击,而是某个智能体的决策结果时,界面就变成了智能体的“身体”。
Agent这个词这两年被用得很泛。在我的理解里,一个 Agent 至少要具备三个能力:感知输入、做出决策、执行动作。放到端侧场景里,感知可以来自用户输入、传感器、剪贴板、日历事件;决策可以来自本地小模型或规则引擎;执行动作则可以是修改数据、调用系统能力、或者——改变界面状态。
Skill则是把 Agent 的某一种能力封装成独立模块。比如“总结当前页面文字”是一个 Skill,“根据日程自动调整提醒时间”是另一个 Skill。Skill 的好处是:Agent 不需要知道所有事情的细节,它只需要知道“我有哪些 Skill 可用”,然后按需调用。
把这三者串起来,SwiftUI-Agent-Skill 要解决的问题就很清晰了:让端侧智能体以一种可扩展、可组合的方式,通过 SwiftUI 的声明式状态系统来驱动界面变化,同时把每一种具体能力封装成独立的 Skill 模块。
这解决了一个很实际的痛点。现在很多所谓的“AI 功能”都是硬编码的:产品经理说“加一个总结按钮”,工程师就写一个按钮、调一次接口、把结果塞进某个@State。下次要加“翻译按钮”,又得重来一遍。Agent + Skill 的思路是把“决策”和“执行”分开,界面只负责展示状态,Agent 负责决定调哪个 Skill,Skill 负责干活并返回新状态。
适合谁来参考?我认为有三类人值得往下看:一是已经在写 SwiftUI、想把手上的 App 往智能化方向推的 iOS 开发者;二是对端侧 Agent 架构感兴趣、但不知道怎么和 UI 层结合的技术负责人;三是想理解“声明式 UI + 智能体”这套组合拳底层逻辑的产品或架构同学。哪怕你暂时不写 Swift,这套分层思路也可以迁移到其他声明式框架上。
2. 整体架构设计与分层思路拆解
2.1 为什么要把 Agent 和 UI 彻底解耦
我见过不少项目,一开始图省事,直接在 View 的onTapGesture里写Task { await agent.run() },然后 agent 内部又直接去改@State。这种写法在 demo 阶段没问题,一旦 Skill 数量超过五个,就会变成一团乱麻:View 里混着业务逻辑,Agent 里混着 UI 状态,Skill 之间还互相依赖。
我的做法是强制分三层,层与层之间只通过明确的数据结构通信。
第一层是View 层,只做两件事:把当前状态渲染出来,把用户意图(intent)发出去。它不关心这个意图会被哪个 Skill 处理,也不关心处理要多久。
第二层是Agent 层,负责接收意图、决定调用哪个或哪些 Skill、处理 Skill 返回的结果、把最终状态变更推回给 View 层。Agent 本身不包含具体业务能力,它更像一个调度器。
第三层是Skill 层,每个 Skill 是一个独立模块,输入是明确的参数结构,输出是明确的结果结构。Skill 不知道 View 的存在,也不知道 Agent 的存在,它只对自己的输入输出负责。
这样分层之后,加一个新能力只需要写一个新的 Skill 并注册到 Agent 的 Skill 列表里,View 层几乎不用动。这是这套架构最大的价值。
2.2 状态管理选型:ObservableObject 还是 @Observable
SwiftUI 的状态管理这几年变化不小。早期我们用ObservableObject+@Published,后来苹果推出了@Observable宏,写法更简洁,性能也更好。在这个项目里我最终选了@Observable,原因有三个。
第一,@Observable的粒度更细。它只追踪实际被读取的属性,而不是整个对象一变就全量刷新。当 Agent 状态里既有“当前正在执行的 Skill 名称”又有“历史消息列表”时,这个差异会很明显。
第二,@Observable和@State配合更自然。你可以直接@State private var agentState = AgentState(),不需要再套一层@StateObject。
第三,从长期维护角度看,@Observable是苹果主推的方向,新项目没必要再背ObservableObject的历史包袱。
不过要注意一个坑:@Observable类如果要在多个 View 之间共享,不能每个 View 都@State一份,那样会创建多个实例。正确做法是在顶层创建一次,通过@Environment注入,或者在初始化时传入。我踩过这个坑,当时调试了半天为什么两个 View 看到的状态不一致,最后发现是各自@State了一份。
2.3 Skill 的注册与发现机制怎么设计
Skill 的注册机制我改过三版。第一版是硬编码一个 switch,根据意图字符串匹配。第二版改成协议 + 数组,Agent 持有一个[any Skill],遍历查找能处理当前意图的 Skill。第三版加了优先级和互斥标记。
最终的结构大概是这样:每个 Skill 遵循一个AgentSkill协议,协议里要求提供skillIdentifier、supportedIntents、priority三个静态属性,以及一个execute(input:) async throws -> SkillResult方法。Agent 在收到意图后,先筛选出supportedIntents包含该意图的 Skill,再按priority降序排列,依次尝试执行,直到有一个成功返回。
为什么要有优先级?因为有些 Skill 是“兜底”性质的,比如一个通用的“让模型自由回答”Skill,它的优先级应该最低,只有在没有更专门的 Skill 能处理时才轮到它。而像“查询本地日程”这种确定性强的 Skill,优先级应该高,因为它更快、更准、更省资源。
互斥标记是为了处理一种情况:两个 Skill 都能处理同一个意图,但它们的副作用冲突。比如一个 Skill 要修改用户数据,另一个要只读展示,这时候不能让它们同时跑。我的做法是在 Skill 协议里加一个exclusiveGroup可选属性,同一组内只允许一个 Skill 执行。
2.4 数据流走向与线程模型
整套架构的数据流是单向的:View 发出 Intent → Agent 接收并决策 → Skill 执行 → 返回 SkillResult → Agent 更新 State → View 重绘。
线程模型上,我坚持一个原则:所有状态变更都回到主线程。Skill 的execute方法内部可以自由使用后台线程做耗时操作,但返回结果后,Agent 更新 State 的那一步必须@MainActor。SwiftUI 对状态变更的线程很敏感,不在主线程改会触发运行时警告,严重时直接崩溃。
我通常会把 Agent 类标记为@MainActor,这样它内部所有方法默认都在主线程执行。Skill 的耗时操作通过Task.detached或者nonisolated方法切到后台,算完再await MainActor.run回来。这个模式我用了很多次,实测下来很稳。
3. 核心模块的细节实现与实操要点
3.1 AgentState 的状态字段怎么定
AgentState 是整个架构的核心数据结构,它决定了 View 能渲染什么、Agent 能追踪什么。我建议至少包含以下几类字段。
第一类是会话状态:isProcessing表示当前是否有 Skill 在执行,currentSkillIdentifier表示正在执行哪个 Skill,lastError存放最近一次错误。这些字段让 View 能展示加载指示器、当前动作描述、错误提示。
第二类是消息历史:一个[AgentMessage]数组,每条消息包含角色(用户/Agent/Skill)、内容、时间戳、关联的 Skill 标识。这是对话式界面的基础。
第三类是能力状态:比如availableSkills列表、disabledSkills集合。有些 Skill 可能因为权限或环境原因暂时不可用,View 需要知道这些信息来做灰显处理。
第四类是上下文数据:这部分因项目而异。如果是日程类应用,可能存当前选中的日期;如果是文档类应用,可能存当前打开的文档 ID。我倾向于把上下文数据也放进 AgentState,而不是散落在各个 View 的@State里,这样 Skill 执行时能直接读取,不需要层层传参。
字段定好之后,我强烈建议给 AgentState 加一个snapshot()方法,返回一个不可变的快照结构。这样在调试时可以把任意时刻的状态打印出来,排查问题非常方便。
3.2 Skill 协议的接口设计细节
Skill 协议的接口设计直接决定了扩展性。我最终的协议大概长这样:
protocol AgentSkill: Sendable { static var skillIdentifier: String { get } static var supportedIntents: Set<String> { get } static var priority: Int { get } static var exclusiveGroup: String? { get } func execute(input: SkillInput) async throws -> SkillResult }几个设计点值得说明。
Sendable约束是为了让 Skill 能安全地在并发环境中传递。Swift 6 对并发安全要求更严,提前加上这个约束能避免后面迁移时的痛苦。
static属性而不是实例属性,是因为 Agent 在筛选 Skill 时还没创建实例,用静态属性可以避免不必要的对象创建。只有确定要执行某个 Skill 时,才创建它的实例。
SkillInput和SkillResult都是结构体,不是字典。用结构体是为了类型安全,编译器能帮你检查参数是否齐全。我早期用过[String: Any],结果运行时经常因为 key 拼错而崩溃,后来全部改成结构体,这类问题就消失了。
SkillResult里我通常会包含三个字段:output(给用户看的内容)、stateChanges(需要合并回 AgentState 的变更)、followUpIntent(可选,表示执行完后是否要触发下一个意图)。followUpIntent这个设计让 Skill 之间可以串联,比如“总结文档”执行完后自动触发“保存摘要”。
3.3 意图识别与路由的实操方案
意图识别是 Agent 的入口。我的方案是分两级:先用规则匹配,规则匹配不上再走模型。
规则匹配很简单:维护一个[String: Set<String>]的映射表,key 是关键词,value 是可能的意图集合。用户输入进来后,先做分词,然后查表。如果某个意图的命中关键词数量超过阈值,就直接路由到对应 Skill。这个方案快、准、可解释,覆盖了大部分常见场景。
规则匹配不上的情况,才调用本地模型做意图分类。这里要注意,模型输出的是概率分布,不是确定答案。我会设一个置信度阈值,比如 0.7,低于这个值就返回“我不太确定你想做什么”,而不是硬猜一个意图。硬猜的后果是用户觉得这个 Agent 很蠢,体验反而更差。
路由时还有一个细节:同一个意图可能有多个 Skill 能处理。这时候按优先级排序,但不要只试第一个就放弃。我的做法是依次尝试,如果某个 Skill 抛出“无法处理”的特定错误,就继续试下一个;如果是其他错误,就停止并上报。这样既保证了灵活性,又不会掩盖真正的 bug。
3.4 状态合并与冲突处理
Skill 执行完返回stateChanges后,Agent 需要把这些变更合并到 AgentState 里。这里有个容易被忽略的问题:并发执行多个 Skill 时,状态变更可能冲突。
我的处理方式是给 AgentState 加一个版本号。每次合并前检查版本号是否匹配,不匹配就说明期间有其他变更发生,需要重新读取最新状态再合并。这个思路和数据库的乐观锁类似。
对于消息历史这种追加型字段,合并很简单,直接 append。对于isProcessing这种布尔字段,要小心:如果两个 Skill 并发执行,第一个执行完把isProcessing设为 false,但第二个还在跑,界面就会错误地显示“空闲”。我的做法是用计数器而不是布尔值,processingCount加一减一,等于零时才表示真正空闲。
这些细节在 demo 阶段看不出问题,一旦并发场景多起来就会暴露。我建议从一开始就用计数器方案,省得后面重构。
4. 完整实操流程与关键环节实现
4.1 项目初始化与依赖配置
新建一个 SwiftUI 项目后,我建议先建好目录结构,不要等代码多了再整理。我的目录划分是这样的:
/Agent AgentCore.swift AgentState.swift IntentRouter.swift /Skills AgentSkill.swift SummarySkill.swift ScheduleSkill.swift ... /Views ContentView.swift MessageListView.swift ... /Models AgentMessage.swift SkillInput.swift SkillResult.swift依赖方面,如果要用本地模型做意图分类,需要引入相应的推理框架。如果暂时不用模型,纯规则匹配也能跑起来,后面再逐步替换。我建议第一版先不接模型,把架构跑通,确认数据流没问题,再考虑加模型。这样出问题时容易定位是架构问题还是模型问题。
4.2 AgentCore 的核心循环实现
AgentCore 的核心是一个handle(intent:)方法,它串联了整个流程。我把它拆成几个步骤,每步都有明确的职责。
第一步是意图解析。把原始输入转成结构化的 Intent 对象,包含意图类型、参数、置信度。
第二步是Skill 筛选。从注册的 Skill 列表里找出支持该意图的,按优先级排序,过滤掉互斥组里已有 Skill 在执行的情况。
第三步是执行与重试。依次尝试执行筛选出的 Skill,捕获特定错误决定是否继续尝试下一个。
第四步是状态合并。把 SkillResult 里的 stateChanges 合并进 AgentState,处理版本冲突。
第五步是后续意图处理。如果 SkillResult 里有 followUpIntent,递归调用 handle 方法。
这五步里,第三步和第四步是最容易出问题的。第三步要注意错误类型的区分,我定义了一个SkillError枚举,包含cannotHandle、invalidInput、executionFailed、timeout几种情况,只有cannotHandle才触发重试。第四步要注意合并的原子性,我通常把整个合并过程包在一个MainActor.run里,确保不会被打断。
4.3 一个具体 Skill 的完整实现示例
拿“总结当前页面文字”这个 Skill 举例,完整实现大概是这样。
首先定义输入输出结构:
struct SummarySkillInput: SkillInput { let text: String let maxLength: Int } struct SummarySkillOutput: SkillOutput { let summary: String let originalLength: Int }然后实现 Skill 本体:
struct SummarySkill: AgentSkill { static let skillIdentifier = "com.example.summary" static let supportedIntents: Set<String> = ["summarize", "summary"] static let priority = 100 static let exclusiveGroup: String? = nil func execute(input: SkillInput) async throws -> SkillResult { guard let summaryInput = input as? SummarySkillInput else { throw SkillError.invalidInput } guard summaryInput.text.count > 10 else { throw SkillError.cannotHandle } let summary = try await performSummary( text: summaryInput.text, maxLength: summaryInput.maxLength ) let output = SummarySkillOutput( summary: summary, originalLength: summaryInput.text.count ) return SkillResult( output: output, stateChanges: [ .appendMessage(AgentMessage( role: .skill, content: summary, skillIdentifier: Self.skillIdentifier )) ], followUpIntent: nil ) } private func performSummary(text: String, maxLength: Int) async throws -> String { // 实际总结逻辑,可以是本地模型或规则截取 // 这里省略具体实现 } }几个实操要点。guard let ... as?做类型转换是必须的,因为 SkillInput 是协议类型,运行时才知道具体类型。text.count > 10这个检查是为了避免对太短的文本做无意义的总结,直接抛cannotHandle让 Agent 尝试其他 Skill。stateChanges里用了一个枚举.appendMessage,这是我把状态变更抽象成命令模式的做法,比直接传闭包更安全、更可序列化。
4.4 View 层如何响应 Agent 状态变化
View 层的写法要尽量“笨”。它不应该有任何业务判断,只做渲染和转发。
struct ContentView: View { @Environment(AgentState.self) private var agentState @State private var inputText = "" var body: some View { VStack { MessageListView(messages: agentState.messages) if agentState.processingCount > 0 { ProgressView(agentState.currentSkillDescription) } HStack { TextField("输入指令", text: $inputText) Button("发送") { let intent = Intent(rawInput: inputText) inputText = "" Task { await agentState.agent.handle(intent: intent) } } .disabled(agentState.processingCount > 0) } } } }这里有个细节:agentState.agent.handle这个调用看起来有点怪,为什么 Agent 挂在 State 上?我的做法是把 Agent 作为 AgentState 的一个属性,因为 Agent 需要读写 State,放在一起生命周期好管理。另一种做法是用@Environment分别注入 Agent 和 State,但那样要保证两者引用的是同一个 State 实例,容易出错。
processingCount > 0时禁用发送按钮,这是防止用户连续点击导致并发意图堆积。有些场景下你可能希望允许排队,那就把禁用去掉,改成在 Agent 内部维护一个意图队列。
5. 常见问题排查与避坑经验实录
5.1 状态不更新或更新延迟
这是最常见的问题,表现是 Skill 明明执行完了,界面却没变。排查思路按以下顺序来。
先确认状态变更是否在主线程。在stateChanges合并的地方打一个assert(Thread.isMainThread),如果触发断言,说明有后台线程在改状态。解决办法是把合并逻辑包进MainActor.run。
再确认@Observable对象是否被正确持有。如果 View 里用的是@State private var agentState = AgentState(),而 Agent 内部又持有另一个 AgentState 实例,那 View 观察的和 Agent 改的就不是同一个对象。检查方式是打印两个实例的内存地址,不一致就说明有问题。
最后确认属性是否真的被读取。@Observable只追踪被读取的属性,如果 View 里没有读取某个属性,改它不会触发重绘。这个特性有时候会让人困惑,但理解了就还好。
5.2 Skill 执行卡住不返回
Skill 的execute方法是 async 的,如果内部有死循环或者等待一个永远不会来的信号,就会一直卡住。我的做法是给每个 Skill 执行加超时。
let result = try await withThrowingTaskGroup(of: SkillResult.self) { group in group.addTask { try await skill.execute(input: input) } group.addTask { try await Task.sleep(for: .seconds(30)) throw SkillError.timeout } let result = try await group.next()! group.cancelAll() return result }这个模式我用了很多次,实测下来很稳。注意group.cancelAll()要放在拿到第一个结果之后,取消另一个任务。超时时间根据 Skill 类型调整,本地规则类 Skill 给 5 秒就够,调用模型的给 30 秒。
5.3 意图识别准确率低
规则匹配阶段准确率低,通常是关键词表设计得不好。我的经验是:关键词要区分“强信号”和“弱信号”。强信号词出现一个就足以确定意图,弱信号词需要多个同时出现。比如“总结”是强信号,“这个”“一下”是弱信号。
模型分类阶段准确率低,通常是训练数据不够或者标签体系太细。我的建议是意图类别不要超过 20 个,每个类别至少准备 50 条样本。类别太多会导致模型难以区分,准确率断崖式下降。
还有一个容易被忽略的点:用户输入里的错别字和口语化表达。规则匹配对错别字很敏感,“总结”打成“总节”就匹配不上了。我的做法是在分词前先做一次简单的纠错,用一个常见错别字映射表。这个表不用很大,覆盖几百个高频错别字就能显著提升匹配率。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 界面不更新 | 状态变更不在主线程 | 加主线程断言 | 包进 MainActor.run |
| 界面不更新 | 观察的对象不是同一个实例 | 打印内存地址 | 统一用 Environment 注入 |
| Skill 卡住 | 内部死循环或等待 | 加日志看卡在哪一步 | 加超时机制 |
| 意图识别不准 | 关键词表设计差 | 统计误判样本 | 区分强弱信号词 |
| 意图识别不准 | 模型标签太多 | 看混淆矩阵 | 合并相似意图 |
| 并发状态冲突 | 多个 Skill 同时改状态 | 加版本号检查 | 乐观锁重试 |
| 内存泄漏 | Skill 持有 View 引用 | 用 Instruments 查 | Skill 不持有 UI 对象 |
5.5 几个我踩过的坑
第一个坑是在 Skill 里直接弹窗。早期我图省事,某个 Skill 执行完直接调UIAlertController弹提示。结果 Skill 和 UIKit 耦合了,测试时没法在无 UI 环境下跑。后来改成返回stateChanges,由 View 层根据状态决定是否弹窗。这个改动让 Skill 变得可测试,值得。
第二个坑是意图参数用字典传。前面提过,[String: Any]类型不安全,运行时才报错。改成结构体后,编译期就能发现参数缺失。这个改动虽然前期麻烦,但长期省了很多调试时间。
第三个坑是忘记处理 Skill 的取消。用户发出意图后如果快速退出页面,Skill 还在后台跑,跑完去改一个已经销毁的 State,就会崩溃。解决办法是在 View 的onDisappear里取消相关的 Task。我通常会在 AgentState 里维护一个currentTask引用,退出时currentTask?.cancel()。
第四个坑是优先级设得太随意。一开始我给所有 Skill 都设 priority 100,结果路由时顺序不确定,同样的输入有时走这个 Skill 有时走那个。后来我定了一个规则:确定性 Skill 优先级 200 以上,模型类 Skill 100 到 200,兜底 Skill 100 以下。这样路由结果就稳定了。
6. 扩展方向与个人实操体会
这套架构跑通之后,扩展空间其实比想象中大。我目前尝试过几个方向,效果都不错。
一个方向是Skill 的组合。有些任务需要多个 Skill 协作完成,比如“把当前文档总结后发到日程提醒里”。我的做法是引入一个CompositeSkill,它内部持有一个 Skill 列表,按顺序执行,前一个的输出作为后一个的输入。这样 Agent 只需要路由到一个 CompositeSkill,不用关心内部细节。
另一个方向是Skill 的热插拔。把 Skill 编译成独立的动态库,运行时加载。这个在 iOS 上限制比较多,但在 macOS 上可行。好处是可以在不发版的情况下增加新能力。我试过一个简化版:把 Skill 的配置(关键词、优先级、参数模板)放在远程配置里,代码还是编译进去的,但行为可以远程调整。这个方案落地成本低,效果也不错。
还有一个方向是给 Skill 加反馈机制。用户可以对 Skill 的执行结果点赞或点踩,这些反馈数据收集起来,可以用来调整 Skill 的优先级,或者作为模型微调的样本。我在一个内部项目里加过这个功能,跑了一个月后发现,有些 Skill 的优先级确实需要调整,数据比拍脑袋准。
我个人在实际操作中的体会是:这套架构最大的价值不在于“智能”,而在于“可维护”。Agent 和 Skill 的分层让每一部分都能独立测试、独立替换。我可以在不碰 View 的情况下换掉整个意图识别模块,也可以在不碰 Agent 的情况下增加十个新 Skill。这种解耦带来的开发效率提升,比任何单点智能都实在。
最后分享一个小技巧:在开发阶段,给 AgentCore 加一个dryRun模式,只做意图识别和 Skill 筛选,不实际执行。这样调试路由逻辑时不用等 Skill 跑完,速度快很多。这个开关在正式环境关掉就行,代码量很小,但调试体验提升明显。