先把话说在前面这一类“SwiftUI Agent Skill”的项目这两年越来越多人问但真正能跑通、能落地、不是只停留在 demo 层面的其实不多。标题里三个词拆开看都不难SwiftUI 是苹果生态的声明式 UI 框架Agent 是现在最热的自主智能体概念Skill 在 Agent 体系里通常指“技能”也就是模型之外的可执行工具能力。但把这三样东西揉成一个工程涉及协议设计、状态管理、异步编排、并发安全、UI 绑定坑远比想象中多。这个项目定位很清楚做一个以 SwiftUI 为前端、以技能系统为核心的 Agent 客户端框架。换句话说它不是一个聊天框套壳而是让 Agent 真正具备“调用工具完成任务”的能力并且这些能力能被动态注册、灵活扩展。这个定位解决了什么问题现在很多 AI 应用的痛点在于模型只会“说”不会“做”而 Skill 体系就是让模型能通过结构化参数去触发真实操作。适合谁来参考想在自己的 App 里集成智能体能力的 iOS/macOS 开发者、对 Agent 架构有兴趣但不知道怎么落地的人还有那些被五花八门的 Agent 框架搞晕、想知道“协议到底怎么设计”的人。我先说结论这个项目的核心难度不在 UI而在 Agent 编排层和技能注册机制的设计。文章按我的实际落地顺序来写从架构思路、协议设计、核心实现到调试现场和扩展方向尽量把每个“为什么这么写”都讲明白。1. 整体设计思路拆解为什么是这套组合1.1 先给项目定个位“SwiftUI-Agent-Skill”本质上是一套客户端侧的 Agent 运行时框架。它跟服务端 Agent 最大的区别是UI 和技能都在本地Agent 的状态需要实时驱动界面刷新技能的注册和调用也发生在本地进程中。我见过不少开发者一上来就用 SwiftUI 写聊天界面然后把大模型 API 封装一下就宣称做了个 Agent。但那只是聊天机器人。真正的 Agent 要具备一个完整的循环理解用户意图、判断是否需要调用工具、抽取参数、执行技能、把结果返回给模型、生成最终回复。这个循环在服务端有 LangChain 之类的框架帮你抽象但在纯 Swift 环境下这套编排逻辑得自己写。所以这个项目的第一个设计决策是把技能系统做成插件化的注册表而不是硬编码的 if-else。理由很简单Agent 的技能会越来越多今天加一个天气查询明天加一个日程写入后天可能加一个文件搜索。如果每个技能都写死在对话流程里编排层会越来越臃肿而且没法单独测试。1.2 技能体系在整个架构里的位置理解 Skill 体系最好的方式是把它想象成一个“工具箱”。模型是大脑负责决定用什么工具Skill 体系是工具箱负责把工具整齐地放好、标注清楚、提供统一的插拔接口AgentManager 是手负责从工具箱里取工具、执行、再放回去。用生活类比的话你请一个管家Agent帮你办事。管家本身不会修水管但他知道你有“通下水道”这个技能就打电话给工人Skill工人带着工具来干活干完再把结果汇报给管家管家转告给你。这个过程中工人不需要懂你家里装修风格UI管家也不需要真的会通下水道业务实现两边通过“工单”结构化参数协作。这个分层的好处非常明显技能可以独立开发、独立测试不污染对话逻辑新增能力只需要注册一个 Skill改动成本极低Agent 可以感知“有哪些技能可用”从而决定要不要调用而不是瞎猜1.3 为什么前端选 SwiftUI 而不是 UIKit关于 SwiftUI 的选择我要多说两句。它最大的优势是状态驱动的 UI 重建机制界面不是“被代码操控着更新”而是“随状态变化自动更新”。这对 Agent 场景来说简直是天作之合因为 Agent 运行过程中状态变化非常频繁——消息在增加、处理状态在切换、技能在激活、工具在返回结果这些如果用 UIKit 去手动刷新会写出一堆 delegate 和通知代码。SwiftUI 的Published属性、ObservableObject协议、StateObject包装器天然就是为这种多状态实时联动场景准备的。再加上 Swift 5.5 之后 async/await 的普及Agent 编排层的异步逻辑可以用近乎同步的代码写出来复杂度直线下降。2. 核心模块设计与关键细节2.1 Skill 协议应该怎么设计这是整个项目的地基。我第一版写得很随意就是一个execute()方法加一个名字结果后面越用越难受。踩了几次坑之后我重新整理了一套相对完整的协议定义核心要素包括技能的唯一标识id展示名称和功能描述供模型理解参数声明告诉模型这个技能需要什么参数、参数类型和约束异步执行入口统一返回结果结构上下文透传技能可能需要访问会话信息、用户偏好等参数声明这块尤其关键。想让模型准确填参就必须给模型结构化的参数 schema类似 JSON Schema 的简化版。我之前试过让技能自己解析自然语言参数效果非常差模型经常抽错字段。后来改成声明式参数定义让模型按 schema 生成参数准确率提升明显。一个简化但完整的协议大概长这样/// 技能参数定义 struct SkillParameter: Identifiable, Sendable { let id: String // 参数名如 city let title: String // 参数展示名如 城市 let type: ParameterType // 类型string/int/bool/option let required: Bool let description: String // 参数说明模型靠这个理解语义 let options: [String]? // 枚举选项可选 let defaultValue: String? } /// 技能执行结果 struct SkillResult: Sendable { let success: Bool let data: [String: Any]? // 结构化结果 let text: String? // 可直接展示的文本结果 let errorMessage: String? } /// 技能协议 protocol AgentSkill: Identifiable, Sendable { var id: String { get } var name: String { get } var summary: String { get } var parameters: [SkillParameter] { get } func execute( arguments: [String: Any], context: SkillContext ) async throws - SkillResult }加粗标出两个容易被忽略的设计点Sendable和SkillContext。前者是为了并发安全因为技能可能在不同任务中并发执行如果技能实现里捕获了可变状态很容易出数据竞争后者是为了让技能能访问会话级别的上下文信息比如“当前是谁在对话”“会话历史摘要”否则很多技能没法工作。2.2 技能注册表不是简单字典那么简单技能注册表看起来就是一个[String: any AgentSkill]的字典key 是技能 id。但实际工程里需要额外解决几个问题。第一模型怎么知道有哪些技能可用每次对话都要把技能列表塞进提示词而且技能多了之后 token 消耗很大。我的做法是给每个技能维护一个“活跃度”或“最近命中频率”定期把低频技能降级成摘要描述高频技能用完整描述。这一步省下的 token 成本相当可观。第二注册表要支持运行时动态增减。所以注册表本身应该是个MainActor隔离的可观察对象增删技能的时候 UI 能实时感知。比如设置界面里开关一个技能聊天界面立刻就能感知到 Agent 能力范围变了。第三技能冲突检测。两个技能可能声明了相似的参数结构或者 id 重复。注册的时候要校验否则会出现模型想要的技能被错误匹配的情况。2.3 AgentManager 的状态机设计AgentManager 是整个编排层的心脏。我把它设计成一个MainActor final class持有三样东西技能注册表会话消息列表当前运行状态空闲、思考中、调用技能、生成回复选择MainActor是因为它的所有公开属性都要驱动 UI 刷新如果在内网线程修改Published属性SwiftUI 会给出运行时警告而且刷新时机不可控。与其到处加DispatchQueue.main.async不如直接整类隔离到主 actor内部用 async/await 而非阻塞操作这样既安全又清晰。设计上有一个关键决策把模型调用和技能执行都封装成异步函数而编排循环本身就是一段 async 代码。模型推理的时候技能还没执行技能执行的时候模型在等待结果。整体是一个串行流程但每一步都不阻塞 UI。用户看到的界面效果是输入消息→状态变为“思考中”→显示技能调用卡片→显示技能结果→状态变为“回复中”→最终消息落到列表里。这个状态机的价值在于它让 UI 层永远只需要根据状态做渲染而不是被事件牵着走。界面代码里几乎看不到“某个按钮触发了什么刷新”的逻辑一切变化都是状态的自然结果。3. 实操落地从空工程到跑通一轮带技能的对话3.1 工程结构按层拆分先说目录结构。我建议不要把所有文件堆在一起按职责分成四层Models数据模型、Skills技能实现、Agent编排与注册表、ViewsSwiftUI 界面。SwiftUI-Agent-Skill/ ├── Models/ │ ├── ChatMessage.swift │ ├── SkillTypes.swift │ └── AgentState.swift ├── Skills/ │ ├── WeatherSkill.swift │ ├── NoteSkill.swift │ └── SkillRegistry.swift ├── Agent/ │ ├── AgentManager.swift │ ├── PromptBuilder.swift │ └── ToolCallParser.swift └── Views/ ├── ConversationView.swift ├── MessageBubble.swift ├── SkillInvocationCard.swift └── AgentControlPanel.swift这种拆分不只是为了好看。它强制你遵守依赖方向Views 只依赖 Agent 和 ModelsSkills 只依赖 ModelsAgent 依赖 Skills。后面加新技能的时候你的手不会伸到 UI 层去改任何东西。3.2 实现一个内置技能操作一目了然技能实现是理解整套体系最快的入口。我挑一个“创建备忘”的技能来说明完整链路不要用天气之类的例子因为天气还要接网络 API容易分散注意力。备忘技能的逻辑就是接收标题和正文两个参数写入本地存储返回成功提示。final class NoteSkill: AgentSkill { let id note.create let name 创建备忘 let summary 创建一条备忘记录需要提供标题和正文内容。 let parameters: [SkillParameter] [ SkillParameter( id: title, title: 标题, type: .string, required: true, description: 备忘的标题通常是几个词或一句话, options: nil, defaultValue: nil ), SkillParameter( id: body, title: 正文, type: .string, required: true, description: 备忘的正文内容支持多行, options: nil, defaultValue: nil ), ] func execute( arguments: [String: Any], context: SkillContext ) async throws - SkillResult { guard let title arguments[title] as? String, let body arguments[body] as? String else { return SkillResult( success: false, data: nil, text: 参数缺失无法创建备忘, errorMessage: missing_required_fields ) } try await NoteStore.shared.save(title: title, body: body) return SkillResult( success: true, data: [title: title, body: body], text: 已创建备忘\(title), errorMessage: nil ) } }注意这里几个细节参数描述写得非常直白这是给模型看的不是给人看的。模型的工具调用质量很大程度取决于这些 description 的质量。另外一个细节是结果同时返回结构化数据和文本。结构化数据供编排层后续处理文本则可以直接展示给用户两者分开能兼顾自动化和展示需求。3.3 编排循环Agent 的核心中枢编排循环是整个项目最值得细看的代码。逻辑不复杂但每一步都有讲究把用户输入追加到消息列表把所有可用技能的描述压缩成提示词调用模型要求模型返回两类结果之一普通回复或工具调用请求如果是工具调用解析出技能 id 和参数查注册表执行技能把结果以消息形式追加到会话把“技能执行结果”连同原始请求再喂给模型生成最终回复成功则追加最终消息失败则回退错误信息核心实现大致如下MainActor final class AgentManager: ObservableObject { Published private(set) var messages: [ChatMessage] [] Published private(set) var state: AgentState .idle Published private(set) var activeSkill: any AgentSkill? private let registry: SkillRegistry private let llmClient: LLMClient func send(_ text: String) async { messages.append(.user(text)) state .thinking do { // 第一步让模型判断是否需要调用技能 let skillPayload registry.compactPromptPayload() let firstTurn messages [.system(skillPayload)] let firstResponse try await llmClient.chat(history: firstTurn) // 第二步如果模型决定调用技能则执行 if let call try ToolCallParser.parse(from: firstResponse) { guard let skill registry[call.skillId] else { throw AgentError.skillNotFound(call.skillId) } activeSkill skill state .executingSkill let context SkillContext( sessionId: currentSessionId, recentMessages: messages.suffix(6) ) let result try await skill.execute( arguments: call.arguments, context: context ) // 第三步把结果回传给模型生成最终回复 let secondTurn messages [ .toolResult(result.text ?? 执行完成) ] state .replying let finalReply try await llmClient.chat(history: secondTurn) messages.append(.assistant(finalReply)) } else { messages.append(.assistant(firstResponse)) } } catch { messages.append(.error(error.localizedDescription)) } activeSkill nil state .idle } }这个流程本质上就是 ReAct 思路的简化版只不过不追求多轮思考链而是控制在一轮工具调用以内。原因是移动端场景对延迟敏感多轮工具调用的体验很容易崩掉。如果后续要做复杂任务可以在循环外加一层 while 判断最大轮数但我不建议初学者一上来就做复杂版先跑通一轮再扩展。有个细节值得提技能执行结果回传模型的时候用.toolResult(...)拼接而不是简单塞进 user 消息。这么做是为了让模型明确知道这段内容是工具执行结果而不是用户说的话。很多模型对 prompt 前缀非常敏感区分消息来源能明显减少模型把工具结果当成用户消息去回应的概率。3.4 SwiftUI 界面绑定与交互界面部分最核心的就是把 AgentManager 绑到视图上。我用EnvironmentObject注入深层的子视图随时可以读取状态。struct ConversationView: View { EnvironmentObject private var agent: AgentManager State private var inputText var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in List(agent.messages) { message in MessageBubble(message: message) .id(message.id) } .onChange(of: agent.messages.count) { withAnimation { proxy.scrollTo(agent.messages.last?.id, anchor: .bottom) } } } if agent.state .executingSkill { SkillInvocationCard(skill: agent.activeSkill) .transition(.move(edge: .bottom)) } HStack { TextField(输入消息, text: $inputText) .onSubmit { send() } Button(发送) { send() } .disabled(agent.state .thinking || agent.state .replying) } } .animation(.default, value: agent.state) } private func send() { let text inputText inputText Task { await agent.send(text) } } }这里有一个很容易踩的细节列表滚动跟随。用onChange(of: agent.messages.count)而不是监听最后一条消息的文本是因为技能调用过程中会插入中间消息消息数量变化是最可靠的信号。另外发送按钮在 Agent 处理期间禁用但要保留输入框可编辑否则用户想连续提问会被卡住。技能调用卡片放的是 AgentManager 的activeSkill和状态。卡片只在executingSkill状态下出现展示当前在跑什么技能、处理到哪一步这能极大缓解用户等待时的焦虑感也让整个 Agent 的行为可解释。透明度和动画用transition和animation配合状态变化自动触发不需要手动控制。4. 调试实录我踩过的坑和排查思路这部分是我最想写给后来者的因为协议设计你可以看书学但下面这些坑基本是血泪换来的。4.1 并发隔离技能在后台执行导致 UI 卡顿第一次把技能改成真正的异步网络调用时我犯了一个错误技能实现里用了 URLSession 的异步接口没问题但回调里去修改一个共享的单例存储那个存储的操作没做隔离于是偶发崩溃而且只在特定网络条件下复现。排查过程很典型先看控制台有没有主线程警告再开 Thread Sanitizer最后定位到是存储单例内部的可变字典被多个任务同时读写。修复方案是给存储类加一个独立的 actor 隔离或者把所有写操作收敛到技能内部串行执行。经验是技能协议声明Sendable只是第一步技能实现里所有共享可变状态都必须用 actor 或锁保护。别以为 UI 层在主 actor 就万事大吉技能可能被多个编排任务并发调用尤其是后面做多任务并行的时候。4.2 状态不刷新Published 改了 UI 不动有段时间调试技能执行时的卡片显示发现activeSkill明明赋值了界面就是不出现。查了半天发现问题是 AgentManager 的某个方法没有标注MainActor整个类虽然标注了但子方法被Task带着脱离了主 actor 上下文去执行赋值操作落在了后台线程。SwiftUI 对主线程更新有运行时检查但有时候这种跨 actor 的赋值不会立刻报错而是表现为“偶尔刷新、偶尔不刷新”。排查技巧很实用在init里加一个断言MainActor.assumeIsolated或者打印Thread.isMainThread能快速判断对象是否还在主线程。从那以后我养成了习惯所有ObservableObject的类声明处直接写MainActor不要等出问题再加。4.3 模型工具调用参数解析翻车参数解析是最容易出问题的环节。模型的回复是自然语言夹杂着工具调用的 JSON我第一次用正则去匹配结果模型在 JSON 里加了注释、多余的逗号、甚至把参数名拼错。最后改用更稳的策略让模型在系统提示词里严格按我给的 JSON 格式返回然后解析时做多重容错——先尝试完整解析失败后尝试提取代码块再解析再失败就把整段文本交给用户看而不是假装成功。这里有个经验模型返回的工具调用参数里字符串类型最容易被截断比如正文里有换行或引号。如果你用JSONSerialization解析字符串一定要指定.fragmentsAllowed选项否则多行字符串直接被拒绝。4.4 技能注册失败列表里永远找不到新技能这个问题看起来简单实际隐蔽在类型擦除上。Swift 里[String: any AgentSkill]字典存的是 any 类型读取时要用as?转回具体类型。但有一类错误是注册表里明明有值执行的时候却报“找不到”。查了半天发现是技能 id 字符串不一致一个地方用了note.create另一个地方在 id 前面加了前缀模型返回的 id 又是在提示词里拼接的三者对不上。排查手段很简单在注册方法里打日志输出每个技能的 id在查找方法里同样打日志对比两次输出问题立刻暴露。这让我意识到技能 id 最好定义成静态常量统一在同一处引用不要到处手写字符串。5. 测试策略与继续扩展的方向5.1 单测、快照与 mock 三件套Agent 项目最让人头痛的是不确定性。模型每次返回都不一样没法做传统意义的断言式测试。我的做法是分三层第一层是技能单测。每个技能单独喂入构造好的参数断言执行结果的结构化数据和文本。这部分完全确定可以把所有分支都覆盖到包括参数缺失、存储失败、异常参数类型。第二层是编排快照测试。用固定的 mock 模型回复跑完整流程把最终的消息列表和状态变化序列记录下来每次改动后对比快照。如果编排逻辑改了快照会告诉你改动影响了哪些环节相当于回归测试。第三层是模型交互的可视化调试面板。我给项目加了一个隐藏面板显示每次请求的完整 payload 和模型原始返回。实际调试中这个面板比日志好用得多因为你能直观看到提示词哪些地方把模型带偏了工具调用的 JSON 到底长什么样。很多问题不是代码 bug是提示词问题可视化调试是解决这类问题最快的方式。5.2 技能体系还能怎么扩展做完了基础框架有几个扩展方向是非常自然的按成本从低到高排列技能依赖注入。现在技能是自己创建自己的依赖后面可以改成初始化时注入服务这样写测试的时候能替换成 mock 存储。技能并行执行。有些任务天然可以并行比如同时查天气、查日历、查交通。把编排循环从“一轮只调一个技能”改成“一轮可调多个技能”需要给注册表加并发执行器同时要注意多个技能的结果如何合并回传给模型。长期记忆。现在SkillContext只透传会话摘要后面可以扩展成访问持久化的用户画像让技能能感知用户偏好。这会涉及到隐私设计建议把记忆模块做在独立的 actor 里显式控制读写权限。技能市场。既然技能是协议化的理论上可以做一个远端技能包的下载和热更新机制——从服务端拉取元数据、校验签名、注册进本地注册表。这在团队内部分发技能时特别有用但注意签名校验和沙盒权限一定要在第一天就设计好否则后面补会非常痛苦。我个人在反复调这个项目时的体会是框架的价值不在于写得多炫而在于每次新增能力时的阻力是不是趋近于零。协议和注册表设计得好的话加一个技能就是新建一个文件、注册一行代码的事。如果哪天你发现加个技能要改五个地方那就说明抽象该重构了。最后再分享一个小技巧给技能的描述文案建立一套写作规范比如“不要超过两句话”“必须写明使用前提”“明确写出参数之间的依赖关系”。模型能不能正确调用工具一半看代码一半看描述。把描述文案当成产品文案来写效果立竿见影。这套跑通了之后你会发现 Agent 的可靠性、可解释性和可维护性都能上一个台阶后续接更多技能也只是体力活。