
最开始我做这个项目时心里其实只有一个很朴素的疑问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) - AsyncThrowingStreamAgentEvent, 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 直通视图最直观但最容易被忽视的坑既然引擎对外暴露的是AsyncThrowingStreamSwiftUI 侧最自然的做法就是在视图任务里直接消费它Observable final class ChatViewModel { var messages: [ChatMessage] [] var agentPhase: AgentPhase .idle private var agentStream: TaskVoid, 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 里讲过滚动跟随的问题这里补一个更隐蔽的 bugScrollViewReader.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 技能全部暴露导致模型乱点兵注册表里技能一多模型的工具选择准确率就开始下降。比如注册了查询天气查询日历发送通知打开网页四个技能后用户问今天会下雨吗模型偶尔会先调发送通知——完全不合逻辑。后来我发现不是模型笨而是你给了它太多可选项。修复方案是引入技能可见范围把技能分成几个技能包SkillPackSwiftUI 根据当前场景只向模型暴露相关的那部分。在聊天主界面只暴露通用工具包。用户进入日程助手场景时加入日历、提醒技能。涉及系统控制时才暴露通知、播放控制类技能。模型选错了多半是我们暴露错了。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 应用也能少踩几个我已经替你们踩过的坑。