资讯动态

SwiftUI Agent技能架构:状态机与单向数据流实战

发布时间:2026/10/21 13:07:11 来源:尧图企业网站定制
前阵子在整理 SwiftUI-Agent-Skill 这个项目时我一直在想一个问题SwiftUI 写界面简直是一把梭为什么一旦接智能体交互代码就变得像一团乱麻后来我意识到问题多半不在 SwiftUI 本身而是技能逻辑和视图状态被塞在了同一个 ViewModel 里。这个项目把 Agent 的每一项能力抽象成独立的 Skill再用状态机和单向数据流把它们接回界面让“指令输入—技能执行—结果展示”变成一条清晰可控的链路。如果你也想在自己的应用里做一个工具面板、自动化助手或者纯粹想把 SwiftUI 的状态管理玩明白这套思路值得照着拆一遍。1. 项目整体设计与思路拆解1.1 为什么“技能”必须单独抽一层最朴素的实现方式是在 ViewModel 里给每个技能写一个方法。第一版时我接过一个需求界面上有查天气、设提醒、发消息三个入口当时我在一个Published的loadingState里塞了三个布尔值再配一个errorMessage和一个resultString。功能跑通没问题但当技能数量涨到八个、十个之后ViewModel 里全是if和switch。任何一个技能加了新状态比如“等待用户确认”“正在重试”就得回去改三四处改错一个字段 UI 就错乱。真正的问题不是代码多而是“技能”没有被当成一个独立对象。技能本身包含三样东西输入参数、执行动作、执行状态。如果这三样散落在不同的属性里它就不具备内聚性。SwiftUI-Agent-Skill 的核心做法是把每个技能定义成一个遵守AgentSkill协议的类型每个技能自己知道要怎么跑、往哪里上报进度、抛什么错误。界面层只拿到一个SkillPhase枚举再做简单翻译。这样做还有一个好处单元测试可以脱离 SwiftUI 运行。你不需要打开模拟器不需要渲染视图直接对一个技能注入模拟数据断言它从.running走到.finished还是.failed。在真实项目里这种可测试性比少写几行代码重要得多。1.2 单向数据流与状态机选择我在这个项目里采用了很保守的单向数据流View 发起点按钮事件Hub 负责调度技能技能执行完把结果写回 HubHub 更新Published属性View 自动刷新。整个过程不允许 View 直接调用技能内部方法也不允许技能反向修改 View 的私有状态。为什么强调单向因为 Agent 场景里并发特别多。一个指令可能同时触发三个技能每一个技能都有独立的生命周期。如果允许双向通信界面会收到来自多个方向的更新最终你会分不清当前显示的是哪个技能的状态。单向数据流把“发生什么”和“显示什么”彻底分开排查问题时只需要盯住 Hub 里的状态变化。技能状态我统一用枚举表达public enum SkillPhase: Equatable { case idle case running case finished(String) case failed(String) }一开始我也用过isLoadingerrorMessageresult三个独立变量后来发现它们会产生非法组合isLoading true同时errorMessage有值界面就不知道到底该显示加载中转圈还是错误提示。枚举状态机从类型层面消灭了这种状态每次只有一个 case 生效View 用switch穷尽匹配新加状态时编译器会提醒你所有处理过的地方都要补上。这种“让编译器帮你兜底”的体验用惯了就回不去了。2. 核心细节解析与实操要点2.1 Skill 协议最小化设计SwiftUI-Agent-Skill 里最重要的类型是AgentSkill协议。我刻意把它做得非常小只留下三个必要成员public protocol AgentSkill: Identifiable { var id: String { get } var displayName: String { get } func run(_ input: [String: Any]) async throws - Any }id用于在 Hub 中唯一标识技能displayName展示在界面上run是实际的执行入口。输入输出统一用[String: Any]和Any而不是泛型这是有意为之。Agent 技能的输入天然是动态 JSON比如用户说“帮我在周五下午三点提醒我开会”解析出来的参数就是[time: ..., title: ...]你不可能在编译期知道用户会说什么。与其在协议上用associatedtype给自己找麻烦不如让技能内部自己转换参数类型。但我也不是完全放弃类型安全。在具体技能类型里我会在run方法开头做一次参数解码把[String: Any]转成结构化的配置对象转不成功就抛一个带中文提示的错误。这样对外是动态的对内是类型安全的两边的好处都拿得到。下面是两个示例技能。第一个模拟天气查询第二个模拟创建提醒struct WeatherSkill: AgentSkill { let id weather let displayName 查询天气 func run(_ input: [String: Any]) async throws - Any { guard let city input[city] as? String else { throw SkillError.invalidInput(缺少城市参数) } // 这里替换成真实天气 API 调用 try await Task.sleep(for: .seconds(1)) return \(city) 今天晴气温 18-24 度 } } struct ReminderSkill: AgentSkill { let id reminder let displayName 创建提醒 func run(_ input: [String: Any]) async throws - Any { guard let title input[title] as? String, let time input[time] as? String else { throw SkillError.invalidInput(缺少提醒参数) } // 写入本地提醒存储 try await Task.sleep(for: .seconds(0.5)) return 已创建提醒\(time) \(title) } }2.2 技能编排并发、超时、取消一个 Agent 指令很少只触发一个技能。比如“明天上午出门前看看天气顺便提醒我带伞”这里WeatherSkill和ReminderSkill需要同时跑。最直观的写法是顺序await但这样两个技能串行执行总耗时是两个技能耗时的和。在 SwiftUI 里正确的做法是交给TaskGroup并发执行。我封装了一个批量执行方法核心逻辑如下func runSkills(_ tasks: [(skillID: String, input: [String: Any])]) async - [String: Any] { await withTaskGroup(of: (String, ResultAny, Error).self) { group in for task in tasks { guard let skill skillMap[task.skillID] else { continue } group.addTask { do { let output try await skill.run(task.input) return (task.skillID, .success(output)) } catch { return (task.skillID, .failure(error)) } } } var results: [String: Any] [:] for await (skillID, result) in group { switch result { case .success(let value): results[skillID] value case .failure(let error): results[skillID] 错误\(error.localizedDescription) } } return results } }这里有个容易踩的坑如果group.addTask里的技能直接throw整个TaskGroup会在第一个失败任务处提前终止其他还在跑的技能会被隐式取消。如果你希望某个技能失败不影响其他技能一定要在addTask内部把错误捕获成Result再作为正常返回值带出来。我不知道这个坑之前也踩过第一次测试时一个技能报错连带另外三个技能的结果全丢了。超时控制也是 Agent 场景的刚需。网络接口不一定可靠不能让界面无限转圈。我写了一个withTimeout辅助函数func withTimeoutT: Sendable( _ operation: escaping () async throws - T, seconds: TimeInterval ) async throws - T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: .seconds(seconds)) throw SkillError.timeout } defer { group.cancelAll() } let result try await group.next()! return result } }超时时间我默认设 15 秒。为什么不是 5 秒也不是 30 秒大多数查询类接口在正常网络下 10 秒内能给到结果5 秒在弱网下容易误报30 秒又让用户等得太焦虑。业务方如果特别强调实时性可以调成 10 秒但基础默认值不要低于 8 秒。3. 实操过程与核心环节实现3.1 从零搭一个技能面板这里我用一个最小可运行示例带你走一遍整个搭建流程。先建一个 SwiftUI 生命周期的新 App Target然后按下面几步操作。第一步定义错误类型和协议。把AgentSkill协议放在单独文件里同时定义SkillErrorenum SkillError: LocalizedError { case invalidInput(String) case timeout case cancelled var errorDescription: String? { switch self { case .invalidInput(let message): return 参数错误\(message) case .timeout: return 执行超时 case .cancelled: return 已取消 } } }第二步写SkillHub。它是整个项目的中枢持有技能地图和技能状态MainActor final class SkillHub: ObservableObject { Published private(set) var phases: [String: SkillPhase] [:] Published private(set) var skills: [AnySkill] [] private var skillMap: [String: AnySkill] [:] init(skills: [AnySkill]) { self.skills skills for skill in skills { skillMap[skill.id] skill phases[skill.id] .idle } } func run(_ skillID: String, input: [String: Any]) async { guard let skill skillMap[skillID] else { return } guard phases[skillID] .idle || isFailed(skillID) else { return } phases[skillID] .running do { let output try await skill.run(input) phases[skillID] .finished(\(output)) } catch { phases[skillID] .failed(error.localizedDescription) } } private func isFailed(_ skillID: String) - Bool { if case .failed phases[skillID] { return true } return false } }第三步写主界面。界面只做两件事列出所有技能点击某个技能时触发hub.runstruct ContentView: View { StateObject private var hub SkillHub(skills: [ AnySkill(WeatherSkill()), AnySkill(ReminderSkill()) ]) var body: some View { List(hub.skills) { skill in SkillRow(skill: skill, phase: hub.phases[skill.id] ?? .idle) .onTapGesture { Task { await hub.run(skill.id, input: [ city: 某城市, title: 带伞, time: 明天 08:00 ]) } } } .navigationTitle(技能面板) } }这里演示的onTapGesture里直接构造了输入参数真实项目中这些参数应该来自用户输入框或 Agent 意图解析层。重点看流程点击后run先修改 phase 为.runningView 收到状态变化自动显示加载视图技能执行完毕phase 变为.finishedView 再自动显示结果。整个过程中View 没有直接碰技能内部逻辑。3.2 让状态驱动界面SkillCardView为了让界面更好看我单独抽了一个SkillRow组件专门负责将SkillPhase翻译成视觉元素struct SkillRow: View { let skill: AnySkill let phase: SkillPhase var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text(skill.displayName) .font(.headline) switch phase { case .idle: Text(等待执行) .foregroundStyle(.secondary) case .running: HStack(spacing: 8) { ProgressView() Text(执行中...) } case .finished(let output): Text(output) .foregroundStyle(.green) case .failed(let error): Text(error) .foregroundStyle(.red) } } Spacer() } .padding(.vertical, 8) } }这个组件看起来很简单但它是整个项目正确性的试金石。只要状态机定义得足够清晰这里的switch就是完整且可维护的。如果你加了一个新状态比如.waitingForConfirmSwift 编译器会强制你在这里补一个分支不可能出现“状态漏处理”的静默 bug。我还试过用ViewBuilder把状态渲染封装成独立函数实测下来对于两三个技能没什么必要反而是统一的switch更直观。只有在技能卡片种类差异很大的时候才应该为每个技能提供单独的SkillCard视图。状态驱动还有一个隐性好处预览和测试非常方便。你可以不运行 App直接给SkillRow传入一个.running状态或者在 SwiftUI Preview 里传入.failed(模拟错误)单独调试每一种 UI 形态。这一点在调整样式时特别高效。4. 常见问题与排查技巧实录4.1 主线程检查器报错明明在 SwiftUI 里还会崩SwiftUI 本身是主线程友好的但你的技能内部跑的是异步网络请求异步函数返回后并不会自动回到主线程。如果这个技能在后台队列执行然后直接修改Published的phases就会出现“Publishing changes from background threads is not allowed”这类崩溃。解决办法是给SkillHub类标记MainActor。我上面给出的SkillHub已经加了MainActor所以在run方法里修改phases是安全的。这里要特别注意不要只给run方法加MainActor因为方法内部调用skill.run时技能本身可能切换到后台执行一旦切出去回到run方法时它能不能继续改状态取决于调用点是否继承了 MainActor 上下文。最稳妥的是把整个 Hub 隔离到主线程状态变更永远发生在主线程上技能的网络请求则通过内部Task或TaskGroup切到后台。如果遇到崩溃先看崩溃日志里是否有 “Main Thread Checker” 字样再检查所有修改Published的方法是否都在MainActor作用域内。不要用DispatchQueue.main.async去包Swift 并发下你更应该用“类型标注”而不是“手动派发”来解决问题。4.2 点一次按钮触发三次请求技能重入问题一开始我在 UI 上没有做防重入技能执行过程中按钮仍然可以点击用户多点两次就会发出重复请求。表现上是同一个技能被并发执行返回结果后界面出现奇怪的闪烁。解决分两层。第一层是在SkillHub.run开头加守卫只有在.idle或.failed状态下才允许启动新任务。第二层是在 UI 上如果当前 phase 是.running直接禁用整行点击.onTapGesture { guard hub.phases[skill.id] .idle else { return } Task { await hub.run(...) } }不过这里有个细节如果技能失败后允许重试.failed状态也要放行否则用户遇到一次失败后整个技能就永远点不动了。我在run里判断的是isFailed才放行否则只允许 idle这样重试和首启都覆盖到。4.3 泛型擦除与协议数组一个容易卡住的地方很多人在给SkillHub声明技能数组时第一反应是写成[AgentSkill]。如果AgentSkill协议带associatedtype编译器会直接拒绝协议只能用作泛型约束不能作为数组元素类型。就算你强行把associatedtype擦除成Any后面调用skill.run时依然会碰到类型转换的麻烦。我在这个项目里采用的方案是把协议输入输出统一成 JSON 类型再配一个AnySkill包装器。AnySkill自身也遵守AgentSkill内部持有真实技能实例这样 SwiftUI 数组里放的都是同一个具体类型规避了协议数组的限制struct AnySkill: AgentSkill { let id: String let displayName: String private let wrapped: any AgentSkill init(_ skill: any AgentSkill) { self.id skill.id self.displayName skill.displayName self.wrapped skill } func run(_ input: [String: Any]) async throws - Any { try await wrapped.run(input) } }这个包装器写起来很简单但它解决了两个问题一是让技能可以放到Published数组里二是让 SwiftUI 的List/ForEach可以直接遍历。如果你坚持要用强类型泛型也不是不行但你需要为每个技能手动写一个ForEach分支代码量会大不少而且动态技能注册场景很难覆盖。4.4 超时之后任务还在跑隐式取消没那么神奇withThrowingTaskGroup的defer { group.cancelAll() }很有用但它不是万能的。如果你的技能做了耗时很长的同步计算或者底层网络库不响应取消超时抛错后任务仍然会在后台跑完。表现是界面已经显示“超时”几秒后技能结果又回来了甚至再次更新了phases。我的做法分两步。首先在技能里尽量用支持取消的底层接口比如现代系统的网络请求原生支持取消就不需要额外处理。其次如果技能里要写一个长循环每轮循环里主动调用Task.checkCancellation()for item in items { try Task.checkCancellation() // 处理单个 item }这样当超时触发后技能能在下一个检查点快速退出而不是继续闷头跑几十秒。还有一个小技巧超时时间到了以后Hub 可以在catch块里把phases[skillID]恢复到.idle让用户能马上重试而不是卡在“超时错误”里不能动。5. 个人实际体会与扩展建议写这个项目最大的感触是 SwiftUI 的“状态驱动”不是一句空话它只有在状态被建模得足够清晰时才能真正发挥威力。技能面板看起来和普通列表没什么区别但当你连续接入十几个技能每个技能都有不同参数和不同结果格式时SkillPhase就是整个项目的稳定锚点。我在实际开发中宁可多花一小时把状态机画清楚也不愿意在 ViewModel 里多塞一个布尔值。后面再做同类项目我会优先扩展两个方向。第一个是技能执行事件流。现在的run方法只返回一个最终结果但很多 Agent 技能是分步骤执行的比如“调用模型解析意图—调用工具—写入数据库—返回结果”中间每一步都想让用户看到。这时候可以把返回值改成AsyncThrowingStreamSkillEvent, Error就能实时推送每一步进度。第二个是技能依赖注入。现在SkillHub在初始化时统一接收技能数组这个方式够用但不够灵活如果技能本身需要配置不同 API 客户端可以在Skill内部自己持有依赖或者用一个简单的工厂函数注册技能界面完全不需要知道技能是怎么组装出来的。最后提醒一句不要一上来就把项目做成“万能 Agent 框架”先把一个技能跑通再逐步抽象。我自己第一版就吃了过度设计的亏写了不少根本没用到的基础设施。从两个技能开始让AgentSkill协议、SkillHub、SkillPhase这三个核心组件流畅运转后面加技能就是往列表里塞一个类型的问题了。

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

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

免费获取报价 →
↑