做SwiftUI开发的人应该能感觉到这两年AI代理Agent相关的框架越来越多但大部分都是Web侧的工具真正落到苹果生态里、能顺手接进App的产品形态反而不多。前阵子我接了一个模拟项目X的活需求很简单给现有iOS App加一个能自动调用系统能力的代理入口比如帮用户查天气、设置提醒、拉取数据然后聊天气泡里直接展示结果。听起来不复杂但真动手做的时候才发现整套交互牵扯到技能注册、工具调用、流式输出、任务状态流转全塞在SwiftUI里会变得很难收拾。我把这套“SwiftUI-Agent-Skill”的完整实现思路和踩坑记录整理出来希望对正在做类似功能的同路人有点帮助。1. 整体设计思路为什么要把Agent Skill做成一套框架1.1 先想清楚你需要的是一套“技能系统”不是一堆页面一开始我也犯过这个毛病想着做一个聊天界面然后用一个大ViewModel把AI消息、用户消息、调用结果全塞进去。做了几版之后发现根本没法维护代理每次调用工具的流程都不一样有的技能是即时返回结果有的技能要用户先确认参数有的技能执行到一半会失败需要重新尝试。这些状态如果全靠if-else写在视图里SwiftUI的body会变成一堆嵌套分支改一行可能炸一片。后来我换了个思路把代理能做的每一件事都抽象成一个个“技能”Skill每个技能自带描述、参数规则、执行逻辑、返回结果。聊天界面只负责展示不感知具体的业务逻辑代理只负责调度技能也不管界面怎么画。这样整个系统变成了三层技能注册层、代理执行层、SwiftUI展示层。每一层只干自己的事接口对好之后可以分别测试。1.2 选型对比SwiftUI凭什么比UIKit更适合做代理交互层可能有人会问既然代理交互这么复杂直接用UIKit不是更容易控制吗表面上看UIKit的响应式更灵活但实际体验下来SwiftUI把数据和视图绑定的方式反而更适合代理这种“状态多、变化频繁”的场景。代理执行过程中状态会经历思考中、候选工具、工具执行中、流式输出、执行完成这么几个阶段。UIKit的做法通常是用delegate回调通知控制器刷新UI一旦回调多了控制器就成了“上帝对象”什么都要管。SwiftUI这边直接用Observable或者StateObject状态一变界面自动刷新省去了手动同步的环节。当然SwiftUI也有它麻烦的地方比如比较复杂的动画和嵌套滚动处理起来不如UIKit顺手。但就代理交互这个场景而言大部分界面都是聊天列表、状态卡片、进度指示器SwiftUI的声明式写法刚好是加分项。我最后的选择是核心逻辑用Swift实现成独立模块UI层全部走SwiftUI两者通过协议解耦这样即使以后UI要切换到别的方式核心层不用动。1.3 领域分层把技能注册、执行、展示拆成三件独立的事我最终确认的分层结构是这样的技能层AgentSkill协议每个技能对象持有标识符、能力描述、参数格式和执行方法。代理层AgentRuntime负责对话循环、意图判断、技能选择、调用执行、异常恢复。展示层SwiftUI View负责聊天布局、气泡渲染、工具调用卡片、状态动画。这三层之间用接口通讯。技能层不知道界面的存在代理层也不直接持有任何View。展示层通过观察代理层的可观察对象来刷新自己。这样的好处是我可以在没有界面的情况下先完整测试整个代理链路比如用命令行程调试代理调度逻辑最后再接UI效率高很多。2. 技能核心框架实现协议、注册表与运行时2.1 Skill协议怎么设计才够灵活技能协议是整个系统的地基所以一开始就要克制不要放太多东西。我的做法是只定义必要的能力剩下的事情交给协议扩展和具体实现。public protocol AgentSkill { var identifier: String { get } var displayName: String { get } var description: String { get } var parameterSchema: SkillParameterSchema { get } func execute( parameters: [String: Any], context: SkillExecutionContext ) async throws - SkillResult }identifier是全局唯一的技能标识代理会用它来匹配意图displayName和description是给模型看的帮助模型判断何时该调用这个技能parameterSchema定义了参数结构限定代理只能传约定的字段。execute方法是实际干活的入口参数是一个字典返回值是SkillResult。SkillResult我设计成了枚举而不是一个简单的字符串public enum SkillResult { case text(String) case structured([String: Any]) case attachment(SkillAttachment) }为什么要做成枚举因为不同技能的返回形态差别很大。查天气可能返回一段文本拉取日程可能返回结构化数据需要界面用列表渲染下载文件可能返回附件。如果全塞进一个字符串里展示层会很费劲。上下文对象SkillExecutionContext主要携带一些环境信息比如当前用户标识、会话ID、取消令牌等。技能执行的时候可能需要这些信息来做权限判断或日志记录。2.2 技能注册中心用Observable管理全局技能表所有技能不能散落各处需要一个统一的注册中心来管理。这里用Swift 5.9之后新增的Observable宏非常合适。MainActor Observable public final class SkillRegistry { public private(set) var skills: [String: any AgentSkill] [:] public func register(_ skill: any AgentSkill) { skills[skill.identifier] skill } public func register(_ skills: [any AgentSkill]) { skills.forEach { register($0) } } public func skill(for identifier: String) - (any AgentSkill)? { skills[identifier] } public func execute( _ identifier: String, parameters: [String: Any], context: SkillExecutionContext ) async throws - SkillResult { guard let skill skills[identifier] else { throw AgentSkillError.skillNotFound(identifier: identifier) } return try await skill.execute(parameters: parameters, context: context) } }MainActor标注是因为技能注册表会被界面直接读取要确保线程安全。技能的execute方法本身不限定在主线程执行所以耗时任务不会卡UI。注册表不只是存储工具它还承担了一个职责给模型提供“技能清单”。每次代理要决定调用哪个技能之前会先从注册表取出所有技能的描述文本拼接成提示词的一部分。所以技能的description字段不能随便写要尽量说清楚“这个技能是干什么的、什么时候该用、什么时候不该用”。2.3 参数Schema与调用校验的实践参数Schema是纯数据。我用了一个轻量结构体来描述public struct SkillParameterSchema { public struct Field { public let name: String public let type: SkillParameterType public let required: Bool public let description: String public init(name: String, type: SkillParameterType, required: Bool, description: String) { self.name type self.required required self.description description } } public let fields: [Field] public init(fields: [Field]) { self.fields fields } } public enum SkillParameterType: String { case string case integer case boolean case number case array case object }参数Schema有两个用途。第一个用途是给模型参考由代理层把它转成JSON格式的提示词第二个用途是做运行时校验防止代理传了错误类型的参数进去。校验逻辑我写成了一个独立方法func validateParameters(_ parameters: [String: Any], against schema: SkillParameterSchema) throws { var errors: [String] [] for field in schema.fields where field.required { if parameters[field.name] nil { errors.append(缺少必填参数: \(field.name)) } } for (key, value) in parameters { guard let field schema.fields.first(where: { $0.name key }) else { continue } if !typeMatches(value, field.type) { errors.append(参数 \(key) 的类型不匹配期望 \(field.type.rawValue)) } } if !errors.isEmpty { throw AgentSkillError.invalidParameters(details: errors) } }实际测试下来模型经常会“自作聪明”地补一些缺省参数或者多加字段。加了校验之后错误会提前暴露比技能执行到一半才发现参数不对要友好得多。3. SwiftUI交互层实战从聊天气泡到工具调用卡片3.1 消息模型设计别把展示和业务耦合聊天界面的数据模型很容易设计成“文本消息”一种形态但代理场景下消息类型其实是多样的。我的做法是用一个带枚举的模型区分不同的消息来源和渲染方式。public struct ChatMessage: Identifiable, Equatable { public let id: UUID public let role: ChatMessageRole public let createdAt: Date public var content: ChatMessageContent } public enum ChatMessageRole { case user case assistant case system } public enum ChatMessageContent: Equatable { case text(String) case toolCall(ToolCallPresentation) case result(ToolResultPresentation) }ToolCallPresentation对应的是代理决定调用某个技能的那一瞬间界面要展示一张卡片上面写着“正在调用天气查询”附上参数摘要下方会进入执行中的动画状态。ToolResultPresentation是技能执行完毕后的展示卡片可以折叠、可以重新执行。把业务逻辑和展示做进同一个枚举听起来有点耦合但实际用下来好处很明显每条消息都可以单独定义自己的视图和交互而不必在父视图里做大规模switch。父视图只需要一个简单的条件渲染switch message.content { case .text(let text): MessageBubbleView(text: text) case .toolCall(let call): ToolCallCardView(call: call) case .result(let result): ToolResultCardView(result: result) }3.2 流式输出的打字机效果实现代理的回复如果是走流式API返回的界面上得有一个逐字出现的打字机效果。SwiftUI要实现这个效果核心是拿一个AsyncStream接收增量文本然后逐步追加到一个状态变量里。Observable final class StreamedTextViewModel { var displayedText: String private var streamTask: TaskVoid, Never? func startStream(_ stream: AsyncStreamString) { streamTask?.cancel() displayedText streamTask Task { [weak self] in for await chunk in stream { guard !Task.isCancelled else { break } self?.displayedText.append(chunk) await Task.yield() } } } func stopStream() { streamTask?.cancel() streamTask nil } }这里有个细节值得注意每次追加文本之后调用了Task.yield()。这不是多余的因为如果每个chunk到达后都不让出线程SwiftUI在刷新大量文本的时候可能会造成界面卡顿特别是在文本特别长的时候。Task.yield()让主线程有机会处理其他事件打字机效果会平滑很多。一个经验是流式输出不要逐字更新最好做一个行缓冲或者时间切片每20到30毫秒批量追加一段文本。这样既保留了打字机的视觉反馈又不会把主线程的性能吃干榨净。3.3 工具调用卡片的状态驱动工具调用卡片是整个交互层里最复杂的一块。它至少要有三种状态待确认、执行中、成功/失败。待确认状态出现在某些危险或高成本的技能里比如要发起支付、删除数据不能直接执行必须让用户点一下确认。我通过一个类型来承载整个卡片的状态enum ToolCallState: Equatable { case pendingConfirmation case running case succeeded(SkillResult) case failed(String) }卡片的视图会根据状态切换显示。pendingConfirmation时展示参数摘要和确认、取消按钮running时展示一个ProgressView和技能名称执行时间超过某个阈值还会显示“正在等待外部响应”之类的副文案succeeded时显示结果内容并允许用户点击结果跳转到相关页面failed时展示错误信息和重试按钮。SwiftUI里做这类多状态切换最省心的方式是用一个子视图把状态作为输入参数内部再做switch。父视图不需要关心卡片内部怎么渲染只需要把ToolCallState传进去就行。4. 状态管理与并发代理执行管线的完整闭环4.1 代理状态机设计从思考到技能调用的流转代理执行不是一个只调用一次技能就结束的事情。理想情况下代理会先分析用户意图决定要不要调用工具拿到工具结果之后再组织语言回复。这个过程需要有一个状态机来约束。我的状态定义是这样的enum AgentState: Equatable { case idle case thinking case selectingSkill case executingSkill(String) case streaming case completed case failed(String) }idle表示代理空闲可以接收新消息thinking是模型正在思考界面上可以出一个转圈的等待气泡selectingSkill是代理已经决定要调用工具但还没开始执行executingSkill对应正在执行某个具体技能streaming是模型正在生成回复文本completed表示整轮对话结束。状态机的价值是让界面层永远知道当前该画什么。比如thinking的时候聊天列表底部滚动出一个“正在思考...”的气泡streaming的时候同样位置变成打字机文本。有了状态枚举界面的条件渲染会清晰很多。4.2 用AsyncStream接管增量输出代理执行过程中的增量信息很多模型思考过程的局部结果、工具调用的状态变更、最终回复的文本片段。如果用传统的闭包回调代码会变得很碎。我选择把所有增量都统一包装成事件流。enum AgentEvent { case stateChanged(AgentState) case skillSelected(SkillSelection) case skillOutput(String) case textDelta(String) case finished(ChatMessage) case failed(AgentError) } typealias AgentEventStream AsyncStreamAgentEvent代理执行方法返回的是一个事件流UI层直接订阅func run(input: String, in context: AgentChatContext) - AgentEventStream { AsyncStream { continuation in let task Task { await self.executePipeline(input: input, context: context, emitter: continuation) } continuation.onTermination { _ in task.cancel() } } }这里把Task的取消和事件流的终止绑定在一起UI层在视图消失的时候只要把订阅取消整个代理执行任务也会随之取消。这个设计在SwiftUI的视图生命周期里特别好用因为SwiftUI的视图经常因为滚动、切后台而重新创建如果任务不跟着取消很容易出现泄漏和重复执行。4.3 会话持久化内存、文件还是数据库代理会话如果只停留在一轮对话不持久化那实现很简单。但只要多轮对话一上就会发现上下文管理是个大坑。最朴素的方案是把所有历史消息全部塞进模型上下文字段里但这样既费token又会让模型在长对话里“失忆”。我的做法是做一个两层存储内存层用一个[UUID: ChatMessage]字典保存当前会话的所有消息UI层直接读取。持久化层把会话整体编码存到本地文件以会话ID为文件名下次启动时读回。要不要上数据库如果用户的会话量不大单机App用文件就行。如果会话数量很多或者需要全文搜索历史会话可以考虑SQLite。核心理由是代理聊天记录本质上是顺序增长的文档流文件顺序写入、整体读取的效率远高于数据库的多表查询。持久化时机我选择在每条消息追加后的300毫秒之后统一落盘避免每条消息都触发一次磁盘写入造成不必要的性能损耗。5. 常见问题与调试实录我踩过的那些坑5.1 主线程卡死与任务抢占问题技能执行如果写得不小心很容易把耗时操作直接扔在主线程上。比如我早期做数据抓取技能的时候直接在execute方法里同步请求网络结果一调用就卡UI。后来我把所有技能的执行函数标注为async throws内部如果需要走网络或者本地大计算就明确放到后台任务里func execute(parameters: [String: Any], context: SkillExecutionContext) async throws - SkillResult { let data try await Task.detached(priority: .userInitiated) { // 放网络请求或耗时计算 return try await NetworkService.fetchData(...) }.value return .structured([data: data]) }这里用Task.detached而不是简单的async let是因为我明确要脱离当前执行上下文。不过谨慎起见detached任务里捕获参数时要小心循环引用问题。还有一个容易忽略的坑多个技能同时被触发时如果它们共享同一个资源对象需要做并发保护。我在注册表加了一个简单的并发控制每个技能执行期间同一技能标识只允许一个执行实例存在避免重入。5.2 技能重复触发的重入保护有一次实测的时候发现代理因为模型输出不稳定同一个技能在一轮回复里被调了三遍比如查天气被连续调了三次每次参数一样结果也一样。这不仅浪费调用还会在界面上堆出三张结果卡片。解决办法是在事件流里做去重记录当前轮次已经执行过的技能标识和参数指纹如果模型再次选中完全相同的技能和参数直接跳过执行复用上一步的结果。struct SkillInvocationKey: Hashable { let skillIdentifier: String let parameterFingerprint: String }参数指纹用参数的排序后的键值对拼接出来这样一组参数可以稳定映射到同一个指纹。这个去重逻辑让结果卡片干净了很多同时也在提示词里加了约束“每个技能在一轮回复中最多调用一次”。5.3 SwiftUI预览与异步链路调试SwiftUI预览有时是可靠的但代理执行链路因为涉及网络请求和状态变化在预览里几乎跑不出真实效果。我的做法是把UI层和核心层分开调试。核心层的调试完全不用SwiftUI直接写一个命令行工具用真实的用户输入跑代理管线打印事件流。这样能快速验证意图识别是否准确、技能参数校验是否通过、异常分支有没有正确处理。UI层则用Mock数据来预览。比如准备几个固定的ChatMessage数组覆盖文本消息、工具调用卡片、失败卡片、流式输出中等等场景然后预览这些数组在不同状态下的渲染效果。#Preview(工具调用中) { ToolCallCardView(call: ToolCallPresentation( skillId: weather, displayName: 查询天气, parameters: [city: 广州], state: .running )) }Mock数据能保证界面的各种形态都能被单独验证不依赖真实的代理调度。5.4 技能执行超时与失败重试代理技能执行没法保证每次都成功外部网络不稳定、第三方接口限流、本地文件权限变化都会导致失败。我一开始只是把错误信息展示在卡片上后来发现光展示不够用户会希望重试。重试逻辑我放在卡片层失败状态下面显示一个“重新执行”按钮点击之后重新调用注册表里的execute方法。但重试不能无限点我加了一个简单的退避策略最多重试三次三次都失败就给出明确的错误提示并建议用户换一种说法重新提问。超时控制也很关键。所有技能执行都走一个统一的超时包装func withTimeoutT(_ operation: escaping () async throws - T, seconds: TimeInterval) 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 AgentSkillError.timeout(seconds: seconds) } let result try await group.next()! group.cancelAll() return result } }默认超时时间设为15秒个别需要长时间处理的技能可以在实现协议时自行覆盖这个值。超时之后除了抛错还要确保对应的底层任务被取消比如网络请求走的是URLSession的话可以直接通过取消Task来释放连接。6. 性能优化与后续扩展6.1 减少不必要的界面刷新SwiftUI的观察机制很方便但用得不小心会导致大范围视图重绘。工具卡片在running状态下的ProgressView动画会带动整个父视图频繁刷新。我的经验是尽量把频繁变化的子视图包在独立的、小而专的View里并且用equatable修饰符来避免无关状态变化引发的重绘。struct ToolRunningProgressView: View, Equatable { let skillDisplayName: String var body: some View { HStack(spacing: 12) { ProgressView() Text(正在执行\(skillDisplayName)...) } .font(.callout) } }另外代理状态机的状态变化不一定要完全绑定到整个聊天页面。比如executingSkill状态我只需要让正在变化的那个工具卡片感知而不是让所有历史消息都跟着重排。这需要在数据模型设计阶段就把消息节点独立。6.2 让技能注册可扩展、可组合技能系统做起来之后最爽的一点是新增技能超级方便。比如新加一个“日程安排”能力只需要新建一个遵守AgentSkill协议的对象注册到SkillRegistry里代理就能自动识别并调用它。为了让技能能够组合出更复杂的能力我后来引入了技能依赖的概念。比如“出差行程助手”这个技能内部可能会依赖“航班查询”和“酒店预订”两个子技能。SkillExecutionContext里增加了一个辅助的调度器子技能可以互相调用。public struct SkillExecutionContext { public let userID: String public let sessionID: String public let skillRunner: AgentSkillRunner public init(userID: String, sessionID: String, skillRunner: AgentSkillRunner) { self.userID userID self.sessionID sessionID self.skillRunner skillRunner } }这样技能之间的复用程度一下子提高了代理不只是在“一个技能对应一件事”而是可以编排多个技能完成更复杂的任务。6.3 后续可以扩展的几个方向这套系统跑通之后我计划在几个方向上继续扩展。首先是技能结果的可视化模板目前结构化数据只能以列表形式展示下一步想做成组件化卡片让不同的技能返回结果时直接映射到富交互视图。其次是多代理并行协作现在还是一个代理串行执行技能未来可以在一个会话里让多个代理角色各自维护上下文并共享一个技能系统。再次是把技能执行日志做得更细致比如记录每次调用的耗时、参数大小、失败原因方便做监控和回归测试。就个人体验而言把这套技能系统搭好之后后续接新功能的速度确实提升了很多但过程中也会不断遇到边界问题每一次调试失败都让我对代理交互的理解更实际一点。如果你也在做类似的东西希望这篇整理能让你少走一些弯路。