最开始我做这个项目时,心里其实只有一个很朴素的疑问:SwiftUI 能不能不只是当一个"展示聊天消息的壳子",而是真正把 Agent 的决策循环、工具调用、流式响应都装进一个原生应用里?
答案是可以,但过程比我想象中曲折得多。这篇文章是我把SwiftUI、Agent、Skill 三者揉在一起的完整复盘,包括架构设计、技能机制、状态管理的三种姿势,以及我在调试中踩过的五个真实翻车现场。如果你正准备在自己的苹果生态应用里接入大模型智能体,这篇应该能帮你省下不少弯路。
1. 先拆清楚:这个项目里的"Agent"和"Skill"到底是什么
1.1 Agent 不是聊天机器人,三件事定义了它
很多人在 SwiftUI 里接入大模型,做出来其实就是"带 UI 的 ChatGPT 客户端"——用户发一句,模型回一句,中间没有任何自主行为。这只能叫"聊天应用",不叫 Agent。
我理解中的 Agent 至少包含三件事:
- 感知:接收用户的自然语言输入,加上当前界面上下文、技能列表,构成一次完整的请求。
- 决策:模型不直接回答用户,而是先判断"要完成这个任务,我该调用哪个技能、传什么参数",甚至连续调用多个技能。
- 行动:执行技能并拿到结果,再把结果回填给模型,让模型基于真实数据生成最终回答。
这里的行动环节,恰恰是 SwiftUI 应用天然的优势区域。因为苹果生态里有一大堆系统能力——日历、提醒、通讯录、健康数据、快捷指令——普通 Web 页面访问起来要层层申请权限,原生应用却能相对优雅地拿到这些数据。所以我把这个项目定位成"苹果生态内的 Agent 客户端",而不是又一个网页聊天框。
1.2 Skill 是指"技能",不是开发者技能
标题里的 "Skill" 我第一次看到时也犹豫了一下,是"开发者掌握的 SwiftUI 技能"还是"Agent 拥有的能力模块"?做下来之后我可以确定:这里指的是后者。
Skill 在 Agent 体系里就是一个可被模型发现、选择和执行的工具单元。往大了说,它可以是"查询用户日历""发送系统通知""控制播放器";往小了说,它可以是一个简单的计算函数。关键在于:模型通过自然语言描述来理解这个 Skill 是干什么用的,通过 JSON Schema 来理解该传什么参数,通过一段 Swift 异步执行体来真正完成任务。
我把 Skill 设计成协议时,参考的是 iOS 里 App Intents 的思路——它本质上就是一种"把应用能力暴露给外部系统"的机制。只不过 App Intents 暴露给 Siri / 快捷指令,Skill 暴露给大模型。
1.3 为什么是 SwiftUI,而不是套壳 Web 或 Flutter
其实最开始我也动过用 Web 套壳的念头,毕竟生态成熟、支持多平台。但做了几天就被两个问题劝退了:
- 本地能力接入:Web 页面访问日历、通知、文件权限,每一道都难受。SwiftUI 直接跑在系统上,权限弹窗、数据复用、安全区域处理都是原生的。
- 状态同步:Agent 跑起来之后,消息列表、技能执行状态、流式输出都是高频变化的数据。SwiftUI 的响应式状态管理恰好擅长这个,不需要我在 JS 和原生之间搭一座随时可能塌的桥。
再者,SwiftUI 写出来的 App 可以顺手兼容 iPhone、iPad、Mac,一套状态逻辑走天下。对个人开发者来说,这性价比很高。
2. 核心架构:一条 Swift 异步管道串起"感知-决策-行动"
2.1 Agent 循环的 Swift 表达:不要用 while true 硬轮询
Agent 的核心逻辑是一个循环:把消息发给模型,模型返回"要调用某技能",执行技能,再把结果加进上下文,继续发给模型。这在 Python 里非常好写,但搬到 Swift 里,第一个坑就是:你不能在 SwiftUI 视图的 Body 里跑这个循环,也不能用一个简单的 while true 去阻塞主线程。
我的做法是把 Agent 循环封装成一个独立的异步引擎,用AsyncThrowingStream向上层吐事件流:
enum AgentEvent { case textChunk(String) // 流式文本 case skillSelected(SkillInvocation) // 模型选中技能 case skillResult(SkillInvocation, String) // 技能执行结果 case completed(String) // 最终回答结束 } final class AgentEngine { func run(input: String, in session: ConversationSession) -> AsyncThrowingStream<AgentEvent, Error> { AsyncThrowingStream { continuation in let task = Task { do { var history = session.history history.append(.user(input)) // 主循环:最大迭代次数兜底,避免死循环 for _ in 0..<session.maxIterations { let response = try await llmClient.complete( messages: history, tools: skillRegistry.visibleSchemas() ) if let toolCall = response.toolCall { continuation.yield(.skillSelected(toolCall)) let result = try await skillRegistry.execute(toolCall) continuation.yield(.skillResult(toolCall, result)) history.append(.toolCall(toolCall)) history.append(.toolResult(toolCall, result)) } else { let finalText = response.text continuation.yield(.completed(finalText)) break } } continuation.finish() } catch { continuation.finish(throwing: error) } } continuation.onTermination = { _ in task.cancel() } } } }这个设计有几个关键决定:
- 用
AsyncThrowingStream而不是回调闭包:因为 SwiftUI 侧可以用for try await优雅地消费事件流,取消、异常处理都顺着 Swift 的异步机制走,不需要手动维护一堆闭包。 - 最大迭代次数兜底:模型偶尔会陷入"不断调技能但不收束"的循环里。没有这个兜底,用户界面可能永远停在"思考中"。
- 事件类型覆盖了 UI 需要的所有状态:哪条是流式文字、哪个技能被选中、技能执行完没有,UI 层不需要去猜测 Agent 内部发生了什么。
2.2 模型服务层:把各家 API 的差异挡在协议后面
大模型服务方的接口风格差异很大,有的走 Chat Completions 风格,有的用 Messages API,有的支持工具调用,有的只支持提示词注入。如果 AgentEngine 直接依赖某一家的 SDK,后面一旦换模型或加多模型,整个引擎都要动刀。
所以我做了一层薄薄的隔离:
protocol LLMClient: Sendable { func complete( messages: [LLMMessage], tools: [ToolSchema] ) async throws -> LLMResponse } struct LLMResponse: Sendable { var text: String var toolCall: ToolCall? // 模型决定调用的技能 }主流程里只依赖这个协议。具体怎么拼 HTTP 请求、怎么解析 SSE 流、工具调用字段叫什么名字,全部藏在具体实现里。这也是我推荐所有做 Agent 应用的人第一件事先做的事——先隔离,再实现。
实际落地时我发现,工具调用的协议差异比想象中大。有的服务方把工具调用直接放在tool_calls字段里;有的则要求你把工具描述写进系统提示词,让模型"假装"调用。所以我给每个模型适配器都做了独立测试用例,确保同一个 Agent 引擎在抽象层之上感受不到差异。
2.3 技能注册表:让模型"看得见"你的 Swift 函数
技能注册表是 Agent 和 SwiftUI 之间的"电网"——它把一组 Swift 异步函数变成模型可以理解、可以选择、可以调用的工具列表。
protocol Skill: Sendable { var id: String { get } var name: String { get } var description: String { get } var inputSchema: ToolSchema { get } func execute(arguments: Data) async throws -> String } final class SkillRegistry { private var skills: [String: any Skill] = [:] func register(_ skill: any Skill) { skills[skill.id] = skill } func visibleSchemas() -> [ToolSchema] { skills.values.map(\.inputSchema) } func execute(_ call: ToolCall) async throws -> String { guard let skill = skills[call.name] else { throw SkillError.unknownSkill(call.name) } return try await skill.execute(arguments: call.arguments) } }这一步做好之后,Agent 的"能力边界"就不是写死在代码里了,而是靠注册决定。想加一个新能力,写一个符合Skill协议的类型,注册进去,模型自动就能"看到"它。
3. 技能机制:从硬编码命令到模型自主选工具
3.1 技能的骨架:一份 JSON Schema 加一段 Swift 执行体
技能的灵魂在inputSchema。模型不看你的 Swift 代码,它只通过description和inputSchema判断"这个技能是什么、我该怎么用"。所以写技能描述比写函数实现还重要。
我给日历技能写描述时,一开始写的是"查询日历",结果模型经常在参数不齐全的情况下乱调。后来我改成:
查找用户在指定时间段内的日历日程。 当用户提到会议、日程、安排、空闲时间时使用。 时间段缺省时默认最近7天。模型的表现立刻好了很多。这背后的逻辑其实很好理解——大模型本质上是靠自然语言描述来"理解"工具的,你在描述里把触发条件、默认行为、参数语义写清楚,它才不至于瞎猜。
JSON Schema 的结构也直接影响模型参数填写的准确率。我的经验是:参数越少越好,类型越明确越好,description 里要写清楚单位、格式、取值范围。日期参数一律用 ISO 8601 字符串,并给出示例值。模型在示例面前比在规则面前可靠得多。
3.2 让模型正确返回工具调用的三个细节
第一个细节:工具列表中不要出现两个功能相似的技能。我试过同时注册"查天气"和"查空气质量",模型经常二选一选错。合并成一个query_weather技能,内部按参数区分行为,准确率更高。
第二个细节:参数的 schema 里,required 字段一定要谨慎。一旦某个字段设为必需,模型就必须填。但"从今天到某天之间的日程"如果结束时没填,模型可能会硬编一个日期。我更倾向于把所有字段设为可选,然后让技能实现体处理缺省逻辑。
第三个细节:技能结果回传时,一定要带上明确的执行状态。不管是"成功返回数据"还是"查无结果",都要组织成模型容易阅读的文本。我用的是统一格式:
技能执行成功(技能名: 查日历) 查询时间段: 2025-04-01T00:00:00Z ~ 2025-04-08T00:00:00Z 返回: 共有2条日程 - 2025-04-03 14:00 产品评审(60分钟) - 2025-04-05 10:00 月度规划会(90分钟)这个格式的作用是让模型能直接从文本里提取信息,不需要再做一次结构解析。
3.3 完整走一遍:日历技能从注册到执行
我拿一个最小可跑的流程来说明。假设用户说:"我看下这周三下午有什么安排。"
第一步,SwiftUI 把这条消息发给AgentEngine,引擎带着当前对话历史和技能列表请求模型。模型返回的不是文本,而是一个工具调用对象,大概长这样:
{ "name": "query_calendar", "arguments": "{\"start\":\"2025-04-09T00:00:00Z\",\"end\":\"2025-04-09T23:59:59Z\"}" }第二步,SkillRegistry.execute把参数解码成日历查询条件,调用EventKit拉取日程。这里有个值得分享的细节:应用应该在用户首次触发日历技能时才申请权限,而不是 App 启动时就弹窗。放在技能执行流程里,用户理解度最高——因为权限弹窗前他已经说了"查看日程",上下文是连贯的。
第三步,执行结果回传引擎,引擎把它追加到消息历史里,再一次请求模型。这第二轮模型看到的是"工具执行结果:共有1条日程……",于是它组织出自然语言回答:"这周三下午 14:00 有一个产品评审会议,时长 60 分钟。"
整个过程在 UI 上的表现就是:出现一个小卡片显示"正在使用 日历技能",然后卡片收起,模型继续输出文字。用户能感知到 Agent 真的"做了事",而不是单纯复读。
4. SwiftUI 状态管理:Agent 流式输出与界面同步的三种姿势
4.1 AsyncStream 直通视图:最直观但最容易被忽视的坑
既然引擎对外暴露的是AsyncThrowingStream,SwiftUI 侧最自然的做法就是在视图任务里直接消费它:
@Observable final class ChatViewModel { var messages: [ChatMessage] = [] var agentPhase: AgentPhase = .idle private var agentStream: Task<Void, Never>? func send(_ text: String) { messages.append(.user(text)) agentPhase = .thinking let stream = engine.run(input: text, in: session) agentStream = Task { [weak self] in do { for try await event in stream { await MainActor.run { self?.handle(event) } } } catch { await MainActor.run { self?.handleFailure(error) } } } } private func handle(_ event: AgentEvent) { switch event { case .textChunk(let chunk): // 追加到最后一条 assistant 消息的尾巴上 messages[messages.count - 1].content += chunk case .skillSelected(let call): messages.append(.skillCard(call)) case .skillResult(let call, let result): if let idx = messages.lastIndex(where: { $0.isSkillCard(id: call.id) }) { messages[idx].skillResult = result } case .completed(let finalText): agentPhase = .idle } } }注意我用了@Observable而不是@Published。@Published只能配ObservableObject,而且每次给数组赋值都会触发一次整个视图刷新;@Observable在 iOS 17 之后粒度更细,可以只让依赖了具体字段的视图刷新。对高频流式文本来说,这个差异肉眼可见地影响流畅度。
坑在哪里?在于MainActor.run不是免费午餐。如果每个 token 都包装一次await MainActor.run,在主线程排队的时候,界面会出现一种"一顿一顿"的感觉,尤其在抽奖式连续输出场景下。我后面会讲降频方案。
4.2 任务生命周期:取消、重试与 UI 状态的一致
用户界面必须有"停止生成"按钮,但原生Task.cancel()并不是万能的。它能取消 Swift 并发任务,但是否能中断正在进行的网络请求和技能执行,取决于底层实现。
我的处理方式是:
- 发送新消息时,先取消上一个
agentStream任务。 - 网络层用
URLSession配合Task取消,这样可以真正中断连接。 - 技能执行体里,凡是涉及长时间运行的循环(比如分页拉数据),要主动检查
Task.checkCancellation()。
还需要处理一个边角情况:用户发送新消息的速度可能比技能执行还快。如果上一个技能结果晚于新消息返回,就会污染新的对话上下文。我的方案是在ConversationSession里增加一个代数计数器,每次send自增,事件回到 UI 层时发现代数不匹配就直接丢弃。
4.3 主线程与降频:别让每个 token 都触发一次整个列表重绘
流式输出最常见的性能问题不在网络,而在视图刷新。messages[messages.count - 1].content += chunk每次都会触发内容变化,若整个消息列表都监听了messages,那么每次到一个 token 就全表重算。
三个改善办法,效果是叠加的:
第一,把消息列表改成按id渲染的LazyVStack,每条消息内部只读取自己的content,这样单条消息更新时只刷新那一条的文本节点。
第二,对渲染做节流。我在 ViewModel 里维护一个pendingChunk字符串,每收到 20 个字符或 80 毫秒才往messages里刷一次:
case .textChunk(let chunk): pendingChunk.append(chunk) if pendingChunk.count >= 20 || Date().timeIntervalSince(lastFlush) > 0.08 { flushPendingChunk() }第三,ScrollViewReader不要无条件滚动。如果用户正在往上翻历史消息,你硬把 scroll 拉到最底部,体验会非常差。我加了一个isUserScrolling标记,手势开始时置 true,滚动停止两秒后才恢复自动跟随。
5. 实测排坑:五个真实翻车现场与修复方案
5.1 技能结果炸掉上下文窗口
第一次让 Agent 去读用户最近的日程时,我的技能实现直接把整个月份的日程全量返回了。结果模型上下文被塞进一大坨 JSON,后面的对话质量急剧下降,甚至出现"生成中断"。
这个坑的本质是:技能执行结果和对话上下文共享同一个上下文窗口,而工具结果是不经过压缩的。修复方案我试过几种,最终留下来的是"摘要优先"策略:
- 技能执行时,如果结果超过 2KB,就先用本地逻辑做压缩,只保留时间、标题、参与人等关键字段。
- 超长内容写进一个临时文件或数据库,上下文里只放"已返回 47 条日程,详情见文件 xxx"。
- 如果结果确实重要且无法压缩,宁可再轮询一次模型,也不要一次性灌进去。
5.2 流式传输的边缘残缺字符
SSE 流式传输中,文本是按 TCP 包到达的,一个 UTF-8 中文字符的字节可能被拆到两个包里。我一开始直接把这个 chunk 按字符串追加,出来一堆乱码。
修复方式其实很简单:不要直接拼接字符串,而是维护一个Data缓冲区,每次收到新数据先 append,再尝试用整个缓冲区解码成字符串,能解码多少就取多少,剩余部分保留在缓冲区里等下一个包:
private var buffer = Data() func appendStreamData(_ data: Data) -> String? { buffer.append(data) guard let decoded = String(data: buffer, encoding: .utf8) else { return nil // 数据不完整,等下一包 } buffer = Data() // 解码成功,清空缓冲区 return decoded }顺手也处理了"半行 JSON"的问题——模型偶尔会在流式过程中把工具调用的 JSON 拆成多个 chunk,缓冲区累积解码的方式同样能解决。
5.3 消息列表自动滚动翻车
我在 4.3 里讲过滚动跟随的问题,这里补一个更隐蔽的 bug:ScrollViewReader.scrollTo在小消息列表上没问题,可一旦消息超过 20 条,滚动到"最后一条"时经常差一截,原因是最后一条消息在滚动瞬间还没完成布局。
我的解决方法是:在滚动前先让当前可见的最后一条消息id稳定不变,然后滚动到底部 ID,配合.onChange(of:)做轻微延迟。另外我把底部缓冲区从一条消息扩展到了两条——滚动目标指向倒数第二条,反而视觉上更稳。
5.4 Swift 6 严格并发下的可发送性警告
这个项目半年前在 Swift 5 模式下跑得很欢,一开 Swift 6 严格并发,冒出来一堆编译错误。核心原因是:Skill协议、ToolCall、SkillInvocation这些类型在跨任务传递时,编译器需要确认它们是Sendable的。
我的建议不是硬消除警告,而是认真设计值的不可变性:
SkillInvocation变成纯struct,所有属性let。SkillRegistry上锁保护注册表字典,用actor隔离访问。- 视图模型里的可变状态,统一收敛到
@MainActor注解的类上。
这些改动看起来麻烦,但它是强制你想清楚"哪些状态该在哪条线程上被修改"。想想如果数据从网络层、技能层、UI 层同时涌进一个可变数组,那画面太美不敢看。
5.5 技能全部暴露导致模型乱点兵
注册表里技能一多,模型的工具选择准确率就开始下降。比如注册了"查询天气""查询日历""发送通知""打开网页"四个技能后,用户问"今天会下雨吗",模型偶尔会先调"发送通知"——完全不合逻辑。
后来我发现,不是模型笨,而是你给了它太多可选项。修复方案是引入"技能可见范围":把技能分成几个技能包(SkillPack),SwiftUI 根据当前场景只向模型暴露相关的那部分。
- 在聊天主界面,只暴露通用工具包。
- 用户进入"日程助手"场景时,加入日历、提醒技能。
- 涉及系统控制时,才暴露通知、播放控制类技能。
模型选错了,多半是我们暴露错了。
6. Demo 能跑之后的取舍:哪些架构决定我建议保留,哪些可以砍
6.1 我保留了注册表和权限开关
技能注册表的成本看起来高,但它是整个项目灵活性的来源。我后面加一个"空气质量查询"技能,只写了 60 行 Swift 代码就接进去了,模型立刻就能用。这种扩展性让我非常确信:Agent 应用的能力边界,应该由注册表驱动,而不是硬编码在 if-else 里。
权限开关也值得保留。我在每个技能上加了requiresPermission标记,用户可以在设置页里单独关闭某个技能。这既是隐私保护,也是让用户对 Agent 有掌控感的关键设计。实际测试中,用户对"应用有没有经过我同意就去读日历"这件事非常敏感。
6.2 我砍掉了过度设计的"记忆模块"和可视化 Agent 日志
一开始我想做一个记忆模块,让 Agent 长期记住用户偏好,还做了一个实时展示 Agent 内部推理日志的面板。做到一半我发现:在移动端小屏幕上展示"模型正在调用哪个技能、参数是什么"这种原始日志,对普通用户毫无意义,反而增加系统负担。
最终我砍掉了原始日志,改成"技能卡片"这种轻量可视化——只展示"正在查询日历""已获得3条日程"这种结果层面信息。砍掉后,界面清爽多了,而且用户对 Agent 行为的理解没有降低。
6.3 给准备从 Demo 迈向发布的你三条忠告
第一,先把技能体系打好,再做花哨的 UI。没有技能支撑的 Agent 前端只是一层漂亮外壳,用户提问"帮我安排会议"却得不到执行,体验会非常糟糕。
第二,一定要给所有外部调用设超时、设上限。模型服务的响应时间不是你能控制的,技能执行也可能慢如蜗牛。没有超时保护,一个卡住的技能调用足以让整个对话线程失去响应。
第三,多设备适配要趁早。SwiftUI 写起来跨平台很轻松,但 iPhone 上的输入习惯、iPad 上的分屏布局、Mac 上的键盘快捷键完全是三套交互逻辑。你现在省掉的适配工作,以后会以几何级数还回来。
最后再分享一个实际操作中的小技巧:调试 Agent 循环时,别急着接真实模型,先用一个 MockLLMClient,让它按预先写好的脚本返回"先调技能、再回文本"。这样你可以把 UI 层的所有状态变化、滚动行为、技能卡片渲染都调试到稳定状态,再接真模型。接上真模型之后再翻车,你也能一眼看出是模型选择问题还是 UI 层问题,而不是两者搅在一起无从下手。希望你做出来的 Agent 应用,也能少踩几个我已经替你们踩过的坑。