作为常年做 SwiftUI 客户端的开发者,我最近在为自己一个内部项目设计 Agent 交互界面时,最大的感受是:Agent 的能力并不难接,难的是怎么让它变成一套能落地、能调试、能扩展的 UI 体系。后端把技能、工具、上下文都封装好了,但到了 SwiftUI 这一侧,如果只是简单地把返回文本塞进一个 ScrollView,那等待你的就是状态混乱、任务无法取消、日志无从查起。这个“SwiftUI-Agent-Skill”项目,就是我基于上述痛点整理出来的一套组件层思路——它负责把 Agent 的技能注册、执行管线、事件回传和 SwiftUI 视图状态映射成一套完整的工程结构。这篇文章不聊 Agent 算法本身,就聚焦在 SwiftUI 这一侧怎么承接 Agent 技能,并给出可以直接参考的模型设计和代码骨架。
我先说结论:Agent 技能要嵌入 SwiftUI,最重要的不是写多少个酷炫视图,而是先把技能执行体系和界面状态体系之间的边界划清楚。只有先定义好数据模型、事件协议、注册中心和执行管线,界面层才会真正轻松。下面我就按我自己实现时的顺序,从模型设计到视图层落地,再到并发处理和踩坑记录,完整过一遍。
1. 技能模型的边界:界面与 Agent 之间先签一份“协议”
很多 Agent 类项目一开始都长一个样:后端返回一大段 JSON 或 Markdown,前端拼命写各种解析器,今天匹配“工具调用”,明天匹配“思考过程”,后天又冒出一个新的返回结构。这种开发方式前期很爽,后期就是无底洞。我的建议是,把技能定义当成一份“协议”来做,界面和 Agent 都依赖它,各自实现互不关心。
1.1 从自由文本到结构化技能
Agent 的最终行为,本质上可以拆成一个个技能(Skill)。每个技能有名称、有参数、有执行入口、有结果类型。比如“获取天气”是一个技能,“创建待办”是另一个技能。如果 Agent 在对话里决定调用某个技能,那它给出的应该是一个结构化指令,而不是一段让人去猜的自然语言。
在 Swift 里,我先定义了这样一个核心类型:
struct AgentSkill: Identifiable { let id: String let name: String let summary: String let parameters: [SkillParameter] let execute: (SkillContext) async throws -> SkillResult }这个模型看着简单,但它承担了三件事:
id:SwiftUI 列表渲染、日志归集、事件追踪全都依赖它,必须稳定且全局唯一。parameters:是渲染技能表单的数据源,也是校验调用参数的数据源。execute:闭包作为执行入口,内部可以调用本地能力,也可以封装网络请求。闭包的好处是注册技能时非常灵活,不需要为每个技能建一个子类。
1.2 参数与结果都要可枚举
参数不能是一个松散的字典,否则 UI 没法自动构建表单。我定义了一个带类型信息的SkillParameter:
struct SkillParameter: Identifiable { enum ParameterType { case string case integer case boolean case date case options([String]) } let id: String let name: String let summary: String let type: ParameterType let isRequired: Bool let defaultValue: Any? }这里的关键点是:参数类型必须让界面能推断出控件。string对应文本框,boolean对应开关,options对应选择列表。这样技能面板不用每个技能手写一套自定义表单,而是根据参数描述自动生成。之所以强调这一点,是因为实际开发里技能数量会快速增长,手动维护表单的成本会非常高。
结果数据同样需要结构化:
enum SkillResult { case success(payload: Codable, message: String?) case failure(error: SkillFailure, message: String?) }payload给后续界面渲染提供结构化数据,message可以展示给用户。相比之下,如果只返回纯文本,界面根本不知道该怎么展示“结构化数据”部分。
提示:别小看
summary字段。它有两个用途:一是 UI 上作为技能的说明文字;二是未来如果接 LLM 做自动调用,这个字段可以作为提示词的一部分。我见过很多项目把描述信息写死在代码里,技能一多根本没法维护。把它放进模型,后面能省大量功夫。
2. 技能注册中心与执行管线:把调用链拆成可观测的步骤
有了技能模型,下一步就是注册与执行。我在项目里单独写了一个AgentSkillRegistry,所有技能都注册到这个中心里。界面只和注册中心打交道,不需要感知具体技能内部实现。
2.1 注册中心的基本能力
@MainActor final class AgentSkillRegistry { static let shared = AgentSkillRegistry() private(set) var skills: [String: AgentSkill] = [:] private var lock = NSLock() func register(_ skill: AgentSkill) { lock.lock() defer { lock.unlock() } skills[skill.id] = skill } func skill(for id: String) -> AgentSkill? { lock.lock() defer { lock.unlock() } return skills[id] } var allSkills: [AgentSkill] { lock.lock() defer { lock.unlock() } return Array(skills.values) } }这里我用了@MainActor,是因为注册和查询主要都在 UI 流程里发生。不过执行闭包本身是异步的,真正耗时操作可能发生在后台线程,所以闭包内部要用await去处理跨线程问题。
NSLock解决的是并发场景下字典的读写安全。虽然@MainActor隔离了大部分 UI 流程,但保不齐有后台分析任务也想查询技能列表,加把锁更稳妥。
2.2 执行管线:每一步都能发出事件
我踩过这样一个坑:早期版本的技能调用是直接在视图里await skill.execute(...),结果界面只能干等,用户不知道现在到底是在“解析参数”还是“调用远端接口”,一旦卡住,完全没法排查。
后来我把执行过程拆成了管线,每个阶段都产生一个事件:
enum AgentEvent { case skillStarted(skillID: String, parameterValues: [String: Any]) case skillFinished(skillID: String, result: SkillResult, duration: TimeInterval) case skillFailed(skillID: String, error: Error) case tokenProduced(skillID: String, text: String) case messageGenerated(message: String) }管线实现大致是这样:
struct SkillPipeline { var registry: AgentSkillRegistry func execute( skillID: String, parameterValues: [String: Any], eventSink: @escaping (AgentEvent) -> Void ) async throws -> SkillResult { guard let skill = registry.skill(for: skillID) else { throw SkillPipelineError.skillNotFound } // 参数校验 try validateParameters(skill: skill, parameterValues: parameterValues) eventSink(.skillStarted(skillID: skillID, parameterValues: parameterValues)) let startDate = Date() do { let context = SkillContext(parameters: parameterValues) let result = try await skill.execute(context) let duration = Date().timeIntervalSince(startDate) eventSink(.skillFinished(skillID: skillID, result: result, duration: duration)) return result } catch { eventSink(.skillFailed(skillID: skillID, error: error)) throw error } } }这个管线的价值体现在三处:
- 可控:每个阶段都能通知外面,UI 可以实时渲染进度。
- 可测:我可以只传一个模拟 Agent 技能,验证界面的状态变化,不需要依赖真实后端。
- 可观测:事件流就是天然日志,后面做时间线视图非常轻松。
2.3 参数校验与超时策略
参数校验这块,我建议至少支持“必填校验”和“类型校验”两个基础能力。以options类型为例,如果传入的值不在选项列表里,应该直接报错,而不是把脏数据传给执行闭包。
超时处理也很有必要。真实 Agent 推理常常会因为网络或外部服务问题而长时间无响应。我在管线上额外包了一层withTimeout:
func withTimeout<T>( _ seconds: TimeInterval, operation: @Sendable @escaping () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw SkillPipelineError.timeout } let result = try await group.next()! group.cancelAll() return result } }超时时间建议根据技能类型区分:本地文件操作可以给 10 秒;远端调用建议 30 秒到 60 秒。界面上也要给用户可感知的“加载中”状态,而不是一直转菊花。
注意:
withThrowingTaskGroup里抛超时错误后,别忘了group.cancelAll(),否则另一个子任务还会继续执行。这虽然不是致命问题,但会造成后台资源的浪费,也被我用日志抓到过好多次。
3. SwiftUI 界面层的落地细节:从事件流到可操作面板
模型和执行管线稳定之后,SwiftUI 界面层的工作就变成了一件事:把事件流映射成视图状态。我这里的核心原则是,界面不直接调技能执行闭包,而是通过一个 Store 统一接收事件并管理状态。
3.1 会话级 Store:状态唯一入口
我实现了一个AgentSessionStore,承担三件事:持有当前会话的消息列表、执行技能、把事件转换为状态变更。
@MainActor final class AgentSessionStore: ObservableObject { @Published var messages: [AgentMessage] = [] @Published var activeSkillID: String? @Published var skillLogs: [AgentEvent] = [] private var pipeline = SkillPipeline(registry: .shared) private var executionTask: Task<Void, Never>? func runSkill(_ skillID: String, parameterValues: [String: Any]) { executionTask?.cancel() executionTask = Task { do { activeSkillID = skillID let result = try await pipeline.execute( skillID: skillID, parameterValues: parameterValues, eventSink: { [weak self] event in Task { @MainActor in self?.handle(event: event) } } ) // 把结果追加为一条 assistant 消息 if case .success(let payload, let message) = result { let content = message ?? String(describing: payload) messages.append(AgentMessage(role: .assistant, content: content)) } } catch { // 错误也需要转成用户可读的消息 messages.append(AgentMessage(role: .assistant, content: "技能执行失败:\(error.localizedDescription)")) } activeSkillID = nil } } private func handle(event: AgentEvent) { skillLogs.append(event) // 根据事件类型做额外 UI 更新 } }有一个容易忽略的细节:eventSink可能来自任意线程,所以我在里面包了一层Task { @MainActor in ... },确保状态变更都在主线程完成。不这么做,你会看到 SwiftUI 偶发崩溃或者界面不刷新,而且极难复现。
3.2 流式输出与增量渲染
真实 Agent 的回复往往是流式的,用户会看到文字一个一个蹦出来。这里最自然的做法是:每次收到tokenProduced事件,更新最后一条 assistant 消息的文本。
private func appendToken(_ token: String) { if let last = messages.last, last.role == .assistant { var updated = last updated.content += token messages[messages.count - 1] = updated } else { messages.append(AgentMessage(role: .assistant, content: token)) } }这里有个性能经验:如果每次 token 都完整替换整个数组,列表数据不多时没问题;但当消息超过上百条时,SwiftUI 的 diff 计算成本会明显上升。我的做法是,对最后一条消息单独用@Published var currentOutput: String驱动一个独立视图,整条消息完成后再合并进messages数组。这样既保持流畅性,又不会让列表反复刷新。
3.3 技能面板与日志抽屉
技能面板的 UI 完全由AgentSkill.parameters驱动。我在视图里对每个SkillParameter做一个switch判断,生成对应的输入控件:
@ViewBuilder func parameterInput(_ parameter: SkillParameter) -> some View { switch parameter.type { case .string: TextField("请输入\(parameter.name)", text: binding(for: parameter)) case .boolean: Toggle(parameter.name, isOn: binding(for: parameter)) case .options(let options): Picker(parameter.name, selection: binding(for: parameter)) { ForEach(options, id: \.self) { option in Text(option).tag(option) } } case .integer, .date: TextField("请输入\(parameter.name)", text: binding(for: parameter)) } }日志抽屉则是把skillLogs按时间顺序展示出来,每条日志显示事件类型、技能 ID、耗时。它的作用是给开发调试用,我通常会做成一个可以滑动隐藏的面板,而不放进正常对话流。
4. 并发与生命周期:让 Task 与 SwiftUI 视图严格对齐
SwiftUI 里最容易出的问题就是:任务在视图出现时启动,视图销毁后任务还在跑。比如用户点了一个技能,感觉不对立刻切到别的页面,结果几秒后日志里还是在回调,甚至弹出一个已经被释放的绑定。处理这个问题没有奇技淫巧,就是让任务的取消时机和视图生命周期对齐。
4.1 在 View 里只负责绑定生命周期
我不建议在 View 的 body 里直接用Task { store.runSkill(...) },因为这样任务的生命周期是悬空的。SwiftUI 提供了.task修饰符,它会在视图消失时自动取消任务,但无法自动取消你手动创建的executionTask。
我最终采用的做法是:在视图中用onAppear创建消费任务,用onDisappear明确取消:
struct AgentSessionView: View { @StateObject var store = AgentSessionStore() var body: some View { VStack { messageList skillPanel logDrawer } .task { await store.consumeAgentOutput() } .onDisappear { store.cancelExecution() } } }cancelExecution()会取消当前执行中的技能任务。这样无论用户是返回上一级,还是切换 Tab,都不会再收到迟到的回调。
4.2 用 AsyncStream 接收 Agent 输出
事件回传除了闭包,更 Swift 的做法是用AsyncStream。Store 里可以维护一个流,执行管线向流里写事件,Store 在consumeAgentOutput里循环读取:
func consumeAgentOutput() async { for await event in pipeline.eventStream { handle(event: event) } }这个写法的好处是,事件读取是异步迭代的,天然支持取消。任务被取消时,for await会立刻退出,不会再有后续事件处理。
4.3 避免同一技能重复执行
我加了两个防护:一是执行前判断activeSkillID是否为同一个技能且非空;二是新任务启动时总是executionTask?.cancel(),保证同一时间只有一个技能在执行。
颗粒度再细一点,可以引入“会话级互斥”:如果用户正在看一个耗时较长的技能,其他技能面板置灰。这个交互细节能避免很多“我明明点了两次,怎么执行了两遍”的吐槽。
经验:在 Store 里维护一个
activeSkillID还有一个隐藏好处:技能面板可以用disabled(store.activeSkillID != nil)来禁用所有按钮。否则用户连点几次,后台请求会并发发出,最后界面上出现几条重复的 assistant 消息,观感非常差。
5. 实测中遇到的坑与最终建议
到这里,整体架构已经能跑通了。但真正让它变得好用,是在处理了下面几个问题之后。这些坑都很典型,我尽量把现象和原因都说清楚。
5.1 长文本流式输出导致视图重排卡顿
最开始我在messages数组里直接更新最后一条消息的字符串,每条 token 到达都会触发Text重新计算行高。当单条消息长度超过几千字时,iOS 18 上开始出现可感知的卡顿,帧数掉到 30 以下。
解决方式是把输出拆分:如果当前输出超过一定阈值,比如 2000 字符,就不再每一 token 都替换,而是每 0.5 秒批量更新一次。实现上用一个小型定时器,配合Task.sleep来做节流:
var pendingText = "" while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) if !pendingText.isEmpty { let textToAppend = pendingText pendingText = "" appendChunk(textToAppend) } }这算是一个“故意损失实时性换流畅性”的取舍。用户几乎感知不到延迟,但滚动画面的流畅度提升非常明显。
5.2 技能参数 ID 不稳定导致表单状态丢失
SwiftUI 的ForEach要求元素id稳定。我早期用参数名作为id,结果有两个技能都定义了名为query的参数,导致它们在同一个面板里渲染时状态互相串位,文本框的值会跳到另一个技能上。后来我把SkillParameter的id改成了"技能ID-参数名"这种复合形式,问题立刻消失。
这也是为什么要强调模型里必须有独立稳定的id。任何 UI 状态绑定都不应该依赖“显示名称”这种可能变化的信息。
5.3 视图消失后仍有弹窗提示
有一次用户执行一个长时间技能时直接关闭了当前页面,几秒后 App 却弹出了网络错误 Alert。原因就是execute闭包内部在抛出错误后,直接调用了某个全局单例的 UI 反馈方法。
我的处理很粗暴但有效:让 Store 持有“当前视图是否活跃”的状态,所有错误提示都先发到 Store,由 Store 判断是否还在当前会话中。如果视图已经不可见,就把错误记录到skillLogs,不做弹窗,等下次打开日志抽屉时能看到。
5.4 先做模拟 Agent 再做真实接入
如果你正准备做类似项目,我的建议是,不要一上来就接真实 Agent 服务。先在本地写一个MockAgentSkill,每 0.1 秒产生一个 token,模拟真实推理流程。这样你能把 UI 状态、取消逻辑、日志面板全部调顺,再接入真实服务时,只需要替换技能执行闭包即可,界面上不用改任何东西。
我在这个项目里就是这么推进的:前面两天全在搭模型、管线、Store 和模拟技能,真实服务接入只花了一个下午。而且模拟环境可以随意造出错场景,这对测试用户可见的错误文案特别有用。
5.5 最后分享一个“面板优先级”的设计心得
Agent 技能面板最容易越做越臃肿。每个技能都往面板里塞控件,最后用户根本不知道先点什么。我最后定下来的规则是:面板默认只显示最近使用过的 6 个技能,其余收进“全部技能”列表。并且根据参数数量决定展示形态——参数超过 3 个的技能,使用二级确认表单;参数少的技能,点一下就执行。
这个设计不是技术问题,但直接影响我们产品的可用性。技术架构搭好之后,这类交互取舍反而成了体验好坏的分水岭。
回到之前说的“协议”思路:整个 SwiftUI-Agent-Skill 组件层最核心的产出,不是某个华丽的聊天视图,而是那套稳定的技能模型、事件协议和执行管线。有了它们,无论以后接多少 Agent 能力,SwiftUI 这侧都只是增加技能注册和对应视图布局的问题,不需要推倒重来。如果你正在折腾同类项目,我建议先把数据模型和执行管线捣鼓明白,界面反而可以慢慢做。