资讯动态

SwiftUI智能体技能架构:可插拔Skill与Agent调度实战

发布时间:2026/10/14 10:05:38 来源:尧图企业网站定制
最近我在做的一个项目叫 SwiftUI-Agent-Skill简单说是把 iOS/macOS 上的智能体能力拆成一个个可插拔技能Skill然后用 SwiftUI 只负责做交互界面。这套东西解决了我一个很头疼的问题过去写客户端内的智能逻辑业务代码和界面逻辑经常缠在一起换一个能力就要动整个页面现在技能可以独立注册、独立测试界面只跟着 Agent 的状态走。文章面向想在自己 SwiftUI 项目里做类似设计的开发者不管你是新手还是已经写过不少界面都能按文中的思路从零搭出一套最小可用的智能体技能框架。我会把核心设计、关键代码、踩坑过程都摊开讲尽量让你不用再折腾一遍。1. 项目由来为什么我会把 SwiftUI 和 Agent 技能绑在一起1.1 一个偶然的需求这个项目一开始并不是为了追什么新概念。当时我在做某跨平台系统需要在系统内置一个“智能助手”入口用户可以在里面完成文本摘要、格式转换、简单问答这些操作。刚开始的代码很直接界面上放几个按钮每个按钮绑定一个函数函数里面写死处理逻辑。看起来没问题但随着技能数量变多问题就冒出来了。第一个问题是每个技能内部都有类似的状态处理比如“执行中”“成功”“失败”我需要在每个页面里复制粘贴一大段状态绑定。第二个问题是新同学想加一个技能至少要读懂原来的页面结构否则很容易把功能塞到错误的位置。第三个问题是测试很难写因为业务逻辑和 SwiftUI 视图绑得太紧拆不开。后来我想能不能像电脑上装软件一样把每个能力封装成一个“技能包”需要的时候注册给一个统一的调度器界面上只显示当前有哪些技能可用当时 Swift 里的 Combine 和 SwiftUI 已经比较成熟而 Agent 这个概念又天然适合做“调度中枢”于是 SwiftUI-Agent-Skill 就冒出了雏形。1.2 SwiftUI 作为智能体界面的优势说实话如果只是为了做技能调度用 UIKit 也能做但 SwiftUI 在表达“状态驱动界面”这件事上确实省力气。智能体执行一次技能本质上就是一次状态流转空闲 - 执行中 - 有输出 / 出错。SwiftUI 里的Published属性可以直接对应这些状态视图几乎不用写手动刷新代码。举个例子以前用 UIKit 时代你可能需要 delegate 或者 NotificationCenter 把“执行结束”这个事件传给界面中间环节一多就容易漏。SwiftUI 的ObservableObject把状态变成可订阅的数据流视图里一个EnvironmentObject就能直接拿到省掉了大量转发逻辑。而且 SwiftUI 的预览能直接喂不同状态给视图我在开发时经常给Agent塞一个假的技能测试界面在不同状态下的表现非常方便。这也是我把 Agent 和 SwiftUI 绑定起来的一个核心理由Agent 决定逻辑SwiftUI 就是一块随状态变化的画布。2. 整体设计与核心概念Agent、Skill、UI 三者怎么解耦2.1 技能Skill即协议先理清一个核心概念技能到底是什么。在这个项目里一个技能就是一个“可以独立执行的功能单元”。我会用一个协议来定义技能而不是把技能直接做成一个类。protocol Skill { var id: String { get } var name: String { get } func run(input: SkillInput, context: SkillContext) async throws - SkillOutput }这里每个技能只需要回答三件事我怎么被识别id、我显示成什么名字name、给我输入和上下文我怎么执行run。协议带来的好处是技能之间互不知道对方存在。做天气查询的技能不需要知道文本摘要技能的存在也不应该依赖它。协议设计上我特意用了SkillInput和SkillOutput这两个统一类型而不是给每个技能定义自己的输入输出结构。这是为了第一步快速改造。你肯定会问这样不是丢掉类型安全了吗确实。我的经验是在项目早期先跑通闭环比起“完美类型”更重要。我后面会在第 4 章专门讲怎么在这个基础上逐步引入更严谨的类型擦除方案但不能一开始就被复杂泛型卡住。2.2 调度中枢Agent怎么管理技能有了技能之后还需要一个对象负责接收用户的意图找到对应的技能执行它再把结果送回到界面。这个对象就是 Agent。Agent 的核心是一个技能注册表。我用一个字典来存放注册进来的技能ID 是键技能本身是值。注册、注销、执行都通过 Agent 来做。这样一来页面不需要感知任何具体技能它只需要说“我要运行 id 为 weather 的技能”Agent 会自动处理剩下的事情。MainActor final class Agent: ObservableObject { Published private(set) var currentSkillID: String? Published private(set) var output: SkillOutput? Published private(set) var isRunning false Published var errorMessage: String? private var skills: [String: AnySkill] [:] private var runningTask: TaskVoid, Never? func register(_ skill: AnySkill) { skills[skill.id] skill } func runSkill(id: String, input: SkillInput) { guard let skill skills[id] else { errorMessage 技能不存在\(id) return } runningTask?.cancel() isRunning true currentSkillID id output nil errorMessage nil runningTask Task { do { let result try await skill.run(input: input, context: SkillContext(userID: demo, locale: zh_CN)) guard !Task.isCancelled else { return } output result } catch { errorMessage error.localizedDescription } isRunning false currentSkillID nil } } }这里我用了MainActor把 Agent 放在主线程因为技能执行结束后需要更新Published属性SwiftUI 的视图刷新必须在主线程上做。这个设计很关键后面我会专门说为什么。2.3 UI 层只做一件事呈现状态如果 Agent 管住了逻辑那么 UI 层就只管“看状态、发指令”。这是这个项目里我自己觉得最重要的一条边界。一个典型的 SwiftUI 页面只需要做三件事展示当前技能列表、接收用户输入、根据 Agent 的isRunning和output改变界面。struct AgentView: View { EnvironmentObject var agent: Agent State private var city var body: some View { VStack(spacing: 16) { TextField(输入城市, text: $city) .textFieldStyle(.roundedBorder) Button(查询天气) { agent.runSkill(id: weather, input: SkillInput(prompt: city)) } .disabled(agent.isRunning) if agent.isRunning { ProgressView(查询中...) } else if let output agent.output { Text(output.text) } if let errorMessage agent.errorMessage { Text(errorMessage) .foregroundColor(.red) } } .padding() } }你可以看到视图里没有任何“如何查天气”的逻辑它只是调用了agent.runSkill。如果你要换一个天气服务你只需要替换注册到 Agent 里的天气技能界面无需任何改动。这种边界带来的直接收益是加新技能不用动 UI改 UI 不用碰技能逻辑测试也更好写。3. 从零搭建最小可用骨架协议、调度、视图三步走3.1 定义 Skill 协议和技能类型我们先从技能侧开始。为了不让泛型把话题带偏我会先用一个统一的SkillInput和SkillOutput作为技能之间的“标准信封”。实际项目中你可以把任意参数塞进params字典或者后续再替换成泛型方案。struct SkillInput: Codable { var prompt: String var params: [String: String] [:] } struct SkillOutput: Codable { var text: String var meta: [String: String] [:] }接下来是 AnySkill。为什么需要一个 AnySkill原因是 Swift 的协议如果带了关联类型associatedtype或者复杂泛型很难直接放进一个[String: Any]字典。为了在第一步保持简单我采用了一个结构体包装器把这个手艺叫“类型擦除”的简化版。struct AnySkill { let id: String let name: String let run: (SkillInput, SkillContext) async throws - SkillOutput initR: Skill(_ skill: R) where R.Input SkillInput, R.Output SkillOutput { self.id skill.id self.name skill.name self.run skill.run } }其实真正的类型擦除还可以封装得更完整比如把id和name都通过闭包往外暴露但这里已经足够解决“注册表需要统一类型”的问题。然后是具体的技能比如一个模拟的天气技能struct WeatherSkill: Skill { let id weather let name 天气查询 func run(input: SkillInput, context: SkillContext) async throws - SkillOutput { let city input.params[city] ?? input.prompt ?? 未知城市 try await Task.sleep(nanoseconds: 1_500_000_000) return SkillOutput( text: \(city)今天晴转多云气温24℃东南风2级。, meta: [source: local-mock] ) } }这个技能内部没有真实网络请求只是故意模拟了 1.5 秒延迟方便界面展示“执行中”状态。实际项目中你把网络请求放到run函数里就行。3.2 定义技能上下文 SkillContext技能不能只有输入有时候还需要知道一些环境信息比如当前用户 ID、语言偏好、日志存储、网络会话等。如果把这些参数直接塞进SkillInput会让输入对象变得很臃肿。所以我单独定义了一个上下文结构。struct SkillContext { var userID: String var locale: String var logger: ((String) - Void)? static var defaultContext: SkillContext { SkillContext(userID: anonymous, locale: zh_CN, logger: nil) } }你可能会问为什么用普通 struct 而不做成依赖注入容器我的想法是在最小骨架阶段一个轻量 struct 已经能传递足够的运行环境。当技能数量继续增长比如超过二十个那时候再考虑引入统一的注册中心或依赖容器也不迟。过早地引入“盒子套盒子”式的架构反而会让新队友一头雾水。3.3 Agent 类的状态流与技能注册看到这里你可能已经发现一个规律技能是纯功能的而 Agent 是持有状态的。上面那个Agent例子里的Published属性就是整个系统里状态流的核心。我们来梳理一下状态是如何流转的用户点击查询按钮调用agent.runSkill(id:input:)Agent 先取消上一次还在运行的任务防止旧结果覆盖新结果然后置isRunning truecurrentSkillID id清空上一次的output技能内部的异步操作完成后把结果赋值给output最后无论成功还是失败isRunning都会被重置currentSkillID变成 nil这个状态流覆盖了智能体最基本的生命周期。后面如果你要做流式输出比如技能正在逐步产生 token可以通过增加Published var streamingText: String来解决思路完全一致。3.4 一个完整示例天气查询技能现在我们把技能、Agent、视图串起来。在你的主视图中你需要创建 Agent 对象并注册技能。我用StateObject持有 Agent确保它在页面生命周期内一直存在。main struct AgentSkillDemoApp: App { StateObject private var agent: Agent init() { let agent Agent() agent.register(AnySkill(WeatherSkill())) self._agent StateObject(wrappedValue: agent) } var body: some Scene { WindowGroup { AgentView() .environmentObject(agent) } } }这样当AgentView里的 TextField 收到城市名后会构造一个SkillInput然后调用agent.runSkill(id: weather, ...)。因为 WeatherSkill 已经注册到 Agent 里所以执行会自动开始界面也会跟着isRunning显示进度条。整个最小闭环就是注册技能 - 用户触发 - Agent 调度 - 技能执行 - 状态刷新。4. 关键细节的“为什么”状态更新、并发与依赖注入4.1 为什么我选 ObservableObject 而不是纯函数在项目早期我试过把技能调度写成一组纯函数比如executeWeatherSkill(input) - SkillOutput然后把结果手动赋给某个变量。这种方式单元测试很方便但落到 SwiftUI 里就很别扭。因为 SwiftUI 的刷新机制要求状态是可观察的你总不能每次手动objectWillChange.send()。ObservableObject配合Published是 SwiftUI 社区最常用的方案它有完整的 Combine 支持。比如我想在 Agent 上做一个“最近执行记录”列表可以用Published var history: [SkillRecord]然后通过Combine的sink或者直接在视图里用List来展示。如果我选择了纯函数这一切都要从零搭起。所以我的原则是技能内部逻辑可以用纯函数但 Agent 调度层必须是一个 ObservableObject。4.2 技能执行中的线程切换与 Task 管理这个项目里最容易踩坑的就是线程问题。SwiftUI 的视图更新只能在主线程但技能里的网络请求、文件读取往往会在后台线程。如果技能在后台线程结束后直接给Published赋值轻则界面不刷新重则崩溃。我的做法是第一层保险给 Agent 类标注MainActor这样 Agent 的内部状态都锁定在主线程上。第二层保险是在run函数内部异步操作结束后先把结果交给Task的返回值再由Agent.runSkill里的Task在主线程上下文中继续执行。因为Task创建在MainActor环境中所以output result一定落在主线程。如果你写了一个技能类这个类不是MainActor你在run里更新自己的属性那就要格外小心。常见的错误写法是// 错误示例不要在技能内部直接更新 Agent 的状态 DispatchQueue.global().async { let result ... DispatchQueue.main.async { agent.output result } }虽然也能工作但手动DispatchQueue.main.async一旦遗漏就会出现莫名其妙的界面不刷新。这也是我为什么把技能和 Agent 分开的原因技能不拥有任何Published状态它只负责返回结果状态更新完全由 Agent 完成线程问题被限制在一个很小的范围内。4.3 技能上下文与依赖注入的取舍项目越往后做越会发现技能需要一些外部依赖比如网络请求器、数据库、用户会话。如果每个技能内部自己去初始化这些东西那么技能之间就会出现重复代码测试也很难做。我在SkillContext里预留了位置但实际项目里我更推荐在创建技能时注入依赖而不是在运行时通过上下文传。原因是创建技能时注入可以更早地暴露问题。比如一个设计为“需要登录才能使用的技能”你可以在构造函数里传一个UserSession实例。如果拿不到有效会话它甚至无法被初始化。这样比执行时再抛错要好得多。同时也别过度设计。我见过一些同学一开始就想上“技能容器 依赖注入框架 配置中心”结果一个简单 Query 技能写了三百行配置。我的建议是先用上下文结构体传递必要信息当技能数量超过十个且你开始为每个技能重复写网络初始化代码时再考虑抽象的注入方案。5. 实操中踩过的坑与排查技巧实录5.1 SwiftUI 视图不刷新的常见原因相信很多朋友一开始都会遇到技能明明执行完了但界面没有变化。我在这个项目里至少踩过三种原因。第一种是非主线程更新Published。这个最容易排查直接在output result那行打断点看线程是否为主线程。如果是后台线程参考 4.2 的解决办法。第二种是视图层级中缺少EnvironmentObject。如果某个子视图用EnvironmentObject var agent但父视图没有.environmentObject(agent)SwiftUI 会直接崩溃。当然还有种“假象”父视图传了 environmentObject但子视图是被一个独立闭包创建的导致接收不到。第三种是把Published属性本身包装错了。比如你定义了一个Published var output: SkillOutput?但在视图里用的是let agent Agent()而不是StateObject。每次视图重算时这个Agent被重新创建状态自然就丢了。这也是初学者最容易忽略的。5.2 技能取消与任务泄漏在这个项目里用户在界面上连续点击多个技能按钮是很常见的行为。如果不做取消管理上一个技能还在跑结果你把新技能执行结果拿出来却发现被旧技能的结果覆盖了。我的解决方案是在runSkill开头调用runningTask?.cancel()并在执行完技能后检查Task.isCancelled。runningTask?.cancel() runningTask Task { do { let result try await skill.run(input: input, context: context) guard !Task.isCancelled else { return } output result } catch is CancellationError { // 正常取消不设置错误信息 } catch { errorMessage error.localizedDescription } ... }另外一个隐藏问题是技能内部的长时间任务也可以监听到取消。比如一个下载技能你应该在URLSession的 task 上使用withTaskCancellationHandler真正取消底层网络请求否则只是外部任务取消底层资源还在跑。这个坑我在调试日志时发现过界面早已重置但网络请求还在走造成不必要的流量消耗。5.3 如何做技能热拔插与测试Agent 的技能注册表是普通字典所以你可以随时注册和注销技能。这个特性在做调试时特别有用。我通常会在开发环境里注册一个DebugSkill它什么都不做只打印当前输入和上下文。struct DebugSkill: Skill { let id debug let name 调试 func run(input: SkillInput, context: SkillContext) async throws - SkillOutput { context.logger?(输入\(input)) return SkillOutput( text: 收到输入\(input), meta: [type: debug] ) } }这样你可以在界面上加一个“调试”按钮随时查看 Agent 收到的数据。等上线时只需要把这一行注册代码删掉就行丝毫不会影响其他技能。同时因为技能是独立注册的单元测试时可以直接用 mock 技能替换真实技能测试 Agent 的状态流转不用碰网络。5.4 问题速查表我把实际开发中遇到过的问题整理成了一个速查表以后再遇到类似情况可以直接查。现象常见原因解决方法技能执行完成但界面无变化非主线程更新状态使用MainActor标注 Agent 或手动切到主线程连续切换技能后结果串了旧任务未取消在runSkill开头调用runningTask?.cancel()页面一打开就崩溃子视图缺少环境对象确定组件链路都注入了.environmentObject(agent)技能列表点不动isRunning一直为 true检查执行路径是否在错误分支重新设置了isRunning技能内部网络请求还在跑未使用withTaskCancellationHandler在技能内部监听Task.isCancelled并取消底层请求注册了技能但找不到注册 ID 和触发 ID 不一致使用统一的SkillID常量或枚举定义单元测试频繁超时技能内部真实网络或延迟注入 mock 请求器或直接使用Task.sleep可控的假技能这张表的价值在于大多数问题并不复杂但排查起来很浪费时间。当你对状态流足够熟悉后很多问题可以直接从“状态有没有被正确修改”这个角度去复盘而不是漫无目的地到处加打印。在这个项目里我还养成了一个习惯每次在界面上新增一种状态就先在预览图里手动把 Agent 的状态改成一个中间值看看 UI 长什么样。比如预览一个this.agent.output SkillOutput(text: 测试)你会立刻看到界面的对应样子。这样做能让我在写真实技能前就把 UI 都校对好。这个项目的核心思想其实就是一句话用 Agent 当调度中枢把技能做成可插拔的模块SwiftUI 只负责把 Agent 的状态翻译成画面。整个结构并不复杂但好处是边界非常清晰新技能进来不用改 UI新界面也不用重写业务逻辑。如果你也想给 SwiftUI 客户端加智能能力不妨先按这套骨架把最小闭环跑通让一个 mock 技能能正常显示结果再去填真实业务。这样后续的扩展就会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