☰
SwiftUI AI Agent开发:技能协议与状态管理实战指南
2026/10/14 8:58:06 网站建设 项目流程

最近在 Apple 生态里做 AI 应用开发的朋友,应该都感受到了一个明显趋势:SwiftUI 不再只是用来做普通的表单、列表、导航界面,而是越来越多地被用在 AI Agent 类产品的交互层。我自己在做一个 SwiftUI-Agent-Skill 项目时,踩了不少坑,也总结了一些值得分享的经验。

这个项目要解决的问题很直接:在 iOS / macOS 上,用 SwiftUI 构建一个 AI Agent 的宿主界面,并且把 Agent 的“技能”(Skill)做成一套可插拔的协议体系。也就是说,Agent 不只停留在“聊天机器人”的层面,它能够调用工具、执行技能、返回结构化结果,同时这些能力全部通过 SwiftUI 的声明式语法组织起来,让界面与技能逻辑松耦合。如果你正在做一个带 AI 能力的 SwiftUI 应用,或者想给现有 App 加上“会调用工具”的智能助手,这篇内容应该能帮你省下不少试错时间。

1. 项目整体设计与思路拆解

1.1 为什么选择 SwiftUI 做 Agent 界面

选择 SwiftUI 作为 Agent 的 UI 载体,最大原因是它和 Swift 语言的深度绑定,以及声明式状态驱动模型天生适合聊天这类高频变化的界面。想想看,一个 Agent 交互页面要考虑的因素:消息流不断追加、流式输出逐字更新、技能调用的过程状态(开始、执行中、完成、失败)随时切换。这在 UIKit 时代需要写大量 delegate 和数据源方法,而在 SwiftUI 里只需要维护好一个消息数组和几个状态对象,界面会自动跟随状态刷新。

而 Agent 端,需要的是“技能”这一层抽象。什么叫技能?简单说,就是 Agent 在执行用户请求时,可以动态选择并调用的一段能力代码。比如查天气、算数学、读写备忘录、发送通知,甚至调用某个内部 API。将这些能力抽象成统一的 Skill 协议,Agent 就能像搭积木一样,在运行时决定调用哪个技能、传什么参数、拿回什么结果、再基于结果生成对用户的回答。这个组合的价值在于:SwiftUI 负责“怎么展示”,技能协议负责“能做什么”,两者通过状态管理衔接,边界非常清晰。

1.2 架构分层思路

当时我把整个项目拆成四层,每一层的职责尽量单一:

界面层:SwiftUI 视图,包括消息列表、输入框、技能状态条、流式输出气泡。这层只做展示和动画,不处理业务逻辑。

状态管理层:一个 ObservableObject 类型的会话模型,负责持有消息历史、当前技能执行状态、流式输出缓存。所有界面需要展示的数据都从这里取。

Agent 核心层:持有大模型服务的封装、技能注册表、执行调度器。这一层不关心 UI 怎么画,只负责“接收输入 -> 决定调用哪个技能 -> 调用模型生成回复”。

技能层:各个具体的 Skill 实现,统一遵守 SkillProtocol,完成真实的业务操作。

这个分层的核心好处是:后面想加一个新技能,只需要写一个符合协议的 struct/class,注册进技能注册表就行,UI 和 Agent 核心层完全不用动。反过来,想把聊天界面从 SwiftUI 换成别的框架,只要保住状态管理层和 Agent 核心层的接口,改动范围也有限。我在实际开发中多次感受到这个分层带来的便利——尤其是项目中期频繁加技能的时候,每加一个技能只需要关注技能本身,不需要碰其他代码。

1.3 技能协议的核心设计考量

设计 SkillProtocol 的时候,我考虑过好几种写法,最后敲定的协议包含三个关键部分:

  1. 技能描述:字符串,包含技能的用途说明,以及在什么场景下触发。这部分会拼进给大模型的系统提示词里,让模型知道“你有哪些技能可用、每个技能是干什么的”。

  2. 参数声明:定义技能需要哪些入参。这里我没有用 Swift 的 Codable 硬编码,而是定义了一个参数描述结构体数组,包含参数名、类型、是否必填、描述。原因很简单——大模型返回的工具调用参数是一个 JSON 字典,需要把 JSON 映射到 Swift 结构,如果参数声明是结构化的,映射逻辑就完全通用。

  3. 执行函数:接收一个 JSON 字典作为参数,返回一个异步结果。具体实现里再做真正的业务操作。

这套设计思路,本质上是为了解决 Agent 场景最核心的问题:模型决定调用什么技能、传什么参数,这个决定是在运行时才发生的,所以我们必须在协议层就支持“动态发现技能”和“动态传参”。如果用强类型的方式预先把所有技能调用写死,Agent 就失去了灵活性。

