最近在整理一个 Agent 项目的客户端界面时我发现很多团队都把精力花在模型调用和工具链上却很少认真考虑一件事用户怎么感知这个 Agent 能干什么、正在干什么、干得怎么样。标题里提到的“SwiftUI-Agent-Skill”简单说就是我在 SwiftUI 里给 Agent 系统做的一套 Skill技能管理面板。今天这篇就聊聊这个项目的设计思路、踩坑过程以及给你可以直接抄走的关键实现代码。这套东西适合两类人看一类是正在做 Agent 客户端、但界面还停留在“一个输入框加一个消息列表”的开发者另一类是刚接触 SwiftUI、想知道怎么用现代 Swift 写法把复杂状态管理落地的朋友。我会尽量把“为什么这么做”讲透而不是只贴代码。1. Agent 与 Skill 的基础认知1.1 先把“Agent 技能”这个概念说清楚在过去一年多的时间里Agent 这个词已经被说烂了但绝大多数人聊的都是大模型推理、提示词策略、工具调用。实际把一个 Agent 产品化的时候你会发现真正决定体验的其实是另一层东西这个 Agent 到底会做哪些具体的事每一项事务的边界在哪参数怎么定义结果怎么反馈。这一系列能力的集合就是我这里说的 Skill。如果用生活类比来理解Agent 本身像一个“万能实习生”Skill 则是它被允许执行的“岗位职责清单”。清单上每一项都写清楚了这个任务叫什么、要什么输入、会返回什么结果、中间可能要调哪些外部资源。没有 Skill 清单的 Agent就像没有岗位说明的实习生——你问它什么它都能“接着聊”但你真的交代一件事让它去办它反而不知道从哪里下手。Skill 其实是从传统编程里的“函数”演进过来的概念但比函数多了两个关键部分一是自带对用户意图的匹配规则也就是说系统要知道用户这句话应该触发哪个 Skill二是自带输出模板让回复结果能结构化展示而不是一段自由文本。这就像把函数、意图识别、参数槽位、结果渲染绑成了一个独立的业务单元。1.2 为什么 UI 层要单独做一套 Skill 面板早期我负责的项目里Agent 的能力全靠服务端配置用户只有一个聊天窗口。结果遇到两个很实际的问题第一用户根本不知道这个 Agent 会干什么只能像开盲盒一样反复试。想让它总结文件它可能以为你只是聊天想让它查个报表它可能回答一句“我暂时没有这个功能”。这让我意识到能力本身必须有可见性。第二运维和调试非常痛苦。服务端日志用自然语言记录每一条工具调用排查问题靠肉眼搜关键词效率低到离谱。当时我就在想如果把 Skill 当成一等公民在客户端用一个面板把技能清单、运行状态、参数结构全部展示出来是不是从源头就能解决一半信任和调试的问题于是就有了这个 SwiftUI-Agent-Skill 项目的雏形。在苹果生态里SwiftUI 自然是最顺手的 UI 框架。它配合 Swift Concurrency、Observable 等现代特性能让状态管理干净很多。下面我会从整体设计开始一步步拆整个项目。2. 整体设计思路与模块拆解2.1 设计目标和方案选型动手之前我给自己定了几条硬性标准技能面板必须与聊天窗口平级而不是藏在“设置”里。技能是用户触达能力的路径应该像工具栏一样随时可见。每个技能要能下钻查看详情包括参数定义、最近调用历史、成功率不然面板只是个花架子。状态更新必须实时Agent 每执行完一个步骤UI 要立刻反馈不能有“转圈等结果”的空白期。数据模型要前后端统一最好用一套 DSL 描述技能构成而不是服务端发 JSON、客户端再硬编码字段。技术选型上我用 SwiftUI 做界面层Swift Concurrencyasync / await串联业务技能数据结构用 Codable 协议承载底层基于一套前后端约定的 JSON 结构做映射。简单说服务端把 Agent 当前可用的技能清单拉下来客户端渲染成面板点击某个技能之后通过 WebSocket 接收执行状态流App 里维护一个相对完整的状态机。2.2 六大核心模块的职责划分整个项目按功能分成六个模块它们之间有明确边界SkillStore全局唯一的技能数据仓库负责拉取、缓存、更新技能列表也是连接 UI 和网络层的枢纽。AgentClient网络层封装包含技能列表拉取、技能下发、执行状态监听三组接口。SkillDetailViewModel单个技能详情页的视图模型处理参数表单、校验、执行按钮状态。ExecutionMonitor专门监听某一次技能调用的实时状态解析状态流并驱动 UI 刷新。SkillDashboardView总览面板展示全景技能状态。SkillRunHistoryStore记录每一次调用历史用于调试和展示。为什么这样拆我的经验是如果让 Dashboard 直接持有执行状态界面会非常容易被细节绑架。现在把执行过程放到独立 Monitor 里去Dashboard 只需要关心“哪个技能正在跑”具体跑到哪一步让详情页去关心这样各层职责清晰。2.3 什么场景适合直接套用这套设计如果你正在做的是这几种类型的产品这套 SwiftUI-Agent-Skill 的思路可以直接复用企业内部助手类应用需要给员工展示“你能帮我做什么”技能面板展示的就是能力入口。智能硬件控制中枢把每个设备的每类操作都抽象成一个 Skill面板就是遥控器升级版。数据运维工具Skill 对应一个数据巡检项或者一次数据清洗任务面板相当于自动化任务的可见化控制台。个人知识库助手把笔记整理、标签归类、内容总结都设计成 Skill用户点一下就能执行。如果你的产品只是一个“聊天玩具”那确实不需要做这么重。但只要你希望 Agent 能稳定地完成某一类专业任务技能面板就是不可省的一层。3. 核心实现从数据模型到交互界面3.1 Skill 的数据模型怎么设计才不坑数据模型是整个项目的地基这里先展示最核心的 Swift 结构。为了让代码不至于太长我略去了部分辅助字段。struct AgentSkill: Identifiable, Codable, Hashable { let id: String let name: String let summary: String let category: SkillCategory var parameters: [SkillParameter] var enabled: Bool var lastRunAt: Date? var runCount: Int enum SkillCategory: String, Codable, CaseIterable { case fileProcessing file_processing case textAnalysis text_analysis case dataQuery data_query case systemControl system_control } } struct SkillParameter: Identifiable, Codable, Hashable { let id: String let key: String let title: String let type: ParameterType var defaultValue: String? var required: Bool var options: [String]? enum ParameterType: String, Codable { case text text case number number case bool bool case choice choice case file file } } enum SkillExecutionStatus: String, Codable { case idle idle case running running case succeeded succeeded case failed failed case canceled canceled }这里有个原则技能数据里永远不要写死 UI 文案。summary是给用户看的简介title是参数给用户看的标签key才是真正传到服务端的字段名。这样同一个技能文案上演进不影响接口展示层可以灵活调节。一个容易踩的坑是很多人会把SkillParameter做成全字符串数组觉得动态表单随便拼一拼就行。但实际一旦字段变成 bool字符串数组就没法承载开关状态一旦字段变成 choice 下拉你还得额外传 options。所以我直接用枚举约束类型前后端约定好“类型是 text 就得传文本校验、是 file 就得走文件上传链路”从源头避免数据污染。3.2 状态驱动的技能列表界面SwiftUI 界面我用了 Observable 作为状态源。iOS 17 下Observable比ObservableObject简洁非常多无需要管Published改动触发也更精准。下面是简化版本的SkillStoreimport Observation import Foundation Observable final class SkillStore { private(set) var skills: [AgentSkill] [] private(set) var isLoading false private(set) var errorMessage: String? private let client AgentClient() func loadSkills() async { isLoading true defer { isLoading false } do { let remoteSkills try await client.fetchSkillList() skills remoteSkills } catch { errorMessage 技能列表加载失败\(error.localizedDescription) } } func toggleSkill(_ skillID: String, enabled: Bool) async { guard let index skills.firstIndex(where: { $0.id skillID }) else { return } skills[index].enabled enabled do { try await client.updateSkillEnabled(skillID: skillID, enabled: enabled) } catch { skills[index].enabled !enabled errorMessage 更新失败已回滚状态 } } }有两点想特别提醒第一乐观更新。我先把 enabled 翻转给用户看接口失败再回滚。Agent 技能开关是一个高频操作让用户等待网络返回再刷新 UI体验会非常生硬。乐观更新对这类操作性状态特别合适。第二defer { isLoading false }是 Swift 里很实用的写法无论成功还是提前返回都能保证 loading 状态被复位避免 UI 卡在 loading。列表视图本身可以用List做分组展示按分类分组。这里就不贴完整结构了主要说设计交互上的取舍每个技能行右侧放一个“运行”按钮顺手触发一次尝试而不是必须进详情页才能跑。技能行的 subtitle 显示简要描述和最近运行时间这样用户在总览页扫一遍就能大概判断哪些能力在用、哪些其实没人碰。3.3 执行状态流与运行历史技能执行最怕“黑盒”用户点了执行界面转圈十分钟后一句“失败了”。为了把执行过程可视化为逐条状态我设计了一个事件流模型通过 WebSocket 从服务端接收状态推进。先看模型struct SkillExecutionEvent: Codable, Identifiable { enum EventKind: String, Codable { case started started case parameterValidated parameter_validated case toolInvoked tool_invoked case toolReturned tool_returned case partialOutput partial_output case completed completed case failed failed } let id: UUID let skillID: String let executionID: String let kind: EventKind let message: String let createdAt: Date }ExecutionMonitor专门缓存当前正在执行的事件列表并且暴露给详情页订阅。代码如下Observable final class ExecutionMonitor { private(set) var events: [SkillExecutionEvent] [] private(set) var status: SkillExecutionStatus .idle private var continuations: [CheckedContinuationVoid, Error] [] private let eventSubject AsyncStreamSkillExecutionEvent.makeStream() func monitor(executionID: String) async { events.removeAll() status .running do { for try await event in eventSubject.stream { events.append(event) if event.executionID ! executionID { continue } switch event.kind { case .started, .parameterValidated, .toolInvoked, .toolReturned, .partialOutput: continue case .completed: status .succeeded return case .failed: status .failed return } } } catch { status .failed } } }这个实现有点粗糙实际项目里事件是网络层代理塞进来的但思路就是事件是一个异步序列Monitor 不断 appendUI 由 SwiftUI 自动感知更新。事件流展示界面就是逐行渲染文本和时间每条事件显示一个 dot 加描述成功和失败染不同颜色。响应式 UI 在这一层特别有快感Agent 每调完一个工具、每生成一段中间输出界面就像流水线一样推进行程。那比“转圈等最终结果”高级太多。我强烈建议有条件的同学把partialOutput事件做“流式字幕滚动”也就是最新的事件自动滚到可视区。这个细节对体验提升非常大用户能看到 Agent 当前“卡在哪个环节”而不是两眼一抹黑。3.4 技能分类与权限控制的结合权限控制不是 UI 层的主线但仍有两个 UI 上很容易忽略的点第一权限按钮状态。技能行要支持禁用状态灰显并且被禁用的技能不能运行。这里我建议尽量使用 SwiftUI 的.disabled()而非在 onTapGesture 里做 if 判断因为系统级禁用可以自动带上辅助功能语义对 VoiceOver 更友好。第二管理端和用户端显示不同的技能范围。比如有一个isAdminVisible字段admin 技能只在管理员模式下渲染普通用户看不到。这类数据要放在技能模型层面简单粗暴地加一个accessLevel字段就好“admin”和“user”两级最清晰。不要用一长串权限字符串列表用枚举否则后面前后端对应起来非常难受。4. 实操过程中的踩坑记录4.1 SwiftUI 列表刷新不及时这是我遇到的第一个严重问题。Observable配合List按理说会自动感知数组变化但我给技能开关的时候列表 UI 偶尔不刷新。排查之后发现问题出在我用Array的下标直接改元素skills[index].enabled enabled这种写法确实能触发局部更新但只要是旧视图还在引用旧数组的某一项偶尔就会出现不同步。解决办法是技能开关这种操作不要小心翼翼地只改一个字段直接把整个AgentSkill替换掉。也就是说先取出旧值然后复制一份、改字段、整体塞回数组。虽然看着多了一次复制但获得的是一次确定的 UI 刷新机会。这个技巧非常土但有效的离谱。4.2 后端字段一多Codable 解析开始失控早期我在 AgentSkill 里放了超过 20 个字段其中很多是“可选值”比如iconName、runHistory。Swift 的 Codable 对缺字段默认会抛错你必须实现init(from:)又臭又长。后来我学聪明了所有可选字段专门归类写一个默认值结构然后只用decodeIfPresent解析真正会动态变化的字段其他全用 optional。下面这个技巧帮了我大忙extension AgentSkill { static let placeholder AgentSkill( id: , name: , summary: , category: .textAnalysis, parameters: [], enabled: true, lastRunAt: nil, runCount: 0 ) }如果一个字段缺失或者类型变了我至少能让 UI 用 placeholder 兜底不至于整个列表因为一条脏数据全挂掉。4.3 并发环境下的状态错乱技能面板支持一次跑多个技能。当两个技能同时在跑ExecutionMonitor 这边偶尔会出现“A 技能的执行事件渲染到了 B 技能的详情页”。查了一圈问题出在 WebSocket 事件分发层事件本身带了executionID但详情页监听时没有做过滤凡是流里来的事件就一股脑塞进 UI。修复特别简单在 Subscription 里强校验event.executionID currentExecutionID不是自己这一单的直接丢弃。我还在 UI 上加了隔离测试让两个技能同时跑一个成功一个失败确保状态互不污染。这个测试建议保留每次重构完都跑一遍。4.4 技能禁用后内存泄漏有一次在模拟器上反复开关技能面板内存一路疯涨。用 Instruments 一看发现是AgentClient在每次开关时注册了 WebSocket 订阅而取消订阅的 deinit 时机没处理好订阅闭包一直持有视图模型。折腾了很久最后统一了一条规定订阅只存在于ExecutionMonitor里面开关技能只改变模型数据绝不动态创建新订阅。4.5 真机调试总是失败模拟器却好端端的这个坑不是 SwiftUI 独有的但几乎每次做网络功能都会遇到。模拟器走的是 Mac 的网络公司内网接口用 localhost 就能通真机走到局域网之后必须让服务端把局域网地址加进白名单而且 iOS 的 ATSApp Transport Security默认拦非 HTTPS 请求。真机调试时如果需要临时测 HTTP开发阶段可以加NSAllowsLocalNetworking但发版前务必移除。这里整理成一张速查表方便你排查现象可能原因解决方向列表不刷新用下标修改了数组元素改为整体替换模型触发确定性更新两个技能状态混掉事件流没按 executionID 过滤订阅时强制校验 ID丢弃无关事件内存持续上涨订阅生命周期和页面不一致订阅集中在 Monitor页面销毁时统一取消真机连不上服务ATS 或局域网白名单开发期用 NSAllowsLocalNetworking发布前移除Codable 解析失败服务端字段缺失或类型变化用 decodeIfPresent placeholder 兜底状态回滚不生效捕捉错误后没改回 UI回滚时也要整体替换技能模型然后刷新5. 运行历史模块与调试体系5.1 把每次 Skill 调用变成一条可追溯记录先看一个实际展示点击某一个技能往下拉能看到“最近运行”列表。这里我直接用了SwiftData做本地持久化每一条记录包含执行 ID、技能 ID、触发时间、耗时、状态、错误信息、工具调用次数。为什么选用 SwiftData 而不是 UserDefaults 或者 JSON 文件因为运行历史是只增不退的数据可能积到几百几千条UserDefaults 不适合存这种量级的结构体JSON 文件在写入并发时容易出问题。SwiftData 的模型层可以和 Observable 无缝共享查询结果也能直接绑定列表省掉很多胶水代码。核心模型差不多是这样Model final class SkillRunRecord { var executionID: String var skillID: String var startedAt: Date var finishedAt: Date? var statusRaw: String var errorMessage: String? var toolCallCount: Int init(executionID: String, skillID: String, startedAt: Date) { self.executionID executionID self.skillID skillID self.startedAt startedAt self.statusRaw SkillExecutionStatus.running.rawValue } }statusRaw用 String 存而不是直接存枚举是因为 SwiftData 对枚举的序列化支持不直接。读出来的时候转换一下就好代价很低。这也是我的一个习惯凡是需要跨版本演进的数据字段尽量以原始值形态存储避免 Core Data 或 SwiftData 的迁移问题。5.2 成功率与平均耗时统计的计算方式上过一个版本后产品同学提了个需求想看每个技能的成功率和平均耗时。本来以为很简单结果发现直接查 SwiftData 再过滤也不是不行但每次打开详情页都要全量滚动一遍历史记录几千条数据一多明显能感觉到卡顿。后来我换了思路在写历史记录的同时维护一张轻量聚合表按“技能 ID 状态”汇总计数每次插入记录时给对应字段加一。需要看统计时直接查聚合表毫秒级返回。这就是典型的“读时简单、写时多算一点”的取舍。如果你没有这么高的展示频率也可以临时排队在后台算主要看业务场景。Model final class SkillStats { var skillID: String var totalRunCount: Int var successCount: Int var totalDuration: TimeInterval var successRate: Double { guard totalRunCount 0 else { return 0 } return Double(successCount) / Double(totalRunCount) } var averageDuration: TimeInterval { guard totalRunCount 0 else { return 0 } return totalDuration / Double(totalRunCount) } }5.3 本地历史和服务端日志谁更重要单纯从查问题角度服务端日志永远最重要。但本地历史有一个独特价值它能展示“用户视角的执行过程”尤其是参数校验失败这种客户端就能判断的错误根本不需要服务端参与。所以我把历史记录和状态流打通到同一个时间轴上你点开某一条历史能看到“用户填了什么参数 Agent 选择了什么技能 工具调用序列 最终结果”半分钟就能定位一次失败。这里想强调的是这类面板做久了你就知道Agent 项目真正的开发瓶颈很多时候不是模型效果而是排查链路太长。技能面板加事件流加历史记录等于给整个系统装了一个“行车记录仪”。5.4 运行历史的开关设置我发现一个挺普遍的误区好多团队默认把“运行历史”做成永久保留结果存储越来越膨胀。我的建议是给运行历史加一个保留策略设置默认保留最近 30 天也允许保留 90 天。真正需要长期分析的记录可以手动置顶。界面层就放一个 Picker切换保留策略时清理过期数据。enum HistoryRetentionPolicy: String, CaseIterable { case sevenDays 7 天 case thirtyDays 30 天 case ninetyDays 90 天 case forever 永久保留 }这个设置放在技能面板的“设置”入口里不要放在详情页。越是低频的系统级配置越不要干扰日常页面。6. 常见问题与排查指南6.1 前端一键排查的技能状态异常速查表开发过程中最容易遇到下面几类问题我整理成表方便你做第一轮判断现象大概率原因怎么查技能列表是空的拉取接口失败 / 权限过滤把技能全滤掉了看网络请求返回体 控制台打日志确认权限断言技能点了没反应服务端不存在该技能 / 本地无权限 / 参数校验没通过看按钮点击之后有没有触发状态变化再查服务端日志状态一直 runningWebSocket 连接断开 / 事件流没有 completed 状态检查 WebSocket 心跳机制确认事件服务能否产生终态参数文件传不上去文件类型不支持 / 大小超限看客户端上传接口响应码检查服务端限制参数UI 显示“未知错误”错误码没映射全在错误解析层补一个兜底文案并记录原始错误码这里面最隐蔽的是第二个“点了没反应”因为表面上毫无异常也不会报错。我排查过一次用户测试环境里技能列表是从服务端拉取的但技能详情页的 skill ID 是写死的旧 ID数据库里早就没有这条记录了。点一下没反应点两下没反应也没有错误提示。后来我在按钮点击事件里强制打印当前技能的唯一标识一眼就发现问题。所以前端调试时建议在这种关键动作的地方打一条包含上下文标识的日志不要只打“点击成功”这种无效信息。6.2 SwiftData 数据库迁移的三个注意事项第一次用 SwiftData 时很容易踩模型迁移的坑跟 Core Data 其实一个套路。整理三点第一尽量不要删字段。删除字段会让旧库迁移变得很麻烦。早期我删过一个note字段结果测试机上所有历史记录查询直接报错。后来解决方案是给模型加版本化字段每次变更都标注新增时间。第二给枚举字段用 String rawValue 存储。如果你直接用Model把一个 Swift 枚举存成属性某些 SwiftData 版本下会崩溃或无法保存因为枚举到数据的隐式转换非常脆。所有枚举字段保留原始值字符串读写时再转换一遍既安全又能直接在调试器里看懂值。第三及时做数据清理。如果你有“永久保留”策略建议每分钟检查一次数据量超过阈值之后在后台清理最老的记录。App 启动时做完整清理会明显卡一下别这么干。6.3 两个“看起来很聪明”但非常坑的写法一是“状态全放 View”。新手很容易把所有状态直接塞进 SwiftUI 的 View 里觉得省事。但实际上代码超过几百行后你会在十几个State之间互相投递消息最后根本分不清谁驱动了谁。我的原则是页面级状态放 View跨页面状态放 Store独属于某次执行过程的状态放 Monitor。用这一条规则基本就能避免状态乱飞。二是“为了炫技用复杂反射”。有一个同事为了“动态渲染任意 Skill 参数表单”用 Mirror 把所有模型属性反射出来生成 UI。结果维护成本极高任何字段重命名都会静默失败。后来我把参数模型显式化不用反射反而每个字段的 UI 都清晰可控。Swift 的反射是个技术玩具别拿它做核心业务支撑。6.4 一些算得上心得的边界处理技能运行中用户可能杀进程退后台也可能切到别的页面。这时候执行到底还继续不继续我的经验是客户端只管展示和取消请求真正的执行状态得服务端保持App 重连之后从服务端补拉当前执行状态。也就是说技能执行不是“App 进程的延伸”而是一种“服务器状态”。这个心智如果不建立起来后面所有断线重连问题都会非常难调。再有一个是无网络场景。技能面板不应该整页空白应该用 SwiftUI 的ContentUnavailableView做空态占位同时区分“服务端返回空列表”和“网络异常”两种情形。很多同学只做一个 if 判空导致网络一断就显示“暂无技能”容易让用户误以为系统坏了还不如直接写“无法连接服务器请检查网络”。7. 从技能面板到技能编排中心的思考7.1 单技能执行已经不够用之后当 Agent 能力越来越强用户往往不满足于“点一个技能执行一个任务”而是想“点一个按钮完成一串关联操作”。比如进入一个“日报生成”流程内部要先后执行“汇总今日消息记录”“抽取关键事项”“生成结构化日报”“发送到指定频道”这其实就是技能编排。我在这个版本里做了一个非常朴素的编排标识给技能模型加一个nextPossibleSkillIDs: [String]?表示执行完当前技能之后有哪些技能可以被推荐为下一步。UI 上不主动强制执行而是在技能完成的事件流末尾展示几个推荐入口用户点一下就能接着跑后续技能。这个设计的好处是把流程控制权仍然交还给人不会因为自动化过度而失去可控性。等到你采集到的用户行为数据多了自然知道哪些“技能接力路径”是高频路径再把这些路径做成可保存的流程模板。这是一个产品演进路径从技能面板到编排面板再到自动化流程。7.2 未来可以扩展的输入方式技能执行目前全靠用户点击按钮参数靠手填。下一步我准备在技能详情页接入一些自然语言解析能力用户直接用一句话描述想干什么例如“查一下上周的销售汇总”客户端解析出合适的技能和参数然后自动填充表单。这本质上是把 Agent 的意图识别能力下沉到 UI 层。这个扩展的工程量主要在两块在客户端维护一份“技能名 常用说法”的本地索引做一个轻量意图识别。如果本地命中不了就调用服务端的模型接口做一次语义匹配。我个人更倾向于前者优先先把高频说法做成本地快速路径再让服务端做兜底。毕竟技能数量一共几十个语义匹配的复杂度远没有很多人想象的那么高。7.3 多端复用同一套技能模型SwiftUI 只覆盖了苹果生态。但 Skill 的抽象如果做得好它可以同时服务 iOS、macOS 和 Web。我这套项目里的AgentSkill数据模型本身是完全不依赖 SwiftUI 的纯 Foundation 层所以挪到哪一边都行。真正需要重写的是 UI 表现层不是业务模型。如果你有跨端野心强烈建议在第一版就把技能数据的 JSON 结构定义清楚包含版本号字段。后面模型一演进不同端的兼容性判断就有据可依不至于各端各改各的字段。8. 结尾想说的几句实在话做了这个 SwiftUI-Agent-Skill 项目之后我最大的感受是Agent 产品能不能被信任很多时候其实不是模型能力决定的而是用户“能不能看见 Agent 每一步在干什么”。能看见才敢放手让它去干。如果你现在正准备给自己的 Agent 做一个客户端面板我的建议是别一上来就想做花哨的 3D 星球界面或炫酷粒子动画先把三件事做扎实技能清单拉取和展示、单技能的实时执行状态流、运行历史的持久化查询。这三件事的工程难度都不高但做完之后整个系统的可靠性和可调试性会上一个台阶你自己也会对“Agent 到底在做什么”有清晰得多的掌控感。最后分享一个小细节SwiftUI 的.animation不要太依赖默认动画。状态流更新非常频繁时默认动画会让列表跳来跳去。我给事件流渲染只加了.contentTransition(.opacity)让新事件淡入而列表滚动用.scrollPosition去控制。肉眼观感反而比之前乱弹的动画好很多。踩过一次你就能记住高频更新的 UI尽量减少位移类动画宁可让它在原地闪烁也别让它乱飘。