
1. 从“聊天式AI”到“技能编排”SwiftUI应用接入Agent的一次架构重构过去一年我一直在做一款面向个人效率管理的原生应用用SwiftUI搭界面、SwiftData存数据。功能做到一半发现一个尴尬问题市面上的AI能力基本都停留在“聊天”层面用户问一句、模型回一段话然后呢没有然后。对效率工具来说这种交互是断裂的——用户需要的不是一段文字而是让App替他完成一个动作。后来我把思路从“Chat”转到了“Agent Skill”上也就是这个项目名SwiftUI-Agent-Skill的由来SwiftUI负责渲染与交互Agent负责理解用户意图和调度Skill则是一组可执行的具体能力单元——查天气、记备忘、启动计时器、读取健康数据每一项都是一个独立技能。用户只需要说一句模糊的话Agent解析后自动匹配技能、执行动作、把结构化结果交还给UI层渲染。这篇文章是我在自建这套体系过程中沉淀下来的完整拆解内容包括为什么传统的“API直连”模式不适合复杂App、技能协议怎么设计才能不绑架业务代码、Agent调度器如何做意图路由与回退、以及我会重点讲几个线上踩过的坑——比如技能注册顺序导致的诡异路由问题、Task里更新UI状态的线程冲突、还有大模型返回格式不稳定时的兜底策略。如果你跟我一样是SwiftUI开发者正琢磨怎么把AI能力“嵌入”到界面和业务流里而不是做一个孤零零的对话框这篇文章应该能帮你在动手之前少走弯路。2. 为什么我放弃了“UI层直接调大模型”的写法很多人的第一个念头很直接界面里有一个输入框用户输入文本我直接把文本拼成Prompt发给云端模型把返回的Markdown渲染出来。这套写法做Demo没问题但往真实业务里一放就露馅了。2.1 UI直连的三个致命缺陷第一个缺陷是意图与动作的割裂。用户说“明天早上八点提醒我买牛奶”模型能理解这句话但它没有能力直接写入你的提醒列表。你需要自己去解析返回值、自己写解析规则、自己处理“模型没按格式返回”的意外。这等于把Agent的一大部分工作又甩回给了UI层。第二个缺陷是技能无法复用。如果只有一个页面这样写倒还勉强能接受。但当你需要多个页面共享同一套能力——比如首页、语音快捷指令、小组件都要调用“查询天气”这个动作——你只能在每个地方都写一遍解析逻辑稍有不同步就会出现“首页能用、小组件失灵”的割裂局面。第三个缺陷是状态同步的灾难。SwiftUI是声明式UI一切围绕状态驱动。但大模型的返回是异步、非结构化的你无法预测它会返回什么字段也就无法干净地映射到View Model的State里。结果是代码里到处是if let嵌套、类型强转、默认值兜底维护成本直线上升。2.2 Agent-Skill模型如何解决这些问题我采用的方案是给App加一层薄薄的“技能编排层”Skill技能一个最小可执行单元有名称、有描述、有入参定义、有返回类型。它不关心自己是被谁调用的只负责完成一个动作并返回结构化结果。Agent调度者接收用户原始输入通过大模型做“意图解析”判断应当调用哪个技能、传什么参数再把技能返回的结果整理成UI可以直渲染的结构化数据。SwiftUI ViewModel绑定层暴露用户输入入口监听Agent的执行状态流把技能结果映射到界面状态。这样带来的最大好处是界面代码不再直接面向大模型而是面向一组稳定、可预期的技能接口。大模型在任何环节返回了意外格式都被封在Agent调度层里面消化掉UI层看到的永远是你定义好的状态模型。2.3 何时不该用这套架构坦率讲这套方案有额外成本不是所有场景都值得上。如果你的需求只是“AI问答”“文档总结”“把用户输入润色一下”那直接一个流式返回界面就够了不需要技能编排。我当时判断的标准是三条用户输入是否天然包含“做某事”的意图如提醒、查询、记录、控制多个页面或入口是否共享同一批动作能力技能执行结果是否需要触发UI状态变化或系统能力如写数据库、发通知三条里中两条以上就可以考虑引入Agent-Skill层。只中一条的话还是老实做直连吧。3. 核心架构设计Skill协议、Agent调度器与SwiftUI状态机我最终落地的架构分为三层责任边界非常清晰。先把三层各自负责的内容说清楚再讲每层的具体设计。3.1 Skill协议的最小定义所有技能必须遵循统一的协议。我最初设计协议时分了两版第一版过度设计约束太多导致每个技能都背着沉重的模板代码后来砍到了最核心的四件事/// 所有Agent技能的公共协议 protocol AgentSkill: Identifiable { /// 技能唯一标识用于注册与路由 var id: String { get } /// 技能的人类可读名称用于调试日志 var displayName: String { get } /// 技能的语义描述Agent用它对用户意图做匹配 var semantics: String { get } /// 技能的入参模型JSON可解码 associatedtype Input: Codable /// 技能的出参模型JSON可编码 associatedtype Output: Codable /// 执行一次技能返回结构化结果 func execute(input: Input) async throws - Output }重点说一下semantics字段。这是整个协议里最容易被忽略但最重要的一环。调度器在匹配意图时靠的不是技能ID而是这个描述文本。写得好不好直接决定大模型能否准确路由。比如计时器技能startTimer技能可以启动一个倒计时器。使用场景用户说出类似 “三分钟后提醒我”“帮我定个八分钟的闹钟”“倒计时半小时”等带有时间 长度和目的的表达。入参中duration是倒计时秒数timerName标识计时用途。这里要写得像一个“使用说明书”带有典型例句而不是干巴巴一句话“启动计时器”。我实测下来描述越贴近用户可能说的自然语言路由准确率越高。3.2 Agent调度器意图、参数与回退三步走调度器负责串联“收到一句话”到“技能出结果”全流程。我把它的实现拆成了三个步骤每一步都有明确的边界和错误处理策略。第一步意图解析Intent Resolution把用户原文发给大模型要求输出一个结构化的“意图调用包”包括技能ID和JSON格式参数。为了保证格式稳定我给模型写了一套非常严格的输出规范{ skill_id: startTimer, input: { duration: 480, timerName: 煮鸡蛋 }, confidence: 0.95 }这里有个残酷的经验不要相信大模型会老老实实按你的JSON Schema输出。它经常会把字段名大写、多写注释、把数字当字符串、甚至在JSON外面包一层Markdown代码块。所以我的调度器在拿到原始响应后会先做一层“JSON净化”——把代码块包裹剥掉、去掉注释、再传给JSONDecoder做容错解码。这层净化逻辑我后面单独讲。第二步技能路由Skill Routing拿到意图调用包后调度器在注册表中查找对应的技能处理器。查找不到时不会直接报错而是把“未匹配的意图”作为一个独立技能重新交给大模型让它生成一句友好的引导语告诉用户当前可用的能力有哪些。第三步结果润色Result Polishing技能执行完成后输出结果可能是一段文本、一组数据、或一个错误。调度器会把这些结果统一封装成AgentMessage类型附带执行状态、耗时、时间戳再交给UI层。3.3 SwiftUI状态机的五态模型界面层需要能清晰反映Agent的执行状态用户才知道当前发生了什么——是正在理解、正在执行、还是执行失败了。我设计了一个五态枚举并且通过Observable驱动界面更新/// Agent执行状态直接映射到SwiftUI界面 enum AgentExecutionState { /// 空闲等待用户输入 case idle /// 正在把用户意图解析成技能调用 case parsing /// 正在执行某个技能附带技能名称 case executing(skillName: String) /// 执行完成附带结构化结果 case finished(AgentMessage) /// 执行失败附带错误信息 case failed(String) }这五态对应到界面上就是输入框可编辑/提交后转圈A面文案/正在执行的技能名称展示/结果卡片刷新/错误横幅提示。状态切换全部发生在MainActor上避免后台上报状态时触发SwiftUI的线程安全崩坏。3.4 技术选型的几个关键决定基类视图模型用Observable宏需要iOS 17。相比ObservableObject它更轻量、更新更精准配合Environment注入可以避免多层传递。所有技能执行用async/await不使用回调闭包。回调嵌套在技能数量一多以后排查链路会让你怀疑人生。超时控制统一在调度器做技能各自不处理超时。记住大模型服务可能在任何时候不响应30秒无返回就必须走失败分支。4. 工程落地依赖选择、权限声明与调试基建架构设计完真正往工程里落的时候我发现有一堆跟架构无关但直接影响稳定性的细节。这些坑属于“文档里不会写、不踩不知道”的那种。4.1 最低系统版本与依赖清单我把最低部署版本定为iOS 17原因有三Observable宏和Environment注入需要iOS 17。SwiftData在iOS 17才稳定我后续的技能实现大量用到它。iOS 17的设备在2025年的渗透率已经足够高不值得为老版本牺牲架构简洁性。依赖方面我只保留了两类一类是网络请求层任意封装的URLSession或轻量网络库一类是JSON解析层。大模型的接口调用我没有引入SDK而是直接用URLSession构造HTTP请求。这样做的好处是技能层完全不受具体模型厂商的SDK绑定换模型服务商只改调度器里的一个网络客户端实现技能层零改动。4.2 Info.plist权限声明清单技能要访问系统能力权限声明是第一个暗坑。我整理了一张表照着配就行权限Info.plist Key何时触发备注网络无默认允许技能需访问云端时ATS需允许HTTPS通知无需用户授权计时/提醒技能使用UNUserNotificationCenter健康数据NSHealthShareUsageDescription读取健康统计技能还分读/写两种要分别声明麦克风NSMicrophoneUsageDescription语音输入技能如有仅App内语音识别时需要文件/照片视具体技能文件读写技能用UIDocumentPicker可绕过有一个经验健康数据的权限描述文案一定要写到具体功能。我遇到过审核因为“描述太泛”被拒的情况后来改成“用于在健康统计技能中展示您的步数和心率趋势”才顺利通过。这属于审核经验范畴但技术上不写清楚一样会出问题。4.3 技能沙盒调试日志技能多了以后定位问题最大的敌人是“不知道哪一步出了问题”。我给整个链路加了一条贯穿式调试日志每个技能执行时自动打印以下内容用户原文Agent解析出的技能ID原始大模型响应完整保留净化后的JSON技能入参解码成功与否技能执行耗时技能出参编码成功与否打印到一个统一的格式里。可读之后排查效率会提升一个量级——你能一眼确认问题出在意图识别、入参解码还是技能执行本身。后续做离线回放测试时这些日志还能当测试样本用。5. 四项可复用的Agent技能实现详解架构和工程准备就绪后最关键的就是技能本身。我从实际场景里挑了四个典型技能展开讲计时器本地动作、天气查询网络动作、健康统计系统权限动作、备忘管理数据持久化动作。这四个技能覆盖了大多数SwiftUI App会用到的技能类型。5.1 计时器技能本地不依赖网络的技能范式计时器是“技能需要主动触发UI更新”的一个好案例。基础实现如下/// 计时器技能的入参 struct TimerInput: Codable { /// 倒计时秒数 let duration: Int /// 计时关联的用途名称如“煮面”“午休” let timerName: String } /// 计时器技能的出参 struct TimerOutput: Codable { /// 倒计时结束时间ISO8601字符串 let fireDate: String /// 展示文本 let message: String } /// 计时器技能 struct StartTimerSkill: AgentSkill { let id startTimer let displayName 设置倒计时 let semantics 用户想要启动一个倒计时或定时提醒时触发…… func execute(input: TimerInput) async throws - TimerOutput { let fireDate Date().addingTimeInterval(TimeInterval(input.duration)) let content UNMutableNotificationContent() content.title 定时提醒 content.body input.timerName let trigger UNTimeIntervalNotificationTrigger( timeInterval: TimeInterval(input.duration), repeats: false ) let request UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: trigger ) try await UNUserNotificationCenter.current().add(request) return TimerOutput( fireDate: ISO8601DateFormatter().string(from: fireDate), message: 已设置\(input.duration)秒倒计时\(input.timerName) ) } }有两个细节要提醒一是UNUserNotificationCenter.add本身是异步安全的可以直接await但需要在调用前确保通知权限已经申请过。二是计时器结束后面向UI的反馈不应该由计时器技能直接把状态推到界面——技能只负责“创建通知”界面监听通知中心的回调来更新UI状态。这样技能保持纯粹界面逻辑不会被技能代码绑架。我最初把“更新UI状态”写进了技能内部导致计时器技能深深耦合了视图模型后面重构才拆开的。5.2 天气查询技能处理外部服务的不稳定性天气查询涉及外部网络服务。技能内部封装了“地理位置获取、天气数据拉取、格式整理”三条链路。struct WeatherInput: Codable { /// 城市名或经纬度字符串 let location: String } struct WeatherOutput: Codable { /// 温度摄氏度 let temperature: Double /// 天气状况文案 let condition: String /// 今日最高/最低温 let high: Double let low: Double } struct WeatherSkill: AgentSkill { let id queryWeather let displayName 天气查询 let semantics 用户询问某地天气、温度、是否下雨等…… func execute(input: WeatherInput) async throws - WeatherOutput { // 1. 地理编码把城市名或自然语言描述转成坐标 let coordinate try await Geocoder.shared .coordinate(for: input.location) // 2. 拉取实时天气 let weather try await WeatherService.shared .fetchRealtime(coordinate: coordinate) // 3. 映射为结构化输出 return WeatherOutput( temperature: weather.temperature, condition: weather.conditionText, high: weather.high, low: weather.low ) } }这里的核心难点不是天气服务本身而是用户输入的位置描述非常不标准。用户可能说“我这儿的天气”“北京天气”“明天杭州会不会下雨”。三句话对应三种解析深度。我的处理是第一类直接取系统定位权限后的当前位置第二类走地理编码第三类需要通过Agent解析出“明天”这个时间语义再用天气服务的历史预报接口查数据。外部服务的另一大问题是可能下线或变慢。我给这类技能统一加了重试机制第一次失败后隔两秒重试一次最多重试三次。如果三次都失败调度器会走失败分支把“天气服务暂时不可用”的明确提示返回给UI而不是直接抛一个看不懂的底层错误。5.3 健康统计技能系统权限与数据整合的实现细节健康数据是系统权限最重的技能写起来很考验对HealthKit框架的熟悉程度。struct HealthStatInput: Codable { /// 统计类型步数、心率、睡眠等 let metricType: String /// 时间范围今天、本周、本月 let period: String } struct HealthStatOutput: Codable { /// 数值数组按天排列 let values: [Double] /// 日期标签便于UI展示 let dateLabels: [String] /// 汇总信息 let summary: String } struct HealthStatSkill: AgentSkill { let id queryHealthStat let displayName 健康数据统计 let semantics 用户可以要求App读取并展示健康数据…… func execute(input: HealthStatInput) async throws - HealthStatOutput { let type: HKQuantityType switch input.metricType { case 步数: type HKQuantityType(.stepCount) case 心率: type HKQuantityType(.heartRate) case 睡眠: type HKCategoryType(.sleepAnalysis) default: throw SkillError.unsupportedMetric } // 聚合查询最近的周期数据 let samples try await HealthStore.shared .querySamples(type: type, period: input.period) // 转成按天聚合的数组 return aggregate(samples, period: input.period) } }写这类技能最重要的经验是HealthKit查询必须在HKHealthStore()的正确实例上进行而且iOS 17以后的查询接口统一改成了异步版本老的闭包式写法还能用但不推荐。另外一个坑是权限——一次只能请求一个数据类型的读取权限多个类型一起请求会出现不必要的权限弹窗轰炸用户拒绝率极高。所以我在技能前先做了一次“引导授权页”让用户一次性勾选多个权限技能只负责在授权通过后读取数据。5.4 备忘管理技能SwiftData持久化与UI实时联动最后一个技能演示了技能如何跟SwiftData协同工作。用户的原始需求可能是“记一下我明天下午要开会顺便提醒我带笔记本”——这是一个非常典型的模糊多意图输入。struct MemoInput: Codable { let title: String let dueDate: Date? let reminderBody: String? } struct MemoOutput: Codable { let memoID: String let createdDate: Date let actionableCount: Int } struct CreateMemoSkill: AgentSkill { let id createMemo let displayName 快捷备忘 let semantics 用户需要快速记录一段事项…… func execute(input: MemoInput) async throws - MemoOutput { // 1. 写入SwiftData let memo MemoItem( title: input.title, createdAt: .now, dueDate: input.dueDate ) let context ModelContext() context.insert(memo) try context.save() // 2. 如需提醒创建本地通知 if let dueDate input.dueDate, let body input.reminderBody { try await scheduleNotification(date: dueDate, body: body) } return MemoOutput( memoID: memo.id.uuidString, createdDate: .now, actionableCount: 1 ) } }不过需要注意SwiftData的ModelContext不能跨线程使用——技能在后台Task里执行时要么在任务内部创建独立的ModelContext要么通过MainActor.run切回主线程操作。我采用的方式是让“写数据库”这个环节强制回主线程执行虽然牺牲一点性能但换来的是记忆一致性实际体感差别很小。还有一件重要的事备忘创建成功后UI列表需要自动刷新。我的解法是调度器在技能完成后发出一条“数据变更”广播所有监听备忘列表的视图模型收到后重新查询SwiftData。技能本身不关心谁在监听只负责把数据写进去查询和刷新是界面层的责任。6. 线上踩坑实录线程冲突、注册失效与大模型格式炸弹架构跑通之后我一度以为可以高枕无忧了。真到线上测试发现三个问题几乎榨干了我的排查耐心——现在都写出来希望你能避免。6.1 Task里更新UI状态引发的“MainActor隔离”崩溃这个坑是这样的技能在后台Task里执行执行完了直接调用了self.state .finished(result)更新视图模型然后就崩了。错误信息指向MainActor隔离。原因很好懂SwiftUI的视图模型必须在主线程更新状态。async/await的优雅之处在于它会自动帮你在两套执行上下文之间切换但前提是视图模型类标注了MainActor。我的视图模型当时漏掉了这个标注导致编译器没有强制主线程更新运行期才炸。修复方式很简单给视图模型加一行标注MainActor Observable final class AgentViewModel { var state: AgentExecutionState .idle // ... }这个坑典型的教训是任何持有UI状态的类从一开始就标注MainActor别等崩溃了再去补。异步代码在主线程隔离和后台并发混合时编译器不会永远帮你兜底。6.2 技能注册顺序导致的路由失效第二个坑更隐蔽。我一开始把技能注册表设计成了“顺序遍历匹配”最先匹配到的技能生效。表面上没问题但实际的语义匹配是基于大模型的模型并不是按描述接近度排序的——它是在所有可用技能里“选一个最像的”。注册顺序会影响模型能看到哪些技能但不影响最终匹配结果。问题真正出现在技能注册表更新不及时上。我用了懒加载注册第一次执行Agent前才把技能列表注入。后来在运行时动态增加了一个“查快递”技能但没有触发重新注册调度器里仍然查不到它——更糟糕的是大模型此时仍然能看到新增能力的描述导致它生成了“查快递”的意图包路由层却找不到处理器最后只能走失败分支回退。修复方案是放弃懒加载改为启动时一次性全部注册并把注册表设计成“快照不可变”的模式。要加新技能必须像发版一样走完整注册流程而不是热插拔。这样虽然少了点动态性但换来了确定性我后来还想搞技能热更新想想还是作罢——稳定性优先。问题类型表现根因修复方案注册顺序部分意图匹配错技能注册表未全量刷新启动时全量注册不可变快照线程崩溃UI状态更新时抛MainActor错误视图模型未标注MainActor视图模型统一标注模型格式JSON解码失败率30%大模型输出不规范净化层容错解码强约束输出6.3 大模型格式炸弹与容错解码这条是最让我头疼的。我实测用了两套不同的云端模型服务做对比同一套Prompt格式化输出成功率从95%到60%不等。后者简直是一场灾难。最后我总结出的对抗策略是“三层防线”第一层Prompt强约束。在Prompt末尾加上“你必须只输出一个JSON对象不要包含任何解释性文字、Markdown标记或注释。若无法理解用户意图输出{\“skill_id\”:null}”。第二层净化器。把大模型返回的字符串预处理一遍剥离首尾的反引号包裹、去除json标记、裁掉说明性文字的前缀后缀。净化后交给默认解码逻辑。第三层容错解码。多个已知容错路径进行尝试static func decodeAgentCall(from raw: String) throws - AgentCall { // 第一遍净化后直接解码 let cleaned purifyJSON(raw) if let data cleaned.data(using: .utf8) { do { return try JSONDecoder().decode(AgentCall.self, from: data) } catch { // 第二遍尝试把字符串值转成数字模型偶尔会给数字加引号 let repaired repairNumericQuotes(cleaned) ... } } // 兜底从原始字符串中用正则抽取关键字段 throw AgentError.undefinedIntent }这一套下来解码失败率从30%降到了大约1%。剩下的1%用引导语兜底。6.4 技能超时与资源释放最后一个经验是技能必须要有一个“全局超时”机制。我最开始没在意某个网络技能在外部服务挂掉时一直等待直到用户界面卡死。后来在调度器里加了统一的Task超时封装单个技能最长执行时间限制为20秒超时自动转入失败状态同时取消底层网络请求。func runWithTimeoutT(_ operation: () async throws - T, timeout: Duration .seconds(20)) async throws - T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: timeout) throw SkillError.timeout } // 第一个完成的任务胜出其余自动取消 guard let result try await group.next() else { throw SkillError.emptyResult } return result } }这里有个细节withThrowingTaskGroup会等所有子任务完成才返回所以如果超时任务先抛错操作任务还在挂起会导致资源不释放。需要在超时分支里主动调用group.cancelAll()再抛错。这个小细节困惑了我整整一个下午。7. 进一步演进从单Agent到多Agent协作与离线意图识别现在这套架构跑得已经很稳了。但如果你的需求继续变大有几个方向是值得往下走的。7.1 多Agent按领域拆分技能数量超过15个以后单个Agent的语义空间变得过于拥挤路由准确率开始下降。我的解法是拆成多个Agent有一个日程调度Agent负责所有跟时间相关的技能一个数据查询Agent负责外部信息获取一个系统控制Agent负责设备本地操作。调度层再加一个“主控Agent”先判断用户意图属于哪个领域再交给对应领域的Agent处理。这个层级关系做出来后任务划分的粒度反而变清晰了新增技能时不需要动主控逻辑只往对应Agent注册即可。7.2 端侧小模型做意图初筛大模型在线解析有一个无法回避的问题延迟。哪怕一次意图解析只要800毫秒也比纯本地方案慢很多。我测试过把“意图解析”拆成两步先用端侧小型分类模型判断意图属于哪个领域比如“计时”“查询”“备忘”只有领域内技能确实不匹配时才需要大模型介入。不过端侧模型对中文语义理解能力波动大我目前的策略是“高置信度才走端侧低置信度全部走云端”。这样既保证了大部分简单交互的低延迟又不会因为端侧误判导致体验割裂。7.3 技能结果缓存与离线可用性网络型技能天气、新闻等天然依赖在线。但“缓存”和“最后成功结果”可以在断网时大幅提升体验。我的做法是每个网络技能在成功执行后把输出结果编码存档到本地下次执行时如果网络请求失败返回缓存结果并附上“数据更新于几小时前”的标注。这种方法简单、实用、而且很受用户欢迎。8. 给想上手的人一句话从小处做起最后说点个人体会。SwiftUI-Agent-Skill这套东西刚有雏形的时候我也犯过一个典型错误想着把所有功能一次性全部技能化结果做了两个星期发现一半技能根本用不上反而消耗了巨大精力。后面学乖了从最核心的三个高频场景开始——计时提醒、快捷备忘、天气查阅——这些是用户每天都在用、且意图足够清晰的功能。先把这三个跑通再逐步扩展。Agent和Skill的设计红利不在于一开始就搭一个庞大的宇宙而在于你后面每加一个新技能都只写一个实现、注册一行UI层不用动。这种“渐变式扩展”的舒适感才是这套架构最大的回报。如果你准备自己动手建议先把AgentSkill协议和调度器的三层流程意图解析、路由、结果处理跑通再往里填技能。协议和调度是地基技能是砖瓦。地基搞得足够扎实后面每次盖楼才会顺手。