2. 核心细节解析与实操要点

2.1 Agent 技能协议的具体实现

我先定义一个基础协议,代码如下:

protocol AgentSkill { var name: String { get } var description: String { get } var parameters: [SkillParameter] { get } func execute(with arguments: [String: Any]) async throws -> SkillResult }
struct SkillParameter { let name: String let type: SkillParameterType let isRequired: Bool let description: String } enum SkillParameterType: String { case string, integer, number, boolean }

这里有个特别值得注意的点:execute函数接收的参数是[String: Any],而不是一个具体的 Decodable 结构。这看起来有点丑,但从 Agent 工具调用的实际流程看,这是最稳的一种做法。大模型返回的参数本质上就是 JSON 字典,你要么自己解析到具体类型,要么直接把字典传给技能。选择后者,把解析职责下放到每个技能内部,反而让协议保持简单。

同时,为了让 Agent 在决定调用技能时能够自动校验参数,我还给参数声明加了一个校验工具方法:

extension AgentSkill { func validate(_ arguments: [String: Any]) throws { for param in parameters where param.isRequired { guard arguments[param.name] != nil else { throw SkillError.missingParameter(param.name) } } } }

这个校验方法虽然很简单,但能把“参数缺失”这类问题在触发技能前拦截掉,避免技能执行到一半才发现参数不对。实际调试中,我发现这类校验能节省大量排查时间。

2.2 技能注册表与动态发现

Agent 需要一个地方保存所有注册的技能,并且能通过“名字”查到技能实例。这里我实现了一个 SkillRegistry:

final class SkillRegistry { private var skills: [String: any AgentSkill] = [:] func register(_ skill: any AgentSkill) { skills[skill.name] = skill } func skill(named name: String) -> (any AgentSkill)? { skills[name] } var allSkillDescriptions: String { skills.values.map { skill in """ 技能名:\(skill.name) 说明:\(skill.description) 参数:\(skill.parameters.map { "\($0.name)(\($0.type.rawValue))\($0.isRequired ? "必填" : "可选"): \($0.description)" }.joined(separator: "; ")) """ }.joined(separator: "\n") } }

allSkillDescriptions这个方法非常重要,它负责生成给大模型看的技能清单。实际测试下来,技能描述的格式和详细程度直接影响模型能不能正确触发技能。描述写得太简略,模型会忽略;参数描述写得不清楚,模型会传错参数类型。我在这个方法的输出格式上迭代了很多次,最终的格式包含了技能名、说明、参数名、类型、是否必填和参数描述,模型对这些结构化的信息理解得最准确。

2.3 会话状态管理和流式输出

在 SwiftUI 里做聊天界面,最核心的状态模型是这样的:

final class ChatSessionModel: ObservableObject { @Published var messages: [ChatMessage] = [] @Published var activeSkill: ActiveSkillState? @Published var isStreaming: Bool = false enum ActiveSkillState { case running(name: String) case success(name: String, summary: String) case failure(name: String, message: String) } }

ChatMessage可以用一个枚举来表示消息来源和内容类型:

struct ChatMessage: Identifiable { let id = UUID() let role: Role var content: String var associatedSkillName: String? enum Role { case user, assistant, system } }

界面层只需要订阅messages的变化,SwiftUI 会自动刷新列表。流式输出的时候,可以不断把新的文本片段追加到最后一条 assistant 消息的content上。注意一点:当消息数组频繁变化时,列表性能是个隐患,后面有专门的优化技巧。

2.4 界面层与技能状态的联动

我的界面设计是:消息列表占主要区域,输入框在底部,顶部有一个细条用于展示当前技能执行状态。技能状态条根据activeSkill的值切换显示内容:

  • 正在执行时,显示一个加载指示器和技能名。
  • 成功时,显示一个对勾图标和简短总结,几秒后自动消失。
  • 失败时,显示一个感叹号和错误信息,保留到用户下次输入。

这里用 SwiftUI 的animation修饰符配合状态切换,做得非常平滑。关键代码如下:

struct SkillStatusBar: View { @ObservedObject var model: ChatSessionModel var body: some View { Group { if let state = model.activeSkill { switch state { case .running(let name): Label("正在执行技能:\(name)", systemImage: "arrow.triangle.2.circlepath") case .success(let name, let summary): Label("\(name)完成:\(summary)", systemImage: "checkmark.circle.fill") case .failure(let name, let message): Label("\(name)失败:\(message)", systemImage: "exclamationmark.triangle.fill") } } } .font(.footnote) .padding(.horizontal) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray.opacity(0.1)) .animation(.easeInOut(duration: 0.2), value: model.activeSkill) } }

