开工之前先说明一下这个 Side Project 我起名就叫 SwiftUI-Agent-Skill名字比较直白SwiftUI 负责界面Agent 负责对话和决策Skill 承载具体执行力。我最近一直在折腾它目标是做一个跑在 iOS 上的轻量 AI 工作台不是那种只能聊天的套壳应用而是让 AI 真正能“动手办事”——比如算个表达式、查个备忘录、处理一段文本这些都是通过可插拔的 Skill 技能模块完成的。如果你也在研究 SwiftUI 和 AI Agent 怎么结合或者想把手上的 LLM API 接入自己 App 里做成工具链这篇文章应该能帮到你。我会把项目的整体架构、Skill 协议的设计、Agent 循环的实现以及我在开发中踩过的一堆坑一次性倒出来。后面涉及代码的部分我都会贴关键实现尽量做到你打开 Xcode 就能跟着写。1. 项目概览与核心思路1.1 这个项目到底在干什么SwiftUI-Agent-Skill 从用户视角看就是一个带聊天界面的助手但它和普通聊天应用最本质的区别在于用户输入一句话之后Agent 会判断是否需要调用工具如果需要就解析出结构化参数调度对应的 Skill 执行再把执行结果反馈给模型最终生成一段自然的回答。举个例子你在输入框里敲“帮我算一下 (237)*2 等于多少”普通的聊天 App 要么直接答错要么把问题丢给大模型让它在文本里瞎猜。但这个项目里Agent 会识别出这是数学计算于是触发 calculator 这个 Skill由本地的代码真正执行表达式计算拿到结果后再组织语言回复你。整个过程不是一次性完成的而是一个“模型提出工具调用 - 程序执行工具 - 模型总结结果”的循环。这个小项目的范围我有意控制得很小不做账号系统不做云端同步所有配置和技能元数据都存本地。MVP 只需要支持 3 到 5 个技能、多轮上下文、流式输出和技能热注册。这种边界设置让我能在几周内把核心链路跑通而不是把时间花在无关紧要的周边功能上。对想复现的人来说这个规模刚好看得完、改得动。1.2 为什么是 SwiftUI、Agent、Skill 这三样组合先说 SwiftUI。做这种对话型界面SwiftUI 的声明式语法和状态驱动模型是天然匹配的。消息列表无非就是一组数据模型 ScrollView ForEach新增一条消息、更新某条消息的流式文本都只需要改状态视图自动刷新。比起 UIKit 手写 diff 和 cell 管理开发效率高很多。然后是 Agent。这里说的 Agent 不是一个很玄的概念在我的实现里它就是一个调度核心维护消息历史、构造包含工具描述的请求、解析模型返回的工具调用、调用 Skill、把结果回填给模型。它解决的核心问题就是“什么时候该用哪个技能”本质上是一个带工具调用能力的对话循环。最后是 Skill。如果你做过传统工具型 App应该体会过功能越来越多、if else 越堆越乱的痛苦。Skill 把每个能力封装成独立的插件遵循同一个协议Agent 只依赖协议不依赖具体实现。新增一个技能就是新增一个结构体不需要改 Agent 的代码也不需要动界面层。这三样组合起来正好形成“界面层 决策层 执行层”的清晰分工。1.3 目标读者与预期收获这篇文章适合三类人一是对 SwiftUI 开发有一定基础、想尝试 AI 应用的同学二是已经在调 LLM API、想把它从单轮对话升级成工具调用模式的人三是纯粹对“可插拔技能系统”感兴趣、想看协议怎么设计的人。如果你是完全没写过 Swift 的小白也不用太紧张我会尽量把每一步讲清楚但你最好先知道 SwiftUI 的基本概念比如 View、State、ObservableObject否则 UI 部分可能有点吃力。读完这篇文章你至少能搭建一个运行在 iOS 上的最小 Agent 闭环并且理解如何扩展属于自己的技能。2. 整体架构与技能体系设计2.1 目录结构与模块划分项目代码结构我花了不少心思核心原则是“依赖方向从外向内界面不直接依赖技能实现”。直接看目录SwiftUI-Agent-Skill/ ├── App/ │ ├── AgentSkillApp.swift │ └── RootView.swift ├── Core/ │ ├── Agent/ │ │ ├── Agent.swift │ │ ├── AgentContext.swift │ │ └── AgentStep.swift │ ├── Skill/ │ │ ├── Skill.swift │ │ ├── SkillContext.swift │ │ ├── SkillResult.swift │ │ └── SkillRegistry.swift │ ├── LLM/ │ │ ├── LLMProvider.swift │ │ ├── LLMTool.swift │ │ └── LLMResponse.swift │ ├── Models/ │ │ ├── ChatMessage.swift │ │ └── ChatRole.swift │ └── Services/ │ └── TokenCounter.swift ├── Features/ │ ├── Chat/ │ │ ├── ChatView.swift │ │ ├── MessageBubbleView.swift │ │ └── InputBarView.swift │ └── SkillStore/ │ ├── SkillListView.swift │ └── SkillDetailView.swift └── Resources/ └── Skills/ └── builtin-skills.jsonCore 层不依赖 SwiftUI这样无论是给 Agent 写单元测试还是以后把同一套逻辑搬到 macOS 或 watchOS都不需要动核心代码。Features 层只通过协议和 Core 打交道比如说 ChatView 不知道 calculator 怎么实现它只看到一个 [ChatMessage] 数组在更新。这种分层在实际开发里帮了大忙。有一次我重构技能注册逻辑改动只涉及 SkillRegistry 和对应测试界面层完全没有动。如果一开始就把逻辑写在 View 里这种重构根本不敢碰。2.2 Skill 协议把能力变成插件的核心所有技能都遵循同一个协议这是整个项目的基石。第一版我用的是 class 继承后来发现根本不需要Swift 的 protocol struct 已经够用。我的 Skill 协议长这样public protocol Skill: Identifiable, Sendable { var id: String { get } var name: String { get } var description: String { get } var parameterSchema: String { get } func execute(context: SkillContext) async throws - SkillResult }每个字段都有它的意义。id 是技能唯一标识注册和调用时都靠它name 和 description 是给 Agent 看的尤其是 description它决定了模型在什么场景下会选中这个技能我后面会专门讲怎么写 description 更有效parameterSchema 是一个 JSON Schema 字符串用来告诉模型这个技能需要哪些参数、参数类型是什么execute 是实际执行的入口。为什么用 JSON Schema 而不是硬编码参数列表因为 LLM 本身擅长解析文本给一个结构化 schema它才能返回符合要求的 JSON 参数。这个设计是 Agent 工具调用的标配也是我参考常见大模型工具协议总结出来的通用做法。SkillContext 是一个轻量结构体包含当前对话 id、用户原始输入、解析后的 arguments以及一个可以访问 LLMProvider 的引用。SkillResult 更简单就是执行结果文本和可选附件。public struct SkillContext: Sendable { public let conversationID: UUID public let userInput: String public let arguments: [String: Any] public let provider: LLMProvider } public struct SkillResult: Sendable { public let output: String public let attachments: [Attachment] }这里有个经验arguments 的取值类型最好统一。模型有时候会把数字返回成字符串所以我在解析后做了一层归一化把“23”转成 23。别小看这一步因为这个坑我至少踩了三次。2.3 Agent 调度核心与上下文管理Agent 是这个项目的大脑我把它设计成一个MainActorclass因为它需要更新 UI 绑定的状态比如当前正在流式输出的文本、最新消息列表。Agent 内部维护一个消息数组和技能注册表核心方法是 reply(to:)。我简化后的实现思路是MainActor final class Agent { private var messages: [ChatMessage] [] private var skills: [String: any Skill] [:] private let provider: LLMProvider private let maxIterations 5 func register(_ skill: any Skill) { skills[skill.id] skill } func reply(to userInput: String) async throws - String { messages.append(.user(userInput)) for _ in 0..maxIterations { let response try await provider.complete( messages: messages, tools: skillSchemas() ) if let toolCall response.toolCall { let text: String do { text try await runSkill(toolCall) } catch { text 技能执行失败\(error.localizedDescription) } messages.append(.toolResult(toolCall.id, text)) } else { let text response.text ?? messages.append(.assistant(text)) return text } } throw AgentError.maxIterationsReached } private func runSkill(_ toolCall: LLMToolCall) async throws - String { guard let skill skills[toolCall.name] else { throw AgentError.skillNotFound(toolCall.name) } let arguments try JSONSerialization.jsonObject( with: Data(toolCall.arguments.utf8) ) as? [String: Any] ?? [:] let context SkillContext( conversationID: conversationID, userInput: latestUserInput, arguments: arguments, provider: provider ) let result try await skill.execute(context: context) return result.output } }这个循环最大的特点是“让模型决定下一步”。模型返回工具调用我就执行工具模型返回正常文本我就把文本拿给用户。maxIterations 用来兜底防止模型陷入“反复调用同一个工具”的死循环。5 次是我试下来的平衡点太少会中断复杂任务太多会浪费 token 和时间。上下文管理上我保留了完整的消息历史包括工具调用的入参和结果。这不是为了界面上都显示而是让模型在后续推理时能“看到”自己之前做过什么。工具结果消息一般不会直接展示给用户但必须在发给模型的消息列表里。2.4 状态管理与数据流界面上所有可见的东西都来自一个 AgentStore我把它设计成 ObservableObject里面持有 Agent 实例和当前显示的消息数组。Agent 一旦产生新消息或工具调用步骤就更新 store 的 published 属性SwiftUI 视图随之刷新。状态流我用一个简单的枚举表示enum ChatPhase: Equatable { case idle case thinking case streaming(text: String) case awaitingTool(toolName: String) }为什么需要等待工具状态因为工具执行期间模型已经给出了一个部分响应用户应该看到“Agent 正在调用计算器”这样的反馈而不是界面毫无反应。这个状态看起来简单但能极大提升使用体验不然工具执行超过两秒用户会以为 App 卡死了。数据流是单向的View 只能调用 store 里的方法store 再去调 AgentAgent 通过回调把新状态推回 store。我不会在 View 里直接改消息数组这是保证复杂状态不失控的关键。3. 核心功能实现与实操过程3.1 工程初始化与基础依赖我先用 Xcode 新建了 iOS App最低版本设到 iOS 16。原因很简单NavigationStack 和 AsyncStream 在 iOS 16 就能用而 Observable 要 iOS 17。我想让这个项目能覆盖更多人的设备所以用 ObservableObject 这套成熟方案。依赖管理直接用 Swift Package Manager不需要第三方库。有人可能会问为什么不引入某个大模型的官方 SDK我的想法是把 LLM 调用抽象成 LLMProvider 协议具体实现可以随时换。这样既能避免被单一服务绑定也方便后面做单元测试时用 mock provider。LLMProvider 协议核心就两个方法一个是非流式 complete返回完整文本另一个是流式 completeStream返回 AsyncStream。具体业务代码里我写了一个基于 URLSession 的实现把请求体拼成主流模型服务都兼容的格式工具调用列表放在 tools 字段里。public protocol LLMProvider: Sendable { func complete( messages: [ChatMessage], tools: [LLMTool] ) async throws - LLMResponse func completeStream( messages: [ChatMessage], tools: [LLMTool] ) async throws - AsyncStreamString }这个抽象让 Agent 完全不知道底层模型 API 长什么样。后面我想换个更便宜的模型服务只需要新增一个 Provider不用动 Agent。3.2 实现第一个 Skill计算器理论讲完实操开始。我拿计算器技能当第一个例子因为它逻辑简单又能完整演示协议怎么落地。完整算数求值我用 NSExpression 实现表达式解析交给系统但安全问题必须注意我只接受白名单字符过滤掉字母和分号。struct CalculatorSkill: Skill { let id calculator let name 计算器 let description 执行四则运算支持 - * / 和括号。当用户需要数学计算或数字运算时使用。 let parameterSchema { type: object, properties: { expression: { type: string, description: 待计算的数学表达式例如 (237)*2 } }, required: [expression] } func execute(context: SkillContext) async throws - SkillResult { guard let expression context.arguments[expression] as? String else { throw SkillError.missingArgument(expression) } let sanitized expression.filter { -*/0123456789(). .contains($0) } let expressionObj NSExpression(format: sanitized) guard let result expressionObj.expressionValue(with: nil, context: nil) as? Double, result.isFinite else { throw SkillError.invalidExpression } return SkillResult(output: String(format: %g, result)) } }description 字段我写得很具体特别强调“当用户需要数学计算时使用”。你可能会觉得这句话是废话但实践下来模型判断工具调用时非常依赖这种语义信号。如果你写“帮助用户”,它可能在用户问天气时也调用计算器因为意图描述太模糊。打安全补丁也要注意NSExpression 虽然好用但直接传用户输入会存在命令注入风险。过滤字符是最粗粒度的防护真正的项目里我还会用表达式解析器做白名单校验而不是靠拦截字符串。3.3 Agent 循环与函数调用解析跑通 Agent 循环的关键在于正确解析工具调用。主流服务返回的结构一般是这样的当模型决定调用工具响应里会包含一个 toolCalls 数组每个元素有 id、function.name、function.arguments。arguments 是一个 JSON 字符串。我踩过的第一个大坑就是用不匹配的模型去解析。有些模型不支持工具调用你传 tools 参数它也会忽略然后直接返回一段文字。所以我在 LLMResponse 里设计了一个toolCall可选值解析失败就当普通文本处理绝不报警。另外流式工具调用有一个很恶心的坑异步流返回的是一个个增量 chunk工具调用的名称和参数可能分散在多个 chunk 里。我的处理方式是先拼完整流再解析 JSON。也就是说流式场景下工具调用阶段不逐字输出而是等工具执行完再一次性显示结果。这个设计让 UI 实现简单很多。Agent 循环里还要处理工具执行失败的情况。我不让错误直接传给用户而是把错误信息包装成一条 tool result 消息返回给模型。例如“技能执行失败除以零”。模型看到这条信息后通常会自动修正参数重新调用或者向用户解释。这种机制比直接崩溃友好得多。3.4 SwiftUI 界面实现与流式更新聊完调度看 UI。聊天界面由三块组成消息列表、输入栏、状态提示条。消息列表用 ScrollViewReader 包裹新消息出现时自动滚动到底部。每条消息用一个 MessageBubbleView 渲染根据 role 区分左对齐还是右对齐。流式更新是这里最容易搞砸的地方。我最开始的实现是每收到一个 chunk 就 append 到字符串然后直接更新 store。这是一个高频小更新看起来没问题但消息一多就开始卡顿。后来我改成节流每 0.05 秒只刷新一次 UI中间来的 chunk 合并到一起。具体实现用 AsyncStream 很顺手final class AgentStore: ObservableObject { Published var messages: [ChatMessage] [] Published var streamingText: String Published var phase: ChatPhase .idle func send(_ text: String) { let message ChatMessage(role: .user, content: text) messages.append(message) phase .thinking Task { do { let reply try await agent.reply(to: text) messages.append(.assistant(reply)) phase .idle } catch { messages.append(.assistant(出错了\(error.localizedDescription))) phase .idle } } } }流式版本的 send 我改用了 agent.streamReply(to:)内部是一个 AsyncStreamstore 在 for await 循环里把文本累积到 streamingText等到流结束后再落成正式消息。这样 View 在流式阶段就显示 streamingText结束后显示 messages.last。Published var streamingText和Published var messages分开是关键因为消息数组每次 append 都会触发完整的列表 diff如果流式期间频繁 append列表性能会很差。流式文本只影响一个 Text 视图开销小得多。3.5 技能注册与内置技能列表技能怎么进到系统里我做了两层。第一层是内置技能清单resources 里放了一个 builtin-skills.json启动时加载文件读取每个技能的 id、name、description、schema并实例化对应的 Skill。第二层是运行时注册用户可以在设置页打开一个 Skill触发 registry.register(skill)。SkillRegistry 内部维护两个字典一个是注册表[String: any Skill]另一个是元信息[String: SkillMeta]。注册表用于实际调用元信息用于列表展示和给 Agent 构造 tools。final class SkillRegistry { private var skills: [String: any Skill] [:] private(set) var meta: [String: SkillMeta] [:] func register(_ skill: any Skill, enabled: Bool true) { skills[skill.id] skill meta[skill.id] SkillMeta(id: skill.id, name: skill.name, description: skill.description, enabled: enabled) } func enabledToolSchemas() - [LLMTool] { meta.filter(\.value.enabled).map { LLMTool(name: $0.key, description: $0.value.description, schema: ...) } } }这个注册表最大的好处是Agent 每次请求只带上启用的技能禁用掉不用的技能可以省 token。例如用户不常用天气那天气技能就不进 tools模型也不会把它当成候选。这个筛选逻辑对 token 成本控制非常有效。4. 踩坑实录与问题排查技巧4.1 流式刷新导致 SwiftUI 卡顿前面提到流式文本节流这里详细说说现象。第一次集成流式输出时我在 for await 里每收到一个 chunk 就更新 Published最后模拟器上所有文本都正常但真机上滚动列表时明显掉帧。为什么因为每次更新都触发整个 body 重新求值列表里的每一行都要重新 diff尤其是长对话场景几百条消息全量 diff 的成本不可忽略。解决办法有三步第一流式期间只更新 streamingText不 append 新消息第二对 streamingText 的赋值做时间节流保证刷新频率不高于每秒 20 次第三给消息列表的 ForEach 加上稳定的 id避免视图因为位置变化被重建。我这里做了一个小工具函数用 Date 判断距上次刷新是否超过 50ms不足则缓存到 pendingText到时间再合并更新。代码不复杂但效果立竿见影真机帧率从明显卡顿变成 60fps 无压力。4.2 Actor 隔离与 Swift 6 并发问题把项目迁移到 Swift 6 语言模式时编译期报了一堆 actor 隔离错误。典型问题在于 Skill 协议里的execute(context:)是异步方法但实现它的结构体可能不是 Sendable 的或者 SkillContext 里的 LLMProvider 被多个任务并发访问。我的经验是给协议加上 Sendable所有属性都用 letSkillContext 里的 arguments 统一转成值类型字典Provider 也声明为 Sendable。另外把技能的注册表隔离到一个专门的无主 actor 里读取注册表时用await registry.skill(for:)。这样既避免了数据竞争也让依赖关系更清晰。有些人会觉得 Swift 6 严格并发太麻烦干脆关掉语言模式。但对这种异步任务特别多的项目严格并发检查能在编译期揪出很多潜在的数据竞争。我建议就忍着改别豁免。4.3 工具调用的参数解析与错误处理工具调用参数解析是另一个高频雷区。模型返回的arguments字符串有各种奇形怪状多了前后空格、用了单引号、数字带引号、缺少必填字段。我一开始直接用JSONDecoder解码到 Codable 模型结果三天两头抛错。后来我改成先JSONSerialization转成[String: Any]再做字段类型归一化。比如 expression 字段如果带引号就尝试去掉多余引号无符号整数被解析成字符串就转成 Int。解析失败时不要直接抛错而是返回一个带错误说明的 SkillResult让模型知道是哪个字段出了问题。我还给每个 Skill 加了一个validate(arguments:)方法在 execute 前调用。这个 validate 可以复用 parameterSchema 做 JSON Schema 校验虽然 iOS 没有内置 JSON Schema 库但我写了个几十行的小验证器只查 required 和 type。这个小成本换来的稳定性非常值。4.4 调试技巧Agent 内部状态可视化Agent 是个黑盒光看聊天界面很难判断是模型选错了工具还是技能执行出了问题。我加了一个调试回调让每个关键步骤都能抛出来final class Agent { var onStep: ((AgentStep) - Void)? } enum AgentStep: CustomStringConvertible { case willSendToModel(messageCount: Int) case modelReturnedToolCall(String) case willExecuteSkill(String) case skillFinished(String, output: String) case modelReturnedText(String) var description: String { ... } }在 DEBUG 模式我把它输出到控制台同时驱动一个 DebugPanel 视图显示当前上下文 token 估算。这样当用户问“算一下 23”我能一眼看到 Agent 是不是先选了计算器又有没有正确传参。调试面板还有个隐藏功能显示每一步的耗时。如果模型请求耗时 3 秒而技能执行只有 0.1 秒那问题显然在网络或模型侧如果技能执行耗时 5 秒就要回头查技能内部逻辑。成本定位不那么准确但至少能分辨问题的取值范围。5. 后续扩展与个人体会5.1 可以继续做的方向这套 Skill 架构目前只做了文本类和数值类技能但协议本身具备扩展空间。比如我想加一个 ImageGenerationSkill让它接收一段 prompt返回一张图片附件。在 SwiftUI 里显示图片附件不是什么难事真正要解决的是附件上传和存储路径设计。另一个方向是技能热更新。现在技能是编译期写死 JSON 注册远程下发技能描述和实现还不够灵活。后续可以做一个沙箱机制让技能包以 zip 形式下载App 内校验后动态加载。这个方向比较复杂但几乎是插件化框架的必修课。还有低成本的改进把意图分类移到端上。现在每次对话前都要请求模型才能判断是否调用工具如果端上先用一个小模型把“数学计算”“备忘记录”这类高频意图分出来就能减少一半的模型调用。这个思路我还在试验但 Skill 协议化以后接入并不难。5.2 几个值得复盘的设计决策整个项目写下来有几个决策如果重新做我会更早坚定。第一个是 Skill 的 description 必须为“模型”写而不是为“人”写。最初我把 description 写成给开发者看的功能说明结果模型经常选错技能后来我老老实实地模仿 Prompt 工程那种风格写清楚触发条件和典型场景准确率立刻上来了。第二个是不要追求一次性支持 20 个技能。一次只给 Agent 暴露 5 个技能和暴露 20 个技能模型的选择准确率和响应速度完全是两个量级。token 预算不是唯一瓶颈候选太多会让模型犹豫。所以我做了一个开关面板让技能默认离线只有用户主动开启才进入候选池。第三个是协议最小化。我不在 Skill 协议里塞多余的方法比如生命周期回调、进度上报都是后来通过组合扩展加的。每加一个协议方法所有现有技能的维护成本都会上升。这也是我把 Skill 设计成纯 struct 而不是类的另一个原因struct 更能克制住“随便加状态”的冲动。5.3 一点真实的建议如果你也想搭一个类似的 SwiftUI Agent 项目我的建议特别简单先写一个能算数学的计算器技能把 Agent 循环完整跑通然后再去想架构和扩展。不要一上来就整很多协议、很多抽象层。抽象只有在第二个真实技能出现时才会变得有价值第一个技能时只会拖慢你。我一开始也是从网上找了不少参考资料但真正把代码跑起来之后才发现工具调用的细节和 UI 的流式刷新才是大头。多花点时间在捕获模型返回的非预期行为上比花时间纠结目录结构值钱得多。这个项目目前还是我的实验田接下来我打算把技能远程下发和端上意图分类两个方向做深一点等有结论了再回来继续分享。