我最近在Xcode里搭了个叫 SwiftUI-Agent-Skill 的小项目。核心就做了一件事:把“Agent”这个概念落进 SwiftUI 应用里,让应用内的 AI 助手不再只是你一句我一句地聊天,而是能自己决定调用哪些工具、按顺序执行几个技能、最后把结果整理成答案。标题里的三个词正好对应三层:SwiftUI 是界面宿主,Agent 是决策中枢,Skill 是可插拔的执行能力。
为什么会想做这个东西?我在 iOS 开发群里发现很多人在问同一个问题:SwiftUI 里能不能做“会自己调工具的 AI 助手”?后端框架里 Agent 已经玩得很成熟了,可到了移动端,资料就很零散。这个项目就是把最核心的流程——Agent 循环、技能注册、工具调用、SwiftUI 状态联动——完整打通,并且整理成一套可以直接照着抄的架构。
适合谁看?首先是已经会 SwiftUI 基础、想在 iOS 或 macOS 上做 AI Agent 应用或智能助手的开发者;其次是想了解移动端 Agent 和 Web 端 Agent 有什么不一样、会踩什么坑的人。下面我把每一步的取舍和遇到过的真实问题都写出来。
1. 项目在拆什么:SwiftUI、Agent、Skill 三个词背后的架构
1.1 SwiftUI 负责的不只是界面
先明确一个定位:SwiftUI 在 Agent 系统里的角色是“呈现层”。很多人理解就是把聊天界面搭出来,但实际不止。Agent 在执行技能的过程中,会经历思考、选技能、执行中、执行完成、失败重试等状态。这些状态如何在 UI 上呈现,决定了用户是觉得“这个助手真的在做事情”,还是觉得“这破 App 怎么又卡了”。
所以在项目里我没有把 UI 和 Agent 逻辑混在一起,而是把 Agent 做成一个独立的运行时,SwiftUI 通过 ObservedObject 或 @Observable 订阅它的状态。UI 只管渲染状态,Agent 只管决策和调度。这个分层在后面你改技能的时候会感受到巨大好处——给 Agent 加一个新技能,你完全不需要碰 UI 代码。
另外,SwiftUI 的声明式语法对 Agent 状态机特别友好:状态变了,界面自动刷新。比如 Agent 从“思考中”切到“执行技能-查询天气”,列表视图会自动展示对应行,不用手动去更新任何控件。这种体验用 UIKit 写会繁琐很多,SwiftUI 天然适合做这种状态驱动的界面。
1.2 Agent 不是聊天机器人,而是调度核心
再来说 Agent。标题里的 Agent,指的可不是跑到系统设置里的那个“后台 Agent 进程”,而是能自主决定下一步做什么的调度循环。
一个最简单的 Agent 循环是这样的:把用户的话和已有消息交给模型,模型判断要不要调用工具;如果调用,就解析出工具名和参数,执行对应技能;把技能执行结果回传给模型,让模型根据结果生成下一步动作或最终答案。这个循环可能要跑好几轮,直到模型产出最终回复。
后端的 Agent 框架习惯把这个循环叫做“ReAct”——Reasoning and Acting。移动端做 Agent,本质就是把这套循环搬到 Swift 里。难点不在循环本身,而在怎么把循环和 Swift 的并发模型、UI 生命周期协调好。Web 后端跑 Agent,线程随便开,内存随便用;移动端要管主线程、要管电池、要管网络断开,这些约束直接影响架构选择。
1.3 Skill 是 Agent 的手和脚
Skill 是这套架构里真正干活的模块。比如“查天气”“设闹钟”“搜索文件”都是技能。每个技能有四个要素:技能名、描述、参数 Schema、执行函数。
技能名要给模型看,所以不能随意起。描述要写清楚“什么场景下触发、参数怎么填、返回什么”,这样模型才能准确选择。参数 Schema 是 JSON Schema 格式,模型看过 Schema 就能学会填参数。执行函数才是真正写逻辑的地方,可以去调 API、读文件、发通知。
为什么要把技能拆出来而不是直接写死在 Agent 里?因为 Agent 的决策能力是通用的,但它能做什么取决于你挂了多少技能。拆开之后,加技能、减技能、换技能都是独立的事,甚至可以把技能打包成 Swift Package 插件。后面我会详细讲这个怎么设计。
2. 技能层设计:让模型能“看懂”你的工具
2.1 技能协议:三个字段决定模型怎么调用
技能层是整个项目里最重要的一层。我先定义一个协议:
public protocol AppSkill: Sendable { var name: String { get } var description: String { get } var parametersSchema: [String: Any] { get } func execute(arguments: [String: Any]) async throws -> SkillResult }三个关键字段的作用:
- name:技能的唯一标识,也是模型工具调用里的函数名;
- description:给模型的说明书。模型靠这个字符串决定“要不要选这个技能”;
- parametersSchema:模型填参数的模板。
description 怎么写给模型看?我总结了一个简单公式:触发场景 + 输入参数含义 + 输出内容格式 + 返回失败条件。写得越具体,模型调用准确率越高。比如一个“get_weather”技能描述写成“当用户询问某城市当前天气时使用。参数 city 为城市名,输出格式为‘城市+天气+温度+风力’。如果城市不存在,返回错误信息。”
这里说明一下:参数的 Schema 格式,主流大模型接口都兼容 JSON Schema 的 object 类型。Swift 里没有原生的 JSON Schema 类型,直接用[String: Any]字典最方便,反正最后也要序列化成 JSON。完整性上注意 key 用"type"、"description"、"properties"、"required"这几个标准字段即可。
2.2 注册中心:技能列表的唯一入口
接下来是技能注册表。Agent 需要一个地方知道“我现在会哪些技能”。我用一个类来管理:
final class SkillRegistry: Sendable { private var storage: [String: any AppSkill] = [:] func register(_ skill: any AppSkill) { storage[skill.name] = skill } func toolDefinitions() -> [[String: Any]] { storage.values.map { skill in [ "type": "function", "function": [ "name": skill.name, "description": skill.description, "parameters": skill.parametersSchema ] ] } } func execute(name: String, arguments: [String: Any]) async throws -> SkillResult { guard let skill = storage[name] else { return SkillResult(success: false, output: "", errorMessage: "未找到技能 \(name)") } do { return try await skill.execute(arguments: arguments) } catch { return SkillResult(success: false, output: "", errorMessage: error.localizedDescription) } } }这里有两个容易踩坑的点。
一是并发安全。如果在 SwiftUI 界面初始化时注册技能,同时 Agent 在后台执行技能查询,这属于跨隔离域访问。最简单的方案是把 AgentRuntime 也锁在 @MainActor,注册和调度都走主线程,靠 async/await 做网络切换。Demo 这么搞完全没问题,如果技能本身非常耗时,再考虑把执行放到后台 Task 里。
二是错误处理。Skill.execute 本身可能抛错,但 Agent 循环不能因为一个技能失败就中断。所以 execute 方法内部捕获错误,转成 SkillResult 返回,Agent 再把失败信息交给模型,让模型决定是换一种方式还是直接回复用户。这个设计看似绕,实际上保住了整个循环的连续性。
2.3 参数解析:宽容处理模型的“幻觉JSON”
LLM 生成的参数是文本 JSON,虽然模型很擅长,但一定会出现格式怪异的 JSON。我建议在技能内部定义结构化参数模型,用 Codable 解码:
struct WeatherArguments: Codable { let city: String } func execute(arguments: [String: Any]) async throws -> SkillResult { guard let jsonData = try? JSONSerialization.data(withJSONObject: arguments), let decoded = try? JSONDecoder().decode(WeatherArguments.self, from: jsonData) else { return SkillResult(success: false, output: "", errorMessage: "参数解析失败") } // 使用 decoded.city }如果解码失败,不要直接 throw 崩溃,返回一个明确的错误字符串,模型看到后会自动修正参数重新调用。这个“把错误交回给模型”的习惯,是 Agent 开发里非常核心的容错思想。我第一次做的时候在这里偷懒,直接抛了个 decodeError,结果整个 Agent 循环当场终止,用户只看到一句“出错了”,模型压根没有机会自我修正。
工具列表的生成也有一个性能细节:每次 Agent 请求都要带上所有技能的 description。技能多了以后,token 会涨得很快。注册中心可以通过 description 长度限制来隐藏低频技能,或者用向量检索动态选技能,这些都属于进阶优化,后面第 6 部分会再提。
3. SwiftUI UI层与Agent的通信链路
3.1 状态机建模:从idle到failed
UI 要实时感知 Agent 状态。我定义了这几种状态:
| 状态 | 含义 |
|---|---|
| idle | 空闲等待输入 |
| thinking | 模型正在思考,没有调用工具 |
| executing(name) | 正在执行某个技能 |
| finished(answer) | Agent 已生成最终回复 |
| failed(reason) | 循环超限或模型不可用 |
对应 Swift 枚举:
enum AgentState: Equatable { case idle case thinking case executing(name: String) case finished(answer: String) case failed(reason: String) }State 放哪?iOS 17 可以用 @Observable 宏,iOS 16 用 ObservableObject。我 Demo 里为了兼容更多系统用的是 ObservableObject。
@MainActor final class AgentViewModel: ObservableObject { @Published var state: AgentState = .idle @Published var messages: [ChatMessage] = [] private let agent: AgentRuntime func send(_ text: String) { Task { state = .thinking // agent.run执行完整循环 let result = try await agent.run(userMessage: text) state = .finished(answer: result) } } }3.2 与UI的通信链路:Task 与主线程切换
这里是新手最懵的地方。在 @MainActor 上调用await agent.run(...),看起来是卡住主线程,但实际上不会。Swift 的 async/await 机制会让出线程,UI 照常响应。真正会崩的是在非主线程回调里改了 @Published 属性,或者直接在 MainActor 上下文中执行大计算。
经验法则:
- 所有状态修改统一放在 @MainActor 上;
- 技能执行里如果有重计算,用 Task.detached 或并行 group;
- 涉及 UI 控件的任何更新,如果你想通过 Notification / Delegate 传出来,务必切回 MainActor。
我个人在 Demo 里的做法是让 AgentViewModel 直接标上 @MainActor,所有方法都自动在主线程上执行,网络请求该切线程切线程,async 挂起时主线程依然可以处理触摸事件,不会卡 UI。
3.3 过程可视化:让用户看到Agent在干什么
我加了一个细节:当 Agent 切换到 executing(name) 状态时,UI 会显示一行“正在执行:查询天气”。效果非常明显,用户不会觉得 App 卡死了,反而会有“这个助手在干活”的感觉。这个显示的字段就来自状态机里的executing(name)。
Agent 循环每执行完一次技能,我就往 messages 里塞一条“系统消息”样式的气泡,比如“工具 get_weather 返回:某市天气:晴,23℃,微风”。这样用户能看到 Agent 的判断过程,调试问题也直观。这个透明化是 Agent 应用区别于传统聊天框的体验关键。
我这里提到的天气城市用了“某市”,是因为这个 Demo 只是一个模拟技能,没有真实接入天气服务。你完全可以替换成城市名,但真实项目里要注意用户隐私和气象数据的合规使用。
4. 最小可用Demo:从零搭一个双技能Agent
4.1 项目结构一览
Demo 的项目结构如下:
SwiftUI-Agent-Skill/ ├── App/ │ └── AgentDemoApp.swift ├── Models/ │ ├── ChatMessage.swift │ └── AgentState.swift ├── Agent/ │ ├── AgentRuntime.swift │ ├── SkillRegistry.swift │ └── LLMClient.swift ├── Skills/ │ ├── AppSkill.swift │ ├── TimerSkill.swift │ └── WeatherSkill.swift └── Views/ ├── ChatView.swift └── AgentStatusView.swift我尽量保持文件少,方便理解。生产项目可以再拆,但 Demo 保留这个粒度最好。
4.2 两个技能的代码
代码在前面已经给了 Skill 协议和注册中心,这里把两个技能补全。
第一个是计时器技能:
struct TimerSkill: AppSkill { var name: String { "start_timer" } var description: String { "当用户希望设置倒计时或定时提醒时使用,参数seconds为秒数" } var parametersSchema: [String: Any] { [ "type": "object", "properties": [ "seconds": ["type": "number", "description": "倒计时的秒数"] ], "required": ["seconds"] ] } func execute(arguments: [String: Any]) async throws -> SkillResult { guard let seconds = arguments["seconds"] as? Double else { return SkillResult(success: false, output: "", errorMessage: "缺少seconds参数") } try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) return SkillResult(success: true, output: "倒计时结束,已等待\(seconds)秒", errorMessage: nil) } }第二个是模拟天气查询技能:
struct WeatherSkill: AppSkill { var name: String { "get_weather" } var description: String { "当用户询问某个城市的天气时使用,参数city为城市名" } var parametersSchema: [String: Any] { [ "type": "object", "properties": [ "city": ["type": "string", "description": "城市名,例如:某市"] ], "required": ["city"] ] } func execute(arguments: [String: Any]) async throws -> SkillResult { guard let city = arguments["city"] as? String else { return SkillResult(success: false, output: "", errorMessage: "缺少city参数") } // 实际项目里这里替换成天气API请求 let weatherText = "晴,23℃,微风" return SkillResult(success: true, output: "\(city)天气:\(weatherText)", errorMessage: nil) } }4.3 AgentRuntime:核心循环的实现
actor AgentRuntime { private let client: LLMClient private let registry: SkillRegistry private let maxIterations = 5 init(client: LLMClient, registry: SkillRegistry) { self.client = client self.registry = registry } func run(userMessage: String) async throws -> String { var messages: [LLMMessage] = [ LLMMessage(role: .system, content: "你是一个智能助手,可以使用技能完成任务。"), LLMMessage(role: .user, content: userMessage) ] for _ in 0..<maxIterations { let response = try await client.complete( messages: messages, tools: registry.toolDefinitions() ) if let toolCall = response.toolCall { let result = try await registry.execute( name: toolCall.name, arguments: toolCall.arguments ) messages.append(LLMMessage(role: .assistant, content: response.content, toolCall: toolCall)) messages.append(LLMMessage(role: .tool, content: result.output, toolCallID: toolCall.id)) } else { return response.content ?? "" } } throw AgentError.maxIterationsReached } }这段代码里最容易忽略的是:appending assistant 消息时要带上 tool_call 信息,tool 角色消息要带上 tool_call_id,否则服务端无法关联上下文。格式错了模型会“失忆”,表现为明明刚执行完技能,下一轮又忘了结果。
4.4 LLMClient:网络层简化实现
struct LLMClient { var model: String var apiKey: String var baseURL: URL func complete(messages: [LLMMessage], tools: [[String: Any]]) async throws -> LLMResponse { var request = URLRequest(url: baseURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONSerialization.data(withJSONObject: buildPayload(messages: messages, tools: tools)) let (data, _) = try await URLSession.shared.data(for: request) return try JSONDecoder().decode(LLMResponse.self, from: data) } }注意:这个网络层只是演示。真实 iOS 应用如果直接把模型接口的密钥写进客户端,等于把账密公开送给每一个拿到 App 的人。正确做法是 App 只做 UI,真正的 API 密钥放在自己的后端服务里,客户端与后端通信,由后端去调用模型接口。
4.5 SwiftUI视图层:把状态渲染出来
struct ChatView: View { @ObservedObject var viewModel: AgentViewModel @State private var inputText = "" var body: some View { VStack { List { ForEach(viewModel.messages) { message in MessageBubble(message: message) } if case .executing(let name) = viewModel.state { Label("正在执行技能:\(name)", systemImage: "bolt.fill") } if case .thinking = viewModel.state { ProgressView("思考中…") } } HStack { TextField("输入问题", text: $inputText) Button("发送") { viewModel.send(inputText) inputText = "" } } } } }4.6 运行效果验证
编译运行后,输入“帮我查一下某市的天气”,可以看到效果:
- 状态切到 thinking;
- 模型返回工具调用 get_weather,参数 {"city":"某市"};
- UI 显示执行技能;
- 技能返回“某市天气:晴,23℃,微风”;
- 模型基于结果生成最终答复,UI 显示回答文本。
这个流程虽然肉眼看起来只是一进一出,但中间决策与执行已经独立完成了。
5. 踩坑实录:五类高频问题的排查方法
5.1 模型返回了“幻觉”参数
模型生成工具调用时,返回的 JSON 可能带上并不存在的字段,比如 get_weather 的参数写成了{"city":"某市","unit":"celsius"}。你的 Schema 里没有 unit,但模型自作主张加了。这在用 Codable 解码时会直接失败。
解决办法是:解析参数时忽略未知字段。JSONDecoder 默认就忽略未知字段,但前提是你用 Codable 结构体,并且不要用 strict 模式。一定要加上容错逻辑:解码失败时封装成“参数解析失败,请重新提供参数”的错误返回给模型,模型会自动修正。我见过有人在这里直接抛异常,Agent 循环当场终止,体验非常糟糕。
5.2 主线程崩溃与跨线程状态更新
SwiftUI 里最常见的是“后台线程修改 @Published”。如果你在 LLMClient 的回调里直接viewModel.state = .finished,大概率遇到“Publishing changes from background threads is not allowed”崩溃。
排查方法:所有状态变更统一放到 @MainActor。代码里在 AgentViewModel 上加 @MainActor,它的所有方法就自动在主线程执行;如果技能内部有回调,用 await 收回来再改状态。这个习惯养成之后,这类崩溃基本不会再出现。
5.3 上下文膨胀与Agent死循环
Agent 循环每轮都会把 tool 返回拼进消息数组。如果技能返回超长文本,几轮后上下文就爆了。解决思路有两个:
- 技能返回前做摘要,只保留关键信息;
- 控制最大轮次,跑满了直接把历史截断或者让模型总结后重新开始。
死循环也常见:模型反复调用同一个失败的技能,可能是因为技能报错信息不清,模型以为还没试。解决方式是在错误信息里明确提示“此技能已失败,停止调用”,配合 maxIterations 兜底。我自己的经验:最大迭代次数设 5,既能完成任务,又能防止无限烧 token。
5.4 模拟器和真机的性能差异
这个坑特别隐蔽。模拟器上跑 Agent 循环丝滑,真机上却卡顿。原因往往是你在主线程同步做了大量 JSON 序列化 / 反序列化,或者技能执行里有重计算。建议把技能执行包在 Task.detached 里,需要主线程更新 UI 时再 await 切回来。另外,流式输出能显著降低首字延迟,但实现复杂度会上一个台阶,Demo 可以先不做。
5.5 API Key的安全红线
这是最容易被忽略也最致命的一个坑。把模型 API 密钥直接写进 SwiftUI 应用,那所有拿到 App 的人都能把密钥刨出来。真实项目必须通过自己的后端代理模型请求,客户端只和后端通信。如果你只是在本地 Demo 自用,可以临时写在配置文件里,但记得在 .gitignore 中排除,不要手滑把密钥提交到公开仓库。这不是“以后再说”的问题,我见过不止一两个开发者因为这个泄露被厂商封号。
6. 这个项目后续还可以怎么玩
6.1 把技能打包成Swift Package
技能协议稳定之后,可以把每个技能做成独立 Swift Package,Agent 通过依赖注入把包里的技能注册进 registry。这样团队可以并行开发不同技能,产品只需要声明启用哪些包。这就是“技能插件化”。实测下来这个改动对架构的收益非常大,加新技能变成一次纯增量的代码提交,不会动到 Agent 核心。
6.2 给技能加权限确认
技能会访问系统能力或用户数据,所以必须在 UI 层有一个授权流程。比如“发送通知”技能,Agent 决策完之后先在 UI 上弹确认框,用户点了允许才执行。我在 Demo 里只做了计时器和模拟天气,都是无害技能,所以没加。但真实应用里,凡是涉及敏感操作的技能,都需要这个确认步骤。这一步做不好,应用大概率过不了审核。
6.3 多Agent分工与流式输出
技能多了之后,一个 Agent 处理所有请求会变得又慢又容易乱。可以根据领域拆多个 Agent,比如一个管家 Agent 负责调度,下面挂日程 Agent、天气 Agent、文件 Agent。每个 Agent 有自己的技能集,管家只做意图路由。
流式输出方面,SwiftUI 可以用 AsyncSequence 接收模型返回的增量文本,配合 Text 逐字渲染。这套改动不涉及 Agent 核心循环,但体验提升非常明显。我后续准备在这个 Demo 上继续加这两块。
做这个项目之前,我一直觉得 Agent 的核心是模型能力。做完之后我的体会刚好反过来:对于 SwiftUI 工程,技能层的设计决定了 Agent 的天花板。模型再聪明,如果你的技能描述含糊、参数 Schema 混乱、错误处理粗暴,Agent 一样会表现得像个半成品。所以我建议你动手写第一个技能时,就先设计好失败语义:什么情况算成功、什么情况算失败、失败后能不能重试。想清楚这些,再考虑让 Agent“更聪明”也不迟。
另外有个小技巧分享:调试 Agent 循环时,把所有工具调用和返回都打在控制台里,按时间线看。肉眼盯一遍循环,比对着抽象日志猜快得多。这个小习惯帮我在排查死循环和参数幻觉时省了大量时间。