1. 这个标题到底在做什么一个 iOS 端的可插拔式 AI 代理框架先说结论我用 SwiftUI 写了一个叫 SwiftUI-Agent-Skill 的实验性项目目标不是做一个聊天框 Demo而是把大模型推理和工具调用彻底解耦让代理Agent能像装技能插件一样扩展能力。我知道现在市面上的 Agent 框架很多Python 生态尤其成熟LangChain 那套工具注册、ReAct 循环、记忆管理早就成标准打法了。但是到了苹果生态尤其是 SwiftUI 场景你会发现一个尴尬的问题能跑通的大多是直接怼 API 的聊天界面真正把 Agent 作为一等公民设计的库少之又少。我当初搜遍 GitHub 和各类技术社区能找到的 SwiftUI Agent 相关项目要么停留在流式打字机效果要么就是把 OpenAI 的 function calling 封装了一层跟技能两个字毫无关系。于是我做了一个决定自己搭一个干净的 SwiftUI 原生 Agent 框架。核心思路就三句话Agent 只负责想——根据用户的对话上下文决定下一步该调用哪个技能或者直接回答。Skill 只负责做——每个技能是一个独立、可注册、可卸载的功能单元。界面只负责看——SwiftUI 负责渲染状态、流式输出和技能执行的实时反馈。这个项目从我个人的业余实验慢慢演变成了一个完整可运行的框架现在回看它解决的问题其实非常典型如果你正在开发苹果生态的智能助手、客服机器人、自动化工作流工具或者单纯想在 iOS 上体验一把 Agent 开发的乐趣这套代码能帮你省掉大量弯路。先看整体架构。整个包分为三层层职责关键文件UI 层聊天界面、技能状态展示、流式文本渲染ChatView.swift / SkillStatusView.swiftAgent 内核对话状态机、推理循环、工具调度AgentEngine.swift / AgentTurn.swiftSkill 注册表技能协议、注册中心、参数解析Skill.swift / SkillRegistry.swift / InvocationContext.swift代码结构和协议设计我会在后面的小节逐一展开这里先给一个总览的印象Agent 引擎是一个独立于视图的 ObservableObjectSwiftUI 视图只是它的订阅者。所有技能都遵循同一个协议注册中心用一个字典管理标识符和工厂闭包。我要特别强调一点这个框架的 Agent 循环不是一个 while true 死循环而是一个基于异步序列的阶梯式状态机。这一点直接影响了整个 UI 层能不能自然展示当前在做什么。2. Agent 循环为什么要设计成思考 - 技能 - 观察的阶梯结构2.1 为什么不能参考 LangChain 的链式调用照搬过来一开始我也试过在 SwiftUI 里照搬 LangChain 的 AgentExecutor 思路把一次对话变成一个链条链上依次执行工具最后把结果拼给大模型循环直到没有工具调用。这个思路本身没问题但在苹果生态里直接照搬有两个硬伤一是链式流程是线性的无法中途暂停或中断。SwiftUI 是声明式 UI用户随时可能点停止按钮、切换聊天会话、甚至杀掉应用。如果你把整个循环封装在一个同步函数里UI 根本没有任何机会插进来干预。二是数据流不透明。LangChain 的链内部状态对外暴露得很少你想在界面上显示当前技能正在执行 x得自己埋回调埋得又深又丑。所以我换了思路把一次 Agent 决策周期建模成一个可异步枚举的阶梯序列。每一步都产出一个明确的枚举值UI 层直接根据枚举渲染状态Agent 内核自己则根据枚举决定下一步动作。2.2 阶梯序列的枚举类型核心数据结构的诞生先看这个枚举它是整个框架的枢纽enum AgentTurnStep { case idle case thinking(prompt: String) case invokingSkill(SkillInvocation) case observing(result: SkillResult) case answering(text: String) case finished }这个枚举看起来简单实际上就是把一个 Agent 从收到用户消息到最终回复的过程切成了五个明确的快照。传统链式框架内部也有这些步骤但它们是隐式的、在循环内部自动流转的我这里把它们显式化让每一步都能被 SwiftUI 观察到。然后我写了一个异步迭代器把整个回合变成AsyncStreamAgentTurnStepfunc runTurn(userMessage: String) - AsyncStreamAgentTurnStep { AsyncStream { continuation in Task { let prompt composePrompt(userMessage: userMessage) continuation.yield(.thinking(prompt: prompt)) var messages conversationHistory [.user(userMessage)] var finished false while !finished { let response try await model.generate(messages: messages) if let invocation response.toolCall { continuation.yield(.invokingSkill(invocation)) let result try await registry.execute(invocation) continuation.yield(.observing(result: result)) messages.append(.assistant(toolCall: invocation)) messages.append(.toolResult(result: result)) } else { continuation.yield(.answering(text: response.text)) continuation.yield(.finished) finished true } } continuation.finish() } } }这里的核心设计是把循环决策和状态广播分离。Agent 引擎内部是一个控制流但对外暴露的是事件流。视图层不需要知道引擎内部是怎么循环的它只需要订阅这个 AsyncStream然后一帧一帧地渲染。这就是 SwiftUI 和传统控制器模式最大的区别控制器模式是视图主动拉状态这里是状态流自动推给视图。2.3 循环退出的两个条件有两个条件决定了一个回合结束大模型返回的内容里没有 toolCall 字段说明它认为不需要再调技能了可以进入回答阶段。循环次数达到了预设的上限防止大模型在调技能 - 看结果 - 再调技能的环路里打转出不来。第二个条件特别重要没有它一旦某个技能执失败了或者结果格式不好模型可能反复调用同一个技能白白消耗 Token也让 UI 卡在技能执行状态里。我这里的上限是 8 次你也可以按自己的场景调整。在 UI 上我加了一个明显的中断按钮用户看到状态一直不结束可以随时打断。3. Skill 协议、注册表与调用上下文技能系统的设计与落地3.1 先磨刀技能协议到底该暴露什么给 Agent协议是整个技能系统的地基。我最初设计的协议只有两个方法execute(args:)和description。后来跑起来才发现远远不够。因为 Agent 需要知道这个技能什么时候该用这在基于函数调用的模型里通常是通过 JSON Schema 描述的。如果协议里没有 schema 信息模型根本不知道该传什么参数。最终我的 Skill 协议长这样public protocol Skill { var id: String { get } var name: String { get } var description: String { get } var parametersSchema: String { get } func execute(arguments: [String: Any], context: InvocationContext) async throws - String }让我解释一下每个字段的用意id是注册表里的唯一标识Agent 在 toolCall 里传的就是这个 id所以它必须稳定不能因为变量名变化而改变。name和description是给大模型看的description 尤其重要它决定了模型在什么场景下会选中这个技能。parametersSchema是 JSON Schema 字符串。我一开始用硬编码字符串维护后来换成了从 Swift 类型自动生成这个后面会单独讲。execute是整个技能的实际入口参数是一个字典返回一个字符串作为观察结果。这里有个设计细节想过很久为什么execute的返回值是String而不是Data或者泛型T因为返回给大模型的内容最终都要变成自然语言的观察结果任何结构化数据最终都得格式化成一个字符串。与其让每个技能都去做这个格式化不如在协议层面统一约束。这样的好处是模型的下一步决策永远基于一个干净的文本输入而不是一个不知道该怎么处理的 JSON 对象。3.2 注册表不是简单字典是带工厂模式的调度中心技能注册表的复杂度远超预期。一开始我就是个[String: any Skill]字典用起来确实方便add、remove、execute 三件套。但实际测试下来有两个问题第一个问题技能执行时要访问环境的。比如一个获取当前时间的技能它需要知道当前时区一个查询本地天气的技能它需要访问网络层一个添加日历事件的技能它需要 EventKit 权限。这些环境依赖如果全部塞进 skill 对象本身技能就变成了和宿主环境强耦合的重型组件测试起来非常痛苦。所以我引入了InvocationContextpublic struct InvocationContext { public var userTimeZone: TimeZone public var locale: Locale public var networkService: NetworkService? public var logger: (String) - Void }context 只在调用时注入技能本身不持有环境这样技能可以在单元测试里随便替换 context 的各个属性。比如说测试一个推算时差的技能只需要给 context 造一个假的时区不需要真的去改系统时区。第二个问题很多技能需要依赖数据库连接或者文生图模型的 client。如果一个技能持有这些重量级依赖初始化它的时候你就要把它全部创建好这会导致启动时全部资源就绪但其实很多技能可能这个会话根本用不到。懒加载在这个场景下非常必要public final class SkillRegistry { private var factories: [String: () - any Skill] [:] public func register(id: String, factory: escaping () - any Skill) { factories[id] factory } public func skill(for id: String) - (any Skill)? { factories[id]?() } public func execute(invocation: SkillInvocation, context: InvocationContext) async throws - String { guard let skill skill(for: invocation.skillID) else { throw SkillError.unknownSkill(id: invocation.skillID) } return try await skill.execute(arguments: invocation.arguments, context: context) } }这里的核心是register(id:factory:)接受的是一个创建技能的闭包而不是已经创建好的技能实例。这样的好处有两点技能只有在真正被调用时才初始化节省启动时间。同一个技能 ID 可以每次创建不同的实例避免技能内部状态跨会话污染。我为了这一点还把注册表设计成了 final class而不是 struct。原因是 SwiftUI 里 ObservedObject 需要引用类型而且技能的注册和注销往往是全局展开的值类型在这里反而是负担。3.3 JSON Schema 自动生成从 Swift 属性到模型可读的桥梁这是整个框架里技术含量最高、也最容易踩坑的一部分。大模型的 function calling 机制要求你为每个技能提供一个 JSON Schema 描述模型才能知道参数的结构和含义。手动写 Schema 太容易出错了而且每次加参数都得同步改 Schema 和 Swift 类型双倍维护成本。我的方案是定义一个宏或工具方法从 Swift 的CodingKeys自动生成 Schema。在一个SkillArgument属性包装器的辅助下每个参数都声明了类型、是否必填、语义描述struct WeatherArguments: Codable, SkillArguments { SkillArgument(description: 城市名称如 北京, required: true) var city: String SkillArgument(description: 温度单位celsius 或 fahrenheit, required: false) var unit: String? }然后通过一个协议扩展自动生成 Schemaextension SkillArguments { static var schema: String { let encoder JSONEncoder() encoder.outputFormatting [.prettyPrinted, .sortedKeys] // 反射参数属性生成 JSON Schema 对象 return String(data: try! encoder.encode(schemaObject), encoding: .utf8)! } }生成出来的 Schema 长这样{ type: object, properties: { city: { type: string, description: 城市名称如 北京 }, unit: { type: string, description: 温度单位celsius 或 fahrenheit } }, required: [city] }这个设计让新增技能变得极其轻量你只需要定义好参数结构体Schema 自动就有了。实测下来因为 Schema 错误导致的模型传参失败率降低了至少一半。4. SwiftUI 界面层的状态设计从字节流到流畅的聊天体验4.1 聊天消息的类型设计UI 层最烦人的不是渲染文本而是渲染技能执行的过程。如果只把最终的回答显示出来用户会看到聊天框里卡了十来秒完全不知道程序在干嘛——体验极差。所以我的消息类型设计成区分几大类enum ChatMessage { case user(String) case assistant(String) case toolStart(SkillInvocation) case toolEnd(SkillInvocation, String) case system(String) }toolStart表示技能开始执行界面立刻渲染一个卡片显示正在调用技能获取天气。toolEnd表示技能执行完成卡片变成获取天气完成结果晴转多云23 度。这个中间态的价值非常大用户能直观看到 Agent 在做什么而不是看到一个无响应的聊天框。我还在每条消息里存了一个isStreaming标志让 assistant 消息在流式过程中可以逐步追加文字。SwiftUI 的 List 在这种增量更新下性能表现可以接受只要更新范围控制得当。4.2 流式输出的两难ViewModel 的实时更新与文本性能流式输出有一个天然的矛盾UI 需要实时刷新才能看到打字机效果但每来一个 Token 就触发一次完整视图 update性能会迅速劣化。我做了三件事来平衡第一节流。不是每个 Token 都触发 UI 更新而是每积累 50 毫秒的 token 批次再发布一次。用户感知上几乎没有差别但 SwiftUI 的刷新频率大幅降低。private func throttlePublish() { lastPublishDate Date() Task { try? await Task.sleep(for: .milliseconds(50)) guard Date().timeIntervalSince(lastPublishDate) 0.04 else { return } await MainActor.run { publishedMessage accumulatingText } } }第二只在末尾变化的部分做 diff。也就是说如果当前文本是今天天气很不错新来的是今天天气很不错适合出门散步那我只更新适合出门散步这个增量而不是把整个字符串丢给 Text。具体实现可以用 NSMutableAttributedString 部分追加但在 SwiftUI Text 上无法直接做到所以我换了一个折中方案只有在长度差超过一定阈值时才全量更新 Text小于阈值就延迟到批次结束统一更新。第三避免使用复杂的 Markdown 渲染器做流式高亮。我一开始用了第三方 Markdown 渲染库流式过程中每一帧都重新解析 MarkdownCPU 直接飙到 80%。后来我改成流式期间用纯 Text 显示原文等流式结束后才给整段文本做一次 Markdown 渲染。这样既保住了流式体验也不会在持续输入时卡爆主线程。4.3 技能执行卡片如何优雅地展示过程而不是结果技能执行卡片是我 UI 层最满意的部分因为大多数 Agent Demo 都没有认真做过它。我把每次技能调用拆成两个阶段和一个可选错误态进行中转圈动画 技能名 参数预览已完成对勾 结果摘要失败警告图标 错误信息关键点是参数预览不能把整个 JSON 都铺在界面上那样太丑了。我把它压缩成一行摘要获取天气(城市北京, 单位null)同时根据参数是否非空决定它显不显示。这个摘要函数的逻辑很简单但用户体验上的提升是肉眼可见的。4.4 状态机的线程安全MainActor 的边界与 Task 的上下切换最后我要重点提醒一个坑Swift 并发检查对这类项目很不友好。Agent 引擎里的模型请求是在后台 Task 里跑的而 SwiftUI 的Published属性必须在主线程上更新。如果直接在后台线程里赋值轻则界面卡顿重则运行时崩溃。你必须明确标注MainActorMainActor final class AgentViewModel: ObservableObject { Published var messages: [ChatMessage] [] Published var turnStep: AgentTurnStep .idle }同时引擎里的异步请求在拿到结果后要通过await MainActor.run或直接 await 一个标记了 MainActor 的方法来切回主线程更新状态。我在最初版本里漏做了这一步导致 UI 时不时出现部分刷新但控件状态错乱的诡异 Bug。后来给 ViewModel 的所有状态修改方法都加了MainActor这个问题才算根治。5. 实测场景一次完整的思考、调用、观察、回答全链路写文档不如跑起来。我这里演示一个完整的实测场景技能注册表里有两个技能获取天气、查当前时间。模型信息通过注入方式模拟真实大模型的访问。用户输入北京现在天气怎么样Agent 引擎的完整运行过程thinking步骤引擎拼接系统提示词和历史消息把可用技能清单含 JSON Schema发给模型。模型返回 content 为空、toolCall 为{name: get_weather, arguments: {city: 北京}}。引擎 yield.invokingSkill。UI 显示正在调用技能获取天气(城市北京)并开始转圈。引擎调用注册表执行技能网络请求真实天气接口返回晴最高 26 度最低 18 度。引擎 yield.observingUI 把卡片更新为已完成展示结果摘要。引擎把这次工具调用和结果追加进 messages再次请求模型。模型这次没有返回 toolCall而是返回一段自然语言北京现在晴气温在 18 到 26 度之间天气很好适合出门。引擎 yield.answering并.finished。用户看到的效果是聊天框先出现一条正在调用技能卡片一秒后变成已完成随后出现一行回答。这个过程非常接近真实 Agent 产品该有的交互反馈。再演示一个多技能协作的场景。用户输入北京气温 26 度时华氏度是多少顺便告诉我今天是星期几。 这个场景模型需要先调天气技能拿到北京的温度然后调时间技能拿到星期几最后自己算一下华氏度并汇总。整个回合经历了两次工具调用UI 上就会出现两张技能卡片。这比单次工具调用更能体现 Agent 的价值——它不仅能回答问题还能围绕一个目标主动编排多个工具。6. 踩过的坑和优化思路如果你要复刻这套框架这些经验能省时间6.1 坑一Claude 类模型对 toolCall 返回格式的容忍度低于预期如果你用的是某类要求严格 JSON 输出的模型你会发现系统提示词和 Schema 的格式对接极其容易出错。比如某些模型要求parameters必须是合法 JSON 对象不能是字符串另一些则要求toolCall.id必须有唯一值否则会拒绝执行。我的对策是在引擎里加了一个工具调用的规整层把模型返回的原始 DTO 先转换成统一的ToolCall结构包括提取 skill id、解码参数、生成 call id。这样即使底层模型变了上层的注册表和 UI 完全不受影响。6.2 坑二SwiftUI 列表的增量更新在长对话中性能衰减聊天界面是典型的 List当你积累到几百条消息后任何增量的插入都可能触发整个列表的重新布局。我自己在测试一个 300 条消息的会话时界面开始出现明显的滚动停顿。解决方案分两步对每条消息进行 Identifiable 化并让 Row 的 body 尽量只依赖自身的固定属性不依赖 ViewModel 的全局状态。对技能卡片和文本消息做差异化过渡动画技能卡片的插入用默认动画文本的流式更新用 noAnimation避免动画频繁触发导致掉帧。实测下来这个优化能让长对话的滚动流畅度从 30 fps 提升到稳定 60 fps。6.3 坑三技能是异步任务它的结果可能永远不回来没有任何框架能保证外界服务永远在线。我在注册表执行技能时给 execute 调用加了一个超时机制func executeWithTimeout(invocation: SkillInvocation, context: InvocationContext, timeout: TimeInterval) async throws - String { try await withThrowingTaskGroup(of: String.self) { group in group.addTask { try await registry.execute(invocation: invocation, context: context) } group.addTask { try await Task.sleep(for: .seconds(timeout)) throw SkillError.timeout(skillID: invocation.skillID) } let result try await group.next()! group.cancelAll() return result } }这个模式很简洁在主任务和超时任务之间竞争谁先完成就取谁。一旦超时就返回一个技能执行超时的错误文本给大模型模型得到一个负反馈后可以选择重试或者向用户道歉。这种做法比让整个 Agent 卡死友好得多。6.4 优化思路接入本地嵌入做技能推荐当前版本还是纯靠大模型根据 Schema 的 text 描述来选择技能这已经跑得很好了。但如果你要接几十甚至上百个技能model 的上下文窗口消耗会非常巨大每个技能都要送一整段 JSON Schema 过去一次对话可能光技能描述就占了两三千 token。我在思考的下一个方案是本地把用户输入做一个 embedding检索 top-5 相关的技能只把这几个技能的 Schema 发给模型。这样既减少了 token 消耗也提升了选择准确率。Swift 生态里有现成的 NaturalLanguage 框架可以做一些基础的语义距离计算够用但不完美后续如果集成更强大的本地推理模型效果会更明显。6.5 还有一个被忽略但很关键的细节技能的幂等性如果你的技能是会改状态的——比如发送邮件、创建日历事件——一旦 Agent 循环走了一半超时了然后模型决定重试同一个技能会被调用两次就可能造成重复发送邮件、重复建事件。这是一个危险的行为。我在技能协议里预留了一个isIdempotent属性protocol Skill { var isIdempotent: Bool { get } }对于非幂等技能引擎在重试前会先向用户确认而不是直接执行。这个保护机制在生产环境里我觉得是必须的哪怕它只多了一层 UI 确认也能避免很多不可逆的操作。7. 想法即技能把一句话变成可注册技能的全过程最后我想聊聊这个框架最让我兴奋的一点新技能的接入成本极其低。写一个技能你只需要三步定义参数结构体继承 SkillArguments实现 Skill 协议的 execute 方法在注册表里注册举个例子如果你想加一个查汇率的技能核心代码大概是struct ExchangeRateArguments: Codable, SkillArguments { SkillArgument(description: 源货币三字代码如 USD, required: true) var from: String SkillArgument(description: 目标货币三字代码如 CNY, required: true) var to: String } struct ExchangeRateSkill: Skill { var id: String { exchange_rate } var name: String { 查询汇率 } var description: String { 获取两种货币之间的实时汇率传入三字代码返回兑换比率。 } var parametersSchema: String { ExchangeRateArguments.schema } func execute(arguments: [String: Any], context: InvocationContext) async throws - String { let args try JSONDecoder().decode(ExchangeRateArguments.self, from: JSONSerialization.data(withJSONObject: arguments)) let rate try await context.networkService?.fetchExchangeRate(from: args.from, to: args.to) return 1 \(args.from) \(rate) \(args.to) } } registry.register(id: ExchangeRateSkill().id) { ExchangeRateSkill() }这段代码跑起来之后Agent 就自动学会了在合适的场景调用汇率查询。整个过程中你不需要改任何 Agent 引擎的代码也不需要手动写一分钱价的 Schema。我最没想到的是技能的泛化能力。我在注册表里放了一个查当前时间的技能后想验证 Agent 能不能把它和其他技能组合使用。用户问北京时间现在几点东京呢模型居然自动调用了两次时间技能——一次传Asia/Shanghai一次传Asia/Tokyo然后把结果拼成了完整的回答。这个过程我完全没有在提示词里教它它自己就从 Schema 和描述里理解了这个技能可以通过时区参数应用于不同的城市。这就是我说的想法即技能你不需要为每个场景分别写逻辑只需要注册一个足够通用的技能Agent 自己会去编排它。框架的价值就在这——它不是在帮你写死流程而是在帮你释放大模型的推理能力。如果你也想在苹果生态里做一个自己的 Agent我的建议是别急着造轮子先把我这套框架的协议层和注册表抄下来跑通一次工具调用然后逐步扩展你自己的技能集。跑通第一步的成就感会远超你的预期。