☰
SwiftUI Agent Skill 实战:构建桌面智能助手的完整架构解析
2026/10/21 22:55:08 网站建设 项目流程

项目标题叫“SwiftUI-Agent-Skill”,听起来像是三个技术名词硬凑在一起,但如果你真的用 SwiftUI 做过带“智能体”形态的应用,就会明白这三个词代表的是一条完整的产品链路:先有一个状态驱动的界面框架,再有一个能自主调用工具的 Agent 内核,最后把能力拆成一项项可插拔的技能。这篇文章我想用一次真实项目复盘的方式,把这个组合拆开讲清楚。项目本身不复杂——目标是在 macOS 上做一个桌面智能助手,输入自然语言,由 Agent 决定调用哪些 Skill 完成任务。很多人一上来就纠结模型选型和提示词,实际真正难的是怎么把这套流程优雅地塞进 SwiftUI 的声明式模型里。

我花了两周时间从零搭起一套方案,期间踩了不少坑,下面是把核心设计和实现细节完整梳理后的分享。无论你是想给现有 SwiftUI 应用加一个智能交互层,还是想学习 Agent 类项目在端侧如何落地,这篇内容应该都能给你参考价值。

1. 整体架构拆解:为什么是 Agent + Skill,而不是 Chat 套壳

1.1 从“对话”到“执行”的思维转变

很多开发者做 AI 应用的第一步是把某个大模型 API 封装进 SwiftUI,然后做一个类似聊天窗口的界面,用户说什么模型就答什么。这种方案投入快,但它本质上只是“打字机”,不是“执行者”。你问“帮我创建一个今天下午三点的提醒”,它只会回复“好的,我可以帮你创建一个提醒”,然后就没了——因为应用本身不具备把这句话变成系统动作的能力。

SwiftUI-Agent-Skill 这个项目的核心,是把 Agent 当作一个“会思考的调度器”,把 Skill 当作一组真正动手干活的模块。用户输入自然语言之后,Agent 并不直接回答,而是先理解意图、列出计划,再挑选合适的 Skill,由 Skill 去调用系统能力,例如创建日历事件、打开文件、发送通知或修改某个 SwiftUI 状态,最后把执行结果反馈给 Agent,由 Agent 再组织成自然语言回复给用户。

这个转变非常关键。它让应用从“会聊天的界面”进化成“会干活的助手”。我的经验是,如果你打算做一个有点真实的智能体应用,直接从这种架构起步,不要先写聊天壳子再想办法缝能力,否则后面缝合时的状态管理会让你痛不欲生。

1.2 Agent 与 Skill 的职责边界

在设计初期我画过一张职责分工表,这张表后来成为整个项目代码组织的基本原则,也推荐你参考:

模块职责不应做的事
Agent 引擎维护对话上下文、理解意图、规划步骤、调用模型 API不要直接操作系统功能,不要写死业务逻辑
Skill 注册中心收集所有可用技能、生成技能清单描述、校验参数不要解析自然语言,不要参与模型决策
具体 Skill执行一个特定动作,返回结构化结果不要直接改动 Agent 的消息数组,不要返回冗长的日志
SwiftUI 视图层渲染对话状态、展示技能执行过程、接收用户输入不要直接调用模型 API,不要持有技能实例

把职责边界划清楚之后,写起来非常顺手。Agent 永远不会 import AppKit,具体 Skill 也不关心是大模型还是小模型在调度它。这样每个模块都可以单独测试,替换模型供应商时也不用碰技能层。

1.3 三条关键设计原则

第一条原则是“技能清单即是系统提示”。模型并不知道你的应用有什么能力,你必须把每个技能的名字、用途、参数格式做成一份清单,作为系统提示词的一部分喂给它。这意味着每注册一个新技能,自动就会影响模型对“它能做什么”的理解。

第二条原则是“一切结果都要回流到 Agent”。Skill 执行完成之后不允许直接结束流程,必须把执行结果(成功或失败、数据摘要、错误信息)变成 Assistant 消息发回给模型,让模型接着规划下一步。一旦某个技能执行失败,Agent 需要能基于这个失败结果调整方案,而不是傻乎乎地继续。

第三条原则是“SwiftUI 状态只认事件流”。Agent 这个异步生物天生和 SwiftUI 的声明式状态管理有冲突,我的解决办法是让 Agent 只向外吐一个AsyncStream,视图层订阅这个流,再转换成@State或@Observable渲染。所有内部消息更新、技能执行进度都通过这个流暴露,UI 绝不直接读取 Agent 内部的可变状态。这保障了多任务并发时不会出现 UI 错乱。

