☰
SwiftUI 与 Agent 架构解析:声明式 UI 驱动端侧智能体
2026/10/13 7:37:03 网站建设 项目流程

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 跑完,速度快很多。这个开关在正式环境关掉就行,代码量很小,但调试体验提升明显。

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

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

立即咨询