☰
用SwiftUI打造AI智能体技能管理:从技能协议到全链路实现
2026/10/14 9:39:47 网站建设 项目流程

提到 Agent,做 iOS 开发的朋友可能第一反应是网络代理那一套工具链,但这两年你再听到这个词,几乎都是指智能体。我在 SwiftUI 里写了一个项目,标题就叫 SwiftUI-Agent-Skill,说白了就是把“AI 智能体”的技能管理能力塞进一个原生 SwiftUI 应用里:用户在前端输入一句自然语言,Agent 拆解任务后去匹配并调用注册好的技能,然后把执行结果渲染回界面。这个项目最适合两类人:一类是已经在接各种大模型接口、想让 App 具备工具调用能力的开发者;另一类是刚开始接触 SwiftUI 状态管理、想找一个能落地的中型实战项目的朋友。这篇博文我会从设计思路、技能注册中心、界面绑定链路到踩坑记录,完整拆解整个项目。

1. 项目初衷与整体设计思路:为什么要把“技能”单独抽象成一层

1.1 没有技能抽象时,AI 功能会越写越乱

先说我最初踩到的坑。第一版做的是硬编码链路:界面上一个 TextField,用户输入“帮我查一下现在几点了”,我直接在按钮点击事件里写 if input.contains("几点"),然后走到一个函数里取时间;后来又加了一个“帮我记一条待办”的判断,接着是天气查询、汇率换算……大概加到第五个 if 的时候,整个文件已经没法看了。每一个新功能都要改同一块逻辑,界面代码、业务代码、数据处理全部搅在一起,测试也不好写,同事接手时只能顺着 case 一个一个翻。

后来我意识到,问题出在“技能”没有被当作一个独立概念。技能就应该像手机里的 App 一样,有一套标准的安装和调用协议。用户不会关心 App 内部是怎么实现的,他只看主屏幕上有没有这个图标、点开能不能解决问题。Agent 也是一样:它只需要知道“当前有哪些技能可用”“怎么把调用参数传给技能”“技能返回了什么东西”,至于技能内部是调了网络接口还是本地数据库,Agent 根本不关心。

1.2 给技能定义一个“契约”

所以在我的项目里,完整链路是这样:SwiftUI 界面接收用户输入 → 把输入交给 AgentEngine(智能体引擎) → AgentEngine 解析意图 → 查询技能注册表 → 找到匹配的技能并调用 → 技能执行完毕后返回结构化结果 → 结果发布到界面状态 → 界面自动刷新。

这个链路里最重要的是技能注册表,而技能注册表的核心是协议。我定义了一个叫 SkillProtocol 的协议,每个技能都必须实现这个协议才能注册进系统。协议里包含几个关键字段:技能的唯一标识符、技能名称、技能描述、接收的参数模型,以及一个 execute 方法。技能描述非常重要,因为 Agent 做意图匹配时依赖这个描述来理解“这个技能是干什么的”,描述写得越精确,路由命中率越高。

这里我用了一个生活化的类比来给团队解释:技能协议相当于外卖平台的“商家入驻合同”。平台不会替每家餐厅设计菜单,但每家餐厅都要遵守同一个配送和结算规则。Agent 就是外卖平台,技能就是商家,协议就是那份合同。合同定得合理,商家越多越繁荣;合同定得含糊,后面所有管理成本都会爆炸。

1.3 为什么选择 SwiftUI 而不是 UIKit 或跨平台方案

既然要做 Agent 技能管理这类偏交互的 Demo,SwiftUI 是当前最合适的选择。界面声明式描述带来的最大好处是状态驱动刷新:技能执行是异步过程,Agent 可能在后台思考、调用接口、拼接结果,这一系列状态变化在 SwiftUI 里只需要维护几个 @Published 属性,视图就会自动更新对应区域。用 UIKit 当然也能做,但你需要手动处理通知或代理回调来刷新界面,代码量和心智负担都会多不少。

还要说明一点,我没有在这个项目里引入任何第三方跨平台框架。原因是技能系统本身已经很依赖 Swift 的协议和泛型特性,再叠加额外的抽象层容易把问题复杂化。保持纯原生,每一层的问题都容易定位,而且对想学习源码的朋友更友好。

2. 核心概念拆解:AgentEngine、技能注册表、参数模型与状态机

2.1 AgentEngine 是什么:一切调度的大脑

AgentEngine 是整个系统的中枢。它不关心 UI 怎么画,也不关心某个技能内部是怎么实现的,它只做三件事:接收文本输入、解析意图、调度技能。

