SwiftUI中的Agent+Skill框架:摆脱if-else,构建可扩展的任务调度系统

发布时间:2026/10/13 16:49:49
SwiftUI中的Agent+Skill框架:摆脱if-else,构建可扩展的任务调度系统 项目名我起了个SwiftUI-Agent-Skill翻译过来没啥歧义一套跑在SwiftUI应用里的Agent技能框架。Agent在这里不是玄学就是一个负责拆解用户意图、派发任务、汇总结果的调度核心Skill则是挂在它下面的一个个独立能力模块比如“记一笔”“算个数”“查个状态”“生成一段文案”。我折腾这套东西的出发点很直接很多App里的所谓智能功能到最后都是写死的一长串if-else逻辑一多就变成一坨谁也碰不了的面条代码而SwiftUI本身又是状态驱动、视图可以跟着Published实时刷新的两者叠加之后我发现完全可以把“Agent调度”和“SwiftUI响应式刷新”打通做出一套新需求过来只需要新增一个Skill模块就能接上的框架。这篇博文适合那些已经在用SwiftUI、又想给自己的App上“AI增强”或者“任务编排”能力的开发者我会把协议设计、调度流程、状态管理、技能注册这些细节全部拆开讲还会把跑通之后遇到的高频坑一起整理出来。1. 项目剖析为什么需要一套Agent技能框架1.1 传统流程图式开发在移动端的痛点很多人在做“智能助手”类需求时第一版一般都是这么写的用户输入一句话先用关键字判断意图命中“提醒”就跑提醒模块命中“天气”就跑天气模块命中“计算”就跑计算模块。代码写起来也直接无非是if input.contains(提醒) { ... } else if input.contains(天气) { ... }但撑到五六个模块之后问题马上暴露每次新增功能都要改动主流程的if-else主文件会不断膨胀。各种技能模块被“并列”塞在一个函数里它们的输入输出完全没有统一规范。调试时很难判断“用户意图”到底落到哪一步状态不可见。想在界面上展示“当前Agent在做什么”几乎要额外再写一份状态同步代码。这就是典型的路由和业务逻辑耦合在一起。我在做第一版SwiftUI-Agent-Skill时目标很明确让路由逻辑和技能逻辑彻底分开并把Agent当前的执行状态暴露成SwiftUI可以直接观测的属性让视图层毫不费力地跟着任务进度刷新。1.2 AgentSkill拆解之后的变化把Agent当做一个独立的协调者之后整个结构就清晰了。Agent只关心三件事接收用户输入。找到当前能处理这个输入的那个Skill。调用Skill并拿到结果把结果交还给视图层。Skill则反过来只做一件事定义自己擅长处理什么类型的意图以及当Agent把任务交给它的时候要如何执行。它既不知道也没有必要知道别的Skill是否存在更不需要管界面上怎么展示。这种拆解方式最大的变化体现在“扩展性”上。假设产品经理下个月说要在App里加一个“生成旅行计划”的新能力在老架构里我要去改路由层的判断分支而在SwiftUI-Agent-Skill里我只需要新建一个TravelPlanSkill实现统一协议后注册进Agent剩下的路由、调度、状态反馈全部自动生效。对我来说这个才是Agent模式的真正价值它不是帮你“自动思考”而是帮你把一大堆模块的管理成本降下来。2. 核心设计协议怎么拆状态怎么管2.1 技能的抽象一个协议覆盖所有扩展Skill的接口我反复调过几次最后收敛成一个比较稳定的协议。先看代码import Foundation struct AgentContext { var userID: String var appState: [String: Any] } struct AgentResult: Sendable { let text: String let data: [String: Any]? } protocol AppSkill: AnyObject, Sendable { /// 全局唯一标识 var skillID: String { get } /// 在列表里的展示名 var displayName: String { get } /// 排序权重越小越靠前 var order: Int { get } /// 判断这个技能能否处理输入意图 func canHandle(_ intent: String) - Bool /// 专属执行入口 func execute(_ intent: String, context: AgentContext) async throws - AgentResult }这里有一个很重要的设计选择canHandle和execute接收的都是原始字符串intent而不是已经被解析好的结构化意图。早期我试过让Agent先做一遍NLP解析把用户输入解析成一个意图结构体再往下传结果发现每个Skill对“意图”的理解是不一样的强行统一解析反而会把脏活累活都堆在Agent身上。现在这个设计相当于把“对输入的理解”完全下沉到Skill内部Agent只负责把原话传下去能者多劳。如果你觉得“能不能处理”用布尔值不够精确也可以升级成优先级评分机制比如返回0到100的分数由Agent选择分最高的那个Skill。这个我在后面会专门讲第一版直接用布尔就够了。2.2 调度者的定位Agent不写业务逻辑Agent核心我实现成了一个MainActor的ObservableObject这样它能天然保证所有状态变更都在主线程完成视图刷新不会出现随机黑屏或者数据不同步的问题。import SwiftUI enum AgentPhase: Equatable { case idle case processing case finished case failed(String) } MainActor final class AgentCoordinator: ObservableObject { Published private(set) var skills: [AppSkill] [] Published private(set) var phase: AgentPhase .idle Published private(set) var resultText: String private var context AgentContext(userID: local-user, appState: [:]) /// 注册技能重复注册同一个skillID会被忽略 func register(_ skill: AppSkill) { guard !skills.contains(where: { $0.skillID skill.skillID }) else { return } skills.append(skill) } /// 接收用户输入进入调度流程 func send(_ input: String) async { phase .processing resultText let sortedSkills skills.sorted { $0.order $1.order } guard let target sortedSkills.first(where: { $0.canHandle(input) }) else { phase .failed(当前没有技能能处理这句话试试换个说法) return } do { let result try await target.execute(input, context: context) resultText result.text phase .finished } catch { phase .failed(error.localizedDescription) } } }这段代码里最核心的调度就一个first(where:)把技能按order排序然后找到第一个能处理当前输入的Skill。为什么用first(where:)而不是遍历所有技能最后挑一个因为实际使用中“排在前面的技能”往往代表用户更常用的能力而且一旦命中就可以直接跳过后续判断性能也好一些。AgentPhase是重头戏它把Agent当前状态暴露给视图层。SwiftUI里你只需要监听phase就能自然地在界面上显示转圈、完成文案、错误提示不需要额外写状态机。2.3 状态可见让SwiftUI随时知道Agent在做什么Skill是动态注册进Agent的所以UI上有一个很实际的诉求怎么把“当前一共装了几个技能、每个技能叫什么”直接展示在界面上。老办法是维护一个平行的状态数组每次注册技能时同步更新两个地方很容易漏。我的方案是让skills直接作为Published属性Agent这边一变SwiftUI的List自动跟着刷新。struct SkillListView: View { ObservedObject var agent: AgentCoordinator var body: some View { List(agent.skills, id: \.skillID) { skill in HStack { Text(skill.displayName) Spacer() Text(优先级 \(skill.order)) .font(.caption) .foregroundColor(.secondary) } } } }状态可见还体现在“中间过程”里。比如某个Skill执行要3秒用户最直接的感知就是“卡住了”。用AgentPhase.processing去驱动一个进度条或者ProgressView用户至少知道系统在跑。这个小细节看起来不起眼但真实体验相差很大。我建议任何接这套框架的人界面一定要对processing状态做视觉反馈千万不要等到finished才更新。3. 实操落地在SwiftUI里跑通一个多技能Agent3.1 初始化框架与技能注册在真实项目里技能注册的时机我建议放在App启动阶段而不是某个视图的onAppear里。因为SwiftUI的视图在导航栈前进后退时会被加载很多次如果注册逻辑写在onAppear里配合register方法里的guard逻辑虽然不会重复注册但每次进页面都会执行一遍无谓的遍历。放到启动阶段一次性注册完毕后面只管调度。这里给出一个简单的组装入口main struct AgentApp: App { StateObject private var agent AgentCoordinator() var body: some Scene { WindowGroup { ContentView() .environmentObject(agent) .task { agent.register(NoteSkill()) agent.register(CalculatorSkill()) agent.register(WeatherSkill()) } } } }把AgentCoordinator用environmentObject传下去技能注册用.task一次性完成这样整个App范围内所有视图都能访问同一个Agent实例也能拿到同一个技能列表。如果之后你需要从远程下发技能配置只需要把register的调用换成异步加载返回后的批量注册接口完全不需要动。3.2 用ObservedObject把任务流转接到界面ContentView里我维护了一个输入框、一个发送按钮以及一个结果展示区域。整体逻辑很直白struct ContentView: View { EnvironmentObject var agent: AgentCoordinator State private var inputText var body: some View { VStack(spacing: 16) { HStack { TextField(输入一句话试试, text: $inputText) .textFieldStyle(.roundedBorder) Button(发送) { submit() } .disabled(agent.phase .processing) } .padding() // 技能列表 SkillListView(agent: agent) // 状态区 StatusPanel(phase: agent.phase, resultText: agent.resultText) Spacer() } .padding() } private func submit() { let content inputText inputText Task { await agent.send(content) } } }这里我特地在发送前把inputText先取出来再清空输入框然后用Task把send丢到异步上下文里。为什么不能直接传inputText因为SwiftUI的State绑定更新存在延迟你在点击瞬间直接传很可能拿到的是旧值。这个小坑我踩过一次后来学乖了凡是要从输入框取值给异步用的先赋给一个局部变量。3.3 给技能排序与任务路由多技能并存时order字段就变得非常重要。比如我注册了NoteSkill和CalculatorSkill两者都可能认为自己能处理“计算明天的花销”这类带有文本和数字的话。如果NoteSkill的canHandle写得太宽它会抢在所有技能前面把任务吃掉。所以我给每个Skill的order设定了一个默认策略专项技能比如计算、天气排前面order设为0。兜底技能比如“记下来”排后面order设为100。当两个技能都想抢同一个意图时靠order做仲裁。如果你碰到了“那个技能经常抢任务”的抱怨不要急着去改需求先看看是不是order配得不够合理。给技能排序不是写死规则而是一种轻量的优先级路由。struct PriorityRule { static let high 0 static let normal 50 static let fallback 100 }实际使用中我发现order用固定的三档就够不需要搞得很细。因为SwiftUI-Agent-Skill这套框架适合的是“模块数量在二十个以内”的场景超过这个量你该考虑引入独立的意图解析服务了靠order做路由还是太粗糙。4. 两个技能模块的实现与细节整理4.1 结构分析与模块拆分我自己写的第一个演示技能是“记事”第二个是“计算”。选择这两个是因为它们能把文本匹配、业务逻辑、异步操作、错误处理四种情况都覆盖到。下面把两个技能分别拆开看你会更清楚一个合格的生产级Skill长什么样。4.2 记事技能把一句话保存成备忘录这个技能的核心有两点一是canHandle里判断关键词“记”“备忘”“提醒”二是execute里创建一个待办条目并返回成功文案。import Foundation final class NoteSkill: AppSkill { let skillID note let displayName 快速记事 let order PriorityRule.normal func canHandle(_ intent: String) - Bool { let lowered intent.lowercased() return lowered.contains(记) || lowered.contains(备忘) || lowered.contains(note) } func execute(_ intent: String, context: AgentContext) async throws - AgentResult { // 尽量从输入里提取真正要记的内容 let cleaned cleanNoteContent(intent) guard !cleaned.isEmpty else { throw SkillError.emptyNote } // 这里可以替换成自己的存储服务比如CoreData、SwiftData、网络接口 try await LocalNoteStore.shared.save(title: cleaned) return AgentResult( text: 已经帮你记下了\(cleaned), data: [note: cleaned] ) } private func cleanNoteContent(_ intent: String) - String { return intent .replacingOccurrences(of: 记, with: ) .replacingOccurrences(of: 备忘, with: ) .replacingOccurrences(of: note, with: , options: .caseInsensitive) .trimmingCharacters(in: .whitespacesAndNewlines) } } enum SkillError: LocalizedError { case emptyNote var errorDescription: String? { switch self { case .emptyNote: return 内容为空记不了东西 } } }这段代码初看很简单但有几个细节值得展开。第一cleanNoteContent这里只做最粗粒度的清洗如果用户说的是“帮我记一下明天开会”处理完会得到“帮我一下明天开会”语义并不完美。这里我没有做高深的NLP因为作为框架演示关键词匹配够用如果真要做建议把清洗逻辑抽成独立的TextPreprocessor组件Skill里只调用它方便后续升级。第二try await LocalNoteStore.shared.save()演示的是异步存储。如果存的是本地数据库建议把数据库操作放到子线程别在execute里直接同步操作主线程存储我这里用await就保证了不阻塞主线程。4.3 计算技能处理加减乘除和状态回显第二个技能是计算器它的canHandle要判断输入里是否出现数字和算术运算符execute再把表达式计算出来。import Foundation final class CalculatorSkill: AppSkill { let skillID calculator let displayName 计算器 let order PriorityRule.high func canHandle(_ intent: String) - Bool { return intent.contains() || intent.contains(-) || intent.contains(*) || intent.contains(×) || intent.contains(÷) || intent.contains(算) } func execute(_ intent: String, context: AgentContext) async throws - AgentResult { let expression extractExpression(from: intent) guard !expression.isEmpty else { throw SkillError.emptyExpression } let value try evaluate(expression) return AgentResult( text: 结果是\(value), data: [expression: expression, value: value] ) } private func extractExpression(from intent: String) - String { // 去掉“计算”“等于”“多少”等干扰词 let cleaned intent .replacingOccurrences(of: 计算, with: ) .replacingOccurrences(of: 等于, with: ) .replacingOccurrences(of: 多少, with: ) .replacingOccurrences(of: , with: ) .replacingOccurrences(of: ?, with: ) return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) } private func evaluate(_ expression: String) throws - Double { // 这里不要用String直接传给eval有安全风险 // 建议用一个简单的四则运算解析器 let parser ExpressionParser() return try parser.parseAndEvaluate(expression) } }在移动端做表达式计算有两点经验可以分享千万别直接把字符串塞给某个WebView去执行也没必要引入完整的脚本引擎。对于四则运算手写一个几十行的解析器足够用还可以精确控制错误分支。边界条件要提前想好除数为0、空表达式、连续运算符、括号不匹配任何一个没处理都可能在运行时直接崩掉。我在项目里把evaluate的结果用throws抛出去就是为了让Agent在do-catch里统一转换成failed状态而不是让App闪退。从这两个技能你能看到它们都在遵循同一个接口模式判断自己是否匹配执行任务返回结果或抛错。业务代码被约束在技能内部不污染外部路由这就是协议抽离开出来的直接收益。5. 高频问题与排查实录5.1 界面一直转圈不刷新这个我遇到得最多尤其是把Skill执行放到后台队列后execute返回时忘记切回主线程更新状态。我的解决方案是给AgentCoordinator加MainActor让全部状态变更都发生在主线程。可能有人会担心那执行一个耗时的AI请求不就把主线程卡死了吗不会MainActor修饰的是类里的属性和方法await后面的耗时调用并不会阻塞主线程真正回到主线程的只是后续的状态更新。5.2 技能重复注册SwiftUI的视图生命周期和普通App不太一样同一个页面可能反复进入。如果把register写在onAppear里哪怕有guard防重复也会多出很多无谓遍历。最稳妥的做法是把注册放在App初始化时一次性完成或者把它封装成一个专门的SkillRepository由仓库层保证幂等写入。我试过多次之后还是推荐后者尤其是技能数量多了以后集中管理比散落在一堆View里更清晰。5.3 内存上涨与循环引用Skill闭包捕获Agent上下文时容易形成引用环。比如我在做网络技能时喜欢在execute里写Task { [weak self] ... }但self本身被Agent持有闭包再强引用Agency链路一长就容易泄漏。后来我统一要求Skill内部凡是回调、闭包、异步上下文里用到外部对象一律使用弱引用或者传入值类型参数。这一点排查起来很隐蔽只能靠Instruments里的Leaks检查。5.4 任务被错配到错误技能canHandle写得过于宽松就会发生这种事。比如NoteSkill里前缀匹配了“记”用户说“记录一下我的银行卡号”它会优先被NoteSkill抢走但这句话可能更应该被“财务技能”处理。我把布尔判断改成优先级评分之后这个情况缓解了很多。下面是我整理的一张快速排查表现象定位思路处理方式列表里技能重复出现注册被多次调用在注册层做幂等校验或迁移到启动阶段集中注册点击发送后界面一直转圈异步状态更新脱离主线程确保AgentCoordinator是MainActor某个技能永远抢占任务canHandle范围太宽换成评分制或调低order权重页面返回后Mission状态残留上一次任务状态未重置send开头强制设置idle/processing内存持续上涨闭包强引用Agent或Skill改用weak self / 拆成值类型上下文5.5 一个被忽略的细节技能执行结果如何跨视图传递最后补一个容易被忽略的点。AgentResult数据我用的是text data字典而SwiftUI的绑定层拿到的往往是resultText。如果你的多个页面要用到同一个结果比如某个页面要展示计算明细另一个页面要展示摘要建议在AgentCoordinator里再加一个Published var lastResult: AgentResult?而不是让结果散落在各Skill的静态变量里。这样既保证数据有唯一的出口也让视图层订阅起来更干净。6. 从Demo到工程化的更完整思考6.1 单元测试与协议测试整套协议驱动的设计让我在测试上省了很多心。因为核心调度逻辑不依赖具体业务我可以为每一种状态变化写独立的单元测试。import XCTest testable import AgentSkillDemo final class MockSkill: AppSkill { let skillID: String let displayName: String Mock let order: Int private let handledText: String init(skillID: String, order: Int, handledText: String) { self.skillID skillID self.order order self.handledText handledText } func canHandle(_ intent: String) - Bool { return intent.contains(handledText) } func execute(_ intent: String, context: AgentContext) async throws - AgentResult { return AgentResult(text: mock result from \(skillID), data: nil) } } MainActor final class AgentCoordinatorTests: XCTestCase { func testRouteWithPrioritySkill() async { let agent AgentCoordinator() agent.register(MockSkill(skillID: low, order: 10, handledText: 任务)) agent.register(MockSkill(skillID: high, order: 1, handledText: 任务)) await agent.send(执行任务) XCTAssertEqual(agent.phase, .finished) } }这种测试的价值是当我以后想改动路由策略比如从first(where:)改成评分最高者取胜只要测试用例不变就能立刻验证旧行为是否被破坏。协议化设计让测试覆盖的不再是“某个具体功能”而是“Agent的调度契约”。6.2 可扩展的方向与限制SwiftUI-Agent-Skill目前能覆盖的场景主要是文本命令式交互、任务步骤有限、技能模块可枚举。如果你想让Agent支持多轮对话就需要给AgentContext增加历史记录字段并让Skill可以读取上下文如果你想让Agent支持“组合技能”比如先查询天气再根据天气安排行程那就要引入一个工作流引擎把Agent从“单步路由”升级成“多步编排”。这些扩展在我当前框架里没有全部实现但接口上已经留了余地。AgentResult里的data字典可以携带后续步骤的信息AgentPhase也可以扩展出更多状态比如waitingForInput。6.3 给新接入者的一些建议不要一开始就追求完美的NLP关键词匹配和轻量规则能跑通闭环最重要。给每个技能加上order从第一版开始就固定否则后期排序成本很高。把Skill的注册从视图层拿出去放到App生命周期或者专门的Repository里。在Skill里统一做错误处理不要让Agent去猜Skill内部发生了什么异常。尽量为每个Skill写一个最小可行性测试哪怕只是验证canHandle对输入的真假判断。如果你只是做内部工具型App这套框架的收益会特别明显如果是做面向消费者的产品你还需要在意图识别准确率和多轮对话上投入更多资源但底层的“Agent调度Skill技能树”思想依然可以沿用。最后说点实际的。这个项目做到后面反而是那些“状态不同步”“闭包泄漏”“重复注册”之类的小问题花了我最多时间协议设计本身反而一次成型。把Agent和Skill拆开之后我再也不需要在某个巨大的Service文件里翻几百行找路由逻辑了新技能加进去的时候工整得就像填表格。如果你手头也有一个越写越重的SwiftUI项目我建议你试试先抽一个最轻量的AppSkill协议把某一个功能模块改造成技能然后再让Agent去接管它你会很快感受到这套结构的舒服之处。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询