SwiftUI-Agent-Skill:把智能体拆成可插拔技能的架构实践

发布时间:2026/10/14 8:02:23
SwiftUI-Agent-Skill:把智能体拆成可插拔技能的架构实践
1. 为什么我决定把 Agent 拆成一套可插拔的“技能”1.1 直接在 SwiftUI 视图里调模型代码会快速失控最近在做基于 SwiftUI 的跨平台小工具时遇到了一个很典型的问题AI 能力看起来很好接但接进去之后代码会以惊人的速度腐化。最开始的写法非常直接在视图的.task里发起请求把返回文本塞进一个State var response: String界面就更新了。Demo 阶段一切正常可一旦加上多轮上下文、工具调用、流式输出、中断恢复这些真实需求视图层就变成了一锅粥。我把这种过程称为“硬编码 Agent”。也就是把智能体对话逻辑和 SwiftUI 视图生命周期绑在一起视图拥有消息数组视图决定什么时候调模型视图处理工具调用结果视图负责错误和重试。表面上每个文件都能跑但跨页面共享同一段会话时要么复制状态机要么引入各种全局单例。更麻烦的是只要你想再加一个技能比如让 Agent 能查日程、写纪要、改偏好就必须回视图里改那个越来越长的 switch-case。所以我把这套实践命名为SwiftUI-Agent-Skill它不是某个现成框架的名字而是一套自己在项目中总结出来的结构Agent 是持有上下文、负责决策的执行者Skill 是一段可独立注册、可动态装配的能力单元SwiftUI 只负责展示状态和发送用户意图。把这三者拆开之后我发现自己终于能正常扩展 AI 功能了。1.2 我的设计目标Agent 与 Skill 分离状态与界面分离拆解之后我的核心原则是两条Agent 不直接认识 SwiftUISkill 不直接认识 Agent 的调用方。所有交互都通过事件流完成。打个比方。Agent 像是一个厨房Skill 是厨房里的一份份菜谱。客人并不会走进后厨去翻菜谱他只会对前台服务员说“我想要一份番茄炒蛋”。服务员把需求传给厨房厨师根据菜谱执行最后把菜端出来。如果今天要新增一道菜厨师只需要往菜谱架里放一份新菜谱不需要重新培训整个厨房。SwiftUI 在这个比喻里就是前台服务员和餐桌它负责把用户的话转成订单再把菜端上桌。在这个前提下我把状态分成三层视图状态滚动位置、输入框草稿、当前选中技能会话状态消息列表、当前智能体运行阶段、待处理事件技能执行状态哪个技能正在运行、进度如何、输出事件怎么回传。分层让多窗口、多标签页共享会话时不用再头疼。比如 iPad 上左边是对话列表、右边是技能运行详情两个视图订阅同一个会话状态但各自只读自己关注的那部分互不干扰。这也是后面所有代码设计的出发点。2. 核心架构SwiftUI-Agent-Skill 的整体数据流2.1 AgentRuntime 是中央调度器整套结构里最核心的对象是AgentRuntime。所有用户输入先进入它所有技能执行结果也通过它出来。视图不会直接持有技能实例更不会直接调用某一个技能方法。Runtime 负责三件事意图匹配、技能分发、事件广播。先看最基础的骨架final class AgentRuntime { var skills: [AgentSkill] [] var session: AgentSession var events: AsyncStreamAgentEvent { _events.eraseToAnyPublisher() } private var _events PassthroughSubjectAgentEvent, Never() func submit(userInput: String) { // 1. 把输入写入会话 // 2. 匹配技能 // 3. 执行技能或调用模型 // 4. 把进度作为事件发出去 } }我建议不要让视图直接持有AgentRuntime而是通过一个Observable的AgentStore来包装。视图从 Store 里读状态并向 Store 发送用户指令。Store 内部调用 Runtime再把 Runtime 发出的事件转为自己的状态变化。这样即使以后把 Runtime 替换成别的实现视图代码也不会大改。2.2 技能调用的三个关键节点匹配、执行、回写状态一次完整的技能调用可以分为三个节点。首先是匹配。用户输入“帮我总结这段会议记录的要点”Runtime 不能盲目调用所有技能它需要根据技能的描述和参数 schema 判断哪个技能最合适。这个判断可以用模型自动完成也可以用本地规则完成。我的建议是第一阶段先做一个简单的本地匹配把命中率高的技能排到前面之后再交给大模型做最终确认。这样既省 token又可控。其次是执行。匹配到技能后Runtime 会把当前会话上下文、参数列表、可用的服务依赖注入到一个SkillContext里。技能在这个上下文里执行它不知道 SwiftUI 的存在也不知道消息列表长什么样它只负责“接收输入、产出事件”。比如一个技能可以把一段文本转成结构化纪要另一个技能可以发起一次搜索请求。最后是回写状态。这是最容易被忽略的一环。技能执行完不能直接修改用户界面上的任何东西也不能直接把结果塞进消息数组。技能应该只返回类似.toolResult(summary)的事件由 Runtime 统一解析再合成新的会话状态。这样才能保证界面的每次变化都能追溯到某个系统事件方便测试和调试。2.3 用 AsyncStream 把技能输出变成可见的流式事件技能返回类型我用的是AsyncStreamAgentEvent而不是简单的回调或闭包。原因是流式事件非常自然大模型的输出是一个字一个字冒出来的工具调用也有开始、进行、结束几个阶段这些都可以通过AsyncStream一点一点发给外部订阅者。func execute(context: SkillContext) - AsyncStreamAgentEvent { AsyncStream { continuation in Task { do { continuation.yield(.progress(0.1)) let result try await service.process(input: context.input) continuation.yield(.progress(1.0)) continuation.yield(.toolResult(result)) } catch { continuation.yield(.failed(error.localizedDescription)) } continuation.finish() } } }这里要特别注意continuation.finish()。如果某个技能因为外部 API 异常而没有正常结束整个事件流就会挂在那里界面上就会出现“正在加载”但永远转不完的尴尬。后面我会单独讲我在超时处理上踩的坑。3. 技能协议设计如何让新技能“插上就能用”3.1 协议定义、元信息与参数描述要让一个技能“插上就能用”协议定义必须包含两类信息一类是描述性的一类是可执行性的。描述性信息包括技能 id、名称、描述、参数 schema可执行性信息就是execute方法。protocol AgentSkill { var id: String { get } var name: String { get } var description: String { get } var parameterSchema: JSONSchema? { get } func execute(context: SkillContext) - AsyncStreamAgentEvent }parameterSchema是一开始容易忽略的地方。不要小看这个 schema它决定了模型或调度器怎么把自然语言转成参数。比如“帮我总结这段会议记录里的行动项”模型需要知道rawText这个参数代表什么、是必需还是可选。有了 schema运行时可以对参数做校验界面也可以自动生成表单甚至在技能执行前让用户确认参数是否正确。description也至关重要。这个描述不是写给人看的而是写给意图匹配器或大模型看的。如果描述写得太模糊Agent 会不知道什么时候该用这个技能写得太具体又容易在相似场景里互相冲突。我的经验是描述里要包含“触发条件 输入要求 产出结果”比如“当用户提供一段会议记录并要求提炼行动项时使用输入为原始会议文本输出为结构化行动项列表。”3.2 技能注册表与冲突处理技能注册表本质上就是一个字典[String: any AgentSkill]。注册时用技能的id作为 key覆盖或拒绝由开发者自己决定。我更倾向于拒绝重复注册并在日志里明确报错因为静默覆盖很容易把线上环境搞挂。冲突处理是比较麻烦的。假如有两个技能一个叫“日程查询”一个叫“日历操作”两者的描述都可能被“帮我看看明天下午有没有空”这条输入命中。我的做法是让每个技能带一个score()方法实现简单的关键词权重匹配func score(userInput: String) - Double { let keywords [会议, 日程, 日历] return keywords.reduce(0) { $0 (userInput.contains($1) ? 1 : 0) } }更复杂的场景可以交给大模型做工具选择但本地打分仍然有价值它可以作为候选列表的初筛。我的实践是先本地打分排序把 top3 作为候选传给模型让模型做最终决定。这比每次把所有技能描述全发给模型更省 token也能减少技能数量增多后的选择漂移问题。3.3 依赖注入不直接写具体业务 API技能实现里有一个很常见的错误直接使用全局单例或者直接构造具体服务。比如在技能里写CalendarService.shared.query(...)看起来没问题但测试这个技能时就麻烦了——你没法替换成一个 mock 服务。我的做法是初始化 Runtime 时统一注入依赖struct AppDependencies { let meetingService: MeetingServicing let calendarService: CalendarServicing } let dependencies AppDependencies( meetingService: RealMeetingService(), calendarService: RealCalendarService() ) let runtime AgentRuntime(dependencies: dependencies)技能要么通过SkillContext拿到这些服务要么在注册时通过闭包捕获。我更推荐前者因为SkillContext是每次执行都会创建的天然隔离不会出现技能之间的状态污染。测试时只要创建一个带 mock 依赖的 Runtime再执行技能断言事件流是否符合预期。3.4 一个完整的“会议纪要”技能示例我拿一个最简单的技能举例完整代码大概长这样struct MeetingNotesSkill: AgentSkill { let id meeting_notes let name 会议纪要 let description 当用户提供会议记录文本并要求整理结构时使用 let parameterSchema JSONSchema( type: .object, properties: [rawText: .string(description: 原始会议文本)], required: [rawText] ) let summarizer: Summarizing func execute(context: SkillContext) - AsyncStreamAgentEvent { AsyncStream { continuation in guard let rawText context.arguments[rawText] as? String, !rawText.isEmpty else { continuation.yield(.failed(缺少参数 rawText)) continuation.finish() return } Task { do { continuation.yield(.progress(0.2)) let markdown try await summarizer.summarizeToMarkdown(rawText) continuation.yield(.progress(1.0)) continuation.yield(.toolResult(markdown)) } catch { continuation.yield(.failed(error.localizedDescription)) } continuation.finish() } } } }这段代码里没有任何 UIKit 或 SwiftUI 的痕迹。summarizer是一个抽象的Summarizing协议真实实现可以调大模型也可以调本地 CoreML测试时换成一个假实现即可。这样一个技能注册进去之后无论上层是 iOS、macOS 还是 watchOS都能直接复用。4. SwiftUI 端如何与 Agent 状态保持同频4.1 用 Observable 管理会话状态而不是堆在视图里SwiftUI 最擅长的是状态驱动界面所以我把会话状态抽成一个独立的Observable类Observable final class AgentSession { var messages: [ChatMessage] [] var phase: AgentPhase .idle var activeSkillID: String? var eventTimeline: [AgentEvent] [] }视图里只需要持有这个session和一个负责“向 Runtime 发消息”的AgentStore。不要在视图的 body 里写大模型调用代码所有网络请求都放在 Store 里发起。这样做的好处是即使用户在这个页面点了发送按钮然后立刻切到另一个 Tab会话仍然在同一个 Store 里继续运行不会被视图销毁打断。Store 大致是这样的结构MainActor Observable final class AgentStore { private let runtime: AgentRuntime var session AgentSession() func sendMessage(_ text: String) { runtime.submit(userInput: text) } }Store 可以是全局单例也可以由 App 根视图创建并通过.environment注入。我更推荐后面这种方式因为测试时可以每次创建一个全新的 Store。4.2 流式输出的更新策略与 ID 稳定性流式输出是 SwiftUI 界面最容易崩溃的地方之一。如果每次收到一个文本片段都直接替换消息数组中的字符串ForEach会认为这个视图被重建了动画会乱跳列表会滚动闪烁严重时还会因为身份冲突崩溃。我的解决方法是给每一条消息一个固定 ID消息内容本身用一个可变盒子包一层struct TextChunk: Identifiable { let id UUID() var text: String }在流式更新时只修改text的值不修改id。这样ForEach会保持同一个 cell不会有创建销毁的开销界面也能平滑滚动。如果你的列表里还有技能调用的状态指示器比如“正在调用日程查询技能”同理用一个独立的ToolCallProgress对象来保存进度值。4.3 任务生命周期.task、取消与后台恢复SwiftUI 的.task会在视图出现时启动在视图消失时自动取消。如果我们直接在.task里监听 Runtime 的事件流那么用户切走页面时这个监听就没了。有时候我们需要的就是这种“随视图一起走”的订阅但也有时候我们希望在后台保持会话继续。我建议区分两种场景如果技能执行结果只影响当前页面就直接用.task如果结果是全局性的比如跨 Tab 的日程写入那就应该由根级 Store 监听事件流然后把结果写进AgentSession页面只是观察者。同时要处理 App 进入后台的情况。最简单的方式是监听scenePhase.onChange(of: scenePhase) { _, newPhase in switch newPhase { case .background: runtime.suspend() case .active: runtime.resume() default: break } }suspend会取消当前正在执行的超时任务保存未完成的事件流指针resume时再恢复。如果技能本身支持后台执行比如等待网络返回可以把 CPU 休眠保持住但这种场景并不多我还是建议后台直接中断回到前台时询问用户是否重试。5. 我实测时踩过的坑及对应的处理办法5.1 技能执行串行化导致的“卡死”假象有一次我连续调用了两个技能第一个技能需要请求外部接口等了三秒才返回第二个技能是纯本地的文本整理按理说是瞬间完成。结果界面上出现了非常诡异的现象第一条消息已经显示“技能已完成”但第二条技能迟迟不开始整体看起来像卡死了一样。排查之后发现问题不在判断逻辑而是在 Runtime 的事件消费端采用了串行等待。第一个技能的事件流没有finish()之前第二个技能的事件没有进入消费队列。解决办法是给每个事件流的消费加一个超时保护超过规定时间就强制finish()并输出一个.timeout事件。我封装了一个小工具本质上是给AsyncStream加一个超时定时器func withTimeout(seconds: TimeInterval, stream: AsyncStreamAgentEvent) - AsyncStreamAgentEvent { AsyncStream { continuation in let task Task { for await event in stream { continuation.yield(event) } continuation.finish() } Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) if task.isCancelled false { continuation.yield(.failed(技能执行超时)) continuation.finish() task.cancel() } } } }之后我再也没遇到过“转圈圈转半天”的情况超时工具成了标配。5.2 状态恢复时的技能调用顺序错乱另一个坑出在会话恢复。App 在后台被杀掉后我保存了messages数组里面包含技能调用记录和结果。恢复时直接把消息数组塞回 SwiftUI 界面希望用户看到完整的聊天历史。结果发现部分技能调用被重新触发因为恢复逻辑里对“技能调用记录”做了重放。这类问题的根源在于混淆了两个概念日志和指令。技能调用记录是日志用来展示给用户看它不是执行指令不能因为出现在消息列表里就重新执行。我在模型里新增了一个字段isReplayed恢复时把技能调用记录标记为只读不让它进入 Runtime 的执行队列。如果你确实需要在恢复时重新计算结果比如技能依赖的数据已经过期我建议手动触发一个“刷新”操作而不是自动重放。自动重放容易产生副作用比如给用户发一封邮件、写一条日程这些操作在 App 启动时做是非常危险的。5.3 调试时的黑盒困境给 Agent 增加事件日志技能多了之后最大的痛苦是“不知道 Agent 心里在想什么”。纯靠print打印调用栈太原始SwiftUI 界面又无法实时看到内部状态。后来我在 Runtime 里直接内置了一个事件日志所有AgentEvent都追加到一个环形缓冲区并且开放给界面。enum AgentEvent: Equatable { case sessionStarted case skillMatched(id: String, score: Double) case toolStarted(id: String) case toolResult(String) case progress(Double) case failed(String) case timeout(String) }在开发模式下我让事件日志显示在页面底部类似一个“系统控制台”。这样技能匹配失败、参数校验不过、外部 API 超时都能一目了然。这个日志还有一个额外好处可以回放给测试同事让他们把崩溃前的完整事件时间线发给我定位问题效率高了很多。6. 这套框架能带给我们什么以及后续可以怎么扩展6.1 与之前“硬编码 Agent”写法相比的收益我整理了一张对比表把重构前后放到一起看会更直观。维度硬编码 Agent 的常见做法SwiftUI-Agent-Skill 的收益模块边界视图里塞分支判断技能通过协议隔离视图完全无感测试难以单独测试 UI 里的对话状态可单独测试 Runtime 和每个技能扩展性每加一个能力就要改视图状态机注册一个新技能几行代码搞定状态管理消息数组散落在页面之间统一由 AgentSession 持有调试靠 print 和断点有可回放的事件日志和超时保护最明显的变化在测试环节。以前为了验证“用户输入某个问题会不会触发某个技能”我要么写冗长的 UI 测试要么手动去 App 里点。现在一张单元测试就能搞定构造一个带 mock 服务的 Runtimesubmit一段输入断言事件流里是否包含预期的.toolStarted。这个测试还能挂在 CI 上每次提交代码自动跑一遍回归问题能第一时间暴露。6.2 可以继续做的方向技能编排、多 Agent 协作、离线技能这套结构改成技能编排也很自然。技能的execute方法可以返回一个特殊的.delegate事件表示“我内部需要调用另一个技能”。Runtime 收到这个事件后再按普通技能分发流程处理。这样我能搭出更复杂的链路比如“整理会议纪要”技能内部先调用“日程查询”技能查到相关日程后再调用“摘要生成”技能最后汇总成完整纪要。多 Agent 协作也是一条可扩展的路线。现在 Runtime 里只有单智能体如果我把它拆成AgentRuntime和AgentSession两个抽象再让多个 Runtime 实例共享同一个事件总线就能实现“一个前台调度 Agent、多个后台专业 Agent”的架构。SwiftUI 只负责展示每个 Agent 的状态完全不关心背后有几个智能体在跑。离线优先的技能也值得投入。网络模型不是什么时候都可用但像文本格式化、数据提取这类纯本地能力可以用 CoreML 做一个小型模型跑在端侧。把它们注册成低优先级的技能在线时优先用大模型离线时降级到本地技能用户的体验不会断档。我在实际构建里最深的体会是SwiftUI 的强项是“状态驱动 UI”但 Agent 本质上是一个“事件流驱动系统”两者的接缝不能放在视图里而是要靠一层明确的 Runtime 事件流去连接。只要能先把状态和技能这两件事彻底拆干净后面接什么能力都会很顺手这也是我把这套结构命名为 SwiftUI-Agent-Skill 的真正原因。