1. 项目概述与设计动机
接手这个项目的时候,第一反应是“又一个套壳的AI聊天应用”。但把 SwiftUI-Agent-Skill 这几个词拆开看,其实背后藏着一个很实际的需求:怎么用 SwiftUI 优雅地搭建一个带智能代理能力的应用,并且让代理真正具备可扩展、可插拔的执行能力。
市面上大多数所谓的“Agent应用”,核心逻辑都写死在代码里。用户问一句天气,代码里写死一个公开接口调用;用户问一句新闻,又写死一个网络请求。今天加一个能力,明天改一个接口,后天需求一变,整个页面状态和业务逻辑全都要跟着动。这种实现方式撑不起超过十个功能的项目,更别谈能力复用了。
SwiftUI-Agent-Skill 这个项目要解决的,就是这套结构性问题。它的核心思想不复杂:把“用户意图”和“执行能力”彻底解耦。界面只负责把用户的话送给Agent,Agent负责拆解意图,而真正干活的是挂在Agent身上的各种Skill。每个Skill是一个独立的功能单元,遵循同一套协议,可以被动态装载、卸载、替换。做项目的过程中我发现,这个架构用在SwiftUI上有个天然的优势——SwiftUI的声明式语法和状态驱动模型,和Agent这种“输入-思考-行动-反馈”的循环非常匹配,状态流转写起来比 UIKit 时代顺手太多。
这篇文章适合两类人:一类是想认真做一版智能代理应用的 Swift 开发者,另一类是厌倦了把功能写死在视图层里、想尝试插件化架构的 iOS/macOS 工程实践者。我会把从零搭建这个系统的思路、细节、踩坑记录全部写出来,重点是协议设计、Skill 注册机制、异步流式输出的 UI 适配,以及实际运行时的状态管理方案。每个环节都会有可复用的实现代码和避坑提示。
2. 核心架构与Skill机制设计
2.1 整体结构分层
先看一眼这个项目最终落地的分层结构,不复杂,但每一层都是踩过坑之后才确定下来的。
最底层是Skill层,每个Skill是一个自包含的功能模块,负责具体的活儿。中间是Agent层,它持有当前注册的所有Skill,接收用户输入,输出意图识别结果,然后调度对应的Skill执行。最上层是SwiftUI视图层,它只做两件事:展示对话内容和当前状态,把用户的输入转交给Agent。
实际开发中我通常不叫它Agent,团队内更习惯叫它“能力分发中心”——它确实也像路由器,请求进来,按规则转发到对应的处理器。这个比喻对新人理解这个架构非常友好。
分层的意义在于:视图层永远是“死”的,它不知道也不关心哪一个Skill在处理用户的请求。它只知道Agent在跑、跑完了、或者跑挂了,然后根据状态刷新UI。而Agent本身也是“傻瓜”的,它不包含任何业务逻辑,只负责匹配和调度。真正的业务实现都发生在Skill内部。
这样做有一个很直接的好处:新增功能时,不需要修改任何既有代码,只需要新增一个Skill实现,注册进去就行。这一点在维护期价值极大。项目跑到第五个版本时,我统计了一下,新增功能平均只需要改动两个文件:Skill实现文件和一个注册列表,其他代码一概不动。
2.2 Skill协议:松耦合的正确姿势
Skill是整个系统的地基,协议设计得漂不漂亮,直接决定了后续扩展时是丝滑还是遭罪。我最终定稿的协议骨架长这样:
protocol Skill: Identifiable { var id: String { get } var name: String { get } var description: String { get } var tags: [String] { get } /// 判断自己是否能处理某条输入 func canHandle(_ message: String) -> Bool /// 执行并返回结果 func execute(context: SkillContext) async throws -> String }第一次写这套协议时,我犯了个典型错误:把canHandle的逻辑做成黑名单式的被动匹配,结果两个Skill抢同一类输入,系统随机选一个执行,用户反馈极差。后来改成主动声明式匹配,让每个Skill自己判断能不能处理某条消息,优先级和冲突交由Agent统一裁决。这个改动让系统行为变得可预测,排查问题的难度也降了一个量级。
关于tags字段,一开始其实没想加。后来接入第三方Skill时,发现光靠描述文本做匹配,误判率实在感人。加了tags之后,Agent可以先走标签粗筛、再走描述细判,准确率提高得很明显。
SkillContext 是一个上下文传递体(见第3.2节),它里面不装业务数据,只装用户基本信息、会话ID、历史消息列表和处理约束(比如超时时间、最大返回长度)。所有Skill共享一套上下文协议,但彼此之间不知道对方的存在,这是一个硬约束——任何Skill之间禁止直接通信。是刚开始做这个项目时,某个Skill把结果直接写在了另一个Skill要读取的文件路径上,两个Skill互相耦合得死死的。后面在协议文档里明确禁止了这种用法,强制所有数据交换走Agent统一路由。
2.3 动态注册与优先级裁决
Agent内部维护着一个Skill注册表,简单说就是“一个数组 + 一系列管理方法”。真正值得展开说的是它的调度逻辑:
struct SkillRegistry { private var skills: [any Skill] private var priorityTable: [String: Int] func selectSkill(for message: String) -> (any Skill)? { let candidates = skills .filter { !$0.tags.isEmpty && $0.canHandle(message) } .sorted { priorityTable[$0.id] ?? 0 > priorityTable[$1.id] ?? 0 } return candidates.first } }优先级表的引入是我在接入第五个Skill时的事。前四个Skill各管一摊,井水不犯河水。第五个Skill是个“兜底闲聊包”,什么话都能接。问题立刻暴露:闲聊包总是抢走其他Skill的输入,因为它什么都能匹配上。解决方式就是给每个Skill配一个可配置的优先级数字,默认情况业务型Skill高于闲聊兜底型,而具体数字存放在一个集中配置里,用的时候读取一下,改起来也很方便。
再强调一次:优先级的数值不是常量定义在Skill内部的,而是存放在Agent的配置表中。原因是运维人员不会也不应该改代码,但他们会经常调整一个功能的“话语权”。
3. 界面层与状态管理实践
3.1 SwiftUI状态驱动模型
SwiftUI这层是整个系统里的“颜值担当”,也是我最忌讳写复杂逻辑的一层。经验原则就一句话:视图层绝不放业务判断,所有状态由上层统一下发。
项目里用了一个可观测的代理对象来串联Agent和视图:
@MainActor final class AgentViewModel: ObservableObject { enum Phase { case idle case thinking case executing(skillID: String) case responding(String) case failed(String) } @Published private(set) var phase: Phase = .idle @Published private(set) var messages: [ChatMessage] = [] private let agent: Agent func send(_ text: String) async { messages.append(.user(text)) phase = .thinking do { let reply = try await agent.route(text) phase = .responding(reply) messages.append(.agent(reply)) phase = .idle } catch { phase = .failed(error.localizedDescription) messages.append(.system(error.localizedDescription)) phase = .idle } } }视图端就非常简单了,一个VStack里面是列表和输入框,对phase做条件渲染:
struct ChatView: View { @StateObject private var viewModel = AgentViewModel() var body: some View { VStack { messageList inputBar } } @ViewBuilder private var messageList: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.messages) { message in MessageBubble(message: message) } } } .onChange(of: viewModel.messages.count) { _, count in withAnimation { proxy.scrollTo(count - 1, anchor: .bottom) } } } } }踩坑最多的地方是phase的状态流转。刚开始我把 thinking 和 executing 混为一个状态,结果用户看到的效果就是“转圈圈磨蹭很久,然后一下子输出一大段”。后来拆分成两个状态,thinking期间显示“正在理解意图”,executing期间显示“正在调用某某技能”,用户至少能感知到系统在工作,而不是死机了。加上对应的Skill名称显示出来,观感会好很多。
3.2 上下文传递与会话保持
Agent在不同轮次之间必须是“有记忆”的,不能每句话都当作独立请求。这个记忆机制就落在SkillContext上。
struct SkillContext: Sendable { let sessionID: UUID let userProfile: UserProfile let history: [ChatMessage] let constraints: ExecutionConstraints var currentInput: String }需要注意一个设计细节:history传的是输入输出消息的纯文本数组,而不是模型对象。这么设计是为了兼容多个不同后端——有的Skill要调大模型,有的Skill只是查本地数据库,它们需要的信息格式不一样,统一传纯文本最保险。
另一个细节是:上下文默认被设计为不可变值类型。Skill如果要记录一些中间状态(比如搜索历史、临时变量),必须使用外部存储,不能自己改上下文。这么做是为了保证并发安全。Multi-turn会话在App里是常态,Swift并发和Actor模型用得好,这个项目能很稳定;用不好,就会在低概率下遇到奇怪的竞态问题。强制不可变从根上消掉了很大一类并发bug。
会话保持的方案我选择了简单可靠的一版:内存中保留最近N条(默认50条)消息作为上下文。这个N不是拍脑袋定的,是参考了主流大模型的上下文窗口和日常实用性的折中结果。太大了浪费token,太小了聊不到五句话就“失忆”。
4. 实操全过程:从零接入一个完整Skill
4.1 环境准备与基础配置
开始写代码之前,先把最小工程跑起来。如果你是从Xcode模板新建的App工程,需要做三件基础准备工作:
第一,确认最低部署版本。项目设置里 Deployment Target 至少 iOS 16 或 macOS 13,因为要用到比较新的SwiftUI API和 Swift Concurrency特性。我团队现在统一放在iOS 17,可以放心使用@Observable宏(比ObservableObject简洁不少),但为了兼容第三方库,项目主体依然保留了ObservableObject的写法。代码里两个模式混用,其实并不是一件很理想的事,但现实中的项目很少能一上来就全量迁移,需要在稳定性和开发效率之间做平衡。
第二,引入依赖。项目核心的Agent调度代码可以手写,不需要第三方框架。但如果你的Skill里有调大模型的,建议用一个现成的网络层框架做请求封装,省去处理超时、重试、流式解析的琐碎工作。这一层尽量选相对通用、维护活跃的库,避免后续被上游“卡脖子”。
第三,模块化。这一步是最容易被新项目忽略的,但也是最重要的一步。我使用的是Swift Package分模块:一个核心模块只放协议和Agent;一个UI模块放SwiftUI视图;业务Skill各自成Package。为什么这么拆?因为Skill会越写越多,且经常是不同人Parallel开发的。分模块虽然前期会多一点配置成本,但后续每次编译只需要编译变更的模块,迭代速度会快很多。
4.2 手写一个天气Skill实例
理论说完了,动手写一个真实可用的Skill。这里我不能用具体商家的真实API地址,就用一个常见的公开天气接口协议做演示,实际替换任何服务商都一样,核心逻辑不受影响。
先定义数据结构:
struct WeatherResponse: Decodable { let location: String let temperature: Double let condition: String let humidity: Int let updatedAt: Date }接着实现Skill本体。注意匹配逻辑是两个关键词分开写的,因为你永远不知道用户会说“今天天气how”还是“上海现在多少度”:
struct WeatherSkill: Skill { let id = "weather" let name = "天气查询" let description = "查询指定城市当前天气与温度" let tags = ["天气", "温度", "下雨", "气温", "晴", "雨"] func canHandle(_ message: String) -> Bool { let lower = message.lowercased() return lower.contains("天气") || lower.contains("温度") || lower.contains("气温") } func execute(context: SkillContext) async throws -> String { let city = extractCity(from: context.currentInput) ?? "北京" let url = URL(string: "https://api.example.com/weather?city=\(city)")! let (data, _) = try await URLSession.shared.data(from: url) let weather = try JSONDecoder().decode(WeatherResponse.self, from: data) return "\(weather.location)当前\(weather.condition),气温\(weather.temperature)°C,湿度\(weather.humidity)%" } private func extractCity(from input: String) -> String? { let candidates = ["北京", "上海", "广州", "深圳", "成都", "杭州"] return candidates.first { input.contains($0) } } }这个Skill本身没什么技术含量,但实现时我注意到两个现实问题:
一是城市匹配的候选列表写死在代码里终归不是办法,后续要改成动态配置或模糊匹配,但第一版先用白名单撑住主流程。二是接口没有做超时控制,极端网络下用户会一直等。系统层面对超时的兜底(见5.3节)能在一定程度上缓解这个问题,但Skill自己不该完全依赖上层。
4.3 注册、调试与验证流程
Skill写好后,注册就一行:
let registry = SkillRegistry() registry.register(WeatherSkill())但“注册完了能用”和“稳定可用”之间还差着调试和验证。这个环节我有一套固定流程,按顺序走,能省下大量返工时间:
第一步是单测Skill本身。构造几个典型输入,比如“北京天气怎么样”“上海现在几度”“随便聊聊”,分别验证正匹配、负匹配和边界输入。配合断言判断返回的字符串是否包含预期信息,跑一轮大概一分钟,比每次改完都启动模拟器人肉点一遍高效得多。
第二步是做集成验证。把Skill挂到Agent上,用命令行模拟用户对话流,检查多轮输入下的上下文是否正确传递。这个阶段最容易发现轮次之间的“失忆”问题。
第三步才是真机或模拟器验证UI表现。重点看状态切换是否顺滑、流式输出是否卡顿、错误提示是否友好。
这三步走完后还有最后一个“验收”节点:检查Skill的可替换性。把天气Skill从注册表移除,再接入一个查询天气的备用Skill,确认用户无感知,功能无缝切换。这一步验证的是整个架构的插件化能力——如果替换一个能力需要改动任何界面代码或Agent核心代码,说明耦合没拆干净,需要回归重构。
5. 常见问题与性能瓶颈排查
5.1 冷启动与技能加载体验
项目跑起来后,第一个被吐槽的问题就是“冷启动太慢”。技能加载其实并不慢,真正拖后腿的是首次启动时对全部Skill的静态扫描与preflight校验——每个Skill都会预检它依赖的配置、文件、网络可用性,这一整套跑完,UI已经卡了几百毫秒。
解决办法是把Skill的加载过程拆成两阶段:首屏只加载必需的核心Skill(比如对话处理、基础工具),其余Skill懒加载,等用户真正用到时再注入注册表。配置项齐全与否也不在启动时检查,而是在执行时超时兜底。
类似的道理在做任何Agent应用都适用:启动阶段只做“能开工”的最低准备,把耗时的预检和预热全部挪到后台或首次使用时。实测下来,冷启动速度从850ms降到280ms,体感差别非常大。
5.2 并发冲突与数据竞争
Swift并发在SwiftUI + Agent这套组合里最大的坑,我认为是跨Actor传递可变对象。核心模块虽然是值类型,但UI层绑定的是引用类型的ViewModel,一旦出现在后台执行完Skill、回主线程刷新UI的时序问题,崩溃信息往往让人摸不着头脑。
排查工具的优先级我排个序:首先打开Xcode的Main Thread Checker,并开启线程Sanitizer(TSan,线程一致性检查器,用于检测数据竞争和线程安全问题)。模拟器上复现不了的诡异问题,上真机加TSan跑一遍,多半能找到是哪个变量跨Actor写坏了。
经验之谈:尽量把所有跨线程数据集中在一个@MainActor的边界对象里做交换,不在视图里手动写DispatchQueue.main.async。该用actor的用actor,该标@MainActor的标@MainActor,编译器能帮你挡住的大部分问题,就不要留着到运行时。
5.3 Skill执行超时与失败兜底
任何一个真实系统里,第三方接口总会有抽风的时候。Skill框架里必须内置超时控制机制。我在ExecutionContext里塞了一个统一超时参数,默认按Skill类型区分,网络型Skill给8秒,本地计算型给2秒。实现方式是用Swift Concurrency里现成的超时机制,简单可靠:
func executeWithTimeout(skill: any Skill, context: SkillContext) async throws -> String { do { return try await withThrowingTaskGroup(of: String.self) { group in group.addTask { try await skill.execute(context: context) } group.addTask { try await Task.sleep(for: .seconds(context.constraints.timeout)) throw SkillError.timeout } let result = try await group.next()! group.cancelAll() return result } } catch { throw SkillError.timeout } }这里有一个重要的细节:超时后必须调用group.cancelAll()。如果不取消,后台Skill的网络请求会继续跑到完,占用线程和连接资源,在并发高的场景下会越积越多。踩过这个坑后,我在所有异步任务组里都养成了“拿完第一个结果立刻取消整个组”的习惯。
失败兜底的策略是分级的:第一级是重试一次(只对网络类错误),第二级是返回一个固定的兜底话术(告诉自己“没查到,换个问法试试”),第三级是记录日志并在界面上显示“技能执行失败,已反馈给开发者”。对用户展示永远不输出原始错误信息,这个原则从第一天就定了。
5.4 流式输出与UI渲染
如果你的Agent后端支持流式返回,UI上要运营的“小心机”就更多了。最核心的一点:不能把流式token直接append到SwiftUI的Text里,每来一个token就刷新一次View。性能会崩得非常快,尤其是内容一长,打字机效果直接变PPT。
我的方案是引入一个缓冲机制:每30毫秒批量刷新一次,而不是每token刷新一次。用Timer或者Task.sleep循环去冲缓冲池,效果实测比逐字刷新流畅太多。这个思路和前端框架批量更新DOM的原理是一样的,都是在“实时性”和“渲染性能”之间找平衡点。
final class StreamBuffer { private var pending = "" { didSet { lastFlush = Date() } } private var lastFlush = Date() private let interval: TimeInterval = 0.03 func append(_ chunk: String) -> String? { pending += chunk guard Date().timeIntervalSince(lastFlush) >= interval else { return nil } defer { let flushed = pending; pending = ""; return flushed } return pending } }更进一步的优化是,在大段文本流式输出的场景下,把最终完整内容一次性更新到消息数组里,但UI上只放大最后一条消息的渲染增量,前序消息不参与diff计算。SwiftUI的惰性容器在列表长的时候节省的开销非常可观。
流式输出的另一个易忽略点是被动追加与滚动到底部的配合。用户正在上翻历史消息时,自动滚动会打断阅读。务必要检查用户当前的滚动位置,如果用户不在底部附近,就暂停自动滚动,等用户回到底部再继续。这个小细节,很多商业App都没做对,用户体验差距一下子就拉开了。
6. 写在最后的几个实操心得
做这个项目最深的体会是:Agent系统的复杂度和Skill数量呈指数级上升,而不是线性上升。前面三个Skill时,怎么编排都不乱;到了十个Skill的时候,匹配、优先级、上下文管理的问题会集中爆发。所以如果你准备入坑这类架构,从第一天开始就要把协议和注册机制的路走对,别图快先写死,后面再回来重构——重构Agent的调度逻辑,比重构一个普通业务模块要痛得多,牵一发动全身。
另外一个小建议:Skill的命名和tags要认真规划。项目里前五个Skill的命名还算规矩,后面团队一大,新人随手加tags,匹配开始混乱。后来我定了一个规范:tags必须包含动作词、对象词和场景词三类,缺一不可。这套标准一直用到现在,效果稳定。
最后分享一个比较容易忽略但让用户产生好感的细节:给每个Skill加一个简短的中文名字,执行阶段在界面上展示“正在使用天气查询技能…”。用户其实不知道内部分了多少模块,但在等待期间看到这个提示,会明显更有耐心。这不算技术题,体验设计而已,但对Agent应用来说,值得用心。