让技能的执行状态和消息列表并列展示,用户体验会好很多。用户能清楚地看到“模型正在调用某个技能”,而不是干等。尤其是技能执行耗时超过一两秒的场景,如果没有这个状态条,用户会以为程序卡死了。有了状态条,整个交互过程就变得透明、可信。

3. 实操过程与核心环节实现

3.1 大模型服务层的封装

Agent 的核心逻辑里,我封装了一个 LLMService,用来统一管理与大模型的交互。这个服务层要支持两件事:普通对话和流式输出。底层用的协议是聊天补全协议,每次请求会带上三部分内容:系统提示词(包含技能清单)、历史消息、以及用户的当前输入。

代码结构上,服务层对外暴露这样的接口:

protocol LLMServiceProtocol { func streamChat(messages: [ChatMessage], skills: String) -> AsyncThrowingStream<String, Error> }

内部实现的时候,需要处理的关键点是:将[ChatMessage]转换成协议要求的消息格式,并把技能描述拼进系统消息。实际请求的时候,为了让模型更稳定地输出工具调用,我在系统提示词里明确写了:

当需要使用工具时,先输出一个结构化标记,包含技能名和参数 JSON。例如 [SKILL_CALL]{"name": "calculator", "arguments": {"expression": "1+1"}}[/SKILL_CALL],随后再继续输出自然语言回复。

当然,不同的模型对工具调用的格式要求不同。有的模型原生支持工具(工具调用模式),有的需要靠提示词驱动。我在项目里做了一层适配,如果检测到模型接口支持工具参数,就走原生工具调用;否则走标记解析方式。这样不管用哪家大模型,都能兜底。

3.2 技能执行调度器的实现

调度器的作用,是解析模型输出中的技能调用请求,找到对应技能并执行。我写了一个 AgentExecutor:

