SwiftUI智能体实践:Agent状态机与技能编排框架详解
一个iOS开发者记录SwiftUI与智能体技能封装的一次实践。界面上跑着多个可编排的技能背后是统一的Agent状态机和任务分发逻辑这套东西拆开来看并不复杂但有几个关键决策值得展开说。1. 整体架构思路先想清楚Agent和Skill的边界1.1 核心需求解析这个项目名字拆开看就是三个词SwiftUI、Agent、Skill。SwiftUI是苹果的声明式UI框架Agent在这里指代智能体或者说任务执行体Skill则是可复用、可编排的能力单元。把它拼在一起就是要在SwiftUI应用里构建一套智能体技能系统让应用可以像搭积木一样把不同技能组合起来由Agent统一调度执行。为什么要做这样一套东西我实际遇到的痛点很具体项目里越来越多的业务逻辑需要按流程执行比如一个内容处理任务要经过“识别语义 → 提取关键信息 → 格式化输出”每一步都有不同的处理策略如果把逻辑全部写死在页面里代码会迅速腐化。有了Agent和技能的分层每个技能独立实现Agent负责编排UI层只做展示整个链路就清晰了。这套方案的适用人群很明确已经有SwiftUI基础、想在项目里引入任务编排概念的开发者。不需要分布式系统经验核心思想就是把复杂流程拆成有状态、可组合、可观测的单元。1.2 为什么选择SwiftUI做底座我见过不少团队在这种场景下纠结要不要上UIKit我的观点很直接SwiftUI的声明式语法和状态驱动模型天生适合做这种“UI响应状态变化”的系统。Agent执行任务时会周期性抛状态变更传统方式要手动刷新多个UI组件SwiftUI这边只要把状态源放到ObservableObject或者Observable里视图会自动刷新不需要我写任何一行刷新代码。另外SwiftUI的ViewModifier和协议机制给了技能包装很大自由度。我可以在技能层做统一的调试面板、执行日志、参数校验这些在UIKit里要做到不侵入业务逻辑需要写一堆基类和代理协议。选择SwiftUI还有一个现实原因跨平台。现在SwiftUI已经覆盖iOS、macOS、watchOS、tvOS一套Agent核心代码可以跑在多个端上。我在项目里用到的技能大多是纯逻辑和UI无关所以SwiftUI的这套技能模型天然具备复用价值。1.3 架构分层的思路整个系统的分层我建议这样划分层级职责关键组件展示层渲染Agent状态、技能列表、执行进度视图、视图模型编排层调度技能执行顺序、处理上下文传递Agent对象、任务队列技能层实现具体能力单元技能协议、具体技能实现基础层日志、配置、网络抽象服务容器、工具函数这种分层有一个明显的好处每一层都只依赖下一层不会跨层引用。视图模型不会直接调用具体的网络服务而是通过Agent发起一个技能执行请求。技能实现也不知道UI层的存在它只负责返回结果或者抛错误。这样做的代价是代码量会多一部分每一层都要有桥接结构。但我实测下来后续维护成本下降是几何级的。比如我后来新增一个“定时提醒”技能只写了技能实现然后把技能注册进Agent的技能表界面没有任何改动新的能力就自动出现在下拉列表里了。2. 核心设计Agent状态机与技能协议2.1 Agent对象的数据结构Agent是整个系统的中枢它要维护的信息包括当前状态、技能注册表、执行上下文、任务队列。在设计这个数据结构时我特别注意了线程安全因为有场景会在后台队列执行技能同时主线程读取状态做UI刷新。MainActor final class Agent: ObservableObject { enum State { case idle case ready case running case paused case finished case failed(AgentError) } Published private(set) var state: State .idle Published private(set) var currentSkillName: String? Published private(set) var executionLog: [String] [] private var skills: [String: AnySkill] [:] private var context: ExecutionContext func registerSkillType: Skill(_ skill: SkillType) { skills[SkillType.id] AnySkill(skill) } func execute(skillID: String, input: SkillInput) async { state .running currentSkillName skillID // 从注册表中取出技能传入上下文执行 } }这段代码有几个关键点MainActor保证所有状态变更都在主线程避免用DispatchQueue.main.async包来包去。AnySkill是类型擦除的具体实现因为协议不能直接放进字典类型擦除是必要的桥接。executionLog数组很重要它记录了每个技能的启动时间、结束时间、结果摘要。UI层可以读这个数组做时间线展示调试时这个时间线帮了我大忙有一次技能执行超时就是靠时间线定位到具体是哪个环节消耗过多。2.2 Skill协议的定义技能协议的设计决定整个系统的灵活度。我试过几个版本最终保留的是带输入输出关联类型的协议protocol Skill { associatedtype Input associatedtype Output static var id: String { get } static var displayName: String { get } func execute(input: Input, context: ExecutionContext) async throws - Output }Input和Output用关联类型好处是每个技能自带类型约束编译期就能发现类型不匹配。有一个协作者把它换成Any类型想减少模板代码结果运行时各种类型判断乱飞稳不住。ExecutionContext是跨技能传递数据的载体里面放了一些通用信息比如用户偏好、当前时间、历史执行摘要。本质上是一个key-value存储但限制key必须是CodingKey类型这样后续方便序列化。2.3 技能的注册与发现机制技能注册我采用了集中注册策略App启动时把所有技能注册进Agentlet agent Agent() let translator TranslateSkill() let summarizer SummarizeSkill() let analyzer SentimentSkill() agent.register(translator) agent.register(summarizer) agent.register(analyzer)这种写法的好处是显式依赖启动时就能看到注册了哪些技能漏注册或者重复注册会在控制台打出调试信息。坏处是每次新增技能都要改启动文件但实际项目里技能数量不会太多这个维护成本可以接受。我还加了一个特殊能力技能可以通过Context里的标志位互相调用。比如翻译技能的实现里可以读取上下文判断输出语言是否需要再走一遍“格式调整技能”这种间接组合避免硬编码技能之间的依赖。3. SwiftUI界面把Agent状态映射到视图3.1 状态驱动的UI结构这套界面的核心思路是单一状态源驱动所有视图。Agent对象通过StateObject注入根视图子视图只关心自己需要的状态切片。struct AgentDashboardView: View { StateObject private var agent Agent() var body: some View { VStack(spacing: 16) { StatusHeader(state: agent.state) SkillListView(agent: agent) ExecutionPanel(agent: agent) } .task { await bootstrapAgent() } } }StatusHeader展示当前Agent状态SkillListView列出可执行技能ExecutionPanel展示执行日志与结果。三个子视图互不通信全部透过agent对象单向流动数据。这种结构让我后来加需求非常轻松比如要加一个“历史记录”面板只要再写一个视图读agent.executionLog即可。3.2 执行流程呈现技能执行是一个异步过程UI需要展示三个阶段准备中、执行中、已完成/失败。我用枚举加associated value处理enum ExecutionStage { case ready case executing(skillName: String) case completed(result: String) case failed(error: String) }在技能列表里每个技能行会根据currentSkillName是否匹配显示不同的loading动画或结果图标。SwiftUI的ProgressView配上.tint()就可以做出清晰的执行态标识不需要自定义绘画代码。有一个细节技能执行期间用户可能反复点击同一个技能这会引发并发问题。我在Agent.execute里加了执行锁同一时刻只允许一个技能执行如果已经处于.running状态后续调用直接忽略并返回提示。等后续做任务队列时再升级成队列机制把并发抢占改成排队执行。3.3 视图模型与服务层的解耦尽量避免在视图里直接写业务逻辑。每个面板配一个视图模型视图模型调用Agent的方法并处理输出数据的格式化。MainActor final class SkillListViewModel: ObservableObject { Published var displayedSkills: [SkillDescriptor] [] private let agent: Agent init(agent: Agent) { self.agent agent } func loadSkills() { displayedSkills agent.availableSkills.map { SkillDescriptor(id: $0.id, name: $0.displayName) } } }这个解耦让视图只做纯粹的渲染工作逻辑高度集中在视图模型里测试时可以直接对视图模型发起异步请求不依赖视图生命周期。4. 实操核心Agent-Skill框架的关键实现版本4.1 版本演进第一版原型到稳定架构第一版原型很粗暴一个SkillRouter对象管理所有技能技能之间用if-else判断类型执行完直接拼字符串返回。这个版本验证了核心链路是通的但代码没法维护加一个新技能就要改Router里的判断很快就膨胀了。第二版引入了协议和泛型把技能从Router里解放出来。但关联类型的代价是数组和字典不好管理于是写了AnySkill类型擦除。这个版本已经能支撑日常开发但技能之间的传参还是用[String: Any]字典运行时解包多了就偶尔崩溃。第三版是当前版本做了两个关键改进引入SkillInput类型化输入每个技能声明自己接收什么类型的参数引入ExecutionContext贯穿一次完整执行会话上下文可以保存中间结果供后续技能使用。演进过程中的最大教训是不要一开始就上复杂抽象先把一条主线走通再逐步把变化点抽象成协议。很多开发者看到这种项目会直接设计一套完美的协议体系结果写了两周发现业务场景根本用不上白白浪费工期。4.2 核心代码技能执行引擎技能执行引擎是整个框架最核心的部分负责从注册表取技能、校验入参、注入上下文、执行并收集结果。struct AnySkill { let id: String let displayName: String private let _execute: (SkillInput, ExecutionContext) async throws - Any initSkillType: Skill(_ skill: SkillType) { self.id SkillType.id self.displayName SkillType.displayName self._execute { input, context in let typedInput try input.decode(as: SkillType.Input.self) let output try await skill.execute(input: typedInput, context: context) return output } } func execute(input: SkillInput, context: ExecutionContext) async throws - Any { try await _execute(input, context) } }AnySkill把具体技能类型擦除让Skill数组可以统一管理。input.decode(as:)负责把通用的SkillInput转成技能自己的输入类型这里用JSON序列化做类型转换避免类型强转崩溃。执行引擎的主流程从注册表按ID取出AnySkill把SkillInput传给AnySkill的包装执行闭包执行闭包内部decode成具体输入类型调用具体技能的执行方法返回Output包装成Any回传给引擎引擎记录日志更新Agent状态这个流程里最容易翻车的地方是第3步的类型转换。SkillInput本质上是一个JSON对象如果技能输入和SkillInput字段不对齐decode直接抛错。我加了一层校验逻辑decode失败时抛出AgentError.inputTypeMismatch错误信息携带技能ID和期望类型排查非常高效。4.3 执行上下文与链式调用ExecutionContext是支持链式调用多个技能的关键。一次完整执行会话里先跑“文本分析”技能得到关键词列表再跑“搜索”技能用关键词检索相关内容最后跑“聚合”技能把结果拼装给用户。每个技能把结果写入上下文下一个技能读取。struct ExecutionContext { private var store: [String: Any] [:] mutating func setT: Encodable(_ value: T, forKey key: ExecutionContextKey) { store[key.rawValue] value } func getT: Decodable(_ type: T.Type, forKey key: ExecutionContextKey) throws - T { guard let raw store[key.rawValue] else { throw ExecutionContextError.valueNotFound(key: key.rawValue) } let data try JSONSerialization.data(withJSONObject: raw) return try JSONDecoder().decode(T.self, from: data) } }上下文存储采用JSON兼容对象这样整个上下文可以被序列化保存到本地App重启后恢复执行现场。这个需求来自一次崩溃恢复场景执行到第三步时用户杀掉了App重启后希望从第三步继续而不是全部重跑上下文持久化直接搞定。4.4 Agent的监控与调试能力状态机加执行日志跑起来后我发现如果只记录成功和失败定位问题还是不够快。于是加了一个更细粒度的调试信息每个技能执行前后的上下文快照、耗时、入参出参摘要。struct DebugTracer { var entries: [TracerEntry] [] mutating func record(_ stage: Stage, skill: String, detail: String) { let entry TracerEntry( timestamp: Date(), stage: stage, skill: skill, detail: detail ) entries.append(entry) } }这些监控数据在开发阶段直接打印控制台发布版本里通过OSLog按需输出。我还在UI上加了一个隐藏的手势连续点击版本号五次展开调试面板展示所有tracer条目不用连Xcode也能真机排查问题。4.5 与Swift并发体系的集成Swift 5.5之后的async/await和Actor模型让Agent框架的并发处理优雅了很多。技能的execute方法设计为async throws天然适配网络请求和本地计算。调用方通过Task发起执行通过Task.isCancelled配合技能实现内部取消逻辑。有一个坑MainActor隔离的Agent如果直接调用非隔离的async方法编译器会报错。我的解法是把Agent统一标成MainActor技能实现内部不影响因为技能本身不持有UI状态只是在执行完回调Agent的方法时才切回主线程。let output try await Task.detached(priority: .userInitiated) { try await skill.execute(input: typedInput, context: context) }.value用Task.detached把重计算放到后台同时保留CancellationError的自动传递。这种写法在复杂技能比如大文档分析里效果显著主线程完全不会被阻塞。5. 常见问题与排查技巧实录5.1 SwiftUI状态不刷新是怎么回事最典型的问题是改了Published属性但视图不刷新。排查步骤先确认修改发生在主线程还是后台线程如果是后台线程修改SwiftUI的发布机制会失效。另一个可能是视图没有持有正确的对象引用子视图收到的Agent是被复制出来的值而非引用类型。实际项目里我遇到过一次把Agent传给子视图时用了let agent agent而不是ObservedObject var agent结果状态改变了子视图完全不感知。解决办法是检查所有接收Agent的地方确保使用ObservedObject或StateObject属性包装。5.2 技能执行超时的处理网络类技能最怕超时。我在Agent.execute里统一加了超时控制传入一个Durations配置默认30秒超时后抛AgentError.timeout并更新状态为.failed。let task Task { try await skill.execute(input: typedInput, context: context) } let output try await Task.timeout(task, duration: skill.timeout)Task.timeout需要自定义实现核心逻辑是用TaskGroup竞争执行任务和延迟谁先回来就取消另一方。这个封装被我复用在多个项目中稳定可靠。需要注意超时取消并不代表底层网络请求一定被取消。技能内部对Task.isCancelled有响应时取消才会真正中断。所以技能实现里凡是遇到网络循环都要主动检查取消状态。5.3 技能之间数据格式不匹配的坑多个技能串行时上游输出的[String]格式下游技能可能期望的是[String: String]decode时直接抛typeMismatch。这个问题的根源是上下文读取时用了硬编码类型。我的解法分两层第一层在技能实现里做类型兼容读取时尝试多种类型第二层在编排逻辑里加显式转换技能比如一个ListToDictSkill专门负责改格式。第二层更符合面向对象思想格式转换被封装成可编排技能逻辑清晰还可复用。5.4 内存泄漏排查记录我用SwiftUI写这类系统时最隐蔽的内存泄漏来源是视图模型持有AgentAgent又在完成回调里更新Agent状态视图模型通过ObservedObject监听Agent导致循环引用。解决办法是理清持有关系Agent由根视图的StateObject持有子视图模型的Agent引用全部标记为weak这样Agent释放时子视图模型也自动清理。目前在我的代码里Agent是唯一没有循环引用的核心对象这个原则要保持住。如果遇到泄漏用Xcode的Memory Graph检查定位重点观察Agent实例是否在视图销毁后仍然保留。5.5 调试面板的输出内容最终版本的调试面板输出了当前Agent状态、已注册技能列表、最近执行的50条tracer记录、上下文中的key列表。这些信息在真机环境排查时帮助巨大很多时候不需要回办公室连Xcode现场看调试面板就能定位问题方向。6. 事后复盘与建议这套SwiftUI-Agent-Skill框架从设计到落地一共花了三周。前两周都在搭建核心结构和评审设计真正写业务技能只用了一周。和之前纯UIKit时代的面向对象写法相比这次的代码量少了约30%核心原因是SwiftUI的声明式UI天然和状态机模型契合少了很多刷新控制代码。有一个体会想分享给后来者这样的框架设计里“未来需要支持的能力”决定协议怎么写。如果一开始只想到“热更新技能”协议可能就设计成插件中心模式不利于后续做复杂的技能编排。我的建议是从真实业务场景反向推导协议至少梳理三条实际执行链路再动笔设计。还有一个运维层面的建议所有技能的版本号要和App版本绑定技能表在启动时通过远程配置下发这样线上技能出问题可以快速禁用不用等待发版。我在项目里做了个粗版“技能开关”一个JSON配置控制哪些技能可用、调哪个版本的实现实测事故响应速度提升了不少。目前这套框架在小规模场景跑得很稳下一步计划是引入技能执行历史持久化把每次执行业务记录保存下来支持回放和分析。另外想把技能之间的依赖关系可视化展示让编排配置从代码配置升级成可视化拖拽。这些功能都在验证阶段稳了再来记录心得。如果你也在纠结怎么把智能体技能体系落到SwiftUI应用里我的建议是先手工写好两条技能链路作为基线然后抽象协议跑通后再扩展。不要一上来就建大而全的框架风险太高返工成本吃不消。