做个人项目这件事,我一直有个习惯:先起个好名字,再想清楚这个项目到底解决什么问题。“SwiftUI-Agent-Skill”这个名字,拆开看其实是三个词:SwiftUI、Agent、Skill。SwiftUI 是苹果生态的界面框架,Agent 是现在 AI 圈最火的智能体形态,Skill 则是把“能力”做成了可插拔、可注册、可调用的模块。我瞅着这个名字就觉得有意思——它不是教你怎么写个聊天框,而是试图在 SwiftUI 客户端里,把一个大模型驱动的智能体拆成清晰的功能部件,让应用具备真正“动手干活”的能力。
这个项目解决的核心问题是:传统 App 里写死业务逻辑,AI 只能陪你聊天;而接入了 Agent 技能体系之后,用户可以说一句话,App 内部自动完成意图拆分、工具选择、参数填充、任务执行,再把结果回填到对话流里。整个过程全部在端侧或通过自己的服务端编排,界面用 SwiftUI 原生实现。适合想入门 AI 客户端开发、对技能架构感兴趣、或者准备自己做 AI 工具类 App 的开发者参考。
我用这套思路做了一个模拟项目 X 之后,最大的感受是:真正的难点不在“接一个大模型 API”,而在怎么把“技能”这个模块设计得足够干净,让 Agent 有手有脚、让 SwiftUI 界面不至于被回调地狱拖死。这篇文章会把我的完整设计思路、实操过程、踩过的坑和排查记录都摊开讲,希望给你省点时间。
1. 项目整体定位与架构拆解
1.1 SwiftUI-Agent-Skill 到底在做什么
先说结论:这个项目不是一个“聊天机器人 Demo”,而是一个跑在 iOS / macOS 上的 AI 智能体客户端框架。它具备对话能力,但对话只是入口。真正的核心是它的 Skill 技能层——通过一系列可动态注册的“技能模块”,让 Agent 不仅会“说”,还会“做”。
比如,用户说“帮我看看日历里明天上午有没有空档,然后加一个 10 点的健身提醒”。没有 Skill 的系统,只能回一句“我无法访问你的日历”;有 Skill 之后,Agent 会先识别出两个意图——查询日历、添加提醒,再把参数(时间、标题、类型)提取出来,按序调用系统日历接口和本地通知接口,最终把执行结果汇总成自然语言回给用户。
这种模式本质上是把“模型决策”和“工具执行”解耦:模型负责判断调什么、填什么参数;本地代码负责真正执行。SwiftUI 在这里承担三个职责:展示对话流、展示技能调用的中间状态、给用户一个可配置的技能管理界面。
1.2 架构分层的取舍思路
我实际开发时,把项目分成了四层。
- 视图层:SwiftUI 的 View 和 ViewModel,负责渲染对话消息、工具调用卡片、设置页。
- Agent 层:负责对话状态机、消息历史管理、意图分组、调用决策循环。
- Skill 层:技能协议、注册表、动态加载器,以及各技能的具体实现。
- 服务层:网络请求、模型 API 接入、本地存储、系统能力封装(日历/提醒/文件等)。
这个分层参考了比较成熟的插件化设计。核心思想是:Agent 层绝对不直接依赖具体的技能实现,它只依赖一个统一协议。比如要新增一个“查询天气”技能,我只需要实现协议,服务端或本地注册表里加一条记录,UI 不需要任何改动。
为什么要这样做?因为我踩过硬编码的坑。第一个版本我把技能调用直接写死在 Agent 处理逻辑里,加一个技能要改三处代码,改完还要重新发版。后来把技能抽象成协议,配合本地 JSON 配置和服务端的技能清单,新增技能从“发版”变成“加配置”,效率完全不一样。
1.3 为什么选择 SwiftUI 而不是跨平台方案
我并不是说跨平台方案不好,而是这个项目选择 SwiftUI 的理由非常具体:Agent 客户端需要频繁唤起系统能力——日历、提醒、Shortcuts、钱包、文件、相机。这些能力用 SwiftUI + Swift 原生调用,语言层面的胶水代码最少,权限交互也最顺。
另外,SwiftUI 的声明式状态管理对“流式对话”特别友好。大模型输出是逐步返回的,你要一边接收消息片段,一边刷新界面。如果用 UIKit 或跨平台框架,可能得手动管理 TableView 差量更新;SwiftUI 里只需要把消息列表放进一个状态容器,新数据一到,Diff 算法自动处理 UI 刷新,省了很多事。
2. Skill 技能系统的设计思路
2.1 Agent 为什么一定要有“技能”这个概念
在动手写代码之前,我先想清楚了一个问题:为什么不能用朴素的“User 说一句话 -> 模型回一句话”来解决问题?答案是:模型不擅长稳定地执行带有副作用的操作。它会编格式、漏参数、重复调用,但只要你把它放进“工具调用”框架里,把每个能力的边界和参数定义清楚,模型立刻就能表现得非常可靠。
所以对 Agent 而言,Skill 就是它“伸手出去抓世界”的那个手。没有 Skill 的 Agent 只是复读机,有 Skill 的 Agent 能改变系统状态——比如创建文件、发送通知、操作日历。这个区别是质的。
我在设计时给 Skill 提了几个硬性要求:
- 独立性:每个技能可以单独启用、禁用、卸载,互不干扰。
- 可自描述:技能要用结构化的方式声明自己的名称、描述、参数,这样才能被模型识别。
- 可追踪:技能调用过程应该产生可查看的运行日志,方便排查。
2.2 Skill 协议与注册机制
我定义了一个 Swift 协议作为所有技能的底座,简化之后大概是这样的:
public protocol Skill: Sendable { /// 技能的唯一标识,例如 "calendar.create_event" var id: String { get } /// 技能名称 var title: String { get } /// 技能描述,用于让模型理解该技能的用途 var description: String { get } /// 参数的 JSON Schema 结构 var parameterSchema: SkillParameterSchema { get } /// 执行技能 func execute(context: SkillExecutionContext) async throws -> SkillResult }这里有两个细节值得聊。一个是Sendable:Agent 调用技能的线程不一定在主线,Swift 并发模式下,跨 actor 传递需要确保类型安全,所以协议要求所有实现类是可发送的。另一个是SkillExecutionContext:它把对话 ID、用户 ID、当前消息、超时配置、取消信号都封装在一个上下文对象里,避免每个技能写不一样的参数列表。
注册机制我用了中间注册表。App 启动时扫描内置技能目录,同时从远端拉取一个技能清单,合并后写入内存注册表。注册表里维护一个“技能 ID -> 技能实例”的映射,并附带启用状态、执行权限等级、频率限制等附加信息。这样后续做技能市场时,只需要往注册表里插一条新记录。
2.3 参数 Schema 与意图匹配
这是整个 Skill 设计里最容易翻车的地方。你让模型调用技能,模型并不知道你的 Swift 结构体长什么样,它只认 JSON。所以每个技能必须提供一份参数 Schema,用来告诉模型:你要传哪些字段、每个字段是什么类型、哪些必填、可选项的枚举是什么。
我自己写了一个轻量的 Schema 类型,没引入第三方库:
struct SkillParameterSchema: Codable { var type: String = "object" var properties: [String: PropertySchema] var required: [String] } struct PropertySchema: Codable { var type: String // "string" / "integer" / "boolean" var description: String var enumValues: [String]? var defaultValue: String? }有了这份 Schema,我把它拼进发给模型的系统提示和工具定义里。模型在回答过程中,如果认为需要调用某个技能,会返回一个结构化请求,例如:
{ "skill": "calendar.create_event", "parameters": { "title": "健身提醒", "date": "2026-02-10", "time": "10:00", "duration": 30 } }我拿到这个请求后,先用 Schema 校验参数完整性,再映射到对应的 Swift 模型,最后执行。校验这一步非常重要:模型偶尔会漏传必填参数,或者把时间格式写错。不做校验直接调用系统日历,轻则创建出空标题事件,重则崩溃。
3. SwiftUI 客户端的核心实现细节
3.1 用 AsyncStream 处理流式对话数据
智能体对话和普通聊天不一样的另一点是:大模型经常会先返回一个“工具调用决定”,等技能执行完,再返回一段最终自然语言。这个过程里,UI 需要依次显示“模型正在思考”“模型准备调用技能”“技能执行中”“技能执行完成”“模型正在生成最终回答”等不同状态。
我用 AsyncStream 做了一条消息事件流。ViewModel 订阅这个流,每收到一个事件就更新状态对象,SwiftUI 自动刷新。
enum AgentEvent { case token(String) case skillStarted(SkillInvocation) case skillSucceeded(SkillInvocation, SkillResult) case skillFailed(SkillInvocation, Error) case messageCompleted(AgentMessage) }界面上的消息模型统一用:
struct AgentMessage: Identifiable, Equatable { let id = UUID() var role: Role var text: String var skillCalls: [SkillCallDisplay] = [] var createdAt: Date }SkillCallDisplay单独保存技能调用的展示信息,包括技能 id、状态、耗时、错误信息。这样消息内容和技能记录是分开的,方便做重试和追溯。刚开始我差点把这些信息混在一起,后来发现分开处理更干净。
3.2 技能调用的状态卡片交互设计
用户其实很在意“AI 刚才到底做了什么”。如果对话里只给一句话“已经帮你添加了提醒”,用户心里会犯嘀咕。我参考了一些成熟 Agent 产品的交互,给每一次技能调用做了一个折叠卡片,展示在对话消息下方。
卡片有四态:
| 状态 | 显示内容 |
|---|---|
| 待执行 | 技能名称 + 转圈动画 |
| 执行中 | 技能名称 + 参数概览 + 计时器 |
| 成功 | 绿色对勾 + 返回结果摘要 |
| 失败 | 红色叹号 + 错误信息 + 重试按钮 |
实现上,这只是一个ForEach循环遍历消息里的skillCalls数组,然后用状态枚举决定卡片底色和图标。参数概览会显示“日历 -> 创建事件:健身提醒 10:00-10:30”,而不是原始 JSON,因为用户看不懂 JSON,但一眼能看出要创建什么。
做重试按钮的时候有个坑:Agent 技能的幂等性。日历创建事件不能重复点一次就创建两遍。我的做法是在技能执行上下文里加一个幂等 ID,重试时带上同一个 ID,由技能实现方去判断是否已经执行过。日历这种系统接口没有天然幂等,我就设计成“重试默认只查不写,由二次确认卡片决定是否再次写入”,稳妥很多。
3.3 多会话管理与本地存储
Agent 应用不做本地存储,基本等于白做。用户关了 App 再打开,聊天记录全部不见,技能配置也全部重置,这体验太差了。我用 SwiftData 存储会话和消息,技能配置则用 JSON 文件加 UserDefaults 混合存储。
存储粒度是这样的:
- SwiftData 表一:会话表,字段包括会话 ID、标题、创建时间、最后活跃时间。
- SwiftData 表二:消息表,保存角色、文本、消息创建时间、关联会话 ID。
- JSON 文件:技能启用状态和用户自定义参数。
- UserDefaults:一些全局设置,比如 API Key 索引、流式开关。
用 SwiftData 做多表查询很顺手,比如“获取某个会话的全部消息”,只需一句谓词查询。我唯一感叹的是 SwiftData 在 iOS 17 之后才稳定,如果你的部署目标更低,可以考虑 Core Data 或者直接用 SQLite。这个项目的目标版本是 iOS 17 以上,所以没有纠结。
3.4 多平台适配:iOS 与 macOS 之间的差异
SwiftUI 的优势之一是一套代码跑多端,但 Agent 应用在 iPhone 和 Mac 上交互方式差异其实不小。iPhone 上是竖屏对话、全屏展示、通过系统日历授权调用;Mac 上则是宽屏侧边栏会话列表,用户可以多开窗口。
我的做法是:主视图用 NavigationSplitView,侧边栏放会话列表,右侧是对话区;在 iPad / Mac 上自动双栏,在 iPhone 上退化为单栏导航。
技能市场页面在 iPhone 上是独立的模态页,在 Mac 上则是个独立窗口。这些都是小改,但如果你一开始不做区分,后面拆起来很痛苦。建议上手时就把#if os(macOS)的代码隔离出来,比如键盘快捷键、菜单栏命令、文件拖拽,别等做到后面再返工。
4. 实操过程:从零搭好核心链路
4.1 模型接入与技能清单的下发方式
我一开始想复杂了,搞了一堆模型网关、提示词模板中心,结果发现团队协作成本陡增。最后简化成:本地放一份默认技能清单 JSON,通过某个配置接口把远端清单覆盖过来。模型 API 服务用一个池子封装,支持多 Provider 切换,后续要换模型或者做 A/B 测试只需要改配置。
如果你现在要复现,我会建议你按这个步骤走:
- 先在项目里建一个
Skills目录,每个子目录放一个技能实现文件。 - 建一个
skill_registry.json,声明所有内置技能,格式如下:
[ { "id": "calendar.create_event", "title": "创建日历事件", "description": "在用户的默认日历中创建一个新事件,需要提供标题、开始时间和持续时间。", "enabled": true }, { "id": "reminder.add", "title": "添加提醒", "description": "为本机创建一个提醒事项,支持指定时间和优先级。", "enabled": true } ]- 在 ViewModel 初始化时,读取这份 JSON,用协议注册到全局注册表里。
- 构建请求体时,把启用的技能转成模型的工具定义(可参考各模型服务商的“工具调用 / function calling”接口文档,核心都是把技能名、描述、参数 Schema 传过去)。
这个过程最核心的调试点是:描述字段一定要写仔细,越具体,模型调度准确率越高。比如“创建日历事件”,如果你只写“创建日历事件”,模型会在用户说“帮我定个明早的闹钟”时误调日历技能;如果你写“用于在日历中创建日程事件,适合会议、约会、健身、吃饭等有时间地点的事件;闹钟请使用提醒技能”,调度错误立刻少很多。
4.2 工具调用循环的正确状态转移
Agent 一轮完整对话的流程,我画在代码里是这样的状态机:
enum AgentTurnState { case idle case thinking case needSkill(decided: SkillInvocation) case executingSkill(invocation: SkillInvocation) case generating case finished }流程是:
- idle 收到用户消息。
- 进入 thinking,发一个“正在思考”的 UI 事件。
- 请求模型。模型返回两种结果之一:直接生成最终文本,或者返回一个技能调用请求。
- 如果是技能调用请求,进入 needSkill,校验参数后转到 executingSkill。
- 技能执行完成后,把技能结果拼接成一条工具消息,再次请求模型。
- 模型基于工具结果生成最终自然语言,流转到 finished。
这里有个关键教训:技能执行结果必须回传给模型,让模型“知道”它刚才干成了什么,然后组织最终语言。我第一版漏了这一步,导致用户看到的情况是:日历事件确实创建了,但模型回复仍然是“我已经尝试调用技能了”,语气模棱两可,用户很不爽。把结果回传后,模型会输出“已为您在 2 月 10 日上午 10 点创建了健身提醒”,体验顺滑得多。
4.3 界面组装与交互闭环
实操里我把界面拆成三个 Tab:对话、技能、设置。
对话 Tab 用List渲染消息,被调用的技能卡片嵌在消息行内。顶部加一个“新建会话”按钮。正文下方有一个输入区,输入区上沿放快捷指令 chips,比如“帮我添加提醒”“查一下日历”,点一下自动把语句填充进输入框。
技能 Tab 展示所有已注册技能,用开关控制启用状态。点击某一行进去可以看到技能的完整描述、参数定义、最近调用记录、失败次数。这个界面看起来简单,但它是技能系统能不能被用户信任的关键。用户看到“失败次数”和“重试按钮”,比看到一团含糊的“智能”要安心得多。
设置 Tab 管 API Key、模型选择、温度参数、流式开关和清理数据入口。API Key 我不建议直接明文存 UserDefaults,至少用 Keychain 包一层。实测很多新手会忽略这个,发到外部环境容易泄露密钥。
5. 常见问题与排障实录
5.1 Swift 并发下的数据竞争和 UI 假死
项目做流式输出时,最容易遇到的是 UI 卡顿。原因通常是:在后台线程收到了 model token,直接把 token 追加到String,而这个字符串同时又被主线程读取渲染。SwiftUI 的主线程和后台线程之间没有桥接,则界面会闪退或卡死。
我的排查过程:
- 第一步,在 Xcode 里打开 “Main Thread Checker”,发现大量线程冲突报错。
- 第二步,把所有直接修改 UI 状态的操作都放进一个
@MainActor的 ViewModel 里。 - 第三步,网络层和技能执行层使用独立的 actor 返回事件流,只在 ViewModel 里面合并状态。
但这里有一个更隐蔽的问题:如果把整个处理流程都放进@MainActor,网络异步回调继续往主线抛,界面依然会掉帧。最终方案是,大模型请求和技能执行都在后台 executor 上跑,只有最终“更新状态对象”这个动作在主线程执行,并且把更新动作做批量合并,而不是每个 token 刷一遍整个消息数组。
5.2 技能注册不生效,提示“No skill found”
我在写动态技能市场时遇到过一个问题:从远端拉取技能清单后,按 ID 去注册表查技能实例,总是提示找不到。
排查发现是两个原因叠加:
- 远端清单里的技能 ID 有不可见字符,比如空格或换行。处理网络 JSON 时需要 trim 一下字段。
- 技能库文件下载后在文件系统里的扩展名被系统改成
.txt,模拟器无法识别为可加载 bundle。
我做了一个防御性操作:
let normalizedID = rawID .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased()同时规定所有技能的 load 入口统一走一个工厂方法,内部做 bundle 资源定位,下载完成后先校验 bundle 结构,再执行注册。这个校验救了后续好多回。
5.3 长对话历史导致模型调用超时和 Token 超限
Agent 应用跑几轮之后,对话历史会越来越长。如果不做任何裁剪,最终会在某个节点触发模型的上下文窗口上限。我自己的一个会话在连续 20 轮技能调用后,请求体直接超过上限,接口返回 400。
解决办法分三步:
- 设置一个“对话历史摘要”机制:消息超过一定条数后,把前面的几条消息压缩成一段摘要,放进系统提示的最前面。
- 保留完整的技能调用结果,但跳过中间长参数回显。比如日历创建事件返回的结果字段很多,回传给模型时只保留“调用成功、事件标题、起始时间”这几个最小字段。
- 对每轮请求前计算预估 token 数,如果超过阈值,就把最老的消息剔除,而不是直接报错。
实际效果很好,长会话基本不会再超限。代价是模型对特别早期的信息记忆变模糊,但这对于工具型 Agent 来说影响不大,因为关键事实都已经固化成日历事件或提醒事项了。
5.4 权限问题:用户拒绝授权日历/提醒之后怎么办
技能调用系统能力,必须经过授权。用户第一次使用时拒绝授权,Agent 就会卡在“技能执行失败”状态。
我在每个需要权限的技能里加了“预检”步骤:
func execute(context: SkillExecutionContext) async throws -> SkillResult { let granted = try await calendarManager.requestAccess() guard granted else { throw SkillError.permissionDenied } // ... }权限被拒后,界面会弹出一个小提示条:“需要日历权限才能创建事件,请到设置里开启”。同时技能卡片状态显示为失败,并提供“去设置”按钮,用 URL 打开系统的权限设置页。
这个细节看似简单,但能明显降低用户对“AI 智能体”的负面感知。用户不会觉得是 Agent 不行,而是会觉得是权限流程引导不够。
6. 功能扩展路线与个人经验
6.1 从“技能”到“工作流”:下一阶段的想象力
做完 SwiftUI-Agent-Skill 项目之后,我最大的体会是:技能是单点能力,工作流才是杀手锏。
单个技能只能做一步操作,但用户的诉求往往是一连串操作。比如“每天早上 9 点把天气和今日日程总结推给我”,这个需求单靠一个技能解决不了,它需要组合技能:先查天气、再查日历、然后拼接摘要、最后创建定时推送。
我的后续计划是增加一个“工作流编排层”——用户可以在技能市场里组合技能链,Agent 按图执行。技术上其实不难:技能协议不变,只是执行者不再是一个技能,而是一组技能的有向图。状态展示、失败重试、幂等设计全部可以复用。
6.2 给想上手这类项目的朋友几点大实话
第一,先把技能协议钉死,再做 UI。UI 改起来容易,协议改起来会让你怀疑人生。写协议之前,至少要想清楚技能的输入输出、错误处理、权限模型、取消机制、幂等性这五件事。
第二,不要一上来就追求多模型接入。先用一个模型服务商跑通全链路,等整个 Agent 的循环确实稳定了,再在设置里塞“模型选择”。多接入一个 Provider,意味着你要处理多一套工具调用格式的差异,而前期根本没有这个必要。
第三,动画和体验细节永远值得多花时间。Agent 类应用拼的不只是模型能力,更是用户对“下一步会发生什么”的掌控感。技能卡片的状态提示、流式输出的字粒级动画,都会直接影响用户愿不愿意继续用下去。
第四,一定留日志。上线之后你不可能断点调试,技能调用的完整参数、执行结果、耗时、错误堆栈全部要落盘。我花了半天做的本地日志面板,在之后解决用户反馈问题时救了我无数次。
这个项目还有好多可以挖的东西,比如本地向量记忆、语音入口、跨设备技能同步。但无论怎么加,我始终认为,核心价值一定在“技能系统稳不稳、扩展顺不顺”这件事上。把地基打稳了,之后所有新玩法都是往这棵树上挂果实,不会累。