在具体实现上,AgentEngine 里维护着一个技能注册表,注册表本质上是字典,key 是技能的 id,value 是技能实例。当用户输入“帮我看看明天天气怎么样”,AgentEngine 拿到文本后先做预处理:小写化、去掉无意义的语气词、提取关键部分,再把提取结果和每个技能的描述做匹配评分,选得分最高的技能执行。这一段听着简单,真正做起来比想象中要花更多心思,因为用户不会总是用你预设的句式来说话,技能匹配的容错设计在第 4 部分会专门讲。

2.2 技能注册表:用字典管理所有能力

技能注册表我采用了一个独立的类 SkillRegistry,内部存储结构是 [String: any SkillProtocol]。这个类只负责三件事:

  • register(_ skill: any SkillProtocol):把技能加入字典
  • unregister(id: String) :按 id 移除技能(某些场景下会有用,比如权限变更)
  • findBestMatch(input: String) -> (any SkillProtocol)?:根据文本找到最合适的技能

为什么用字典而不是数组?因为 id 查找是 O(1),而 Agent 随时需要对技能做启停管理。用数组也能实现,但每次匹配都要遍历,项目里技能数量越多,性能就越不可控。我实测过在技能数量超过 50 个之后,字典方式的匹配耗时基本不变,数组方式已经开始出现肉眼可感知的延迟了。

2.3 参数模型:技能与外部世界的统一接口

每一个技能都声明自己需要什么参数,这些参数集合被建模成一个 Codable 结构体。以天气查询技能为例,它需要的参数是 city 和 date;以待办事项技能为例,它需要的参数是 action 和 content。

这么做有一个核心收益:参数校验可以前置。Agent 在调用技能之前,可以把用户输入里的关键信息抽取出来,尝试填充到这个参数结构体里。如果缺了必填字段,Agent 可以直接在界面上提示“请补充城市信息”,而不需要真正执行到技能内部才发现参数缺失。这种前置校验在小项目里可能感觉不明显,技能一旦复杂起来就非常关键。

2.4 技能状态机:从 idle 到 success 的闭环

技能执行不是一个瞬间动作,用户输入的指令被路由到技能后,要经过运行、等待、失败/成功等多个状态。我在项目里定义了一个枚举 SkillExecutionState:

  • idle:初始状态
  • running:正在执行
  • succeeded(result: String):执行成功,附带结果文本
  • failed(message: String):执行失败,附带错误信息
  • cancelled:被用户或上层主动取消

为什么要把状态建模得这么细?因为 SwiftUI 界面需要根据状态展示不同内容。running 时候显示 ProgressView,succeeded 后展示结果卡片,failed 时展示错误提示并给一个重试按钮,cancelled 时收起进度动画。如果状态只有一个“完成/未完成”,界面交互层次就会很单薄,体验也不够好。我还做了一个额外处理:每个状态切换都打一条带时间戳的日志,方便追踪任务是在哪一步卡住的。

3. 实操解析:从协议设计到技能注册中心落地的完整过程

3.1 先写协议:技能的“宪法”

在 Xcode 里新建一个 Swift 文件,命名为 SkillProtocol.swift,核心代码:

import Foundation protocol SkillProtocol: Identifiable { var id: String { get } var name: String { get } var skillDescription: String { get } associatedtype Parameters: Codable func execute(with parameters: Parameters) async throws -> SkillResult } struct SkillResult { let summary: String let detail: String? let timestamp: Date }

这里使用 associatedtype Parameters 是为了让每个技能自己决定参数结构,但对外统一收敛为 Codable。执行方法用 async throws 是因为技能内部几乎都会有网络请求或耗时操作,Swift 并发是必然选择。SkillResult 用来统一包装返回值,summary 是展示在列表里的简短结果,detail 是可以展开看的完整信息。

这里有一个我特意踩过的坑:命名冲突。项目里既有技能协议 SkillProtocol,又有执行结果 SkillResult,如果你同时引入了某些第三方库,容易出现类型名称冲突。所以我在实际项目里给协议加了前缀,比如 APPSkillProtocol。文章里为了讲解清晰还是用了短名字,实际编码时我会建议加上前缀。

3.2 技能注册中心的完整实现

SkillRegistry 的具体实现如下:

