首页 / 资讯中心 / 文章详情

SwiftUI原生实现AI Agent:Skill技能机制与工具调用

SwiftUI原生实现AI Agent:Skill技能机制与工具调用 ★ FEATURED ARTICLE
去年年底我在做一款原生客户端的智能助手时最头痛的问题是怎么把 AI 的“思考”和 SwiftUI 的界面状态自然地拼在一起。一开始我图省事把对话逻辑、技能调用全部塞进一个 ViewModel结果技能数量一多代码直接失控每个按钮背后都是一堆 if-else 判断每次新增能力都要动好几个文件。后来我干脆换了一套思路把整个 Agent 的行为拆成独立的“技能单元”这就是 SwiftUI-Agent-Skill 这个框架的由来。这篇文章想把整个设计过程、核心实现和踩过的坑完整记录下来。它解决的问题很具体如何用 Swift 原生的方式在 SwiftUI 应用中搭一个支持工具调用的 AI Agent 框架并且让每一个新增能力都以最小成本接入。如果你正在做类似的事——不管是给应用加一个自然语言指令入口还是想把常用操作封装成可被 AI 调用的函数这篇内容都值得你花十分钟读完。1. 项目定位与整体设计思路1.1 为什么选择 SwiftUI 原生实现而不是引入跨平台框架在动手写第一行代码之前我其实纠结过要不要用跨平台的 Agent 框架。市面上确实有不少现成的方案但它们大多基于 JavaScript 或者 Python 生态。如果强行接入原生应用要么起一个内嵌的解释器要么通过 WebView 搭一座桥。这两种方式我都试过原型体验一言难尽内嵌解释器让包体积显著上涨WebView 桥接又面临数据同步和内存管理的双重麻烦。更重要的是Swift 5.9 之后引入了Observable宏配合 SwiftUI 的依赖注入机制状态管理变得非常干净。Agent 在处理任务时进度、消息列表、当前正在调用的技能名称这些本来就要实时反映在界面上。如果直接用Observable把所有状态绑定到视图层代码路径会短得多也更容易调试。跨平台框架虽然能一次到处跑但对于一个只面向苹果生态的智能助手来说原生实现带来的开发效率和运行性能才是实打实的。1.2 Skill 机制的设计目标把能力从 Agent 本体中抽离最初我犯了一个经典错误把技能逻辑写死在 Agent 类里面。比如“获取天气”就是一个func fetchWeather()“设置提醒”就是一个func setReminder()。每一个新功能都要修改 Agent 主体加方法、改消息分发逻辑。很快 Agent 类膨胀到上千行而且每加一个技能都要重新编译整个模块非常痛苦。Skill 机制的思路完全不同Agent 只负责“理解用户的意图”和“分发调用”具体做什么全部下沉到一个个独立的 Skill 对象中。每个 Skill 自己声明名字、描述、参数格式以及执行函数。Agent 维护一个注册表运行时把注册表里的技能描述发送给大语言模型由模型根据用户请求从注册表里挑选合适的技能再返回结构化参数。Agent 拿到结果后去调度对应的 Skill 对象。这个设计的核心理念是“开闭原则”对扩展开放对修改关闭。新增能力时不需要动 Agent 的核心代码只需要新写一个遵守协议的 Skill 类型然后注册进去。职责边界非常清晰Agent 不带业务逻辑Skill 不带消息处理逻辑。1.3 技术选型评估手动解析 JSON 还是用 Codable 自动处理技能调用链路上有一个绕不开的问题如何解析大模型返回的结构化参数。最早我用JSONSerialization手动转字典然后从字典里逐个取值。代码长也就罢了最怕的是类型不匹配——模型偶尔会返回一个字符串当数字用崩溃倒不至于但容错代码写得想吐。后来我改成 JSON Schema 定义每个技能的参数格式然后用JSONDecoder做自动解析。举个例子天气技能需要城市名字和日期Schema 定义如下{ type: object, properties: { city: { type: string, description: 城市名称如上海 }, date: { type: string, description: 日期格式为YYYY-MM-DD默认今天 } }, required: [city] }Swift 端对应定义一个WeatherArguments: Codable结构体struct WeatherArguments: Codable { let city: String let date: String? }这样模型返回的 JSON 只要结构正确JSONDecoder就能直接转目标类型。处理不了的情况当作时间参数缺失处理引导模型重新调用。比起手写字典解析代码简洁并且不容易出错。2. 核心框架实现Skill 协议与注册机制2.1 Skill 协议的定义与关键考量Skill 是整个框架的地基。经过多次迭代最终协议长这样public protocol AgentSkill { var id: String { get } var displayName: String { get } var skillDescription: String { get } var parameterSchema: String { get } var needsConfirmation: Bool { get } func run(arguments: String) async throws - String }几个字段的取舍值得展开说说。skillDescription是整个框架里最容易被忽略但影响最大的设计。会话过程中Agent 需要把每个技能的描述发给模型作为“模型判断该调用哪个技能”的依据。描述写得太泛模型会在错误的场景触发技能写得太细又占用了大量上下文令牌对话一长开销飙升。我的经验是控制在两到三句话先写技能能做什么再写什么情况下不要调用它。这比长篇大论有用得多。parameterSchema这个字段最初设计成字符串而不直接定义Codable类型其实是故意的。原因有两个一是大模型 API 本身就接受字符串形式的 JSON Schema可以直接透传二是把参数校验逻辑统一放在协议层具体 Skill 不用操心底层格式只要确保自己能解析通过parameterSchema描述的 JSON 就行。needsConfirmation用来标记危险操作的模式有点意思。比如“删除文件”“发送消息”之类的技能设计上要求必须经过用户确认。Agent 在调用这些技能前会在 UI 层弹一个确认框防止误触。你可以理解为 iOS 系统里的权限弹窗完全由框架统一管理各技能无需自己实现。2.2 Agent 核心类的设计与状态流转Agent 类是框架的核心枢纽。它做了这样几件事维护技能注册表、负责与大模型通信、管理多轮对话上下文、把模型返回的技能调用参数调度给对应 Skill。先看最基础的骨架Observable final class Agent { var state: AgentState .idle var messages: [ChatMessage] [] private var skills: [String: any AgentSkill] [:] func registerSkill(_ skill: any AgentSkill) { skills[skill.id] skill } func processUserInput(_ text: String) async { state .processing messages.append(.user(text)) // 构建发送给模型的请求 let prompt buildPrompt(userText: text) // 这一步是核心LLM解析回复决定是直接回答还是调用技能 do { let response try await callLLM(messages: messages) if let call response.toolCall { try await executeSkillCall(call) } else { messages.append(.assistant(response.content)) } state .idle } catch { state .failed(error.localizedDescription) } } }你会发现核心就一个循环用户说话模型判断是要直接回答还是调用技能如果调用技能就把返回结果再次发给模型直到模型认为可以给出最终回答。这个循环结构几乎对所有主流大模型都适用区别只在于 API 请求的构造细节。状态流转看起来简单其实有一件很重要的事Observable宏和MainActor的关系。SwiftUI 的视图必须在主线程更新但processUserInput里涉及网络请求不可能全程在主线程跑。我当时被这个坑折腾了很久解决办法是给 Agent 类标上MainActor网络请求部分用Task.detached或独立 actor 处理最终结果回到主线程再更新状态。别小看这个细节如果不主动处理很快你就会在控制台看到Publishing changes from within view updates这类警告。2.3 技能注册与动态扩展机制注册技能用最简单直接的方式let agent Agent() agent.registerSkill(WeatherSkill()) agent.registerSkill(ReminderSkill()) agent.registerSkill(FileSearchSkill())运行期把技能说明构造为一个列表随每次请求发送给模型。这里有一点很关键注册表里可以注册上百个技能但每次请求不必全部发送。我后来加了一个SkillSelector组件根据用户输入的关键词做一次粗粒度筛选只把匹配度最高的几个技能描述发送给模型。比如用户说“帮我看看明天的会议”跟天气、提醒相关的技能被选中文件搜索技能的描述压根不会进入上下文。这样做有几个立竿见影的好处上下文长度明显降低模型误触发不相关技能的概率下降响应速度也有提升。不过这个策略对描述质量要求更高如果筛选器漏掉了正确技能模型就不可能执行它。我目前的实现是关键词匹配加上简单的相似度计算留了接口方便后续接入本地嵌入模型。3. SwiftUI 集成实操从 Agent 状态到界面响应3.1 用 Observable 连接 Agent 与视图层SwiftUI 侧的集成是整个项目里最顺的部分因为Observable宏天然就是为视图刷新设计的。看一个实际的例子展示技能执行状态的卡片struct SkillExecutionCard: View { Bindable var agent: Agent var body: some View { VStack(alignment: .leading, spacing: 12) { if let current agent.currentSkill { HStack { ProgressView() Text(current.displayName) Spacer() if agent.state .processing { Button(取消) { agent.cancelCurrentTask() } } } } } .padding() .background(.thinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } }注意agent.currentSkill这个属性它是Observable自动推导出来的只要 Agent 状态一变视图就自动刷新无需手动调用objectWillChange。相比传统的ObservableObject配合Published代码量少了一半而且类型安全更好。我也用了一段时间的 Combine那时需要到处维护Published属性很容易漏掉某个需要刷新的点。现在Observable把这种细节都处理掉了属性变化自动追踪重要的可变状态不再丢更新。3.2 技能调用链路的 UI 呈现进度、确认与错误反馈UI 层另一个有讲究的地方是技能调用链路的可视化。一个典型的链路是用户说“帮我订下周三下午两点的会议室”Agent 先调用“查询空闲会议室”技能拿到结果后再调用“创建预订”技能。每次调用和返回都应该在界面上形成一条清晰的记录。我实现的消息列表里加入了消息类型用来区分用户消息、助手回复和技能执行记录public enum MessageType { case user case assistant case toolInvocation case toolResult }界面渲染的时候toolInvocation显示成一组缩进的标签包含技能名称、传入参数摘要、执行状态。这样用户能够清楚地追踪 Agent 每一步在执行什么操作避免出现“AI 擅自把事情办了却不告诉我”的困惑感。技能执行前需要用户确认的话界面会弹出一个标准的确认对话框显示技能名称和参数摘要。这里有个细节确认对话框在 SwiftUI 里用.confirmationDialog最顺手并且配合needsConfirmation标志使用。如果在一个技能执行过程里连续触发了多个待确认的子技能需要排队处理不要同时弹多个对话框不然用户会感觉界面在打架。3.3 视图与 Agent 的依赖注入方案在 SwiftUI 里共享 Agent 实例我选择了简单直接的依赖注入方案。在 App 入口处创建实例然后通过environment传给所有子视图main struct AgentApp: App { State private var agent: Agent init() { let agent Agent() agent.registerSkill(WeatherSkill()) agent.registerSkill(ReminderSkill()) _agent State(initialValue: agent) } var body: some Scene { WindowGroup { ContentView() .environment(agent) } } }子视图中用Environment(Agent.self)获取实例。这种做法好处是依赖关系透明测试的时候可以很方便地替换成一个模拟 Agent。坏处也有就是如果你在某个深层视图里用State复制了一份 Agent整个状态链会断开视图和 Agent 会各走各的路。这属于 SwiftUI 的经典坑原因是在Observable配合State使用时State持有的是值语义还是引用语义特别容易踩糊。我在实际项目里干脆做了一个小组件专门把 Agent 实例和 SwiftUI 预览环境绑定每次测试都从这儿拿避免重复踩坑。4. 常见问题与排查技巧实录4.1 并发问题MainActor 与网络请求的冲突从上手第一天开始并发问题就一直没消停过。最典型的表现是界面卡住、技能执行缓慢然后控制台报一堆Main actor-isolated property错误。我最初的实现没加MainActorAgent 类里的所有属性都是普通 Swift 属性。结果就是网络请求回调里的数据更新在后台线程进行SwiftUI 视图还强行读这些属性直接引发数据竞争。Xcode 的 Swift Concurrency 检查帮我自动标出了所有问题点。最终的方案就是这样MainActor Observable final class Agent { ... }所有状态都归主线程管。那么网络请求怎么办封装成一个单独的 actor专门处理 HTTP 通信返回Data之后转交给主线程解析。比如说actor NetworkClient { func send(request: URLRequest) async throws - Data { // 在 actor 内部进行网络请求不占用主线程 } }Agent 在processUserInput里调用let data try await networkClient.send(...)。由于 actor 和MainActor的隔离这个调用是安全的不会阻塞 UI。这一步非常重要建议所有想用 Swift 原生实现 Agent 的朋友都优先想清楚线程模型不然后面会一直很痛。4.2 大模型返回不合法 JSON 的容错方案框架跑了一段时间后我统计了一下调用记录发现大模型在返回工具参数时偶尔会因为超长文本被截断导致 JSON 不完整或者在一个长对话的中间突然不回 JSON 却直接说话了。这两种情况处理不好用户会看到工具调用失败甚至整个对话断掉。我在解析层做了三层防护。第一层增加系统提示词明确声明“必须返回合法 JSON禁止额外文本参数缺失时自行补充合理默认值”。第二层用宽松的正则把模型返回内容里的 JSON 块抽取出来。第三层万一JSONDecoder失败那把错误信息重新发给模型附带提示“刚才解析失败请重新返回完整 JSON”。实测下来三层机制合在一起可以把可用率提到 98% 以上。值得留意的是第三层策略会增加一次额外请求拉长响应时间。对于比较关键的操作比如订票、支付多花这点时间换取稳定性是划算的。而对于即时聊天场景则直接放弃解析让用户重新问一遍就好。4.3 SwiftUI 视图刷新与 Agent 状态更新的时序问题SwiftUI 的视图刷新是异步的经常出现这样的场景agent.state已经变为.processing了某个子视图里通过if判断来控制加载动画但这个视图没有立刻刷新导致动画延迟片刻才出现。这个其实不算 bug因为 SwiftUI 会在下个 RunLoop 周期统一刷新人眼通常感知不到。真正该注意的情况是如果你在Button的 action 里同步修改 Agent 状态又马上在同一个事件里做其他 UI 操作有时会收到“Modifying state during view update”的警告。这是因为按钮 action 本身处于视图更新阶段直接修改Observable状态会破坏 SwiftUI 的内部断言。解决办法是在事件处理里尽量用Task包裹如果需要在界面点击后立即修改 Agent把 Agent 的调用放在Task里面Swift 会自动选择合适的时机更新状态避免了崩溃和警告。4.4 Swift 6 严格并发检查下的适配经验如果你已经切换到 Swift 6 语言模式会发现并发检查比之前严厉许多。最直接的影响是Observable类中存储可逃逸闭包会非常困难编译器会要求所有闭包明确标注Sendable。实际中我们遇到的具体问题是技能注册的时候某些技能对象持有一个发送通知的闭包这个闭包可能从任何线程被调用。给闭包加上Sendable之后闭包体内部不能捕获非 Sendable 的变量。有一段时间为了适配这一限制我们不得不把几个技能被捕获的数据类型改成 struct从源头保证线程安全。遇到这种情况建议先重新检查自己的架构看是不是有一些没必要共享的可变状态。如果可以尽量用值类型传递参数、用 actor 隔离共享资源省得后面升级依赖时再做迁移。5. 从框架搭建到实际项目的经验总结框架本身只是第一步真正让它值钱的是后续的能力演进过程。我做过的几轮迭代里印象最深的是给 Agent 增加“数据展示”技能让它能生成简单的图表视图。这个技能返回的不是普通字符串而是 JSON 描述UI 层解析参数后自动渲染成 SwiftUI 的图表。这个过程让我意识到Skill 的输入输出不应该局限于文本良好的参数类型设计可以让 UI 和逻辑都受益。还有一点值得分享的体验是“技能描述描述模型能力”这一反直觉现象。在同一个模型下仅仅优化几个技能的skillDescription就能让任务失败率明显下降。想理解这一点可以把技能描述想象成 API 文档文档写得好不好直接决定调用方怎么用。后来我专门留了个环节收集未命中案例定期对比优化描述内容。这套设计跑下来我的实际感受是用 SwiftUI 原生构建 Agent 框架最大的优势不在于代码量更少而在于整个链路的数据类型是统一的。任何技能、任何消息、任何状态都能用 Swift 的类型系统表达清楚。它不像 JavaScript 那样灵活但也正因为这一点在工程上更容易保持边界清晰。中小型项目完全可以照这个思路起步后续等需求复杂了再逐步引入更重的并发模型或者更智能的技能筛选策略也不迟。最后如果你的需求不复杂也可以先从一套精简版做起只保留协议、注册表加上一个能调用的技能快速跑通核心链路。等到真正体会到“AI 调用原生功能”这一点的开发体验时你自然会知道该往哪个方向扩展。
阅读完成 · 觉得有帮助?
咨询建站