SwiftUI 做 AI 对话界面网上教程一抓一大把但绝大多数都停在调用 API、把文本流式打出来这一步。真正让一个助手从聊天玩具变成能干活的东西核心在于给大模型接上可执行的工具——也就是 Agent Skill 这套机制。我最近在 iOS 17 上重新做了一个叫SwiftUI-Agent-Skill的个人项目把这两件事完整串到了一起不光是 UI 层用 SwiftUI连 Agent 的决策循环、技能注册、流式状态刷新都是纯 Swift 原生实现。这篇文章不聊那些 demo 级的内容我会把整个项目从架构选型到踩坑记录完整铺开适合那些已经能跑通基础聊天、想再往前走一步的人。这个项目解决的是一个很具体的痛点API 调用本身不难难的是让大模型输出动作指令然后在你的 App 里安全地执行这些指令再把执行结果喂回给模型让它继续推理。这个循环就是 Agent 的核心工作流而 Skill技能就是连接模型决策和本地能力的桥梁。网上大多数 SwiftUI Agent 项目要么用 Python 服务端做编排要么直接用现成的商业框架很少有人在 Swift 原生环境里自己实现一整套。我把我这套方案拆成六部分讲清楚包括为什么用 Swift 原生做 Agent 引擎、技能注册机制怎么设计、五个内置 Skill 的取舍、流式响应怎么和 SwiftUI 状态安全地协同、以及我在实测中踩过的一堆并发和解析的坑。1. 为什么非要把 Agent 塞进 SwiftUI说清楚这个项目到底在做什么1.1 我最初想解决的实际问题如果你只是给大模型发一条消息让它写一首关于夏天的诗那根本不需要 Agent。但如果你想让用户说一句帮我关掉深色模式然后把这条笔记存到备忘录模型本身是做不到的——它只能输出一段文本或者一个 JSON 结构真正去改系统设置、写本地文件必须由 App 自己完成。这个项目的出发点就是在 SwiftUI 应用里内置一个决策循环让大模型作为大脑本地能力作为手脚。当模型觉得需要操作某个功能时它不直接输出结果而是输出一个标准化的工具调用请求tool callAgent 引擎解析这个请求、找到对应的 Skill、执行、拿到结果、再返回给模型继续生成。整个过程用户感知上就是一次自然的对话但背后实际发生了多次 API 往返。比较一下不用 Agent 和用 Agent 的差别就很直观不用 Agent 时用户说提醒我明天九点开会模型只会回一句好的我记住了然后什么也没发生。用 Agent 之后模型会输出一个createReminder调用App 真的去创建了一条本地提醒再把已创建提醒明天早上9点这个结果喂回给模型模型最后回复用户已经帮你设置好了。差别是质变。1.2 当时摆在面前的几条技术路线我为什么选了 Swift 原生第一条路线是直接在 SwiftUI 里调云端 Agent API让服务端负责技能编排。好处是省事缺点是调试困难、离线不可用、而且每次对话都要把大量系统提示词传来传去。第二条路线是用一个 Python 或者 Node 的本地桥接服务SwiftUI 只做展示。这个方案能跑但一个 iOS App 要依赖一个本地服务进程才能工作分发和维护的成本都很高。第三条路线就是我最终选的Agent 引擎本身跑在 App 进程内用 Swift 的 async/await 实现决策循环Skill 以符合某个协议的对象形式注册进来。选择 Swift 原生还有一个很实际的理由SwiftUI 的声明式状态系统天然适合表达对话过程这个随时间变化的状态机。消息列表、技能执行状态、流式文本增量这些都可以建模成可观察的状态源UI 自动响应更新。用 Observable 宏之后整个 Agent 引擎可以直接作为视图的状态来源属性一变界面就跟着变省去了大量桥接代码。从工程角度看这套方案让项目的核心价值集中在 App 内部模型只负责推理和决策所有行为逻辑都在本地可审计、可测试、可替换。我自己的体会是第一版跑通之后迭代新 Skill 的成本低到了一个非常舒服的程度——加一个新能力只需要写一个实现协议的 struct注册进去就行Agent 引擎完全不用改。2. Skill 注册中心让 Agent 学会调用工具的地基设计2.1 协议设计一个 Skill 到底要暴露什么信息给模型Agent 要调用工具首先得让模型知道有哪些工具可用、每个工具能接收什么参数。这就是 Skill 的元信息设计。我把每个技能抽象成符合AppSkill协议的类型核心是让模型端能够理解这个技能的名字、用途和参数约束。protocol AppSkill: Sendable { /// 技能的唯一标识避免重名冲突 var id: String { get } /// 技能对外展示的名称会作为 tool 名传给模型 var name: String { get } /// 技能的描述模型会读这段文本来决定是否调用该技能 var description: String { get } /// 参数的 JSON Schema 描述约束模型生成合法参数 var parametersSchema: String { get } /// 执行技能接收模型生成的参数返回结果文本 func execute(arguments: [String: Any]) async throws - String }这里有个容易被忽略的点description字段极其重要。模型选择工具的准确度很大程度上取决于你把这个技能描述得多清楚。我一开始写getTime这个技能的描述时偷懒写了获取时间结果模型在用户问现在几点的时候经常不调用工具直接凭训练数据瞎猜一个时间。后来我把描述改成了获取当前设备的本地时间和日期。当用户询问几点了今天星期几当前日期等问题时必须调用此工具使用准确率立刻上来了。写技能描述的第一原则是明确边界、给出触发条件示例。2.2 注册机制一个中心数组搞定所有技能管理技能注册不是一个复杂的系统一个数组加一个 lookup 方法就够了。每个新技能只需要在初始化 AgentEngine 时传入引擎内部会维护一个按 id 索引的字典。MainActor final class AgentEngine: Observable { private var skills: [String: any AppSkill] [:] private(set) var messages: [ChatMessage] [] private(set) var phase: AgentPhase .idle func register(_ skill: any AppSkill) { skills[skill.id] skill } func skill(for name: String) - (any AppSkill)? { skills.values.first { $0.name name } } }AgentEngine 本身是MainActor隔离的所有状态变更都在主线程完成这就让消息列表和界面状态天然线程安全。Skill 的执行方法虽然也是异步的但复杂的 IO 操作可以在 Skill 内部通过Task.detached或者自己管理线程来做对外暴露的 async 接口不阻塞主线程。把技能注册设计成运行时注入而不是编译期枚举还有一个好处便于按权限范围动态增减技能集合。比如在 macOS 版本上注册系统控制类技能在 iOS 版本上不注册或者根据用户是否是会员决定是否注册高级技能。引擎本身不知道技能具体实现了什么它只负责把模型要调用的工具名翻译成 Skill 实例。2.3 把 Skill 集合转换成模型能理解的 tools 参数把 Swift 的协议对象转换成 API 请求里的 tools 参数需要一步序列化。因为AppSkill扩展性很强我在引擎层把它们统一映射为标准的 tool JSON 结构{ type: function, function: { name: create_note, description: 快速保存一条文本笔记到本地存储。, parameters: { type: object, properties: { title: { type: string }, content: { type: string, description: 笔记正文内容 } }, required: [content] } } }这里 parametersSchema 在 Swift 侧是一个 JSON 字符串序列化时要先解析成字典再合并进大结构不能直接当作字符串塞进去否则模型端接收到的 schema 格式是坏的。我最初就犯过这个错把 schema 当成字符串拼进 JSON结果模型返回的参数解析成功率直接掉到五成以下。正确的做法是先JSONSerialization.jsonObject把它变成[String: Any]再作为 parameters 的值。2.4 关键决策为什么不让 Skill 持有 UI 引用Skill 设计上有一条红线技能不能持有任何 SwiftUI 视图引用不能直接修改界面状态。所有 UI 更新必须通过 AgentEngine 的状态层传导。理由很简单技能是可复用的逻辑单元一旦和具体视图耦合换个界面就得重写技能。而且在高并发的执行场景下从后台线程直接碰主线程的 UI 对象很容易崩。正确的交互方式是Skill 执行时通过返回结果文本告知引擎我做了什么引擎把结果作为一条 assistant 消息的延续传回给模型。如果某个技能需要用户额外确认比如删除文件这种危险操作那就设计成技能返回一个需要确认的标记由 UI 层检测到标记后再弹出确认框确认后再次触发同一技能的幂等执行。这个模式我后面会详细讲。3. 五个实用 Skill 的取舍哪些值得内置哪些不应该做3.1 内置技能清单每类技能解决什么场景为了验证 Agent 引擎的通用性我先内置了五个差异比较大的技能覆盖查询类、控制类、存储类、联网类四种形态。这样能测试引擎在快慢不一的技能之间切换的稳定性。技能类型作用复杂度时间查询查询类获取本地日期、时间、星期低系统外观切换控制类切换深色/浅色模式低快速笔记存储类保存一段文本到本地低天气查询联网类根据城市名获取实时天气中网页摘要联网类抓取网页正文并自动摘要高选这五个是有讲究的时间查询验证最基础的参数解析系统外观切换验证真实改变设备状态的能力快速笔记验证数据持久化天气和网页摘要验证网络请求和外部数据解析。五个技能基本覆盖了调用-执行-返回-再生成的完整链路。3.2 时间查询与系统外观切换基础技能的参数设计范例时间查询看似简单参数设计上却有一点经验值得分享。模型的参数生成不是完全可预测的如果技能定义得过于宽松返回的参数格式会很混乱。我最终的参数定义是所有技能里最严格的struct TimeSkill: AppSkill { let id time let name get_current_time let description 获取当前设备的本地时间和日期。当用户询问几点、日期、星期时必须调用。 let parametersSchema { type: object, properties: { format: { type: string, enum: [short, full], description: short 只返回时间full 返回日期和时间 } } } func execute(arguments: [String: Any]) async throws - String { let formatter DateFormatter() formatter.locale Locale(identifier: zh_CN) let format arguments[format] as? String ?? short if format full { formatter.dateFormat yyyy年M月d日 HH:mm EEEE } else { formatter.dateFormat HH:mm } return formatter.string(from: Date()) } }注意给format字段加了enum约束。这样做的好处是把模型的选择空间收敛到两个合法值避免它自由发挥生成2024年上午10点半这种格式化字符串大幅降低解析成本。系统外观切换这个技能稍微特殊因为它要修改 UI 风格而技能本身又不应持有 UI 引用。我的做法是让技能通过UserDefaults写入一个主题偏好再由 SwiftUI 侧监听这个偏好自动切换。技能只负责做决定 写状态界面的响应交给 SwiftUI 的响应式机制struct AppearanceSkill: AppSkill { let id appearance let name toggle_dark_mode let description 切换深色模式和浅色模式。用户要求开深色、关深色、切换外观时调用。 let parametersSchema { type: object, properties: { style: { type: string, enum: [dark, light] } }, required: [style] } func execute(arguments: [String: Any]) async throws - String { guard let style arguments[style] as? String else { throw SkillError.invalidArguments } let isDark style dark UserDefaults.standard.set(isDark, forKey: userAppearanceDark) return isDark ? 已将系统外观切换为深色模式 : 已将系统外观切换为浅色模式 } }这里有一个我自己很满意的小设计技能执行完不做任何Published通知视图层通过订阅UserDefaults的键值变化自动刷新。这样技能保持纯粹、可复用UI 层也保持简洁。3.3 快速笔记持久化能力的典型实现快速笔记验证的是数据写入能力。这个技能的边界是只负责把内容写进本地存储不负责读取历史。技能定义了两个参数一个可选标题一个必填内容。因为本地文件写入本身很快所以可以直接在方法里同步完成struct NoteSkill: AppSkill { let id note let name create_note let description 快速保存一条文本笔记到本地相册目录。适用于用户说记一下保存这个帮我写个便签等场景。 let parametersSchema { type: object, properties: { title: { type: string, description: 笔记标题可选 }, content: { type: string, description: 笔记正文必填 } }, required: [content] } func execute(arguments: [String: Any]) async throws - String { let content arguments[content] as? String ?? let title arguments[title] as? String ?? 未命名笔记 let filename note_\(Date().timeIntervalSince1970).txt let text # \(title)\n\n\(content) let url FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent(filename) try text.write(to: url, atomically: true, encoding: .utf8) return 笔记已保存标题\(title)文件名\(filename) } }写这个技能时我特意没有引入数据库用纯文本文件就够了。如果你的 App 本身有 Core Data 或者 SwiftData完全可以把写入逻辑换成对应的存储方案接口不用变。关键是让返回给模型的结果文本包含足够的信息标题、文件名这样模型才能向用户准确汇报保存到了哪里。3.4 联网类技能的边界处理不是所有信息都适合让 Agent 全权处理天气查询和网页摘要需要网络请求。这两个技能我总结出一个重要设计原则联网技能必须是查询后整理而不是查询后盲信。即技能执行完成后返回给模型的结果应当包含原始数据的关键部分让模型基于事实回答而不是让模型凭空总结。天气查询的实现思路是接收一个城市名参数通过一个免费天气接口拿到 JSON解析出温度、湿度、天气状况然后拼成一段结构化文本返回给模型。我把模型生成的参数城市名和真实接口返回的数据分开处理——城市名允许模型自由发挥但天气数据必须来自真实接口两者不可混淆。func execute(arguments: [String: Any]) async throws - String { guard let city arguments[city] as? String else { throw SkillError.invalidArguments } // 实际编码时需要对城市名做 URL 编码 let encoded city.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? city let url URL(string: https://example-weather.api/v1/current?city\(encoded))! let (data, _) try await URLSession.shared.data(from: url) let json try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] // 提取温度、天气状况等拼成结果文本 let temp json[temperature] as? Double ?? 0 let desc json[condition] as? String ?? 未知 return 城市\(city)当前温度\(temp)℃天气状况\(desc) }网页摘要这个技能我最终没有放在默认启用列表里只在手动开启时注册。原因是网页抓取的不确定性很大有些站点会拦截请求、有些页面是动态渲染、有些正文超长会影响 token 消耗。我的建议是像这类高成本、高不确定性的技能应当让用户显式确认后再执行或者做成先返回网页主要信息再询问用户是否需要完整摘要的两段式流程。3.5 哪些技能我刻意没有内置有正例就有反例。我刻意不内置三类 Skill一是邮件发送、短信发送这类有不可逆后果的操作因为一旦模型判断失误影响无法撤销二是涉及账号密码、支付信息的操作这些必须走系统自带的专门流程没必要让模型经手三是完全可以通过系统分享表单完成的操作比如保存图片到相册强行做成技能反而绕远路。我的经验结论是一个 Agent 应用的技能列表应该是少而关键的。每多一个技能模型在选择时就要多一分判断成本误选的概率也会上升。初期控制在五个以内跑通后再逐步加这样系统的稳定性最好。4. 流式响应与 UI 刷新类型安全地在 SwiftUI 里吃下 SSE 数据流4.1 SwiftUI 状态模型用 Observable 表达 Agent 的每个阶段Agent 的执行过程和普通的发一条消息等回复不太一样它内部有多个阶段思考中、工具调用中、等待工具结果、继续生成、完成。这些状态如果不用结构化的方式表达UI 会变得很难写。我给引擎定义了一个枚举enum AgentPhase: Equatable { case idle case thinking case executingSkill(String) // 正在执行哪个技能 case responding case finished case failed(String) }界面可以根据phase渲染不同的状态提示。比如executingSkill(get_current_time)时我在消息气泡上方显示一个小的技能调用卡片写着正在获取时间…。这个 UI 细节极大提升了Agent 感——用户能直观看到模型正在做什么而不只是一个转圈的菊花。用 Observable 宏之后AgentEngine 可以直接用State持有State private var engine AgentEngine(model: ...) var body: some View { MessageList(messages: engine.messages) .overlay(alignment: .top) { if case .executingSkill(let name) engine.phase { SkillStatusCard(skillName: name) } } }因为AgentEngine是MainActor隔离的SwiftUI 的 body 求值也在主线程这里天然是线程安全的。所有状态变化都在主线程也就不会出现后台线程更新 UI的经典崩溃。4.2 SSE 解析增量缓冲、事件切分与错误容忍流式响应的底层是 SSEServer-Sent Events。我用 URLSession 的bytes序列来做逐行读取每一行解析出来可能是三种类型空行事件分隔符、data:前缀的数据行、:开头的注释行。核心解析器大概是这样的func streamResponse() async throws { let request buildChatRequest() let (bytes, response) try await URLSession.shared.bytes(for: request) guard let http response as? HTTPURLResponse, http.statusCode 200 else { throw AgentError.badResponse } var eventBuffer for try await line in bytes.lines { if line.isEmpty { // 一个 SSE 事件结束处理 buffer 里的完整数据 if let jsonData eventBuffer.data(using: .utf8) { processEvent(jsonData) } eventBuffer } else if line.hasPrefix(data:) { let payload line.dropFirst(5).trimmingCharacters(in: .whitespaces) // 忽略 [DONE] 标记 if payload [DONE] { break } eventBuffer payload } } }这段代码有几个细节要注意。第一bytes.lines是按行迭代的天然适配 SSE 的换行分隔。第二data:前缀剥离后可能是一个完整的 JSON chunk也可能只是半个 JSON——因为模型服务端常常把一个 data 事件的内容分多次推过来。所以 processEvent 内部需要做增量 JSON 累积每次先尝试把当前 buffer 当作完整 JSON 解析失败就继续等待下一段private var jsonAccumulator func processEvent(_ rawData: Data) { // 尝试直接解析 if let obj try? JSONSerialization.jsonObject(with: rawData) { handleChunk(obj) return } // 否则把数据追加到累积区尝试拼成完整 JSON jsonAccumulator String(decoding: rawData, as: UTF8.self) if let obj try? JSONSerialization.jsonObject(with: Data(jsonAccumulator.utf8)) { handleChunk(obj) jsonAccumulator } }第三个注意点是[DONE]标记的处理。目前大多数兼容 OpenAI 协议的服务端都会在最后发一个data: [DONE]必须在循环里显式 break否则迭代会一直等下去。4.3 流式 delta 与工具调用的分流处理SSE 事件里有两种有效的 delta文本内容 delta 和工具调用 delta。文本 delta 直接追加到正在生成的消息文本上if let delta chunk[delta] as? [String: Any] { if let content delta[content] as? String { currentText content // 更新消息列表最后一条的内容 } if let toolCalls delta[tool_calls] as? [[String: Any]] { // 收集工具调用片段 } }工具调用 delta 比较特殊它是分段到达的。模型会先发一个空的tool_calls数组项带 index 和 id然后又发几段function.arguments字符串碎片。必须在内存里按 index 累积这些碎片等流结束后才能拿到完整的工具调用参数 JSON。这个累积逻辑写得不仔细就会出现参数残缺、JSON 解析失败的问题。我遇到的一个很有意思的坑是某些模型服务端在流式返回工具调用参数时会把arguments字段拆得非常碎一个{city: 北京}能拆成七八段发过来。解决方案就是上面说的累积器模式把所有碎片按 index 拼起来再统一解析而不是每收到一段就尝试解析一次。4.4 打字机效果与列表滚动两个体验细节流式文本直接拼接上去之后SwiftUI 的 List 不会自动滚到底部。需要监听消息列表的变化用ScrollViewReader滚动到最新一条的 anchor。我踩过的坑是滚动时机如果每次 delta 到达都触发滚动视觉上反而会抖动因为模型生成速度快的时候一秒钟可能有几十次更新。我的做法是节流——只在新消息创建、以及文本长度每增加约 30 个字符时滚动一次人为制造一种顺滑跟随的节奏。.onChange(of: engine.messages.last?.text.count) { _, newCount in guard let newCount else { return } let target (newCount / 30) * 30 // 每30个字符滚动一次 if newCount target || newCount 0 { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(lastMessageID, anchor: .bottom) } } }打字机效果本身不需要额外处理流式 SSE 天然就是边收边显示。真正需要注意的只有滚动和增量的频率控制。另外用户滑上去看之前的消息时不应该被强制拉回底部所以滚动逻辑还要加一个用户是否在底部的判断这个用简单的滚动偏移阈值就能实现不复杂但很影响体验。5. 实测踩坑记录并发隔离、JSON Schema 解析与状态竞态5.1 最头疼的坑工具调用循环到底怎么退出Agent 决策循环的本质是请求—判断—执行—再请求的循环。循环本身好写但退出条件设计不好就会出鬼故事。我最初的实现只判断如果响应里没有 tool_calls 就结束结果在一次测试中模型连续调用了五次同一个技能完全停不下来。后来我加了两道保险第一设置最大迭代次数默认 5 次超过直接终止并把已生成的内容返回给用户第二检测到重复调用同一技能且参数相近时第二次开始主动提示模型该技能最近已被调用过如果结果没有变化请直接回答用户不要重复调用。这两条叠加之后循环失控的问题基本消失了。private var iterationCount 0 private let maxIterations 5 func runAgentLoop() async throws - String { iterationCount 0 while iterationCount maxIterations { iterationCount 1 let response try await callModel() if let toolCalls response.toolCalls, !toolCalls.isEmpty { for call in toolCalls { let result try await executeSkill(call) appendToolResultMessage(call.id, result) } } else { return response.content } } return 抱歉操作次数过多我暂时停下来了。你可以让我重新进行其中一步。 }这个循环是整个 Agent 引擎的心脏值得仔细打磨。每次迭代的callModel都要携带完整的消息历史包括之前迭代产生的 tool 调用记录模型才能理解上下文。为了不把消息列表无限撑大我会在每次迭代后把系统提示词合并压缩成一条消息减少 token 消耗。5.2 并发坑MainActor 内部再派生子任务时的数据竞争Swift 6 的严格并发检查非常严格。AgentEngine 标注了MainActor但 Skill 的execute方法是 async 的实现内部会做网络请求或文件操作。这样就会出现一个常见错误在 Skill 内部尝试访问主线程的共享状态。因为 Skill 并不继承 AgentEngine 的 MainActor 隔离域它可能是任意线程上执行的。Swift 编译器的报错信息又长又抽象第一次遇到很容易懵。解决方法是严格执行Skill 不碰共享状态这条纪律。Skill 需要的所有输入都通过arguments传入所有输出都是纯文本结果。如果某个 Skill 确实需要读取 App 的配置那就把配置作为参数在注册时传入或者在执行前由引擎去读取并附加到 arguments 里。这样 Skill 的隔离边界非常干净Sendable约束也好满足。还有一个并发相关的坑URLSession 的bytes(for:)返回的URLSession.AsyncBytes不是Sendable的在严格并发模式下把 bytes 传给其他 Task 会编译报错。我的处理是streamResponse方法整个作为一个 actor 隔离的方法运行不在方法内部创建新的 Task 去消费 bytes。如果确实需要并行处理就在调用层用TaskGroup管理每个 Task 内部独立建立 URLSession 请求。5.3 JSON 解析的宽容策略大模型的参数生成不总是合法的模型返回的工具调用参数在真实世界中远比你 schema 定义的更放飞。我实际遇到过的情况包括但不限于{format: short}变成{format: true}city: 北京变成city: [北京]更离谱的还有把两个参数合并成一个字符串的。这些情况如果代码里不做任何容错用户体验就是点了一下发送然后 Agent 显示技能执行失败。我的宽容策略分三层第一层字符串字段如果类型不对就转成String(describing:)能用就用第二层如果数值字段解析不出来就直接设为默认值比如时间技能的 format 默认 short第三层实在无法解析才抛错误并且错误信息要写明是哪个参数不合法这样模型在下一轮循环中能根据错误信息自我纠正。func extractString(from args: [String: Any], key: String, defaultValue: String ) - String { guard let raw args[key] else { return defaultValue } if let s raw as? String { return s } if let n raw as? NSNumber { return n.stringValue } return String(describing: raw) }有人可能会觉得这种宽容策略会掩盖真正的问题但我的看法是模型参数生成本质上是一个概率过程宽容解析相当于给这个概率过程加了一层鲁棒性保护。真正重要的字段比如删除文件的路径可以严格解析不重要的展示字段宽松处理这样既安全又好用。5.4 stop 按钮的实现与 Task 取消的一个隐蔽 bugUI 层做了一个停止生成按钮对应到引擎是取消当前正在执行的任务。看似简单实际实现里面藏着一个隐蔽 bug。我最初用task.cancel()取消外层调用但底层 URLSession 的网络请求并不会自动取消——它继续在后台下载完整个流只是没人消费了浪费流量和电量。后来查明原因外层 Task 的取消需要传递到 URLSession 的请求上才行。正确的做法是在发起请求前就用Task包装并把请求放到里面或者在收到取消信号后专门取消 URLSession 的 data task。我的实现用了后者用一个URLSessionDataTask保存当前请求的引用private var currentDataTask: URLSessionDataTask? func stopGenerating() { currentTask?.cancel() currentDataTask?.cancel() phase .idle }另一个容易忽略的点是取消后的状态清理。用户在生成中途点击停止这时如果直接把 phase 设为 idle消息列表里会留着一条半截的 assistant 消息看起来像话说了一半就没了。我更推荐的做法是保留这条半截消息但把它的状态标记为已停止UI 上显示一个已手动停止的小标记。这样用户能明白发生了什么而不是以为自己看到了一条损坏的消息。5.5 实测中的性能数据Token 消耗和响应延迟我专门记录了五轮典型对话的 token 消耗和耗时给一个直观的概念。测试环境是模拟器 iPhone 16 Pro模型用中等等级的通用对话模型五轮对话包含两次工具调用操作延迟秒输入 token输出 token纯文本对话无工具调用1.8-2.61200180时间查询一次工具调用3.2-4.51400260天气查询一次工具调用联网5.5-8.01500320快速笔记存储结果回传4.0-5.81450300连续三次工具调用8.5-122200480从数据能看出每多一轮工具调用输入 token 就会增加约 300-400。这是因为每次迭代都要把前面的任务结果重新发给模型。如果对话很长token 消耗会相当可观。我的优化方案是超过三轮工具调用后把历史工具结果压缩成简短的摘要而不是完整重新发送。这个压缩逻辑可以放在引擎内部对上层透明。6. 跨设备适配与发布约束iOS、macOS 两端的差距比想象中大6.1 iOS 与 macOS 上 Skill 能力边界的不同同一个 Agent 引擎在 iOS 和 macOS 上能注册的技能集合天然不同。iOS 上可以调用系统照片、快捷指令、手电筒等移动能力macOS 上则更适合调用文件系统、App 间通信、终端命令等桌面能力。直接一个技能列表两端共用体验会很奇怪——在 iPhone 上问帮我打开手电筒很好用但在 Mac 上模型就会尝试调用一个根本不存在的技能。我目前的处理是分层注册引擎核心技能两端共用时间、日历、笔记平台特有技能通过条件编译注册#if os(iOS) engine.register(FlashlightSkill()) engine.register(ShortcutsSkill()) #endif #if os(macOS) engine.register(FileSearchSkill()) engine.register(OpenAppSkill()) #endif这样做的额外好处是不同的系统权限模型也能被妥善处理。iOS 的手电筒技能需要相机权限说明macOS 的文件搜索需要用户授权文件夹访问这些都可以在对应 Skill 的 execute 方法里自行处理。6.2 沙盒、网络权限与 ATS 配置的注意事项macOS 的 App Sandbox 默认禁止网络访问和任意文件读写第一次在 macOS 上跑天气技能时直接报网络错误排查了半天才发现是沙盒权限没开。调试期可以先在 Signing Capabilities 里把 Network Client 打开文件访问用用户选中的文件夹权限。iOS 这边的坑主要集中在内网测试的 ATSApp Transport Security。如果模型 API 的某一个技能走 HTTP 明文请求比如本地测试服务需要在 Info.plist 里配置例外或临时允许。但这里有一个安全底线正式上架版本绝对不能用NSAllowsArbitraryLoads全量放开我只对特定域名做例外。这类问题越早发现越好别等到测试机跑的开心、上架审核时被打回来。6.3 App 分发的现实问题API Key 往哪放这是所有个人 AI 项目绕不开的现实问题。把 API Key 写死在 App 里任何拿到安装包的人都能通过抓包提取出来后果是被盗刷。最稳妥的方案是走你自己的服务端做转发App 只和服务端通信。但个人项目往往没有服务端条件我的过渡方案是App 启动时让用户自己填写 API Key只存在 Keychain 里后续请求带上服务端生成的短期令牌做代理。这个方法体验略微繁琐但安全性比硬编码强得多。给一个人短期自用工具的建议可以用 UserDefaults 存虽然不如 Keychain 安全但方便但一定要在关于页明确提示风险。给一个可能分发出去的工具别省这一步老老实实做代理服务或者用服务商提供的安全机制。7. 几个还挂在路线图上的想法以及我的一些体会这个项目做到现在最让我满意的地方是整个 Agent 引擎大概只有六七百行 Swift 代码却撑起了一个完整的能自己决定调什么工具、什么时候调、调完怎么汇报的智能体。这种从零开始搭一个 Agent 基础设施的工程过程比直接用别人封装好的框架更能让你理解工具调用机制的本质。那不是一个黑盒而是一个非常清晰的模型输出结构化指令 → 本地安全执行 → 结果回传继续推理的循环。接下来我还想做的几件事也算给看文章的同行提供几个思路。第一是给 Skill 加一个技能依赖概念——某些技能执行前需要先执行另一个技能获取前置数据比如天气技能需要先调用定位技能拿城市名。第二是让决策循环可中断可恢复用户可以在技能执行中插入指令Agent 会重新规划而不是死等当前技能跑完。第三是引入多模型调度能力同一个 Agent 里简单闲聊用轻量模型复杂工具调用用更强模型能省不少费用。最后说一个我在做这个项目时的体会Agent 应用和传统 App 最大的不同在于它的行为不是代码写死的而是模型根据上下文实时决策的。这就意味着你要把一部分控制权让渡给模型但又不能全让。技能注册中心就是那个可控的让渡——模型只能在你注册的技能范围内活动每次活动都有明确的结构和限制。想清楚这个边界你的 Agent 应用就成功了一半。