SwiftUI Agent Skill实战:从流式会话到工具调用

发布时间:2026/10/14 9:22:13

SwiftUI Agent Skill实战:从流式会话到工具调用 做SwiftUI的Agent类应用最容易被卡住的不是UI而是“AI能力”和“App本体”之间那条又宽又深的沟。SwiftUI本身只是个声明式界面框架它不负责网络请求、不负责流式解析、更不负责让智能体理解你的App里有哪些功能。所以当我看到“SwiftUI-Agent-Skill”这个命名的时候心里第一反应是这应该是一套把“大模型智能体会话能力”封装进SwiftUI工程的最佳实践Demo。它要解决的核心问题可以概括成三句话用SwiftUI做聊天界面用Agent管理多轮对话与工具调用的流转用Skill机制让智能体真正能操作App内的功能。这个方向也很适合想从“调接口展示文本”进阶到“做真正Agent产品”的iOS开发者来参考无论你是刚开始接触大模型接入还是已经在业务里跑了几个会话流程里面的架构取舍和排查经验应该都有点参考价值。1. 项目整体设计当SwiftUI遇上Agent Skill1.1 这个项目到底要解决什么问题先说一个在真实业务里特别常见的误区很多人把“接入大模型”等同于“发一个请求把返回的文本显示在TextView/Text里”。如果只是做一个聊天玩具这个思路够用。但Agent类应用的核心特征是模型会反过来调用你的工具。比如模型说“我需要查询当前天气才能回答你”然后产出一个结构化的工具调用意图App接收到这个意图后得去定位系统能力或业务服务执行完再把这个结果作为一条特殊消息回灌给模型模型才能真正给出最终答案。这个链路如果全部写在一个ViewController或者一个ViewModel里短期能用但你会被三件事反复折磨状态同步、流式增量更新、工具调用的动态注册。状态同步指的是模型的回复是流式的逐个token往外蹦SwiftUI需要高频刷新界面工具调用的动态注册指的是你不能把每个技能都硬编码在业务逻辑里否则加一个新功能就要改老代码。而“SwiftUI-Agent-Skill”这种命名方式基本就把答案写在脸上了用Skill协议统一所有工具能力让Agent会话层不依赖具体业务只管消息流转和工具分派。这样UI层、会话层、技能层三者解耦各自演进。1.2 技术选型与架构分层在实际搭建时我把整个工程分成了三层职责非常明确。第一层是UI表现层它只做三件事渲染消息列表、处理输入框事件、展示技能调用状态。这一层不关心网络不关心解析也不关心调用链。它持有的只是一个“会话展示模型”或者一个消息数组通过绑定关系驱动界面刷新。第二层是Agent会话核心层这是整个项目的心脏。它负责维护完整的消息历史负责把用户输入发送到服务端负责解析流式返回的数据也负责识别模型返回的“工具调用意图”。这一层对外暴露的方法其实很收敛通常就一个send(content:)但内部要做的事很多缓存上下文、处理取消、管理重试、拼接工具结果。设计上我刻意没有让它依赖任何具体业务它只理解“消息”和“技能签名”。第三层是Skill技能层也是“SwiftUI-Agent-Skill”里Skill存在的意义。每个技能实现一个统一协议注册到一个技能注册表SkillRegistry里。Agent会话层拿到模型产出的工具调用意图后去注册表里查对应名称的技能动态注入参数执行拿到结果再回灌给会话层。这个分层最直接的好处是当产品说“新加一个查询订单状态的技能”时你只需要新写一个Skill实现注册进去UI层和Agent层完全不用改。如果你试图把技能逻辑全部写在聊天页面里那每加一个能力都是灾难我踩过这个坑。2. Agent会话核心状态管理、流式输出与工具调用2.1 消息模型与会话生命周期在SwiftUI里做消息列表第一件事是定义消息模型。最开始我的设计很简单一个id一个role一个content字段。但真实跑起来会发现这个模型根本不够用因为Agent场景下的消息类型比普通聊天多得多。我把消息大致分成了五类用户文本、模型文本、工具调用意图、工具执行结果、系统状态消息比如超时提示、错误提示。每种类型对应不同的UI展示和不同的数据存储逻辑。enum MessageRole: String, Codable { case user case assistant case tool case system } struct ChatMessage: Identifiable, Equatable { let id: String let role: MessageRole var content: String var toolCallID: String? var toolName: String? var createdAt: Date }这个模型的好处是工具调用意图和工具执行结果也可以作为消息参与上下文重建。当Service端要求“整个会话按顺序回传”时你不需要额外拼接直接把消息数组里的所有项按顺序编码过去就行。一个容易忽略的点是toolCallID一定不能丢因为流式返回工具调用时分段数据里都会带上同一个ID你要靠它把多个分段拼成一个完整参数体同时后续回灌执行结果时也要用到这个ID来配对。会话生命周期方面我用了一个AgentSession对象来管理并且把它的实例显式绑定到页面生命周期上。不要把它做成单例因为你未来很可能需要多会话并行比如一个会话处理主对话一个会话做意图识别预处理。我给会话设计了idle、assistantThinking、toolExecuting、error四个状态UI层根据状态切换按钮的可用性和Loading展示。2.2 流式响应的SwiftUI落地流式输出是Agent类App体验的分水岭。如果等模型把所有内容生成完再一次性展示用户面对的就是一个几秒钟甚至十几秒的空白等待这很劝退。如果你想走流式技术上就必须解决两个问题增量回调、UI批量刷新。网络层我用的是基于URLSession的封装这是因为URLSession原生支持bytes(from:)方法可以像读文件一样一行一行读取HTTP流式响应。服务端通常返回的是以data:前缀的Server-Sent Events格式或者一行一个JSON对象的自定义格式。我建议兄弟们在接入前先和服务端约定好格式否则解析会写得很痛苦。这里直接放一个简化的核心逻辑func streamChat(messages: [ChatMessage]) - AsyncStreamString { AsyncStream { continuation in let task Task { var request URLRequest(url: endpoint) request.httpMethod POST request.httpBody try JSONEncoder().encode(messages) // 鉴权头、超时配置等省略 let (bytes, response) try await URLSession.shared.bytes(for: request) guard let httpResponse response as? HTTPURLResponse, httpResponse.statusCode 200 else { continuation.finish() return } for try await line in bytes.lines { if line.hasPrefix(data:) { let payload line.dropFirst(5) continuation.yield(String(payload)) } } continuation.finish() } continuation.onTermination { _ in task.cancel() } } }这里有两个非常关键的经验。第一把Task的引用捕获到continuation.onTermination里这样当页面消失或用户点击停止生成时底层的URLSession请求会真正被取消而不是在后台白白占用连接。第二不要每收到一个增量就马上更新Published数组并让SwiftUI重新渲染性能会肉眼可见地下降。我给流式增量做了一层节流用一个时间窗口或字符数阈值比如每80毫秒或者每积攒20个字符刷新一次实际测试下来既流畅又不闪烁。还有一点容易被忽视如果服务端返回的是整段JSON而不是纯文本增量比如某些模型在返回工具调用参数时会把整个参数体一次性flush出来文本则是逐字增量你在解析时必须能同时处理两种模式。流式文本直接追加到最后一个assistant消息的content字段工具参数增量则要按层级拼JSON字符串等收到终止标记后再统一解析。2.3 工具调用循环的同步与回灌这是Agent和普通聊天最大的区别。我不打算写死“一次请求-响应”这种线性逻辑因为真实模型可能在一次响应里连续要求执行多个工具也可能在拿到工具结果后又发来新的工具调用意图。完整流程应该是一个循环用户说话 - 发送整个消息列表给服务端 - 解析响应。如果响应里含工具调用意图逐条分派给SkillRegistry执行把执行结果追加为tool角色消息把新的消息列表再次发送给服务端重复直到模型返回纯文本。func send(content: String) async throws - [ChatMessage] { messages.append(ChatMessage(role: .user, content: content)) var roundCount 0 while roundCount maxToolCallRounds { let response try await performCompletion(messages: messages) let toolCalls extractToolCalls(from: response) if toolCalls.isEmpty { messages.append(response.message) return messages } else { for call in toolCalls { let result try await skillRegistry.execute(call: call) messages.append(ChatMessage( role: .tool, content: result, toolCallID: call.id, toolName: call.name )) } } roundCount 1 } // 超过最大工具调用轮数必须强制终止并提示用户 throw AgentError.tooManyToolCalls }这个循环里最值得抠的细节有三个。第一个是最大工具调用轮次必须存在否则模型会在某些异常情况下进入死循环不停地调用工具而不给最终答案我设置的上限是5轮超过直接报错。第二个是同一轮里多个工具结果要全部回灌后再发起下一轮请求顺序不要打乱有些模型比较依赖工具调用的前后顺序。第三个是回灌给服务端时tool角色消息必须带上对应的tool_call_id并且工具调用意图的消息也需要原样保留在消息列表中而不是只塞一个结果。这些规则在各大模型平台的函数调用文档里都有写明建议踩坑时优先去核对平台约定。3. 技能系统Skill System的协议设计与注册机制3.1 让技能成为一等公民协议怎么定义才合理“技能”这个词在Agent产品里已经被用得很泛了落到代码上其实是一段可被模型触发、可以复用、有明确输入输出的能力封装。在设计Skill协议时我参考了插件化架构的思路核心是把每个技能做成一个独立类型拥有自己的元信息、输入Schema和执行方法。protocol AppSkill { var name: String { get } var description: String { get } var parametersSchema: String { get } // JSON Schema func execute(arguments: [String: Any]) async throws - SkillResult } struct SkillResult { let content: String let isError: Bool }name是模型用来匹配技能的唯一标识必须全小写且用下划线连接不要有空格和特殊字符description很关键它是模型决定“该不该调用这个技能”的主要依据措辞要写清“在什么时候用、能提供什么信息”parametersSchema则直接决定了模型生成参数的准确性。演示项目里我写了一个天气查询技能模型看到description里的‘查询任意城市的当前天气’就知道该在用户问天气时调用它。execute是真正干活的函数它拿到模型填充的参数调用天气服务API把结果转成一段纯文本返回。3.2 参数解析与失败处理模型给的参数永远要怀疑模型生成工具参数时虽然大部分情况下符合Schema但也经常出现参数缺字段、类型不对、JSON格式坏了等问题。我一开始直接把模型产出的一段JSON字符串做JSONDecoder解码到具体技能的入参结构体结果线上频繁遇到崩溃或错误提示。后来学乖了统一走一层容错解析先脱去空白字符再尝试反序列化如果只是多了个逗号这类非致命错误我会先用正则做一次轻量修正。struct WeatherSkill: AppSkill { let name query_weather let description 当用户询问某个城市的天气时使用返回温度、天气状况和风力 let parametersSchema # {type:object,properties:{city:{type:string}},required:[city]} # func execute(arguments: [String: Any]) async throws - SkillResult { guard let city arguments[city] as? String, !city.isEmpty else { return SkillResult(content: 缺少city参数无法查询天气, isError: true) } // 调用实际天气服务这里略 return SkillResult(content: 上海多云28°C东南风3级, isError: false) } }一个非常重要的兜底逻辑是技能执行失败时不要直接抛异常打断整个Agent循环而是返回一条带有错误描述的结果文本。因为模型需要知道“刚才那个工具失败了原因是什么”才能决定下一步是换一种说法重新调用还是直接回答用户“暂时无法获取天气”。如果直接抛错整个会话就断了体验很差。3.3 技能注册、去重与动态管理SkillRegistry本质是一个并发安全的名字到技能类型的映射表。我用的是Actor来做这样并发执行多个技能调用时读写操作不会冲突。注册时最关键的是“不允许重名”否则会出现同样的技能名被后注册的覆盖掉排查半天都不知道为什么模型调用的行为变了。我会在注册时加一个断言如果同一名称出现两次直接报错提示。actor SkillRegistry { private var skills: [String: any AppSkill] [:] func register(_ skill: any AppSkill) throws { guard skills[skill.name] nil else { throw SkillError.duplicateName(skill.name) } skills[skill.name] skill } func execute(call: ToolCall) async throws - String { guard let skill skills[call.name] else { return 未找到技能:\(call.name)请告诉用户该能力不可用 } let result try await skill.execute(arguments: call.arguments) return result.content } }我强烈建议把“未找到技能”也作为一种结构化结果返回给模型而不是抛异常。因为模型可能由于理解偏差拼写了一个不存在的技能名此时如果能收到“没有这个技能可用技能有xxx、yyy”模型往往能自我纠正重新发起正确调用。这个机制在实际体验中大幅度提升了成功率。至于动态管理就是在运行时允许注册和注销技能这为后续“按用户权限展示不同技能”预留了空间。4. SwiftUI界面层消息列表、输入栏与技能面板4.1 聊天消息列表的增量渲染SwiftUI的List做聊天界面最大的问题是它默认会为每个行创建并维护视图消息多了之后滑动会有卡顿感。另一个问题是流式更新时如果直接对整个Published数组赋值SwiftUI会重新评估所有消息哪怕是只更新最后一条。这里有三个优化动作我实测非常有效。第一ChatMessage必须是一个可靠的Identifiable类型ID一定不能每次生成新的否则列表会闪。第二优先考虑把消息列表放到LazyVStack里搭配ScrollView而不是直接用List因为LazyVStack允许你只渲染可视区域内的消息。第三在工具调用和流式更新这种高频变动场景给每个消息行加上一个能唯一标识内容版本的字段避免SwiftUI根据Equatable做多余的重算。ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.messages) { message in MessageRow(message: message) .id(message.id) } } .padding() } .onChange(of: viewModel.messages.count) { if let lastID viewModel.messages.last?.id { withAnimation { proxy.scrollTo(lastID, anchor: .bottom) } } } }滚动到底部这里有个细节用onChange(of: viewModel.messages.count)来触发滚动是因为如果只监听最后一条消息的内容变化在流式输出中每条增量都会触发滚动用户想回看之前的内容都会被强行拽到底部体验很糟。监听消息数量的话只有在真正新增了一条消息时才会滚动更像微信聊天的手感。4.2 输入栏与发送状态机输入栏看起来很不起眼但在Agent场景下它的状态切换比想象中复杂。发送前、等待模型响应时、工具执行中、流式生成中按钮应该分别处于不同的状态。我这里用一个枚举来表示发送相关的UI状态。enum SendingState { case idle case thinking case streaming case executingTool case failed(String) }在等待阶段输入框可以允许用户继续输入内容但发送按钮要禁用并且用一个进度指示器展示“AI正在思考”。在流式生成阶段用户看到的文本还在持续滚动此时点击空白区域不应该让键盘弹出来盖住屏幕也要防止用户连续多次点击发送导致消息顺序错乱。我的做法是只有idle状态下才允许发送其它状态一律把发送按钮置灰。这样一来即使底层网络延迟很长UI也不会出现“两条用户消息之间夹着一条AI消息”的诡异场景。4.3 技能面板与调用状态可视化Agent和普通聊天不一样的地方在于用户其实非常需要知道“AI到底在干什么”。当界面上一片空白只转圈时用户通常会以为App卡死了。所以我做了一个技能面板当模型调用一个技能时在输入栏上方临时插入一张卡片上面显示技能名称、传入的参数摘要、执行状态进行中/成功/失败。这个卡片也是会话消息的一部分但它不是文本消息而是单独的一种展示类型。这样做的好处不仅仅是提示用户还有一个隐藏价值当技能执行失败时用户能看到失败原因并且可以直接在UI上点击“重试”重新触发一次工具调用而不是重新输入一遍问题。我自己用了之后觉得这个设计非常重要它实际上把Agent内部的执行细节透明化了用户的信任感会强很多。5. 常见问题与排查实录5.1 流式输出中断与连接重置我在测试中最常遇到的现象是前几秒还能看到文本增量突然整个请求被掐断界面上什么都没有了。排查下来多半是网络层超时配置太短或者服务端的网关有空闲超时限制。流式接口有个特点模型思考时间可能长达数十秒期间没有任何数据返回如果客户端超时设的是10秒这期间就直接断了。解决方式是给正式请求单独设置较长的超时时间比如timeoutIntervalForRequest设为90秒同时把“等待时间”和“误以为失败”的提示词做好区分。如果是在工具调用后中断常见原因则是回灌给服务端的消息格式不符合接口要求缺了tool_call_id或者role字段不对服务端直接断开连接。遇到这类情况我会先把请求负载完整打印出来人工核对一遍再发给服务端。5.2 工具调用后模型不再生成有时候技能执行成功了返回结果也塞进了消息列表但下一轮请求发出去后模型要么一直空白要么返回一个“好的”就结束了。这个问题的根源往往出在工具结果的角色和内容格式上。模型平台对tool角色的内容有严格要求它通常不是普通文本而应该是一个能表示“这就是工具返回的结构化结果”的格式。我遇到过把content直接写成人类可读文案就导致模型拒绝继续生成的情况后来改成在结果里明确标注“这是一个工具执行的返回结果”再把原始数据附加上去模型的续接能力瞬间就正常了。5.3 UI状态不同步导致重复渲染早期我在Agent会话层直接持有Published数组UI层又持有一个转换后的展示数组两边的同步靠手动调用。结果在流式更新时经常出现“消息重复显示”“已经发送的消息突然消失了”这类问题。后来我把架构改成会话层只发出不可变的状态快照UI层作为这个快照的唯一订阅者直接渲染不额外缓存一份业务数据。这样虽然牺牲了一点点灵活性但数据源唯一再也没出现过重复渲染。这里建议所有做SwiftUI Agent的兄弟尽量把消息数组的下发设计成值类型快照而不是可变对象省去追查竞态问题的痛苦。5.4 上下文膨胀与裁剪策略多轮对话加上工具调用消息数量涨得飞快。当整个消息数组超过一定体量请求体变大服务端响应速度也会下降。我的裁剪策略是当消息数量超过预设上限比如20条把前半部分的对话摘要成一段系统消息放在最前面只保留最近几轮详细消息。这里要注意工具调用相关的消息无论如何不能直接删掉因为后续模型可能还要引用前面的工具结果你只能删除更早期、与当前问题无关的纯文本轮次。我做了几个Agent类App之后最大的体会是SwiftUI这边的工作其实只占四成剩下六成的精力全部花在会话状态机和工具调度的收敛上。Agent的核心在于“循环”而循环里的每一步都有可能让状态炸掉。如果你正打算做一个SwiftUI Agent Skill的项目我建议先从最小的闭环跑通一个文本对话一个天气技能一次完整的工具调用循环。别急着加炫酷的UI先把消息模型、流式输出、技能调度这三个骨架立稳后面的功能都是往骨架上挂肉。这个项目的扩展方向也很多比如把技能面板升级成可视化的工作流编辑器或者在Agent会话层引入多技能并行调度每一步都比单纯堆聊天界面更有挑战也更接近一个真正可用的Agent产品形态。
延伸阅读

更多相关文章

2026/10/14 9:22:13

锂枝晶生长仿真:相场法原理与COMSOL实战

1. 锂枝晶生长仿真:从致命威胁到数字艺术十年前我第一次在显微镜下看到锂枝晶穿刺隔膜的那刻,后背瞬间冒出一层冷汗——这哪是电池失效,分明是微型闪电在电极间劈出了一条死亡通道。如今在COMSOL里重建这个死亡之舞时,意外发现相场…

2026/10/14 9:22:13

Android自闭症康复训练APP开发实践与优化

1. 项目背景与核心价值 自闭症谱系障碍(ASD)是一种神经发育障碍,主要表现为社交沟通障碍和重复刻板行为。根据世界卫生组织数据,全球每160名儿童中就有1名患有自闭症。传统的康复训练需要专业治疗师一对一指导,存在资源…

2026/10/14 9:22:13

SpringBoot+微信小程序实现图书馆智能预约系统

1. 项目背景与需求分析 作为一名长期从事校园信息化建设的开发者,我深刻理解图书馆座位资源紧张的痛点。每到考试周,学生们凌晨排队抢座位的场景屡见不鲜。传统纸质登记方式存在三大致命缺陷:一是无法实时反映座位状态,二是容易产…

2026/10/14 10:42:47

Agent生产级调优实战:性能、成本与稳定性攻坚指南

该干的活儿干完了、Demo也跑通了,Agent 看起来什么都会,可一旦丢到生产环境里,问题就像雨后春笋一样往外冒:反应慢、账单飞涨、时不时还卡死报错。很多人把这归咎于“模型不行”,但实际上,大部分生产事故和…

2026/10/14 10:42:47

AI老照片修复与家庭回忆服务:小众高毛利副业的接单与运营(v1.0)

AI老照片修复与家庭回忆服务:小众高毛利副业的接单与运营版本 v1.0  2026年10月写在前面:这门小生意是做什么的这本手册写给想用业余时间做一份副业的人。你不需要会拍照,也不需要开实体店,只要手里有一台能跑修图工具的电脑、有…

2026/10/14 10:42:47

AnyPS5技术解析:跨设备高性能串流与低延迟优化实践

1. 从“AnyPS5”这个名字说起:它到底想解决什么问题 第一次看到“AnyPS5”这个标题,我脑子里冒出来的第一个念头是:这大概率不是一个官方产品,而是一个带着强烈个人色彩的项目代号。为什么这么说?因为“Any”这个前缀在…

2026/10/14 10:42:47

降维方法全解析:PCA、LDA、t-SNE、UMAP与自编码器实战指南

1. 降维到底在做什么:从生活直觉到数学表达第一次接触“降维”这个词,很多人脑子里浮现的是科幻电影里高维生物把三维空间压成二维画面的场景。这个联想其实挺到位,因为降维的核心动作就是:把原本需要用很多个数字才能描述清楚的东…

2026/10/14 10:42:47

前端面试复盘:JavaScript原理、Vue/React与性能优化清单

三月份我集中投了一拨前端岗位,过程并不算顺利,但复盘之后收获非常大。这篇笔记不适合拿来做“标准答案”背,更像我随手整理的一线面试实录:被问到的高频问题、我当时的回答、事后翻书查资料重新梳理后的参考答案,以及…

2026/10/14 10:37:46

基于Flask的校园失物招领系统设计与实现

1. 项目背景与核心价值 校园里每天都会发生物品遗失的情况,从校园卡、钥匙串到笔记本电脑、书包,学生们经常为寻找失物而烦恼。传统的失物招领方式主要依靠公告栏张贴启事,这种方式存在信息更新不及时、传播范围有限、查找效率低下等问题。 …

2026/10/13 5:19:27

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/13 17:09:18

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/14 0:00:33

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:33

macOS/iOS高精度计时:mach_absolute_time原理与实战

1. 项目概述:为什么mach_absolute_time是 macOS/iOS 时间测量的“黄金标准”在 macOS 和 iOS 开发中,我见过太多人用CFAbsoluteTimeGetCurrent()或NSDate.timeIntervalSinceReferenceDate做性能打点,结果在高精度场景下被系统时钟跳变、NTP 同…

2026/10/14 0:00:33

SCORM课件演示与测试全解析:从API通信到自动化验证

简介:这是一套遵循SCORM标准的交互式课件演示与测试资源,面向E-Learning课程开发者、LMS平台测试人员及在线教学从业者,用于验证课件在不同学习管理系统中的识别、传递与跟踪能力。压缩包共28个文件,约12.07MB,以swf动…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