import Foundation actor SkillRegistry { private var skills: [String: any SkillProtocol] = [:] func register(_ skill: any SkillProtocol) { skills[skill.id] = skill } func unregister(id: String) { skills.removeValue(forKey: id) } func allSkills() -> [any SkillProtocol] { return Array(skills.values) } func findBestMatch(input: String) -> (any SkillProtocol)? { let lowercased = input.lowercased() var bestMatch: (skill: any SkillProtocol, score: Int)? for skill in skills.values { let score = matchScore(input: lowercased, description: skill.skillDescription.lowercased()) if let current = bestMatch { if score > current.score { bestMatch = (skill, score) } } else { bestMatch = (skill, score) } } guard let best = bestMatch, best.score > 0 else { return nil } return best.skill } private func matchScore(input: String, description: String) -> Int { let inputWords = Set(input.split(separator: " ")) let descWords = Set(description.split(separator: " ")) return inputWords.intersection(descWords).count } }

我把 SkillRegistry 设计为 actor,而不是普通的 class。理由有两个:第一,技能注册表可能被多个异步任务同时访问,比如用户连续输入多条指令、Agent 并行调度时,actor 能保证内部字典的线程安全;第二,actor 天然隔离状态,Swift 编译器会在你访问可变状态时强制你考虑并发语义,反而能避免不少隐蔽的 data race 问题。当然,代价是调用端要加 await,但这点代价在正确性面前完全值得。

3.3 一个最简技能:当前时间查询

接下来实现一个最简技能“当前时间查询”,用来验证整个链路:

struct TimeParameters: Codable { let format: String? } final class TimeSkill: SkillProtocol { var id: String { "time_skill" } var name: String { "当前时间" } var skillDescription: String { "查询当前日期和时间,支持返回格式化字符串" } typealias Parameters = TimeParameters func execute(with parameters: TimeParameters) async throws -> SkillResult { let formatter = DateFormatter() formatter.dateFormat = parameters.format ?? "yyyy-MM-dd HH:mm:ss" let now = Date() let summary = formatter.string(from: now) return SkillResult(summary: summary, detail: "当前时间: \(summary)", timestamp: now) } }

这个技能业务逻辑极其简单,但它验证了整个链路:注册、匹配、参数填充、执行、返回结果。我把这种小技能比作单元测试里的“冒烟用例”,先用最小成本打通全流程,再慢慢接入复杂的网络技能,定位问题会容易得多。

3.4 网络技能:天气查询的实现要点

天气查询技能比时间技能复杂在它要做网络请求、解析 JSON、处理失败情况。由于这个技能要访问某个第三方天气 API,我在项目里把 API 地址做成了配置项,方便替换。核心代码如下:

struct WeatherParameters: Codable { let city: String let date: String? } final class WeatherSkill: SkillProtocol { var id: String { "weather_skill" } var name: String { "天气查询" } var skillDescription: String { "查询指定城市当天的天气状况,包括温度、湿度、风力等信息" } typealias Parameters = WeatherParameters func execute(with parameters: WeatherParameters) async throws -> SkillResult { let city = parameters.city.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? parameters.city let date = parameters.date ?? "today" let urlString = "https://api.example.com/weather?city=\(city)&date=\(date)" guard let url = URL(string: urlString) else { throw SkillError.invalidURL } let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw SkillError.serverError } let weatherData = try JSONDecoder().decode(WeatherResponse.self, from: data) let summary = "\(parameters.city) \(date): 晴,气温 28℃,湿度 40%" return SkillResult(summary: summary, detail: weatherData.fullDescription, timestamp: Date()) } }

有两个细节在这里很重要。第一,URLSession 的超时策略一定要配置好,否则在弱网环境下技能会长时间卡在 running 状态,用户会以为 App 死了。第二,网络返回的 JSON 结构要单独建一个响应模型,不要在技能内部到处用字典取值,否则改接口字段时全部要跟着改,维护成本很高。我给天气技能的响应用例设置了 15 秒超时,用户等待进度条转了超过这个时间,AgentEngine 会直接判定为超时并进入 failed 状态。

3.5 待办事项技能:写操作的本地持久化

除查询类技能外,我还实现了写操作型的“待办事项”技能,把用户输入的待办内容经过 Agent 解析后写入本地点数据库。这个技能的参数模型比较有意思,它需要两个字段:action 和 content。action 区分“新增”“删除”“查询”三类操作,content 则是用户希望记录的具体文本。

