首页 / 资讯中心 / 文章详情

SwiftUI Agent技能编排:从Function Calling到事件流的完整实践

SwiftUI Agent技能编排:从Function Calling到事件流的完整实践 ★ FEATURED ARTICLE
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的其他部分完全不需要动。
阅读完成 · 觉得有帮助?
咨询建站