1. 为什么要在SwiftUI里单独做一层Agent技能编排
上个月我帮一位朋友重构AI日程助手App时,遇到了一个非常典型的困境:功能肉眼可见地“能用了”,但代码也开始肉眼可见地失控。聊天页面里塞满了各种模型返回值的switch判断,日历组件要处理工具调用的结果,天气模块又自己发一次网络请求,整个ViewModel越来越像一个大杂烩。他问我:“SwiftUI的声明式UI这么好写,为什么一接入AI能力就变得这么难受?”
这个问题问到了点子上。SwiftUI擅长的是把状态映射成界面,但Agent场景里最核心的不是“界面”,而是“任务”:模型决定要做什么,App要找到对应的工具能力去执行,再把结果回传给模型继续推理。这一整条链路如果散落在视图和ViewModel里,哪怕只有一个技能也会乱,更别说后续还要接第二个、第三个技能。
所以我把这个项目取名为SwiftUI-Agent-Skill,核心思路就是在SwiftUI应用里单独抽出一层“技能编排层”。它不关心你的聊天气泡长什么样,也不关心你用哪家大模型,它只负责三件事:把模型意图翻译成具体技能调用,把技能执行过程中的进度和结果变成UI能消费的事件流,把组件间的状态隔离做得足够干净。如果你正在用SwiftUI开发AI类App,想把大模型的能力真正变成“能干活”的功能模块,而不是只停留在纯文本对话,这个项目应该能给你一套可以直接落地的思路。
1.1 为什么UI层不能直接处理AI返回
大多数SwiftUI开发者的第一版AI接入代码是这样长的:模型返回一串JSON,里面包含意图名称和参数,然后ViewModel拿到这串JSON后自己写if intentName == "createEvent" { ... },再往后就是调用日历API、解析结果、更新多个@Published属性。这套写法在小Demo阶段没有任何问题,但一旦技能数量超过三个,你会立刻碰到几类麻烦。
第一类是类型安全。模型返回的JSON参数是[String: Any],你必须在运行时做大量的类型转换和判空,编译器帮不了任何忙。第二类是职责混乱。ViewModel既要负责发消息、又要负责调日历、还要负责组装提示词,任何一处的改动都会波及另外几处。第三类是最隐蔽的:技能之间的状态难以隔离。比如日程创建以后要顺带查询天气、查完天气又要推荐出行方式,三个技能共享同一个上下文对象,谁改了谁的字段,出了问题根因极难定位。
把技能抽取成独立层之后,这些问题的解决方式就变得很清晰:UI层只需要声明“用户提出了一个任务”,技能层负责“选出哪个技能来处理”,而具体技能的内部实现完全与视图隔离。UI不感知模型返回的原始JSON结构,技能也不感知ChatBubble长什么样。
1.2 我理解中Agent与Skill的边界
在动手写代码之前,必须先明确一个概念边界:Agent和Skill的区别是什么。我给这个项目定的边界很朴素——Agent是决策器,Skill是执行器。Agent负责理解用户意图、拆解步骤、决定下一步调用哪个工具,它通常由大模型承担;Skill则是App侧具体实现的能力单元,比如创建日程、查询天气、发送通知,每一段可执行的功能都被封装成一个技能。
这个边界一旦确定,架构上的分工就顺理成章了。Agent与Skill之间不直接握手,而是通过一个标准化的协议交流:Agent输出结构化意图,Skill声明自己能够处理哪类意图并暴露输入参数格式,Agent再根据这份声明决定是否调用。这套思想其实借鉴了大模型Function Calling的机制,但比Function Calling走得更远——它让技能变成了App内一等公民,可以被注册、被发现、被测试、被复用。
下一节我会把项目的三层骨架拆开,重点说明每个组件为什么必须存在,以及它们之间的依赖规则。
2. 以技能协议为中心的三层项目骨架:每个组件存在的理由
SwiftUI-Agent-Skill的整体结构并不复杂,我划分为三层:视图与交互层、技能能力层、运行时层。运行时层是核心,它负责维护所有技能的注册表、根据意图选择技能、执行技能并返回结果。技能能力层只做一件事:定义具体的AgentSkill实现。视图层则通过一个Store对象与运行时层通信,完全不直接触碰具体技能。
分层带来的最大收益是“替换自由”。只要某个技能遵守协议,你可以随时替换它的内部实现,不影响上层;只要运行时层的接口不变,从测试用的Mock技能切到真实网络技能,也不需要改动一行视图代码。下面我从技能协议的定义开始讲。
2.1 技能协议的核心定义
技能协议是整个项目的地基。我一开始想做得“轻量”,只放一个identifier和一个execute,结果后面大量逻辑不得不靠if let skill as? CalendarSkill这种类型强转来硬撑,非常丑。后来我把协议扩充成下面这个样子,才真正稳定下来。
public protocol AgentSkill: Sendable { /// 技能唯一标识,注册中心用它做去重与寻址 var identifier: String { get } /// 给大模型看的自然语言描述,用于Function Calling的意图匹配 var description: String { get } /// 输入参数的JSON Schema描述,模型根据它生成结构化参数 var inputSchema: SkillInputSchema { get } /// 判断当前意图是否命中该技能,可返回匹配置信度 func canHandle(intent: SkillIntent) async -> SkillMatchResult /// 技能的真正执行入口 func execute(context: SkillExecutionContext) async throws -> SkillOutput }identifier不用多说,它是技能注册表里的主键。description经常被新手忽略,但它恰恰是技能能否被模型正确调用的关键。大模型看不到你的Swift代码,它只能通过这段文字判断“这个技能是干嘛的、什么场景下用”,所以描述要写清楚触发条件,比如“当用户需要新增一条日程时调用此技能,参数包括标题、开始时间和结束时间”,而不是只写“创建日程”。
inputSchema承担了参数契约的职责。我基于JSON Schema定义了一个简化类型,最终会转换成大模型Function Calling工具描述里的parameters字段。这样做的好处是技能参数的校验逻辑可以统一收敛在运行时层,而不需要每个技能自己写一堆guard let。
public struct SkillInputSchema: Sendable { public let type: String = "object" public let properties: [String: InputProperty] public let required: [String] public struct InputProperty: Sendable { public let type: String public let description: String public let enumValues: [String]? } }2.2 三层职责与依赖规则
为了让后来接手项目的人一眼看懂,我用下面的表格把三层职责固定下来。这个表格不是摆设,它是我在重构过程中反复确认过的边界。
| 层级 | 核心组件 | 职责范围 | 允许依赖 |
|---|---|---|---|
| 视图与交互层 | ChatView、AgentSessionStore | 展示消息流、收集用户输入、绑定技能进度状态 | 只依赖运行时层公开接口 |
| 技能能力层 | CalendarSkill、WeatherSkill等 | 实现具体业务能力,接收上下文参数并返回结构化输出 | 只依赖基础类型,不依赖任何视图 |
| 运行时层 | SkillRegistry、SkillPipeline | 技能注册、意图匹配、任务执行、事件流分发 | 只依赖协议与基础类型 |
依赖规则里有一条“铁律”:技能能力层严禁反向依赖运行时层。曾经有个技能为了判断“是否已有同日程”直接去调了注册中心里的另一个技能,结果两个技能之间产生了隐式耦合,单测无论如何都构造不出干净的上下文。后来我把这种“技能调用技能”的需求全部收归到编排器层面,技能与技能之间不直接通信。
2.3 为什么用协议而不是枚举
我见过有人用枚举来组织技能,写一个enum SkillType { case calendar, weather, reminder },然后在ViewModel里再写一个大的switch去分发。这种方案在小规模下确实直观,但每新增一个技能都要改动两个文件,而且无法做到“可插拔”。用协议加动态注册表的好处是:新增技能时只需要新建一个类型并在App启动阶段调用registry.register(...),既有代码零改动。
更重要的是,技能注册是运行时的,这为后续做模块化打下了基础。一个技能可以来自主App模块,也可以来自本地Swift Package、甚至之后可以做成插件化的远程下发。枚举做不到这种动态性,协议可以。
3. 注册中心与任务管道:从技能命中到执行的完整链路
有了协议,下一个问题就是:运行时层到底如何把意图转成技能执行。我设计了两个组件,一个负责“谁适合干这个活”,一个负责“把它干完并把事件送出去”。
3.1 注册中心:actor保证线程安全
SwiftUI的并发环境比传统UIKit更强调数据安全。注册表是典型的共享可变状态,我直接把它设计成actor,让编译器强制保证并发访问的安全,而不是靠开发者自觉加锁。
public actor SkillRegistry { private var skills: [String: any AgentSkill] = [:] public func register(_ skill: some AgentSkill) throws { guard skills[skill.identifier] == nil else { throw SkillRegistryError.duplicateIdentifier(skill.identifier) } skills[skill.identifier] = skill } public func unregister(_ identifier: String) { skills.removeValue(forKey: identifier) } public func allSkills() -> [any AgentSkill] { Array(skills.values) } public func skill(withIdentifier identifier: String) -> (any AgentSkill)? { skills[identifier] } }register方法里对重复标识符做了保护,这个看起来很小的判断在实际项目中非常重要。我们曾经在技能模块加载时因为多次注册同一个标识符,导致意图被匹配到旧版本的技能实现,排查了很久才发现是启动流程里重复执行了注册逻辑。
3.2 技能命中算法与Function Calling的绑定
意图匹配是任务管道里最容易出现玄学的部分。我采用了两段式:先用canHandle做粗筛,再按置信度排序,取最高者作为最终执行技能。每个技能返回的SkillMatchResult里除了Bool,还带一个confidence值,方便未来接入更复杂的路由策略。
public struct SkillMatchResult: Sendable { public let canHandle: Bool public let confidence: Double }上面说的是“本地意图路由”,但在真正的大模型Agent应用里,它还需要与Function Calling机制对接。对接方式并不复杂:运行时层遍历所有技能,把它们的identifier、description、inputSchema拼装成大模型工具列表;模型根据用户话术返回一个工具调用意图,运行时层再把这个结构化意图交给注册中心做匹配。我第一次跑通这条链路时有个很深的体会:模型会不会选错技能,很大程度上取决于技能的description写得好不好,跟代码逻辑关系不大。所以后来我把每个技能描述的撰写当成产品文案来对待,反复推敲触发场景和反例。
3.3 任务管道的超时与上下文生命周期
技能执行最担心的是“悬挂”:模型已经发起了工具调用,但技能内部因为网络问题迟迟不返回,用户界面就一直卡在加载状态。我在SkillPipeline里统一用withThrowingTaskGroup做了超时控制。
public actor SkillPipeline { private let registry: SkillRegistry private let timeout: Duration public init(registry: SkillRegistry, timeout: Duration = .seconds(30)) { self.registry = registry self.timeout = timeout } public func run(intent: SkillIntent) async throws -> SkillOutput { guard let skill = await bestSkill(for: intent) else { throw SkillPipelineError.noMatchingSkill } let context = SkillExecutionContext.make(for: intent) return try await withThrowingTaskGroup(of: SkillOutput.self) { group in group.addTask { try await skill.execute(context: context) } group.addTask { try await Task.sleep(for: self.timeout) throw SkillPipelineError.timeout(skill.identifier) } let result = try await group.next()! group.cancelAll() return result } } }SkillExecutionContext的生命周期设计同样关键。我规定每次执行必须新建一个上下文实例,不允许跨调用复用。上下文里存放userID、sessionID、inputParameters以及技能执行过程中产生的临时记录,这样并发执行多个技能时不会互相踩数据。
4. 流式事件与SwiftUI状态同步:如何避免“UI不刷新”和“主线程崩溃”
任务管道能跑通只是第一步。SwiftUI项目里最折磨人的其实是事件同步:技能执行过程中有进度日志,有工具调用信息,有阶段转换,这些都要以流的方式送给UI,而UI更新又必须发生在主线程。我在这部分踩坑最深,也最想分享。
4.1 SkillEvent事件流设计
我定义了一个SkillEvent枚举,覆盖技能执行全生命周期的各类事件。
public enum SkillEvent: Sendable { case skillMatched(identifier: String) case progress(message: String) case toolCall(identifier: String, arguments: [String: String]) case partialOutput(text: String) case completed(output: SkillOutput) case failed(error: SkillError) }partialOutput是为了支持流式大模型输出——模型在生成回答的过程中,Token是逐批到达的。如果把整段回复累积到最后再一次性返回,聊天体验会变得非常迟钝。有了这个事件,技能层和大模型回调层可以在每次收到增量内容时立刻发出事件。这个设计在MessageKit风格的聊天界面里尤其好用。
4.2 用AsyncStream桥接运行时与视图
SwiftUI消费事件流我用的是AsyncStream。运行时层在执行技能时,把事件写入流中,Store端通过for await消费。这里有个非常容易踩的坑:AsyncStream的消费回调默认在自己所在的执行上下文里,如果你在一个后台任务里开启流,那么事件回调也发生在后台,这时候直接更新@Published属性,轻则UI不刷新,重则直接触发SwiftUI的并发检查崩溃。
我最终采用的方案是把Store整体隔离到@MainActor,并在进入for await循环前明确切换到主Actor上下文。
@MainActor final class AgentSessionStore: ObservableObject { @Published private(set) var messages: [ChatMessage] = [] @Published private(set) var isExecuting = false func execute(task: String) async { isExecuting = true defer { isExecuting = false } do { let stream = try await pipeline.runStreaming(taskText: task) for await event in stream { switch event { case .progress(let message): appendSystemMessage(message) case .partialOutput(let text): updateLastAssistantMessage(adding: text) case .completed(let output): appendAssistantMessage(output.text) case .failed(let error): appendSystemMessage("执行失败:\(error.message)") default: break } } } catch { appendSystemMessage("任务管道异常:\(error.localizedDescription)") } } }SwiftUI的Task { @MainActor in ... }也可以达到类似效果,但我更推荐直接在Store类型上标注@MainActor,因为这样类里的所有属性更新都被编译器盯住,从根上杜绝了后台写UI的可能。
4.3 会话恢复与事件重放
流式事件还有一个常被忽视的需求:当App进入后台再恢复,或者视图因为导航被释放再回来时,事件流早就断了。我的处理方式是在Store里维护一个eventStorage,每次消费事件后追加归档,Store重建时先重放历史事件,再继续订阅新事件流。虽然重放会带来几百毫秒的额外工作,但对体验的提升非常明显,用户不会觉得聊天记录“消失”了一部分。
5. 实测中的五次翻车:根因、复现路径与修复方案
这一节我不写理论,全部是实测中真正遇到过的翻车现场。每条我都会给出根因、复现路径和最终修复方案,希望能帮你少走几个弯路。
5.1 后台线程更新@Published导致的崩溃
第一次集成技能执行时,我让技能在一个Task.detached里跑网络请求,执行完直接在主Store里写@Published属性。结果在Xcode的并发调试模式下,App在写入属性时立刻触发运行时检查:Main actor-isolated property 'messages' cannot be mutated from a non-isolated context。复现概率非常高,几乎每个技能执行完都会崩一次。
修复方式就是上面提到的:把Store标记为@MainActor,技能层与Store之间的通信全部通过事件流进行,禁止技能直接持有Store引用。现在再回头看,这个坑的本质是没有遵守Actor隔离规则,SwiftUI的并发检查其实是在帮我们提前暴露问题。
5.2 技能上下文被并发复用导致参数错乱
有一个天气技能,我在上下文里放了一个“当前城市”字段。最初上下文对象是注册中心里的单例,两个用户同时使用时,A用户查完北京还没落库,B用户的“当前城市”已经被改成上海,结果A的结果被写成了上海天气。定位这个问题花了很长时间,因为两个用户的任务单看日志都正常,只有并发跑才出错。
修复很简单:SkillExecutionContext每次执行前调用make(for:)新建,绝不用单例。这条规则我写进了团队的代码规范,也在协议文档里用粗体标注:上下文必须短生命周期。
5.3 非幂等技能被模型重复执行
模型在推理过程中偶尔会重复发起同一个工具调用,尤其是超时后重试场景。如果技能是“创建日程”“发送通知”这类非幂等操作,重复执行会造成重复扣费或者重复下单。我在任务管道里加入了幂等控制:每条技能执行指令都带一个idempotencyKey,由调用方生成,执行前先查询执行记录表,命中就直接返回上次结果。
public struct SkillExecutionContext: Sendable { public let idempotencyKey: UUID public let userID: String public let sessionID: String public let input: [String: AnySendableValue] }这个设计在工具调用超时重试场景下尤其重要。没有幂等控制之前,一次网络抖动可能导致用户收到两条内容完全相同的日程提醒;加上之后,同样的idempotencyKey永远只会触发一次副作用。
5.4 超时任务没有取消底层网络请求
用了withThrowingTaskGroup之后,超时会抛错给上层,但底层技能内部的URLSession还在继续跑。最直接的后果是:用户看到“执行超时”,但技能实际上在两秒后偷偷完成了,还调用了系统能力,造成状态错乱。
修复方式是把取消动作真正传播下去。Task.sleep超时抛出后,TaskGroup会自动调用子任务的cancel(),但技能如果不去监听取消,一样没用。所以在技能基类里我加了一个检查点:
public func execute(context: SkillExecutionContext) async throws -> SkillOutput { try Task.checkCancellation() // 真正的业务逻辑 for try await chunk in stream { try Task.checkCancellation() } }checkCancellation这个调用看似多余,实际上是保证“超时即终止”的最后一环。没有它,前面的超时机制全是纸面功夫。
5.5 SwiftUI预览中使用真实网络技能导致卡死
SwiftUI的Canvas预览启动时会加载App环境并执行init,我第一次把注册中心初始化放到了App启动入口里,而注册中心里又注册了真实网络技能。结果预览时主线程被网络握手阻塞,整个Canvas直接白屏,完全没法调试UI。
修复方案很朴素:在预览环境里注入一套Mock技能。我用#if DEBUG分支判断,预览环境下只注册固定返回数据的假技能,真实技能只在正式运行环境注册。这个做法不仅解决了卡死问题,还让预览时的数据状态变得可控可预测,甚至可以直接在预览里展示技能流式输出的各种状态。
6. 可测试性设计与从“单技能”走向“多技能编排”的路径
项目走到稳定的基础版本后,我开始处理两个更偏长期的问题:怎么让测试真正有效,以及怎么把单技能调用升级成多技能编排。
6.1 用Mock技能覆盖协议分支
既然技能走协议,测试的核心就是构造协议不同的实现。我在测试Target里写了一批Mock技能,比如一个永远返回正常结果的MockWeatherSkill,一个故意抛错的MockFailingSkill,一个用来模拟超时的MockSlowSkill。注册中心和任务管道的单元测试全部基于这些Mock,不碰任何真实网络。
UI测试也受益于这套设计。通过在启动参数里传入“仅加载Mock技能”的环境变量,我可以让整个App跑在完全可控的数据环境里,每个聊天步骤都可以断言消息流的变化,再也不用担心外部服务不稳定导致的测试误报。
6.2 从单技能调用到多技能编排的演进
当技能数量多了之后,“单次意图选一个技能”就不够用了。Agent往往需要连续调用多个技能:先查日历找出空闲时间,再创建会议邀请,最后给参会人发通知。我在项目里增加了一个AgentOrchestrator,负责维护一个执行计划,按照计划逐条调用技能,并把每步结果作为上下文继续传给下一步。
public struct AgentStep: Sendable { public let skillIdentifier: String public let arguments: [String: AnySendableValue] } public struct AgentPlan: Sendable { public let steps: [AgentStep] }AgentOrchestrator的核心是“暂停与恢复”。每个步骤执行完后,事件流会发出一条stepFinished事件,编排器根据上一步的输出决定下一步的调用参数。在这一层,技能仍然是独立、可复用的,但多个技能之间有了统一的编排入口。未来的方向是让大模型直接生成AgentPlan,那就真正进入了自主Agent的范畴,而整个技能层协议不需要任何改动。
6.3 模块化与最终体会
技能数量继续增长后,我把每一个技能从主工程里拆到了独立的Swift Package里,包之间只依赖基础协议模块。这样单个包可以独立编译、独立测试,主App只保留注册动作。代价是包与包之间的版本管理会稍微复杂,但对于一个要长期演进的项目来说,这个代价非常划算。
最后分享一点我个人在实际操作中的体会:做SwiftUI的Agent技能编排,最容易犯的错误是一上来就追求大而全的框架,什么事件总线、插件系统、规则引擎全都上,结果基础流程都没跑通。我的建议是先让一个技能完整跑通“意图→匹配→执行→流式输出→UI刷新”的最小闭环,再逐步迭代协议和扩展编排能力。现在这套采用协议加注册中心的设计,改起来依然很轻松,每次新增能力都只需要写一个新技能类型,然后注册一行代码,整个App的其他部分完全不需要动。