这个技能解决了纯 AI 接口无法解决的问题:对话是瞬时的,但待办数据需要留存。技能内部使用 SwiftData 做本地持久化,这也是 iOS 17 之后我比较推荐的方案,模型定义简洁、和 SwiftUI 绑定自然,不需要像 Core Data 那样维护大量手动迁移逻辑。不过要提醒一句:SwiftData 目前对复杂的谓词查询支持仍不如 Core Data 全面,如果你要做的技能涉及很多复合条件筛选,要仔细评估。

4. SwiftUI 界面与技能模块的衔接:状态、绑定与刷新策略

4.1 界面层的核心结构

整个界面的骨架是一个 NavigationStack,根视图是技能展示列表,顶部是输入框和发送按钮,底部是执行状态卡片。界面上最重要的一个类是 AgentViewModel,它持有 AgentEngine、技能注册表以及当前执行状态。

@MainActor final class AgentViewModel: ObservableObject { @Published var inputText: String = "" @Published var currentState: SkillExecutionState = .idle @Published var resultMessage: String? = nil private let registry = SkillRegistry() private let engine: AgentEngine init() { let weatherSkill = WeatherSkill() // 注册技能... self.engine = AgentEngine(registry: registry) } func send() async { let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } currentState = .running do { let result = try await engine.handle(input: trimmed) resultMessage = result.summary currentState = .succeeded(result: result.summary) } catch { currentState = .failed(message: error.localizedDescription) } } }

把 ViewModel 标记为 @MainActor 是一个非常重要的决定。SwiftUI 的 body 渲染一定发生在主线程,如果你在一个后台队列里更新了 @Published 状态,虽然某些情况下界面会意外地正常刷新,但已经埋下了数据竞争的不定时炸弹。使用 @MainActor 后,编译器会在你从非主线程上下文调用 send() 方法时主动提示,帮你规避掉这一类低级但高发的崩溃问题。

4.2 技能运行状态如何在界面上优雅展示

状态卡片我用了一个独立的 View,根据 currentState 切换视图内容:

struct StatusCard: View { let state: SkillExecutionState var body: some View { VStack(alignment: .leading, spacing: 8) { switch state { case .idle: Label("就绪,等待指令", systemImage: "checkmark.circle") case .running: HStack { ProgressView() Text("技能执行中...") } case .succeeded(let result): Text(result).foregroundStyle(.green) case .failed(let message): VStack(alignment: .leading) { Text("执行失败").foregroundStyle(.red) Text(message).font(.footnote).foregroundStyle(.secondary) } case .cancelled: Text("已取消").foregroundStyle(.orange) } } .padding() .background(.background.secondary) .clipShape(RoundedRectangle(cornerRadius: 12)) } }

视图的切换只依赖 currentState 一个值,这就是声明式界面最大的爽点:不需要手动隐藏或显示某个控件,界面由状态推导,所有逻辑都收敛在 ViewModel 里。这里我用 .background.secondary 做卡片底色,比纯色块观感更柔和。

4.3 技能列表页:让用户看到 Agent 有哪些能力

我还做了一个“技能仓库页”,从 SkillRegistry 里读取 allSkills() 展示成列表,每个技能行显示技能名称、描述和一个“试一下”按钮。点击按钮会把技能 id 作为命令前缀填充到输入框,用户在文本后面补参数即可。这个页面的价值其实不在功能,而在用户体验预期管理——用户不知道 Agent 能做什么的时候,他根本不会去尝试,给一个可见的技能列表能显著提高产品的可探索性。

这个列表的刷新逻辑也不复杂:视图出现时调用 Task { await viewModel.reloadSkills() },然后在通过 registry.allSkills() 获取技能数组并转成 Identifiable 数据模型后渲染即可。

5. 常见问题与排查技巧实录:我踩过的 8 个坑

5.1 技能注册了,但 Agent 一直匹配不到

这个问题出现的频率最高。排查时先用 print 打印注册表中所有技能的 id 和描述,确认技能确实存在;再打印用户输入和匹配后的得分。我遇到过一次,技能描述写的是“查询中国各城市的天气信息”,用户输入是“明天上海热吗”,分词后交集几乎为零,得分自然为 0。

排查思路是先看描述和用户输入是不是同一个表达体系。技能描述不能写得像函数注释,要写得更口语化一点:你希望用户在聊天框里怎么问,就把技能描述写成类似的样子。比如天气技能描述改为“查询天气、温度、降雨、风力,支持城市名称和日期”,命中率明显上升。

5.2 界面不刷新:状态改了,View 没动

这种问题十有八九是 View 里用了局部 @State,而不是订阅 ViewModel 里的 @Published。我给技能详情页单独写了一个子视图,内部 @State 持有结果文本,结果 Agent 执行完回调更新了 ViewModel,但子视图读取的是局部状态,两边数据对不上。

正确做法只有一个:数据流向全局统一。所有技能执行结果都先发布到 ViewModel,再由父视图传值到子视图。子视图里不要自己开新的状态存储,除非它确实只是管理 UI 临时交互(比如文本编辑框的内容)。

5.3 异步任务取消导致状态残留

用户点击发送后立刻点击取消,这时候如果不处理 Task 的取消逻辑,上一个技能可能还在跑,最后把结果写进了 currentState,虽然用户已经不在那个页面了,但这个状态又会透传到其他视图。

我在 AgentEngine 里为每个任务生成了任务标识符,在执行前检查 Task.isCancelled,在返回结果前再校验任务标识符和当前标识符是否一致。如果不一致,直接丢弃结果。这个机制看似繁琐,多线程环境下却非常关键。

5.4 网络请求报跨域或证书错误

技能请求第三方天气接口时,在模拟器上直接使用 https 一般没问题,但如果 API 用的是自签名证书,URLSession 会直接抛错。另外,如果你的接口服务器配置了跨域限制,需要检查服务器返回的响应头是否允许来自 App 的请求。Debug 阶段为了快速验证,可以在 Info.plist 里配置 ATS 例外,但上线前一定要移除。

5.5 参数解析中的类型擦除问题

由于 SkillProtocol 声明了 associatedtype Parameters,直接把这些技能放在数组里会面临类型不一致的问题。我的解决方法是把注册表存储类型写成 [String: any SkillProtocol],在取出技能后统一用 withUnsafePointer 或类型转换来做参数填充。更优雅的方案是给参数解析单独做一个解析器,用户输入文本先解析成 [String: Any],再按技能声明的 Parameters 类型逐一转换。这个过程在第 3 部分的注册表代码里其实已经埋了伏笔,实际调试时你会发现转换失败的情况不少,要着重做好错误提示。

5.6 SwiftData 模型在技能调用中无法保存

待办技能早期版本里,我在非主线程上下文中创建 ModelContext,导致频繁出现“Context is not a main context”的崩溃。这个问题的根源是 SwiftData 的上下文默认绑定在创建它的线程上,解决方式是在保存数据前切换到 MainActor。当然,你也可以使用私有队列上下文,但在技能调用链路里,最简单的还是把数据相关操作全部收口到 MainActor。

5.7 多个技能之间的优先级冲突

用户输入“帮我看看明天北京的天气适合跑步吗”。这既可能命中天气技能,也可能命中一个健康建议技能,两个技能描述中都含有“天气”“跑步”。我测试下来,仅靠关键词交集经常打出平手,稳定胜出的方法是给每个技能设置一个优先级字段(Int),得分相同时按优先级取高。另外,技能描述长度也会影响匹配,我把描述过长的技能改为拆成多个较短的关键句,匹配效果反而好了。

5.8 模拟器上网络权限与本地网络提示

第一次在模拟器里跑天气技能时,请求迟迟不返回。后来发现是模拟器上的本地网络权限提示没有点允许。这个权限和 iOS 真机类似,在调试的时候容易忽略。检查路径是:设置 → 隐私与安全性 → 本地网络,确认对应的开发 App 开关打开。另外,模拟器对某些 API 的访问会有兼容问题,建议网络类技能用真机验证,别省这一步。

6. 写在最后:一些个人体会

这个项目是我目前做的比较完整的一个 SwiftUI 实战项目,它让我深刻体会到,Agent 类应用的核心挑战从来不在 UI 层,而在技能的抽象和调度。协议设计得稳,后面的注册、匹配、执行、刷新全链路都轻松;协议设计得糙,每加一个技能都要回来改核心代码。如果你打算进一步扩展,我建议下一步给技能加上持久化的启用/停用状态,再做一个可视化编排界面,让用户能拖拽组合多个技能链路,这样项目就能从 Demo 级别接近商业产品了。

最后分享一个实际开发中的小技巧:调试技能匹配时,不要只打印最终命中了哪个技能,要把每个技能的前三名候选和分值全部打印出来。我看到太多开发者只关心结果对不对,不关心路由过程,结果出了问题根本不知道是描述写得差,还是匹配算法有 bug。打印日志这种笨办法,往往是最快定位问题的捷径。

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

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

立即咨询