做 iOS 的同学应该都感觉到了,AI Agent 这个词过去一年在客户端开发里被反复提及,但真正动手做过的人会发现,最难的从来不是接一个模型接口,而是让 Agent 在你手机里“把事办了”。这个 SwiftUI-Agent-Skill 项目,组合了三个关键词:SwiftUI 负责界面层,Agent 充当大脑和调度中枢,Skill 则是一套可插拔的技能模块。简单说,它就是一套跑在 Apple 平台上的 Agent 应用骨架,核心不是聊天,而是技能的注册、路由、执行和状态联动。
这个项目适合两类人看:一类是准备给自己的 App 接入大模型能力的 iOS/macOS 开发者,需要一套能落地的技能系统设计;另一类是刚接触 Agent 开发、想知道“除了聊天还能让模型做什么”的朋友。这篇文章把技能协议怎么抽象、调度器怎么设计、SwiftUI 状态怎么和异步任务联动,以及我在实际开发里踩过的坑,全部拆开讲一遍。
1. 项目定位:为什么把 Agent 和 Skill 绑在一起
1.1 从“对话机器人”到“会干活的助手”
如果你只是把大模型 API 接到一个 SwiftUI 的 TextField 上,那得到的本质上是一个“带记忆的搜索引擎”。用户问一句,模型答一句,全程没有状态变化,没有系统能力调用,也没有可复用的行为单元。这样的产品上线后很容易变成“新奇感三天就没了”的玩具。
这个项目的出发点是改变交互范式:用户发出一条请求,Agent 先理解意图,再决定调用哪个技能,然后技能去操作系统能力——比如创建提醒、查询日历、抓取网页内容、做文件分类——最后把结果带回会话。这里的关键区别在于,模型不直接执行动作,而是通过 Agent 调度器去调用具体的 Skill。模型负责“思考”,Skill 负责“做事”,两者通过结构化的协议连接。
我在最初设计时其实走过弯路,一开始想的是让模型直接输出 JSON 指令,然后由 App 解析执行。这个方案听起来简单,但实际用起来全是问题:JSON 格式稍微变化就崩、参数类型无法校验、技能多了以后路由逻辑全部堆在一个巨型 switch 里。后来我参考了业内常见的 function calling 思路,把技能改成了协议驱动的注册制,才算是把这个问题真正解决了。
1.2 Skill 机制是 Agent 落地的关键拼图
为什么非要单独抽一层 Skill?因为 Agent 的能力边界必须显式声明。没有 Skill 机制的 Agent,就是一个黑盒:你永远不知道它能干什么、不能干什么,也测不准它会在什么时候说出什么不靠谱的话。而有了 Skill 层之后,Agent 的能力变成了一张可以被检视的清单,用户能知道自己可以往这个方向引导,开发者也能针对每一项技能单独测试和维护。
从工程角度看,Skill 机制还解决了几个实际问题。第一是可扩展性,新增一个技能不需要改动 Agent 核心代码,只需要注册一个符合协议的模块;第二是可测试性,每个技能都自带参数定义和结果类型,可以独立单元测试;第三是安全性,技能是 App 内显式收编的能力,不会出现模型自己编造一个“系统调用”来执行的情况。
在这个项目里,Skill 既是运行时能力单元,也是 UI 呈现单元。每个技能能声明自己的图标、标题、参数表单,SwiftUI 界面可以直接读取这些元数据,动态渲染出对应的操作卡片。这样界面层和逻辑层就通过“技能描述”这个中间层对齐了,UI 不用每个功能单独写死。
1.3 技术选型:SwiftUI 做界面层的自然优势
选 SwiftUI 这件事基本没有悬念,因为这是个纯 Apple 平台项目。SwiftUI 的声明式语法和 @Observable 状态模型,天然适合 Agent 会话这种“状态频繁变化、多事件并发到达”的场景。更重要的是,SwiftUI 的视图更新机制能精准地只刷新变化的部分,不会因为一次技能执行状态变化就把整个界面重绘一遍。
不过 SwiftUI 也有需要注意的地方,尤其是和异步任务配合时。Agent 执行技能往往需要几秒甚至更久,期间界面要持续展示进度、流式输出、中间状态,还要接收取消指令。这在传统 UIKit 里你得管理一大堆代理回调和通知中心,而在 SwiftUI 里可以通过状态对象加任务生命周期管理来统一解决。后面第 4 章我会专门讲这一块的实现细节。
2. 架构设计的关键决策
2.1 Skill 协议:最少但够用的抽象
整个项目最核心的抽象就是 Skill 协议。我在设计协议时给自己定了一条原则:字段宁缺毋滥,先满足 80% 场景,再在实现层去扩展。因为协议字段一旦定多了,每个技能都要背一堆用不上的东西,维护成本会线性上升。
我的 Skill 协议包含了五类信息:标识信息(id、name、description)、能力声明(canHandle 或者参数 schema)、执行入口(execute)、UI 描述(icon、color、form 字段)、生命周期回调(onStart、onFinish 可选)。其中最关键的是参数声明,它用结构化的方式描述这个技能需要哪些输入,这样 Agent 可以动态生成调用意图,UI 也可以自动渲染输入表单。
协议里我特别留了一个 canHandle 方法,但实际使用中发现,大部分时候 Agent 的意图路由还是靠大模型自己判断,canHandle 更多是作为一种兜底校验。比如用户说“帮我记一下明天早上九点开会”,模型把意图解析成 create_reminder 技能的调用,这时候 Agent 调度器会用 canHandle 再校验一次,避免模型乱调用。
2.2 Agent 调度器:技能路由与执行
调度器是介于模型和技能之间的指挥官,职责包括:管理会话上下文、调用模型做意图分析、从模型返回的结构化指令中解析技能调用请求、查找技能、填充参数、执行技能、把结果格式化为消息追加进会话。
我在实现调度器时最重视的一点是:模型永远不能直接触碰技能实例。模型只能返回技能标识和参数,真正查找和执行由调度器完成。这个隔离是为了安全,也是为了让技能可以保持无状态、可重入。如果一个技能在执行过程中要访问网络、文件,或者操作系统接口,这些副作用都被封装在 execute 内部,调度器只关心它的输入输出。
调度器还有一个重要职责是控制并发。默认情况下我让 Agent 串行执行技能,一个技能没跑完不发起下一个。原因很简单:大部分技能都有系统副作用,并发执行容易产生竞态,而且用户也无法理解“两个技能同时在动”的状态。当然,对于纯查询类技能,也可以扩展一个并发执行器,但那是后续优化项,不是默认行为。
2.3 SwiftUI 状态层与技能执行层的解耦
很多人在做类似项目时会把状态直接写在视图里,导致视图控制器越来越胖,最后不可维护。我在这个项目里把状态分成了三层:会话状态、技能注册状态、技能执行状态。
会话状态由 AgentSession 持有,它是一个 @Observable 类,里面包含消息数组、当前阶段、活动技能 ID 等;技能注册状态由 SkillRegistry 持有,负责技能的增删查改;技能执行状态则更细,它可能是某个技能内部的进度,也可能是流式输出的增量内容。这三层状态互相独立,UI 通过组合它们来渲染界面。
解耦带来的直接好处是,我可以在不打开 App 的情况下单独测试技能逻辑。比如写一个单元测试,直接调用 SkillRegistry 拿到技能,再构造一个 SkillContext 传入,验证返回值是否符合预期。这在纯 SwiftUI 的单测里很难做到,因为视图状态和异步生命周期纠缠太紧密。如果你准备长期维护一个 Agent 项目,这个解耦架构非常值得参考。
3. 核心实现拆解
3.1 Skill 协议的 Swift 代码示例
先看协议本身的实现,这一段是整个项目的地基:
protocol Skill: Identifiable { var id: String { get } var name: String { get } var description: String { get } var iconName: String { get } var colorHex: String { get } var parameterSchema: [SkillParameter] { get } var isAvailable: Bool { get } func canHandle(intent: String) -> Bool func execute(context: SkillContext, arguments: [String: Any]) async throws -> SkillResult } struct SkillParameter { let key: String let label: String let type: ParameterType let required: Bool let defaultValue: Any? let options: [String]? } enum ParameterType { case text case number case bool case date case enumeration } struct SkillResult { let summary: String let detail: String? let data: [String: Any]? let errorCode: String? }一个容易被忽略的字段是isAvailable。因为某些技能依赖系统权限,比如日历访问、麦克风权限,如果用户没授权,技能应该置为不可用。调度器在路由时会先检查这个标志,直接过滤掉不可用的技能,减少模型调用的幻觉概率。
SkillResult我特意做得比较宽,summary 是展示给用户的一句话,detail 是完整详情,data 是结构化数据。这个设计一开始被同事质疑过,说为什么不直接返回 Codable。但我实践下来发现,技能返回的本质是“给用户看什么”和“给模型看什么”两套信息,用宽泛的结构反而更灵活,想加字段就加,不用破坏调用方代码。
3.2 技能注册与发现机制
技能的注册机制决定了系统的可扩展性。我用了一个超轻量的注册中心:
final class SkillRegistry { static let shared = SkillRegistry() private var skills: [String: any Skill] = [:] func register(_ skill: any Skill) { skills[skill.id] = skill } func registerAll(_ skills: [any Skill]) { skills.forEach { register($0) } } func skill(forID id: String) -> (any Skill)? { return skills[id] } func allSkills() -> [any Skill] { return Array(skills.values).sorted { $0.name < $1.name } } func availableSkills() -> [any Skill] { return allSkills().filter { $0.isAvailable } } func buildPromptDescriptions() -> String { availableSkills().map { "- \($0.id): \($0.description),参数:\($0.parameterSchema.map { "\($0.key)(\($0.type))" }.joined(separator: ", "))" }.joined(separator: "\n") } }其中最有价值的方法是buildPromptDescriptions()。它的作用是把所有可用技能变成一个文本描述列表,注入到系统提示词里,这样模型在意图分析时就能“知道”当前有哪些技能可以用。
实践中这个方法非常关键。因为大模型不知道你的 App 里有什么,你必须把技能目录告诉它,否则它只会给你聊天。我试过写死系统提示词的方案,每次增删技能都要同步改提示词,太痛苦了。现在每次会话开始时动态生成,技能变了提示词自动跟着变。
3.3 Agent 会话状态与消息模型
会话状态的核心是一个 @Observable 类:
@Observable final class AgentSession { enum Phase: Equatable { case idle case thinking case executing(skillID: String) case error(message: String) } var messages: [ChatMessage] = [] var phase: Phase = .idle var streamedContent: String = "" private var currentTask: Task<Void, Never>? private let modelClient = LLMClient() func send(_ text: String) async { let userMessage = ChatMessage(role: .user, content: text, skillID: nil) messages.append(userMessage) phase = .thinking currentTask = Task { do { let request = try await buildAgentRequest() let stream = try await modelClient.streamResponse(request) var assistantText = "" var skillCall: SkillCall? for try await chunk in stream { try Task.checkCancellation() switch chunk { case .text(let text): assistantText += text streamedContent = assistantText case .skillCall(let call): skillCall = call } } if let skillCall { try await executeSkillCall(skillCall, parentText: assistantText) } else { let reply = ChatMessage(role: .assistant, content: assistantText, skillID: nil) messages.append(reply) phase = .idle } } catch is CancellationError { phase = .idle } catch { phase = .error(message: error.localizedDescription) } } } func cancel() { currentTask?.cancel() } }消息模型我做了角色、内容、关联技能 ID 三个字段。角色区分用户和助手,内容存文本,技能 ID 用来标记这条消息是哪个技能的执行结果。技能 ID 字段一开始没有,后来发现定位问题贼困难——用户说了一句话,Agent 决定调技能,但界面上完全看不出这个动作。加上关联技能 ID 之后,可以在消息卡片上展示“使用了 XX 技能”的标签,排障时一眼能看到调用链路。
4. SwiftUI 界面与技能的联动
4.1 消息列表与技能卡片的动态渲染
界面的设计思路是“消息流 + 技能卡片”。普通文本消息就是气泡,而技能执行相关的消息会渲染成一张卡片,上面有技能图标、执行状态、结果摘要和展开详情的按钮。
SwiftUI 里的实现并不复杂,核心是 ForEach 加类型判断:
struct MessageList: View { let messages: [ChatMessage] var body: some View { ScrollView { LazyVStack(spacing: 12) { ForEach(messages) { message in switch message.kind { case .text: TextBubble(message: message) case .skillResult(let skillID, let result): SkillResultCard(skillID: skillID, result: result) } } } } } }这里要注意一点:SwiftUI 的 ForEach 要求消息 ID 稳定且唯一。我一开始用的是消息内容的 hash 作为 ID,但流式输出时内容一直在变,导致整个列表疯狂重建。后来改成了 UUID 作为 ID,只在创建消息时生成一次,这个卡顿问题立刻消失了。
4.2 流式输出的界面更新策略
流式输出是 Agent 类应用体验的关键。模型回答是一段一段生成的,UI 需要实时刷新,但不能每来一个 token 就让整个列表滚动到底部一次,会卡。
我的做法是,当前正在生成的助手消息单独放在一个“流式槽位”里,不进入消息数组。等到流式结束,把完整的文本包装成一条正式消息,再追加到 messages 里。画面更新时,ScrollView 只在流式开始时滚动到底部,内容积压时通过 onChange 监听触发,避免频繁滚动。
struct ChatView: View { @Bindable var session: AgentSession var body: some View { ScrollViewReader { proxy in MessageList(messages: session.messages) .onChange(of: session.phase) { _, newPhase in if case .executing = newPhase { withAnimation { proxy.scrollTo("bottom", anchor: .bottom) } } } } } }流式文本渲染还有个性能优化技巧:把 Text 视图改成只读的,不要在里面做 markdown 实时解析。我之前试过流式过程中实时解析 markdown,每一帧都要重新渲染富文本,性能直接拉胯。后来的方案是:流式阶段只显示纯文本,等整条消息落地后再用富文本渲染视图展示最终格式。
4.3 交互细节:取消、重试与技能意图提示
Agent 任务执行期间,用户一定会想取消。我在界面里放了一个取消按钮,绑定session.cancel()。它做的事情本质上是取消当前的 Task,而 Swift 协程的取消机制会一路传递到技能内部的异步操作。关键在于技能执行过程中要主动检查Task.checkCancellation(),否则取消信号会被忽略,技能还会继续跑。这是新手最容易踩的坑——界面上点了取消,后台任务还在执行。
重试机制我设计得比较克制:只在技能调用失败时提供重试按钮,避免用户反复重发整段对话导致上下文膨胀。点击重试时,调度器会复用上一次解析出的技能调用参数,重新执行一次技能,而不是重新调用模型。
还有一个让用户觉得“懂你”的小细节:技能意图提示。当模型判断用户请求可能对应某个技能但缺少必要参数时,界面会自动弹出一个参数补全卡片,以表单形式让用户填写缺的字段。这个表单就是根据parameterSchema动态渲染的,不需要为每个技能单独写表单 UI。我把这一步称为“半自动技能填充”,在真实使用场景里非常实用。
5. 实操记录:从零跑通一个提醒技能
5.1 工程搭建与目录结构
这个项目的工程搭建很简单:在开发工具里新建一个 iOS App 工程,选择 SwiftUI 生命周期,iOS 17 以上(因为用到了 @Observable)。我没有用任何第三方依赖,模型客户端的对接就用系统网络框架,UI 全部 SwiftUI 实现。
目录结构我建议这样组织,方便长期维护:
SwiftUI-Agent-Skill/ ├── App/ │ └── AgentSkillApp.swift ├── Models/ │ ├── ChatMessage.swift │ ├── SkillTypes.swift │ └── AgentPhase.swift ├── Agent/ │ ├── AgentSession.swift │ ├── SkillRegistry.swift │ └── LLMClient.swift ├── Skills/ │ ├── ReminderSkill.swift │ ├── WebSearchSkill.swift │ └── ... └── Views/ ├── ChatView.swift ├── MessageList.swift ├── SkillResultCard.swift └── SkillFormView.swift这个结构把协议、注册表、会话、技能实现、视图分开,每个文件职责单一。我见过很多 Agent 项目把所有东西塞进一个几百行的文件里,那种代码改起来真的太痛了。
5.2 从协议到实现:写一个 ReminderSkill
拿提醒技能举例,它需要做的核心事情是:把 Agent 传过来的时间、标题、备注参数,转成一个本地提醒并写入系统。实现如下:
final class ReminderSkill: Skill { let id = "create_reminder" let name = "创建提醒" let description = "在指定时间为用户创建一条提醒事项" let iconName = "bell.fill" let colorHex = "FF9500" let isAvailable = true var parameterSchema: [SkillParameter] { [ SkillParameter(key: "title", label: "提醒内容", type: .text, required: true, defaultValue: nil, options: nil), SkillParameter(key: "date", label: "提醒时间", type: .date, required: true, defaultValue: nil, options: nil), SkillParameter(key: "note", label: "备注", type: .text, required: false, defaultValue: nil, options: nil) ] } func canHandle(intent: String) -> Bool { return intent.contains("提醒") || intent.contains("记得") } func execute(context: SkillContext, arguments: [String: Any]) async throws -> SkillResult { guard let title = arguments["title"] as? String, !title.isEmpty else { throw SkillError.invalidArgument("title 不能为空") } guard let date = arguments["date"] as? Date else { throw SkillError.invalidArgument("date 参数无法解析") } try await EventStoreBridge.shared.createReminder(title: title, date: date, note: arguments["note"] as? String) let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm" let dateText = formatter.string(from: date) return SkillResult( summary: "提醒已创建:\(title),时间:\(dateText)", detail: "系统提醒写入成功", data: ["title": title, "date": date], errorCode: nil ) } }这里有一个参数解析的注意点:模型返回的日期往往是字符串,可能是“明天早上九点”这种相对表达,也可能是“2025-06-01 09:00:00”这种绝对表达。我建议在调度器层面做一次统一的日期解析,把字符串转换为 Date,而不是让每个技能自己处理日期格式。这样可以集中管理格式规则,也很方便做时区处理。
5.3 端到端联调的关键步骤
跑通端到端流程,需要验证的不只是“技能有没有执行”,而是整条链路是否顺畅。我总结了一套联调顺序:
- 单元测试阶段:直接调用 Registry 获取 ReminderSkill,构造完整参数调用 execute,验证返回值。这一步跑通了,技能本身逻辑没问题。
- 模拟模型阶段:把 LLMClient 替换为一个本地 mock,固定返回一个技能调用指令,验证调度器是否正确解析、执行、追加消息。
- 真实模型阶段:接上真实 LLM 服务,验证模型是否能根据系统提示词中的技能描述正确返回技能调用指令。
- UI 联调阶段:打开界面,输入“明天早上九点提醒我开会”,观察流式输出、技能卡片、系统提醒是否正常出现。
每一步都有独立的验证目标,出了问题能快速定位是技能逻辑问题、调度问题还是模型问题。我实际开发中,90% 的问题都出在第二步和第三步之间——模型返回的技能调用格式稍有变化,调度器解析就出问题。
6. 常见问题与排查实录
6.1 @MainActor 卡界面:异步执行的线程误区
我遇到的最典型问题就是界面卡死。原因是技能里的网络请求或耗时操作把主线程阻塞了,而 SwiftUI 的所有 UI 更新都必须在主线程。你能看到的症状是:点发送按钮后,整个界面冻住,几秒钟后才恢复。
排查方法很简单:在技能执行的 execute 方法里打日志,标注当前线程。如果看到耗时操作在主线程执行,那就说明你的异步函数没有正确脱离主线程。修复方案是在技能执行前标记nonisolated,或者在调度器里明确把技能调用放到后台任务中执行,再通过状态对象回到主线程更新 UI。
Swift 协程的默认行为其实已经考虑到了这点:如果你在 @MainActor 上下文中调用一个非隔离的异步函数,它会自动切到后台执行,回来时再回到主线程。问题往往出在技能内部又调用了 @MainActor 标记的 API,一环扣一环,就卡住了。我的习惯是技能的 execute 方法明确声明 nonisolated,只有真正需要触碰 UI 的代码才切回主线程。
6.2 技能执行中的上下文丢失
第二个常见问题是上下文丢失。用户说“帮我定个闹钟”,模型解析出闹钟技能,但时间参数是空的,技能执行时因为缺参数直接失败。这不是模型不行,而是你的调度器没有把会话里已有的信息传给模型做参数推断。
解决方案是在调用模型做意图分析前,把最近的会话消息压缩成一段摘要,作为上下文传给模型。比如“帮我定个闹钟”前面用户刚说过“明天早上七点要去跑步”,模型有了上下文就能把“七点”作为闹钟时间参数填进去。我在项目里实现了一个轻量级的上下文压缩器,用简单规则加模型摘要结合的方式,将最近 N 轮对话的关键信息提取出来,作为技能参数补全的辅助依据。
这个功能极大提升了体验。没有上下文补全时,用户必须说全“明天早上七点定闹钟”;有补全后,用户可以说“帮我定个闹钟,跟昨天一样的时间”,系统也能正确推断。
6.3 并发调度竞态:快速连发消息
用户快速连发两条消息时,如果不做控制,会出现两条 Agent 任务同时运行,技能执行结果交错返回,消息顺序全部混乱。我一开始没处理这个问题,测试时发现明明先发了 A 消息,结果 B 的执行结果先回来了。
最后用了一个非常朴素但有效的方案:同一会话下,新消息会取消当前未完成的任务。用户在旧任务还没跑完时发送新消息,旧任务直接取消,新任务重新开始。这样保证任何时候只有一个任务在跑,消息顺序永远是线性的。这个设计也符合用户直觉——用户发新消息,说明他不要旧答案了。
6.4 排查辅助清单
最后整理一份我在实际开发中反复用到的排查清单,每次出问题按这个顺序查,能省大量时间:
- 先看 Agent 的模型调用日志,确认系统提示词里是否包含技能描述,模型是否返回了技能调用结构。
- 再看调度器解析层,确认从模型返回的指令到参数字典的转换是否成功,有没有因为 JSON 结构变化导致解析失败。
- 然后看技能执行日志,确认 execute 方法是否被调用,是否抛出了异常,异常信息是什么。
- 最后看 UI 状态,用 SwiftUI 的调试工具检查 phase 是否正确变化,消息数组是否被正确追加。
这四层日志每层我都单独打印,用统一前缀标记,排障的时候 grep 一下前缀,整条调用链一目了然。
做 Agent 应用和做普通 App 最大的区别在于,你写的代码是“概率性正确”的——模型可能 90% 的情况都正常,但剩下的 10% 会让你的技能系统措手不及。所以架构设计里要时刻考虑容错、取消、参数校验和状态恢复。我在这套 SwiftUI-Agent-Skill 项目上最大的体会是:Skill 协议设计得越轻,技能实现越简单,系统的稳定性反而越高;而 Agent 调度器才是真正决定产品上限的部分,它值得你花最多的精力和耐心去打磨。