SwiftUI中构建Agent-Skill智能任务执行中枢

发布时间:2026/10/13 10:28:01
SwiftUI中构建Agent-Skill智能任务执行中枢
最近两周我一直在重构一个SwiftUI项目模块名就叫“Agent-Skill”。说白了是给iOS端加了一个带技能包的智能代理层用户丢来一个任务代理负责拆解、调度、执行最后由具体的“技能”干完活再返回结果。做完之后我最大的感受是这套抽象把很多原本纠缠在一起的业务逻辑彻底打散了也让整个App从一个被动响应的表单工具变成了一个能主动承接复杂任务的执行中枢。这篇文章就聊聊我当时的拆解思路、落地的代码结构、踩过的坑以及哪些地方特别容易翻车。这个内容适合正在做SwiftUI应用的开发者尤其是想给App接入智能助手、自动化工作流或者准备把应用改造成“任务驱动”形态的人。就算你之前没怎么接触过Agent相关概念只要会基本的SwiftUI和异步编程也能跟着把核心骨架搭起来。“Agent-Skill”这个词机械一点解释Agent是负责接收请求、调度任务、整合结果的大脑Skill是具体干活的手脚。大脑不需要知道某个技能内部怎么实现的手脚也不需要关心请求从哪来。我把这个思路实现成Swift协议和SwiftUI状态管理之后整个项目的复杂度一下子降了一个量级。1. 需求分析与核心思路拆解1.1 为什么要用SwiftUI做客户端载体这次选SwiftUI而不是UIKit最直接的触发点是状态驱动UI。我的应用界面里充斥着一堆异步任务状态发起请求、等待结果、结果返回后刷新列表、任务失败后展示错误提示。如果用UIKit来做委托模式、闭包回调、数据源更新会散落一地每加一个状态都要小心翼翼地去同步界面。SwiftUI把界面看作状态函数界面只是随数据自动变化的渲染层。举个例子代理执行技能时从开始到结束界面只需要绑定一个“任务状态对象”enum TaskPhase { case idle case running case completed(Codable) case failed(String) }界面只需要关心这个枚举不需要关心背后的调度逻辑。这种思维模型特别契合多技能、异步、长流程的场景。Agent-Skill这套结构天然是异步嵌套的用SwiftUI的ObservableObject加Published属性代理执行每个技能时任务状态自动触发界面刷新不会出现“忘了reloadData”这种经典问题。当然SwiftUI不是万能的iOS版本碎片化、复杂列表性能、某些私有API兼容性都是现实问题。但就Agent-Skill这个具体场景而言状态驱动刷新、跨平台复用、快速原型迭代这些优点非常匹配。我之前用UIKit写过一版原型花在刷新列表和同步数据上的时间比业务逻辑还多换到SwiftUI之后同样的功能代码量明显少一圈维护起来也更轻松。1.2 Agent-Skill这套抽象到底在解什么问题很多人一提Agent就想到聊天机器人其实我需要的是一个更通用的“任务执行中枢”。场景是这样的用户可能说一句话比如“帮我把这段文章做个总结”也可能是“把最近几天未读邮件整理成清单”。系统需要跟着拆解任务、选择合适的技能、执行并返回结果。如果把这些功能全耦合在一起代码会非常难维护。所以我画了两条清晰的边界Agent只负责调度和组合不关心技能的内部细节。每个Skill只负责一类明确的小任务对外只暴露“我能处理什么任务”和“给我什么输入能干活”。做成协议之后新技能加入项目只需要实现一个协议然后注册进技能表完全不需要改Agent主流程。这也是我想重点讲的部分因为很多同学做所谓Agent最后发现还是if-else一大堆那其实已经违背了Agent-Skill的初衷。再往深处看这个抽象本质上等价于一个可插拔的插件系统只不过插件不是UI组件而是执行单元。做成这样之后可以给每个技能做单元测试可以并行调度也可以做优先级控制。对一个要持续迭代的项目来说这种健康度非常宝贵。2. 架构设计与关键技术选型2.1 四层架构依赖单向流动架构上我分了四层UI层负责展示和捕获用户意图。调度层Agent调度器负责理解任务、组织技能调用、汇总结果。技能层各类Skill实现定义清楚输入输出。基础设施层网络、存储、模型解析等通用能力。UI层订阅调度层的事件流事件流里包含任务状态、技能执行结果。调度层不持有UI对象只发事件。技能层只依赖基础设施层不依赖调度层。依赖方向是单向的非常容易测试。这种分层一开始看起来有点过度设计但真正把技能数量从2个扩到5个、8个的时候好处就出来了。比如技能层需要访问网络配置网络配置放在基础设施层技能注册时直接注入不会出现一个技能偷偷去拼接请求URL的脏代码。调度器也保持得很干净它只做“找技能、排序、执行、失败重试”这几件事。2.2 技能协议把“能干什么”和“怎么干”拆开技能协议是整套代码的灵魂。我把它设计成下面这样// 技能输入输出 protocol SkillInput { var rawContent: String { get } var metadata: [String: String] { get } } protocol SkillOutput { var text: String { get } var confidence: Double { get } var requestID: String { get } } // 技能协议 protocol AgentSkill: AnyObject { var identifier: String { get } var displayName: String { get } var supportedTaskKinds: SetString { get } var priority: Int { get } func canHandle(task: TaskPayload) - Bool func execute(with input: SkillInput) async throws - SkillOutput }这里有三件事特别重要。第一canHandle和execute分开。执行之前先问一遍“你行不行”避免调度器猜错技能导致运行时异常。而且canHandle是同步的调度器可以把它当作一个廉价的过滤器先过滤出一批候选技能。第二execute必须是异步的。Swift的async/await让代码非常干净技能内部可以再进行网络、文件、模型推理等耗时操作。有了async/await技能执行中的每个步骤都清晰可见也不会因为闭包嵌套导致阅读困难。第三返回值里有confidence。这对组合技能很关键。比如摘要技能置信度不足时调度器可以调用二次处理技能来兜底。虽然这个字段在早期没有实际用处但把接口设计好之后后续做兜底、灰度、反馈收集都方便。2.3 调度器实现actor串行更省心调度器的核心逻辑比较简单接收TaskPayload从技能注册表里找出所有能处理该任务的技能按优先级排序逐个执行。如果某个技能失败记录错误并尝试下一个。最终把最佳结果或全部结果返回。struct TaskPayload { let kind: String let input: SkillInput let userID: String let timestamp: Date } enum AgentError: Error { case noSkillFound(TaskPayload) case allSkillsFailed(TaskPayload, [Error]) } actor AgentScheduler { private let registry: SkillRegistry private let logger: AgentLogger init(registry: SkillRegistry, logger: AgentLogger) { self.registry registry self.logger logger } func process(_ task: TaskPayload) async throws - SkillOutput { var candidates registry.skills.filter { $0.canHandle(task: task) }.sorted { $0.priority $1.priority } guard !candidates.isEmpty else { throw AgentError.noSkillFound(task) } var lastError: Error? for skill in candidates { do { let output try await skill.execute(with: task.input) logger.log(技能执行成功: \(skill.identifier)) return output } catch { lastError error logger.log(技能执行失败: \(skill.identifier), error: \(error)) } } throw AgentError.allSkillsFailed(task, [lastError].compactMap { $0 }) } }我特意用actor包装调度器因为技能执行中可能修改共享状态actor可以无锁地保证串行访问。这在Swift并发模型下是很顺手的选择。刚开始我用了class加锁后来换actor代码量减少一大截而且不太容易出竞态。2.4 注册表与技能生命周期注册表没什么高深技术但需要设计成线程安全的容器。我用了简单的方式final class SkillRegistry { private var storage: [String: AnyAgentSkill] [:] private let queue DispatchQueue(label: skillRegistryQueue) func register(_ skill: AnyAgentSkill) { queue.sync { storage[skill.identifier] skill } } var skills: [AnyAgentSkill] { queue.sync { Array(storage.values) } } }使用AnyAgentSkill做类型擦除可以简化调用方代码但如果你项目里技能类型比较统一也可以直接存储协议类型。这里额外要处理的是技能生命周期技能内部如果携带共享缓存或模型资源注册时最好有预加载机制否则第一次调用会特别慢。我最初在翻译技能里踩过这个坑第一次执行时模型加载卡了两秒后来改成注册时预初始化资源才解决。订阅与取消订阅的逻辑在SwiftUI里可以借助.onAppear和.onDisappear管理但我的场景比较简单ViewModel持有调度器引用即可不需要复杂的订阅生命周期。3. 实操过程与核心实现3.1 工程初始化与并发模式选择我用的是新版本的开发环境部署目标定在iOS 17SwiftUI原生框架。如果工程比较旧建议至少升级到iOS 16因为很多新API和NavigationStack相关功能用起来更顺手。部署目标定得太旧SwiftUI可能缺很多现成控件。工程初始化步骤新建App项目模板选App。开启严格并发检查把Target的Swift Language Version设为6。引入依赖管理。我这里没依赖第三方库纯Swift标准库完成全部逻辑减少供应链风险。第一个版本不要急着引入大依赖先把Agent-Skill骨架跑通再考虑用第三方框架增强性能或方便调试。Swift 6的严格并发模式一开始可能有一堆报错但认真修完会发现代码更安全了尤其对于actor和异步调用这种场景。3.2 统一消息模型让界面和调度层说同一种语言在实际项目里Agent处理的是“消息流”。我定义了一个统一的消息模型struct ChatMessage: Identifiable, Codable, Equatable { let id: String let role: MessageRole let content: String let timestamp: Date var metadata: [String: String] } enum MessageRole: String, Codable { case user case agent case system case skill }这个模型在UI层和调度层都会用到。之所以加role字段是为了让聊天界面能区分用户输入、代理回答、技能执行中的中间提示。技能执行时产生的中间状态也作为特殊消息呈现这样用户可以直观看到代理正在执行哪个技能。假如技能是一个长耗时任务中间提示消息可以先推送到界面上用户看到“正在总结中”就不会觉得App卡死了。这里有个小细节ChatMessage里的metadata字段我用来存一些非展示数据比如置信度、任务ID、耗时。这样调试时可以快速追溯是哪次请求出了问题界面展示时又可以忽略这些字段。3.3 写三个技能摘要、待办抽取、图片文字识别我写了三个技能作为示例足够说明问题。第一个是文本摘要技能final class SummarizerSkill: AgentSkill { var identifier: String { summarizer } var displayName: String { 文本摘要 } var supportedTaskKinds: SetString { [summary, summarize] } var priority: Int { 10 } func canHandle(task: TaskPayload) - Bool { supportedTaskKinds.contains(task.kind) } func execute(with input: SkillInput) async throws - SkillOutput { // 真实场景会调用模型推理这里简化处理 let truncated String(input.rawContent.prefix(100)) return DefaultSkillOutput( text: truncated ..., confidence: 0.8, requestID: input.metadata[requestID] ?? ) } }真实项目中摘要技能内部会调用训练好的模型核心是抽象边界技能内部怎么实现调度器完全不关心。为了演示多层抽象我又写了一个待办抽取技能用来把文本里看起来像待办事项的行抽取出来final class TodoExtractorSkill: AgentSkill { var identifier: String { todo_extractor } var displayName: String { 待办抽取 } var supportedTaskKinds: SetString { [todo, list_action] } func canHandle(task: TaskPayload) - Bool { supportedTaskKinds.contains(task.kind) } func execute(with input: SkillInput) async throws - SkillOutput { let lines input.rawContent.split(separator: \n) let todos lines.filter { $0.hasPrefix(- [ ]) || $0.range(of: #^\d\.#, options: .regularExpression) ! nil } let jsonText todos.map { String($0) }.joined(separator: \n) return DefaultSkillOutput(text: jsonText, confidence: 0.9, requestID: input.metadata[requestID] ?? ) } }第三个技能是图片文字识别。因为SwiftUI本身不提供图像处理能力它需要依赖第三方框架或系统API我在示例里用占位代码表示实际项目里会结合Vision框架或云端服务。这里不展开具体实现重点是技能内部可以封装任意复杂度。写技能时要注意技能应当尽量无状态任务之间不共享可变对象。因为调度器可能并发处理多个任务有状态技能会出现数据串线的问题。我一开始没有严格遵守后来在并发测试中暴露了问题改成了无状态设计。虽然AgentSkill协议里我写的是AnyObject但每个实例的属性要尽量只读。3.4 任务分类与负载解析器任务分类是调度前的关键一步。我写了一个TaskClassifier它的职责是根据用户输入生成TaskPayloadenum TaskKind: String { case summary case todo case ocr } struct TaskClassifier { func classify(_ text: String) - TaskKind { if text.contains(待办) || text.contains(todo) || text.contains(清单) { return .todo } else if text.contains(总结) || text.contains(摘要) || text.contains(提炼) { return .summary } else if text.contains(文字识别) || text.contains(OCR) { return .ocr } return .summary } }为什么把分类逻辑单独拎出来因为技能层不应该做模糊匹配。技能只认task.kind这是明确的枚举值从自然语言到任务类型的转换集中在入口层后续想换成自然语言模型来做也可以不会影响技能协议。负载解析器负责把输入文本和额外参数打包成SkillInputstruct InputPayloadParser { func makeInput(from text: String, requestID: String) - DefaultSkillInput { DefaultSkillInput( rawContent: text, metadata: [ requestID: requestID, source: user_input, createdAt: ISO8601DateFormatter().string(from: Date()) ] ) } }metadata统一用字符串字典好处是序列化、调试、跨模块传递都方便。坏处是类型安全弱一点但初期迭代很快我优先选择速度。3.5 SwiftUI界面接入与状态驱动刷新为了让SwiftUI能使用调度器我把调度器包装成ObservableObjectMainActor final class AgentViewModel: ObservableObject { Published var messages: [ChatMessage] [] Published var isProcessing false private let scheduler: AgentScheduler private let classifier TaskClassifier() private let parser InputPayloadParser() init(scheduler: AgentScheduler) { self.scheduler scheduler } func sendUserMessage(_ content: String) async { isProcessing true messages.append(ChatMessage( id: UUID().uuidString, role: .user, content: content, timestamp: Date(), metadata: [:] )) let taskPayload TaskPayload( kind: classifier.classify(content).rawValue, input: parser.makeInput(from: content, requestID: UUID().uuidString), userID: demo-user, timestamp: Date() ) do { let output try await scheduler.process(taskPayload) messages.append(ChatMessage( id: UUID().uuidString, role: .agent, content: output.text, timestamp: Date(), metadata: [confidence: String(output.confidence)] )) } catch { messages.append(ChatMessage( id: UUID().uuidString, role: .system, content: 处理失败\(error.localizedDescription), timestamp: Date(), metadata: [:] )) } isProcessing false } }界面部分用一个简单的聊天流布局。用户输入任务、点击发送、消息添加到列表整个界面由messages数组驱动struct AgentChatView: View { StateObject private var viewModel: AgentViewModel State private var inputText var body: some View { NavigationStack { VStack { ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(viewModel.messages) { message in MessageBubble(message: message) } } .padding() } HStack { TextField(输入任务..., text: $inputText) .textFieldStyle(.roundedBorder) Button(发送) { let text inputText inputText Task { await viewModel.sendUserMessage(text) } }.disabled(viewModel.isProcessing) }.padding() } .navigationTitle(Agent Demo) } } }这里最容易犯的错误是直接在Button里写同步代码把sendUserMessage调用放进去而不包Task。由于sendUserMessage是异步函数必须用Task包装。Swift 6编译器在严格模式下可能直接报错但很多老项目没有开启严格模式所以很容易漏。MessageBubble组件也可以做得更细致一点比如显示置信度struct MessageBubble: View { let message: ChatMessage var body: some View { HStack { if message.role .user { Spacer() } VStack(alignment: .leading, spacing: 4) { Text(message.content) .padding() .background(roleBackground) .cornerRadius(12) if message.role .agent { Text(置信度\(message.metadata[confidence] ?? -)) .font(.caption) .foregroundColor(.secondary) } } if message.role .agent || message.role .system { Spacer() } } } private var roleBackground: Color { switch message.role { case .user: return .blue.opacity(0.2) case .agent: return .green.opacity(0.2) case .system: return .orange.opacity(0.1) case .skill: return .gray.opacity(0.1) } } }整个界面非常精简但已经足够展示Agent-Skill的核心流程用户输入任务、分类、调度、技能执行、返回结果、UI刷新。整条链路是通的后续扩展新的技能界面几乎不用改。4. 常见问题与排查技巧实录4.1 主线程更新与异步时序问题SwiftUI要求UI更新必须在主线程完成。因为ViewModel被标记为MainActor所以messages发送后界面会更新这没问题。真正的坑在于技能内部的耗时操作如果不放到后台就会卡住整个调度器。举例我把文本摘要技能的模型推理写成了同步阻塞调用结果在模拟器上测试时调度器一执行技能UI就卡住。后来把推理改为Task.detached或者将模型推理放入专门的执行队列func execute(with input: SkillInput) async throws - SkillOutput { let result try await Task.detached(priority: .userInitiated) { // 长耗时推理 Self.model.summarize(input.rawContent) }.value return DefaultSkillOutput(text: result, confidence: 0.85, requestID: input.metadata[requestID] ?? ) }注意Task.detached会创建独立的并发执行上下文如果内部修改外部共享状态会有危险。所以我倾向于让技能实现内部不依赖外部可变状态最多使用线程安全的缓存对象。4.2 技能匹配不准确导致调度混乱canHandle的判断逻辑如果写得太宽松会出现多个技能响应同一任务的情况。我在调试待办抽取时发现用户输入“帮我整理一下待办事项”会被摘要技能误判因为摘要技能的canHandle里我用了contains关键字而“整理”和“帮我”会命中错误规则。解决方案是给任务分类一个更严格的顺序并且在技能层只使用task.kind判断避免在canHandle里做文本模糊匹配。任务分类尽量在入口层完成技能层只处理明确类型。4.3 并发冲突与优先级控制当两个任务同时到达时actor调度器会串行处理。这对大多数用户场景够用了但也意味着第二个任务必须等待。如果产品要求高并发可以把技能执行放在独立的task group中再在Actor内部只做汇总func processGroup(_ tasks: [TaskPayload]) async throws - [SkillOutput] { try await withThrowingTaskGroup(of: SkillOutput.self) { group in for task in tasks { group.addTask { try await self.processWithoutGlobalState(task) } } var results: [SkillOutput] [] for try await result in group { results.append(result) } return results } }这里优先级的控制也需要小心。我习惯给高交互技能设一个相对高的priority批处理技能设低优先级这样调度器在排序时可以保证交互场景的流畅感。如果优先级不控制好可能出现用户点“总结”却等了很久因为后面有个大批量OCR任务在前面跑。4.4 开发调试tips再聊几个实操细节。第一SwiftUI预览里跑网络请求很容易失败因为预览进程的沙箱网络限制很严。我用技能模块做开发时把网络请求全部mock掉预览只跑本地逻辑真机再启动网络。第二AgentScheduler的日志特别重要。我在scheduler里加了ConsoleAgentLogger打印每次技能选择、执行时长和错误原因。开发期没有日志遇到调度错误只能靠猜加了日志之后定位问题快很多。第三协议中的priority字段我建议设计成可配置。默认技能定义里有一个值但在注册时可以通过装饰器覆盖。这样不同环境下可以调整同一技能的执行优先级而不改技能内部代码。4.5 问题速查表问题现象原因解决思路UI卡死点击发送后界面冻结技能内部同步阻塞推理改成Task.detached或独立队列执行耗时操作技能匹配错误任务被错误技能处理canHandle基于文本模糊判断入口层做严格分类技能层用task.kind并发串线返回结果张冠李戴技能内部有共享可变状态技能设计成无状态每个执行使用独立参数内存升高长时间使用后内存上涨技能缓存没有释放使用LRU缓存或主动清理策略调度死锁异步调用互相等待在同步代码里等待异步结果全链路使用async/await不要用dispatchSemaphore阻塞协调首次调用慢第一个任务特别卡技能注册时没有预加载资源注册阶段做模型预加载或延迟时显示进度提示结果顺序错乱两个任务返回顺序颠倒并发执行后按完成时间追加给任务附加序号按序号排序后再展示5. 扩展方向与个人体会做完整套骨架我最大的体会是Agent-Skill这套抽象不是锦上添花而是真的把业务复杂度打散了。以前加一个新功能要改动主流程现在只要写一个技能类注册一下界面甚至都不用动。这种可插拔的体验开发中非常舒服。如果想继续扩展我觉得有这几个方向特别有潜力技能组合编排不只执行单个技能而是通过一个编排脚本串起来比如“先提取待办再按优先级排序最后生成清单消息”。技能远程配置把技能清单做成远程可配置服务端下发新技能包客户端热更新。技能性能度量统计每个技能的耗时、失败率、输入的token消耗做成仪表板。多模态输入目前主要是文本可以扩展成图片、语音等输入类型。最后再分享一个小技巧在Skill协议里加一个version字段升级技能时看版本号就知道该不该重新校准结果。这个小字段在我后来做灰度对比时帮了大忙。整体项目做完从架构到部署我用了大约一周时间。其中半数时间花在调试actor和SwiftUI的交互上剩下时间都在打磨技能协议。如果说有什么值得新手注意的就是先把调度器跑通再慢慢丰富技能不要一开始就堆功能。等骨架稳了后续所有技能都像往货架上摆商品一样简单。