final class AgentExecutor { let registry: SkillRegistry let modelService: LLMServiceProtocol func run(userInput: String, in session: ChatSessionModel) async { // 1. 追加用户消息 session.messages.append(ChatMessage(role: .user, content: userInput)) // 2. 请求模型流式回复 let skillDescriptions = registry.allSkillDescriptions var assistantMessage = ChatMessage(role: .assistant, content: "") session.messages.append(assistantMessage) do { let stream = modelService.streamChat(messages: session.messages, skills: skillDescriptions) var buffer = "" for try await text in stream { buffer += text // 3. 检查是否包含技能调用标记 if let (skillName, arguments) = parseSkillCall(from: buffer) { session.activeSkill = .running(name: skillName) await executeSkill(named: skillName, arguments: arguments, session: session) buffer = removeSkillCall(from: buffer) } assistantMessage.content = buffer session.messages[session.messages.count - 1] = assistantMessage } } catch { assistantMessage.content = "请求失败:\(error.localizedDescription)" session.messages[session.messages.count - 1] = assistantMessage } session.isStreaming = false } }

这里有个重要细节:在流式过程中,随着内容不断到来,assistant 消息的 content 会被频繁更新。我这里直接用session.messages[session.messages.count - 1]去更新数组里的最后一条消息。这种写法在 SwiftUI 里没有问题,因为@Published会触发视图刷新。但如果量特别大,建议把 content 更新放到主线程异步队列里做节流,否则界面滚动会非常费劲。

3.3 技能调用标记的解析策略

模型流式返回的内容是逐步到达的,所以不能等到整个回复结束再去判断有没有技能调用。我的做法是对输出文本做流式扫描:维护一个缓冲区,尝试从缓冲区里解析出完整的技能调用标记。解析逻辑用正则:

func parseSkillCall(from text: String) -> (String, [String: Any])? { let pattern = #"\[SKILL_CALL\]\{"name":\s*"([^"]+)",\s*"arguments":\s*(\{.*?\})\}[/SKILL_CALL]"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]), let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)), let nameRange = Range(match.range(at: 1), in: text), let argsRange = Range(match.range(at: 2), in: text) else { return nil } let name = String(text[nameRange]) guard let argsData = String(text[argsRange]).data(using: .utf8), let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] else { return nil } return (name, args) }

注意,这个正则要求标记是完整的才能匹配成功。如果模型流式输出到一半,标记不完整,firstMatch会返回空,程序可以继续等待后面的文本。等全部拼齐了,再触发技能执行,顺序刚刚好。

3.4 一个具体的技能实现示例

实践的落点我选了“备忘录写入”。实现一个MemoSkill:

struct MemoSkill: AgentSkill { var name: String { "write_memo" } var description: String { "将用户输入的内容保存到备忘录,适合记录待办、想法、提醒事项。" } var parameters: [SkillParameter] { [ SkillParameter(name: "content", type: .string, isRequired: true, description: "需要保存的备忘录正文,必须是完整的文本内容。"), SkillParameter(name: "title", type: .string, isRequired: false, description: "备忘录标题。如果用户未提及,使用当前日期作为标题。") ] } func execute(with arguments: [String: Any]) async throws -> SkillResult { guard let content = arguments["content"] as? String else { throw SkillError.invalidArgument("content") } let title = arguments["title"] as? String ?? "备忘录-\(Date.now.formatted(date: .abbreviated, time: .shortened))" // 这里执行真正的写入逻辑 try await MemoStore.shared.save(title: title, content: content) return SkillResult(summary: "已保存备忘录《\(title)》", detail: content) } }

这个例子里有两点经验:第一,Description 一定要写清楚触发场景,模型才能准确判断“用户这句话要不要调用这个技能”;第二,参数校验别省,尽量在 Swift 层就拦截掉无效参数,不要让技能执行到一半才发现参数缺失。备忘录这类技能很直观,新手也能从它身上理解“技能”这个概念是怎么落到具体代码里的。

3.5 SwiftUI 界面的组装

最后一步是把前面这些模块组装进 SwiftUI 视图。我的主视图大致是这样的:

struct ChatView: View { @StateObject var session: ChatSessionModel let agent: AgentExecutor var body: some View { VStack(spacing: 0) { SkillStatusBar(model: session) ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(session.messages) { message in MessageBubble(message: message) .id(message.id) } } .padding() } .onChange(of: session.messages.count) { _ in if let last = session.messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } InputBar { text in Task { await agent.run(userInput: text, in: session) } } } } }

用ScrollViewReader配合onChange滚动到最新消息,是聊天界面最常用的手段。另外,消息气泡要用LazyVStack而不是List,这样长对话下性能更稳,也方便自定义背景和圆角。后面我会继续讲怎么优化消息列表性能。

4. 性能优化与状态更新策略

4.1 消息列表性能优化

聊天界面在对话轮数一多以后,性能问题会非常明显。SwiftUI 的 List 在消息数量到几百条时,滑动帧率就会开始波动。我的实践方案是:

  1. 使用 LazyVStack 替代 List:LazyVStack 只实例化可见范围内的视图,内存占用低,配合自定义气泡样式也很方便。
  2. 保持消息结构轻量:ChatMessage 不要持有大体积附件,附件信息单独存字典。
  3. 滚动联动节流:每次消息追加都去 scrollTo,如果数据变化过快需要加个节流,避免 scroll 动画排队。

一个需要注意的点:ScrollViewReader的 scrollTo 在 iOS 上偶尔会不生效,常见原因是因为目标视图在onChange触发的同一 frame 里还没渲染完。解决办法是包一层DispatchQueue.main.async,或者在动画里加上anchor参数。

4.2 技能状态自动消失的定时器方案

技能执行成功以后,状态条一直挂着会遮挡界面。我做了一个自动消失的机制:进入 success 状态时开启一个 3 秒的 Task,3 秒后把 activeSkill 置空。但要小心延迟释放的问题,需要用Task的取消机制,防止视图已经销毁了还去更新状态:

private var dismissTask: Task<Void, Never>? private func scheduleDismissal() { dismissTask?.cancel() dismissTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 3_000_000_000) guard !Task.isCancelled else { return } await MainActor.run { self?.model.activeSkill = nil } } }

4.3 并发控制:防止重复提交

用户在模型回复过程中重复发送消息,会造成请求并发混乱。我在 AgentExecutor 里加了一个简单的互斥:用一个isExecuting标志位,执行期间直接把新的用户输入拦截掉,或者加入待发队列。考虑到 MVP 阶段,我这里选择直接拦截并给出“上一轮正在处理中”的提示。

还有一种情况要特别处理:技能执行本身可能比较慢(比如请求外部 API),如果此时模型还在生成后续回复,两条流会穿插。我最终选择的策略是:技能调用一旦出现,就暂停模型流式输出的消费,等技能执行完,再把技能结果作为一条系统消息带回模型上下文,让模型基于技能结果继续生成最终回答。流程串行化以后,界面状态和上下文都清晰很多。

5. 常见问题与排查技巧实录

5.1 模型就是不调用技能怎么办

这是我在实际项目中最常遇到的问题。排查思路按优先级来:

