最近在做 iOS 客户端时,一个反复困扰我的问题是如何把不同功能模块化地塞进同一个应用里,同时又不让代码越写越乱。刚好看到 twostraws 的 SwiftUI-Agent-Skill 相关项目思路,顺着它整理了一套“Agent 代理 + Skill 技能”的接入方案,并且把最小 Demo 跑通了。这篇文章围绕这个项目展开,拆解在 SwiftUI 里如何设计一个带技能体系的智能代理应用,包含协议设计、Agent 管理器、完整聊天界面、常见坑点与工程化建议。适合正在学习 SwiftUI 的开发者,也适合想在 iOS 端做一个轻量级助手类应用的读者。
1. 背景与核心概念
1.1 什么是 Agent 应用
Agent 在软件工程里并不是某一个固定框架,而是一种组织业务能力的方式。一个 Agent 应用通常表现为“用户发出意图 -> Agent 接收请求 -> Agent 匹配并调用对应能力 -> 把结果返回给用户”。你可以把它理解成一个超级入口:用户不需要关心后台各个功能的具体位置,只需要用自然语言描述需求,然后由 Agent 帮你决定该调哪个功能模块。
这种设计在聊天机器人、任务助手、快捷指令面板里非常常见。比如用户说“现在几点”,Agent 会识别出这是一个时间查询需求,然后调用时间技能,而不是把这句话直接丢给某个固定函数去处理。更直白地说,Agent 是大脑,Skill 是手和脚,两者配合才构成一个完整的助手应用。
在 iOS 原生产品里,SwiftUI 很适合搭建这类应用的界面层。它声明式语法能快速完成聊天列表、输入框、状态反馈等交互界面,而协议和结构化并发又很适合做技能模块的抽象。所以把 Agent 与 SwiftUI 结合起来,是一个低成本验证想法、快速搭建原型的方案。
1.2 什么是 Skill 技能模块
Skill 可以理解成一个可以被 Agent 调用的独立能力单元。每个 Skill 应该具备三个要素:名字、触发条件、执行逻辑。名字是给人看的,方便日志和调试;触发条件是判断某条输入是否由这个 Skill 处理;执行逻辑是真正干活的部分。
比如一个时间技能,它的触发条件是输入包含“时间”“几点”等关键词,执行逻辑则是获取当前系统时间并格式化成字符串。一个计算器技能,触发条件是输入包含数字和“+”等符号,执行逻辑则是解析表达式并返回计算结果。
把能力拆分成 Skill 带来的最直接好处是扩展性。以后想在应用里增加天气查询、日程创建、快捷翻译等功能,不需要在 Agent 里写一大堆 if-else,只需要新建一个符合协议的 Skill 文件,然后注册进去即可。Agent 核心代码保持稳定,新功能像插卡一样方便。这也正是 twostraws 这个 SwiftUI-Agent-Skill 项目最值得学习的部分:用一套轻量协议,把“代理调度”与“能力实现”解耦。
1.3 为什么用 SwiftUI 来构建
SwiftUI 是目前苹果主推的原生 UI 开发框架,相比 UIKit 它的学习曲线更友好。对于 Agent 应用来说,SwiftUI 有几点优势特别明显。
第一,状态驱动界面。聊天消息数组变化时,界面会自动刷新,不需要手动调用 reloadData。通过 @StateObject、@Published 和 ObservableObject,我们只需要保证数据源正确,UI 就会随之更新,这非常符合 Agent 过程中“用户消息插入 -> 等待结果 -> 返回消息插入”的动态流程。
第二,视图组合简单。气泡样式的消息单元格、输入框和发送按钮,在 SwiftUI 里可以用少量代码组合出来,不需要维护复杂的 Auto Layout 约束。
第三,原生支持异步。Swift 的 async/await 和 SwiftUI 的 Task 可以很好地衔接 Agent 的异步技能执行。用户点击发送后,可以在不阻塞界面的情况下等待技能返回结果,然后回到主线程更新消息列表。
当然,SwiftUI 也有它需要注意的地方,比如状态共享、滚动性能、键盘处理等,但这些都可以通过后续工程化手段解决。至少对 Agent 这类以列表和文本输入为主的应用,SwiftUI 性价比很高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目结构
2.1 开发环境说明
本文示例使用的是 Swift 5.7 及以上版本的语言特性,比如 actor 和 async/await。开发工具建议使用 Xcode 14 或更高版本,系统目标版本建议设置为 iOS 16 或更高。因为示例中用到了 Date.formatted、NavigationStack 等较新的 API。
需要注意,版本不是死的。如果你的项目还停留在 iOS 15 或 Xcode 13,也可以运行,只是部分写法需要替换。比如 NavigationStack 可以换成 NavigationView,Date.formatted 可以换成旧的 DateFormatter。本文重点演示架构思路,具体 API 建议根据你的实际环境调整。
2.2 项目结构规划
为了让整个工程清晰,我建议创建一个名为 AgentSkillDemo 的 iOS App 工程,然后按下面这种方式组织文件:
text复制SwiftUI-Agent-Skill/
├── AgentSkillDemo.xcodeproj
├── AgentSkillDemo/
│ ├── AgentSkillDemoApp.swift
│ ├── Models/
│ │ ├── ChatMessage.swift
│ │ └── Skill.swift
│ ├── Agent/
│ │ └── Agent.swift
│ ├── Skills/
│ │ ├── TimeSkill.swift
│ │ ├── CalculatorSkill.swift
│ │ └── GreetingSkill.swift
│ ├── ViewModels/
│ │ └── AgentViewModel.swift
│ └── Views/
│ ├── ContentView.swift
│ └── MessageBubble.swift
└── README.md
这个结构把协议、执行器、具体技能、数据模型、视图模型和视图分层,后续维护时能很快定位问题。当你新增一个技能时,只要在 Skills 目录里添加新文件,并在 AgentViewModel 的初始化方法里注册即可。
2.3 创建 Xcode 工程
如果是从头开始,直接在 Xcode 中选择 Create New Project,选择 iOS App,Product Name 填 AgentSkillDemo,Interface 选 SwiftUI,Language 选 Swift。
创建完成后,将默认生成的 ContentView.swift 保留,其余文件按上面的结构右键新建或拖入对应目录。如果已经有工程,也可以直接把对应文件复制进去。
3. 核心设计:从 Skill 协议到 Agent 执行器
3.1 先定义 Skill 协议
在设计 Agent 架构时,第一件事是抽象出技能的统一接口。一个技能需要有名字、关键词和真正的执行方法。我们用协议来约束它:
swift复制// 文件路径:AgentSkillDemo/Models/Skill.swift
import Foundation
/// 所有可注册到 Agent 的技能都必须遵守的协议
protocol Skill: Sendable {
/// 技能名称,方便调试和日志展示
var name: String { get }
/// 触发关键词,当用户输入包含这些词时,Agent 会优先匹配该技能
var keywords: [String] { get }
/// 执行技能,输入是用户原始文本,输出是回复内容
func execute(input: String) async throws -> String
}
解释几个关键点:
Sendable 是为了让 Skill 能够安全地在并发环境中传递。因为 Agent 可能是一个 actor,技能会在异步任务中执行,Swift 编译器需要保证并发安全。这里不需要深入底层原理,只要记住:如果你的技能只包含值类型,用 Sendable 是很自然的选择。
keywords 是简化后的触发机制。真实项目可能会用更复杂的自然语言理解或正则匹配,但示例项目先通过关键词让 Agent 决定调用哪个技能。你可以根据需求改成返回 Bool 的 canHandle 方法,自由度更高。
execute 被标记为 async throws,表示技能可能执行较长时间,也可能失败。比如网络请求技能、读取本地文件技能,都应该用异步形式,避免阻塞界面。这个设计也保留了技能内部处理复杂的空间。
3.2 实现 Agent 执行器
有了协议之后,再定义 Agent。Agent 负责维护技能列表,并对用户输入进行匹配和调用。
swift复制// 文件路径:AgentSkillDemo/Agent/Agent.swift
import Foundation
actor Agent {
private var skills: [any Skill] = []
/// 注册技能
func register(_ skill: any Skill) {
skills.append(skill)
}
/// 处理用户输入,返回回复内容
func handle(_ input: String) async throws -> String {
for skill in skills {
for keyword in skill.keywords {
if input.localizedCaseInsensitiveContains(keyword) {
return try await skill.execute(input: input)
}
}
}
return "我暂时没有学会这个技能,请尝试输入“现在几点”或“3+5”。"
}
}
这里把 Agent 定义成 actor,好处是技能列表的多线程读写是安全的。Swift 的 actor 能保证同一时间只有一个任务访问内部可变状态,避免在并发时产生数据竞争。
handle 方法做了两件事:先遍历技能,再遍历每个技能的关键词,只要用户输入包含其中一个关键词,就调用该技能执行。这种匹配顺序决定了技能注册的先后有影响。如果你想给某些技能更高的优先级,可以把技能列表改成优先级排序,或者在匹配逻辑中加入权重。
如果没有任何技能命中,就返回一句兜底文案,避免界面显示空白回复。兜底文案应该提示用户可用的指令,这样即使匹配失败,用户也知道下一步怎么操作。
3.3 技能注册机制
技能注册本质上是把具体实现注入到 Agent 的过程。我们可以把注册逻辑放在 ViewModel 的初始化方法中,这样应用启动后 Agent 就已经具备能力。
swift复制private let agent = Agent()
init() {
agent.register(TimeSkill())
agent.register(CalculatorSkill())
agent.register(GreetingSkill())
}
注册顺序有讲究。比如 GreetingSkill 的关键词如果包含“你好”,而 TimeSkill 的关键词包含“你好时间”,那么当用户输入“你好,现在几点”时,Agent 不会识别成复杂指令,而是直接交给先匹配到的技能。示例里 TimeSkill 先注册,所以时间相关输入会优先走进时间技能。
这种注册机制最简单,也最容易理解。后续可以扩展出 SkillRegistry,支持注册、反注册、查询、按名称匹配等能力,但核心思想不变。
4. 完整实战:构建一个可对话的 Agent 应用
4.1 创建消息模型
聊天界面最基本的数据结构是消息模型。它需要包含内容、是否由当前用户发送,以及用于 SwiftUI 列表的稳定标识:
swift复制// 文件路径:AgentSkillDemo/Models/ChatMessage.swift
import Foundation
struct ChatMessage: Identifiable, Equatable {
let id = UUID()
let content: String
let isUser: Bool
}
这里用 Identifiable 让 SwiftUI 的 List 能够自动识别每条消息,Equatable 是为了后续做动画或者比较时方便。id 使用 UUID,避免列表复用时的顺序问题。
4.2 编写 AgentViewModel
ViewModel 是连接 SwiftUI 视图和 Agent 的桥梁。它负责保存消息列表,并在用户发送消息时调用 Agent 处理。
swift复制// 文件路径:AgentSkillDemo/ViewModels/AgentViewModel.swift
import Foundation
@MainActor
final class AgentViewModel: ObservableObject {
@Published var messages: [ChatMessage] = []
private let agent = Agent()
init() {
agent.register(TimeSkill())
agent.register(CalculatorSkill())
agent.register(GreetingSkill())
}
func send(_ text: String) async {
let userMessage = ChatMessage(content: text, isUser: true)
messages.append(userMessage)
do {
let reply = try await agent.handle(text)
messages.append(ChatMessage(content: reply, isUser: false))
} catch {
messages.append(ChatMessage(content: "技能执行失败:\(error.localizedDescription)", isUser: false))
}
}
}
@MainActor 表示这个类的方法默认在主线程执行。SwiftUI 的 @Published 修改主线程时,界面会同步刷新。async 方法内部调用 Agent.handle 时,虽然 Agent 是 actor,但通过 await 可以安全地等待异步结果,并不会阻塞主线程。
注意,这里在 send 方法中直接 append 消息,然后等待回复。用户发出消息后,Agent 返回前,我们其实还可以插入一个“正在输入”的占位消息,让体验更完整。示例为了突出核心逻辑,暂不处理这一层。
4.3 实现三个核心技能
先实现最基础的时间技能。它匹配包含“时间”或“几点”的输入,返回当前系统时间:
swift复制// 文件路径:AgentSkillDemo/Skills/TimeSkill.swift
import Foundation
struct TimeSkill: Skill {
let name = "时间"
let keywords = ["时间", "几点", "time"]
func execute(input: String) async throws -> String {
let now = Date.now
let formatted = now.formatted(date: .omitted, time: .shortened)
return "当前时间是 \(formatted)"
}
}
这里用了 Swift 5.7 以后引入的 formatted API,写法简洁,不需要手动创建 DateFormatter。time: .shortened 会输出类似 “下午 3:25” 的格式。
然后是计算器技能。为了演示技能可以包含业务逻辑,我们实现一个简单的加法计算:
swift复制// 文件路径:AgentSkillDemo/Skills/CalculatorSkill.swift
import Foundation
struct CalculatorSkill: Skill {
let name = "加法计算"
let keywords = ["+", "加"]
func execute(input: String) async throws -> String {
let normalized = input
.replacingOccurrences(of: "加", with: "+")
.replacingOccurrences(of: " ", with: "")
let parts = normalized
.components(separatedBy: "+")
.compactMap { Double($0) }
guard parts.count >= 2 else {
return "请按“数字+数字”的格式输入,比如:3+5"
}
let total = parts.reduce(0, +)
if total == Double(Int(total)) {
return "计算结果:\(Int(total))"
} else {
return "计算结果:\(total)"
}
}
}
这段代码会把“3 + 5”或“3加5”统一转成“3+5”,再按加号拆分,转换成 Double 后求和。如果结果是整数,就直接显示整数,避免出现 “8.0” 这种不友好的输出。
最后是问候技能,它主要用来体现“积分匹配”的场景:
swift复制// 文件路径:AgentSkillDemo/Skills/GreetingSkill.swift
import Foundation
struct GreetingSkill: Skill {
let name = "问候"
let keywords = ["你好", "hello", "hi", "嗨"]
func execute(input: String) async throws -> String {
return "你好,我是 SwiftUI Agent,你可以问我现在几点,也可以让我帮你做加法运算。"
}
}
这里的关键词大小写不敏感,因为 Agent.handle 中使用了 localizedCaseInsensitiveContains。用户输入 “Hello” 也能正常触发。
4.4 编写 SwiftUI 聊天界面
界面的核心部分是消息列表、输入框和发送按钮。先定义一个气泡样式的消息视图:
swift复制// 文件路径:AgentSkillDemo/Views/MessageBubble.swift
import SwiftUI
struct MessageBubble: View {
let message: ChatMessage
var body: some View {
HStack {
if message.isUser {
Spacer()
}
Text(message.content)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(message.isUser ? Color.blue : Color.gray.opacity(0.2))
.foregroundColor(message.isUser ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 14))
.frame(maxWidth: 320, alignment: message.isUser ? .trailing : .leading)
if !message.isUser {
Spacer()
}
}
}
}
frame(maxWidth:alignment:) 可以让气泡宽度不超过一定值,同时按照消息类型左对齐或右对齐。这里设置了最大 320 点,适配不同屏幕尺寸时不会出现一条消息撑满全屏的问题。
然后重写主视图 ContentView:
swift复制// 文件路径:AgentSkillDemo/Views/ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = AgentViewModel()
@State private var input = ""
@FocusState private var inputFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 0) {
List(viewModel.messages) { message in
MessageBubble(message: message)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
HStack {
TextField("输入指令,例如:现在几点", text: $input)
.textFieldStyle(.roundedBorder)
.focused($inputFocused)
.onSubmit { send() }
Button("发送", action: send)
.buttonStyle(.borderedProminent)
.disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
.background(.bar)
}
.navigationTitle("SwiftUI Agent")
}
}
private func send() {
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
input = ""
inputFocused = false
Task {
await viewModel.send(text)
}
}
}
这里用 NavigationStack 和 navigationTitle 实现顶部导航标题。List 负责展示消息,TextField 负责接收用户输入,按回车或点击发送按钮都会触发 send 方法。
注意,在 send 方法里先清空输入框,再开启 Task 异步调用 viewModel.send。这样可以避免用户重复点击发送时输入多条相同内容;同时,通过 @FocusState 收起键盘,让界面更清爽。
最后,在应用入口文件里挂载 ContentView:
swift复制// 文件路径:AgentSkillDemo/AgentSkillDemoApp.swift
import SwiftUI
@main
struct AgentSkillDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
至此,一个最小可运行的 Agent 聊天应用就完成了。
4.5 运行与验证
在 Xcode 中选择一个模拟器,点击运行。应用启动后,你会看到一个聊天界面。底部分别是输入框和发送按钮。
测试用例可以输入以下几种内容:
| 输入内容 | 预期回复 |
|---|---|
| 现在几点 | 返回当前系统时间 |
| 3+5 | 返回计算结果:8 |
| 3加5 | 统一转成加法并返回计算结果:8 |
| 你好 | 返回问候语,并提示可用指令 |
| 今天天气怎么样 | 返回兜底文案,提示尚未学会该技能 |
假如你在模拟器里输入“现在几点”,界面会先出现用户的蓝色气泡,紧接着出现 Agent 返回的灰色气泡。由于 TimeSkill 和 ViewModel 都是异步调用,整个交互过程不会卡住界面。
5. 常见问题与排查思路
在实际开发或复现这个项目时,可能会遇到一些问题。下面是几个最常见的现象和排查方案。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
编译报错:'Sendable' is unavailable |
Xcode 版本过低,Swift 版本不支持 Sendable | 升级 Xcode;或在协议中移除 Sendable 并保证技能内部线程安全 |
| 显示“技能执行失败:...”。 | Agent 内部抛出了错误 | 在 execute 方法中打印错误信息,检查具体技能的网络或计算逻辑 |
| 输入多个关键词时,总是只触发第一个技能 | 注册顺序导致优先级固定 | 调整注册顺序,或为 Agent 增加匹配优先级字段 |
| 输入框点击发送后键盘不收起 | 没有正确使用 FocusState | 为 TextField 绑定 @FocusState,发送后把值置为 false |
| List 滑动时消息闪烁 | 消息模型缺少稳定 id | 确认 ChatMessage 遵循 Identifiable 且 id 是 UUID |
| 按钮在输入为空时仍可点击 | disabled 条件不完整 | 检查 disabled 判断中是否包含了空格和换行处理 |
如果出现编译错误,首先要确认 actor 和 async/await 的语法是否被当前 Xcode 支持。本文的示例依赖 Swift 并发特性,所以最好在 Xcode 14 之后的环境运行。如果团队项目已经锁定旧版本,建议先替换协议中的 Sendable 与 async 写法。
除了这些表面问题,更值得关注的是技能匹配顺序。当你把多个技能注册到 Agent 后,一定要给关键词排好优先级。比如,一个“知识问答”技能包含关键词“什么”,另一个“天气”技能包含“天气”,用户输入“今天天气怎么样”时,“什么”这个关键词可能也会命中。好的做法是在 Agent 里加入更精确的匹配逻辑,而不是单纯逐个包含检查。
6. 最佳实践与工程建议
6.1 技能拆分与命名规范
技能的粒度不能太大,也不能太小。太大的技能会让 Agent 很难复用,太小的技能又会导致注册列表膨胀。一个技能应该对应一个明确、独立的用户意图。
命名规范推荐采用“动词 + 对象”的方式,比如 GetTimeSkill、AddNumbersSkill、CheckWeatherSkill。文件内部字段也跟着语义走,这样即便几个月后再看代码,也能快速知道这个技能是干什么的。
6.2 并发与线程安全
Agent 内部的状态使用 actor 保护后,外部调用就不必再担心数据竞争。但技能本身依然要注意:如果技能内部持有可变对象,最好确保它也是线程安全的,或者让技能结构体只存放不可变配置,执行时再创建临时状态。
在 SwiftUI 界面层,所有修改 @Published 属性的操作必须在主线程。ViewModel 使用 @MainActor 就足够了。Task 会自动继承主 actor 上下文,因此 await viewModel.send(text) 不会造成 UI 更新问题。
6.3 安全边界与用户输入处理
Agent 应用的核心风险在于执行用户输入。本文中的计算器技能只做数字相加,没有执行任意表达式,因此安全性相对可控。但在实际项目中,千万不要把你的 Agent 技能设计成“接受任意代码并执行”的模式。如果你需要解析计算表达式,优先选择成熟的表达式解析库,并限制函数调用与符号表。
对于网络请求类技能,必须对用户输入的 URL 做严格校验,只允许 https 协议,并且避免直接拼接 SQL 或 shell 命令。安全边界画得越早,后续踩坑越少。
6.4 错误处理与用户提示
技能执行不应该只返回成功情况。当技能内部出现错误时,最好使用符合 Error 协议的自定义错误类型,在 ViewModel 捕获后统一转成用户可读的文案。比如“网络请求超时,请稍后再试”要比 “Optional(httpError)” 友好得多。
另外,在 Agent 没有匹配到技能时,兜底回复不要只写一句“我不懂”,而是给出当前支持的技能列表。这样用户会更愿意继续使用你的应用。
6.5 可测试性设计
当技能数量变多以后,测试会越来越重要。为了支持单元测试,Agent 应该允许注入不同的技能列表,而不是在 ViewModel 中硬编码。可以把注册逻辑抽到一个 AgentBuilder 中,测试时只注册需要测试的技能,避免依赖真实环境。
对于每个技能,建议直接对 execute(input:) 写单元测试。比如输入“3+5”,断言结果等于“计算结果:8”。这套架构天然支持测试,因为协议和具体实现是分离的。
6.6 动画与交互细节
聊天应用最需要关注的是消息插入动画。SwiftUI 中使用 withAnimation 包住 messages.append,可以让新消息以淡入效果出现。不过要注意 List 的自动滚动问题。当消息数量增多时,默认列表不会自动滚到底部,你需要在消息变化后通过 ScrollViewReader 滚动到最新一条,否则用户每次都手动下拉很影响体验。
7. 项目扩展与进阶方向
当前 Demo 解决了 Agent 的骨架问题,但仍然很简陋。真正要投入使用,还有很多可以完善的地方。
一个方向是引入更智能的意图识别。关键词匹配在简单场景下够用,但稍微复杂一点,它会不断暴露出短板。比如“帮我看看下午三点有没有会”这种自然语言,靠关键词很能难提取出正确语义。这时可以接入语义解析模型,或者引入系统自带的 Natural Language 框架做分词和特征识别。
另一个方向是让技能支持参数。当前每个技能都是直接接收原始输入字符串,解析逻辑只能写在技能内部。更好的做法是把解析拆成两步:先由 Agent 从输入中提取结构化参数,再把参数传给技能。比如“设定一个明天早上八点的提醒”,Agent 应该抽取出时间、动作和内容,然后交给提醒技能去执行。
数据持久化也是一个必选优化。聊天记录如果用内存数组保存,应用一退出就没了。使用 SwiftData 或 Core Data 可以把对话历史持久化到本地,这样用户下次打开应用还能看到之前的会话内容。
如果再结合 Apple 平台的语音能力,也可以加入 Speech 框架做语音输入,或者接入 AVFoundation 播放 Agent 的语音回复,让应用从“文字助手”升级为“语音助手”。整套 Agent + Skill 的架构在扩展过程中不会发生根本性变化,这本身就是这套设计最大的价值。
对我个人而言,这个项目让我重新思考了“模块化”在客户端应用的落地形态。以前遇到新功能需求,总是想着往某个页面上堆代码,而 Agent + Skill 的思路提供了一条更接近业务本质的路径:先抽象能力,再统一调度。后续开发中,即使不做成聊天应用,我也会继续借鉴这套协议思维去组织工具类功能。如果你正打算做一个轻量 Agent,不妨直接 fork 一份代码,从修改技能列表开始动手试试。
