做 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: TaskVoid, 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 调度器才是真正决定产品上限的部分它值得你花最多的精力和耐心去打磨。