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 不直接认识 SwiftUI,Skill 不直接认识 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: AsyncStream<AgentEvent> { _events.eraseToAnyPublisher() } private var _events = PassthroughSubject<AgentEvent, 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 把技能输出变成可见的流式事件
技能返回类型我用的是AsyncStream<AgentEvent>,而不是简单的回调或闭包。原因是流式事件非常自然:大模型的输出是一个字一个字冒出来的,工具调用也有开始、进行、结束几个阶段,这些都可以通过AsyncStream一点一点发给外部订阅者。
func execute(context: SkillContext) -> AsyncStream<AgentEvent> { 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) -> AsyncStream<AgentEvent> }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) -> AsyncStream<AgentEvent> { 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: AsyncStream<AgentEvent>) -> AsyncStream<AgentEvent> { 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 服务的 Runtime,submit一段输入,断言事件流里是否包含预期的.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 的真正原因。