2. 核心技术点拆解:SwiftUI 状态桥接与技能协议设计

2.1 SwiftUI 侧的运行循环设计

SwiftUI 应用里的 Agent 不可能是一个一直旋转的 RunLoop,它需要跟随视图生命周期。我最终使用了.task修饰符绑定整个 Agent 会话,方法是在视图出现时启动任务、视图销毁时自动取消。这个模式看起来简单,但解决了几个严重问题:不再需要手动管理某个“停止按钮”的线程状态;也不再担心视图已经离开而 Agent 还在偷偷修改被释放的对象。

实际代码大概是这样:

struct ChatView: View { @State private var agentSession = AgentSession() @State private var visibleMessages: [DisplayMessage] = [] @State private var pendingInput = "" @State private var currentTaskState: TaskState = .idle var body: some View { VStack { messageList inputBar } .task { await agentSession.attach() } .onDisappear { agentSession.detach() } } }

attach方法内部会订阅agentSession.events这个AsyncStream,每次收到消息更新事件,就把事件映射成visibleMessages追加到列表。为什么要额外包一层DisplayMessage?因为 Agent 内部的消息结构里包含 tool_use 请求、tool_result 结果、原始内容等复杂字段,UI 不需要关心这些,它只需要一个enum MessageKind { case text, executingSkill, skillResult }配合展示即可。这一层适配让 SwiftUI 视图代码保持干燥整洁。

2.2 技能协议:让“会干活”这件事规范起来

Skill 在系统里是一件可插拔的“工具”,它必须满足统一的属性才能被 Agent 调度。我定义了一个协议AgentSkill,核心组成如下:

public protocol AgentSkill: Sendable { var name: String { get } var summary: String { get } var parametersSchema: JSONSchema { get } func execute(arguments: JSONValue) async throws -> SkillOutput }

每个字段都有讲究。

name是技能的唯一标识,也是模型在决策时使用的工具名。我的命名习惯是使用驼峰式短横组合:create_reminder、find_files_by_name、send_desktop_notification。原因是模型处理英文风格的工具名更稳定,命中率高,中文名在部分模型上会出现编码问题。

summary是在模型“看不到代码”的情况下判断技能用途的唯一依据,必须写得像产品功能说明:包含它能解决什么问题、适合在什么场景下调用、有什么注意事项。写清楚 summary 的直接好处是显著降低模型乱调用技能的概率,效果比在代码里加几百行注释都好。

parametersSchema使用 JSON Schema 定义技能的输入项,模型会根据这个结构生成对应的参数 JSON。比如创建提醒的技能 schema 大概是:

{ "type": "object", "properties": { "title": { "type": "string", "description": "提醒标题" }, "date": { "type": "string", "description": "ISO8601 格式的日期" }, "priority": { "enum": ["low", "medium", "high"], "default": "medium" } }, "required": ["title", "date"] }

执行接口execute(arguments:)返回的是一个SkillOutput,里面同样是一个可序列化的JSONValue,最简形式是"success"或者{"count": 3}。返回给 Agent 的结果要尽量简短,避免把完整文件内容或大量日志塞进去,否则模型上下文很容易被无关信息挤爆。这里也是我在实际项目中反复调整的地方,后面会详细讲。

2.3 把协议翻译成模型看得懂的语言

协议只是 Swift 侧的定义,真正要让模型用起来,必须把它渲染成模型约定的 function calling 格式。我在SkillRegistry里集中做了这件“翻译”工作。每个技能注册进SkillRegistry时,系统会自动生成一份工具清单,渲染出的结构大致是:

[ { "type": "function", "function": { "name": "create_reminder", "description": "在系统日历中创建一个提醒事项。当用户提到提醒、日程、待办时使用。", "parameters": { "type": "object", "properties": { ... } } } } ]

这份清单会在两处被使用。第一处是拼进系统提示词,告诉模型“你是桌面助手,你拥有以下技能,请根据用户意图选择”;第二处是传给模型 API 的 tools 参数,让模型具备结构化输出能力。实现后发现事情可以做得很优雅:注册表保持唯一的技能数据源,UI 里展示技能总览、系统提示词、API 参数都从同一个函数生成,不会有信息不同步的问题。

注册一个新技能只需要实例化它并注册,像这样:

