从标题的三个词拆开看:SwiftUI、Agent、Skill。SwiftUI是苹果生态的声明式UI框架,Agent是当下最热的AI智能体概念,Skill则是智能体执行具体任务的能力单元。把这三个词拼在一起,就是一个很典型的项目形态:用SwiftUI做一个带AI智能体能力的应用,并给智能体挂载可扩展的技能包。这篇内容我打算完全围绕这个方向展开,把我自己从零搭这样一个项目的思路、代码和踩坑记录都写出来,希望能给正在做同类事情的朋友一个可参考的样本。
1. 先把Agent和Skill的关系理清楚:不是聊天框,是"会干活"的智能体
1.1 从"只会聊"到"真的能做事"
很多人在SwiftUI里接入大模型的方式,是做一个聊天界面:用户输入一句话,把这句话发给模型服务,再把返回的文字渲染到气泡里。这个流程能跑通,但它本质上只是一个"高级问答框",并没有解决实际问题。真正让大模型从"回答问题"变成"完成任务"的,是给它配上工具,也就是标题里那个Skill。
我自己的理解是这样的:Agent是大脑和调度中枢,Skill是它的手和脚。当用户在界面上说"帮我记一下明天上午十点开会"时,模型自己并不知道怎么往系统日历里写事件,但它可以通过Agent调用一个"日历写入Skill"来完成这个动作。用户看到的是智能体真的把事情办了,而不是只回了一句话"好的我记住了"但实际上什么都没发生。
这种设计思路在项目结构上非常清晰:Agent层只负责理解意图、决定调用哪个Skill、解析返回值;Skill层则是纯粹的函数式能力单元,每一个Skill只做一件具体的事,不关心对话上下文,不关心UI长什么样。
1.2 为什么把Skill设计成独立模块,而不是直接写进Agent里
这是我在项目里反复权衡过的一个决定。最早我图省事,把几个功能直接写成Agent内部的方法,比如addReminder()、createTimer(),全部写在同一个类里。结果不到两周就出问题了:新增一个技能要改Agent的代码,两个技能之间有公共逻辑时不知道放哪里,测试的时候也只能整体跑,没法单独验证某一个技能对不对。
后来我把Skill拆成了独立的协议和实现类,每个Skill都是一个独立的类型,有自己独立的输入、输出和错误处理。Agent只依赖协议,不依赖具体实现。这样改动一个技能不影响其他技能,新增技能也只是往注册表里多加一项。更重要的是,每个Skill可以单独写单元测试,这对项目的长期维护太关键了。
1.3 这个项目最终做成了什么样
我做的这个示例项目,是一个运行在iOS和macOS上的桌面助手应用。界面用SwiftUI搭建,左侧是对话记录,右侧是当前会话的详情或者技能触发面板。底层跑了一个轻量级的Agent循环,通过流式接口调用某个大模型服务。目前内置了四个Skill:
- 备忘录写入Skill:把用户的自然语言描述解析成结构化备忘,写入本地数据库。
- 定时器创建Skill:解析时间表达式,创建系统级定时器。
- 本地文件搜索Skill:在用户指定的目录里搜索文件名和内容。
- 天气查询Skill:调用一个公开的天气接口,把结果格式化后返回给Agent。
这四个技能覆盖了"信息记录、时间管理、本地检索、外部数据获取"几个典型方向,足够把Agent-Skill的完整链路跑通。下面我会把每个关键环节的代码和设计思路都拆开讲。
2. SwiftUI工程里的Agent基础设施:消息模型、状态管理与流式通道
2.1 消息模型是Agent和UI对话的"共同语言"
不管上层怎么折腾,Agent和UI之间交换的始终是消息。我定义了一个AgentMessage类型,用来承载整个对话过程中所有需要展示给用户的内容:
struct AgentMessage: Identifiable, Codable { enum Role: String, Codable { case user case assistant case tool } let id: UUID var role: Role var content: String var timestamp: Date var relatedToolCallID: UUID? var metadata: [String: String]? }这个结构比单纯的"用户消息/回复消息"多了一个tool角色。当Agent调用了一个Skill之后,Skill的执行结果会以一条tool角色的消息追加到对话里,ui层可以为这种消息渲染一个专门的卡片,比如"已创建定时器,时间:明天09:00"。
这里有一个经验:消息ID一定要稳定。SwiftUI的List在渲染大量消息时依赖Identifiable的ID来做diff,如果ID在刷新时发生变化,会导致列表整体重建,滚动位置丢失。我一开始用Date当ID,结果毫秒级重复直接造成了诡异的UI问题,后来全部改用UUID才稳定下来。
2.2 用ObservableObject集中管理Agent状态
SwiftUI的实时性靠ObservableObject协议撑起来,Agent状态集中在仓库里,让View可以精确、主动地观察变化:
@MainActor final class AgentStore: ObservableObject { @Published var messages: [AgentMessage] = [] @Published var isProcessing: Bool = false @Published var activeToolCallID: UUID? @Published var errorMessage: String? private var agent: Agent? func send(_ text: String) { messages.append(AgentMessage( role: .user, content: text, timestamp: Date() )) isProcessing = true Task { do { let reply = try await agent?.process(input: text) messages.append(reply) } catch { errorMessage = error.localizedDescription } isProcessing = false } } }注意这里使用了@MainActor。@Published属性必须在主线程更新,否则SwiftUI的发布机制会发出非主线程的更新,轻则UI刷新异常,重则直接crash。把整个Store声明为@MainActor,是避免"忘记切主线程"这种低级错误最省心的方式。
2.3 流式输出:用AsyncStream做Token级增量通道
大模型接口基本都是流式返回的,如果你等整个回复结束再一次性更新UI,用户会感觉这个应用"卡死了",尤其是一个长回答可能要等十几秒。所以Agent的process方法内部必须支持逐token推送更新。
这里我选用了AsyncStream。它是Swift原生异步序列,天然适合和AsyncSequence、for await配合:
struct AgentOutputStream { fileprivate var continuation: AsyncStream<String>.Continuation? mutating func makeStream() -> AsyncStream<String> { return AsyncStream { continuation in self.continuation = continuation } } func push(_ token: String) { continuation?.yield(token) } func finish() { continuation?.finish() } }在Store的send方法里,我会先拿到Agent返回的AsyncStream<String>,然后:
var stream = agent.makeOutputStream() Task { for await token in stream { // 追加到当前助手消息的content尾部 appendTokenToAssistantMessage(token) } }增量更新的时候要小心:直接修改messages数组里的最后一个元素的属性不会触发@Published的更新,因为AgentMessage是struct,你需要先取出、修改、再放回去。我在实际项目里写了一个小工具方法:
private func appendTokenToAssistantMessage(_ token: String) { guard let idx = messages.lastIndex(where: { $0.role == .assistant }) else { messages.append(AgentMessage(role: .assistant, content: token, timestamp: Date())) return } var current = messages[idx] current.content += token messages[idx] = current }3. Skill协议设计:让Agent真正"会做事"的那层核心代码
3.1 一个干净的Skill协议长什么样
Skill是整个项目的核心抽象。它的定义我反复改了很多版,最终收敛成一个非常精简的协议:
protocol AgentSkill { var name: String { get } var description: String { get } var parametersSchema: String { get } // JSON Schema格式的参数描述 var needsUserConfirmation: Bool { get } func execute(arguments: [String: Any]) async throws -> SkillResult } struct SkillResult { var success: Bool var summary: String var data: [String: Any]? }name是Agent分发给Skill的唯一标识;description用于在模型决定调用哪个技能时进行语义匹配;parametersSchema用JSON Schema描述这个Skill需要哪些参数、参数类型是什么,这决定了模型能不能正确地从用户话里抽出结构化参数。
needsUserConfirmation是我加的一个安全阀门。比如"删除文件"这种不可逆操作,Skill执行前必须弹窗让用户确认;而"查询天气"这种无副作用操作就不需要。这个字段会在Agent层被检查,为高风险操作增加一道防线。
3.2 从自然语言到结构化参数:模型负责"翻译",Skill负责"执行"
这里要重点讲一下Agent是怎么把用户的一句话变成Skill参数的,因为这是很多人理解Agent-Skill机制的卡点。
整体流程是这样的:我把所有已注册Skill的name、description、parametersSchema拼成一段系统提示词,随用户消息一起发给大模型。模型在理解用户意图后,如果判断需要执行某个Skill,就会返回一个结构化的工具调用指令,比如:
{ "type": "tool_call", "tool_name": "create_reminder", "arguments": { "title": "项目周会", "datetime": "2025-06-30T10:00:00", "location": "会议室A" } }Agent解析出这段指令后,从技能注册表中找到对应的Skill,把arguments原样传进去。Skill拿到的是已经被模型"翻译"好的结构化数据,自己不需要再做复杂的自然语言解析,只需要做数据校验和真正的业务操作。
这个分工让Skill的实现变得异常简单。写一个备忘录Skill,实际的工作量就是写一个createReminder(title:datetime:)函数,再加一层参数校验。
3.3 Skill注册表与参数校验:宁可前置失败,不要运行时炸
所有Skill都会注册到一个AgentSkillRegistry里:
final class AgentSkillRegistry { private var skills: [String: any AgentSkill] = [:] func register(_ skill: any AgentSkill) { skills[skill.name] = skill } func skill(named name: String) -> (any AgentSkill)? { return skills[name] } }在execute之前,我会用JSON Schema校验器检查模型传来的参数是否合法。比如create_reminder要求datetime必须是ISO8601格式的字符串,如果模型传了一个格式不对的值,校验会直接失败,并把这个错误信息返回给模型,让它重新构造参数。这一步非常有用,因为模型输出并不是百分百稳定的,偶尔会出现参数名对不上、类型填错、时间格式错乱的情况。
提示:参数校验失败不要直接扔给用户看。比较好的做法是把错误拼接到对话上下文里,让模型自己纠正后重新调用。这样用户感知到的结果是"这个助手能自我修正",体验会好很多。
我实际测试过,加了校验和"错误回灌"机制后,多轮工具调用的成功率从大约80%提升到了95%以上。剩下5%的失败,大多数是用户原始描述本身就模糊,比如只说"明天早上提醒我",没说具体几点,这种情况下模型确实没法做参数抽取。
4. Agent与SwiftUI视图层的桥接:让UI跟着技能调用实时走
4.1 为每种消息角色设计对应视图
SwiftUI部分,我给三种Role分别设计了渲染视图:
.user:右侧蓝色气泡。.assistant:左侧浅色气泡,支持多行文本,内部用Text加Markdown渲染。.tool:一个居中的小卡片,显示技能名称、执行摘要和状态图标。
使用@ViewBuilder做角色分派是比较标准的做法:
@ViewBuilder var messageBubble: some View { switch message.role { case .user: UserBubble(content: message.content) case .assistant: AssistantBubble(content: message.content) case .tool: ToolCallCard(message: message) } }ToolCallCard是我觉得整个UI层最有亮点的一个部件。它会在技能执行期间显示一个进度指示器,执行完成变成绿色的"已完成"标签,失败则变成红色的"失败"标签并显示错误摘要。因为Agent的目标最终是"干活",这个卡片让用户能清楚地看到"智能体正在使用哪个技能做哪件事",而不只是看到一串串的文字在跳动。这种透明度对建立用户信任非常重要。
4.2 技能调用事件的双向通道:从UI触发到Agent回调
除了用户在输入框里打字问答,我还设计了UI直接触发技能的面板。比如界面上有几个按钮——"新建备忘录""创建定时器""搜索文件",用户点击按钮后,不是直接把任务交给大模型处理,而是以"半成品参数"的方式填入输入框,附带一段提示词:"请帮我把这个备忘录保存下来:..."。
这样做的理由是:有些用户的意图非常明确,不需要让模型再去猜。UI先做好参数采集,模型只需要把参数重新组织并发起工具调用,可以减少中间出错的机会。我在macOS版本里把这个面板做成了右侧边栏,iOS版本则做成了输入框上方的横向滚动按钮区域,两种布局都很自然。
4.3 用AsyncStream驱动SwiftUI列表的平滑滚动
流式token驱动的最大坑是UI刷新频率太高。如果每个token到达都触发整个messages数组重新发布,SwiftUI会疯狂重算视图层级,performance会很差,还可能出现编辑器"Attempted to publish during view update"的警告。
我最终的方案是做了一个简单的节流器:token到达时先存进缓冲区,每50毫秒或者当缓冲区累计超过一定字符时,才统一发布一次更新。50毫秒对人眼来说是感觉不到延迟的,但对SwiftUI的渲染管线来说是足足的喘息空间:
final class TokenThrottle { private var buffer: String = "" private var lastFlushTime: Date = .distantPast let interval: TimeInterval = 0.05 func append(_ token: String) { buffer += token guard Date().timeIntervalSince(lastFlushTime) > interval else { return } flush() } func flush() { guard !buffer.isEmpty else { return } lastFlushTime = Date() let chunk = buffer buffer = "" // 发布chunk,追加到消息末尾 } }这个节流器是整个项目里收益最大的微优化之一。全量逐token发布时,一个500字的回答能触发几十次列表重建;加了节流之后,整个回答过程只触发大约10次左右,滚动流畅度质的飞跃。
5. 实测才遇到的坑:并发、生命周期与流式更新的战场记录
5.1 任务取消与视图销毁不同步:用户返回了,请求还在跑
SwiftUI视图是可以随时销毁的,但是一个已经开始的网络请求不会因为视图销毁而自动停止。我第一次做的时候,用户正在等一个比较长的回答,突然切换到了另一个Tab,结果Agent还在后台继续跑,等它执行完Skill往Store里写消息时,那个视图虽然已经不在屏幕上,但这些网络请求还是白白消耗了资源。
解决办法是引入一个任务管理器,跟随Agent的生命周期而不是视图的生命周期:
final class AgentTaskManager { private var tasks: [UUID: Task<Void, Never>] = [:] func launch(_ id: UUID, operation: @escaping () async -> Void) { let task = Task { await operation() tasks[id] = nil } tasks[id] = task } func cancelAll() { tasks.values.forEach { $0.cancel() } tasks.removeAll() } }在Agent被释放或者应用进入后台时调用cancelAll(),这样用户不管怎么切换界面,底层的请求都能被及时中止。这个模式在SwiftUI里尤其重要,因为SwiftUI的视图控制器生命周期比UIKit简单,也意味着它不会给你太多"手动管理"的钩子。
5.2 主线程地狱:异步回调更新UI导致的崩溃
这是我在项目早期遇到的最频发的崩溃源。Skill执行完之后的回调,来自网络的回调,甚至有些系统框架的回调,都可能处于任意线程。如果直接在回调里修改messages,会出现极其难查的并发问题——有时候能稳定复现,有时候过了很久才崩溃一次。
我从一开始就在AgentStore上标注了@MainActor,这帮我挡掉了大部分问题。但还需要提醒:如果你在Skill的实现里用了Task.detached做并行处理,记得回到主线程的方法只能是在Task { @MainActor in ... }块里包裹。Swift的Concurrency模型整体上对新手友好了很多,但"绕过"它的方式也很多,稍不小心就会写出一个在小并发下跑得飞起、大并发下就崩的App。
5.3 多Skill并行时的状态竞争
我在做一次升级时,允许Agent在单个回合里并行调用多个Skill。比如用户说"查一下北京的天气,同时搜索我本地关于'预算'的文件",Agent返回两个独立的tool_call指令。我最初的做法是并发执行两个Skill,然后把结果分别追加到消息列表。
问题来了:两个Skill的执行时间不一样,先返回的会先写入消息列表,导致消息顺序和模型规划的顺序不同。如果刚好这两个Skill调用了同一个上游服务或者因为有共享缓存而相互影响,结果会不可控。
最终我采取的策略是"并发执行,按序收集":用withTaskGroup并发跑所有Skill,但用一个字典暂存每个调用ID的结果,全部跑完了再把消息按原始顺序追加进去。这样既享受了并发加速,又保证了消息展示的顺序稳定。
5.4 断网与超时:Agent怎么面对不听话的网络
大模型服务会超时,天气接口会超时,本地文件搜索在超大目录上也会慢。如果你不给Agent设置超时,用户就会看到一个永远转圈的消息气泡,体验非常差。
我做的超时策略是分级控制的:
- 模型响应超时:15秒。超过直接报错,提示用户"网络似乎有点慢,请重试"。
- 单个Skill执行超时:10秒。Skill协议内部有独立超时,超时后返回失败结果,Agent继续运行,不会让整个会话卡死。
- 整体Agent回合超时:30秒。超过30秒强制终止,把已产生的部分结果展示给用户。
超时用Swift Concurrency里的withThrowingTaskGroup实现很方便,可以在小组件里对任务做时间限制。这个设计让应用在有网络波动的场景下依然能保持基本可用,而不是一遇到延迟就变成一个不可交互的白屏。
6. 进阶优化:让Agent-Skill架构走得更远
6.1 为Skill调用加"消费预算"护栏
大模型工具调用不是免费的,尤其是一个复杂的多轮任务,可能会在背景里悄悄产生好几次模型请求。如果控制不当,用户问一句简单的问题,可能触发三轮模型交互,账单会悄无声息地膨胀。
我给Agent加了一个"消费预算"机制:每一轮用户会话设定一个最大的模型调用次数,默认5次。当Agent需要递归调用Skill或者因为参数错误而自我修正时,每多一次的推理都算一次计划内开销。当调用次数快要触及上限时,Agent会主动停止,改为直接向用户询问更明确的需求,从源头上避免无意义的迭代。
这个机制上线之后,一个显著的变化是:因为参数错误被纠正的次数引发的额外推理被成功拦截在时限内,用户账单的波动明显变小了。如果你的项目面向大量用户分发,一定尽早引入类似机制。
6.2 技能白名单与权限边界:安全不是功能,是底线
大模型的工具调用能力越强,潜在风险就越大。一个Skill如果被模型错误调用——比如把"删除文件"误判为"归档文件"——后果可能不可挽回。所以我的每个Skill都在注册时声明自己的风险等级:
- 低风险:只读操作,如查询天气、搜索文件。
- 中风险:写入操作,如创建备忘录、设置定时器。
- 高风险:删除或不可逆操作,如删除文件、发送邮件。
Agent在执行前会检查风险等级,高风险Skill必须经过用户确认,确认弹窗上除了显示技能名称和参数预览,还要用清晰的语言解释"这个操作会导致什么后果"。这种设计的核心思想是:把最终的控制权保留给用户,Agent只是助手,不是权限所有者。
6.3 记忆上下文裁剪:别让会话无限膨胀
每个Skill执行结果都会作为工具消息传入下一轮模型请求,这是Agent能理解多步任务的关键。但如果会话足够长,累积的上下文会越来越大,既烧token,也会让模型"迷失重点"。
我的裁剪策略:
- 完整保留系统提示词、最近的N条用户消息和助手回复。
- 每轮Skill调用只保留最终的summary字段,丢弃详细data。
- 超过一定长度的历史摘要,用一句话概括后放入系统提示词。
这个策略让Agent在长会话中依然能保持对当前任务的聚焦,同时把token成本控制在可控范围。实际操作中,一个常见的App用户会话通常不会太长,但对那些把Agent当工作台重度使用的人来说,这个机制是刚需。
关于这个项目下一步,我个人的规划是把Skill协议的运行方式进一步扩展,比如让Skill支持多个平台,或者支持用户自定义技能。在做这套架构之前,说实话我也没有预料到把一个简单的"聊天App"重构为"Agent-Skill"架构会带来这么大的体验差异。现在回看最值得的一步,其实是把Skill认真当成一个边界清晰的模块来设计,而不是急着去堆功能。如果你正在做一个类似的项目,我建议你把这条原则也放在最前面:先把Agent和Skill的边界切干净,后面加多少个技能都是顺理成章的事。