  1. 检查技能描述是否足够清晰。描述里最好包含具体的触发条件和例子,比如“当用户说‘记一下XXX’、‘提醒我XXX’时使用此技能”。模型对含糊描述的触发率明显偏低。
  2. 检查系统提示词的引导。建议在系统提示词中明确说明“你可以使用以下技能”,并逐个列出技能名和说明。
  3. 有些模型对 JSON 格式比较敏感,技能调用标记的格式要尽量贴近模型的惯用输出。如果模型喜欢输出 Markdown 表格,就别让它输出严格 JSON。

我试过最有效的一个技巧是:在系统提示词里给一个少样本示例,展示用户输入和对应技能调用的完整配对。加了这一手,模型触发技能的准确率提升非常明显。

5.2 参数解析失败或参数类型错误

技能参数解析失败是最常见的运行时错误。我遇到过的场景有:

  • 模型返回的arguments是字符串而不是对象,因为某些模型会把 JSON 字符串再包一层。
  • JSON 内部包含转义引号,导致JSONSerialization解析失败。
  • 参数名和声明不一致,比如声明了content,模型传的是memo_content。

针对这几个问题,我在解析层做了柔性处理:如果arguments是字符串,就先尝试把字符串解析成 JSON;参数名匹配时做一次模糊匹配(忽略大小写和部分后缀差异)。这些处理逻辑虽然不完美,但对容错帮助很大。

5.3 内存占用持续上涨

SwiftUI 聊天界面内存持续增长,多半是消息模型或者技能执行结果积压导致的。我的排查步骤是:

  1. 用 Instruments 的 Allocations 工具看增长热点。
  2. 重点检查有没有循环引用:SessionModel 持有 AgentExecutor,AgentExecutor 是否持有 SessionModel?我在 AgentExecutor 里用的是弱引用,避免循环。
  3. 消息气泡里加载图片时,确保图片缓存策略正确。最稳妥的做法是异步加载后写磁盘缓存,别把原图数据全部放内存。

5.4 流式输出时界面卡顿

流式输出每来一个 token 就更新一次视图,如果文本很长,更新频率会远超屏幕刷新率。我最终的方案是对 UI 更新做节流:只在累计文本超过一定长度,或者距离上次更新超过 0.1 秒时才刷新视图。这样做之后,眼睛几乎感觉不到变化,但 CPU 占用明显下降。

5.5 常见问题速查表

问题现象可能原因解决方案
模型不触发技能调用技能描述太模糊补充触发场景描述和示例
参数解析失败arguments 是字符串加一层字符串到对象的转换
界面上出现明显的卡顿流式更新过于频繁对 UI 更新做时间/长度节流
消息列表滚动失灵scrollTo 触发时机过早用 DispatchQueue.main.async 包裹
内存持续增长循环引用或大对象积压检查引用循环,做缓存策略
技能状态不消失定时器任务未取消用可取消的 Task 做延迟置空

6. 我想分享的三个实操心得

第一,技能系统的接口设计,决定了整个 Agent 项目能走多远。如果你一开始就把技能参数写死成某个具体类型,后面每加一个技能都要改 Agent 核心层,是非常痛苦的事情。用统一的[String: Any]字典做参数传递,配合结构化的参数描述,虽然看起来不够“类型安全”,但对 Agent 这种动态场景来说,灵活性和通用性强太多。

第二,模型输出的格式适配,一定不能假设它永远符合标准。我在实际测试中见过模型把技能调用标记放在 Markdown 代码块里、把 arguments 写成数组、甚至把参数名拼错。所以解析层必须要做容错,要有一层“脏数据清洗”。这项工作的复杂度常常被低估,值得专门留出时间来完善。

第三,SwiftUI 做 Agent 界面最大的舒适区在状态驱动。消息流、技能状态、输入框状态,统统用@Published驱动界面,开发体验远好于手写刷新逻辑。但性能问题永远要提前考虑,尤其流式输出场景下的 UI 更新节流,建议一开始就设计进去,而不是等出了问题再补。

最后分享一个小技巧:技能执行结果的展示,除了状态条以外,还可以在对应的 assistant 消息下面加一个小的结果卡片。比如备忘录写入成功,就在气泡底部显示一个圆形图标加上一部分摘要。这样用户能直观看到“Agent 做了什么事”,比单纯的语言描述可靠得多。这个卡片我用一个AssociatedView容器实现,根据消息里的associatedSkillName去查找对应的技能结果视图,整体不复杂,但体验提升特别明显。

如果你也在做 SwiftUI 和 AI Agent 结合的项目,希望这篇文章能给你一些参考。尤其技能协议设计那一块,多花点时间琢磨,收益最大。

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

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

立即咨询