AgentSkillRegistry.shared.register(CreateReminderSkill()) AgentSkillRegistry.shared.register(OpenWebPageSkill())

后面无论加多少技能,都不需要改动 Agent 主循环。这也是项目往后扩展最爽的地方。

3. 实操实现:从零写出一个可运行的 Agent 循环

3.1 AgentLoop 的四个阶段

Agent 主循环我拆成了四个阶段:意图分析、技能调度、技能执行、结果回填。整个循环运行在一个并发的异步任务中,通过AsyncThrowingStream向外暴露进度。

每轮流程大致是:

func run(userInput: String) async throws -> String { messages.append(.user(content: userInput)) while !taskFinished { let response = try await model.generate( messages: messages, tools: registry.toolManifest() ) switch response.kind { case .text(let text): finalAnswer = text taskFinished = true case .toolUse(let toolName, let arguments): events.yield(.willExecuteSkill(name: toolName)) let result = try await registry.execute(name: toolName, arguments: arguments) messages.append(.assistantToolUse(name: toolName, arguments: arguments)) messages.append(.toolResult(content: result)) events.yield(.skillDidFinish(name: toolName, output: result)) } } return finalAnswer }

这段伪代码是 Agent 的骨架,接下来要处理几个细节:终止条件、连续多技能调用、失败重试。

3.2 处理“连续多技能”这个高难度场景

实际使用中用户很少只提一个动作,常见输入是“帮我查一下明天的天气,如果下雨就把后天跑步计划挪到下午”。像这种两跳以上的请求,模型的第一轮输出几乎都是连续多个 tool_use 请求。Agent 不能只取第一个技能就执行完结束,而是要把所有 tool_use 都顺序执行掉,再组装成结果发回模型。

这里我踩过一个很实际的坑:执行完第一个技能后立刻把toolResult追加进 messages,然后循环再调用模型 API,模型可能给出的仍然是第二个 toolUse,这个没问题;但如果某个技能执行结果过长,模型在第二轮可能迷失方向,开始“自由发挥”编造结果。解决方法是要求技能返回“摘要优先”的结构化输出,后面再讲。

另一个需要注意的点:模型给出的多个 tool_use 之间存在依赖关系。比如先“打开文件”再“修改文件内容”,第二个的技能参数依赖第一个技能的输出。处理这种场景时,最稳妥的方法还是逐任务执行:执行完第一个工具后把结果喂回模型,让模型看到结果后再决定第二个技能的参数,而不是一次性并行执行。并行看起来炫酷,但遇到依赖场景会出错。我在项目里选择放弃并行执行,换来的是可预测性和稳定性。

3.3 技能执行中的超时与取消

Agent 调度技能的底层是 async/await,但技能内部可能是阻塞式 API 或网络请求。我的SkillRunner给技能执行套了一层超时保护:

func executeSkill<T: AgentSkill>(_ skill: T, with arguments: JSONValue) async throws -> SkillOutput { let timeout = DispatchTime.now() + skill.suggestedTimeout return try await withThrowingTaskGroup(of: SkillOutput.self) { group in group.addTask { try await skill.execute(arguments: arguments) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeout.timeIntervalSinceNow * 1_000_000_000)) throw SkillExecutionError.timeout } let output = try await group.next()! group.cancelAll() return output } }

超时时间不能做成全局固定值。读取本地文件可能需要一两秒,但调用某个外部接口可能需要更久。我在每个AgentSkill实现里都添加了suggestedTimeout属性,默认 5 秒,调用外部网络服务的技能会覆盖为 15 秒。这个粒度让我免于大部分“卡死”事故。

3.4 事件流设计与 UI 反馈

Agent 在执行过程中不能像黑盒一样不吭声,UI 必须实时反馈。我定义了一个AgentEvent枚举,覆盖了所有需要 UI 响应的状态变化:

enum AgentEvent: Sendable { case messageAdded(Message) case planning(String) case willExecuteSkill(name: String) case skillOutput(name: String, output: String) case finished(String) case failure(errorMessage: String) }

ChatView 订阅事件流,根据事件更新不同类型的视图:planning显示一个步骤条,willExecuteSkill显示一个“正在执行:创建提醒”的动画卡片,skillOutput把输出渲染成折叠加色的细节区域。这样的设计让用户能看到 Agent 的思考过程,而不是只有最终结果。用户对过程的感知直接影响他们对应用的信任度,这一点的价值比我想象中大得多。

