☰
SwiftUI + AI Agent + Skill:构建iOS智能工作台的实践指南
2026/10/14 8:29:56 网站建设 项目流程

开工之前先说明一下,这个 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 执行,再把执行结果反馈给模型,最终生成一段自然的回答。

举个例子,你在输入框里敲“帮我算一下 (23+7)*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.json

Core 层不依赖 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 再去调 Agent,Agent 通过回调把新状态推回 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 -> AsyncStream<String> }

这个抽象让 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": "待计算的数学表达式,例如 (23+7)*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:),内部是一个 AsyncStream,store 在 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,所有属性都用 let,SkillContext 里的 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 估算。这样当用户问“算一下 2+3”,我能一眼看到 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 的流式刷新才是大头。多花点时间在捕获模型返回的非预期行为上,比花时间纠结目录结构值钱得多。这个项目目前还是我的实验田,接下来我打算把技能远程下发和端上意图分类两个方向做深一点,等有结论了再回来继续分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询