首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
SwiftUI Agent-Skill架构实战:意图驱动与可插拔技能设计
📅 2026/10/14 8:27:43
✍️ 爱科研究院
👁 阅读 3,247
看到SwiftUI-Agent-Skill这个项目名我第一反应是这又是个把 SwiftUI 当画板的花哨 Demo。但顺着实现思路捋了一遍之后我发现自己判断错了——它真正在解决的问题是我在两三个工具类项目里一直纠结的核心界面层和技能执行层到底该怎么拆才能既保留 SwiftUI 的响应式优势又让业务逻辑不随着功能膨胀而变质。这套东西的核心可以概括成三件事SwiftUI 负责界面和状态呈现Agent 作为统一入口接收用户意图Skill 以可插拔协议的方式承载具体能力。比如你做一个跨 iOS 和 macOS 的效率工具文本摘要、格式转换、信息抽取这些功能不再是一个个写死在按钮背后的闭包而是注册成独立的 Skill交给 Agent 调度。整个项目其实是一个小型的意图驱动应用骨架适合做 AI 助手类产品、工具集合类应用或者任何想在 SwiftUI 应用里加一层可扩展能力的开发者参考。下面我拆开讲讲它的设计逻辑、状态管理方案、Agent 与 Skill 的具体实现顺便把实操中踩过的坑整理成几条可以直接绕开的经验。1. 整体设计思路拆解为什么 UI 和技能要分层1.1 从一个按钮一个动作到意图驱动日常开发里很多 SwiftUI Demo 是这么写的Button里绑定一个ViewModel方法方法内部写死完整逻辑。一两个动作还好等动作涨到二十个工程里就开始出现巨型ViewModel或者到处漂浮的、谁也说不清该归谁管的业务函数。我在处理一个跨平台工具项目 X 的早期版本时也踩过同样的坑。那会儿新增一个从文本里提取邮箱的功能需要同时改动输入页、处理页、结果展示页三处代码并且得手动保证三个页面对数据格式的理解一致。后来把处理逻辑全部拆成独立模块以后才意识到问题根源不在界面复杂度而在动作和意图被绑死在了一起。在 Agent-Skill 的模型里用户不再调用动作而是表达意图。界面收到输入后把输入封装成一个AgentRequest剩下的交给 Agent。Agent 根据上下文和技能注册表找到合适的 Skill 执行最后返回AgentResponse。这个转变带来的直接好处是新增功能时UI 代码基本不用动只需往技能注册表里塞一个 Skill。而且 Skill 是纯逻辑对象不需要启动整个应用就能单测这对日常迭代节奏来说非常关键。1.2 分层拆开以后避开哪些坑我用一个表格来对比两种写法的差异维度传统按钮写死逻辑写法Agent-Skill 分层写法新增功能改界面、改处理逻辑、改状态同步新增 Skill 并注册测试成本需要构造完整界面环境直接向 Agent 发请求断言输出界面膨胀ViewModel 越写越胖状态收敛在 Agent 对外协议上跨端复用iOS 和 macOS 各维护一套逻辑Skill 共享UI 按平台调整功能卸载靠删除代码容易留下埋点注册表移除即可影响范围可控这是我从多个入口调用同一能力这类场景里总结出来的最直观区别。比如一个格式化文本功能如果写死在输入页里那么未来菜单栏快捷入口想调它、Spotlight 风格的面板想调它、通知栏小组件想调它全都要各自复制一遍。统一到 Agent 层之后入口只管拼请求执行永远是同一套 Skill。2. SwiftUI 状态管理的选型与落地2.1 数据流用StateObject还是Observable这类项目里通常会把AgentEngine设计成一个MainActor的ObservableObject或者用Observable宏。具体选哪个取决于部署目标。如果最低系统已经是 iOS 17 / macOS 14优先用Observable代码更简洁Bindable处理输入双向绑定非常顺手。如果还需要兼容 iOS 16 / macOS 13就老老实实用ObservableObjectPublished。这里有个容易晕的点Agent 执行是异步的但 UI 上通常要显示正在处理的转圈状态。我的做法是引擎内部维护一个Published isProcessing执行函数里用defer保证状态复位。SwiftUI 的body是值重建的过程状态一变相关视图自动刷新链路非常顺。需要注意经过await之后的代码不一定会回到主线程。如果直接给非主线程上的Published属性赋值轻则 UI 闪动异常重则触发运行时警告。最稳妥的做法是给整个引擎加MainActor约束让所有状态变更强制回到主线程。2.2 同一套状态逻辑跨 iOS 和 macOS跨平台适配的时候状态逻辑层完全可以复用界面层则必须做平台差异处理。iOS 上NavigationStack加单列布局比较自然macOS 上NavigationSplitView的边栏更适合管理一组技能macOS 还需要考虑MenuBarExtra模式下窗口不常驻的场景。这些差异都不影响 Agent 层因为 Agent 只负责接收请求、返回结果与展示方式无关。实际经验是先定状态协议再分别实现界面。我一般把AgentRequest、AgentResponse这些值类型定义在独立文件里视图层不许直接引用具体处理函数。这样在项目初期就把平台相关的 UI 代码约束在视图文件内后续适配 iPad 分栏、Mac 多窗口的成本会大幅下降。3. Agent 引擎与 Skill 注册机制的详细设计3.1 Skill 协议怎么定边界一个合格的 Skill 协议至少得包含这几个要素唯一标识、简短描述、判断自己能否处理某个请求的方法、实际执行方法。如果技能还需要配置项可以再加一个可选配置协议。但协议不要塞太多能力进去否则每个 Skill 都得实现一堆无关方法可插拔就变成了负担。我在模拟项目 X 里用的协议大概是这个样子import Foundation /// 一次待处理的用户意图请求 struct AgentRequest { let intent: String let text: String let metadata: [String: Any] } /// 技能执行后的统一返回 struct AgentResponse { let text: String let data: [String: Any] let skillID: String } /// 所有技能必须遵循的协议 protocol Skill: Identifiable { var id: String { get } var title: String { get } var priority: Int { get } /// 询问技能是否能处理这条请求 func canHandle(_ request: AgentRequest) - Bool /// 实际执行技能超时由引擎层统一控制 func execute(_ request: AgentRequest) async throws - AgentResponse }id用字符串而不是类名是为了以后做动态下发做铺垫——技能可以从远程配置里加载字符串标识可以跨平台传递。priority用于多技能竞争时的排序通常系统内置技能给高优先级用户自定义技能次之远程插件技能兜底。3.2 调度策略优先级、超时与并发控制多个 Skill 都能处理同一个请求时必须决定先后顺序。最简单的做法是保持注册表插入顺序找到第一个返回canHandle true的 Skill 就执行。但真实项目里技能会分组比如我手里的工具项目 X 就把技能分成了系统基础能力文本处理能力云端扩展能力三类为了不让云端技能抢走本地方能我给每个技能手动指定了priority。调度函数看起来像这样MainActor final class AgentEngine: ObservableObject { Published private(set) var isProcessing false private var skills: [any Skill] [] func register(_ skill: any Skill) { skills.append(skill) skills.sort { $0.priority $1.priority } } func unregister(skillID: String) { skills.removeAll { $0.id skillID } } func handle(_ request: AgentRequest, timeout: TimeInterval 8) async throws - AgentResponse { isProcessing true defer { isProcessing false } guard let skill skills.first(where: { $0.canHandle(request) }) else { throw AgentError.skillNotFound(request.intent) } let response: AgentResponse do { response try await withThrowingTaskGroup(of: AgentResponse.self) { group in group.addTask { try await skill.execute(request) } let result try await group.next() group.cancelAll() guard let result else { throw AgentError.emptyResponse } return result } } catch { throw AgentError.executionFailed(skill.id, source: error) } return response } } enum AgentError: LocalizedError { case skillNotFound(String) case emptyResponse case executionFailed(String, source: Error) var errorDescription: String? { switch self { case .skillNotFound(let intent): return 没有技能能够处理: \(intent) case .emptyResponse: return 技能执行返回了空结果 case .executionFailed(let skillID, _): return 技能 \(skillID) 执行失败 } } }这里有个细节withThrowingTaskGroup并不是单纯的超时控制它还能在第一个结果返回后取消组里的其他任务。如果某个技能内部用了循环处理取消机制能让它尽快退出去避免后台任务一直挂着占用资源。要注意别在主线程里用Task.sleep做超时那会直接卡住 UI。SwiftUI 的异步环境要求我们用结构化并发完成任务而不是额外派一堆不受控的任务。4. 实操从零搭一个最小可运行的 SwiftUI-Agent-Skill 系统4.1 工程结构为了验证这套思路我用 Xcode 里的 SwiftUI App 模板搭了一个最小工程。目录结构按功能拆分视图层和技能层严格分开SwiftUI-Agent-Skill/ ├── App/ │ └── AgentSkillApp.swift ├── Core/ │ ├── AgentRequest.swift │ ├── AgentResponse.swift │ ├── AgentEngine.swift │ └── AgentError.swift ├── Skills/ │ ├── ProtocolSupport.swift │ ├── TextSummarySkill.swift │ └── TextFormatSkill.swift └── Views/ ├── ContentView.swift └── AgentStatusView.swift把值类型和引擎放进Core技能放进Skills界面统一扔到Views。这样编译依赖关系非常清晰核心层不依赖技能技能不依赖界面界面只依赖核心层。4.2 核心代码实现要点先写一个最简单的文本格式化技能它负责把连续多个空行合并成一个。这个功能不复杂但足以验证调度链路struct TextFormatSkill: Skill { var id: String { text.format.collapseBlankLines } var title: String { 清理多余空行 } var priority: Int { 100 } func canHandle(_ request: AgentRequest) - Bool { request.intent text.format || request.intent.hasPrefix(格式化) } func execute(_ request: AgentRequest) async throws - AgentResponse { let cleaned request.text .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } .joined(separator: \n) return AgentResponse(text: cleaned, data: [originalLength: request.text.count], skillID: id) } }这个技能没有真正的异步操作但execute被声明成async throws未来改成调用某个本机模型或者走网络请求都不用改协议。这是异步接口的缓冲价值。再写一个带等待效果的文本摘要技能模拟慢操作struct TextSummarySkill: Skill { var id: String { text.summary.length } var title: String { 统计文本长度 } var priority: Int { 90 } func canHandle(_ request: AgentRequest) - Bool { request.intent text.summary || request.intent.contains(统计) } func execute(_ request: AgentRequest) async throws - AgentResponse { try await Task.sleep(nanoseconds: 600_000_000) // 模拟耗时 let info 原文 \(request.text.count) 字符去掉空白后 \(request.text.filter { !$0.isWhitespace }.count) 字符 return AgentResponse(text: info, data: [length: request.text.count], skillID: id) } }两个技能共享同一个TextView入口注册到引擎后就能各自工作。4.3 一个完整调用链路界面代码里创建一个StateObject的引擎在init里注册技能。TextField绑定用户输入Button里用Task发起异步处理结束后把结果写到State变量。struct ContentView: View { StateObject private var engine AgentEngine() State private var inputText State private var outputText 结果会显示在这里 State private var running false private let summarySkill TextSummarySkill() private let formatSkill TextFormatSkill() var body: some View { VStack(alignment: .leading, spacing: 16) { Text(SwiftUI-Agent-Skill 最小示例) .font(.headline) TextEditor(text: $inputText) .frame(minHeight: 120) .border(Color.secondary.opacity(0.3)) HStack { Button(统计文本) { fire(intent: text.summary) } Button(清理空行) { fire(intent: text.format) } } Text(outputText) .foregroundStyle(.secondary) if running { ProgressView() } } .padding() .onAppear { guard engine.skillCount 0 else { return } engine.register(formatSkill) engine.register(summarySkill) } } private func fire(intent: String) { let request AgentRequest(intent: intent, text: inputText, metadata: [:]) running true Task { do { let response try await engine.handle(request) outputText response.text } catch { outputText 执行失败: \(error.localizedDescription) } running false } } }为了让engine.skillCount可用我给AgentEngine加了一个只读属性extension AgentEngine { var skillCount: Int { skills.count } }这段代码跑起来以后能看到一个很典型的闭环用户意图进入AgentEngine引擎通过canHandle路由到对应技能技能执行完毕返回AgentResponse视图层拿到response.text更新界面。整个链路没有一处switch也没有任何因为新增功能所以要改界面的改动点。5. 实操中遇到的典型问题和排查技巧5.1 异步状态刷新时序问题症状技能明明执行完了界面就是不动或者连续快速点击按钮后结果错乱。排查思路按顺序走三步。第一步在AgentEngine.handle前后打日志确认isProcessing是否按预期翻转第二步在execute方法内部打日志确认返回的AgentResponse是否带上了正确的skillID第三步检查Task内部是否捕获了当前View的状态变量如果某个Task持有的request已经过期结果自然刷新不对。我做模拟项目 X 时遇到过最离奇的一次是TextSummarySkill里的Task.sleep被取消后抛出了CancellationError然后被外层catch当成普通失败处理了。后来统一在AgentError里区分了任务被取消和真实错误UI 侧对取消状态就不再做错误弹窗了。5.2 技能动态加载与内存释放当技能从 JSON 或远程配置里加载时动态注册变成刚需但随之而来的问题是卸载。我用过一种写法注册表里存[String: WeakBox]想通过弱引用避免技能长期占内存结果因为注册表本身是唯一持有者技能被释放后再次查询就崩了。后来放弃这种优化所有技能由注册表强持有卸载时显式调用unregister(skillID:)。技能内部不要捕获外部的大对象尤其是NSManagedObjectContext、WKWebView这类重量级资源。如果技能里确实需要依赖建议用协议抽象一层由引擎注入而不是直接让技能持有。这样可以避免技能本身被上下文反向持有形成循环引用。5.3 平台差异带来的两个具体麻烦iOS 和 macOS 在 SwiftUI 上的差异不只是导航容器。界面层遇到的第一个麻烦是右键菜单macOS 上用户习惯用contextMenu调出操作但同样的菜单在 iOS 上会变成长时间的按压交互项目里如果给同一个视图挂了两套contextMenu逻辑会互相干扰。我的处理方法是把平台差异收敛到独立视图修饰器里Skill层完全不感知。第二个麻烦是TextEditor的滚动行为。macOS 上鼠标滚轮和触控板手势产生的滚动事件跟 iOS 上手指滚动不完全一致用纯 SwiftUI 的TextField和TextEditor在某些版本上会碰到兼容问题。如果技能输出较长文本建议在 macOS 上用ScrollView包一层并且把字体、行距做成常量方便两套界面保持一致。6. 这个架构还能往哪些方向延伸6.1 从单能力到能力市场的演进只要Skill协议是稳定的技能来源就可以不再局限于本地代码。远程下发技能描述、把技能二进制放入独立的动态库引擎通过注册表加载这在技术上都是一条路。我当前的做法是保留一个 JSON 技能清单里面存id、title、priority和canHandle规则。云端的技能清单更新后本地引擎重新同步注册界面自动出现新入口不需要重新发版。这里面比较麻烦的是安全边界远程下发的技能不能直接拿到全部本地文件权限。合理的做法是给AgentRequest.metadata附加权限域字段技能侧按需申请引擎层用sandbox约束请求范围。能力越开放越需要把权限设计前置。6.2 多 Agent 协同与平台能力接入当单一 Agent 处理不了复杂任务时可以让多个AgentEngine实例协作。一个主 Agent 负责拆解任务把子任务分发到专门处理文档、专门处理数据统计的子 Agent。这种结构在 SwiftUI 里实现并不难因为每个AgentEngine都是独立的ObservableObject把它们分别注入到子视图的状态环境里就行。另外可以与系统已有的能力做更深度的结合比如快捷指令、辅助功能接口。对现有 UI 形态来说从应用内按钮触发变成系统级指令触发并不需要改动Skill层只需在系统能力回调里构造同样的AgentRequest。这也是当初我把请求和响应设计成值类型的原因它们可以在系统进程之间被序列化传递。我个人实际操作中的体会是这类项目最怕的不是架构不够先进而是把简单事搞复杂。Skill协议只要能表达我能处理什么和怎么处理就够了不要一开始就抽象出十几个协议、七八个基类。我后来重构过一次模拟项目 X把原来三层的中间抽象全部合并到一层代码量少了三分之一理解成本也降了一大截。最后分享一个小技巧给每个Skill加一个显式的debugDescription打印出id、priority、canHandle的判断结果。排查调度问题时一段清晰的技能注册日志能比断点快得多。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/14 8:27:43
金融科技Java笔试题核心考点拆解:集合、并发、JVM与Spring
2026/10/14 8:22:42
OpenClaw AI助手:低代码自动化工作流实战指南
2026/10/14 8:22:42
Stack Overflow凉了?AI时代开发者知识获取的结构性变局
2026/10/14 9:34:18
AnyPS5:PS5开发者模式下的Linux二进制原生运行实践
2026/10/14 9:34:18
微信小程序校友会系统毕业设计:从源码跑通到答辩讲清楚的完整路径
2026/10/14 9:34:18
牙科STL网格牙龈分割:曲面栅格化与投影驱动的几何提取方法
2026/10/14 9:34:18
正则表达式(rea)实战指南:从元字符到性能优化,彻底搞懂模式匹配
2026/10/14 9:34:18
智能体技能体系实战:从设计编排到治理落地
2026/10/14 9:29:16
从空泛标题到可维护项目:以skills为例的工程实践指南
2026/10/14 0:02:01
Oracle 19c Windows客户端安装配置与连接排错指南
2026/10/14 0:02:01
React Native截图优化:解决Android兼容性问题
2026/10/14 0:02:01
金属锈蚀速率计算模型与防护方案优化实践
2026/10/13 12:25:09
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/13 18:15:04
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/13 8:14:22
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/12 19:53:23
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/11 23:43:10
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)