
前段时间我在做一款内部工具类 App 的 AI 助手模块刚起步的时候查了一圈资料发现市面上的客户端 AI 方案基本都停留在“聊天界面 流式返回”的层次功能稍微多一点就开始失控模型只会说不会做。后来我把服务端常见的 Agent 思路搬到了 SwiftUI 里做了个叫 SwiftUI-Agent-Skill 的小项目核心就是让大模型在 iOS 端真正“用上工具”而且把这些工具能力做成可插拔的 Skill 模块。这篇文章不是教科书式的架构讲解而是我自己踩坑、重构三轮之后沉淀下来的实践记录。适合三类人看正在做 AI 助手类 App、想让模型具备工具调用能力的开发者对 SwiftUI 状态管理和 Swift 并发比较敏感的同学以及纯粹对“客户端 Agent”这个方向感兴趣的玩家。全文围绕“Agent 怎么调度、Skill 怎么设计、SwiftUI 怎么呈现”三条线展开最后会把真实调试中遇到的一堆幺蛾子摆出来能帮你少走不少弯路。1. 项目概述为什么在 SwiftUI 里做 Agent1.1 从“套壳聊天框”到“工具型助手”的差距现在 App Store 里大量 AI 应用的交互模型惊人地相似底部一个输入框上面一个滚动消息列表模型把 token 一个一个吐出来。这类应用不是没有价值但它有一个天然的天花板——大模型只是在“表达”没有能力真正替用户完成任务。我举一个具体的场景。用户问“帮我查一下上周三的记账明细然后按类别汇总”如果只是一个聊天机器人它能给你的最大帮助就是一段建议你自己去看账单的文字但如果是一个具备 Skill 能力的 Agent它可以感知到用户的意图属于“记账查询”从本地的数据层把明细拿出来按类别做聚合然后返回一棵结构化的结果树UI 层再把结果渲染成一张分组表格。前者是语音助手后者是数字员工体验差了两个量级。这也正是 SwiftUI-Agent-Skill 的出发点把“模型决策 工具执行 界面呈现”这条链路完整跑通并且让它足够干净、足够可扩展。项目名里的三个词对应三个核心模块SwiftUI 负责界面与交互呈现Agent 负责意图理解与调度决策Skill 则是一组可以被 Agent 调用的原子能力单元。三者各自独立通过明确定义的接口协作。1.2 为什么选 SwiftUI 而不是 UIKit技术选型上我身边有些朋友会问Agent 这种异步逻辑重、状态多的应用是不是用 UIKit 更稳妥我的答案很明确在 2025 年的环境下SwiftUI 已经是更合理的选择。一个很重要的原因是 SwiftUI 的响应式状态模型天生契合 Agent 应用的需求。Agent 在运行过程中会产生大量异步状态变化正在分析输入、正在选择技能、技能执行中、生成回复中。这些状态在 UIKit 里通常要靠代理回调、通知中心或者组合框架层层传递写起来非常啰嗦而且特别容易漏掉某个回调导致 UI 卡在半截。SwiftUI 这边的做法就直白多了把 Agent 的当前阶段建模成一个显式的枚举状态视图对这个状态做 switch渲染不同组件即可。数据驱动 UI状态单一来源代码量可以压缩到 UIKit 时代的四分之一。另外一个现实因素是 SwiftUI 在 iOS 17 之后补足了大量关键能力Observable 宏让模型对象不再需要手动刷 objectWillChange新的 NavigationStack 做多级跳转非常顺手ScrollView 的 API 也稳定了很多。这些不是我空口夸实际写下来确实比两年前的体验好太多。如果你还停留在 iOS 15 时代的 SwiftUI 记忆我建议重新评估一下。2. 核心架构设计与关键技术拆解2.1 Skill 协议从第一版到第三版的演进项目最初设计 Skill 协议时我直接照搬了服务端某个流行框架的 Tool 抽象一个 name、一段描述、一个 execute 方法。但跑通第一版之后就发现客户端和服务器的场景差异非常大。服务器上 Tool 的使用者是另一个程序参数结构是紧凑的、可信的而客户端 Tool 的使用者是一个可能产生幻觉的模型并且 Skill 执行结果要直接驱动 UI 更新参数字段需要做防御性处理。最终定稿的协议长这样protocol AppSkill: AnyObject { var id: String { get } var displayName: String { get } var metadata: SkillMetadata { get } func canHandle(intent: UserIntent) async - Bool func execute(params: SkillParameters, context: AgentContext) async throws - SkillResult }这里最关键的决策是把 canHandle 和 execute 拆成两个独立方法。第一版我试图用一个统一入口靠试跑来决定技能是否匹配后来发现问题很大试跑意味着副作用可能已经发生——某个技能里发了网络请求、改了本地缓存然后告诉你“我不处理这个请求”这些副作用根本没法回滚。另外试跑耗时不可控用户每发一句话所有技能都试一遍延迟直接拉满。canHandle 的定位必须是“零副作用、低延迟、基于元数据和意图标签做判断”。比如记账查询这个技能它的实现只需要检查意图文本里有没有“记账/账单/开销”这类命中的意图标签完全不碰数据库。这样 Agent 做技能选择时扫描所有注册技能的成本极低可以在毫秒级完成。2.2 状态管理选型Observable 取代 ObservableObject项目里既有一个全局的 Agent 调度器又有一堆局部的 UI 状态状态管理的选型直接决定了后续所有的代码体验。我最早用的是 ObservableObject Published 的组合那是我熟悉的套路但用着用着就发现两个痛点。第一个痛点是包装层太厚。一个普通的技能执行状态要拆成一个 Published 属性还要在 init 里赋初值类的脏值检查逻辑还得自己控制。第二个痛点是视图更新粒度太粗任何属性变化都会触发整个 observer 的更新SwiftUI 虽然能在视图层做 diff但底层 objectWillChange 的触发频率高了之后明显能感觉到滚动列表时有掉帧。后来我把核心模型全部改成了 ObservableObservable final class AgentViewModel { var phase: AgentPhase .idle var messages: [ChatMessage] [] private(set) var skillRegistry: SkillRegistry }改完之后感受最直接的是视图只会在真正读过的属性变化时更新。以前那些为了“确保界面刷新”而加的 manual 刷新逻辑全部删掉了代码量少了一大截。如果你是新项目我强烈建议直接上 Observable别在旧的 ObservableObject 上浪费时间如果是老项目迁移也可以按模块渐进替换不用一口气全改。2.3 Agent 调度循环用显式状态机消灭隐形 BugAgent 的一次完整执行流程按我的抽象可以拆成五个阶段接收输入、识别意图、选择技能、执行技能、生成回复。这五个阶段我是用一个纯枚举建模的enum AgentPhase: Equatable { case idle case analyzing case selectingSkill case executing(skillID: String) case responding }为什么强调显式状态机因为我自己在早期版本踩过散装状态的坑。那时我用三个 BoolisLoading、isSelecting、needsResponse来拼当前状态结果只要碰到一次并发输入这三个 Bool 的组合立刻开始互相打架一个输入还在分析中另一个已经把回复渲染出来了界面出现“加载中转圈的同时已经展示结果”这种诡异现象。改成显式枚举后所有问题迎刃而解。视图层只监听 phase 这一个值加载动画、进度提示、结果面板全部由单一状态驱动从根上消除了组合爆炸。而且我还顺手给状态迁移加了合法性检查从代码层面禁止了非法跳转比如不允许从 idle 直接到 executing。这种约束在团队协作时尤其有价值后来一起开发的同学改代码时再也不会创造出一个“中间态”出来。3. 实操过程从零到一实现一个可运行的 Skill3.1 第一步定义天气查询 Skill 并接入注册表空谈设计没有感觉我直接用一个最典型的技能——天气查询把完整实现流程走一遍。这个技能要做的只有三件事实现 AppSkill 协议、在 execute 里调用天气服务、把实例注册进 Agent。final class WeatherSkill: AppSkill { let id weather let displayName 天气查询 let metadata SkillMetadata( description: 查询指定城市当前天气以及未来三天的预报信息, inputSchema: [ type: object, properties: [ city: [type: string, description: 城市名称必须是中国内地城市的中文名] ], required: [city] ] ) func canHandle(intent: UserIntent) async - Bool { intent.tags.contains(weather) || intent.text.contains(天气) } func execute(params: SkillParameters, context: AgentContext) async throws - SkillResult { let city try params.requireString(city) guard CityValidator.isValid(city) else { throw SkillError.invalidParam(不支持的城市名称) } let data try await WeatherService.shared.fetch(city: city) return .success(WeatherFormatter.summary(from: data)) } }顺带说明一件事inputSchema 是用字典直接拼的没有用强类型结构。刚开始我也纠结要不要建一套 Codable 模型来描述 Schema后来想明白了这个 Schema 最终是要转成 JSON 发给大模型的字典本身就能直接 JSONSerialization没必要在中途多一层转换。只要在 SkillMetadata 的初始化里做一次字典结构的 sanity check 就够了你可以用 Xcode 的断点验证一下保证 model 返回的 JSON 能被正确接收。注册这块我把 SkillRegistry 也做成了 Observable 模型。它内部维护一个数组提供 register、unregister、match(intent:)、allSchemas() 等方法。有个经验供参考不要用静态常量数组存技能清单因为一旦产品要按用户权限做技能可见性控制或者想从服务端下发新技能包静态数组改起来非常痛苦。我的方案是从本地配置文件读初始技能列表运行期间可以动态增删这个灵活性在你真正遇到需求变化时会庆幸当初没偷懒。3.2 第二步SwiftUI 视图如何响应 Agent 状态UI 层的职责非常纯粹——把 phase 翻译成用户看得见的样子。我直接在一个主视图里 switch phase不同阶段渲染不同组件struct AgentChatView: View { State private var viewModel AgentViewModel() var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.messages) { message in MessageBubble(message: message) } } } } if case .executing(let skillID) viewModel.phase { SkillExecutionPanel(skillID: skillID) } InputBar { text in await viewModel.send(text) } } } }执行技能时的面板是一个加分项。当模型决定调用天气技能后界面上会出现一个“正在执行: 天气查询”的带状面板用户能感知到 AI 正在替他做事情而不是在空转。这个透明度对体验提升很大我自己的使用感受是以前看到长时间转圈会焦虑现在看到技能执行面板反而安心。视图层有一个隐藏的坑SwiftUI 的视图更新依赖 Equatable 判断我的 AgentPhase 用了高次枚举关联值设计得很有讲究。之前我把一个 Result 对象放进 executing 的关联值里结果界面怎么都不刷新排查了半天才发现是关联值对象没有实现 Equatable 的比较逻辑。现在统一规定关联值只能放 String、Int 这种简单类型复杂结果一律放在别的地方存phase 里只存技能的 id。这个规定让一批“灵异不刷新”的问题直接被消灭掉。3.3 第三步打通大模型工具调用链路客户端 Agent 能真正工作靠的是与大模型的 Function Calling 能力联动。整个链路分成三轮。第一轮客户端把 SkillRegistry 里所有技能 Schema 压成 tools 数组随用户消息一起发给模型。第二轮模型在理解了用户意图之后返回 tool_calls 指令里面包含技能名和模型建议的参数。第三轮客户端本地执行完技能把结果作为一条 tool 消息回传给模型模型基于这个结果生成对用户的最终回答。实操中这一步有三个非常有效的优化点我单独列一下控制 tools 数组的大小。一次请求尽量不超过 20 个技能描述实测超过这个数量后模型选错技能的概率明显上升context token 也烧得飞快。可以在发请求前先用 canHandle 对所有技能做一轮意图粗筛只把大概率命中的几个技能的 Schema 发给模型。解析 tool_calls 别用严格 Codable。不同厂商模型的字段格式差异比想象中大得多写一个跟字段提取函数配合的解析器比跟解码器搏斗要舒服得多。技能回传的结果不要原样把 JSON 丢给模型。把天气数据先打包成一段精炼摘要例如“北京今天多云 22 度后天有雨”既满足模型生成回复需要的信息又不至于让上下文被无效 token 塞爆。这三条都是我在线上实测里一条条验证出来的。尤其是第一条我见过太多项目把所有技能一股脑发给模型最后模型挑了完全不相干的一个这个问题在技能数量超过 30 个之后几乎是必然出现的。4. 常见问题与排查实录4.1 并发与主线程更新的一连串问题SwiftUI 的界面更新必须发生在主线程Agent 链路里又到处都是 async/await这就注定了并发问题会找上门。我第一次跑通全流程时控制台里刷了一屏的 MainActor 相关警告用起来界面轻微卡顿。当时处理方式是给 Agent 的调度入口统一加上 MainActor 标注MainActor final class AgentCoordinator { func handle(_ input: String) async { // 所有状态更新都发生在主线程 } }有人可能会担心加了 MainActor 会不会把耗时操作堵在 UI 线程上实际上只要 Skill 内部的重活都通过 try await 调后台接口调度层本身在主线程停留的时间是微秒级的完全不影响滑动流畅度。真正的线程策略应该放在 Skill 层去决定一个做 CoreML 推理的技能要在后台队列上执行而一个查 UserDefaults 的技能直接在当前线程跑完也无所谓。我把这句写进了项目注释里调度层统一主线程执行层各自为政。这是这套架构里我觉得最顺手的一个设计决策。4.2 界面上反复出现的“灵异事件”项目调试过程中遇到过一个非常迷惑的 Bug技能执行完成、新消息插入消息列表后ScrollView 会不受控制地跳回顶部。排查了很久最后定位到根因是滚动指令发出时目标位置对应的视图还没有完成布局更新旧的位置已经失效了滚动就落到了默认位置。解决办法是让滚动动作延后一帧执行DispatchQueue.main.async { withAnimation { proxy.scrollTo(lastMessageID, anchor: .bottom) } }这类问题在 SwiftUI 里很常见凡是“数据已更新但视图布局尚未完成”的时候都需要等一帧。另一个类似的问题是技能执行面板反复闪烁现象是面板在开始和完成两个状态间快速跳动我一开始以为是动画冲突后来发现是状态管理里出现了两个相互驱动的子状态——一个监听开始一个监听结束形成递归更新。砍掉派生状态、统一由 phase 单一驱动之后闪烁彻底消失。这两个案例给我的教训是统一的视图的显示逻辑永远只能有一个可信状态源派生状态做得越多灵异事件越多。4.3 模型“胡乱调用”技能的应对方案大模型在工具调用上的准确率不可能做到百分之一百。我在自己数据集上实测纯文本提示词引导时准确率大约在六到七成加上 JSON Schema 并精简了每个技能的描述之后提升到了八成五左右。剩下的一成五错误里典型的有两类参数里塞了一个不存在的城市名用户只是闲聊一句“你今天吃了吗”模型却偏偏调用了天气技能。针对这类问题我设计了三层防御。第一层在 canHandle 里加一个置信度阈值如果意图标签的命中分数低于阈值就返回 false让 Agent 走默认闲聊分支。第二层在 Action 的调度层做前置参数校验city 必须命中预设城市列表不合法就丢弃调用并回传提示让模型重新给参数。第三层在界面上放一个“这个回答不相关”的反馈按钮把用户反馈连同当时的调用链路记录到本地日志后续可以拿这些数据做针对性修正或微调。这套机制上线后无效调用率明显降了一个台阶也算是用实践回答了“Agent 怎么防止自己被模型带偏”这个问题。4.4 快速问题速查表我把调试阶段遇到的高频问题整理成了一张表贴在这里方便大家对照。现象根因解决方式列表滚动位置回跳滚动指令早于布局更新滚动代码延迟一帧执行执行面板闪烁派生状态互相驱动统一由 phase 状态驱动视图不刷新关联值对象未实现 Equatable关联值只用简单类型主线程警告刷屏状态更新发生在后台线程调度层统一加 MainActor模型调用错误技能技能描述不清晰或数量过多精简 Schema、粗筛后再发模型Context 快速膨胀技能结果原样回传把结果格式化成精炼摘要偶发卡顿掉帧技能内部在主线程做重活Skill 层自行切换到后台队列这些坑都不是什么高深技术但每一项都实打实消耗过我的晚上时间。整理出来就是想让后来的人少走这些弯路。说实话做这类客户端 Agent 项目最耗时间的往往不是写功能而是排这种“看起来莫名其妙”的问题。5. 经验总结与后续扩展方向5.1 这套架构适不适合你的项目我拿 SwiftUI-Agent-Skill 这套架构做了两个实际项目一个已经在线上平稳运行另一个还在开发期。如果要给一个判断标准我会用两个特征来衡量你的产品功能是否可以被模块化拆解并且每个模块是否有明确的能力边界。如果答案是肯定的Skill 化改造大概率是划算的如果你的产品就是一个纯粹的单一问答界面硬上这套架构反而会平添复杂度。另外强烈建议刚接触 SwiftUI 的同学不要一上来就照着完整架构抄。先做一个只有一个 Skill 的最小 Agent把状态机和注册表跑通让这个极简链路实现“用户一问 → 模型决策 → 技能执行 → 界面展示”的完整闭环然后再逐步增加技能数量。我在实际迭代里的感受是好的架构不是设计出来的是改出来的。这套方案是第三轮重构之后才变成现在这个清爽的样子第一版真的到处都是单例和数据竞争我自己都不忍心回看。5.2 三个值得探索的扩展方向如果这套基础架构你已经跑通了我觉得有几个方向特别值得继续挖。第一个是技能包的远程分发。把 Skill 的元数据和本地实现类的映射关系做成远程配置服务端发布新技能客户端无需发版就能获得新能力。这对工具型产品意义很大相当于给 App 注入了一个持续进化的器官。第二个是多 Agent 协作。可以设计成主 Agent 维护一个粗粒度的任务理解副 Agent 各自维护一组专业技能主 Agent 按需把子任务派发给副 Agent。比如一个助手 Agent 负责对话一个数据 Agent 负责查询一个操作 Agent 负责系统控制。这个模式在团队协作类工具里会有很大的发挥空间。第三个是和 iOS 系统能力的深度整合。SwiftUI 在 iOS 上访问快捷指令、文件系统、日历、提醒事项等系统能力非常方便把这些封装成标准化的 Skill就能做出相当有趣的本地自动化助手。比如用户说“明天下午三点提醒我开周会”Agent 调用日历 Skill 就把提醒建好了这种体验会让用户觉得这个 AI 是真的“懂他”。我个人在实际开发里的最深体会是做客户端 Agent 项目最难的不是某一个功能的实现而是保持整体架构的克制。每新增一个 Skill、每多出一条消息流转的分支复杂度都在指数增长。我这套方案能稳定跑下来很大程度上归功于 AgentPhase 状态机和 Skill 协议这两层约束它们让后续所有参与迭代的人都有了一个统一的思维框架。最后再分享一个小技巧开发调试阶段记得给每个 Skill 的执行日志和耗时做一个统一的调试面板在你能看到每个环节花了多少毫秒的时候排查问题会变得异常轻松。