☰
SwiftUI中构建Agent-Skill智能任务执行中枢
2026/10/13 10:27:52 网站建设 项目流程

最近两周我一直在重构一个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: Set<String> { 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 17,SwiftUI原生框架。如果工程比较旧,建议至少升级到iOS 16,因为很多新API和NavigationStack相关功能用起来更顺手。部署目标定得太旧,SwiftUI可能缺很多现成控件。

工程初始化步骤:

  1. 新建App项目,模板选App。
  2. 开启严格并发检查,把Target的Swift Language Version设为6。
  3. 引入依赖管理。我这里没依赖第三方库,纯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: Set<String> { ["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: Set<String> { ["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,它的职责是根据用户输入生成TaskPayload:

enum 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,这是明确的枚举值;从自然语言到任务类型的转换集中在入口层,后续想换成自然语言模型来做也可以,不会影响技能协议。

负载解析器负责把输入文本和额外参数打包成SkillInput:

struct 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能使用调度器,我把调度器包装成ObservableObject:

@MainActor 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的交互上,剩下时间都在打磨技能协议。如果说有什么值得新手注意的,就是先把调度器跑通,再慢慢丰富技能,不要一开始就堆功能。等骨架稳了,后续所有技能都像往货架上摆商品一样简单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询