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 负责决定调哪个 SkillSkill 负责干活并返回新状态。适合谁来参考我认为有三类人值得往下看一是已经在写 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 还是 ObservableSwiftUI 的状态管理这几年变化不小。早期我们用ObservableObjectPublished后来苹果推出了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表示正在执行哪个 SkilllastError存放最近一次错误。这些字段让 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: SetString { 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: SetString]的映射表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: SetString [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 跑完速度快很多。这个开关在正式环境关掉就行代码量很小但调试体验提升明显。