
这阵子我一直在折腾一个基于 SwiftUI 的“Agent Skill”项目也就是标题里那个SwiftUI-Agent-Skill。简单说这是一个把大模型对话能力和系统功能调用打通的应用框架用户用自然语言说话Agent 负责理解意图然后调度到具体的 Skill 去执行最后把结果再带回对话里。你问“帮我明天上午十点约个会议室”它不只是回一句“好的”而是真的往系统日历里写一条日程。这种模式在苹果生态里做起来比想象中麻烦因为 SwiftUI 本身只是个 UI 框架不负责意图识别也不负责工具调度。我踩了不少坑之后把这套东西拆成了几个清晰的模块今天这篇就完整梳理一遍从架构设计、协议定义、并发处理到实际接入 SwiftUI 视图和排错经验。适合正在做 iOS / macOS 智能助手类应用、或者想在苹果端落地 Agent 能力的开发者参考。无论你是刚接触 Swift 还是已经有几年经验这篇文章里的关键代码和取舍逻辑应该都能帮你少走几个弯路。1. 项目整体设计与思路拆解1.1 项目定位与核心需求SwiftUI-Agent-Skill本质上解决的是一个很具体的问题大模型本身只能输出文本它没法直接操作你的日历、备忘录、文件系统也发不了网络请求。要让 AI 真正“干活”你必须给它提供一套可执行的工具。这就是 Skill 的定位。Agent 则是调度的大脑。它负责几件事把用户的自然语言输入整理成结构化请求根据请求选择合适的 Skill解析 Skill 执行结果再组织成用户能看懂的自然语言回复这个分工其实和很多后端 Agent 框架的思路一致但在 SwiftUI 场景下有几个额外诉求状态要能驱动 UI 刷新、异步任务要跑得稳、Skill 要做得易扩展。比如用户问完问题之后界面上要能看到“思考中 → 正在调用日程工具 → 已写入日历”这样的实时反馈否则体验会非常生硬。我最初也想过直接用某个现成的 SDK 把 Function Calling 包装一下但试了一圈发现要么是重量级 SDK 绑定了特定服务商要么是框架层强调服务端逻辑、在客户端集成很别扭。最后决定自己封装一套轻量的 Agent-Skill 内核这样既能适配任意 LLM 服务也可以随时往注册表里加新技能不需要改动核心代码。1.2 技术选型背后的理由很多 Swift 开发者会问为什么不用ObservableObject而是用Observable为什么 Skill 协议里要带一个描述用的元数据这些选择背后都有实际原因。先说状态管理。iOS 17 以后苹果主推Observable它在视图刷新粒度上比ObservableObject更精准ObservableObject只要任何一个Published属性变了整个观察体就刷新Observable则只刷新真正被读取属性的视图。这对 Agent 这种状态变化频繁的场景很重要尤其是对话界面里的消息列表、技能执行进度、输入框的可用状态这些控件各自关心不同的状态没必要一锅端地全部重绘。再说 Skill 的描述。大模型是根据字符串描述来“理解”一个工具能不能用的所以每个 Skill 不能只提供一个执行方法还要提供一个面向 LLM 的说明书——包含技能名称、触发场景、参数结构。这个说明书写得好不好直接决定模型能不能正确调用技能。后面我会专门展开讲这块因为它是我在整个项目里调试时间最长、收益也最明显的部分。1.3 模块划分与数据流我最终把项目拆成了四个核心模块模块职责关键内容AgentCore意图理解、对话管理接收输入、维护消息历史、解析 LLM 返回的 tool_callSkillRegistry技能注册与查询保存所有 Skill 实例、向 LLM 提供技能描述列表Skill 层具体执行能力每个 Skill 独立实现输入解码、系统调用、结果输出SwiftUI UI 层展示与交互消息列表、技能执行状态卡片、输入栏数据流是单向的用户在界面输入 → AgentCore 组装请求 → 携带 SkillRegistry 的技能描述一起发给 LLM → LLM 决定调用哪个 Skill 并返回结构化参数 → AgentCore 查注册表拿到对应 Skill 实例 → 执行并拿到结果 → 结果再交给 LLM 生成最终回复 → UI 展示。整个过程里SkillRegistry 是一个纯内存的注册中心不持有任何业务逻辑只做匹配和分发。这样新增技能时只需要初始化一个实例并调用register方法完全不用改动 Agent 核心。2. 核心细节解析与实操要点2.1 Skill 协议设计与扩展性Skill 协议是整个框架的地基设计得不好后面每加一个新能力都难受。我的最终版本是这样的protocol Skill: Identifiable, Sendable { var id: String { get } var descriptor: SkillDescriptor { get } func execute(input: Data) async throws - SkillResult }协议只有三个要求id 用于注册表索引descriptor 是给 LLM 看的描述和参数结构execute 是真正的执行入口。input 用Data而不是具体的 Codable 类型是为了让协议不绑定任何特定参数结构——每个 Skill 自己决定如何解码输入这样有的技能可能要传经纬度坐标有的要传联系人列表都可以各自维护自己的输入模型。SkillDescriptor是支撑整个调度过程的关键struct SkillDescriptor: Codable, Sendable { let id: String let name: String let description: String let parametersSchema: String }调用 LLM 工具接口时系统需要传一个 JSON 格式的工具列表parametersSchema就是直接塞给模型看的。这个字段我用 JSON Schema 格式编写大多数商业 LLM 的 Function Calling 接口都能直接兼容不需要额外的格式转换。实际项目中我加过定时器、网络请求、本地搜索等技能每次新增都只需要关注三件事技能描述怎么写、参数 schema 怎么定、execute 里怎么调用系统 API。AgentCore 和 UI 层零修改这一点让我非常满意——框架的价值就在于“加功能不改核心”。2.2 技能描述怎么写模型才听得懂这是我在整个项目里最想强调的经验Skill 的描述不是给人看的注释而是给模型看的“操作说明”。大多数开发者在第一步就把这事想简单了写个“创建日程”四个字就完事结果就是模型根本没法判断什么时候该调这个工具。我对比过几轮实验差别非常明显描述类型实际效果“创建日程”模型经常在用户只提到“开会”时就误调用且参数经常漏填日期带触发场景、参数约束、缺省策略的完整描述调用时机准确、参数完整率明显提升用户在时间不全时会被反问问清楚最终我给日程技能写的是这样一段当用户想要安排会议、创建提醒、规划待办事项时调用本技能。系统会读取用户提供的日期和时间自动写入系统日历。参数 title 为事件标题startDate 为 ISO8601 格式的开始时间durationMinutes 为持续分钟数。如果用户没有给出具体时间不要调用本技能应当先向用户询问开始时间。注意最后这句话——“如果用户没给出具体时间不要调用”。这是很多开发者容易遗漏的关键约束。模型默认倾向于“猜测并执行”如果你不明确告诉它何时不该调用它会在信息不全时强行填一个当前时间进去。加上这个反例描述之后模型的调用准确率提升非常显著。另外我强烈建议在 description 里加入“示例”。比如对天气查询技能补一句“例如用户问‘上海明天冷不冷’应当传 location上海date明天”模型会参考这个示例来构造参数这比单纯罗列字段描述有效得多。你可以把这理解为喂给模型的小样本提示Function Calling 的场景下少量示例往往比一个严密的 JSON Schema 更管用。2.3 Agent 状态驱动的 UI 刷新Agent 的运行不是一蹴而就的发起请求、等待模型返回、执行技能、再次请求模型生成回复每个环节都有状态变化。为了把过程展示清楚我定义了一个显式的状态枚举enum AgentState: Equatable { case idle case thinking case executing(skill: String) case responding }这个状态配合Observable之后SwiftUI 视图可以直接根据状态切换展示内容thinking时显示转圈并输出“正在思考”executing(skill:)时显示一个技能卡片标注当前正在执行的工具名称responding时显示“正在组织回复”。我用了一个小技巧把状态映射成界面上的“阶段指示器”类似一个 Step 列表用户能清楚看到当下进行到哪一步。实测下来这种过程可见性对主观体验的提升巨大哪怕底层响应慢一点用户也会觉得应用更“聪明”而不是卡顿。3. 实操过程与核心环节实现3.1 从零搭建技能注册中心注册中心是整个框架的核心数据结构我设计得很轻本质上是一个带锁的字典加一个描述列表MainActor final class SkillRegistry: ObservableObject { private var storage: [String: any Skill] [:] func register(_ skill: any Skill) { storage[skill.id] skill } func skill(for id: String) - (any Skill)? { return storage[id] } var descriptors: [SkillDescriptor] { storage.values.map { $0.descriptor } } }我把它标成MainActor是有原因的Agent 的执行和 UI 状态经常同步读写放到主 actor 上可以避免一堆数据竞争问题。实际调用 Skill 的时候虽然可能涉及网络请求、文件读写但 Skill 内部自己开异步子任务注册中心只负责同步查询和枚举因此不会阻塞 UI。App 启动时把所有内置技能注册进去let registry SkillRegistry() registry.register(CreateEventSkill()) registry.register(WeatherQuerySkill()) registry.register(ReminderSkill())这一步我看到很多人在做的时候会纠结要不要用“自动扫描”或“反射注册”在 Swift 生态里没必要手动注册几个技能是十几秒的事换来的是清晰的依赖关系和编译时就有的类型检查。3.2 实现一个真正可用的技能日历日程写入以日程创建技能为例看一个完整的 Skill 实现长什么样。输入参数用嵌套结构这样执行方法里解码会很干净struct CreateEventSkill: Skill { var id: String { create_event } var descriptor: SkillDescriptor { SkillDescriptor( id: id, name: 创建日历日程, description: 当用户想要安排会议、创建提醒、规划待办事项时调用……, parametersSchema: { type: object, properties: { title: { type: string, description: 日程标题 }, startDate: { type: string, format: date-time, description: 开始时间ISO8601格式 }, durationMinutes: { type: integer, description: 持续分钟数默认30 }, notes: { type: string, description: 日程备注可选 } }, required: [title, startDate] } ) } private struct Input: Codable { let title: String let startDate: String let durationMinutes: Int? let notes: String? } func execute(input: Data) async throws - SkillResult { let params try JSONDecoder().decode(Input.self, from: input) let store try await withCheckedThrowingContinuation { continuation in // EventKit 的授权和写入代码省略 } let message 已创建日程\(params.title)开始时间 \(params.startDate) return SkillResult(success: true, output: message) } }注意durationMinutes在 Input 中是可选类型。模型经常不填不必要字段如果把它设为必填会因为缺参数导致解码失败。所以在 schema 里我只把title和startDate设成 required其他字段要么给默认值要么走“可选 代码兜底”的策略。这一点非常关键LLM 输出的 JSON 不像人手写的那么稳定宽松解码、默认值兜底能省掉大量调错时间。3.3 自然语言到技能调用的完整流程有了注册中心和 Skill 实例接下来是 AgentCore 的核心循环。我用的是一个比较标准的工具调用流程func processUserInput(_ text: String) async { state .thinking let messages buildMessages(addingUserText: text) let firstResponse try await llmProvider.chat( messages: messages, tools: registry.descriptors ) // 模型决定调用工具 if let call firstResponse.toolCall { state .executing(skill: call.name) guard let skill registry.skill(for: call.name) else { // 技能不存在把错误反馈给模型 return } do { let result try await skill.execute(input: call.argumentsData) // 工具结果回传给模型让模型组织自然语言回复 let finalResponse try await llmProvider.chat( messages: messages [ toolResultMessage(name: call.name, content: result.output) ] ) appendMessage(finalResponse) } catch { // 失败时直接把错误信息作为工具结果给模型 let errorMessage toolResultMessage( name: call.name, content: 执行失败\(error.localizedDescription) ) let retryResponse try await llmProvider.chat(messages: messages [errorMessage]) appendMessage(retryResponse) } } else { // 模型没有调用工具直接回复 appendMessage(firstResponse.message) } state .idle }这个流程最精华的地方在第 24 行到第 30 行Skill 执行失败时不是简单地把错误抛给用户而是把错误信息重新交给 LLM让模型自己决定是修正参数重试、还是换一个表达方式解释问题。比如模型调用日程技能时传了一个已经被占用的事件 ID技能返回错误模型会在下一轮回复中告诉用户“这个时段已有日程建议换到下午三点”而不是显示一句冷冰冰的系统错误。实际测试时我还发现给模型传参数时保留原始 JSON 比编码成字符串更可靠。call.argumentsData是模型返回的原始 JSON 数据解码成Input时类型更干净。如果中间转一次字符串格式偶尔会多出转义符导致解码失败。3.4 接入 SwiftUI 视图层UI 层我用一个AgentViewModel封装状态和逻辑Observable final class AgentViewModel { var messages: [ChatMessage] [] private(set) var state: AgentState .idle private let registry SkillRegistry() private let agent AgentCore() func send(_ text: String) async { let userMessage ChatMessage(role: .user, content: text) messages.append(userMessage) await agent.processUserInput(text) } }视图层就变得很直白了struct ChatView: View { State private var vm AgentViewModel() var body: some View { VStack { List(vm.messages) { message in MessageRow(message: message) } if case .executing(let skillName) vm.state { SkillStatusCard(name: skillName) } InputBar { Task { await vm.send($0) } } } } }SkillStatusCard是我额外加的一个小组件它把“当前在调用什么技能”变成可见状态。你可以在这里加进度条、技能图标、耗时统计都不影响核心逻辑。这个设计还有一个好处以后要接 IPC、推送或桌面小组件来展示 Agent 进度可以直接复用同一个状态源。4. 常见问题与排查技巧实录这一节的内容是我在真机调试和连续多天日常使用中积攒下来的。很多坑是意想不到的列出来给各位参考。4.1 模型返回的参数 JSON 不合法LLM 输出的tool_calls里的参数不一定每次都是合法 JSON。我遇到比较多的情况包括字段名被缩写、时间格式不是 ISO8601、数字被判定成字符串等。最典型的一次是模型把startDate: 2025-03-01 10:00传进来而不是2025-03-01T10:00:00Z导致解码器直接抛错。我的解决方案是分两层处理。第一层是捕获解码错误时把错误信息回传给 LLM让模型自己修正格式第二层是给核心字段写宽松解析比如时间字段用ISO8601DateFormatter尝试多种格式解析不了再抛错。这两层配合后绝大部分情况都能自动通过。4.2 并发更新 UI 导致视图卡顿早期版本我直接在execute方法里调用 EventKit 写入EventKit的接口是同步的写日历的操作会阻塞当前线程。因为 AgentCore 的调用链是在主线程上跑的结果就是点击发送后整个界面僵住一两秒。排查思路很直接在 Xcode 的 Instruments 里看 Time Profiler发现主线程上有大量 EventKit 堆栈占用。修复方式是所有耗时操作都放到独立的异步函数里并用await等待确保主线程只做 UI 更新。框架层面也可以加一道防御给SkillRegistry加上MainActor但 Skill 的execute用nonisolated或者放到后台任务队列里执行。4.3 同一个技能被并发调用导致状态冲突多轮对话里用户可能在上一轮技能还没执行完就发送了新消息。比如问“帮我查天气”马上又跟一句“顺便建个日程”。如果不加控制两条流程会同时执行日历写入就可能和天气查询的状态互相覆盖。我最后用了一个简单的串行队列AgentCore 内部维护一个Task链新的输入会等待上一个任务结束后再执行。极端情况下你可以用AsyncSemaphore做并发控制但对绝大多数应用场景来说串行处理用户输入是符合预期的——因为对话本身就有先后顺序用户很少需要真正并发的 Agent 处理。4.4 技能注册了但模型就是不调用这个问题最隐蔽也是最让人恼火的技能明明注册了描述写得也不错但模型就是视而不见。我排查后发现主要是两个原因。第一个原因是注册顺序。有些 LLM 服务商对工具列表的长度和顺序敏感工具过多时偶尔会忽略排在后面的工具。解决办法是给工具列表按调用频率排序常用技能放前面或者把一个 Agent 负责的技能数量控制在 5-8 个以内。第二个原因是名字和描述里的关键词不匹配。模型是根据文本相关性来判断何时调用技能的如果用户在输入里用了“改期”“挪一下时间”而技能描述里只写了“创建日程”模型就会犹豫。我的经验是在描述里把常见的同义说法都列一遍“改期、延期、换时间、重新安排都可以调用本技能”。这样模型的命中率会大幅提高。4.5 状态机状态残留与 UI 卡死还有一个值得记录的问题如果用户发送消息后立刻切后台或者 LLM 请求超时状态机可能停在executing状态界面一直显示“正在调用技能”。排查后发现是异常分支没有清理状态。修复方式是在processUserInput外面套一个defer确保无论什么路径退出最终都会回到state .idle。这个经验对所有异步状态机都通用状态恢复必须和退出绑定而不是只放在成功分支里。为了便于参考我把典型问题整理成一个简洁的速查表问题根因处理方案参数解码失败LLM 输出的 JSON 格式不合规宽容解码 错误信息回传 LLM 重试界面卡顿同步操作阻塞主线程耗时任务异步化主线程只刷 UI状态互相覆盖并发处理用户输入AgentCore 内维护串行任务队列技能不触发描述缺少场景关键词列全同义说法控制技能总数状态卡死异常分支未恢复状态用 defer 确保状态回归 idle5. 性能优化与后续扩展路径5.1 抽象 LLM Provider 以支持切换框架运行的核心依赖是 LLM 返回结果但我不希望整个项目绑死在哪一家模型服务上。所以我抽了一个LLMProvider协议让所有模型请求都走统一接口protocol LLMProvider: Sendable { func chat( messages: [ChatMessage], tools: [SkillDescriptor] ) async throws - LLMResponse }这样 AgentCore 完全不知道底层是云端大模型、私有化部署还是端侧模型只要实现协议就能运行。我日常调试用的是一个轻量级云端接口同时也在实验把简单意图识别分流到端侧模型减少不必要的网络请求。这个抽象对后续做多模型切换、灰度测试、离线模式都很有价值。5.2 从单技能调用走向多技能编排当前实现处理的是“一轮对话调用一个技能”但实际用户需求经常是复合的“帮我查下明天天气如果下雨就提醒我带伞”。这需要 Agent 具备多步推理和编排能力。我计划引入一个简单的计划器当意图识别为复合任务时Agent 先拆解出子任务清单逐个执行对应 Skill再用最终结果组装回复。技术上就是在一个循环里重复“调 LLM → 执行工具 → 回传工具结果”直到 LLM 认为所有子任务完成。这个模式的工程改造成本不大但能明显提升真实场景的可用性。5.3 离线技能与隐私边界苹果生态里用户对隐私的敏感度很高这个项目也天然适合做端侧优先本地日历、提醒、文件都是不需要联网就能执行的 Skill。真正需要联网的只有 LLM 请求本身。我调研过 iOS 17 起引入的端侧自然语言框架可以用它做短文本的意图分类把“打开设置”“新建备忘录”这类简单请求直接映射到本地技能不经过网络响应速度快、隐私性也好。LLM 更多是被用来处理需要复杂理解的长尾请求。这种混合调度的思路既控制了成本也提升了体验是后续比较理想的演进方向。最后说一个我实际开发中的体会Agent 项目调试时最大的成本其实不在代码而在于和 LLM 交互时的“预期管理”。你写了一个技能描述以为模型一定能理解但它就是会用偏。我的办法是在开发阶段写了不少自动化测试用例把典型用户语句喂进去检查模型是否选对技能、参数是否合理。这样每次调整描述或 schema 后跑一遍用例回归很快就能迭代出稳定的表现。如果你打算把这套模式用到自己的项目里我强烈建议你从第一天就保留这样的测试集它会是你最可靠的护航工具。