恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
macOS 本地智能助手开发实战:SwiftUI 中的 Agent 与 Skill 编排设计
首页
资讯中心
/
macOS 本地智能助手开发实战:SwiftUI 中的 Agent 与 Skill 编排设计
macOS 本地智能助手开发实战:SwiftUI 中的 Agent 与 Skill 编排设计
发布时间:2026/10/21 4:03:02
先说结论如果想在 macOS 上做一个真正能替代日常机械操作的本地智能面板别急着把一堆功能堆成脚本先把“Agent”和“Skill”拆开想清楚。我最近一直在维护一个用 SwiftUI 写的 macOS 小应用壳代号就叫 SwiftUI-Agent-Skill。简单说它是一个“本地智能助手”的骨架工程左边是一个 SwiftUI 写的对话/指令面板右边是一个 Agent 调度核心再往下是各种可插拔的 Skill 技能模块。用户用自然语言提需求Agent 负责拆解和编排Skill 负责真正动手做事比如读日历、整理文件、发通知、归档资料。这篇文章不是讲某个现成框架的二次封装而是把我自己从零把 Agent 和 Skill 拆开、再拼回去的全过程记录下来。内容会覆盖协议设计、权限处理、并发隔离、上下文控制这些最容易栽跟头的地方。如果你也打算在苹果生态里做类似的智能助手、自动化工具或者想把 LLM 能力接进自己的 SwiftUI 应用这篇应该能帮你省掉不少弯路。1. “Agent Skill”拆法的动机从单任务脚本到可组合工具箱1.1 为什么我不想再写一堆脚本最开始我的自动化需求其实很零散比如“会议前把某目录的 PDF 归档到指定文件夹”“每天早上读一下日历告诉我今天有几个会”“临时记录一段想法然后丢进备忘录”。这类需求用 AppleScript、快捷指令、或者 Python 脚本都能单独做但每新增一个需求就得重新写一个脚本、重新处理一遍权限、重新定一个入口。真正让我崩溃的是这些脚本之间的协作。比如“会前准备”这个动作实际包含查日历、打开会议链接、生成一份待办、把相关材料复制到会议目录——四个独立功能分散在四个脚本里想串起来就得自己写胶水代码而且每次改需求都得动调度那部分。这不合理。于是我把问题反过来想既然所有需求本质上都是“用户说一句话系统执行一连串动作”为什么不做一个统一的调度器调度器不关心具体动作怎么做只负责“决定下一步该调用哪个动作”。而每个动作就是后来被我叫作 Skill 的东西。1.2 Agent 只负责“想”Skill 只负责“做”这是我拆第一个版本时最重要的决定Agent 和 Skill 必须完全解耦。所谓的 Agent核心是一个主循环。它接收用户的自然语言指令结合当前可用的 Skill 列表决定调用哪些技能、按什么顺序调用、参数填什么。它本质上是一个“意图理解 任务编排”的大脑。但它绝对不应该去碰具体实现比如不应该知道日历权限怎么申请、文件路径怎么拼接、通知要怎么弹。那些都属于 Skill 的职责。Skill 是真正做事的人。每个 Skill 只做一件具体的事对外暴露三个东西一个名称、一段给模型看的功能描述、一份参数协议。Agent 看到这些信息后选择合适技能并传入参数。Skill 拿到参数调用系统能力返回一个结构化的结果给 Agent。这个分层的好处是加一个新功能等于加一个新 Skill调度逻辑完全不用动。如果哪天换了更强的模型只需要替换 Agent 核心的实现Skill 一个都不用改。反过来如果 EventKit 的日历权限逻辑变了也只动对应的 SkillAgent 层根本不知道发生了什么。1.3 直接调用与 Agent 编排的对比维度传统脚本/快捷键Agent Skill 编排新增需求每个需求写一个新入口注册一个新 Skill入口天然统一组合能力靠写胶水代码拼接模型自动判断调用顺序变更影响调度和业务混在一起牵一发动全身分层隔离改动收窄到单层失败处理每个脚本自定义Agent 主循环统一判断是否需要重试或换方案对用户的要求需要记命令、记快捷键自然语言描述目标即可这套拆法的本质是把“需求理解”和“能力执行”分开让前者归模型管后者归本地代码管。模型再聪明也没法直接替你调用 EventKit但它能根据 Skill 的描述学会“什么时候该调日历技能”。本地代码再有权限也没法理解“帮我准备一下下午的会”但它能完成“列出下午的日程”这个具体动作。所以Agent 与 Skill 的分界线不是技术层面的而是职责层面的谁负责意图谁负责能力。分清楚了后面所有设计都会顺起来。2. 工程分层SwiftUI 壳、Agent 主循环、Skill 注册表2.1 目录结构与依赖方向工程里我把代码分成三层依赖方向是从上到下的UI 层依赖 Agent 层Agent 层依赖 Skill 层Skill 层不反向依赖任何上层。SwiftUI-Agent-Skill/ ├── App/ // SwiftUI 入口主面板 ├── UI/ │ ├── ChatPanel.swift // 对话流界面 │ ├── SkillStatusView.swift // 技能调用状态 │ └── SettingsView.swift ├── Agent/ │ ├── AgentLoop.swift // 主循环 │ ├── ToolRouter.swift // 技能路由 │ └── ContextManager.swift // 上下文管理 ├── Skills/ │ ├── SkillProtocol.swift // 技能协议 │ ├── CalendarSkill/ │ ├── ReminderSkill/ │ ├── FileArchiveSkill/ │ └── NotificationSkill/ └── Support/ ├── PermissionManager.swift └── Logger.swift这个目录结构本身就在强制一种纪律UI 不能直接调用 EventKit必须经过 Agent 层再去路由到技能。我见过太多工程把权限申请、业务逻辑直接写在 SwiftUI View 的.task里刚开始只有一两个功能时很方便等技能多了View 会变得无法维护权限问题会散落各处。依赖方向越干净后面加权限管理、加日志、加回放功能就越简单。因为所有系统能力调用点都被收拢到 Skill 层我只需要在一处做统一处理就能覆盖所有技能。2.2 Agent 主循环的三种退出条件Agent 的主循环本质上是一个 while 循环每一轮做三件事把当前对话历史交给模型模型返回一个响应如果响应里带有工具调用指令路由到对应 Skill 执行执行结果再作为新消息追加到历史里进入下一轮。这个循环不可能无限跑下去所以必须设计退出条件。我常用的有三个模型返回的响应里没有工具调用指令说明它认为任务已经完成输出最终答复。连续工具调用次数达到上限比如 10 次防止模型陷入死循环。某个 Skill 执行返回 fatal 状态并且模型尝试了重试仍然失败此时主动终止并告诉用户失败原因。实际跑下来第二和第三个条件远比第一个重要。模型在多步工具调用时偶尔会重复调用同一个技能而不自知如果没有次数上限它会一直把同样的参数发给同一个 Skill白白消耗算力用户看到的就是界面卡住半天。现在我直接把上限透出到设置面板让用户自己调。2.3 MainActor 与 UI 状态流转SwiftUI 对 UI 状态的更新必须在主线程而模型调用、网络请求、Skill 里的文件操作都天然是异步的。我一开始偷懒所有 Skill 都是普通async函数然后直接从 Agent 循环里调用结果经常遇到 SwiftUI 运行时警告偶尔还会出现“在后台线程更新了 UI 状态导致的偶发崩溃”。后来我把工程改成“Agent 主循环跑在后台任务里只通过一个MainActor的 observable 对象向 UI 发状态”。具体做法是MainActor final class AgentSessionModel: ObservableObject { Published var messages: [ChatMessage] [] Published var liveToolCall: ToolCallDisplay? Published var phase: SessionPhase .idle }Agent 循环内部处理完一轮就把结果提交给这个 model 对象一次性更新。UI 不直接订阅 Agent 内部的状态只订阅这个 session model。这个改动之后崩溃和警告几乎消失了。如果你打算自己做建议从一开始就明确这条规则任何跨线程的状态变更统一经由一个MainActor隔离的模型对象搞定不要想着用DispatchQueue.main.async到处补丁式修复。2.4 需要提前声明的权限列表macOS 上做自动化工具绕不开权限申请。我在工程里用一个 PermissionManager 统一管理目前涉及三类日历和提醒事项通过 EventKit 访问需要NSCalendarsUsageDescription和NSRemindersUsageDescription。通知通过 UserNotifications 框架虽然系统弹出的是运行时授权请求不需要 Info.plist 描述但最好在用户第一次启动时就引导授权。文件访问如果开启了沙盒需要用户通过文件选择器授权目录。如果需要持久访问要用 Security-Scoped Bookmark否则应用重启后授权会失效。这里最大的教训是权限申请一定要走一个统一入口而不是让每个 Skill 自己去申请。因为同一套权限可能被多个 Skill 共用。比如日历权限CalendarSkill 和 ReminderSkill 都要用如果各自申请用户会看到两三次重复弹窗。统一到一个 Manager 里做引用计数权限已经拿过就不要再触发弹窗。3. Skill 协议的最小可用设计声明、校验、副作用、回滚3.1 一份能跑起来的 Skill 协议设计协议时我坚持一个原则最小可用。协议里只放 Agent 真正需要知道的信息不要放一堆将来可能用得上的“弹性设计”。当前版本的核心协议如下protocol Skill: Identifiable { var id: String { get } var name: String { get } var description: String { get } /// 参数结构用于生成 JSON Schema associatedtype Parameters: Codable associatedtype Output: Codable /// 执行前是否需要用户确认 var requiresConfirmation: Bool { get } /// 是否会产生副作用写文件、发通知、修改日历 var hasSideEffects: Bool { get } func run(_ params: Parameters) async throws - Output }这里最关键的是name和description。模型不是通过代码调用技能而是通过这串文字来理解“这个技能是干什么的”。description 写得越具体模型就越不容易误用。比如差查看日历事件。好读取日历中某一天的所有事件。适合回答“今天有什么安排”“明天几点开会”之类的问题。参数 date 必须是 YYYY-MM-DD 格式calendarName 可留空表示默认日历。我踩过很明显的坑早期 description 写得太简略模型把“归档文件”技能误当成“搜索文件”技能来用连续几次把不相干的任务路由给了它。后来我把每个 Skill 的 description 当成模型提示词的一部分来认真写包含使用场景、参数格式提醒、常见误用警告路由准确率立刻上来了。3.2 参数校验放在 Skill 内还是 Agent 内我的答案是基本校验放 Skill 内语义校验放 Agent 外。每一个 Skill 的run方法第一件事就是校验参数。比如CalendarSkill要求 date 是合法日期且不能是乱码字符串如果没有格式校验模型可能生成20244-13-01这种日期直接抛错返回给 Agent让 Agent 重新生成参数。这种“参数错了让模型自己重新生成”的闭环比在 Skill 里强行容错要可靠得多。而语义层面的校验就不该放在 Skill 里。比如用户说“帮我准备下午的会”Agent 判断需要调用 CalendarSkill 查日程再调用 FileArchiveSkill 归档文件。但归档目标路径是否合理、是否与会议主题匹配这些是 Agent 需要根据对话语义判断的Skill 只负责把“给定的日期、给定的目录”执行完。所以协议里我设计了一个ValidationResultenum ValidationResult { case valid case invalid(reason: String, suggestion: String?) }invalid里附带suggestion是为了让 Agent 在拿到失败原因后知道怎么修正。比如“date 格式应该为 YYYY-MM-DD当前为 13 月”模型看到这个提示通常能自动改过来。这个设计让校验结果不再只是错误流而是变成了模型反馈链的一部分。3.3 副作用声明与用户确认hasSideEffects和requiresConfirmation这两个属性是我在第二版时加上的非常重要。原因是一次事故测试时我说“把下载文件夹里的 PDF 移到归档目录”Agent 正确调用了 FileArchiveSkill移动了 30 多个文件。但我没写确认步骤运行完才发现归档目录路径配错了文件被移去了不该去的位置。虽然找回来了但那次让我下决心把“危险动作”和“普通查询”区分开。现在的处理方式switch await permissionGate.evaluate(skill) { case .allowed: break case .needsConfirmation(let summary): let ok await confirmationPresenter.confirm(summary) if !ok { throw SkillError.cancelledByUser } }当 Skill 声明hasSideEffects true时Agent 在执行前会先暂停向 UI 发送一条确认卡片卡片上写清楚“将要执行什么动作、涉及哪些文件/对象、是否可撤销”。用户点确认后Agent 才有权限调用 Skill 的run。这里要注意requiresConfirmation是否开启不能只由 Skill 自己决定还需要用户在设置里有一个全局开关。我见过有人图省事把所有技能的requiresConfirmation都设成false结果自动化确实流畅了但也再不敢让这个应用处理真正重要的文件了。安全感和流畅度之间必须让用户自己权衡。3.4 日志和回放没有日志的 Agent 工程都是盲盒写日志这件事看起来不性感但调试 Agent 时它是唯一的救命稻草。我在工程里加了一个Logger专门记录每一轮调用的完整链路[13:02:01] user: 帮我准备下午的会 [13:02:03] agent: 需要日历查询调用 CalendarSkill [13:02:04] CalendarSkill input: {date:2025-06-11T00:00:00Z} [13:02:06] CalendarSkill output: 找到2个事件14:00 产品评审会 [13:02:06] agent: 需要创建待办调用 ReminderSkill [13:02:08] ReminderSkill input: {...} ...这个日志的价值在调试多技能组合时立刻体现出来你能看出模型在哪一步选错了技能、在哪一步生成了非法参数、在哪一步陷入了循环。没有这套日志面对“用户说了句话程序没反应”这种问题你只能靠猜。我还会把“每次工具调用的输入输出”持久化到本地文件用 JSON Lines 格式。测试时跑几条典型指令把日志路径拖进命令行就能快速对比不同模型版本对同一指令的路径选择差异。自动化工具的可解释性就藏在这份日志里。4. 一次完整链路让 Agent 处理“会前准备”4.1 把自然语言请求拆成技能组合我用一个相对完整的场景来展示这套工程实际跑起来是什么效果用户对面板说了一句“帮我准备一下今天下午的会”。Agent 拿到这句话后并不会直接执行任何技能而是先把它转换成“需要哪些信息、需要调用哪些技能”的计划。它会把这句话拆成几个潜在的意图“今天下午的会”指的是哪个时间段、哪个日历事件“准备”在这个场景里意味着什么可能包括创建一份待办列表也可能包括把相关材料归档。这个动作完成之后用户是否需要收到通知提醒模型通常不会一次性给出最终答案它会先调用 CalendarSkill 查时间线拿到具体的会议信息后再决定后续步骤。这就是多技能编排的基础Agent 不是靠一次思考得出完整计划而是通过多轮工具调用逐步逼近目标。4.2 三个 Skill 的实现要点这个场景里我配置了三个技能。CalendarSkill 负责查询日历事件。实现核心是 EventKitstruct CalendarSkill: Skill { var name calendar_query var description 查询日历中某一天或某个时间段的事件。 var hasSideEffects false var requiresConfirmation false struct Parameters: Codable { var startDate: String var endDate: String? } func run(_ params: Parameters) async throws - [CalendarEventDTO] { let store try await permissionManager.calendarStore() // 使用 EKEventStore 查询对应区间的事件 // 转成轻量的 DTO只保留标题、时间、位置避免把底层模型信息直接交给模型 ... } }这里的细节是返回给 Agent 的不是 EventKit 的原始EKEvent对象而是一个让它“够用但不臃肿”的 DTO。模型不需要知道 EKEvent 内部的 calendarItemIdentifier 是什么它只需要知道会议标题、时间、地点以及是否需要准备会议链接。ReminderSkill 负责创建提醒事项。它需要在用户的某个清单里新增一条待办并设置截止时间。这个技能是hasSideEffects true的所以它会触发确认弹窗。FileArchiveSkill 负责把指定目录下的文件移动或复制到另一个目录。这个技能也声明了副作用。考虑到文件操作的风险我在参数里支持move和copy两种模式默认是copy。相比move复制至少不会让原文件消失多一层保险。4.3 工具调用格式与提示词约定Agent 层与模型的交互我采用的是当前各家模型普遍支持的“工具调用”格式系统消息里告诉模型有哪些技能可用、每个技能的入参是什么模型根据对话内容决定是否发起调用以及填什么参数。工具定义如下{ type: function, function: { name: calendar_query, description: 查询日历事件。, parameters: { type: object, properties: { startDate: { type: string, description: 开始时间 ISO8601 }, endDate: { type: string, description: 结束时间 ISO8601可空 } }, required: [startDate] } } }在系统提示词里我额外写了三条规则如果需要查询用户日程优先调用calendar_query不要凭空猜测日历内容。执行任何会修改系统状态的操作前必须先调用对应技能由系统弹确认框。每次工具调用后根据返回结果判断是否需要继续调用其他技能直到任务完成再输出最终回复。这三条规则看着简单但很有效。它其实是在告诉模型“哪些事可以通过工具解决哪些事不能凭经验回答”避免模型产生自信幻觉。4.4 一次执行过程回放在一次真实执行中日志大概是这样的用户帮我准备一下今天下午的会 [Agent] 计划先确认今天下午有没有会 [Tool] calendar_query startDate2025-06-11T12:00:00Z endDate2025-06-11T18:00:00Z [Result] 找到1个事件产品评审会16:00会议室A议题新版本验收 [Agent] 计划为用户创建会议待办 [Tool] reminder_create title产品评审会准备deadline15:30 [Permission] 用户已确认 [Result] 已创建提醒 IDxxx [Agent] 计划归档相关文件 [Tool] file_archive source/Downloads/产品评审/, target/Archive/产品评审/, modecopy [Permission] 用户已确认 [Result] 已复制5个文件 [Agent] 最终回复已为你确认今日 16:00 的产品评审会添加了 15:30 的准备提醒并将下载目录中的 5 个相关文件归档到产品评审目录。这条链路验证了整套设计的核心价值用户可以一次性说清一个复合需求Agent 把它拆成三次技能调用中间只需经过两次用户确认。一切对系统状态的修改都在用户可控范围内查询类操作全自动完成。5. 实测最容易翻车的三件事并发、权限弹窗、上下文膨胀5.1 Actor 隔离Skill 注册表不是线程安全的字典第一版里我用一个普通字典存储所有注册的 Skillvar registry: [String: any Skill]Agent 的主循环在后台任务里运行可能在多轮循环中同时访问这个注册表而 UI 层会在面板上刷新“当前可用技能”列表也去读同一个字典。于是偶发踩踏就来了多个任务同时读、同时写轻则崩在fatal error重则莫名其妙丢失技能。解决方案是把它变成一个 actoractor SkillRegistry { private var storage: [String: any Skill] [:] func register(_ skill: any Skill) { storage[skill.id] skill } func skill(for id: String) - (any Skill)? { storage[id] } func allDescriptions() - [String] { storage.values.map { $0.name : $0.description } } }这个改动并不复杂但它强制定下了规则任何对技能表的访问都必须通过 actor 的异步接口。这样既保证了并发安全也保证了未来如果加“按权限过滤技能列表”之类的逻辑只需要集中在 actor 内部处理不需要在调用方再三确认。如果你用 SwiftUI 做 UI不要为了让 UI 能直接遍历技能而把它改成主线程单例。正确的做法是给 UI 一个只读的快照比如SkillSummary数组UI 看到的是当下技能的描述和状态而不是可变引用。5.2 首次权限弹窗会把 Agent 卡在半路这是我在真机测试中遇到的最恼人的问题Agent 正在执行的半路上系统弹出了日历权限请求。用户当时不在电脑前Agent 就卡在 await 处整个任务挂起直到用户回来点掉弹窗才继续。这个问题不好消除只能缓解。我的做法是应用启动时在 PermissionManager 里检查所有常用权限的状态如果未授权不要等用户触发技能时才弹窗而是主动引导授权。在 Agent 主循环里增加超时机制。对权限获取操作设置等待时长超时后放弃本轮工具调用并向用户输出“某个权限未授权请确认后在设置中开启”。如果某个权限被拒绝相关技能直接从可用列表里排除同时更新给模型看到的技能描述不让模型反复尝试调用不可用的技能。这三个配合基本消灭了“静默挂起”的问题。用户偶尔看到的是“日历权限未开启我先跳过日历步骤是否继续”而不是整个Agent 无声无息地卡住。5.3 返回给模型的内容要“小而结构化”一开始我的 CalendarSkill 返回的是完整的 EKEvent 数组每个事件包含了各种嵌套属性。结果模型在处理时上下文很快就被大量无关字段填满了甚至出现把一条事件的内部 ID 当成会议链接来回传的怪问题。后来我统一了所有 Skill 的 Output 设计原则只返回 Agent 完成后续决策所需的最小字段。所有字段要么是字符串、数字、布尔值要么是简单的数组/字典不要返回嵌套很深的定制类型。返回的描述文本保持简洁尽量不要让模型再次解析一大段杂糅文本。比如 CalendarSkill 的 Outputstruct CalendarEventDTO: Codable { var title: String var startTime: String var endTime: String var location: String? var notes: String? }这样模型拿到的事件信息干净利落它不再需要去大段文本里搜索“会议地点在哪个字段”。上下文管理因此简单得多我为每条工具调用结果设置一个字符上限超过这个上限的结果会被压缩成摘要再进入历史记录。上下文膨胀在长时间会话里几乎是必然发生的。我目前的做法是当对话历史超过一定 token 预算时把最旧的非关键消息折叠成摘要技能调用的输入输出记录单独存储在本地日志文件里不全部塞进模型上下文。这样模型始终在一个可控的上下文窗口内做决策决策质量会稳定很多。6. 从 Demo 到顺手工具可注册化、自定义脚本、隐私边界6.1 技能注册与按需加载现在工程里的技能列表已经不止三个了如果仍然把所有技能硬编码在App.swift里初始化代码会越来越挤。我把初始化改成了“按需注册”应用启动时扫描 Skill 目录每个技能实现自动通过扩展点注册进 SkillRegistry。具体实现用了一个简单的协议扩展extension Skill { static var allCases: [any Skill] { [Self()] } }然后在启动阶段遍历已知类型统一注册。如果后续要支持第三方技能甚至可以改成扫描某个目录下的可执行文件或脚本动态加载。按需加载的一个附带好处是技能数量多了以后每次传给模型看的技能描述也会变多。如果不做过滤模型在每轮对话里要读完一堆它根本不可能会用到的技能描述既浪费 token 又容易干扰判断。现在我会根据用户当前的上下文做一个粗粒度过滤比如用户在聊日历就不把文件归档技能描述发给模型只在真正需要时才把它加入候选列表。6.2 给用户留一个“自定义技能”入口不是所有人都愿意为一个小需求改协议、写 Swift 代码。我希望工程能具备扩展能力所以加了一个“自定义技能”入口用户可以用 JSON 描述一个技能的路由行为比如指定一个 shell 命令或一个 URL scheme然后把这个 JSON 拖进技能目录应用就会把它注册成一个新的 Skill。安全上这类自定义技能默认是禁用的用户必须手动确认启用。它执行时也会走同一套确认弹窗和日志系统。这样一来不写代码的用户也能利用 Shell 命令完成一些灵活操作比如“把当前文件夹的截图转成 PDF”“打开某个会议 Room 的链接”。这一步是让应用从“我自己用着顺手”变成“别人也能按需扩展”的关键。6.3 数据边界哪些数据不该让模型看到最后再聊一个容易被忽视的问题隐私和边界。不是所有数据都适合发送给模型。我的原则是本地能判定的逻辑尽量本地判定只把“必须依赖模型语义理解”的部分发给模型。例如日历事件标题和说明我会在进入模型上下文前做一次脱敏检查移除明显包含隐私信息的部分。文件归档操作则完全由本地代码执行模型只见文件路径和文件类型看不到文件内容。Skill 的权限声明里我也增加了一个敏感等级enum DataSensitivity { case publicData case sensitive case localOnly }localOnly类型的 Skill 执行结果不会进入模型上下文只会被记录在本地日志里。这保证了系统中有相当一部分能力是“即使模型失控也看不到敏感数据”的。对我来说这已经不是“要不要做”的加分项而是“能不能大胆用”的基础条件。智能助手最大的价值在于它越来越敢替你动手做事但“敢”的前提是“边界清晰”。没有边界意识的自动化工具做得越多风险越大。我在实际维护这套工程时最大的体会是一个好的 Skill 设计会让 Agent 越来越省心。当你把能力封装得足够规整、描述写得足够清楚、边界划得足够明确时模型几乎不需要额外思考就能正确路由。最后再分享一个小技巧每次新增 Skill 后拿三句风格不同的自然语言去测试一句是精准指令一句是模糊意图一句是带着多步需求的复合指令看模型是否能正确地只调用该调用的技能。这个 5 分钟测试比什么单元测试都能更快地暴露描述不清、边界混乱的问题。