4. 核心技能开发实录:三个典型 Skill 的完整实现

4.1 系统日历提醒技能:第一个实战案例

我从“创建提醒”这个最基础的技能开始,因为它能直观演示 Agent 的闭环价值。快速梳理,用户说“明天下午三点开会”,Agent 识别出需要调用create_reminder,参数里有title=会议、date=明天下午三点。

这一步难在时间解析。模型给出的日期可能是格式化的"2025-05-04T15:00:00",也可能是模糊的"明天下午三点",而且夏令时、时区差异都会造成解析错误。我发现可靠的方案是在技能内部不做模糊解析,而是将它交给 Agent:系统提示词里明确要求“当用户表达相对时间时,请根据当前时间推算后转换为 ISO8601 传入”。模型做得比基础的手写解析器要好得多,实现上少走了很多弯路。

技能内部的实现反而很朴素:

struct CreateReminderSkill: AgentSkill { let name = "create_reminder" let summary = "在系统日历中创建一条提醒。当用户提到日程、提醒、待办事项时适合调用。" let parametersSchema: JSONSchema = .object([...]) func execute(arguments: JSONValue) async throws -> SkillOutput { let title = try arguments.getString("title") let dateString = try arguments.getString("date") let date = try ISO8601DateFormatter().date(from: dateString) // 调用系统日历 API,成功后返回 return SkillOutput.json(["status": "created", "eventId": eventID]) } }

我并没有把业务逻辑写在 Agent 代码里,Skill 内部可以自由使用 EventKit、AppKit、Network 框架等系统能力,这是架构中收益最明显的一点。

4.2 文件系统操作技能:给 Skill 加一个安全层

当技能需要读写用户文件时,事情就变得敏感起来。我实现了一个find_files_from_folder技能,Agent 可以根据用户描述搜索指定目录下的文件。最初版本我直接把整个文件列表作为结果返回给模型,结果遇到大目录时模型上下文瞬间膨胀,执行直接变慢甚至超时。

后来改进为只返回前 20 条匹配结果,并附上总数提示,同时要求模型在结果不足时主动追问用户,而不是反复扩大搜索范围。这个改动让上下文占用大幅下降,效果非常明显。

这类涉及用户文件系统的技能还必须加安全防护层,我在执行器里做了三件事:路径白名单检查,技能只能访问用户授权的根目录;禁止解析符号链接跳转到范围之外;每一步操作都写审计日志并保留最近 10 条记录在界面上展示。实际上桌面助手这类应用,信任大于功能,安全边界必须硬性保证。如果哪天有用户反馈“这个应用乱翻我文件”,那再炫的 Agent 技术也救不回来。

4.3 自动化控制技能:用 AppleScript / 快捷键模拟闭环

我设计了一个技能用来控制当前应用,比如“帮我截图”“把桌面壁纸换成今天拍的照片”。这类操作的实现要跨过 AppKit 的沙盒限制,这里严格遵守系统权限模型。在 macOS 上我通过调用系统辅助功能 API 完成自动化操作,实现的关键是明确感知用户意图,并且每次调用前都要检查系统辅助功能权限是否被授予,状态在界面上明确提示。

这个小技能比较有意义的地方是它展现了 Skill 体系的可扩展性:你完全不需要知道内部如何操作,只需要在界面上下达“发一封邮件”或“把窗口移到副屏”这样的指令,Agent 负责把它转换为具体的参数调用相应技能。一个不依赖 UI 的自动化执行层,本质上就是给应用装上了手脚。

4.4 记忆型技能:让 Agent 变得“懂你”

做完上面三个技能之后我发现还缺一个重要能力——长期记忆。一个没有记忆的智能终端应用,每次会话都是重新认识用户,信息分散且低效。我加了一个remember_user_fact技能和recall_user_fact技能,前者负责把用户在对话中透露的偏好保存为一个结构化事实条目,后者在需要时检索相关事实。

记忆的实现并不复杂,底层是一个 SQLite 表,但设计上有个微妙点:Agent 不能把每次对话都当作“值得记忆的事实”,必须有判断地写入。我让 Agent 在系统提示词里遵循规则:只有稳定的偏好和明确的事实才调用remember技能,偶尔一次的临时请求不记录。否则一旦误记了过期信息,下次会误导 Agent。这个技能是我至今仍在使用增强功能的基础盘。

5. 实操复盘与常见问题排查

5.1 我把最容易出错的五个点列成了速查表

项目落地过程中,有些问题不是偶发,而是最初设计不到位带来的必然,这些经验我整理成了一个排查表,应该能帮你省下不少调试时间:

现象根因解决方向
模型一直选择同一个技能,不执行别的技能 summary 写得不明确,模型不认为其他技能适用重写每个技能的 summary,突出其适用边界
技能返回结果太大,模型上下文爆炸没有控制技能返回内容的规模要求技能返回摘要、限制结果条数,并设置输出大小上限
SwiftUI 列表在技能执行过程中刷新错乱直接在 Agent 线程里修改了 @Published 属性全部改为事件流,在主线程统一消费事件更新 UI
多技能调用顺序不稳定并行执行了多个技能,或 result 回填不及时改为串行执行,严格按 tool_use 请求顺序回流
模型“幻觉”技能执行结果技能返回结果的格式不清晰,模型难以判断返回结构统一为{"status": "success/failure", "data": {...}},明确结束状态

5.2 上下文管理是我踩得最深的坑

一开始我天真地想保留全部历史消息,一个月使用后你的上下文会非常庞大,每轮 API 调用都昂贵。我的方案是引入一个分层的上下文压缩机制:最近 6 轮对话、当前活动技能执行状态和一到两条长期记忆放入对话;更早的内容降级成摘要,仅保留关键意图和执行结果。这里不能硬裁剪——直接丢掉前面的消息会让 Agent 在后续任务中失去背景,表现会明显劣化。

具体实现是在“上下文构建”阶段执行,它读取原始消息数组,然后生成一个新数组注入给模型。用户感知不到这个压缩过程,但响应速度和费用会有明显改善。不过要注意,压缩策略不能让 Agent 丢掉正在进行的任务的中间状态,所以我把“当前任务的执行中状态”单独提取出来,始终保留到任务终结。

5.3 技能故障不会让整个 Agent 崩溃

好的架构应该是“技能随便坏,Agent 不会崩”。我的技能执行器在调用任意技能时都做了统一的异常捕获,任何throw都会被转化为SkillOutput.failure,并作为正常 toolResult 回传给模型。这样 Agent 就能“看到”某个技能失败了,然后调整策略或向用户解释原因。

这种设计还有一个额外收益:用户不会因为某个技能暂时故障就丢完整个会话上下文,其他技能仍然可用。相比之下,如果技能抛错直接终止 Agent 循环,那么一次“读取文件失败”可能要重建整个会话状态,体验非常差。

5.4 日志与可观测性:Agent 的黑盒投资

调试 Agent 项目和调试普通 App 完全不同,普通 App 的报错有堆栈,而 Agent 的“报错”可能只是“生成的参数不对”或者“选错了技能”。我在项目里部署了完整运行日志:每轮循环的消息数组、工具调用输入输出、花费 token 数量、耗时。每条记录还额外存储了“为什么选择这个”的模型决策,这会帮解决很多棘手的错误。

记录每一项 skill 调用的时间开销也是一件有价值的事情。我发现部分技能执行一次要 2 到 3 秒,但用户其实可以接受,核心在于 UI 有没有提示。做好“技能执行中”的进度指示,用户耐受度会提升不少。

写在最后:如果你想复刻这个项目

如果你准备动手复刻一个类似的 SwiftUI-Agent-Skill 项目,我个人最实用的经验是“从最小的闭环开始”:先实现一个 Agent 循环加一个最简技能(比如“返回当前时间”),把整个链条跑通,再逐步加技能。不要一开始就设计 20 个技能,否则技能注册表、schema 校验、上下文管理都会因为相互交叉而难排查。每加一个技能,都先把它的 summary 和 schema 打磨到最精准,再放进去应用,这样会大幅减少后面模型选错工具的频次。

还有一个小技巧值得分享:技能的名字强烈建议用英文短驼峰,summary 用自然的描述性句子,说清楚它“适合哪类请求”“不适合哪类请求”。这个做法在模型调度准确率上带来的提升,比调很多长篇系统提示都更明显。你甚至可以把它当作一个隐形的“技能路由权重”来用。

从这个项目扩展到更多场景并不困难:接日历、接提醒、接多媒体控制、接自定义工作流,本质上都是在SkillRegistry里不断登记新能力。一个稳定、清晰、可拓展的 Agent 架构,最终会让应用从一个聊天气泡,长成可以独当一面的桌面帮手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询