最近在做一个 SwiftUI 项目时,需要在 App 内接入大语言模型(LLM)能力,比如文本润色、信息总结、智能回复等。一开始的代码非常混乱:网络请求写在 View 里,提示词拼在 UI 逻辑中,模型返回的 JSON 解析散落在各个文件里。功能虽然能跑,但每加一个“技能”就要动一遍主界面代码,维护成本越来越高。后来参考了 twostraws 的 SwiftUI 项目思路,把 Agent(智能体)与 Skill(技能)两个概念引入现有工程,代码立刻清晰了很多。这套结构对新手构建 AI 类 App 很有参考价值,因此本文围绕“SwiftUI + Agent + Skill”的架构思路做一次完整拆解,包含从协议设计到可运行示例的全过程。
1. 背景与核心概念:什么是 SwiftUI Agent Skill
1.1 SwiftUI 开发者集成 AI 时的常见混乱
SwiftUI 是苹果官方推出的声明式 UI 框架,适合快速搭建跨 Apple 平台的界面。它擅长管理视图状态,但并没有规定“调用大模型接口”这件事应该放在哪一层。
很多开发者一开始会这样写:在视图的 Button 点击事件里直接发起网络请求,拿到结果后再更新一个 @State 变量。浅看似乎没问题,但只要功能稍微变多,就马上露馅:
- 不同页面的请求逻辑大量重复,粘贴复制到处都是。
- 提示词(Prompt)与 SwiftUI 代码耦合,产品同学调整话术,开发要改 Swift 文件。
- 新增能力时,要反复修改 UI 层代码,容易引入回归。
- 无法做单元测试,因为业务逻辑淹没在视图中。
此时如果引入 Agent 与 Skill 的抽象思维,就能把“AI 能做什么”和“界面如何呈现”彻底分开。界面只负责收集用户输入并展示结果,Agent 负责理解任务,而 Skill 则代表一项具体的可执行能力,例如“总结一段文字”“生成三条商品标题”“翻译并润色”。
1.2 智能体与技能的核心思想
用一个生活化的比喻来解释两个概念。
Agent(智能体)就像一位“项目经理”。它自己不直接动手做具体的事,而是接收你下达的任务,分析任务属于哪一类,再分派给对应的“执行者”。这位“执行者”就是 Skill(技能)。
- 如果任务属于“总结文本”,Agent 会把任务交给
SummarizeSkill。 - 如果任务属于“生成广告文案”,Agent 会把任务交给
CopywritingSkill。 - 如果任务无法归类,Agent 可以返回提示“当前不支持这类任务”。
在 SwiftUI 项目中,我们可以用协议(Protocol)来定义这两个角色。协议只规定“能做什么”,不关心“具体怎么做”。这样每个技能都可以独立实现、独立测试、独立替换。
专业角度来看,这属于一种轻量的“策略模式”加“职责链模式”的组合:Agent 持有若干 Skill,根据任务内容选择对应策略;如果今后需要支持更复杂的任务理解,还可以让 Agent 内部先调用一次 LLM 做意图识别,再分发到具体 Skill。本文先展示最简单的规则匹配版,复杂版思路放在最后一节。
1.3 为什么需要掌握这种架构
当前 SwiftUI 与 AI 结合的主要路径有两类:一是直接在原生 App 里调用云端 LLM API;二是使用 Apple 提供的 Core ML 跑本地小模型。无论哪一类,都会遇到“输入数据准备、Prompt 构建、请求发送、输出解析、UI 状态更新”这一连串步骤。
如果没有 Agent/Skill 抽象,这些步骤很容易写成 Spaghetti Code(意大利面式代码)。而一旦建立分层,好处非常明显:
- UI 层只关心
ViewModel状态,不直接拼接参数。 - 技能模块可以独立演进,例如先把总结功能做得足够好,再接翻译功能。
- 网络请求、API Key、模型参数等敏感配置可以集中在服务层,不暴露给视图。
- 新增能力时新增一个
Skill实现即可,旧代码几乎不用改动。
这也是很多成熟 AI 客户端 App 会采用的架构风格。对 SwiftUI 初学者来说,提前了解这种模式,能避免在项目快速膨胀后痛苦重构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与版本说明
2.1 开发环境
本文示例基于常见的 Apple 开发环境编写,具体版本需要根据你本机环境调整,这里以大多数开发者当前使用的配置为例:
- 操作系统:macOS 14 或更新版本
- IDE:Xcode 15 或更新版本
- 语言:Swift 5.9 及以上(涉及 async/await 并发特性)
- 目标平台:iOS 17.0 及以上,SwiftUI 生命周期
- 模拟器或真机:运行项目时需要一台安装了 iOS 17+ 模拟器,或开发者账号支持的真机
如果你的 Xcode 版本较旧,例如停留在 Xcode 14,那么部分异步代码写法可能需要替换为传统的 DispatchQueue.main.async,但整体架构思路不变。建议升级到新版本,以便使用 Swift 原生并发特性。
2.2 创建基础项目
打开 Xcode 后,选择 File -> New -> Project,在模板中选择 App,填写产品名称为 SwiftUIAgentSkillDemo,Interface 选择 SwiftUI,语言选择 Swift。
创建完成后,项目的默认文件结构如下:
code复制SwiftUIAgentSkillDemo/
├── SwiftUIAgentSkillDemoApp.swift
├── ContentView.swift
└── Assets.xcassets
后面我们会在此基础上新建以下文件:
code复制Models/
├── ChatMessage.swift
Protocols/
├── Agent.swift
├── Skill.swift
Services/
├── LLMClient.swift
├── MockLLMClient.swift
Agents/
├── GeneralPurposeAgent.swift
Skills/
├── SummarizeSkill.swift
├── JokeSkill.swift
ViewModels/
├── ChatViewModel.swift
没有真实网络条件时,MockLLMClient 可以模拟返回数据,帮助我们把 UI 层流程先跑通,等拿到正式接口再无缝切换。
2.3 关于 API Key 的提醒
如果后续接入真实大模型服务,需要注册相应平台账号并获取密钥。请务必注意:
- API Key 属于高敏感信息,不要硬编码在 Swift 文件中,尤其不要提交到 Git 仓库。
- 推荐放入
xcconfig配置文件,并添加到.gitignore。 - 在生产 App 中,更安全的做法是让客户端请求你自己的后端服务,由后端统一保管密钥、控制频次、记录日志。
本文示例为了便于复现,先以模拟客户端为主,真实网络客户端只提供核心代码片段,方便按实际情况替换。
3. 核心架构设计:用协议抽象 Agent 与 Skill
3.1 定义 Skill 协议
Skill 是整个架构的最小执行单元。它描述了一个 AI 能力模块的外壳,不包含具体实现。
swift复制// 文件路径:Protocols/Skill.swift
import Foundation
protocol Skill {
/// 技能名称,例如"文本总结"
var name: String { get }
/// 技能描述,Agent 可以通过描述判断该技能适合什么任务
var description: String { get }
/// 执行技能的方法
/// - Parameter input: 用户原始输入
/// - Returns: 技能处理后的输出文本
func execute(input: String) async throws -> String
}
这里有几个设计细节值得注意。
第一,execute 是异步方法,因为绝大多数技能都需要网络请求或本地模型推理。调用方通过 await 等待结果,不会阻塞 UI 线程。这一点对 SwiftUI 尤其重要,因为如果在主线程上发起同步网络请求,界面会立刻卡死。
第二,name 和 description 不只是装饰属性。在更复杂的 Agent 实现中,Agent 会把所有技能的描述拼进一个 Prompt,让 LLM 判断应该调用哪个技能,这就像给机器人提供了一份“工具清单”。即使是规则匹配版 Agent,description 也有助于开发者调试。
第三,协议只要求“输入字符串、输出字符串”。对于大多数 AI 任务来说,这种最简接口已经足够。如果未来技能需要返回结构化 JSON,可以再定义更具体的协议,而不影响现有调用链。
3.2 定义 Agent 协议
Agent 位于 Skill 上层,负责接收任务、选择技能、返回结果。
swift复制// 文件路径:Protocols/Agent.swift
import Foundation
protocol Agent {
var name: String { get }
var skills: [Skill] { get }
func perform(task: String) async throws -> String
}
这个协议同样非常简洁。skills 数组表示智能体拥有的全部能力集合;perform 则是总入口。实际项目中,perform 内部会经历以下逻辑:
- 接收用户自然语言输入。
- 判断任务应该交给哪个技能。
- 调用对应技能的
execute方法。 - 把技能结果返回给 ViewModel 层。
在这里,Agent 不关心技能是否调用 LLM、使用什么 Prompt、解析什么 JSON。它只关心“任务是否被正确分发”。这样设计之后,我们完全可以把 GeneralPurposeAgent 替换为带意图识别能力的 LLMDrivenAgent,而 UI 层不需要变化。
3.3 LLM 客户端抽象
为了不让技能直接依赖具体的网络实现,我们再抽象一层 LLMClient。
swift复制// 文件路径:Services/LLMClient.swift
import Foundation
/// 大语言模型客户端协议
protocol LLMClient {
/// 发送 Prompt 并返回模型生成的文本
func sendPrompt(_ prompt: String) async throws -> String
}
/// 模拟客户端:用于开发和 UI 调试
struct MockLLMClient: LLMClient {
func sendPrompt(_ prompt: String) async throws -> String {
try await Task.sleep(nanoseconds: 500_000_000)
if prompt.contains("笑话") {
return "为什么程序员总是分不清万圣节和圣诞节?因为 Oct 31 == Dec 25。"
}
if prompt.contains("总结") {
return "这是一段经过模拟总结后的短文本。真实项目中,这里会返回 LLM 生成的精简内容。"
}
return "我是模拟客户端,当前收到的任务是:\(prompt)"
}
}
真实客户端通常需要处理以下逻辑:
- 请求地址和鉴权 Header。
- 将 Prompt 包装成模型所需的消息格式。
- 解析返回 JSON,提取
choices字段中的文本。 - 处理超时、限流、网络错误。
由于不同大模型平台的接口格式不同,本文不写死某一家的具体 URL 和字段名。你在接入时只按对应平台的文档替换即可。设计上的好处是:技能层只依赖 LLMClient 协议,底层换服务商时不会影响上层结构。
4. 完整实战:构建一个 SwiftUI 智能助手
这一节我们从零构建一个可运行的 SwiftUI 聊天式助手。界面包含一个消息列表、一个文本输入框和一个发送按钮。用户输入一句话,Agent 自动选择对应技能并返回结果。
为了能离线跑通,我们使用 MockLLMClient 作为底层模型客户端。每个技能内部通过这个客户端生成内容。如果你已经拿到真实的 API,只需要把 MockLLMClient 替换成你自己的 RemoteLLMClient。
4.1 定义聊天消息模型
首先定义一个简单的消息模型。
swift复制// 文件路径:Models/ChatMessage.swift
import Foundation
struct ChatMessage: Identifiable, Equatable {
enum Role {
case user
case assistant
}
let id = UUID()
let role: Role
let content: String
static func user(_ text: String) -> ChatMessage {
ChatMessage(role: .user, content: text)
}
static func assistant(_ text: String) -> ChatMessage {
ChatMessage(role: .assistant, content: text)
}
}
这个模型在界面上用来区分气泡左右位置:用户消息靠右,助手消息靠左。Identifiable 用于 List 渲染,Equatable 用于状态比较和动画更新。
4.2 实现两个技能
第一个技能是“文本总结”,第二个技能是“冷笑话”。在真实项目中,这两个技能内部都会调用 LLM,但作为示例,我们统一走 MockLLMClient。
swift复制// 文件路径:Skills/SummarizeSkill.swift
import Foundation
struct SummarizeSkill: Skill {
let name = "文本总结"
let description = "适合把长文本压缩为简短摘要"
private let client: LLMClient
init(client: LLMClient = MockLLMClient()) {
self.client = client
}
func execute(input: String) async throws -> String {
let prompt = """
请将下面这段文本总结为三句话以内,要求保留核心信息:
\(input)
"""
return try await client.sendPrompt(prompt)
}
}
swift复制// 文件路径:Skills/JokeSkill.swift
import Foundation
struct JokeSkill: Skill {
let name = "冷笑话"
let description = "适合让助手讲一个程序员冷笑话"
private let client: LLMClient
init(client: LLMClient = MockLLMClient()) {
self.client = client
}
func execute(input: String) async throws -> String {
let prompt = "请讲一个简短的程序员冷笑话。用户原话:\(input)"
return try await client.sendPrompt(prompt)
}
}
每个技能都有自己的 client 依赖,通过初始化方法注入。这样做的好处是单元测试时可以传入自定义 Mock,完全不需要发起真实网络请求。默认参数 MockLLMClient() 保证整体代码开箱即用。
4.3 实现通用 Agent
接下来创建 GeneralPurposeAgent。它持有所有技能,并按照关键词规则选择技能。
swift复制// 文件路径:Agents/GeneralPurposeAgent.swift
import Foundation
struct GeneralPurposeAgent: Agent {
let name = "通用助手"
let skills: [Skill]
init(skills: [Skill]) {
self.skills = skills
}
func perform(task: String) async throws -> String {
let lowercasedTask = task.lowercased()
if lowercasedTask.contains("总结") || lowercasedTask.contains("摘要") {
guard let skill = skills.first(where: { $0.name == "文本总结" }) else {
return "抱歉,文本总结技能不可用。"
}
return try await skill.execute(input: task)
}
if lowercasedTask.contains("笑话") {
guard let skill = skills.first(where: { $0.name == "冷笑话" }) else {
return "抱歉,冷笑话技能不可用。"
}
return try await skill.execute(input: task)
}
// 兜底逻辑
return "我暂时只能处理“总结”和“讲笑话”两类任务。"
}
}
在规则匹配版中,我们把“选择技能”的逻辑固定为关键词判断。它的优点是简单、可控、不消耗额外 Token;缺点是用户表达稍有变化就可能找不到对应技能。如果你的项目对意图识别要求高,可以设计一个 LLMDrivenAgent,它会先把所有技能描述发送给 LLM,由 LLM 返回技能编号,再执行对应技能。这样 Agent 就真正具备“理解”能力了。
兜底逻辑非常重要。实际 App 中,用户经常会输入超出预设范围的内容,Agent 应当友好提示而非抛出异常。更稳妥的做法是预留一个 DefaultSkill,比如“我不知道怎么回答,但我会将你的问题记录并反馈”,不过这通常也需要接入具体产品逻辑。
4.4 编写 ViewModel
ViewModel 是 SwiftUI 和业务逻辑之间的桥梁。它负责持有消息数组、调用 Agent、维护界面状态。
swift复制// 文件路径:ViewModels/ChatViewModel.swift
import Foundation
import SwiftUI
@MainActor
final class ChatViewModel: ObservableObject {
@Published var messages: [ChatMessage] = []
@Published var inputText: String = ""
@Published var isSending = false
private let agent: Agent
init(agent: Agent = GeneralPurposeAgent(skills: [
SummarizeSkill(),
JokeSkill()
])) {
self.agent = agent
}
func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isSending else { return }
messages.append(.user(text))
inputText = ""
isSending = true
Task {
do {
let reply = try await agent.perform(task: text)
messages.append(.assistant(reply))
} catch {
messages.append(.assistant("请求失败:\(error.localizedDescription)"))
}
isSending = false
}
}
}
需要注意的几点:
@MainActor保证 ViewModel 所有属性只能从主线程访问,避免在异步任务中更新 UI 时产生并发问题。Task闭包中调用了异步方法,Swift 并发会把操作自动调度到合适线程,但messages.append因为带有@MainActor的类隔离,并不会造成跨线程访问冲突。isSending用来防止用户连续点击发送按钮,属于基本防抖处理。
4.5 创建 SwiftUI 视图
现在重写 ContentView.swift,构建聊天界面。
swift复制// 文件路径:ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = ChatViewModel()
var body: some View {
VStack {
List(viewModel.messages) { message in
ChatBubble(message: message)
}
.listStyle(.plain)
HStack {
TextField("请输入任务…", text: $viewModel.inputText)
.textFieldStyle(.roundedBorder)
.onSubmit {
viewModel.sendMessage()
}
Button("发送") {
viewModel.sendMessage()
}
.disabled(viewModel.isSending || viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
}
.navigationTitle("SwiftUI AI Agent")
}
}
struct ChatBubble: View {
let message: ChatMessage
var body: some View {
HStack {
if message.role == .user {
Spacer(minLength: 60)
Text(message.content)
.padding(12)
.background(Color.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 14))
} else {
Text(message.content)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 14))
Spacer(minLength: 60)
}
}
.padding(.vertical, 4)
}
}
在 ChatBubble 中,用户消息和助手消息分别靠左或靠右对齐。这一版示例没有做流式输出,界面会在完整响应返回后一次性显示文本。真实项目中,如果你希望字符逐个出现,需要把模型返回结果逐步追加到 messages 里,或者使用 AsyncSequence 做流式读取,这是另一个进阶话题。
4.6 运行与验证
完成上述代码后,选择模拟器运行项目。你可以在输入框内输入“请帮我总结这段文字:苹果公司在 2024 年发布了全新的操作系统版本,带来了多项改进,包括更智能的通知管理以及更强大的隐私保护功能”,点击发送。
预期结果:
- 用户气泡先出现在列表底部。
- 稍等片刻,助手气泡出现,内容来自
MockLLMClient。 - 由于是模拟返回,内容会是“这是一段经过模拟总结后的短文本……”
- 如果输入“给我讲个笑话”,助手气泡会返回一句程序员冷笑话。
如果你已经准备好了真实 API,只需要把 ChatViewModel 默认参数中两个技能的 client 替换为真实客户端,并确保真实客户端正确处理鉴权和模型参数,其余代码无需变化。
5. 常见问题与排查思路
5.1 请求失败或没有网络返回
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| App 内请求一直失败 | 未配置 ATS 或网络权限 | 检查 Info.plist 中的本地网络说明;真机需要开启网络权限 |
| 模拟器可以,真机失败 | 本地网络权限提示被拒绝 | 到系统设置中重新开启网络权限 |
| 调用外部 API 超时 | 需要走代理或者公司内网限制 | 确保使用你的开发网络环境;排查防火墙策略 |
| 返回 401 或 403 | API Key 缺失或无效 | 检查密钥是否注入到请求 Header |
在 iOS 上,如果请求的是 HTTPS 接口,通常不需要额外配置。如果调试时使用 HTTP 本地服务,则必须在 Info.plist 中启用 NSAppTransportSecurity -> NSAllowsLocalNetworking。不要把 NSAllowsArbitraryLoads 放进正式包,这会降低安全性。
5.2 SwiftUI 列表不更新
如果你把 @Published var messages 直接改造成数组后,却发现界面没有刷新,通常是因为视图没有订阅对应的 ObservableObject。在 SwiftUI 中,你需要使用 @StateObject 或 @ObservedObject 绑定 ViewModel,才能让编译器自动监听被 @Published 包装的属性。单纯在组件中创建普通类实例不会触发刷新。
另一个常见原因是忘了给 ChatMessage 添加 Identifiable。如果数据模型中缺少稳定的唯一标识,List 在更新时可能无法正确计算差异,导致显示异常。
5.3 JSON 解析失败
真实 LLM 接口通常会返回 choices 数组、content 字段等嵌套结构。解析时最容易出问题的点包括:
- 字段名大小写写错,例如
Content写成content。 - 返回结果中
content为null。 - 使用了多个空格或换行,导致 JSON 字符串被误判。
建议在调试阶段把原始响应体打印出来,再针对字段结构写 Codable 模型。不要想当然认为所有平台字段命名相同。
5.4 任务执行顺序被打乱
如果你使用 Task 时没有保持 ViewModel 与视图的 @MainActor 隔离,就可能出现在异步回调中更新数组的竞争条件。典型表现是最后插入的消息突然消失、或者 UI 显示顺序错误。
解决方法是给 ViewModel 类标记 @MainActor,并且不要在 Task 里去手动调用 DispatchQueue.main.async,让 Swift 并发系统自动处理。对于更复杂的并发需求,可以考虑把 Agent 的独立技能设计为可并发执行的 AsyncSequence 或 TaskGroup,但那是进阶专题。
5.5 模拟客户端无法体现真实流式效果
本文的 MockLLMClient 通过 Task.sleep 模拟了耗时,但它是完整返回文本。真实模型接口往往支持流式输出,即边生成边返回。如果你希望界面像 ChatGPT 那样逐词显示,一般思路是:
- 把网络请求改为流式读取。
- 每收到一段文本就更新一次
@Published的当前回复内容。 - 在 UI 上把正在生成的回复单独显示为一个临时气泡。
由于不同服务商的流式格式差别较大,这里不展开具体解析代码。你只需要记住,架构上必须保证“更新临时气泡”的频率要合理,避免每收到一个字符就触发一次数组整体刷新,导致列表滚动卡顿。
6. 最佳实践与工程建议
6.1 协议边界尽量单一
Skill 协议只接收字符串、返回字符串,这样最简单,也最容易组合。如果你的某些技能需要额外参数,例如指定语言、温度、最大 Token 数,不要把这些参数写死进方法签名。建议在技能初始化时传入配置对象,或者定义带上下文的 SkillInput 结构体。
例如:
swift复制struct SkillContext {
var temperature: Double = 0.7
var maxTokens: Int = 1024
var language: String = "zh-Hans"
}
这样既保持了统一执行入口,又保留技能级定制空间。
6.2 错误类型要细分
在 execute 方法中尽量不要直接抛 Error 的默认实现,而是自定义一个 AgentError 枚举:
swift复制enum AgentError: LocalizedError {
case skillNotFound(String)
case networkUnavailable
case invalidResponse
case tokenLimitExceeded
var errorDescription: String? {
switch self {
case .skillNotFound(let task):
return "没有找到能处理该任务的技能:\(task)"
case .networkUnavailable:
return "当前网络不可用"
case .invalidResponse:
return "模型返回了无法解析的数据"
case .tokenLimitExceeded:
return "请求超出模型上下文长度限制"
}
}
}
这样在 ViewModel 的 catch 块中可以直接调用 error.localizedDescription 显示友好的错误信息,同时保留排查所需的枚举类型。
6.3 配置集中管理,密钥绝不进代码
无论是否接入真实 API,都建议在工程中创建 APIConfig.swift 文件,用静态常量集中维护配置。真实密钥则通过 xcconfig 或后端转发解决。
示例 APIConfig.swift:
swift复制enum APIConfig {
#if DEBUG
static let usesMockClient = true
#else
static let usesMockClient = false
#endif
}
然后在 ChatViewModel 的初始化方法中判断使用哪个客户端。这种开关式配置对日常调试非常方便。
6.4 日志与可观测性
AI 类功能一旦上线,排查问题的难度远高于普通 JSON 接口。最要命的是“同一段提示词,今天返回正常,明天返回乱码”。因此,一定要在客户端记录日志:
- 任务唯一标识。
- 用户发送的原始输入。
- 最终匹配到的技能名称。
- Prompt 概要(完整记录可能涉及隐私,要脱敏)。
- 模型返回的原始文本。
- 耗时和是否成功。
在 Swift 中,你可以自定义一个简单的 Logger 工具,或者使用 OSLog 统一管理。这些日志既可用于调试,也能帮助产品同学评估模型效果。
6.5 合理设计限流与重试机制
SwiftUI 客户端如果被用户高频点击发送按钮,很容易在很短时间内发出大量请求,既浪费 Token,也可能触发服务端的限流规则。除了在 UI 上禁用按钮外,还应该在服务层增加简单的间隔限制。
重试策略也要谨慎。模型接口超时是常态,但无脑重试可能加剧服务端负载。建议:
- 超时时间为 30 秒左右。
- 最多重试 2 次。
- 重试之间指数退避,例如第一次 2 秒,第二次 4 秒。
6.6 安全边界与内容合规
当 App 允许用户自由输入文本时,必须考虑滥用风险。例如用户要求模型生成恶意代码、违法内容或隐私信息。作为开发者,你应当在客户端和服务端都做基本的内容过滤,同时遵守平台审核要求。
不要试图在客户端通过黑名单词表拦截所有风险内容,因为这种方案极易被绕过。最好的方式是:在上游模型服务配置内容审核策略,或者让客户端请求你的后端,由后端调用模型接口前统一校验。最小权限原则同样适用:你的 App 请求什么权限,就只申请什么权限,不要索取与功能无关的通讯录、定位等敏感权限。
6.7 单元测试优先覆盖技能和 Agent
本文架构最大的优势就是可测试性。因为 Skill 和 Agent 都不依赖具体 View,你可以在 Xcode 的测试目录中直接构造 MockLLMClient,验证:
- 技能是否拼接出预期 Prompt。
- Agent 是否能正确按关键词分发任务。
- 兜底逻辑是否返回预期提示。
- 错误发生时 ViewModel 是否正确切换到失败状态。
例如测试 GeneralPurposeAgent:
swift复制func testSummarizeRouting() async throws {
let agent = GeneralPurposeAgent(skills: [SummarizeSkill()])
let result = try await agent.perform(task: "请帮忙总结这篇文章")
XCTAssertFalse(result.isEmpty)
}
测试通过后,你就可以放心地在后续升级网络层时保持上层行为不变。
7. 总结与学习路线
本文围绕 SwiftUI 集成 AI 能力这一场景,梳理了 Agent 与 Skill 的架构设计思路。我们从实际问题出发,定义了最基础的 Skill 协议和 Agent 协议,然后用 MockLLMClient 搭建了一个可运行的聊天式助手。核心思想是:界面依赖 ViewModel,ViewModel 依赖 Agent,Agent 依赖 Skill,Skill 依赖 LLMClient,每一层都可以独立替换、独立测试。
下一步你可以从三个方向继续深入:
- 让 Agent 具备真正的意图识别能力:脱离关键词规则,把技能描述发送给 LLM,由模型自动选择技能。
- 接入流式输出:把一次性返回改成逐字输出,并通过
AsyncSequence实现更自然的大模型对话体验。 - 扩展技能生态:打破“只返回字符串”的限制,让技能可以返回图片、表格、Markdown 渲染内容,甚至多个步骤的执行结果。
实际项目中,最需要警惕的还是安全与成本:一块小小的 Token 消耗日志,一次 API Key 泄露事故,都可能让整个项目前功尽弃。建议你按照最小闭环的顺序推进,先把本文模拟版跑通,再逐步替换真实模型,发现坑点后回到 Agent 层补充兜底逻辑。如果你正在做 SwiftUI + AI 的结合项目,不妨直接把 Agent/Skill 这套抽象先放进工程里,它会在功能变复杂之后帮你省下不少维护时间。
