news 2026/10/21 13:27:58

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SwiftUI Agent技能编排:从Function Calling到事件流的完整实践

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的其他部分完全不需要动。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 13:27:55

AI率过高怎么办?5类工具帮你把论文改成真学术作品

最近总有人拿着刚生成的论文初稿来问我:“你说这AI率到底怎么降?我明明已经让它写了,交上去一查,还是高得离谱。”这类问题越来越多,但我发现大家其实问错了方向。AI率偏高,真正的问题并不是你用了AI&#…

作者头像 李华
网站建设 2026/10/21 13:27:40

SpringBoot+Vue+MySQL打造房屋租赁管理系统:从设计到部署

做一套房屋租赁管理系统其实没有想象中那么复杂,但也没想象中那么简单。老实说,这套系统最吸引我的点是"Info【可直接运行】"这几个字,全栈项目最怕的就是拿到源码跑不起来,环境问题、依赖冲突、数据库版本不一致&#…

作者头像 李华
网站建设 2026/10/21 13:20:52

Visdom源码包实战:PyTorch训练可视化与离线部署指南

简介:Visdom-master.zip 是面向深度学习研究者与 PyTorch 开发者的可视化工具源码包,适合需要实时监控训练过程、管理实验数据或进行二次开发的中高级用户。Visdom 提供交互式环境,可展示图像、文本、音频、图表等多种数据类型,与…

作者头像 李华
网站建设 2026/10/21 13:20:49

Java轻量级系统监控工具实战:CPU、内存、磁盘与网速实时监测

1. 为什么偏要自己写一个系统监控工具类我最早做系统监控的时候,第一反应也是去接现成的开源套件,比如 Prometheus 那套生态。但后来在实际项目里发现一个很典型的问题:很多时候我们的需求并不是“搭建一套完整的监控运维平台”,而…

作者头像 李华
网站建设 2026/10/21 13:19:34

重构技能表达:从静态标签到动态能力坐标系

1. “skills”不是标签,是能力坐标系的动态刻度最近在几个技术社区和职业发展类平台反复看到一个现象:大量简历、项目介绍、甚至开源仓库 README 里,赫然列着一行加粗的单词——skills。它通常以无序列表形式出现,后面跟着一串编程…

作者头像 李华
网站建设 2026/10/21 13:19:34

技能解析与结构化:从原始文本到可计算能力标签

我无法基于当前输入生成符合要求的博文。 原因如下: 输入中仅提供了项目标题 "skills" ,未提供任何实质性的【项目正文】、【关键词】或【摘要描述】; 所谓“相关热搜词”和“最新网络热词”字段为空,未给出具体词…

作者头像 李华