Fullstack Dev、iOS Reverse Engineering、小红书逆向培训

 
模型越强,为什么我们反而越不敢放手?

能力更强的智能体驶向分岔路,人类在高后果节点保留控制杆

模型跑分提高,和它更适合一起工作,并不是同一件事。

一个编码 Agent 可以更快定位错误、完成更长的任务,也可能在需求有歧义时替人选定方向。它交付得更快了,人却不敢离开屏幕:每隔几分钟就要检查它是否扩大范围、改变计划,或把某个未写出的假设当成事实。

本文由《听懂 AI》第 006 期整理而成。节目主要讨论 Mun Logadan 于 2026 年 8 月 14 日发布的个人文章《Why does Opus 5 feel worse to work with?》,并补充 Anthropic 的 Opus 5 发布说明和 Hacker News 社区讨论。原文描述的是作者及同事的使用感受,不是模型对照实验;关于训练和 benchmark 的解释也被作者明确标为推测。

原文到底在抱怨什么

Mun Logadan 并没有说 Opus 5 能力倒退。相反,他认为它比 Opus 4.7、4.8 更有能力,benchmark 表现也很强。让他不舒服的是协作方式:

  • 意图不清楚时,不太愿意停下来询问;
  • 信息缺失时,会自行补充假设;
  • 已有计划存在多种解释时,可能不经确认就重新理解或修改。

这会产生一种反直觉的体验:模型更能完成任务,人却需要更仔细地看守它。这里的证据只是个人观察。它能说明一种真实存在的使用问题,不能证明所有用户都会遇到,也不能据此给 Opus 5 的整体能力下结论。

官方叙事和个人体验为什么会冲突

Anthropic 在 2026 年 7 月 24 日发布 Opus 5 时,把它描述为更主动、更适合长时间多步骤工作的模型。官方公布了 Frontier-Bench、CursorBench、OSWorld 等结果,并列出大量早期客户反馈,其中一些特别称赞它会验证工作、发现隐患,或只在需要人类判断时把人拉回来。

这些材料证明了 Anthropic 想优化的方向,也提供了具体使用案例,但仍主要来自厂商评测和早期客户引述,并不是独立的用户体验研究。个人文章关注的又是另一种场景:任务文本没有写全,隐性业务约束很多,选错方向的代价高。

同一种“主动性”,在两类任务里可能得到相反评价:

  • 任务自包含、结果可自动检查时,主动补步骤能节省时间;
  • 任务依赖未写出的组织背景时,主动补假设可能扩大风险。

所以争议不一定是谁对谁错。双方测量的对象不同:一个更接近“模型能否完成”,另一个更接近“人是否放心让它完成”。

隐藏上下文才是现实工作的难点

“重构登录模块”看起来是一句完整需求,实际可能牵涉旧客户端兼容、审计要求、埋点协议、上线窗口和客户承诺。这些信息可能散落在代码、文档、工单和人的记忆里,不会自动进入提示词。

模型可以写出结构漂亮、测试全绿的新实现,却仍然删掉某个不能改变的旧行为。问题不一定是它不会写代码,而是它不知道自己缺少了哪些背景。

现实任务还经常没有唯一正确答案。两个方案都能运行,但预算、团队经验、发布节奏或维护责任会改变选择。Agent 如果继续执行,就相当于替项目负责人做了技术之外的取舍。

benchmark 的解释为什么只能当作线索

原文猜测,强调 benchmark 的训练环境可能鼓励模型在歧义面前大胆选择答案,因为一项设计良好的评测通常会提供足够信息,并保证存在可以评分的结果。模型如果反问任务设计者,反而无法得分。

这个解释有启发,但没有证据证明 Opus 5 的具体协作行为由某种 benchmark 或训练方式造成。官方发布材料也没有提供能支持这条因果链的数据。

现有证据只能说明,单独测任务成功率可能遗漏“何时需要人类输入”这项能力。真实工作既要看模型能不能解题,也要看它能否发现题面之外的关键决定。

多问问题也不是答案

让 Agent 每做一步都询问,同样会让自动化失去意义。更实用的做法是同时判断三个因素:

  1. 歧义:目标是否有多种合理解释;
  2. 后果:选错会影响多少用户、数据或外部系统;
  3. 可恢复性:操作能否低成本撤回和验证。

根据歧义、后果和恢复成本判断智能体应直接行动、声明假设、请求审批还是停止询问

这是文章中的编辑性框架,不是对 Opus 5 或其他模型的实验结果。

边界清楚、风险低、容易撤回的操作,可以直接完成并留下记录。信息不全但后果较轻时,可以声明假设,只做一个可回退的小步骤。动作虽然明确,但涉及发布、删除、付款或数据迁移时,应先取得审批。歧义和后果都很高时,Agent 应停止并请人决定方向。

问题是否有价值,要看它能不能改变方案或风险,而不是看数量。

注意力也应该计入生产力

Hacker News 讨论后来扩展到模型的固定写作句式、冗长注释和无关改动。有人认为这些问题严重消耗注意力,也有人觉得影响有限。这些都属于社区观察,不是统一实验结果。

但它们提醒了一个容易漏掉的成本:任务完成之后,人还要花多久才能信任结果。一个模型单次成功率更高,如果每次都要清理无关修改、核对隐藏假设和恢复越界操作,整体生产力未必同步提高。

团队可以记录这些指标:

  • 人工持续盯守的时间;
  • 审查和返工耗时;
  • 偏离计划的次数;
  • 越权或不可逆操作的次数;
  • 出错后恢复到安全状态所需的时间;
  • 因为信任不足而无法开放的工具和权限。

能力决定 Agent 能做多复杂的任务,协作成本决定团队愿意给它多大的行动范围。

怎样设计更合适的审批点

团队不必把所有背景写成一份无限增长的规则文件。固定约束适合写进项目说明,动态取舍则需要运行时判断:

  1. 明确只读目录、允许的工具和必须审批的外部写入;
  2. 要求 Agent 在高风险任务开始前复述目标、假设和不可改变的约束;
  3. 把大任务拆成可检查、可撤回的阶段,每阶段提供真实读回证据;
  4. 偏离已批准计划前,说明原因和影响并重新请求授权;
  5. 对发布、删除、迁移、付款和凭据操作设置明确审批点;
  6. 记录人类介入的位置,持续调整哪些步骤可以自动化。

规则文件能保护已经知道的边界,审批机制负责处理还没写进规则的新情况。两者缺一不可。

评测“知道何时问”可以怎么做

如果只给模型材料齐全、答案明确的任务,就很难观察它怎样处理现实中的不完整信息。更贴近协作的 Eval 可以故意留下关键歧义:

  • 提供两个都能运行、但业务含义不同的方案;
  • 隐去一个会改变设计的权限或兼容约束;
  • 混合可撤回操作与不可逆操作;
  • 在执行中途加入与原计划冲突的新证据。

评价时不应只数模型问了多少问题,还要看它是否发现真正会改变结果的歧义,是否区分可逆与不可逆操作,是否在偏离计划前请求授权,以及人类总共花了多少时间介入。

这套指标仍是一种编辑性建议,不是现成的行业标准。它至少把“感觉更累”转换成了可以记录和比较的协作成本。

收听本期节目

《听懂 AI》第 006 期节目封面

原始资料与延伸阅读

  1. Mun Logadan,2026-08-14:Why does Opus 5 feel worse to work with?——个人及同事的协作体验;关于训练与 benchmark 的解释由作者标为推测。
  2. Anthropic,2026-07-24:Introducing Claude Opus 5——官方发布说明、厂商评测和早期客户案例,不应视为独立用户研究。
  3. Hacker News:Why does Opus 5 feel worse to work with?——社区对自主性、写作风格、注释和审核成本的讨论;评论只代表参与者观察。

资料说明:本文没有证明 Opus 5 比旧模型更难协作,也没有把作者的训练猜测当作事实。关于审批矩阵、协作成本和 Eval 的部分,是基于原文问题做出的编辑性整理与实践建议。

 
DeepSeek Harness:为什么要把智能体的所有部件都做成插件

可拆装的智能体工作台通过共享主干连接模型、工具、会话和沙箱模块

比较 AI Agent 时,人们往往先问“用了哪个模型”。但模型只是其中一部分。它能访问哪些文件和工具、怎样管理上下文、何时请求批准、如何恢复失败、把运行记录保存在哪里,这些都由模型之外的 Harness 决定。

DeepSeek Harness 的 Developer Preview 把这层基础设施单独摆到台面上,并给出两个醒目的设计目标:所有能力都可以作为插件替换;每次运行都能从同一条事件流中追溯。

本文由《听懂 AI》第 005 期整理而成。主要来源是 DeepSeek Harness 官方站点、GitHub 仓库和架构文档。2026 年 8 月 26 日发布的 Cordis 预印本补充了可逆副作用和动态依赖的理论说明。项目截至 2026 年 8 月 28 日仍处于 Developer Preview,官方明确表示会出现破坏兼容性的变更。

Harness 到底负责什么

模型可以生成文本或工具调用意图,但它不能直接在操作系统里“自己做事”。Harness 负责把模型接进真实环境:

  • 组装系统提示、历史消息和工具描述;
  • 暴露文件、终端、搜索、网络等能力;
  • 执行工具并把结果送回模型;
  • 管理循环、子智能体、上下文压缩和停止条件;
  • 应用权限、审批、沙箱和凭据策略;
  • 保存会话、运行状态、错误和遥测信息。

同一个模型放进不同 Harness,能完成的任务、消耗的 token、失败方式和安全边界都可能不同。因此,评估 Agent 不能只看模型跑分,还要看它周围这套运行系统。

“所有东西都是插件”具体指什么

DeepSeek Harness 建在 Cordis 插件系统上。官方架构文档没有保留一个不可替换的特权核心:模型适配器、工具注册表、会话日志、智能体循环、沙箱、存储、调度和网页界面都通过插件提供。

这些插件把服务、带类型的事件和依赖关系挂到共享上下文中。开发者可以在配置里替换某个提供者,而不是修改 Harness 源码。例如,换掉模型适配器、把本地文件系统改成远程沙箱,或给某类会话使用不同的工具组合。

运行时并不是简单扫描一个插件目录。它按照 Profile、Bundle、用户补丁和命令行覆盖层组成一棵有顺序的插件树。官方提供 dsh --profile web --dump-config,让开发者查看机器最终实际启动的配置,而不是只看散落在多层文件中的声明。

可逆副作用能解决什么

插件卸载不只是删除一段代码。它可能已经注册监听器、打开连接、挂载服务或启动后台任务。Cordis 要求可撤销的注册在创建时同时登记清理函数,插件卸载时再按生命周期收回这些效果。

2026 年 8 月 26 日提交的 Cordis 论文把这种机制称为“时间可组合性”:组件产生的上下文变化带有逆操作,运行时负责保存并执行。论文还讨论“空间可组合性”,即组件根据声明的依赖动态激活和停用。

这个机制能清理框架知道并登记过的副作用,却不能倒转所有现实操作。插件如果已经删除外部文件、调用第三方 API 或泄露凭据,卸载函数无法让这些事情自动消失。生命周期清晰不等于风险消失。

“每次运行都可追溯”不是读心术

DeepSeek Harness 把会话视为只追加的 SessionEvent 事件流。用户输入、模型请求、模型返回、工具调用与结果、步骤开始结束等事实写入同一记录。下一轮模型历史由日志重新投影,恢复、分叉、搜索、重放、遥测和持久化也从这条事件流派生。

官方文档提出一条运行时约束:“模型可见”就必须能够从日志重建。也就是说,任何真正送进模型请求的内容都应留下对应事件,避免界面显示一套历史、模型实际收到另一套历史。

DeepSeek Harness 的插件组合、可逆生命周期和只追加会话事件流

图中只展示架构关系。实际插件、事件和运行模式以当前配置及官方文档为准。

可追溯不等于能够读取供应商隐藏的内部推理。Harness 只能记录自己收到、创建或发送的内容;如果模型 API 没有返回完整 Chain of Thought,它不会凭空出现在会话日志里。日志透明的是执行链,而不是模型供应商没有暴露的内部状态。

四种模式对应不同的实验目标

官方当前提供四种运行模式:

  • Standard:完整编码 Agent,包括文件、终端、搜索、技能、计划、目标和子智能体;
  • Code:模型可以通过 Code Mode SDK 在一个 TypeScript 程序中组合多轮工具操作;
  • Minimal:只保留持久 Bash 和文件编辑器,用于较小、容易比较的模型评测环境;
  • Creator:在 Standard 能力上增加运行时检查、内存插件试验和 Profile 编写指导。

这些模式的意义不只是功能多少。Minimal 可以减少 Harness 自身对评测结果的干扰;Creator 则把插件开发和组合变成产品能力。团队也可以由基础 Bundle 开始,只为特定项目增加必要插件。

插件化和事件流带来的价值

对于研究者和 Agent 基础设施开发者,这种架构便于回答过去很难分开的实验问题:

  • 同一个模型换不同工具集,结果差多少;
  • 本地沙箱和远程沙箱怎样影响安全与性能;
  • 换掉上下文压缩策略后,恢复和长会话表现是否变化;
  • 某次失败究竟来自模型、工具、权限、上下文还是循环控制;
  • 会话能否从相同事件边界稳定恢复或分叉。

插件边界让替换实验更容易,事件流则提供统一的观察依据。它们提供的是实验和组合能力,不是“换插件一定更好”的保证。

当前最重要的标签仍是 Developer Preview

截至 2026 年 8 月 28 日,官方仓库仍明确写着 Developer Preview,并警告会有破坏兼容性的变更。安全说明更加直接:项目尚未经过安全审计,不能视为安全或生产就绪的软件。

DeepSeek Harness 可以执行模型生成的代码和命令,加载第三方插件,并访问用户允许的网络、进程、凭据和文件。错误模型输出、缺陷、配置错误、恶意输入或不可信插件都可能修改或删除文件、泄露数据,甚至损害宿主机。

官方还强调,沙箱、审批和权限控制只能降低风险,不能保证完全隔离;系统无法保护已经被明确授权访问的资源。社区关于“插件疲劳”、版本冲突和供应链攻击面的担忧因此是合理的工程问题,但 HN 评论只是社区观察,不代表项目已经出现了这些事故。

现在怎样安全地试

如果只是想比较模型或研究 Harness,可以从隔离实验开始:

  1. 使用一次性虚拟机、容器或专用环境;
  2. 只挂载测试项目,不开放主目录和真实仓库;
  3. 不注入生产凭据、SSH Key 或云端密钥;
  4. 采用最小权限和人工审批,先从 Minimal 模式或较小插件集开始;
  5. 阅读第三方插件源码、依赖和配置,再允许执行;
  6. 给可访问文件做备份,并记录外部 API 的副作用;
  7. 保存 --dump-config 结果和版本信息,方便重现实验;
  8. 接受接口可能变化,不把当前 Profile 当成长期稳定契约。

DeepSeek Harness 把模型之外的运行系统摆到了开发者面前,让工具、会话、沙箱、循环和存储都可以观察和替换。它能否从实验台走向稳定生态,取决于接口治理、安全审计、插件质量和长期兼容性。

收听本期节目

《听懂 AI》第 005 期节目封面

  • 标题:DeepSeek Harness:当智能体的每个零件都能热插拔
  • 时长:5 分 04 秒
  • 音频:在线播放或下载
  • 播客主页:听懂 AI

原始资料与延伸阅读

  1. DeepSeek:DeepSeek Harness Developer Preview——产品定位、运行模式和当前 Preview 状态。
  2. DeepSeek AI:deepseek-harness GitHub 仓库——源码、安装、许可证与兼容性警告。
  3. DeepSeek Harness Docs:Architecture Reference——插件树、事件流、Profile、Bundle 和运行时约束。
  4. DeepSeek AI:Safety Notice——安全审计状态、沙箱限制与负责任使用要求。
  5. Yifan Shi、Wei Zhang、Tianyi Cui 等,2026-08-26:A Programming Paradigm for Spatiotemporal Composability——Cordis 可逆效果与动态依赖的理论说明,当前为 v1 预印本。
  6. Hacker News:DeepSeek Harness developer preview——社区对插件治理、兼容性和供应链风险的讨论;评论不代表已验证事实。

资料说明:本文描述的是 2026 年 8 月 28 日可见的 Developer Preview。仓库和 API 正在快速变化,后续版本可能调整名称、模式、接口和安全边界。

 
AI 让代码变便宜以后,工程师真正昂贵的是什么

高速自动生成的软件模块进入狭窄的人类理解与审查工位

AI 编程工具最直观的变化,是让“写出一批能运行的代码”变得更快。一个需求可以在几小时内长出页面、接口、数据表和测试,过去需要几天的实现工作被压缩到一个下午。

但软件交付并不在代码生成时结束。团队还要理解设计、审查影响、验证行为、迁移数据、处理故障,并在几个月后继续修改。生成速度提高后,这些工作反而更容易成为新的瓶颈。

本文由《听懂 AI》第 004 期整理而成。节目来源是 Florian Herrengt 于 2026 年 8 月 11 日发布的个人文章《AI is removing the middle class of software engineering》。原文以作者经历和判断为主,并不是就业市场或软件质量的统计研究;本文另外引入 GitHub、METR 和 DORA 的研究,核对“写得更快是否等于生产力更高”。

原文所说的“中间地带”是什么

Herrengt 描述了一个常见场景:周一早上出现多个由 Agent 生成的巨大合并请求,功能看起来能运行,却没有人能清楚解释数据从哪里来、为什么增加某个抽象、失败后如何恢复。设计依据甚至只存在于一条来回改口的模型对话里。

他所谓的“工程师中间层消失”,不是一项职业分类研究,而是一个比喻:当实现和集成越来越容易,单纯把规格翻译成代码的价值可能下降;能够理解复杂系统、判断取舍并对结果负责的人会更重要。

原文中的“25,000 行合并请求”“一下午生成 20,000 行”等数字是叙事例子,不是行业平均值。文章对薪资分化和岗位减少的判断也是作者预测,不能当成已经发生的统计结论。

AI 到底让开发者快了多少

不同研究给出的答案并不一致,因为它们测量的任务完全不同。

GitHub 在一项受控实验中让 95 名专业开发者完成同一个 JavaScript HTTP 服务器任务。使用 GitHub Copilot 的一组平均用时 1 小时 11 分,未使用的一组平均用时 2 小时 41 分,前者快 55%。这是一个范围清楚、自动测试可以判断完成度的单项任务。

METR 在 2025 年研究了另一种情境:16 名熟悉大型开源项目的开发者完成自己仓库里的 246 个真实任务。允许使用当时的 AI 工具后,任务完成时间反而增加了 19%。参与者原本预计会快 24%,做完后仍主观认为自己快了 20%。

这项结果也不能推广到所有开发。它只代表 2025 年初的工具、这些开发者和成熟仓库。METR 在 2026 年公布的后续数据出现了可能的加速信号:原研究参与者子集估计快 18%,新招募开发者估计快 4%,但置信区间都包含没有提升的可能,而且不愿离开 AI 工具的开发者更容易退出实验,造成明显的选择偏差。研究团队因此决定调整实验设计。

三组结果放在一起,能得到一个更可靠的判断:AI 对明确、局部、容易验证的实现任务可能明显提速;在熟悉但复杂的长期项目中,上下文、验证和协作成本可能抵消一部分收益。不能用一个百分比概括全部软件工程。

瓶颈从敲代码移到理解变化

实现变快以后,团队要处理的变更数量和批次都可能增加。每个变更仍需要回答这些问题:

  • 它是否符合真实业务约束;
  • 新抽象是不是必要;
  • 数据迁移失败时怎样回滚;
  • 测试是否覆盖了没人想到的行为;
  • 出现线上事故时,谁能解释和修复;
  • 半年后还有没有人知道为什么这样设计。

需求经过 AI 生成、理解审查、测试集成和运行维护,说明生成速度只是团队交付的一部分

图中流程是一般性的交付模型,不代表每个团队都使用相同阶段,也没有给出行业统一的速度比例。

DORA 的 2025 年研究把 AI 描述为组织能力的“放大器”:基础流程、平台和文化较强的团队更容易获得收益,原有弱点也可能被同步放大。DORA 在 2026 年的后续分析中还指出,生成阶段节省的时间经常转移到审计和验证;更高的 AI 使用与更高吞吐量、同时也与更高交付不稳定性相关。这里是关联关系,不等于 AI 单独造成了不稳定。

代码行数和 PR 数量为什么会骗人

一个人一天提交十个 PR,看起来像生产力提高了十倍。如果三个审查者接下来花两天理解、退回和重写,工作只是从生成者转移到了团队其他成员。

代码行数、PR 数量和“完成”的任务卡都属于局部产出指标。团队真正关心的是从需求到安全上线的完整周期,以及上线后的失败率、恢复时间、维护成本和知识是否有人掌握。

这并不意味着大改动永远错误,也不意味着技术债绝对不能欠。团队必须知道自己接受了什么风险、为什么此刻值得接受,以及准备怎样偿还。

“工程师两极分化”仍然只是预测

原文认为,AI 会扩大优秀工程师和较弱工程师之间的薪资差距。这是作者的判断,不是文章提供数据证明的结论。

现有证据只足以说明,AI 正在改变工程技能的相对价格。模板实现、样板代码和常规转换越来越便宜;需求澄清、系统建模、复杂度控制、测试设计、事故处理和技术取舍仍然需要大量上下文与责任承担。

初级工程师也不等于“只能写 CRUD”。原文自己举了相反例子:愿意追问、建立理解并检查假设的初级开发者,可能比已经放弃理解的资深开发者更可靠。风险不在职级,而在于是否把 AI 当作建立理解的工具,还是替代理解的借口。

怎样避免代码增长快过团队理解

团队可以从变更规模和知识所有权入手:

  1. 要求 Agent 把任务拆成可独立审查的小改动;
  2. 合并请求必须说明设计理由、替代方案和主要风险;
  3. 用测试和 Eval 验证行为,不只验证代码能编译;
  4. 数据库、权限和基础设施变更必须写回滚方案;
  5. 生成代码的人要能不用聊天记录解释数据流和故障模式;
  6. 同时衡量审查负荷、交付周期、变更失败率和恢复时间;
  7. 把模型对话中的关键决定整理成团队可维护的文档。

使用 AI 并不等于放弃工程判断。真正需要警惕的是,代码已经进入生产,而团队仍不知道它为什么存在、会影响谁,以及出错后该怎么办。

收听本期节目

《听懂 AI》第 004 期节目封面

原始资料与延伸阅读

  1. Florian Herrengt,2026-08-11:AI is removing the middle class of software engineering——个人经验与观点文章。
  2. GitHub Research:Quantifying GitHub Copilot’s impact on developer productivity and happiness——95 名开发者完成固定 JavaScript 任务的受控实验。
  3. METR,2025-07-10:Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity——16 名成熟开源项目开发者、246 个真实任务的随机实验。
  4. METR,2026-02-24:We are Changing our Developer Productivity Experiment Design——晚 2025 工具的后续信号、选择偏差和实验设计限制。
  5. DORA,2025:State of AI-assisted Software Development——AI 使用与组织系统、吞吐量和稳定性的研究框架。

资料说明:原文关于“中间层”、薪资和就业结构的描述属于作者观点。本文补充的研究测量了不同任务和组织情境,结果不可直接互相替代,也不能用于预测单个岗位的未来。

 
加密的思考,也会被偷走吗?这篇论文真正发现了什么

密封的推理胶囊越过安全边界进入另一台兼容机器

看到一段无法阅读的加密文本,人很容易把它当成“安全的乱码”。但在大模型 API 里,这类不透明数据可能保存着模型的隐藏推理、工具返回值,甚至用户输入过的敏感信息。

2026 年 8 月提交的论文《Stealing Reasoning Traces from Proprietary LLM APIs》提出了一个反直觉的风险:研究者没有暴力破解密钥,也没有直接攻破防护最严的前沿模型,而是利用加密推理块可以跨会话、跨用户和跨模型复用的特性,把它交给同一家服务商中防护较弱的兼容模型处理。

本文由《听懂 AI》第 003 期整理而成。事实来源是 Alexander Panfilov 等 8 位作者于 2026 年 8 月 10 日提交的 arXiv 预印本及作者项目页。论文测试的是 2026 年 7 月初可用的特定 API 和模型版本,不能直接代表今天所有接口仍然存在相同行为。

什么是“加密推理块”

推理模型在给出最终回答前,会生成较长的中间推理。服务商通常不把完整推理以明文返回,而是向客户端提供摘要,以及一段签名或加密后的不透明数据。客户端保存这段数据,并在下一轮请求时原样传回,让模型延续之前的推理状态。

论文把这种数据描述为经过认证加密的封装。它既能防止用户直接阅读,也能检测内容是否被篡改,同时让服务端不必长期保存每次会话的完整推理。

需要注意,论文作者也明确说,各服务商没有公开完整的密码学实现。因此,文章中的具体结构和密钥使用方式来自研究者的实验观察与推断,不是服务商公开的协议承诺。

问题不一定是“加密被破解”

论文发现的关键在于兼容范围过大。一个推理块可能被拿到另一段会话、另一个用户,甚至同一服务商的另一个模型中继续使用。

攻击者可以先从能力强、拒绝训练更严格的模型获得一个加密推理块,再把它送给较弱但兼容的模型。后者本来就需要合法解开并处理这类数据,研究者再诱导它把处理到的内容输出出来。

整个过程不需要知道加密密钥,也没有修改密文。真正失守的是“这段加密数据只能在原来的用户、会话和模型里使用”这一安全边界。

加密推理块从强模型经过客户端保存后被跨上下文重放给兼容模型,以及对应的纵深防御措施

示意图只说明安全边界和防御方向,不包含论文中的具体攻击提示或供应商实现细节。

论文描述了四类风险

第一类是模型蒸馏。竞争者可能批量提取强模型的隐藏推理,用来训练或模仿另一个模型,绕过服务商隐藏思维过程的初衷。

第二类是敏感数据泄露。开发者公开 Agent 会话、评测轨迹或 API 日志时,常常只清理肉眼可见的文本,却保留看似无害的不透明推理块。秘密可能仍藏在里面。

第三类是拒答背后的危险内容。模型最终可能正确拒绝一个恶意请求,但隐藏推理已经处理过更具体的信息。如果推理块可以被恢复,安全的最终回答并不代表整个执行过程都没有泄露。

第四类是不可见提示注入。恶意指令可以隐藏在不透明数据中,人工审查日志时看不见,后续接手同一轨迹的 Agent 却可能读取并执行。论文把它作为概念验证和长时任务污染风险来讨论。

31 万个推理块里发现了什么

研究者从 GitHub 和 Hugging Face 收集了 6,708 条公开 Agent 轨迹,重建了 315,320 个推理块。完整论文给出的统计包括:

  • 1,028 个推理块,也就是约 0.3%,包含至少一项隐私泄露;
  • 328 条轨迹,也就是 6,708 条中的 4.9%,至少泄露过一项真实敏感信息;
  • 在排除基准测试身份后的真实用户会话中,研究者去重得到 704 项隐私数据;
  • 其中包括 62 个 API Key、33 个密码、24 个访问令牌、7 个私钥和 30 个个人邮箱;
  • 64 项数据只出现在隐藏推理中,没有出现在可见聊天记录里。

论文摘要还用另一组分类口径概括为 367 项个人身份信息和 182 项凭据。不同数字对应不同分类、去重和数据范围,不能直接相加,也不能理解成同样数量的独立受害者。

研究样本来自公开轨迹,不是对整个互联网或所有生产系统的普查。论文也使用两阶段自动分类筛掉占位符和测试数据,但这仍是一项定向研究,不是完整的泄露率调查。

为什么“我已经清理日志”仍可能不够

论文给出的一个典型风险是会话清理:用户要求 Agent 删除仓库中的秘密,模型在隐藏推理中重新读取并复述这些值;最终可见回答只说“已经清理”,但不透明推理块仍可能保留原值。

因此,只搜索最终回答里的 API_KEY 或密码格式并不够。共享原始 API 记录前,还需要删除 signaturethinkingSignatureencrypted_content 等不透明推理字段。字段名称会随供应商和 SDK 改变,不能依赖一份永远不变的黑名单。

如果含有此类数据的会话已经进入公开 Git 仓库,删除最新文件也不代表历史提交消失。应当检查 Git 历史、缓存、制品和数据集副本,并轮换可能已经暴露的凭据。

论文有哪些限制,漏洞现在还存在吗

这篇论文是 2026 年 8 月 10 日提交的 v1 预印本。实验针对 2026 年 7 月初的 Anthropic、OpenAI 和 Google API 版本,服务商可以在不公告的情况下改变内部实现。

作者无法看到隐藏推理的真实明文,因此不能逐字证明每次提取都完全正确。他们主要用 API 报告的思考 token 数量与恢复文本的 token 数量做对照,并在 120 个 Codeforces 问题上观察到较强的一致性。这是提取可信度的证据,但不是完整的明文真值验证。

论文还说明,团队在发表前已向相关模型服务商、Microsoft 和 Hugging Face 负责任披露。作者报告说,各服务商确认收到报告,此后他们已经无法用相同方法继续发动攻击。这说明供应商可能采取了缓解措施,但不能据此推断所有历史数据已经安全,也不能证明所有相邻攻击面永久消失。

服务商和开发者分别能做什么

论文建议服务商使用多层防御:

  1. 把完整推理留在服务端,客户端只拿随机句柄;
  2. 在认证加密中绑定用户、会话、模型、前序提示和对话历史;
  3. 在 API 网关阻止跨模型推理块;
  4. 为异常重放提供签名或密钥撤销机制;
  5. 训练模型拒绝输出隐藏推理,并监控异常提取模式。

更严格的上下文绑定会影响合法的会话压缩、历史编辑和模型切换,因此不是简单增加一个字段就能完成。即使绑定正确,只要某个模型必须解开并处理旧推理,模型级提示攻击仍可能成为风险,所以需要纵深防御。

开发者现在可以做这些事:

  • 把不透明推理块当作敏感数据,而不是普通日志;
  • 发布会话、轨迹或复现包前,删除完整推理字段;
  • 不把未经验证的外部推理块传给 Agent;
  • 检查已经公开的仓库与历史提交,必要时轮换凭据;
  • 在日志策略里明确区分可见回答、工具结果和隐藏推理载荷。

密文不是废数据,也不是天然安全的秘密存储。看不懂一段内容,只说明人无法直接阅读,并不代表系统中的其他组件也无法处理它。

收听本期节目

《听懂 AI》第 003 期节目封面

原始资料与延伸阅读

  1. Alexander Panfilov、David Schmotz、Ilia Shumailov 等,2026-08-10:Stealing Reasoning Traces from Proprietary LLM APIs
  2. arXiv:论文 HTML 全文——包含威胁模型、实验结果、限制、披露过程和缓解方案。
  3. 论文作者:Stolen Thoughts 项目页——论文结果的交互式说明;示例中可能包含安全研究材料,阅读时不要复制其中的攻击提示或凭据样例。

资料说明:本文的技术结论和数字均来自论文 v1。论文作者报告的攻击状态、供应商范围和缓解结果具有时间性,后续版本或服务商更新可能改变结论。

 
一群模型,各干各的活:Nemotron 3.5 Lightning 和 Switchyard 到底解决什么

未来调车场把不同任务送往不同的专用计算站

一个 AI Agent 真正运行起来以后,并不是每一步都需要最强模型。制定计划、处理复杂异常,可能值得调用能力最强的模型;执行工具、检查返回值、整理格式和重复查询,往往更在意速度和成本。

如果所有步骤都交给同一个昂贵模型,效果容易预测,账单和延迟却会迅速增加。反过来,如果只用便宜模型,复杂任务又可能失败。模型路由想解决的,就是如何在这两种选择之间分工。

本文由《听懂 AI》第 002 期整理而成。节目讨论的主要来源是 NVIDIA 于 2026 年 8 月 11 日发布的 Nemotron 3.5 Lightning 与 NeMo Switchyard 资料,并加入了对项目成熟度、评测边界和 Hacker News 社区争议的核对。

Agent 不只需要一个模型

传统聊天产品通常把一次请求交给一个固定模型。Agent 的情况不同:它可能先规划,再调用工具,读取结果,修正计划,最后生成答案。一次任务里会出现很多性质不同的步骤。

NVIDIA 把这种架构称为“模型系统”:前沿推理模型负责规划和编排,小而快的模型承担代码检查、工具调用、安全告警监控和账单查询等高频工作。这个思路并不要求小模型取代大模型,而是让每种模型做自己更合适的事。

Nemotron 3.5 Lightning 小在哪里

Nemotron 3.5 Lightning 是一个 300 亿参数的混合专家模型(MoE),但每个 token 只激活约 30 亿参数。可以把它理解成一个拥有多个专家小组的组织:总知识容量仍然较大,每次任务只叫少数专家参与,因此单次计算量接近更小的稠密模型。

NVIDIA 的技术文章称,它针对长期运行 Agent 的高频执行层设计,并使用多 token 预测、推测解码和量化等手段提高吞吐量。官方公布的结果包括:

  • 相比同级模型,输出速度最高可达 4 倍;
  • 在 PinchBench 上达到 86% 准确率,完成 1 万个任务的时间比 Qwen3.6 35B 快 30%,同时保持接近的准确率;
  • 提供 BF16 和 NVFP4 检查点,可用于本地和数据中心部署。

这些都是 NVIDIA 选择的测试条件和对照模型,适合用来理解产品定位,不能直接换算成任何业务的固定收益。真实效果仍取决于任务分布、推理框架、硬件、并发量和输出长度。

Switchyard 不只是一个“选模型”按钮

NeMo Switchyard 是一个用 Rust 编写的代理和路由库。它能在不同模型与供应商之间分配请求,也负责 OpenAI Chat、OpenAI Responses 和 Anthropic Messages 等接口格式之间的转换。

仓库目前提供多种路由方式:

  • 用一个分类模型判断请求应该进入强模型还是弱模型;
  • 根据工具结果、错误等会话信号分阶段路由;
  • 先让弱模型回答,再由评审决定是否升级;
  • 按固定比例随机分流,用于 A/B 测试;
  • 编写自定义算法,把质量、延迟和成本偏好放进自己的规则。

任务经过模型路由器分配给推理模型、快速模型或本地模型,再由质量门禁决定返回或升级

这张图表示一般性的模型路由结构,不代表 Switchyard 会自动识别所有任务,也不表示三类模型一定同时存在。

路由器本身也会消耗资源。真实总成本不仅包括最终模型调用,还包括路由判断、额外分类模型、缓存补齐、失败重试和升级调用。若没有统一的质量门禁,所谓“节省成本”可能只是把错误推迟到后面。

那些漂亮的降本数字该怎么看

NVIDIA 公布的内部基准称,Switchyard 在保持前沿级准确率的同时,可把任务完成成本降到单独使用 Opus 4.8 的近三分之一。合作方数据中还有两组很醒目:

  • LangChain 在 145 个多轮 Deep Agents 任务中,只把 7% 的调用交给前沿模型,成本降低 74%,但准确率下降了 6%;
  • Ramp 报告称,在其 SWE-Bench 场景中,成本降低 58%,运行时间减少 33%,同时匹配前沿模型表现。

这些结果说明模型路由有潜力,但它们来自 NVIDIA 及合作方披露,并不是对所有任务的独立保证。尤其要注意 LangChain 的结果并非“质量完全不变”,而是用 6% 的准确率差异换取明显的成本下降。

评估路由器时,至少要同时看四项指标:正确率、端到端延迟、完整任务成本和失败后的恢复成本。只比较单个 token 价格,往往会漏掉路由判断和重试。

换模型会不会毁掉提示缓存

Hacker News 讨论中,争议最大的问题之一是提示缓存。批评者认为,同一会话不断切换模型,会让已经积累的 KV Cache 失效,抵消便宜模型省下的成本。

社区里也有人给出另一种解释:每个模型可以维护自己的缓存;重新切回某个模型时,只需要为它补上缺失的对话增量,而不是每次从头处理全部上下文。这样做仍然需要额外 prefill,而且缓存不能在结构不同的模型间直接共享,但较便宜模型承担更多生成工作后,整体仍可能节省费用。

这段讨论不能当作 Switchyard 的官方缓存承诺。它更像一个提醒:模型池大小、会话黏性、缓存策略和路由频率必须一起设计。模型选得越多,路由器越复杂,未必越划算。

新闻稿很积极,仓库却写着 pre-alpha

截至 2026 年 8 月 28 日,Switchyard 仓库仍把整个项目标为 pre-alpha,并提醒 API 和算法在 1.0 之前可能大幅变化。各组件的成熟度也不同:libsy 标为 Beta、可试验性集成;客户端和 runner 仍是 Alpha;switchyard-server 是演示服务器,明确不建议用于生产环境。

这与新闻稿中的“部署”“企业使用”并不完全矛盾:合作方可能使用的是内部集成、特定组件或受控试验,并不等于公开仓库中的演示服务器已经具备生产条件。对普通开发团队来说,更合理的起点是离线评测或旁路实验,而不是立刻替换线上网关。

真正动手前,先准备一张自己的路由表

如果要验证模型路由,可以从一个很小的模型池开始:一个擅长复杂规划的模型,一个便宜快速的执行模型,再加明确的升级条件。

建议先完成下面几件事:

  1. 从真实日志中整理任务类型,不要凭想象分类;
  2. 用同一批任务建立单模型基线,包括质量、延迟和总成本;
  3. 为每种路由结果记录选中了谁、为什么选、是否升级以及最终是否成功;
  4. 单独测量长会话下的缓存命中率和补齐成本;
  5. 为路由错误准备回退方案,并把重试也计入成本。

好的路由器不会一味选择最便宜的模型。它应当使用可验证的规则,把昂贵能力留给确实需要它的步骤。

收听本期节目

《听懂 AI》第 002 期节目封面

  • 标题:一群模型,各干各的活:聊聊 NVIDIA 的 Nemotron 3.5 Lightning 和 Switchyard
  • 时长:5 分 53 秒
  • 音频:在线播放或下载
  • 播客主页:听懂 AI

原始资料与延伸阅读

  1. Kari Briski,NVIDIA,2026-08-11:NVIDIA Nemotron 3.5 Lightning and NeMo Switchyard Deliver Faster, Smarter, More Efficient Agentic AI
  2. Chris Alexiuk、Chintan Patel,NVIDIA Technical Blog,2026-08-11:NVIDIA Nemotron 3.5 Lightning Delivers Fast, Accurate Specialized Task Execution for Long-Running Agents
  3. NVIDIA-NeMo:Switchyard GitHub 仓库——功能、路由策略、许可证与成熟度说明。
  4. Hacker News:Nvidia Nemotron 3.5 Lightning and NeMo Switchyard——社区关于缓存、路由开销和产品成熟度的讨论;评论不代表已经验证的事实。

资料说明:性能和合作方数据主要来自 NVIDIA 官方材料,本文已保留测试主体、对照对象与准确率差异。关于缓存的内容来自社区讨论,只作为工程问题线索,不作为 Switchyard 的官方保证。

 
它真的“懂”你吗?用一杯咖啡理解大语言模型

咖啡馆里,人与由光点和空白 token 构成的语言模型对话

第一次和大语言模型聊天,很容易产生一种错觉:屏幕另一端像是坐着一个读过无数书、什么都能聊的人。它能续写邮件,能解释概念,也能顺着语气安慰你。可一旦追问一个冷门事实,它又可能用同样笃定的口吻编出不存在的人名、论文和日期。

这两种表现并不矛盾。要理解它,先放下“电子大脑”这个比喻,把它想成一位特别擅长接话、但不会自动查证的咖啡馆店员。

本文由《听懂 AI》第 001 期访谈整理而成。该期节目从科普主题出发,并非改写某一篇原文;文末补充了 Transformer、GPT-3、语言理解争议和真实性评测的原始论文。

一句话版本:它在反复预测下一个 token

假设你说:“今晚下雨,出门记得带……”

人很容易想到“伞”。语言模型做的事情与此有一点相似,但规模大得多:它先把输入切成一组 token,再结合前面的上下文,为下一个 token 计算概率。选出一个之后,它把这个 token 加回上下文,继续预测下一个,直到回答结束。

token 不一定等于一个完整汉字或单词。它只是模型处理文字时使用的基本单位;具体怎样切分,取决于模型采用的分词方法。

输入文字经过 token 切分、概率计算和循环追加后生成回答

图中的候选词和概率只是工作原理示意,不是某个真实模型的测量结果。

2017 年的论文 Attention Is All You Need 提出了 Transformer 架构。它通过注意力机制处理序列中不同位置之间的关系,后来成为大语言模型的重要技术基础。2020 年的 Language Models are Few-Shot Learners 则展示了 GPT-3 这类自回归语言模型在扩大参数和训练数据规模后,可以仅凭文字指令或少量示例完成多种任务。

所以,“预测下一个 token”听起来很朴素,却不代表模型只能做简单的句子补全。模型从大量训练文本中学到语法、文体、概念之间的关联,以及常见的推理表达方式。当这些规律共同参与一次预测时,结果就可能表现为写作、问答、翻译或代码生成。

它不是在脑中翻找一篇现成文章

另一个常见误解是:模型先把互联网背下来,回答时再从某个数据库里找到对应段落。

训练确实可能让模型记住部分内容,尤其是重复出现或具有独特表达的文本。但通常情况下,训练材料中的语言规律会被编码进大量参数。生成回答时,模型根据参数和当前上下文计算后续内容,不是在资料库中逐条检索。

这里需要区分基础语言模型和完整的 AI 产品。一个产品可以在模型外部接入搜索引擎、知识库、计算器或其他工具。此时你看到的答案可能同时包含模型生成和外部检索结果,但检索能力不是“预测下一个 token”天然附带的事实核验机制。

它会用语言,但“理解”仍有争议

模型能正确处理“下雨”和“带伞”的关系,是否就说明它理解雨是什么?

这个问题没有一句公认的结论。Emily M. Bender 和 Alexander Koller 在 2020 年的论文 Climbing towards NLU 中强调,学习语言形式与获得由现实经验支撑的意义不是一回事。模型可以熟练处理词语之间的关系,却没有淋雨、撑伞或被冷风吹过的身体经验。

另一方面,只用“随机鹦鹉”也不足以描述今天模型表现出来的全部能力。它确实掌握了强大的语言模式处理能力,但不能据此直接推断它拥有人的意识、感受或理解方式。

为什么它会一本正经地编答案

模型的基本目标是生成在当前上下文中看起来合适的后续,而不是保证每句话都经过外部证据核对。如果问题含糊、训练材料不足,或者错误说法在语料中很常见,它仍可能生成连贯但不真实的答案。

2021 年的 TruthfulQA 用 817 个问题测试模型是否会复述人类常见的错误观念。在当时接受评测的模型中,最佳结果有 58% 的回答被判定为真实,而人类基线为 94%。这组数字不能代表今天任何具体产品的水平,但它说明了一个长期存在的问题:语言流畅度和事实真实性不是同一个指标。

因此,遇到下面这些内容,不要因为语气自信就直接采用:

  • 具体日期、数字、论文名称和引用;
  • 医疗、法律、财务等高风险建议;
  • 冷门人物、机构和历史事件;
  • 无法打开或无法在其他来源中找到的链接。

普通人怎样用得更稳妥

把语言模型当成一个反应很快、知识面很广、但偶尔会硬撑的助理,通常比把它当成权威更合适。

适合交给它的工作包括改写文字、整理材料、列出备选方案、模拟提问和解释概念。涉及重要事实时,可以要求它区分“已知事实”“推测”和“不确定项”,列出可核验的来源,再由人打开原始资料确认。

提问也不需要背诵所谓的“提示词咒语”。说明读者是谁、想解决什么问题、有哪些限制,再给一个例子,往往就能明显改善结果。与此同时,不要随手提交身份证号、病历、公司机密或未公开代码;能否输入某类数据,应以所在组织的制度和所用产品的数据政策为准。

使用时记住:它很会生成答案,但“很像答案”不等于“答案是真的”。

收听本期节目

《听懂 AI》第 001 期节目封面

原始资料与延伸阅读

  1. Ashish Vaswani 等,2017:Attention Is All You Need——Transformer 架构原始论文。
  2. Tom B. Brown 等,2020:Language Models are Few-Shot Learners——GPT-3 与少样本学习论文。
  3. Emily M. Bender、Alexander Koller,2020:Climbing towards NLU: On Meaning, Form, and Understanding in the Age of Data——语言形式、意义与“理解”的讨论。
  4. Stephanie Lin、Jacob Hilton、Owain Evans,2021:TruthfulQA: Measuring How Models Mimic Human Falsehoods——语言模型真实性评测。

资料说明:节目第 001 期原始 sources.json 只记录了“向不懂技术的人解释大语言模型”这一主题,没有外部 URL。以上论文由本文编辑阶段补充,用于说明相关技术背景和争议,不代表节目逐句改写这些论文。

 
别急着让 AI 写代码,先把项目里的词讲清楚

开发者与抽象 AI 围绕术语表和决策树协作

最近看了 Matt Pocock 的一段视频:

视频只有 15 分钟,讲的却不是某个新模型或提示词技巧,而是一个更基础的问题:让 AI 参与一个已有代码库时,怎样避免每次都从头解释业务名词和历史决定?

Matt 之前的 /grill-me 会持续追问,把模糊的想法问到可以执行。它并没有失效;问题在于,单靠一轮轮问答,已经确认过的概念不会自动成为项目的一部分。下一次会话里,人仍可能要解释“独立视频”到底指什么、某个对象之间是一对一还是一对多、这个状态能否随意切换。

他现在在编码场景中改用 /grill-with-docs。它保留追问,但把共同语言和不容易看懂的决策写进仓库。这样,聊天记录不再是唯一的上下文。

单纯追问,为什么还不够

视频中的例子是一项新功能:在一个管理课程和视频的应用里加入 pitch。这里的 pitch 不是代码里的通用术语,而是视频的“包装”——标题、描述和对外呈现方式;团队会先想出多个 pitch,再选择其中一些制作成视频。

人一听就能根据上下文补全很多含义,AI 却没有这种默认背景。例如:

  • standalone video 是不属于课程或课时的视频,还是“尚未关联 pitch 的视频”?
  • 一个 pitch 能否对应多个视频?一个 pitch 是否可以暂时没有视频?
  • 删除 pitch 时,是连带删除、禁止删除,还是归档?
  • idlescheduledshipped 是强制流转的状态机,还是可以手动修改的标签?

这些不是措辞洁癖。它们会影响数据库关系、删除规则、变量名、文件名、界面分组和后来的人怎样理解代码。若定义只存在于某次聊天里,之后每一次让 AI 修改相关部分,都会重新产生猜测空间。

把“共同语言”写成 context.md

/grill-with-docs 借用了领域驱动设计(DDD)中的“通用语言”思路。它会先寻找 context.md,读取其中的术语和定义;在对话中发现概念不清、用词冲突或新规则时,再要求人确认并更新这份文件。

在视频里,context.md 至少承担三件事:

  1. 说明这个代码库在解决什么问题;
  2. 定义关键实体、状态和关系,例如课程、版本、独立视频与 pitch;
  3. 为不熟悉项目的人和 AI 提供同一份可查阅的词汇表。

它不需要写成一份覆盖全部实现的百科全书。视频里的建议更接近 DDD 的 bounded context:一个大型 monorepo 可以有 context map 和多个上下文;如果一个仓库内大家说的是同一种业务语言,一份放在根目录的 context.md 就够用。

关键不在文件名,而在约束:产品、代码和与 AI 的对话尽量用同一个词。否则,文档里叫“已投递视频”,数据库表叫 standalone_videos,界面又叫“提案视频”,AI 很难判断它们到底是不是同一个东西。

从新需求到共同语言的确认循环:对照 context.md、发现歧义、用场景确认、更新文档后再实现

共同语言需要在每次新需求中核对和更新;它不是一次写完就不再变化的说明书。

先核对词义,再讨论实现

/grill-with-docs 不会读完文档就直接生成代码。它会先把新需求同既有术语表对照,指出含义不清或冲突的地方,并通过具体场景把问题问出来。

视频的演示依次确认了:

  • pitch 与独立视频是一对多关系;
  • 有 pitch 的视频仍属于独立视频,pitch 是它的元数据,而不是另一类视频;
  • pitch 允许暂时不关联任何视频;
  • 状态目前可手动调整,自动流转以后再加;
  • 由于作者更倾向归档而非删除,删除关系选择限制删除。

这些回答随后写回 context.md。作者也展示了一个很现实的细节:写入后产生了 pitched standalone videounattached standalone video 之类别扭的名称。他没有假装第一版术语一定正确,而是提醒自己在“足够清楚”时停止讨论,后续需要时再重构。

这条边界很重要。共同语言的目的不是无限讨论命名,而是让接下来的实现少一点误解。

还有一类信息:为什么当时这样选

词汇表能定义“是什么”,却不总能解释“为什么”。视频把这类信息交给 ADR(Architecture Decision Record,架构决策记录)。

ADR 适合记录那些不看背景会觉得奇怪、又难以轻易撤回的选择:它面临过什么取舍、会带来什么后果。库选型这类容易替换的决定未必值得专门写 ADR;删除策略、数据关系或会影响多个模块的业务定义,通常更值得留下理由。

这也避免 AI 看到一个非直觉的实现时,自作主张把它“优化”掉。它能先读到决策背景,再判断当前需求是否真的要求改变它。

context.md 记录术语和关系,ADR 记录关键决策的取舍与影响;两者让人、AI 与代码共享背景

context.md 保存“是什么”,ADR 保存“为什么这样选”。

确认过的含义怎样留在项目里

Matt 的观察是:定义稳定后,AI 不必反复解释同一个概念,回复会更简洁;代码中的命名和规划文档也会更容易互相检索。这是他在工作流中的经验,而不是对所有模型和项目都成立的性能测试结果。

确认过的业务含义不必停在对话记录里。把它记录到仓库后,下一位开发者、下一次会话和后续生成的代码,都从同一份上下文开始。

从视频可以整理出一套小而可用的做法:

  1. 新功能开始时,只列出会影响数据、界面或规则的核心名词;
  2. 为每个名词写简短定义,并给一个能区分边界的例子;
  3. 让 AI 先检查这些词与现有代码、文档是否冲突,再进入实现;
  4. 把难以撤回的决定和取舍写成 ADR;
  5. 当名称已经能支持当前工作时继续开发,别为了完美命名无限停留。

这里的重点不是复制某个斜杠命令。即使不用这两个 skill,团队也可以建立同样的习惯:把 AI 提出的关键歧义当作待确认的产品或技术问题;确认后更新共享文档,而不是只在聊天窗口里回答一次。

/grill-me 并没有被淘汰

视频最后给出了一条很清楚的使用边界:有代码库时,优先用 /grill-with-docs;没有代码库的开放式任务,则继续用 /grill-me。作者还举了非工程场景的例子:有人用后者整理为母亲写悼词时的回忆,价值就在于耐心追问,而不是建立术语表。

项目刚开始时,作者仍倾向 /grill-with-docs,因为这恰好是最需要建立共同语言的阶段。差别不在于有没有足够多的代码,而在于这次对话是否要留下能被后续工作复用的领域知识。

让 AI 写代码之前,把项目里的词说清楚,看起来比直接输入需求慢一点。但当这些词会进入表名、组件名、接口和用户界面时,早一点确认往往比之后在许多文件里改名更便宜。

 
一台电脑上,怎样让两个 Codex CLI 账号互不干扰

两套独立的 Codex CLI 环境:各自的终端、状态目录与锁定边界,彼此没有连接

两个账号应各自使用独立的本地状态目录;它们可以同时工作,但不共享认证和会话。

一个人同时有个人和工作两个 OpenAI 账号时,最容易踩的坑不是登录,而是登录之后。默认情况下,Codex CLI 把认证、配置、会话和本地状态都放在同一个目录。后一次登录会让下一次启动的 CLI 使用新的身份;MCP、插件和会话历史也混在一起。

我在 macOS 上用 codex-cli 0.145.0 核对过这个行为。Codex 的配置源码把 CODEX_HOME 定义为全部本地状态的根目录:默认是 ~/.codex,设置后会改用指定目录。源码中的说明 也说明日志和 SQLite 状态会随这个目录变化。

这意味着可以把“个人”和“工作”当成两套独立的 CLI 环境,而不是在同一套配置里反复登录、退出。

这不是原生的多账号切换

先把边界说清楚。Codex CLI 还没有类似 --account work 的正式账号选择器;官方仓库中相应的功能请求仍是开放状态。该请求 本身也把现状描述为:默认只有一个本地状态目录,多账号只能换目录、换认证文件或重新登录。

所以 CODEX_HOME 的作用不是把两个账号放进一个账号列表里。它做的是把两套状态彻底分开:

  • 个人账号有自己的认证、配置、MCP、Skills、插件和会话记录;
  • 工作账号也有自己的一套;
  • 两个终端可以同时运行,各自读取自己的 SQLite 状态库;
  • 已经启动的 Codex 不会在运行中切换身份。要换账号,必须从对应入口启动新的 CLI 进程。

这比手动替换 ~/.codex/auth.json 稳妥得多,也更容易查清一条会话究竟用了哪个身份。

建两个独立目录,再分别登录

下面示例用两个目录保存状态。目录名只表示用途,不会把账号名称传给 OpenAI:

mkdir -p "$HOME/.codex-profiles/personal" "$HOME/.codex-profiles/work"

# 首次使用个人环境时登录个人账号
CODEX_HOME="$HOME/.codex-profiles/personal" codex login

# 首次使用工作环境时登录工作账号
CODEX_HOME="$HOME/.codex-profiles/work" codex login

以后从相同的入口启动即可:

# 个人环境
CODEX_HOME="$HOME/.codex-profiles/personal" codex

# 工作环境
CODEX_HOME="$HOME/.codex-profiles/work" codex

登录完成后,分别检查状态:

CODEX_HOME="$HOME/.codex-profiles/personal" codex login status
CODEX_HOME="$HOME/.codex-profiles/work" codex login status

如果日常经常在两个环境间切换,可以给终端写两个别名或两个很短的启动脚本。关键不是别名的名字,而是每个入口固定指向一个目录。涉及外部操作,例如创建 PR、发消息或使用带权限的 MCP 工具时,先看当前终端来自哪个入口。

不要复制或软链接 auth.json

看上去最快的做法,是先在默认目录登录一次,再把 auth.json 复制到另一个目录。这个方法不可靠。

一个凭据保险箱分出两条路径:正常刷新的文件保持高亮,复制出的文件破裂并出现警告

复制的认证文件可能在另一个副本刷新 refresh token 后失效;两个目录应分别登录。

Codex 使用的 OAuth refresh token 可能是一次性的:当一个副本刷新 token 后,另一个副本里的旧 token 会失效。官方仓库已有复现说明:复制认证文件后,第一次可能还能使用缓存的 access token,之后可能出现 401。问题 #15410 还明确指出,用软链接或复制文件来共享 ChatGPT 订阅认证都不是稳定方案。

每个目录各自执行一次 codex login。不要从另一套环境复制认证文件,也不要把认证文件纳入 Git、网盘同步或备份脚本。

配置隔离带来的实际影响

账号隔离不是只多两个 auth.json。新目录一开始没有你原来配置过的 MCP server、插件、Skills、偏好设置或历史会话。这既是代价,也是这个办法有用的原因。

我通常会把配置分成两类:

  1. 与身份无关、也不含密钥的通用设置,可以用一个受版本控制的模板维护;
  2. 包含公司地址、MCP OAuth 登录状态、访问令牌或本机路径的设置,只放在对应环境里。

这样做的好处是,工作账号不会意外加载个人的高权限工具,个人会话也不会写进公司的历史记录。代价是第一次使用时要分别安装或配置真正需要的工具。

要注意,本文只讨论从终端启动的 Codex CLI。桌面端、IDE 扩展和其他 GUI 进程未必会继承终端环境变量;不能因为 CLI 被隔离,就假定它们也已经切换到同一账号。它们应单独核对登录状态和凭据位置。

适合的使用场景和不适合的使用场景

这个办法适合把合法且明确授权的身份分开,例如个人订阅与公司账号、两个客户提供的独立账号,或需要避免配置互相污染的测试环境。

它不应用于自动探测额度、在账号受限后自动切到下一个账号,或把多个账号的额度当作一份可轮换的资源。OpenAI 的服务条款禁止规避速率限制、使用限制和保护措施;个人账号也不应与他人共享凭据。OpenAI Terms of Use

如果目标只是让日常开发时的个人、工作上下文互不干扰,两个目录、两次独立登录和两个固定启动入口已经够用。它没有魔法,也不会扩大任何一个账号的权限或额度;它只是把本来会混在一起的本地状态分开保存。


**来源与核验范围:**本文基于 codex-cli 0.145.0 在 macOS 上的本地检查,以及 OpenAI 公开的 Codex 配置源码多账号需求讨论认证文件复制问题服务条款。Codex 的行为和条款可能更新;实际配置前请以本机 codex --help 与当前条款为准。

 
十一年后,我用 SwiftUI 重写了「闪印」,还给它加上了 AI

2015 年,我做过一款很小的 iOS App,中文名叫「闪印」。它只解决一件事:把旅行、采购或工作清单整理好,预览,然后打印到纸上。

旧版用 Objective-C 和 Storyboard 开发,后来陆续支持了 iPad、iPhone Xs Max 和 iCloud。它没有复杂的账号系统,也不试图成为项目管理工具。清单建好,纸张打出来,任务就完成了。

十一年后,我重新打开这个项目,决定用 SwiftUI 把它重写一遍。新版项目叫 PrintableCheckList-SwiftUI,代码已经开源。

这次重写不是给旧界面换一层 SwiftUI。我要保留原来的用途,也要回答一个新问题:如果 AI 能帮人省掉大量录入工作,一份「可打印清单」今天应该怎么做?

它现在能做什么

PrintableCheckList 仍然可以完全手动使用。你可以创建多份清单,一次粘贴多行内容,编辑、删除或拖动排序,再生成带方框的打印预览,通过 iOS 系统打印控制器输出。

AI 是可选的快捷入口。比如输入:

生成一份带孩子去北海道旅行 7 天的冬季行李清单,需要考虑滑雪和儿童常用药。

App 会返回清单标题和项目。结果不会立刻写入数据,而是先进入编辑页;你可以改标题、删掉不需要的内容、补上个人物品,确认以后再保存。AI 也能给现有清单补充遗漏项,不必每次从头生成。

整个过程可以概括为:

输入主题
  ↓
按需联网搜索
  ↓
模型返回结构化 JSON
  ↓
去重、限长、清理序号
  ↓
用户检查和修改
  ↓
保存到本地 → 预览 → 打印

这里最重要的一步不是「生成」,而是生成后的确认。模型负责减少输入,用户仍然决定最后打印什么。

清单不只是待办事项

接入 AI 时,我很快遇到一个看似简单的问题:用户说「全球票房前十名」时,他要的是十部电影,不是「查询票房」「核对排名」之类的十个任务。

因此,内置提示词会区分两类内容:

  • 准备事项、操作步骤和计划,要生成简短、可执行的清单项;
  • 排行榜、目录和资料列表,要直接返回条目本身,并保留顺序和用户指定的数量。

模型必须返回固定的 JSON 结构。App 还会清理 Markdown 围栏、编号和重复内容,限制标题与项目长度。补充已有清单时,已经存在的项目也会被过滤掉。

这些处理不显眼,却决定了 AI 生成的内容能不能真正进入一个普通 App,而不是停留在聊天窗口里。

给时效性问题增加联网查证

旅行行李清单通常不需要搜索,但「最新票房排行」「最近发布的产品」或「当前汇率」不同。只靠模型已有知识,很容易得到过期答案。

PrintableCheckList 提供三种搜索模式:

  • 自动:识别排行、新闻、天气、价格等时效性主题,只在需要时搜索;
  • 始终搜索:每次生成前都先查资料;
  • 关闭:直接使用模型生成。

目前 GLM 通过 Web Search API 搜索,OpenAI 通过 Responses API 的 Web Search 搜索。搜索结果会先整理成一段带来源的材料,再交给清单生成器。结果页显示来源链接,但来源不会混进最终的清单项。

DeepSeek 和自定义 OpenAI 兼容服务仍可生成清单,只是不启用这条原生搜索路径。这样没有假设所有 /chat/completions 服务都支持同一种联网工具。

BYOK:API Key 留在用户设备上

新版采用 BYOK(Bring Your Own Key)模式。用户可以选择 GLM、OpenAI、DeepSeek,或填写自己的 OpenAI 兼容服务地址和模型名称。

API Key 存在 iOS Keychain,访问级别为 WhenUnlockedThisDeviceOnly。普通配置存入 UserDefaults,但不会包含 Key。生成请求和必要的搜索请求由设备直接发给用户选择的服务商,不经过开发者服务器。

没有配置 AI 也不影响手工创建、编辑、预览和打印。我坚持保留这条边界。AI 应该缩短输入时间,不应该变成打开清单 App 的通行证。

本地优先,也照顾旧用户的数据

每次编辑都会先保存到设备的 Application Support/PrintableCheckList/projects.json。没有网络时,清单的创建、修改和打印都能继续使用。

可选的 iCloud 路径使用 NSUbiquitousKeyValueStore,沿用旧版的 keyProjects。代码也保留了原来的 bundle identifier,并实现了 NSKeyedArchiver 迁移:旧 Objective-C 里的 ProjectItem 会转换成新的 Codable Swift 模型;旧 ID 不是 UUID 时,则生成稳定的 UUID。

这部分比重新画界面麻烦得多,却是一次真正的 App 更新必须承担的责任。重写代码不应该等于让用户重新输入数据。

需要说明的是,未签名模拟器不能代替真实 iCloud 环境。仓库已经覆盖旧数据导入和同步逻辑测试,但签名真机上的 iCloud 端到端验证仍然是发布前检查项。

打印仍然是主角

虽然新版加入了 AI,项目名称里的 Printable 没有变。

预览页使用 SwiftUI 显示标题、项目和空白方框;真正打印时,App 生成一段经过 HTML 转义的排版内容,再交给 UIPrintInteractionController。iPad 上还单独处理了打印弹窗的锚点,避免 popover 因缺少来源视图而崩溃。

测试中还会把默认中文旅行清单交给打印格式化器,确认它能排在一张 A4 纸内。相比「按钮能点」,这更接近 PrintableCheckList 真正要完成的事情。

工程本身也换了一种维护方式

新版最低支持 iOS 17,使用 SwiftUI 和 Swift Concurrency。工程文件由 XcodeGen 根据 project.yml 生成,.xcodeproj 不进入版本库。生成、构建、测试、模拟器运行和归档分别有独立脚本,日常开发不必手动维护 Xcode 工程里的文件引用。

截至 2026 年 7 月 22 日,我在 iPhone 16 Pro / iOS 18.5 模拟器上执行了完整测试:42 个测试用例中,41 个通过,1 个 Keychain 用例因为无签名模拟器缺少 entitlement 而按预期跳过。覆盖范围包括:

  • 清单的创建、编辑、排序、持久化和旧数据迁移;
  • 打印内容转义与 A4 分页;
  • AI 配置、Key 隔离、响应解析、去重和错误映射;
  • GLM 与 OpenAI 的联网搜索请求和来源解析;
  • 手工创建、AI 新建、AI 补充、取消、失败重试和设置页等 UI 流程。

本地运行

需要 macOS、Xcode、iOS 模拟器和 XcodeGen。克隆后运行:

git clone https://github.com/terryso/PrintableCheckList-SwiftUI.git
cd PrintableCheckList-SwiftUI
./Scripts/generate.sh
./Scripts/build.sh

执行完整测试:

./Scripts/test.sh

安装并启动模拟器版本:

./Scripts/run-simulator.sh

AI 配置不是运行项目的前提。你可以先把它当作一款普通的本地清单 App,之后再决定要不要填入自己的 API Key。

写在最后

软件重写很容易让人只关注新框架、新界面和新功能。但回到 PrintableCheckList,真正不能丢的只有两件事:旧数据还在,清单还能顺利打印。

SwiftUI 让界面和状态管理简单了很多,AI 让创建清单更快,联网搜索让时效性内容有了核对来源。不过这些能力最后都服务于一个很朴素的动作:拿起一张纸,照着清单去做事。

项目地址:github.com/terryso/PrintableCheckList-SwiftUI

 
用 Swift 构建 MCP Server:从零到接入 Claude 的完整教程

如果你是 Swift 开发者,又想把自己 Mac 上的能力(本地文件、Shortcuts、Xcode 项目、Core Data 数据……)暴露给 Claude、ChatGPT 这类 AI 助手,那么 MCP Server 就是你要的东西。而目前主流的 MCP 教程几乎都是 Python 或 TypeScript,Swift 版本极少——这也让 “swift mcp server” 成为一个几乎无人竞争的关键词。

本文用一个能跑通的最小示例,带你从零构建一个 Swift MCP Server,并接入 Claude Desktop。

什么是 MCP Server?

Model Context Protocol (MCP) 是 Anthropic 在 2024 年底提出的开放协议,用来标准化 “LLM 应用 ↔ 外部工具/数据源” 之间的通信。你可以把它理解成 “AI 应用的 USB-C”

  • MCP Host:AI 应用(Claude Desktop、Cursor、Codex CLI……)
  • MCP Client:Host 内部为每个连接建立的会话客户端
  • MCP Server:你写的进程,向 Host 暴露 toolsresourcesprompts

协议本体是基于 JSON-RPC 2.0 的双向消息,通过两种传输承载:

传输 场景 特点
stdio 本地进程,Host 直接 spawn 简单、零配置、无网络暴露
Streamable HTTP / SSE 远程或跨机器 Accept: application/json, text/event-stream

对本地 Mac 工具来说,stdio 是默认选择

为什么用 Swift 写 MCP Server?

多数教程默认 Python/Node,但用 Swift 有几个独特优势:

  1. 原生调用 macOS API:EventKit、Contacts、AppKit、Core Data、Shortcuts、ScreenCaptureKit……不需要 shell 桥。
  2. 单文件可执行swift build -c release 产出一个静态二进制,Claude Desktop 直接 spawn,无 Python 环境依赖。
  3. 强类型 + async/await:JSON-RPC 消息用 Codable + enum 建模,工具 handler 天然并发安全。
  4. 和 Xcode / SwiftUI 项目共享代码:同一份 Package 里既能被 App target 用,也能被 MCP server target 用。

架构总览

一个最小可用的 Swift MCP Server 包含四层:

┌─────────────────────────────┐
│  Claude Desktop (Host)      │
└──────────────┬──────────────┘
        stdio  │  JSON-RPC 2.0
┌──────────────▼──────────────┐
│  Transport  (stdin/stdout)  │  按行读、按行写
├─────────────────────────────┤
│  JSON-RPC Dispatcher        │  method → handler
├─────────────────────────────┤
│  MCP Protocol Layer         │  initialize / tools/list / tools/call
├─────────────────────────────┤
│  Your Tools                 │  echo / read_notes / run_shortcut ...
└─────────────────────────────┘

项目搭建

新建一个 Swift Package:

mkdir SwiftMCPDemo && cd SwiftMCPDemo
swift package init --type executable

编辑 Package.swift(macOS 13+,用到 AsyncStreamFoundation 的 JSON 编解码):

// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "SwiftMCPDemo",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(name: "SwiftMCPDemo", path: "Sources/SwiftMCPDemo")
    ]
)

第一步:JSON-RPC 消息建模

MCP 的每条消息都是 JSON-RPC 2.0。用 Codable 把请求 / 响应 / 错误建模一次,后面所有 handler 都复用:

import Foundation

struct RPCRequest: Decodable {
    let jsonrpc: String
    let id: JSONValue?      // 可能是 number / string / null(通知无 id)
    let method: String
    let params: JSONValue?
}

struct RPCResponse: Encodable {
    let jsonrpc = "2.0"
    let id: JSONValue?
    var result: JSONValue?
    var error: RPCError?
}

struct RPCError: Encodable {
    let code: Int
    let message: String
    var data: JSONValue?
}

/// 一个能表达任意 JSON 的枚举,避免到处写 [String: Any]
enum JSONValue: Codable {
    case null
    case bool(Bool)
    case int(Int)
    case double(Double)
    case string(String)
    case array([JSONValue])
    case object([String: JSONValue])

    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()
        if c.decodeNil() { self = .null; return }
        if let v = try? c.decode(Bool.self) { self = .bool(v); return }
        if let v = try? c.decode(Int.self) { self = .int(v); return }
        if let v = try? c.decode(Double.self) { self = .double(v); return }
        if let v = try? c.decode(String.self) { self = .string(v); return }
        if let v = try? c.decode([JSONValue].self) { self = .array(v); return }
        if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return }
        throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON")
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        switch self {
        case .null: try c.encodeNil()
        case .bool(let v): try c.encode(v)
        case .int(let v): try c.encode(v)
        case .double(let v): try c.encode(v)
        case .string(let v): try c.encode(v)
        case .array(let v): try c.encode(v)
        case .object(let v): try c.encode(v)
        }
    }
}

第二步:stdio 传输层

MCP over stdio 用 换行分隔的 JSON(每条消息一行)。关键点:

  • 所有日志必须写 stderr,不能污染 stdout;
  • 读取用行缓冲,避免半条 JSON。
actor StdioTransport {
    private let stdin = FileHandle.standardInput
    private let stdout = FileHandle.standardOutput

    func readLines() -> AsyncStream<Data> {
        AsyncStream { continuation in
            Task.detached {
                var buffer = Data()
                while let chunk = try? self.stdin.read(upToCount: 4096), !chunk.isEmpty {
                    buffer.append(chunk)
                    while let nl = buffer.firstIndex(of: 0x0A) {
                        let line = buffer.subdata(in: 0..<nl)
                        buffer.removeSubrange(0...nl)
                        if !line.isEmpty { continuation.yield(line) }
                    }
                }
                continuation.finish()
            }
        }
    }

    func send(_ response: RPCResponse) throws {
        var data = try JSONEncoder().encode(response)
        data.append(0x0A) // '\n'
        try stdout.write(contentsOf: data)
    }
}

func log(_ msg: String) {
    FileHandle.standardError.write(Data("[mcp] \(msg)\n".utf8))
}

第三步:注册工具

定义一个 Tool 协议,让每个工具自描述 schema 并处理调用:

protocol Tool: Sendable {
    var name: String { get }
    var description: String { get }
    var inputSchema: JSONValue { get }   // JSON Schema
    func call(arguments: JSONValue) async throws -> JSONValue
}

struct EchoTool: Tool {
    let name = "echo"
    let description = "Echo the input text back to the caller."
    let inputSchema: JSONValue = .object([
        "type": .string("object"),
        "properties": .object([
            "text": .object([
                "type": .string("string"),
                "description": .string("Text to echo back.")
            ])
        ]),
        "required": .array([.string("text")])
    ])

    func call(arguments: JSONValue) async throws -> JSONValue {
        guard case .object(let obj) = arguments,
              case .string(let text) = obj["text"] ?? .null else {
            throw NSError(domain: "echo", code: 1,
                          userInfo: [NSLocalizedDescriptionKey: "missing `text`"])
        }
        // MCP tool 返回的是 content 数组
        return .object([
            "content": .array([
                .object([
                    "type": .string("text"),
                    "text": .string(text)
                ])
            ])
        ])
    }
}

第四步:Dispatcher 与 MCP 生命周期

MCP 一次会话至少要处理三个方法:initializetools/listtools/call

final class Server {
    let transport = StdioTransport()
    var tools: [String: any Tool] = [:]

    func register(_ tool: any Tool) { tools[tool.name] = tool }

    func run() async {
        for await line in await transport.readLines() {
            await handleLine(line)
        }
    }

    private func handleLine(_ data: Data) async {
        guard let req = try? JSONDecoder().decode(RPCRequest.self, from: data) else {
            log("bad json: \(String(data: data, encoding: .utf8) ?? "?")")
            return
        }

        var resp = RPCResponse(id: req.id)
        do {
            switch req.method {
            case "initialize":
                resp.result = .object([
                    "protocolVersion": .string("2025-06-18"),
                    "capabilities": .object([
                        "tools": .object([:])
                    ]),
                    "serverInfo": .object([
                        "name": .string("swift-mcp-demo"),
                        "version": .string("0.1.0")
                    ])
                ])

            case "tools/list":
                let list = tools.values.map { t in
                    JSONValue.object([
                        "name": .string(t.name),
                        "description": .string(t.description),
                        "inputSchema": t.inputSchema
                    ])
                }
                resp.result = .object(["tools": .array(list)])

            case "tools/call":
                guard case .object(let p) = req.params ?? .null,
                      case .string(let name) = p["name"] ?? .null,
                      let tool = tools[name] else {
                    throw NSError(domain: "mcp", code: -32601,
                                  userInfo: [NSLocalizedDescriptionKey: "tool not found"])
                }
                let args = p["arguments"] ?? .object([:])
                resp.result = try await tool.call(arguments: args)

            case "notifications/initialized":
                return   // 通知无需回复

            default:
                resp.error = RPCError(code: -32601, message: "method not found: \(req.method)")
            }
        } catch {
            resp.error = RPCError(code: -32000, message: "\(error)")
        }

        if req.id != nil {
            try? await transport.send(resp)
        }
    }
}

main.swift 里把它跑起来:

@main
struct App {
    static func main() async {
        let server = Server()
        server.register(EchoTool())
        log("swift-mcp-demo starting on stdio")
        await server.run()
    }
}

编译:

swift build -c release
# 产物路径
echo "$(pwd)/.build/release/SwiftMCPDemo"

接入 Claude Desktop

编辑 ~/Library/Application Support/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "swift-demo": {
      "command": "/绝对路径/SwiftMCPDemo/.build/release/SwiftMCPDemo"
    }
  }
}

重启 Claude Desktop。在对话框输入框左下角的 🔌 图标里应能看到 echo 工具。让 Claude 调用:

用 echo 工具回显 “Hello from Swift MCP”。

如果一切正常,Claude 会把返回内容展示回来。

常见坑

  1. stdout 被日志污染:任何 print 都会破坏 JSON-RPC 帧。所有日志一律走 stderr。
  2. 忘记 notifications/initialized:Host 发来的通知没有 id,如果你也回一个响应会让客户端报协议错。判断 req.id != nil 再发送。
  3. schema 与 arguments 不一致inputSchema 里声明的 required 字段必须真的能从 arguments 里拿到,否则 Host 会跳过工具或报错。
  4. 权限提示卡住:如果工具触及通讯录、日历、屏幕录制等,第一次运行会弹系统授权;Claude Desktop 是无窗口 spawn,用户可能看不到——先在终端里手动跑一次触发授权。
  5. remote / SSE 传输:Streamable HTTP 的 POST 必须Accept: application/json, text/event-stream,否则官方 SDK 直接 406。stdio 走不通再考虑升级到 HTTP。

常见问题(FAQ)

Q:Swift MCP Server 能跨平台跑吗?
可以。核心代码只依赖 Foundation,Linux 上的 Swift 5.9+ 也能编译;要触达 macOS 专属 API(EventKit 等)时才会被平台绑定。

Q:需不需要自己实现 JSON-RPC,社区有没有现成库?
有官方 Swift SDKmodelcontextprotocol/swift-sdk)。生产项目直接用它;本文手写是为了把协议讲透。

Q:MCP Server 支持流式返回吗?
支持。工具可以在长任务里通过 notifications/progress 推进度,但要小心:客户端普遍有 30~60 秒左右的调用超时,超长任务应拆成 “创建 job → 查询结果” 两个工具。

Q:怎样调试?
最简单的办法:用 mcp-inspectornpx @modelcontextprotocol/inspector /path/to/SwiftMCPDemo)在浏览器里逐条查看请求与响应。

Q:MCP 会不会被 CLI 工具替代?
围绕 CLI vs MCP 有过一场讨论,但对于强类型、需要 schema 的 macOS 原生能力,MCP 仍然是最合适的封装。

结论

Swift + MCP 是被严重低估的组合:一份 Swift Package 就能把 macOS 原生能力干净地暴露给任何符合 MCP 的 AI 客户端,无 Python、无网络、类型安全。这篇教程的完整代码可以直接复制运行;下一步建议:

  • EchoTool 换成 RunShortcutTool,用 Processshortcuts run
  • 加一个 read_notes 工具走 AppleScript / EventKit;
  • 打包成 .pkg 或 Homebrew tap,让别人一键装。

如果你在做类似方向的实验,欢迎订阅本站 RSS 或看看姊妹项目 Open Agent SDK (Swift),那边把 “Agent Loop + MCP 集成” 完整跑通了。

 
BMAD Loop:把开发循环的控制权,交还给确定性代码

如果你看过我之前那篇 Story Automator 上手实录,应该还记得我最后的结论:

白天手工跑,目前还是自己手工跑会更快。但睡前把一批 Story 交给它过夜跑,这个场景它真的挺合适。

那篇文章里我留了个没回答的问题——为什么它跑得比人手工还慢? 我当时说"还没仔细分析它的实现原理"。

现在 BMAD 6.10 把这套东西重写了一遍,改名 BMAD Loop,也顺手把那个问题接上了。答案只有一句话,但它是理解整个设计的钥匙:

控制环里,不应该放 LLM。


BMAD Loop 封面:确定性 Python 编排器位于环心,LLM 节点挂在环上

先纠正一个最容易踩的误解

很多人第一次接触 BMAD Loop,会以为它是几个新 skill:bmad-loop-setupbmad-loop-sweepbmad-loop-resolvebmad-dev-auto

不是。这几个 skill 本身什么也不做。

真正驱动循环的,是一个用 uv 从 Git 装进来的 Python 工具——bmad-loop 包(仓库在 bmad-code-org/bmad-loop)。那几个 skill 只是编排器在循环的不同阶段会去调用的"基本操作"(官方文档里叫 primitive,说白了就是最基础的、可以单独派活的小单元):

  • bmad-dev-auto:开发——把意图变成经得起 review 的产物
  • bmad-loop-sweep:巡检——清理延后工作台账
  • bmad-loop-resolve:交互——和人一起消除歧义

换句话说,skill 是肌肉,Python 编排器才是中枢神经。 这一点想通了,后面所有设计都顺理成章。


灵魂信条:No LLM in the control loop

官方 README 的副标题一句话就给它定了性:

A deterministic ralph-loop orchestrator for the BMAD-METHOD implementation phase.

翻译过来:一个确定性的循环编排器。"确定性"(deterministic)这三个字是全文最重要的一组词。

它把整个开发循环切成两种完全不同的工作:

工作 谁来做 为什么
控制逻辑:选哪个 story、重试几次、什么算完成、能不能提交 纯 Python 代码 要确定、可调试、可复现、不花钱
创意工作:写代码、写测试、做对抗式 review LLM(在一次性会话里) 这才是 LLM 擅长、且只有 LLM 能做的事

回过头看 Story Automator 为什么慢——它的控制环里塞满了"用提示词去问 LLM 现在该干嘛"的环节。每问一次都要花 token、等推理,还可能跑偏,跑偏了就再问一次。把调度交给 LLM,等于让一个容易走神、按字计费的新人在流水线上当调度员。

BMAD Loop 的做法是:调度员换成一段不会走神、不收钱的 Python 代码,LLM 只在每个工位上干它该干的创意活,干完就走。

控制环与创意工位:确定性 Python 在上,dev 与 review 两个独立 LLM 会话在下

这样做换来四个好处,是后续所有机制的出发点:

  • 确定性:同样的 sprint 跑两次,调度路径一致
  • 可调试:流程是代码,出问题能打断点、看日志,而不是"猜提示词哪里没说清"
  • 可复现:每次运行的决策都有磁盘上的状态机记录
  • 省钱:控制逻辑零 token 消耗

四个让它"敢放手"的关键机制

"控制环不放 LLM"说起来轻松,但它带来一个尖锐的问题:编排器怎么知道一个 LLM 会话干完了、干对了? 旧做法是让编排器自己也是个 LLM,去"看"会话的输出——这正是 Story Automator 的包袱。

BMAD Loop 用四个机制绕开了这个包袱。

机制一:每个步骤都是全新上下文的一次性会话

Dev 和 review 是两个独立会话,review 会话绝不继承 dev 会话的上下文。

这一点反直觉,但极其关键。如果 review 会话带着 dev 写代码时的记忆,它天然会"护短"——人对自己刚写的代码容易先入为主、下不去狠手(心理学叫锚定效应,anchoring bias),LLM 也一样。把 review 放进一个对 dev 一无所知的全新会话里,它才会真的去挑刺,而不是附和。

类比:你不能让写代码的人和 code review 的人是同一个脑子。上下文隔离,就是给 review 配一双"没见过这份代码"的眼睛。

机制二:靠 hook 事件文件通信,绝不抓屏

编排器怎么知道会话结束了?答案是给 coding CLI(Claude Code / Codex / Gemini)注册 hook——StopSessionStartSessionEndPreCompact。这些 hook 在关键节点往磁盘写结构化事件文件,编排器只管 watch 这些文件。

而每个 skill 在自动化模式下跑完,会写一个机器可读的 result.json,声明自己这一轮的产物和状态。

旧做法(Story Automator):         BMAD Loop 的做法:
┌─────────────┐                    ┌─────────────┐
│  编排器(LLM) │                    │ 编排器(Python)│
│   去看屏幕   │ ←脆弱、贵、易错      │  watch 文件  │ ←稳、免费、结构化
└─────────────┘                    └─────────────┘
       ↑                                  ↑
   抓 pane / 读对话                  读 Stop hook 写的事件
                                   读 skill 写的 result.json

抓屏(pane-scraping)是上一个时代的痛:终端输出格式一变、模型多说了一句废话,编排器就懵了。换成"hook 写文件、编排器读文件",接口就从自然语言降维成了结构化数据,鲁棒性立刻上一个台阶。

机制三:Trust nothing, verify everything

这是整个系统最硬核的地方。每个 LLM 会话结束后,编排器不信任会话自己说的"我搞定了",而是去磁盘上独立校验:

  • spec frontmatter(文件开头的元信息)状态:story 的规格文件状态字段是否真的变成了 done
  • baseline-commit 匹配:会话声称改了哪些文件,和 git 里实际的 diff 对不对得上——这是一个便宜的"LLM 撒谎检测器"
  • 非空 diff:到底有没有真的改东西
  • sprint-status 同步:状态文件是否和实际进度一致
  • 你的测试 / lint 命令:最后提交前,跑一遍你自己定义的测试和 lint

校验全过,才允许 commit。任何一项不过,要么重试,要么升级。

这条哲学值得单独记住:LLM 会幻觉,但 git 不会。 把"是否真的完成"这个判断,从"问 LLM"挪到"看磁盘证据",整个系统就稳了。

机制四:deferred-work 台账 + sweep,终于有人读它了

循环里总会遇到"现在干不了"的活——某个 edge case 要等另一个 story 先落地、某个决策该人来拍板。这些不能硬干,也不能丢,于是写进一份台账:deferred-work.md

有意思的是这份台账的身世。在更早的 BMAD 版本里,这是个有名的半成品——bmad-code-review 会往 deferred-work.md 里写延后项,但没有任何 skill 会回头读它(社区甚至专门提了 issue 报这个 bug)。写进去的债,永远没人还。

BMAD Loop 的 bmad-loop-sweep 终于补上了这一环。它做的事是只读巡检:把台账里每条 open 的项,对着真实代码库逐条验证(grep 症状、查 git log、读相关文件),然后分成五类:

分区 含义 编排器怎么办
already_resolved 后来的工作顺手解决了,但没标记 拿证据(file:line / commit)自动关掉
bundles 现在就能一起干的,按相同文件/子系统打包成一个 dev 会话 执行
blocked 得等某个未来的 story/epic 落地 标记阻塞方,挂着
skip 已过时、无关、或项目明确排除 跳过
decisions 必须人来拍板(改冻结 spec、改 API 形状等) 升级给人

"写进去的债,有人还了"——而且是带着证据还,不是凭台账里的旧状态拍脑袋。这条机制让循环可以长时间无人值守地跑下去而不至于债台高筑。


一张图看清整个循环

把上面四个机制拼起来,一个 story 在 BMAD Loop 里的完整生命周期是这样的:

BMAD Loop 完整生命周期:sprint-status → 编排器五步流水线 → 通过则下一个 story,失败则进 deferred-work 台账 → sweep → 自动关闭 / 打包再干 / 升级给人

整条链路的控制流是 Python,只有②③④这几个"创意工位"是 LLM 在一次性会话里干活。这就是"确定性编排器"的完整含义。


多模型编排:三个 CLI,按角色混搭

BMAD Loop 通过一个通用的 tmux 适配器驱动三种 coding CLI:claude(默认)、codexgemini。而且可以按阶段混搭——配置在项目的 .bmad-loop/policy.toml 里:

[adapter]
name = "claude"          # 默认所有阶段都用 claude

[adapter.review]
name = "codex"           # 但 review 阶段换成 codex

为什么要混搭?因为不同模型擅长的事不一样。一个很实用的组合是:让一个模型写代码、让另一个模型做对抗式 review——两个不同家族的模型互相挑刺,比同一个模型自审要狠得多。这正好和"机制一:review 用全新上下文"叠加,双重消除偏置。

这已经不是"调一个模型"了,是模型编排


什么时候它会停下来叫你:CRITICAL 升级

无人值守不等于无人干预。有一种情况编排器会主动暂停整个 run,等人——CRITICAL 升级

触发条件通常是:dev 或 review 会话发现冻结的 spec(<frozen-after-approval> 块)自相矛盾,或者对某个关键场景保持沉默,没法安全地继续。这时候它不猜、不硬干,而是把 run 挂起,等你用:

bmad-loop resolve --story <story-key>

起一个交互式会话。这个会话里有人(你),所以它会问你问题、给出 2-4 个具体选项和推荐。你拍板之后,它去改 spec 本身——不是改代码——把歧义消掉,然后编排器重新驱动这个 story,对着一份修正过的、没有矛盾的 spec 重跑。

这个设计很克制,有几条硬规矩值得点赞:

  • resolve 会话只改 spec 内容,不写一行功能代码、不跑测试、不提交
  • 不动 sprint-status.yaml,也不设 spec 的 status 字段——这些由编排器在恢复时确定性地产出
  • 如果信息不够、或者正确的修复超出了 spec 编辑的范围(比如需要改 PRD/架构),它会直接说"我解决不了",不写完成标记,run 继续挂着——这是安全的默认行为

一句话:遇到拿不准的,宁可停下来等你,也不编一个答案往下冲。 这是对"无人值守"最负责的理解。


怎么用:上手三步

前置条件就一条:你得有一个 BMAD v6 项目,而且 bmad-sprint-planning 已经跑过、生成了 sprint-status.yaml。换句话说,PRD / 架构 / epics&stories / sprint planning 这条链得先走完,Loop 才有故事可转。

装好之后(通过 bmad-loop-setup 这个 skill,它会从 Git 装 Python 工具 + 跑 bmad-loop init 注册 hook、铺 skill、写 policy.toml),核心命令其实很少:

bmad-loop init        # 装 bmad-loop-* skill + hook + policy.toml + gitignore
bmad-loop validate    # 预检:config / sprint-status / git / tmux / CLI / hook
bmad-loop run --dry-run   # 先打印计划,不真的拉起会话
bmad-loop run         # 开跑
bmad-loop tui         # 或者干脆全在可视化面板里操作

完整命令清单覆盖了 run / sweep / resume / resolve / decisions / status / attach / stop / clean 等,但日常 90% 的场景就是上面这几条。bmad-loop tui 那个仪表盘挺漂亮——run 选择器、sprint 树、deferred-work 台账、每个 story 的实时任务表、带颜色的日志流,一屏打尽。

一个必须知道的一次性设置坑:如果目标项目里 coding CLI 从来没跑过(比如 claude 没在这个目录启动过),你要先手动启动一次,接受 workspace-trust 和 hooks 审批对话框。编排器拉起的子会话没法替你点这些首次运行对话框,而一个挂着的对话框会被编排器误判成"会话超时"。


血统:从 Story Automator 到 BMAD Loop

把 BMAD Loop 放回时间线里,它的位置就很清楚了:

Story Automator          bmad-automator / bmad-auto          BMAD Loop
(2026 初, 我那篇          (中间的过渡形态,                  (6.10, 重写为
 实测的版本)               工具名 bmad-auto)                  确定性 Python 编排器)
      │                        │                                 │
      └──── 控制环里有 LLM ─────┴───── 重写 ────────► 控制环里没有 LLM ──┘
              (慢、贵、易跑偏)                         (确定、可调试、省钱)

README 里写得很坦诚:"Inspired by the original bmad-automator (a separate, legacy project)"——它明确把上一代当成 legacy,自己是从头来的重写。

而它给自己定位是 "a deterministic ralph-loop orchestrator"。如果你关注过 autonomous dev 这个圈子,应该听说过 Ralph——那个让 Claude Code 自己跑开发循环的工具。BMAD Loop 借用了"ralph-loop"这个模式(无人值守、反复迭代的小循环),但把它确定性地实现在了 BMAD 的 story 体系上。所以它是"Ralph 的精神 + BMAD 的骨架 + Python 的中枢"。


适合谁,不适合谁

延续我测评 Story Automator 时的坦诚基调,给你一个不吹的判断。

适合用的场景:

  • 你已经完整走完 BMAD 的规划链(PRD → 架构 → epics → sprint planning),手头有一串清晰、可独立实现的 story
  • 你接受"睡前梭一把"这种异步交付模式——第二天起来看结果,而不是盯着它实时干
  • 你的项目有可靠的测试和 lint(机制三最后那道闸靠它们),否则 verify 形同虚设
  • 你想做多模型互相 review,又不想自己手动切来切去

不适合 / 要谨慎的场景:

  • story 还很模糊、依赖关系没理清——这种跑进循环里大概率触发一堆 CRITICAL 升级,反而更累
  • 没有测试的项目——编排器再聪明,最后那道 verify 闸门空转,等于裸奔
  • 期待它"又快又好又自动"——确定性编排让它更稳、更省,但单 story 的绝对速度未必比一个熟手手工盯更快。它的价值在批量、异步、可恢复,不在单点提速

和上一代最大的区别,也是我现在最看好它的一点:因为控制环是确定性代码,它可调试、可复现、可信任。Story Automator 时代那个"为什么这么慢"的黑盒,这一次终于打开了——流程是 Python,你看得到每一步在干嘛、为什么这么决策。光这一点,就值得把它从"试验品"升级成"可以认真用起来的工具"。


写在最后

从 v6.8 的"锁定意图"(让 AI 先搞懂你要什么),到 6.10 的 BMAD Loop(让确定性的代码当调度员、LLM 只管写代码),BMAD 这两年的演进方向其实非常一致:

把不该让 LLM 干的活,一件一件从 LLM 手里拿回来。

意图理解该锁定的,用 SPEC 锁定;调度该确定的,用 Python 确定;该人拍板的,挂起 run 等人。LLM 越来越被收敛到它真正擅长的那块创意工作上。

这不是对 LLM 不信任,恰恰是对它的尊重——别让它干它不擅长、又会幻觉、还按字收费的活。

如果你也在用 BMAD 做项目,强烈建议拿一个 sprint 来认真试一次 BMAD Loop。哪怕只是为了让 deferred-work.md 那本"永远没人还的债账"终于有人管,也值。


参考来源:

 
当 AI 开始建造自己:Anthropic 递归自我改进的深度解读

原文:When AI builds itself — Anthropic Institute
作者:Marina Favaro, Jack Clark | 发布于 2026 年 6 月


一句话总结

Anthropic 用自己内部的硬数据证明了:AI 正在加速 AI 的开发,而且加速度本身也在加快。从外部基准测试到内部工程效率,所有曲线都在上扬。递归自我改进——AI 完全自主地设计和开发自己的继任者——还没有实现,但可能来得比大多数机构准备好的时间更早。


进化的五个阶段

Anthropic 把这个过程分成了五个阶段:

阶段 时间 人类在做什么 AI 在做什么
人工驱动 2021–2023 写代码、写文档 不存在
聊天助手 2023–2025 主导一切工作 生成代码片段,人类复制粘贴
编程 Agent 2025–2026 审查和引导 独立写文件、编辑代码
自主 Agent 今天 设定目标 自己跑代码,给其他 Agent 派活
闭环 20XX? 监督与验证 自己训练和构建模型

这个阶段的划分不是理论推演,而是 Anthropic 内部真实发生的事情。注意最后那个 20XX?——连 Anthropic 自己都不确定时间点,但方向是明确的。


外部证据:基准测试的加速饱和

如果你只看公开数据,趋势同样惊人。

AI 能完成的任务时长每 4 个月翻一倍(之前是每 7 个月)。这是什么概念?

  • 2024 年 3 月:Claude Opus 3 能完成人类约 4 分钟 的任务
  • 2025 年 3 月:Claude Sonnet 3.7 搞定 1.5 小时 的任务
  • 2026 年 3 月:Claude Opus 4.6 搞定 12 小时 的任务
  • 如果趋势持续:2026 年内可能覆盖数天级别的任务,2027 年可能覆盖数周级别的任务

几个重要基准测试的状态:

  • SWE-bench(真实世界的软件工程测试):两年内从个位数跑到饱和。模型拿到真实的开源代码库和真实的 bug 报告,自己写修复代码并通过项目测试
  • CORE-Bench(复现已有研究):从 2024 年约 20% 的成功率,15 个月后饱和
  • METR 长任务基准:Claude Mythos Preview 能连续工作至少 16 小时,已经触及 METR 能测量的上限

来自 Anthropic 内部的数据

公开基准测试能告诉你模型有多强,但看不到 AI 对 AI 开发本身的加速效应。Anthropic 这次公开了内部数据,这是这篇文章最有价值的部分。

80% 的代码由 Claude 编写

截至 2026 年 5 月,Anthropic 合并到代码库的代码中超过 80% 由 Claude 编写。Claude Code 在 2025 年 2 月发布之前,这个数字只有低个位数。

每人每季度代码贡献量,从 2021 年 Q2 到 2026 年 Q2,标注了从 Claude 1 到 Mythos Preview 的发布节点

这张图有两个拐点:

  1. 2025 年初:Claude 开始自己运行代码(而不是让人类复制粘贴),代码量开始上升
  2. 2026 年:模型开始自主工作更长时间,曲线陡然加速

2026 年 Q2,典型工程师每天合并的代码量是 2024 年的 8 倍。注意,代码行数是不完美的度量——它度量的是数量而非质量。但方向是明确的。

一个更直观的数字:2026 年 3 月,130 名 Anthropic 研究人员的调查显示,中位数受访者估计使用 Mythos Preview 后产出约为不使用 AI 时的 4 倍

代码质量已接近人类水平

代码质量有两个维度:能用可维护

在"能用"这个维度上,证据已经非常清楚。Anthropic 员工纠正、重定向或接管 Claude 的频率持续下降——包括最复杂、最开放的任务。

Claude Code 在四种不同难度任务上的会话成功率,比较了从 Claude Sonnet 4.5 到 Claude Opus 4.7 六个模型的表现

在开放性任务上,Claude 的成功率在 2026 年 5 月达到 76%,六个月内提升了 50 个百分点。

一个具体的例子:一次常规升级导致数万个训练任务崩溃。工程师把现场信息丢给 Claude,Claude 在大约两小时内隔离了一个冷门的调试 flag,可靠地复现了问题并确认了修复。这通常是两到三天的工作量。

在"可维护"这个维度上,差距在快速收窄。Anthropic 内部普遍认为:Claude 写的代码在 2025 年底还不如人类,目前已经基本持平,预计年内将超过人类水平

一个有趣的发现:Anthropic 用 Claude 自动审查代码变更,回溯分析发现,如果一直用 Claude 审查,它能在大约三分之一的 bug 进入生产环境之前就发现它们。而写出那些代码的工程师,是世界上构建这类系统最顶尖的一批人。

实验优化:从超有用到超人类

Anthropic 每次发模型都跑一个固定测试:给 Claude 一段训练小型 AI 模型的代码,让它尽可能加速同时保持正确性。

  • 2025 年 5 月,Claude Opus 4:约 3x 加速
  • 2026 年 4 月,Claude Mythos Preview:约 52x 加速
  • 对比:一个熟练的人类研究员需要 4–8 小时才能达到 4x

明确目标下的实验执行这个环节,Claude 在不到一年内从"超有用"变成了"超人类"。

研究判断力:最后的差距

但实验执行和实验设计是两回事。Anthropic 做了一个实验来衡量这个差距:

他们找了 129 个真实的研究会话,这些会话都有一个共同特点——研究员在某个时刻走了一个弯路。他们把这个弯路之前的内容截断,问各个 Claude 模型"你下一步会怎么做",然后用一个能看到完整会话结果的 Claude 来判断:AI 和人类谁的选择更好?

九个模型在「能否比人类选出更好的下一步」上的表现

结果:

  • 2025 年 11 月,Claude Opus 4.5:51% 的情况下比人类选择更好
  • 2026 年 4 月,Claude Mythos Preview:64% 的情况下比人类选择更好

注意,这组数据本身就偏向 AI——因为他们刻意挑选了人类判断有改进空间的时刻。但作为一个衡量 AI 研究判断力随时间提升的指标,方向是清晰的。

这就是 AI 今天和"能自主设计自己继任者"之间的差距:方向设定——选择什么问题值得研究、什么结果值得信任、什么时候该放弃一条路。


三种未来场景

Anthropic 提出了三种可能的未来:

场景一:趋势停滞,但当前能力广泛扩散

指数曲线可能实际上是 S 曲线,我们可能正在接近拐点。"研究品味"可能是一种无法通过扩大训练来获得的能力。或者瓶颈可能在供应链——芯片产能、电网扩张、互联带宽。

即使模型能力冻结在今天的水平,变革仍然巨大。Project Glasswing 项目中,Mythos Preview 在最初几周就发现了全球最重要系统中超过一万个高危软件漏洞。一个 100 人的公司将能完成过去 1000 人的工作。

Anthropic 认为这个场景可能性最低——因为他们观察到每一个可衡量的能力指标都在同一条上升曲线上,还没有看到曲线变平的迹象。

场景二:AI 实验室持续获得复合效率增益

AI 开发被大幅自动化,但人类继续设定研究方向。100 人的公司能做 10,000 甚至 100,000 人组织的工作。

但这里有 Amdahl 定律的影子:加速一部分流程只会把瓶颈推到其他地方。Anthropic 已经遇到了这个问题——随着代码量暴增,人类的代码审查成了新的瓶颈。同样,新想法、新工具、新模拟的爆炸式增长远远超出了他们能追求的范围。

识别和修复瓶颈的能力,可能成为任何组织最重要的能力。

场景三:AI 系统实现完全的递归自我改进

AI 开始设计和精炼自身。进步的速度完全由算力可用性决定。人类角色大幅缩减,主要转向监督、验证和确认一个不断扩展的"虚拟实验室"。

这个场景最不确定的部分是对齐问题

  • 模型可能足够对齐且有足够的研究品味,发现并实施人类尚未达到的新方案
  • 也可能够聪明到在不确定时主动暂停开发
  • 但也可能——今天模型中罕见的不对齐行为在构建继任者时复合增长,变得越来越频繁却越来越不被理解,直到人类失去控制

Anthropic 的立场:我们需要暂停的选项

文章最后提出了一个明确的政策立场:

如果有可能有效地减缓这项技术的发展,给我们更多时间来处理其巨大影响,我们认为这可能是好事。但如果减速只是让最不谨慎的参与者在技术上赶上来,可能会让每个人都更不安全。

Anthropic 明确表示:如果其他前沿开发者也能以可验证的方式减速或暂停,他们愿意这样做。

但实现可信的暂停极其困难:

  • 训练运行比导弹发射井更容易隐藏
  • 训练的输入是通用资源(算力、电力)
  • 悄悄违约的激励巨大——谁在别人暂停时继续,谁就能继承领先地位
  • 暂停还需要定义触发条件、解除条件和裁决机制

Anthropic 承诺在未来几个月组织政策制定者、研究人员、公民社会和其他 AI 公司的对话,推动这些问题——特别是围绕完全递归自我改进和如何创建更好的协调选项。


我的思考

几点个人观察:

1. "8 倍代码量"是一个被低估的数字。 因为这不只是"写了更多代码"——它改变了工程师的角色定义。工程师从"写代码的人"变成了"审查和引导 AI 的人"。当审查速度跟不上生成速度时(Amdahl 定律),整个流程会再次重组。

2. 研究判断力的进步是最值得关注的指标。 代码编写和实验执行已经接近或超过人类水平,但"决定研究什么"这个最后的人类堡垒正在缩小——从 51% 到 64% 的胜率提升只用了五个月。如果这个趋势持续,"研究品味"可能也只是另一种 AI 能力——AI 会失败一段时间,然后突然变好。

3. 三种场景的分布比结论更重要。 Anthropic 明确说他们认为场景一最不可能。但他们没有押注场景二还是场景三——这本身就是一种信号。如果他们确信递归自我改进不会发生,他们会说"我们距离场景三还很远"。他们没有这么说。

4. 暂停的悖论。 Anthropic 愿意暂停的前提是"其他人也暂停"。但在一个没有全球协调机制的世界里,这几乎等同于"我们不暂停"。这不是批评——这是一个真实的囚徒困境。文章在这一点上非常诚实。

5. 最被低估的风险:不是 AI 变得强大,而是人类的协作基础设施被侵蚀。 文章引用了一位 Anthropic 员工的话让我印象深刻:

工作和生活曾经运行在人与人之间的小恩小惠的礼物经济上。"你能帮我跑一下这个脚本吗?"……每一个请求都创造了一点人情债、一点相互认知。Claude 更快,不产生人情债,但每一个这样的请求都是一次人类协作机会的丧失。

当 AI 让每个请求都能被即时满足时,人与人之间的协作纽带也在被悄无声息地削弱。这不是技术问题,而是社会结构问题。


原文核心数据速查

指标 数值
Claude 编写的代码占比 > 80%(2026 年 5 月)
工程师代码产出提升 8x(对比 2024 年)
研究员自评产出提升 ~4x(使用 Mythos Preview)
开放性任务成功率 76%(2026 年 5 月,六个月提升 50 个百分点)
实验优化加速 从 3x(Opus 4)到 52x(Mythos Preview)
研究判断力超越人类 64% 的时刻模型建议优于人类(Mythos Preview)
任务时长翻倍周期 ~4 个月(从 ~7 个月加速)
Claude 一次性修复量 800+ 修复将某类 API 错误降低 1000 倍

本文基于 Anthropic Institute 2026 年 6 月发布的 When AI builds itself 撰写,包含个人解读和分析。

 
我把博客开源了:一个把终端搬进浏览器的技术博客

仓库地址:github.com/terryso/hack-buffer
线上地址:blog.suchuanyi.dev


先说结论:这不是一个「深色主题」博客

很多人做「终端风」,就是在白色博客上换成深色背景加个等宽字体,完了。这个博客不是这样做的。

打开 blog.suchuanyi.dev,你看到的不是一个换了皮的 WordPress。你会看到一个在浏览器里运行的终端 IDE。每一个 UI 元素都有对应的终端隐喻,不是装饰,是交互逻辑本身。


顶部状态栏:你的 tmux pane

导航栏模仿的是 tmux 的 pane 标题行。左边是站点名 terry.so 前面带一个绿色圆点 ,然后是当前路径:

● terry.so  ~/posts/open-source-terminal-blog  main*

~/ 后面跟着你当前所在的路径段,最后一截高亮显示——就像你在 tmux 里看到的 pane 标题一样。末尾的 main* 表示当前分支有未提交的改动(当然是假的,但感觉对了)。

右边是状态信息:GitHub Fork 链接、⌘K 命令面板入口,还有一个绿色脉冲圆点配 CONNECTED 字样——你的终端连上了远程服务器那种感觉。

整个导航栏是 sticky 的,磨砂玻璃效果(backdrop-blur),往下滚也不会消失。


底部状态栏:Vim 的 mode line

页面最底部固定了一行状态栏,完全模仿 Vim 的底部 mode 行:

[NORMAL]  index.md          g home  t tags  a about  ⌘K palette  UTF-8  14:32
  • 左边是 NORMAL 模式标签(绿色高亮),像 Vim 的 -- INSERT --
  • 旁边是当前文件名,比如 index.mdposts/open-source-terminal-blog.md
  • 右边是快捷键提示和系统信息:编码(UTF-8)、当前时间(实时更新)

这不是静态装饰。时间每秒刷新,文件名跟随路由切换,NORMAL 标签一直告诉你「你不在输入模式」。


Vim 键位:全程不用鼠标

这是我最喜欢的部分。整个站点的导航可以用 Vim 键位操作:

按键 动作
g 回首页(连续按两次 gg 跳到第一页)
t 标签页
a 关于页
⌘K 命令面板
h / / [ 上一页
l / / ] 下一页
G(大写) 跳到最后一页
/ 聚焦搜索框(Vim 搜索的肌肉记忆)
ESC 关闭命令面板

在首页翻页的时候,hl 的体验和 Vim 里左右移动光标一模一样。gg 跳回第一页,G 跳到最后一页——完全复刻 Vim 的行首行尾。

搜索框按 / 聚焦,这是 Vim 里搜索的键位。搜索结果出来之后可以 ESC 关掉。整套键盘流可以完全不用鼠标浏览整个博客。


命令面板:终端里的模糊搜索

⌘K 打开命令面板。外观是一个 $ 开头的终端输入框,底下列出可用命令:

$ type a command...
  :home
  :tags
  :about

输入几个字母自动过滤,Enter 执行第一个匹配项,ESC 关闭。和 VS Code 的命令面板一样好用,但长得像你的 shell。


首页:ls 你的文章列表

首页不是传统博客那种大图卡片布局。它更像是在终端里 ls -la 你的文章目录:

$ ls -la ~/articles | sed -n '1,10p'

上面这行是真的渲染在页面上的,作为 banner 的一部分。每个文章条目是一个网格行:

01  文章标题                                UPDATED: 2026-05-30
    文章描述文字...                          SIZE: 12KB
    #tag1  #tag2  #tag3                     READ: 8MIN

左边是序号(两位数,零填充),中间是标题 + 描述 + 标签,右边是文件元信息——就像 ls -la 的输出列。标签用 # 前缀,加了细边框,像终端里的 badge。

右上角显示当前页码和总数:PAGE 01/04 · TOTAL 37,用大写字母和零填充——信息密度拉满,但不会觉得乱。


文章页:YAML frontmatter 直接渲染

打开一篇文章,正文上方不是传统的「作者 + 日期」元信息块。你看到的是一段被渲染的 YAML frontmatter

---
title:   "文章标题"
date:    2026-06-07
category:[开源, 博客]
tags:    [开源, TanStack Start, pgvector]
status:  published
---

绿色分隔线、等宽字体、键值对网格布局——就像你在终端里 cat 一个 Markdown 文件,frontmatter 原样输出。status: published 用绿色高亮,暗示这篇文章已经 merge 了。

这个设计不是偶然的。写博客的人天天和 frontmatter 打交道,把它直接展示出来,读者一眼就知道「这是一篇 Markdown 文件」,而不是一个 WordPress 页面。


搜索框:长得像 grep

首页的 AI 搜索框不是一个普通的输入框。它长这样:

$ grep -r  问点啥...例如 swift agent 集成  /

左边是绿色的 $ 提示符,紧跟着 grep -r,然后才是输入区域。右边有个 kbd 标签提示按 / 可以聚焦——还是 Vim 的搜索键。

搜索中的状态是 embedding query...,搜索结果标题行显示匹配数:// 3 matches,每条结果前面有相似度百分比。搜索失败的时候是 err: ...

整个搜索体验就像你在终端里跑了一个命令,然后看着输出一行一行出来。


配色系统:oklch + 语义 token

终端风的灵魂不只是等宽字体,还有配色。

整个博客的颜色系统用 oklch 色彩空间定义,只有六个 token:

Token 用途
background oklch(0.16 0.01 260) 深蓝黑底
foreground oklch(0.96 0.005 260) 接近白色的前景文字
surface oklch(0.21 0.012 260) 卡片/面板背景
border oklch(0.30 0.012 260) 微妙的分隔线
muted oklch(0.62 0.01 260) 次要信息
accent oklch(0.78 0.18 145) 终端绿——所有可交互元素的颜色

accent 是那个标志性的终端绿,用在链接、提示符、YAML 分隔线、闪烁光标、状态指示灯、快捷键高亮……所有需要「跳出来」的地方。统一、克制、不花哨。

组件层不写任何裸色值。所有颜色都走这六个 token。想换一套配色?改 styles.css 里六行代码,全站跟着变。

选中文字的高亮也是绿色的——::selection 用了 color-mix 把 accent 和透明度混合,选中效果像终端里高亮了一行输出。


闪烁光标:一直在线

页面标题末尾有一个闪烁的下划线 _,用 CSS step-end 动画实现,一秒闪烁一次——和终端里光标的节拍一模一样。

这个光标不是装饰。它在告诉读者「这个页面是活的,你可以输入」。首页标题「Agent 内核深潜」后面跟着 cursor-blink,搜索框打开的时候也是这种节奏。整个站点的交互节奏是统一的。


404 页面:cat: post not found

文章找不到的时候,你看到的不是一个大大的 404 插画。你看到的是:

$ cat: post not found
cd ~/

cd ~/ 是一个可点击的链接,带你回首页。就像你在终端里 cat 了一个不存在的文件,然后 cd 回到 home 目录。


文章内容的排版细节

正文用 sans-serif 字体(Inter),行高 1.75,但标题全部回到等宽字体。这是刻意的设计——结构信息用 mono,阅读内容用 sans。代码块背景比页面底色更深一层(oklch(0.13)),有细边框和圆角,代码高亮用 github-dark 主题。

引用块的左边是绿色竖线,背景有 6% 的绿色透明叠加。分隔线是虚线(dashed),不是实线——像终端里的注释行。

列表的 marker(disc / decimal)全部用 accent 绿色。链接有下划线但透明度 40%,hover 的时候变成实色——微妙但有反馈。

表格强制等宽字体,字号缩小到 0.875rem,表头有 surface 背景。整个表格看起来像终端里的 pstop 输出。


顺便说一下 AI 功能

说了这么多终端风,AI 功能其实是锦上添花。但既然做了,也挺好用:

  1. TL;DR —— 每篇文章自动生成三句话中文摘要(Gemini Flash)
  2. 语义相关推荐 —— 文章底部自动推荐 3 篇最相关的旧文(pgvector 余弦相似度)
  3. 自然语言搜索 —— 首页 grep 框输入自然语言,按语义返回结果

三个功能全部通过 Lovable AI Gateway 调用,项目里没有任何 API Key。同步管线用内容哈希做增量闸门,没变过的文章不重算、不花钱。一行脚本触发:./scripts/sync-posts.sh prod

不想用 AI?VITE_ENABLE_AI=false 一行关掉,退化成纯静态博客。


技术栈一览

选型
框架 TanStack Start(React 19、SSR、文件路由)
构建 Vite 7
样式 Tailwind v4 + shadcn/ui,oklch 色彩 token
后端 Supabase(Postgres + pgvector + RLS)
AI Lovable AI Gateway(Gemini embedding + Flash 摘要)
部署 Cloudflare Workers
内容 Markdown,gray-matter 解析

首屏 < 100KB,SSR 输出,每个路由都有 canonical / OG / JSON-LD。


Fork 指南

如果你想基于这个博客做自己的:

  1. Fork 仓库github.com/terryso/hack-buffer/fork
  2. 在 Lovable 导入 → 自动拿到 Supabase 项目和 AI Gateway
  3. 替换 content/posts/ → 放你自己的 Markdown(frontmatter:title / date / description / tags)
  4. 改品牌__root.tsx(站点信息)、about.tsx(自我介绍)、SiteShell.tsx(站名和导航)、styles.css(配色 token)
  5. 改同步脚本scripts/sync-posts.sh 换成你的域名
  6. 部署 + 同步 → Publish 之后跑 ./scripts/sync-posts.sh prod

终端风的 UI 和 Vim 键位不需要任何后端依赖。即使你完全不用 AI 功能,这套终端交互体验也是开箱即用的。


最后

这个博客最大的亮点不是 AI,不是 RAG,不是增量同步。是你打开它的那一刻,感觉像在终端里读文章。顶部路径栏、底部模式行、Vim 键位、grep 搜索框、YAML frontmatter 渲染、闪烁光标——整套 UI 都在说同一件事:这里属于程序员。

AI 是工具,终端是审美,开源是态度。

仓库在这里:github.com/terryso/hack-buffer

有问题开 Issue,或者直接在博客上按 / 搜——毕竟它自己就能搜。

 
BMad v6.8:AI开发正式进入"锁定意图"时代
ChatGPT Image 2026年5月26日 10_06_17

/bmad-spec 提炼意图合约,
/bmad-ux 拆分视觉与行为脊柱,
/bmad-investigate 用工程化取证方式解决复杂问题。

完整更新日志: https://www.bmadcode.com/bmad-update-may-2026-web-bundles-prd-brief-platforms/

 
一张图看懂 Hermes 的自我进化机制
hermes-agent

想深入了解, 可以阅读下面关于Hermes自进化的系列文章: 从 Memory、Skill 到 Background Review,完整拆解 Hermes 的自进化架构。

https://blog.suchuanyi.dev/posts/hermes-self-evolution-1-overview

 
BMAD Story Automator 上手实录:把 5 个待办 Story 交给 AI 自主推进

如果你已经习惯通过 BAMD 写代码,接下来真正耗时间的,往往不是“写”,而是“协调”。

一个 Epic 里有 5 个、10 个、20 个 Story。每个 Story 都要经历创建规格、开发实现、自动化测试、代码审查、回顾总结。真正让人疲惫的,不是某一步本身,而是你要不断盯着流程、切换会话、处理失败、决定下一步。

Story Automator 想解决的,就是这层“人肉编排”。

昨晚我实际跑了一遍 /bmad-story-automator 的完整流程。下面就是这次使用过程的记录,以及一些当时截下来的图。


它解决的不是“写代码”,而是“盯流程”

先用一句话概括 Story Automator:

你告诉它要处理哪些 Story、用什么执行策略,它就自动完成「创建规格 → 开发实现 → 测试自动化 → 代码审查 → 回顾」这条流水线,只在真的需要人类决策时才打断你。

这和普通“单命令跑一个 Skill”不一样。它更像一个构建周期编排器

初始化
  → 读取 Epic / Sprint 状态
  → 选择 Story 范围
  → 评估复杂度
  → 选择 Agent 策略
  → 执行 create / dev / automate / review / retro
  → 在失败或冲突时升级给人类

从设计上说,它是在自动化“协调工作”,而不是直接替代某一个具体开发步骤。


升级BMAD到最新版(v6.6)

如果你想体验 Story Automator,需要先把 BMAD 升到 6.6。安装时记得把 BMad Automator (Experimental) 这个模块勾上。

BMAD 6.6 安装时新增的 BMad Automator 模块

不然装完 BMAD,后面是用不起来的。


第一次运行:先补齐 Stop Hook,防止工作流半路被打断

第一次执行 /bmad-story-automator 时,它不会急着开跑,而是先做初始化检查。

从下面这张图可以看到,它先加载配置,然后尝试读取当前编排状态;如果发现状态目录还不存在,这是正常的首次运行场景。接着它会自动安装 Stop Hook.claude/settings.json 中。

Story Automator 首次运行时自动安装 Stop Hook

第一步:先选 Story,不是盲跑所有待办项

真正进入编排前,Story Automator 会先读取 Epic 和 sprint 状态,然后让你决定处理范围。

下面这张图展示了一个很典型的场景:Epic 5、6、7 中一部分 Story 已完成,一部分仍然待办。工具会把这些状态直接展示出来,然后询问你要处理哪些 Story。

Story Automator 根据 sprint 状态列出可处理的待办 Story

第二步:复杂度不是拍脑袋,而是先打分再分配 Agent

这是我觉得 Story Automator 最有意思的部分之一。

它不是把所有 Story 一股脑丢给同一个 Agent,而是先生成一个Story 复杂度矩阵。截图里可以看到:

  • Story 5.3 “Server 命令与 API 认证” → 4 分 / Medium
  • Story 6.1 “通过 SDK AgentMCPServer 暴露 Axion” → 2 分 / Low
  • Story 6.2 “axion mcp 命令与外部 Agent 集成验证” → 2 分 / Low
  • Story 7.1 “基于 SDK Pause Protocol 的用户接管机制” → 2 分 / Low
  • Story 7.2 “--fast 模式” → 5 分 / Medium
Story Automator 自动生成 Story 复杂度矩阵

更关键的是,它不只给分,还给出原因

  • authorization / permissions
  • 实时通信
  • 验收标准数量高
  • Story 文本较大

这意味着复杂度评估不是黑盒。你看到的不只是“结论”,而是“为什么它觉得这个 Story 更难”。这会直接影响后续的 Agent 选择策略。

换句话说,Story Automator 在做的事其实是:

先把 Story 变成“可调度对象”,再决定谁来执行。


第三步:你可以塞入自定义指令,但默认不强迫你多想

接下来它会问你有没有自定义指令

Story Automator 询问是否添加自定义指令

比如:

  • 每次修改后都运行测试
  • 优先处理某个 Story
  • 注意数据库迁移

这个设计我很喜欢,因为它在“全自动”和“可控”之间找到了一个不错的平衡:

  • 如果你没有特殊要求,直接选 none
  • 如果你有本轮迭代的偏好,可以临时注入

也就是说,它把“人类经验”当成一种可选输入,而不是每次都强迫你从头配置一大堆参数。


第四步:执行设置决定它跑得多激进

再往下,就是执行策略层面的配置。截图中可以看到两个核心问题:

  1. 是否跳过 automate 步骤(测试自动化)
  2. 最大并行会话数是多少
Story Automator 的执行设置界面

默认值是:

  • 不跳过 automate
  • 最多 1 个并行会话

对大多数真实项目来说,测试自动化是交付闭环里最不该轻易跳过的一环;而并行度默认设为 1,也避免了多个会话同时改动同一代码库时互相干扰。也就是说:

默认先追求“可控完成”,而不是“并行冲刺到极限”。


第五步:不同复杂度,自动映射到不同 Agent

有了复杂度矩阵之后,Story Automator 就能推荐 Agent 配置。

推荐配置如下:

  • Low:create / dev / auto / review 都用 Claude
  • Medium:create / dev / auto / review 都用 Codex,Claude 作为备选
  • High:同样以 Codex 为主
  • Retro:回顾阶段使用 Claude
Story Automator 基于复杂度推荐 Agent 组合

从这个配置可以看出,它已经不是“调用一个模型”的层面了,而是在做模型编排

而且它还提供了策略选项:

  • Suggested:采用按复杂度分层的推荐配置
  • Uniform:所有 Story 都使用同一个 Agent

不同团队可以这样用:

  • 想稳一点,按推荐走
  • 想保持行为一致,就统一 Agent

而从后面的配置摘要截图也能看出来,这次实际演示最后保存成了 all-claude。这恰好说明:推荐是推荐,不是强制。 你既可以让系统按复杂度智能分配,也可以为了稳定性或一致性,手动统一到同一类 Agent。

这一步让整个系统更像一个“调度器”,而不是一个简单的命令包装器。


第六步:配置会被显式保存,方便恢复和复盘

当你确认后,Story Automator 会把这次运行配置保存下来。截图里展示的是一个名为 all-claude 的配置摘要:

Story Automator 保存运行配置并生成摘要

摘要里写了几件事:

  • Epic 是哪个
  • Story 范围是什么
  • 自定义指令有没有
  • create / dev / auto / review / retro 分别用什么 Agent
  • 是否跳过自动化

这类“摘要页”看起来很普通,但它是编排器可恢复、可审计、可复盘的基础。

因为自动化一旦跨越多个 Story、多次会话、多个阶段,就一定会面对这些问题:

  • 中途停了怎么办?
  • 我这次到底选了什么配置?
  • 为什么这批 Story 用的是这个 Agent 组合?

有了显式保存的配置,后续无论是恢复执行还是事后分析,都不会变成猜谜游戏。


实际体验结论

昨晚睡觉前,我直接把这 5 个 Story 交给 Story Automator 去跑。早上起来看结果,它总共跑了 5 个半小时

坦白说,速度是比我自己手工盯着跑要慢的。按我平时的节奏,这 5 个 Story 如果自己来,估计 3 个小时内能收完。至于为什么会慢这么多, 具体原因还不太清楚, 还没有仔细的去分析它的实现原理,不过目前还只是试验版,能跑通比较重要。

目前比较适合:睡觉前梭一把。

另外一个我觉得做得不错的点,是每个 Epic 跑完之后,它会顺手做一次复盘,把有用的信息补到 project-context.md 里。

Epic 完成后会把复盘结果补充到 project-context.md

所以我现在对它的看法很简单:

白天手工跑,目前还是自己手工跑会更快。
但睡前把一批 Story 交给它过夜跑,这个场景它真的挺合适。

如果你正在使用 BMAD 来开发项目,你一定要试一下 Story Automator,它可能是你将重复协调时间从小时级降到分钟级的工具。

 
深入 SwiftWork(第 4 篇):数据层与服务——SwiftData、状态恢复与 Markdown 渲染

本文是「深入 SwiftWork」系列第 4 篇(完结篇)。系列目录见这里

前三篇讲了事件怎么从 SDK 流到 UI、时间线怎么渲染、工具卡片怎么可视化。这篇收尾,看 SwiftWork 的基础设施——数据怎么存、状态怎么恢复、Markdown 怎么渲染、代码怎么高亮、API Key 怎么管。

这些组件各自独立,但都是"让应用可用"的必要部分。

SwiftData 模型层

SwiftWork 用 SwiftData 做持久化,注册了四个模型:

// SwiftWorkApp.swift
.modelContainer(for: [
    Session.self,
    Event.self,
    AppConfiguration.self,
    PermissionRule.self
])

Session

@Model
final class Session {
    @Attribute(.unique) var id: UUID
    var title: String
    var createdAt: Date
    var updatedAt: Date
    var workspacePath: String?
    @Relationship(deleteRule: .cascade, inverse: \Event.session)
    var events: [Event]
}

@Relationship(deleteRule: .cascade) 意味着删除 Session 时自动删除它下面所有 Event。workspacePath 是可选的——用户可以给每个会话指定不同的工作目录。

Event

@Model
final class Event {
    @Attribute(.unique) var id: UUID
    var sessionID: UUID
    var eventType: String
    var rawData: Data        // JSON 序列化的 AgentEvent
    var timestamp: Date
    var order: Int
    var session: Session?
}

第 1 篇讲过这个设计——rawData 是整个 AgentEvent 序列化后的 JSON blob。不拆成独立字段的原因是 metadata 的结构因事件类型而异,拆字段会导致大量空列和 Schema 频繁变更。

AppConfiguration

@Model
final class AppConfiguration {
    @Attribute(.unique) var id: UUID
    var key: String
    var value: Data
    var updatedAt: Date
}

通用的 key-value 存储。用 SwiftData 实现而不是 UserDefaults,因为 SwiftData 支持 async 访问、数据迁移和 iCloud 同步(将来可能用到)。存的值包括:

  • hasCompletedOnboarding — 是否完成首次引导
  • selectedModel — 用户选择的模型
  • lastActiveSessionID — 上次活跃的会话 ID
  • windowFrame — 窗口位置和大小
  • inspectorVisible — Inspector 面板是否可见

AppStateManager:应用状态恢复

AppStateManager 负责在 App 重启后恢复用户的工作状态——上次打开的会话、窗口位置、Inspector 面板的开关。

@MainActor
@Observable
final class AppStateManager {
    var lastActiveSessionID: UUID?
    var windowFrame: NSRect?
    var isInspectorVisible: Bool = false

    func loadAppState() {
        lastActiveSessionID = loadUUID(key: "lastActiveSessionID")
        windowFrame = loadNSRect(key: "windowFrame")
        isInspectorVisible = loadBool(key: "inspectorVisible")
    }

    func saveLastActiveSessionID(_ id: UUID?) { ... }
    func saveWindowFrame(_ frame: NSRect) { ... }
    func saveInspectorVisibility(_ visible: Bool) { ... }
}

底层用 AppConfiguration 的 key-value 存取:

private func saveString(_ string: String, forKey key: String) {
    let descriptor = FetchDescriptor<AppConfiguration>(
        predicate: #Predicate { $0.key == key }
    )
    if let existing = try? modelContext.fetch(descriptor).first {
        existing.value = Data(string.utf8)
    } else {
        let config = AppConfiguration(key: key, value: Data(string.utf8))
        modelContext.insert(config)
    }
    try? modelContext.save()
}

upsert 逻辑——先查有没有,有就更新,没有就插入。loadNSRect 把字符串转回 NSRect(用 NSRectFromString),loadBool 比较字符串 "true"。

保存时机

状态保存不是在 App 退出时一次性完成的,而是在各个触发点分散保存:

状态 保存时机
lastActiveSessionID 用户切换会话时(SessionViewModel.selectSession
windowFrame 窗口移动/缩放时(500ms 节流)+ App 退出时
inspectorVisible Inspector 面板切换时

窗口位置的保存做了节流——didMoveNotificationdidResizeNotification 触发频率很高,每次都写 SwiftData 不值得。用一个 500ms 的 Task.sleep 做防抖,只有最后一次移动/缩放才会真正保存:

// ContentView.swift
let saveWindowFrameThrottled: (Notification) -> Void = { _ in
    saveTask?.cancel()
    saveTask = Task { @MainActor in
        try? await Task.sleep(for: .milliseconds(500))
        guard !Task.isCancelled else { return }
        if let window = mainWindow {
            appStateManager.saveWindowFrame(window.frame)
        }
    }
}

恢复流程

App 启动时,ContentView.task 触发恢复:

.task {
    settingsViewModel.configure(modelContext: modelContext)
    hasCompletedOnboarding = settingsViewModel.isAPIKeyConfigured
        && !settingsViewModel.isFirstLaunch

    if hasCompletedOnboarding == true {
        configureAndRestoreState()
    }
}

configureAndRestoreState 按顺序恢复:

  1. 初始化 AppStateManager,加载保存的状态
  2. 初始化 SessionViewModel,获取会话列表
  3. 根据 lastActiveSessionID 选中对应会话
  4. 恢复 isInspectorVisible
  5. 恢复窗口位置(如果 window 引用已经到达)

窗口位置的恢复有一个时序问题——WindowAccessor 的回调是异步的,window 引用可能在 task 之后才到达。所以 onChange(of: mainWindow) 里也做了恢复:

.onChange(of: mainWindow) { _, newWindow in
    if let newWindow {
        restoreWindowFrame(in: newWindow)
    }
}

MarkdownRenderer:Visitor 模式渲染 Markdown

Agent 的回复是 Markdown 格式的——标题、列表、代码块、粗体、链接。SwiftWork 用 Apple 的 swift-markdown 库解析 Markdown,然后用 Visitor 模式遍历 AST,生成 SwiftUI 视图。

为什么不用现成的 Markdown 渲染组件

macOS 上的 Markdown 渲染组件不多。AttributedString(markdown:) 只支持基础格式(粗体、链接),不支持代码块、表格、引用块。WebView 方案(用 Markdown.js 渲染到 HTML)引入了 WebKit 的依赖和内存开销。手写 Visitor 可以精确控制每个元素的渲染方式,而且不引入额外依赖。

Visitor 实现

private struct MarkdownToViewsVisitor: @preconcurrency MarkupVisitor {
    private(set) var views: [AnyView] = []

    mutating func visitHeading(_ heading: Heading) -> Result { ... }
    mutating func visitParagraph(_ paragraph: Paragraph) -> Result { ... }
    mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { ... }
    mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { ... }
    mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { ... }
    mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { ... }
    mutating func visitTable(_ table: Table) -> Result { ... }
    mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { ... }
}

每个 visit 方法处理一种 Markdown 节点,把生成的视图追加到 views 数组。最终 MarkdownRenderer.render() 返回这个数组,MarkdownContentViewForEach 渲染。

内联格式处理

段落、列表项里的内联格式(粗体、斜体、行内代码、链接)通过 collectAttributedString 处理。它递归遍历子节点,构建 AttributedString

private mutating func collectAttributedString(from markup: any Markup) -> AttributedString {
    var result = AttributedString()
    for child in markup.children {
        if let strong = child as? Strong {
            var s = collectAttributedString(from: strong)
            s.font = .body.bold()
            result.append(s)
        } else if let emphasis = child as? Emphasis {
            var e = collectAttributedString(from: emphasis)
            e.font = .body.italic()
            result.append(e)
        } else if let inlineCode = child as? InlineCode {
            var codeAttr = AttributedString(inlineCode.code)
            codeAttr.backgroundColor = Color.primary.opacity(0.06)
            codeAttr.font = .system(.body, design: .monospaced)
            result.append(codeAttr)
        } else if let link = child as? MarkdownLink {
            var linkAttr = AttributedString(collectInlineText(from: link))
            linkAttr.foregroundColor = Color.accentColor
            linkAttr.underlineStyle = .single
            linkAttr.link = URL(string: link.destination)
            result.append(linkAttr)
        }
        // ... SoftBreak, LineBreak, Strikethrough
    }
    return result
}

AttributedString 是 SwiftUI 原生支持的富文本类型。把它传给 SwiftUI.Text(attributed),SwiftUI 会按设定的 font、color、backgroundColor 渲染。行内代码得到灰色背景的等宽字体,链接得到蓝色下划线。

类型名冲突

swift-markdown 和 SwiftUI 有类型名冲突——两者都有 TextLink 等类型。解决方案是用 typealias:

private typealias MarkdownText = Markdown.Text
private typealias MarkdownLink = Markdown.Link

在 visitor 内部用 MarkdownTextMarkdownLink 引用 swift-markdown 的类型,SwiftUI.Text 引用 SwiftUI 的类型。

CodeHighlighter:Splash 代码高亮

代码块的高亮用 John Sundell 的 Splash 库。目前只支持 Swift 语法高亮,其他语言 fallback 到等宽纯文本:

enum CodeHighlighter {
    static func highlight(code: String, language: String?) -> AnyView {
        let trimmedLanguage = language?.lowercased()
        if trimmedLanguage == "swift" {
            return highlightedSwiftView(code: code)
        } else {
            return plainCodeView(code: code)
        }
    }

    private static func highlightedSwiftView(code: String) -> AnyView {
        let theme = Theme.sundellsColors(withFont: Splash.Font(size: 13))
        let format = AttributedStringOutputFormat(theme: theme)
        let highlighter = SyntaxHighlighter(format: format)
        let attributed = try? AttributedString(highlighter.highlight(code), including: \.appKit)
        return AnyView(Text(attributed ?? AttributedString(code)))
    }
}

Splash 的管线:源码字符串 → SyntaxHighlighterAttributedStringOutputFormatNSAttributedStringAttributedStringSwiftUI.Text

为什么只支持 Swift?因为 Splash 只支持 Swift。如果要支持 Python/JavaScript/Bash,需要换一个多语言的高亮库(比如 Highlight.js 的 Swift wrapper),或者用 Tree-sitter。目前 Swift 代码块的高亮频率最高(SwiftWork 本身是 Swift 项目),先支持 Swift 够用。

KeychainManager:API Key 安全存储

API Key 不能明文存在 SwiftData 或 UserDefaults 里。SwiftWork 用 macOS Keychain 存储:

struct KeychainManager: KeychainManaging, Sendable {
    func save(key: String, data: Data) throws {
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key
        ]
        let status = SecItemAdd(query.merging([kSecValueData: data]), nil)
        if status == errSecDuplicateItem {
            SecItemUpdate(query, [kSecValueData: data])
        }
    }

    func load(key: String) throws -> Data? {
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query, &result)
        if status == errSecItemNotFound { return nil }
        return result as? Data
    }
}

KeychainManaging 协议抽象了底层实现,方便测试时 mock。协议扩展提供了 saveAPIKey/getAPIKey/deleteAPIKey 的便捷方法。

Keychain 存储有两个好处:数据加密(系统级别的),以及不受 App Sandbox 的文件访问限制。

TitleGenerator:自动生成会话标题

新建的会话标题是"新会话"。Agent 第一次执行完成后,TitleGenerator 用 LLM 根据对话内容生成一个简短的标题:

enum TitleGenerator {
    static func generate(events: [AgentEvent], apiKey: String, ...) async -> String? {
        guard !apiKey.isEmpty else { return nil }

        let messages = events
            .filter { $0.type == .userMessage || $0.type == .assistant }
            .suffix(10)  // 只取最近 10 条
            .map { ["role": ..., "content": String($0.content.prefix(500))] }

        let body = [
            "model": model,
            "max_tokens": 50,
            "system": "根据以下对话内容,生成一个简短的标题(最多20个字符)。只输出标题。",
            "messages": messages
        ]
        // 调 LLM API,返回标题文本
    }
}

触发时机在 WorkspaceView.setupTitleGeneration 里——通过 AgentBridge.onResult 回调,在 Agent 执行完成且会话标题还是"新会话"时触发:

agentBridge.onResult = { [weak session] _ in
    guard let session, session.title == "新会话" else { return }
    if let title = await TitleGenerator.generate(events: events, ...) {
        sessionViewModel.updateSessionTitle(session, title: title)
    }
}

这是一个轻量的 LLM 调用——只有 50 token 的输出限制,system prompt 很短,取最近的 10 条消息、每条截断到 500 字符。实测延迟在 1-2 秒,不影响用户体验。

总结

SwiftWork 的数据层和服务组件各司其职:

组件 职责
SwiftData Session/Event/AppConfiguration 持久化
AppStateManager 应用状态恢复(会话、窗口、面板)
EventStore 事件持久化协议,SwiftData 实现
MarkdownRenderer swift-markdown AST → SwiftUI 视图
CodeHighlighter Splash 语法高亮(Swift)
KeychainManager API Key 安全存储
TitleGenerator LLM 自动生成会话标题

它们是前几篇讲的核心管线(AgentBridge → EventMapper → TimelineView)之外的"支撑层"。没有它们应用也能跑,但用户体验会差很多——没有持久化意味着每次重启都从零开始,没有 Markdown 渲染意味着 Agent 的回复是一堆原始文本,没有 Keychain 管理意味着 API Key 明文存储。


系列文章

相关链接

 
深入 SwiftWork(第 3 篇):Tool Card——可扩展的工具可视化系统

本文是「深入 SwiftWork」系列第 3 篇。系列目录见这里

前两篇讲了事件怎么从 SDK 流到 UI。这篇聚焦其中一类事件——工具调用的可视化。

Agent 调工具是 Agent 应用里最频繁的操作。一次典型任务可能调用二三十次工具——读文件、写文件、执行命令、搜索代码。如果每次工具调用都显示成一样的灰色方块,用户很难快速区分"Bash 在跑什么命令"、"Edit 在改哪个文件"。

SwiftWork 的解决方案是一套可扩展的工具渲染系统:每种工具注册一个渲染器,ToolCardView 根据工具名称查找对应的渲染器来显示。新增工具类型时,只需要写一个实现 ToolRenderable 协议的 struct,注册到 ToolRendererRegistry,不用改 TimelineView 的任何代码。

从问题出发:为什么不用统一的工具视图

最简单的做法是给所有工具调用用同一个视图——显示工具名称、输入参数、输出结果。第 2 篇里的 ToolCallView 就是这个角色:

struct ToolCallView: View {
    let event: AgentEvent
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 4) {
                Image(systemName: "wrench.and.screwdriver")
                Text(event.content)  // 工具名称
            }
            Text(input)  // 原始 JSON
        }
    }
}

这个视图对所有工具一视同仁——同样的扳手图标,同样的 JSON 输出。它作为 fallback 够用,但有几个问题:

  • Bash 调用时,用户想看到的是命令本身(git status),不是 {"command": "git status"}
  • Read 调用时,用户想看到的是文件路径(src/main.swift),不是完整的 JSON
  • 搜索工具的结果可能是多行匹配,需要和单行输出区分开

每个工具都有不同的"最有用的信息"。Tool Card 系统就是让每个工具自己决定怎么展示。

ToolRenderable 协议

协议定义了工具渲染器的契约:

protocol ToolRenderable: Sendable {
    /// 此渲染器处理的工具名称(与 SDK ToolUseData.toolName 匹配)
    static var toolName: String { get }

    /// 工具类型主题色(左边条、图标着色)
    static var accentColor: Color { get }

    /// 工具类型 SF Symbol 图标名
    static var icon: String { get }

    /// 根据工具内容生成 SwiftUI 视图
    @ViewBuilder @MainActor
    func body(content: ToolContent) -> any View

    /// 生成摘要标题(折叠状态显示)
    func summaryTitle(content: ToolContent) -> String

    /// 生成副标题(如文件路径、命令摘要)
    func subtitle(content: ToolContent) -> String?
}

协议扩展提供了默认值:

extension ToolRenderable {
    static var accentColor: Color { .gray }
    static var icon: String { "wrench.and.screwdriver" }

    func summaryTitle(content: ToolContent) -> String {
        content.toolName
    }

    func subtitle(content: ToolContent) -> String? {
        nil
    }
}

六个成员,三个有默认值。实现者只需要提供 toolName(静态路由键)和 body(渲染内容)。summaryTitlesubtitle 可以覆盖来提供更有意义的摘要,accentColoricon 可以覆盖来做视觉区分。

ToolRendererRegistry

注册表是一个 [String: ToolRenderable] 字典,用 toolName 做键:

@MainActor
@Observable
final class ToolRendererRegistry {
    private var renderers: [String: any ToolRenderable] = [:]

    init() {
        register(BashToolRenderer())
        register(FileEditToolRenderer())
        register(SearchToolRenderer())
        register(ReadToolRenderer())
        register(WriteToolRenderer())
    }

    func register(_ renderer: any ToolRenderable) {
        renderers[type(of: renderer).toolName] = renderer
    }

    func renderer(for toolName: String) -> (any ToolRenderable)? {
        renderers[toolName]
    }
}

init 时预注册 5 个内置渲染器。查找是 O(1) 的字典访问。@Observable 标记让 SwiftUI 在注册新渲染器时自动刷新——虽然目前的用法里渲染器在 init 时就注册完了,动态注册是留给插件系统准备的。

5 个内置渲染器

BashToolRenderer——终端命令

struct BashToolRenderer: ToolRenderable {
    static let toolName = "Bash"
    static let accentColor: Color = .green
    static let icon: String = "terminal"

    func summaryTitle(content: ToolContent) -> String {
        // 从 input JSON 提取 command 字段
        // {"command": "git status"} → "git status"
        guard let json = parseInput(content),
              let command = json["command"] as? String
        else { return content.toolName }
        return command
    }
}

绿色主题 + 终端图标。summaryTitle 从 input JSON 提取 command 字段——折叠状态下用户直接看到正在跑什么命令。

ReadToolRenderer——文件读取

struct ReadToolRenderer: ToolRenderable {
    static let toolName = "Read"
    static let accentColor: Color = .blue
    static let icon: String = "doc.text"

    func summaryTitle(content: ToolContent) -> String {
        // {"file_path": "src/main.swift"} → "src/main.swift"
        guard let json = parseInput(content),
              let filePath = json["file_path"] as? String
        else { return content.toolName }
        return filePath
    }
}

蓝色主题 + 文档图标。summaryTitle 提取文件路径。

WriteToolRenderer——文件写入

struct WriteToolRenderer: ToolRenderable {
    static let toolName = "Write"
    static let accentColor: Color = .orange
    static let icon: String = "pencil.and.outline"

    func summaryTitle(content: ToolContent) -> String {
        // 提取 file_path
    }

    func subtitle(content: ToolContent) -> String? {
        // 提取 content 字段,截取前 80 字符
        // {"content": "import Foundation\n..."} → "import Foundation..."
        guard let json = parseInput(content),
              let contentStr = json["content"] as? String, !contentStr.isEmpty
        else { return nil }
        return "\(contentStr.prefix(80))..."
    }
}

橙色主题 + 铅笔图标。比 Read 多一个 subtitle——显示写入内容的前 80 个字符。因为写入的内容通常很长,subtitle 给用户一个快速预览。

FileEditToolRenderer——文件编辑

struct FileEditToolRenderer: ToolRenderable {
    static let toolName = "Edit"
    static let accentColor: Color = .orange
    static let icon: String = "pencil.line"

    func summaryTitle(content: ToolContent) -> String {
        // 提取 file_path
    }

    func subtitle(content: ToolContent) -> String? {
        // 提取 old_string,截取前 50 字符
        // {"old_string": "func hello() {"} → "Editing: func hello() {"
        guard let json = parseInput(content),
              let oldString = json["old_string"] as? String, !oldString.isEmpty
        else { return nil }
        return "Editing: \(oldString.prefix(50))"
    }
}

橙色主题 + 编辑图标。subtitle 显示被替换的旧文本片段——让用户知道 Edit 在改哪一行。

SearchToolRenderer——代码搜索

struct SearchToolRenderer: ToolRenderable {
    static let toolName = "Grep"
    static let accentColor: Color = .purple
    static let icon: String = "text.magnifyingglass"

    func summaryTitle(content: ToolContent) -> String {
        // 提取 pattern
    }

    func subtitle(content: ToolContent) -> String? {
        // 提取 path
    }
}

紫色主题 + 放大镜图标。summaryTitle 显示搜索 pattern,subtitle 显示搜索路径。

视觉区分一览

工具 颜色 图标 summaryTitle subtitle
Bash 绿色 terminal 命令 -
Read 蓝色 doc.text 文件路径 -
Write 橙色 pencil.and.outline 文件路径 内容前 80 字符
Edit 橙色 pencil.line 文件路径 被替换文本前 50 字符
Grep 紫色 text.magnifyingglass 搜索 pattern 搜索路径

五种工具在折叠状态下就能一眼区分:颜色不同、图标不同、摘要文本不同。

ToolCardView:容器视图

ToolCardView 是工具卡片的容器。它不做具体的渲染,而是委托给注册表里查到的渲染器:

struct ToolCardView: View {
    let content: ToolContent
    let registry: ToolRendererRegistry
    let isSelected: Bool
    let onSelect: () -> Void

    @State private var isExpanded = false

    var body: some View {
        HStack(spacing: 0) {
            // 左边条(3px,渲染器的主题色)
            RoundedRectangle(cornerRadius: 2)
                .fill(toolAccentColor)
                .frame(width: 3)

            VStack(alignment: .leading, spacing: 0) {
                titleRow       // 始终可见
                    .onTapGesture {
                        onSelect()
                        withAnimation { isExpanded.toggle() }
                    }

                if isExpanded {
                    expandedContent  // 展开后可见
                }
            }
        }
    }
}

卡片分两层:titleRow(始终可见)和 expandedContent(点击展开)。

titleRow

private var titleRow: some View {
    HStack(alignment: .top, spacing: 6) {
        Image(systemName: toolIcon)          // 渲染器的图标
            .foregroundStyle(toolIconColor)

        VStack(alignment: .leading, spacing: 2) {
            HStack(spacing: 4) {
                Text(resolvedSummaryTitle)    // 渲染器的 summaryTitle
                    .fontWeight(.medium)
                Spacer()
                if content.status == .running {
                    ProgressView().controlSize(.mini)  // 运行中转圈
                }
                Text(statusLabel)             // pending / running / completed / failed
                    .font(.system(size: 9))
                    .background(statusColor.opacity(0.15))
            }
            Text(content.toolName)            // 工具名称(小字)
            if let subtitle = resolvedSubtitle {  // 渲染器的 subtitle
                Text(subtitle)
            }
        }
    }
}

标题行从渲染器获取图标、颜色、摘要标题和副标题。状态标签(pending/running/completed/failed)由 ToolContent.status 决定,不在渲染器的控制范围内——它是通用的执行状态,跟工具类型无关。

expandedContent

private var expandedContent: some View {
    VStack(alignment: .leading, spacing: 8) {
        Divider()

        // 工具特定的 body(从渲染器获取)
        if let renderer = registry.renderer(for: content.toolName) {
            AnyView(renderer.body(content: content))
        } else {
            genericToolBody  // fallback
        }

        // 通用 INPUT 区域
        if !content.input.isEmpty {
            HStack {
                Text("INPUT")
                Spacer()
                CopyButton(text: content.input)
            }
            Text(content.input)
                .font(.system(.caption, design: .monospaced))
        }

        // 通用 OUTPUT 区域
        if let output = content.output, !output.isEmpty {
            ToolResultContentView(output: output, isError: content.isError)
        }
    }
}

展开内容分三块:

  1. 渲染器的 body:工具特定的自定义内容。目前的 5 个内置渲染器都在 body 里显示了一个带图标的摘要块——和 titleRow 里的信息类似但更详细。将来可以为复杂工具(比如显示代码 diff 预览)提供更丰富的 body
  2. INPUT 区域:通用的原始输入 JSON 展示,带复制按钮。
  3. OUTPUT 区域ToolResultContentView,下一节讲。

genericToolBody 是没有注册渲染器时的 fallback——只显示工具名和原始输入。

ToolResultContentView:输出渲染 + Diff 检测

ToolResultContentView 有一个智能功能:自动检测输出内容是不是 diff 格式,如果是就用颜色标注。

private var isDiffContent: Bool {
    let lines = output.components(separatedBy: "\n")
    let diffLines = lines.filter { $0.hasPrefix("+") || $0.hasPrefix("-") || $0.hasPrefix("@@") }
    return diffLines.count >= 2
}

检测逻辑:如果输出里至少有两行以 +-@@ 开头,就认为是 diff 内容。简单但够用——SDK 的 Edit 工具输出 diff 格式的结果。

Diff 渲染给每行加背景色:

private func diffLineView(_ line: String) -> some View {
    Text(line)
        .font(.system(.caption, design: .monospaced))
        .padding(.horizontal, 4)
        .background(diffLineBackground(line))
}

private func diffLineBackground(_ line: String) -> Color {
    if line.hasPrefix("+") { return .green.opacity(0.15) }  // 新增行
    if line.hasPrefix("-") { return .red.opacity(0.15) }    // 删除行
    if line.hasPrefix("@@") { return .blue.opacity(0.1) }   // 位置标记
    return .clear
}

非 diff 内容按普通文本渲染,有截断逻辑——超过 5 行或 200 字符时折叠,带展开按钮。

怎么新增一个工具渲染器

假设 SDK 新增了一个 WebFetch 工具,你想在 SwiftWork 里给它一个专属的卡片样式。只需要两个步骤:

第一步:写渲染器

struct WebFetchToolRenderer: ToolRenderable {
    static let toolName = "WebFetch"
    static let accentColor: Color = .cyan
    static let icon: String = "globe"

    @MainActor
    func body(content: ToolContent) -> any View {
        // 自定义视图...
    }

    func summaryTitle(content: ToolContent) -> String {
        // 从 input 提取 URL
        guard let json = parseInput(content),
              let url = json["url"] as? String
        else { return content.toolName }
        return url
    }
}

第二步:注册

// ToolRendererRegistry.init()
register(WebFetchToolRenderer())

不需要改 TimelineView、ToolCardView 或任何其他文件。ToolCardView 在渲染时通过 registry.renderer(for:) 查找渲染器,查到了就用,查不到就用 fallback。

总结

Tool Card 系统的设计思路是协议 + 注册表:

组件 职责
ToolRenderable 定义渲染契约——工具名、颜色、图标、摘要、自定义视图
ToolRendererRegistry 字典查找,toolName → ToolRenderable
ToolCardView 容器视图,委托给渲染器,处理通用逻辑(展开/折叠、状态标签、INPUT/OUTPUT 区域)
ToolResultContentView 输出渲染,自动 diff 检测

这个模式的好处是开放扩展、关闭修改。TimelineView 的分派逻辑(第 2 篇的 toolCardView(for:))不需要知道有多少种工具——它只查注册表。新增工具类型时,改动的范围限定在渲染器文件和注册表的 init 方法。

下一篇是最后一篇,看数据层——SwiftData 的会话/事件持久化、App 状态恢复、Markdown 渲染和代码高亮。


系列文章

相关链接

 
深入 SwiftWork(第 2 篇):事件时间线——18 种事件的可视化与性能

本文是「深入 SwiftWork」系列第 2 篇。系列目录见这里

第 1 篇讲了 AgentBridge 怎么把 SDK 的 AsyncStream<SDKMessage> 变成 [AgentEvent]。这篇看 [AgentEvent] 变成什么——TimelineView 怎么渲染 18 种事件、怎么处理滚屏行为、怎么在事件量很大时保持流畅。

TimelineView 的结构

TimelineView 是工作区的主体,占满了侧边栏和输入框之间的所有空间。它的视图层级很浅:

TimelineView
  ├── ScrollView
  │   ├── topPlaceholder (虚拟化占位)
  │   ├── LazyVStack
  │   │   └── ForEach(virtualizedEvents) → eventView(for:)
  │   ├── bottomPlaceholder (虚拟化占位)
  │   ├── StreamingTextView (流式文本)
  │   └── bottom-anchor (滚动锚点)
  └── returnToBottomButton (回到底部)

没有事件时显示空状态:"发送消息开始与 Agent 对话"。有事件时进入 ScrollViewReader + LazyVStack 的结构。

事件分派:18 种类型到 8 种视图

eventView(for:) 是事件分派的核心。18 种 AgentEventType 映射到 8 种视图:

@ViewBuilder
private func eventView(for event: AgentEvent) -> some View {
    switch event.type {
    case .userMessage:       UserMessageView(event: event)
    case .partialMessage:    EmptyView()
    case .assistant:         AssistantMessageView(event: event)
    case .toolUse:           toolCardView(for: event)
    case .toolResult,
         .toolProgress:      pairedToolEventView(for: event)
    case .result:            ResultView(event: event)
    case .system:            systemOrThinking(event: event)
    case .hookStarted, .hookProgress, .hookResponse,
         .taskStarted, .taskProgress, .authStatus,
         .filesPersisted, .localCommandOutput,
         .promptSuggestion, .toolUseSummary:
                             SystemEventView(event: event)
    case .unknown:           UnknownEventView(event: event)
    }
}

几个值得说的分派逻辑:

partialMessage 渲染为 EmptyView 流式文本不走 ForEach(events),而是在 LazyVStack 下方用单独的 StreamingTextView 渲染。原因在第 1 篇讲过——partialMessage 只累积在 streamingText 里,不进 events 数组。这样避免了 ForEach 频繁插入/删除带来的闪烁和性能开销。

toolUsetoolCardViewtoolResult/toolProgresspairedToolEventView 如果 toolContentMap 里有对应的条目(说明已经收到了配对的 toolUse),toolUse 渲染为 ToolCardView,配对的 toolResult/toolProgress 渲染为 EmptyView——因为它们的内容已经合并在卡片里了。如果 toolContentMap 里没有(比如历史事件加载不完整),就 fallback 到简单的 ToolCallView/ToolResultView

system 类型需要区分"思考中"和普通系统事件。 systemOrThinking 方法检查 metadata 里的 subtype

private func systemOrThinking(event: AgentEvent) -> some View {
    let subtype = event.metadata["subtype"] as? String ?? ""
    let isLastEvent = agentBridge.events.last?.id == event.id
    if (subtype == "init" || subtype == "status") && isLastEvent {
        ThinkingView()              // 旋转齿轮 + "思考中..."
    } else if subtype == "init" || subtype == "status" {
        ThinkingView(isActive: false) // 对勾 + "Agent 已响应"
    } else if let isError = event.metadata["isError"] as? Bool, isError {
        SystemEventView(event: event, isError: true)  // 红色错误条
    } else {
        SystemEventView(event: event)  // 普通系统消息
    }
}

只有最后一条 init/status 事件才显示旋转动画。历史事件显示静态的"Agent 已响应"。这避免了所有历史思考状态都在转圈的问题。

各事件视图的设计

UserMessageView——右对齐蓝色气泡

struct UserMessageView: View {
    let event: AgentEvent
    var body: some View {
        HStack {
            Spacer()
            Text(event.content)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(.blue.opacity(0.15))
                .clipShape(RoundedRectangle(cornerRadius: 12))
        }
    }
}

用户消息右对齐,蓝色半透明背景,圆角矩形。跟 ChatGPT 的消息布局一致。

AssistantMessageView——左侧竖线 + Markdown

struct AssistantMessageView: View {
    let event: AgentEvent
    var body: some View {
        HStack(alignment: .top, spacing: 0) {
            RoundedRectangle(cornerRadius: 1)
                .fill(Color.secondary.opacity(0.3))
                .frame(width: 2)
                .padding(.trailing, 8)
            MarkdownContentView(markdown: event.content)
            Spacer()
        }
    }
}

左边一条灰色竖线做视觉分隔,内容用 MarkdownContentView 渲染。这个组件处理 Markdown 解析、代码高亮和长文本折叠,第 4 篇会详细讲。

ThinkingView——旋转齿轮动画

struct ThinkingView: View {
    var isActive: Bool = true
    @State private var isAnimating = false

    var body: some View {
        HStack(spacing: 8) {
            if isActive {
                Image(systemName: "gearshape")
                    .rotationEffect(.degrees(isAnimating ? 360 : 0))
                    .animation(.linear(duration: 1).repeatForever(autoreverses: false),
                               value: isAnimating)
                Text("思考中...")
            } else {
                Image(systemName: "checkmark.circle")
                Text("Agent 已响应")
            }
            Spacer()
        }
        .onAppear { if isActive { isAnimating = true } }
    }
}

isActive 控制两种状态:旋转齿轮表示正在思考,绿色对勾表示思考完成。onAppear 触发动画,视图滚出屏幕再滚回来时不会重新触发。

ResultView——执行结果 + 统计数据

struct ResultView: View {
    let event: AgentEvent
    // 从 metadata 提取 durationMs、totalCostUsd、numTurns
    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: statusIcon)  // checkmark.circle / pause.circle / xmark.circle
                .foregroundStyle(statusColor)
            Text(subtype)  // success / cancelled / error
        }
        // 下方显示:耗时 | 轮数 | 费用
        HStack(spacing: 12) {
            Label("\(duration)ms", systemImage: "clock")
            Label("\(turns) 轮", systemImage: "arrow.triangle.2.circlepath")
            Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
        }
    }
}

Result 事件显示执行结果的概要统计——耗时多少毫秒、经过多少轮对话、花费多少美元。错误时红底高亮。

SystemEventView——系统消息和错误提示

struct SystemEventView: View {
    let event: AgentEvent
    let isError: Bool

    var body: some View {
        HStack(spacing: 4) {
            if isError {
                RoundedRectangle(cornerRadius: 1).fill(Color.red).frame(width: 3)
                Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
            } else {
                Image(systemName: "info.circle").foregroundStyle(.secondary)
            }
            Text(event.content)
        }
        .background(isError ? Color.red.opacity(0.08) : Color.clear)
    }
}

普通系统消息一行灰色文字 + info 图标。错误消息加红色左边条 + 红色背景 + 警告图标。

滚屏行为:Follow Latest vs Manual Browse

Agent 在执行时会持续产出事件。用户通常想看到最新的事件(自动滚到底部),但有时候想往上翻看历史。这两个需求是冲突的。

SwiftWork 用 ScrollModeManager 管理两种模式的切换:

enum ScrollMode {
    case followLatest    // 自动跟随最新事件
    case manualBrowse    // 用户手动浏览历史
}

@MainActor
@Observable
final class ScrollModeManager {
    var scrollMode: ScrollMode = .followLatest

    var showReturnToBottomButton: Bool {
        scrollMode == .manualBrowse
    }

    private let nearBottomThreshold: CGFloat = 96
    private let scrollUpThreshold: CGFloat = 16
    private var cumulativeUpwardDelta: CGFloat = 0
}

自动跟随的条件: 当用户距底部不超过 96pt 时,自动切回 followLatest。每次新事件到来,TimelineView 自动滚到底部。

切到手动浏览的条件: 用户向上滚动超过 16pt 时,切到 manualBrowse。此时新事件不再触发自动滚动,右下角显示"回到底部"按钮。

// TimelineView.swift
.onChange(of: agentBridge.events.count) { _, newCount in
    updateVisibleRangeForCount(newCount)
    if scrollModeManager.scrollMode == .followLatest {
        scrollToLast(proxy: proxy)
    }
}
.onChange(of: agentBridge.streamingText) { _, _ in
    if scrollModeManager.scrollMode == .followLatest {
        scrollToLast(proxy: proxy)
    }
}

两个 onChange 监听事件数量变化和流式文本变化。只有在 followLatest 模式下才自动滚动。

回到底部按钮: 点击后切回 followLatest,更新 visibleRange 到最新 50 条事件,动画滚到底部:

Button {
    scrollModeManager.returnToBottom()
    let total = agentBridge.events.count
    let lower = max(0, total - 50)
    visibleRange = lower..<total
    withAnimation {
        proxy.scrollTo("bottom-anchor", anchor: .bottom)
    }
}

虚拟化:只渲染可见范围

当事件数量超过几百条时,全部渲染会导致 LazyVStack 创建大量视图,滚动掉帧。SwiftWork 用 visibleRange + renderBuffer 做虚拟化——只渲染可见区域附近的 ±20 条事件。

@MainActor
final class TimelineVirtualizationManager {
    let renderBuffer = 20

    func eventsToRender(visibleRange: Range<Int>, allEvents: [AgentEvent]) -> [AgentEvent] {
        guard !allEvents.isEmpty else { return [] }
        let lower = max(0, visibleRange.lowerBound - renderBuffer)
        let upper = min(allEvents.count, visibleRange.upperBound + renderBuffer)
        guard lower < upper else { return [] }
        return Array(allEvents[lower..<upper])
    }
}

传入 ForEach 的不是 agentBridge.events,而是 virtualizedEvents——经过虚拟化裁剪后的子集:

private var virtualizedEvents: [AgentEvent] {
    let allEvents = agentBridge.events
    if allEvents.isEmpty { return [] }
    if visibleRange.isEmpty {
        let upper = allEvents.count
        let lower = max(0, upper - 50)
        return virtualizationManager.eventsToRender(visibleRange: lower..<upper, allEvents: allEvents)
    }
    return virtualizationManager.eventsToRender(visibleRange: visibleRange, allEvents: allEvents)
}

被裁掉的区域用占位符撑高度,保持滚动条的位置准确:

private var topPlaceholder: some View {
    let upper = max(0, visibleRange.lowerBound - virtualizationManager.renderBuffer)
    return Group {
        if upper > 0 && !visibleRange.isEmpty {
            Spacer().frame(height: CGFloat(upper) * estimatedRowHeight)
        }
    }
}

estimatedRowHeight 取 80pt——一个经验值,大部分事件视图的高度在这个范围附近。不需要精确,只需要让滚动条的大致位置正确。

visibleRange 的更新时机

visibleRange 在几个关键时刻更新:

  1. 初始加载.task(id: agentBridge.events.first?.id)):设为最后 50 条事件
  2. 新事件到来.onChange(of: events.count)):如果在 followLatest 模式,滑动窗口保持最新 50 条
  3. 回到底部:重置为最新 50 条

目前没有实现滚动过程中的 visibleRange 动态更新——用户向上滚动浏览大量历史事件时,visibleRange 不会跟着滚动位置变化。这是一个已知的限制,将来可以通过 onAppear/onDisappear 回调或 ScrollView 的 offset 监听来实现。

初始滚动:解决首次加载的闪烁

首次加载事件列表时,SwiftUI 的 ScrollView 默认从顶部开始渲染。如果会话有几百条事件,用户会先看到顶部的事件,然后闪一下跳到底部。这个闪烁在每次切换会话时都会出现。

SwiftWork 的解决方案:延迟 150ms 后再滚动到底部,等 LazyVStack 完成首屏渲染:

.task(id: agentBridge.events.first?.id) {
    hasCompletedInitialScroll = false
    guard !agentBridge.events.isEmpty else { return }
    scrollModeManager.scrollMode = .followLatest
    visibleRange = 0..<0
    try? await Task.sleep(for: .milliseconds(150))
    guard !Task.isCancelled else { return }
    let total = agentBridge.events.count
    let lower = max(0, total - 50)
    visibleRange = lower..<total
    withAnimation {
        proxy.scrollTo("bottom-anchor", anchor: .bottom)
    }
    hasCompletedInitialScroll = true
}

hasCompletedInitialScroll 标记位控制后续的滚动模式切换——在初始滚动完成之前,onChange(of: scrollPositionId) 不会触发模式切换,避免干扰。

总结

TimelineView 的设计可以概括为三个子系统:

子系统 解决的问题 实现
事件分派 18 种类型到 8 种视图 eventView(for:) + ViewBuilder
滚屏控制 自动跟随 vs 手动浏览 ScrollModeManager + scrollPosition
虚拟化 大量事件时的渲染性能 visibleRange + renderBuffer + 占位符

事件分派是纯粹的视图逻辑——根据 event.type 选择对应的视图组件。滚屏控制和虚拟化是 TimelineView 独有的性能问题,跟 SDK 集成层无关。

下一篇看 Tool Card 系统——ToolRenderable 协议怎么让每种工具有自己的渲染器,以及 ToolRendererRegistry 怎么做到不改动时间线代码就能新增工具类型。


系列文章

相关链接

 
深入 SwiftWork(第 1 篇):SDK 集成层——把 AsyncStream 接进 SwiftUI

本文是「深入 SwiftWork」系列第 1 篇。系列目录见这里

第 0 篇画了全景图——AsyncStream<SDKMessage> → AgentBridge → EventMapper → SwiftUI。这篇拆开中间两层:AgentBridge 和 EventMapper,看它们怎么把 SDK 的消息流变成 SwiftUI 可以直接消费的事件列表。

先说结论:AgentBridge 是整个应用里最复杂的单个文件。它同时做了五件事——消费 Stream、映射事件、配对工具内容、持久化数据、管理内存。每一件都不难,但五件叠在一起要处理不少状态。这篇文章逐个讲清楚。

从 SDK 到 AgentBridge:接口在哪

回顾一下 SDK 提供的核心接口(第 1 篇讲过的):

// SDK 的 Agent.stream() 返回 AsyncStream<SDKMessage>
let agent = createAgent(options: ...)
for await message in agent.stream("hello") {
    switch message {
    case .assistant(let data): ...
    case .toolUse(let data): ...
    case .toolResult(let data): ...
    // 18 种类型
    }
}

SDK 给你一个 AsyncStream<SDKMessage>——一个异步事件流。SwiftUI 需要一个 [AgentEvent]——一个可以在主线程渲染的数组。AgentBridge 就是这两者之间的桥。

它的核心状态只有几个:

@MainActor
@Observable
final class AgentBridge {
    var events: [AgentEvent] = []         // SwiftUI 消费的事件数组
    var isRunning = false                  // Agent 是否在执行
    var streamingText: String = ""         // 流式文本的累积缓冲区
    var toolContentMap: [String: ToolContent] = [:]  // 工具内容配对
    var errorMessage: String?              // 错误信息

    @ObservationIgnored private var agent: Agent?
    @ObservationIgnored private var currentTask: Task<Void, Never>?
    // ...
}

@MainActor 保证所有状态都在主线程访问。@Observable 让 SwiftUI 自动追踪变化。@ObservationIgnored 标记的 agentcurrentTask 不需要触发 UI 更新——它们是实现细节,不是 UI 状态。

sendMessage:一条消息的完整生命周期

用户在输入框打字,按回车。InputBarView 调用 agentBridge.sendMessage(text)。接下来发生的事情:

func sendMessage(_ text: String) {
    guard let agent, !text.isEmpty else { return }

    if isRunning { cancelExecution() }  // 如果正在跑,先停掉

    // 1. 用户消息立即追加到事件列表
    let userEvent = AgentEvent(type: .userMessage, content: text, timestamp: .now)
    appendAndPersist(userEvent)

    errorMessage = nil
    isRunning = true

    // 2. 递增 generation 计数器(用于检测过期的 cancel)
    activeTaskGeneration &+= 1
    let myGeneration = activeTaskGeneration

    // 3. 在后台 Task 中消费 stream
    currentTask = Task { [weak self] in
        guard let self else { return }
        var receivedResult = false
        let stream = agent.stream(text)
        for await message in stream {
            guard !Task.isCancelled else { break }
            if case .userMessage = message { continue }

            let event = EventMapper.map(message)

            // 流式文本走单独的缓冲区,不进 events 数组
            if event.type == .partialMessage {
                self.streamingText += event.content
                continue
            }
            if event.type == .assistant {
                self.streamingText = ""
            }
            if event.type == .result {
                receivedResult = true
                self.onResult?(event.content)
            }
            self.appendAndPersist(event)
        }
        // 流结束但没收到 result → 异常终止
        if !Task.isCancelled && !receivedResult {
            self.appendAndPersist(AgentEvent(
                type: .system,
                content: "Agent 流异常结束,未收到完整响应。",
                metadata: ["isError": true],
                timestamp: .now
            ))
        }
        self.finalizeToolContentMap()
        if self.activeTaskGeneration == myGeneration {
            self.currentTask = nil
        }
        self.isRunning = false
    }
}

几个值得注意的设计决策:

用户消息不等 Stream。 用户消息直接追加到 events,不等 SDK 的 AsyncStream 返回 .userMessage。这样 UI 可以立即显示用户输入,不用等网络往返。Stream 里收到的 .userMessagecontinue 跳过。

流式文本有单独的缓冲区。 partialMessage 不进 events 数组,而是累积到 streamingText。当收到完整的 .assistant 事件时,清空 streamingText。这样 SwiftUI 的 TimelineView 可以用一个单独的 StreamingTextView 渲染正在输入的文本,而 ForEach(events) 不需要频繁插入再删除。

Generation 计数器防止 cancel 竞态。 activeTaskGeneration 是一个递增的计数器。每次 sendMessage 都递增它,记录自己的 generation。Stream 结束后检查 if self.activeTaskGeneration == myGeneration,只有当前 generation 匹配时才清空 currentTask。这防止了用户快速连续发消息时的 cancel 竞态——前一个 Stream 的 cancel 回调不会把新一个 Task 的引用清掉。

EventMapper:18 种消息的纯函数映射

EventMapper 做的事情很纯粹:SDKMessage → AgentEvent。没有副作用,没有状态。

struct EventMapper {
    static func map(_ message: SDKMessage) -> AgentEvent {
        switch message {
        case .partialMessage(let data):
            return AgentEvent(type: .partialMessage, content: data.text, timestamp: .now)

        case .assistant(let data):
            return AgentEvent(type: .assistant, content: data.text,
                metadata: ["model": data.model, "stopReason": data.stopReason],
                timestamp: .now)

        case .toolUse(let data):
            return AgentEvent(type: .toolUse, content: data.toolName,
                metadata: ["toolName": data.toolName, "toolUseId": data.toolUseId,
                           "input": data.input],
                timestamp: .now)

        case .toolResult(let data):
            return AgentEvent(type: .toolResult, content: data.content,
                metadata: ["toolUseId": data.toolUseId, "isError": data.isError],
                timestamp: .now)

        case .toolProgress(let data):
            return AgentEvent(type: .toolProgress, content: data.toolName,
                metadata: ["toolUseId": data.toolUseId, "toolName": data.toolName,
                           "elapsedTimeSeconds": data.elapsedTimeSeconds ?? 0],
                timestamp: .now)

        case .result(let data):
            return AgentEvent(type: .result, content: data.text,
                metadata: ["subtype": data.subtype.rawValue, "numTurns": data.numTurns,
                           "durationMs": data.durationMs, "totalCostUsd": data.totalCostUsd],
                timestamp: .now)

        case .system(let data):
            return AgentEvent(type: .system, content: data.message,
                metadata: ["subtype": data.subtype.rawValue], timestamp: .now)

        // hook、task、auth 等消息全部映射为 system 类型
        case .hookStarted, .hookProgress, .hookResponse,
             .taskStarted, .taskProgress,
             .authStatus, .filesPersisted,
             .localCommandOutput, .promptSuggestion, .toolUseSummary:
            return AgentEvent(type: .system, content: extractContent(from: message),
                metadata: extractMetadata(from: message), timestamp: .now)

        case .userMessage(let data):
            return AgentEvent(type: .userMessage, content: data.message, timestamp: .now)
        }
    }
}

映射策略:

  • 一对一映射assistanttoolUsetoolResulttoolProgressresultuserMessage 各自对应一个 AgentEventType
  • 合并映射hookStarted/hookProgress/hookResponsetaskStarted/taskProgressauthStatusfilesPersisted 等 10 种 SDK 消息全部映射成 .system 类型,通过 metadata 区分具体子类型
  • 数据提取:SDK 消息里的数据字段按需提取到 metadata 字典里,UI 视图按 key 取用

为什么要用 metadata: [String: any Sendable] 而不是给每种事件类型定义单独的 struct?因为 metadata 是一个灵活的字典——新增事件类型时只需要在 EventMapper 里加一个 case,不需要定义新的模型类型。代价是类型安全性降低,取值时需要 as? 转换。对于 UI 层来说,这个取舍是合理的——事件数据只在渲染时读取,不需要编译期类型检查。

ToolContent 配对:把三个事件合成一个卡片

SDK 的工具调用经历三个阶段:toolUse(开始)→ toolProgress(进度更新)→ toolResult(完成)。它们是三个独立的 SDKMessage,但 UI 需要展示为一个完整的工具卡片——包含工具名称、输入参数、执行进度、输出结果。

这就是 toolContentMap 的用途。它用 toolUseId 做键,把三个阶段的事件合并成一个 ToolContent

// AgentBridge+ToolContentMap.swift
func processToolContentMap(for event: AgentEvent) {
    switch event.type {
    case .toolUse:
        let content = ToolContent.fromToolUseEvent(event)
        toolContentMap[content.toolUseId] = content

    case .toolProgress:
        let toolUseId = event.metadata["toolUseId"] as? String ?? ""
        if let existing = toolContentMap[toolUseId] {
            toolContentMap[toolUseId] = existing.applyingProgress(event)
        }

    case .toolResult:
        let resultContent = ToolContent.fromToolResultEvent(event)
        let toolUseId = resultContent.toolUseId
        if let existing = toolContentMap[toolUseId] {
            toolContentMap[toolUseId] = ToolContent(
                toolName: existing.toolName,
                toolUseId: existing.toolUseId,
                input: existing.input,
                output: resultContent.output,
                isError: resultContent.isError,
                status: resultContent.status,
                elapsedTimeSeconds: existing.elapsedTimeSeconds
            )
        }

    default:
        break
    }
}

配对过程:

  1. 收到 toolUse → 创建 ToolContent,状态 .pending
  2. 收到 toolProgress → 更新已有条目,状态改为 .running,记录耗时
  3. 收到 toolResult → 合并输出和错误状态,状态改为 .completed.failed

ToolContent 是一个 struct,每次更新都创建新副本。AgentBridgetoolContentMap@Observable 追踪的属性,所以每次赋值都会触发 SwiftUI 更新。这意味着工具卡片可以实时显示进度变化。

还有一个 finalizeToolContentMap 方法——在 Stream 结束时调用,把所有还在 .pending.running 状态的工具标记为 .completed。防止 Stream 异常终止时,UI 上永远停着一个转圈的进度条。

事件持久化:EventStore 协议

每条事件都经过 appendAndPersist,同时更新内存数组和数据库:

private func appendAndPersist(_ event: AgentEvent) {
    events.append(event)
    processToolContentMap(for: event)

    guard event.type != .partialMessage,
          let eventStore, let currentSession else { return }

    totalPersistedEvents += 1
    try eventStore.persist(event, session: currentSession, order: eventOrder)
    eventOrder += 1

    trimOldEvents()
}

持久化通过 EventStoring 协议抽象:

@MainActor
protocol EventStoring {
    func persist(_ event: AgentEvent, session: Session, order: Int) throws
    func fetchEvents(for sessionID: UUID) throws -> [AgentEvent]
    func fetchEvents(for sessionID: UUID, offset: Int, limit: Int) throws -> [AgentEvent]
    func totalEventCount(for sessionID: UUID) throws -> Int
}

目前只有一个实现 SwiftDataEventStore,用 SwiftData 的 ModelContext 做存储。序列化是手写的 JSON——EventSerializerAgentEvent 转成 [String: Any] 的字典再压成 Data

// SwiftData 的 Event 模型
@Model
final class Event {
    @Attribute(.unique) var id: UUID
    var sessionID: UUID
    var eventType: String
    var rawData: Data        // JSON 序列化的 AgentEvent
    var timestamp: Date
    var order: Int
    var session: Session?
}

为什么把 metadata 塞进 rawData 而不是拆成独立的 SwiftData 字段?因为 metadata 的内容因事件类型而异——toolUsetoolName/toolUseId/inputresultnumTurns/durationMs/totalCostUsd。拆成独立字段会导致大量空列,而且每次新增事件类型都要改 Schema。用一个 JSON blob 存储,读取时再反序列化,更灵活。

持久化的写入时机是每条事件一次。对于 Agent 的一次典型执行(可能产生 50-100 条事件),这意味着 50-100 次 SwiftData 写入。实测没有性能问题——SwiftData 在内存中缓存,批量刷盘。如果将来事件量更大,可以改成批量写入。

内存管理:滑动窗口 + 分页

Agent 的一次复杂执行可能产生上千条事件。全部留在内存里不现实。AgentBridge 用了两层策略:

内存内滑动窗口

private let maxInMemory = 500

func trimOldEvents() {
    guard events.count > maxInMemory else { return }
    let removeCount = events.count - maxInMemory
    let removed = Array(events.prefix(removeCount))
    events.removeFirst(removeCount)
    trimmedEventCount += removeCount

    for event in removed {
        if event.type == .toolUse {
            let toolUseId = event.metadata["toolUseId"] as? String ?? ""
            toolContentMap.removeValue(forKey: toolUseId)
        }
    }
}

内存数组最多保留 500 条事件。超出部分从头部删除,同时清理 toolContentMap 里对应的条目。trimmedEventCount 记录已经删除了多少条,用于分页查询时的偏移计算。

加载时的分页

切换会话时,loadEvents 按总量决定加载策略:

func loadEvents(for session: Session) {
    clearEvents()
    currentSession = session
    guard let eventStore else { return }

    let total = try eventStore.totalEventCount(for: session.id)
    totalPersistedEvents = total

    if total > 1000 {
        // 大会话:只加载第一页
        let firstPage = try eventStore.fetchEvents(for: session.id, offset: 0, limit: 50)
        events = firstPage
        eventOrder = total
    } else {
        // 小会话:全部加载
        let persisted = try eventStore.fetchEvents(for: session.id)
        events = persisted
        eventOrder = persisted.count
    }
    rebuildToolContentMap()
}

用户向上滚动时,loadMoreEvents 按页追加:

func loadMoreEvents() {
    guard let eventStore, let currentSession else { return }
    let offset = trimmedEventCount + events.count
    guard offset < totalPersistedEvents else { return }

    let remaining = totalPersistedEvents - offset
    let limit = min(pageSize, remaining)
    let nextPage = try eventStore.fetchEvents(for: currentSession.id, offset: offset, limit: limit)
    events.append(contentsOf: nextPage)
    rebuildToolContentMap()
}

hasMoreEvents 是一个计算属性,SwiftUI 可以用它显示"加载更多"按钮:

var hasMoreEvents: Bool {
    totalPersistedEvents > trimmedEventCount + events.count
}

权限系统:Agent 调工具前的用户审批

SDK 的 permissionMode: .default 会在工具执行前询问用户是否允许。AgentBridge 通过 setCanUseTool 回调接入这个机制:

private func setupPermissionCallback() {
    agent?.setCanUseTool { [weak self] tool, input, _ in
        guard let self else { return .allow() }
        return await self.handlePermission(tool: tool, input: input)
    }
}

PermissionHandler 先检查已有的权限规则(用户之前选过"始终允许"的工具)。如果规则匹配,直接放行。如果没有匹配的规则,弹出一个原生的 SwiftUI sheet 让用户审批:

var pendingPermissionRequest: PendingPermissionRequest?

PendingPermissionRequest 内部用一个 CheckedContinuation 挂起异步执行,等用户点击"允许一次"/"始终允许"/"拒绝"后恢复:

private func presentPermissionDialog(...) async -> CanUseToolResult {
    let request = PendingPermissionRequest(...)
    self.pendingPermissionRequest = request
    let dialogResult = await request.waitForResult()  // 挂起,等 UI 操作
    self.pendingPermissionRequest = nil

    switch dialogResult {
    case .allowOnce:   // 本次允许
    case .alwaysAllow:  // 写入持久规则
    case .deny:         // 拒绝
    }
}

这个设计把 SDK 的同步权限检查(canUseTool 回调)和 SwiftUI 的异步 UI 交互(用户点击按钮)桥接在一起,靠 Swift 的 async/await + CheckedContinuation 实现。

配置与生命周期

AgentBridge 的配置入口是 configure

func configure(apiKey: String, baseURL: String?, model: String, workspacePath: String?) {
    let options = AgentOptions(
        apiKey: apiKey,
        model: model,
        baseURL: baseURL,
        maxTurns: 10,
        permissionMode: .default,
        cwd: workspacePath,
        tools: getAllBaseTools(tier: .core)
    )
    self.agent = createAgent(options: options)
    setupPermissionCallback()
}

每次用户切换会话,WorkspaceView 会重新调用 configure(因为不同会话可能有不同的 workspace path):

// WorkspaceView.swift
.onChange(of: session.id) { _, _ in
    agentBridge.clearEvents()
    configureAgent()        // 重新创建 Agent
    loadPersistedEvents()   // 加载该会话的历史事件
    setupTitleGeneration()  // 设置自动标题
}

clearEvents 做完整的重置——清空事件数组、取消正在执行的 Task、重置分页状态:

func clearEvents() {
    events = []
    streamingText = ""
    errorMessage = nil
    isRunning = false
    toolContentMap = [:]
    currentTask?.cancel()
    currentTask = nil
    eventOrder = 0
    totalPersistedEvents = 0
    trimmedEventCount = 0
}

总结

AgentBridge 承担了五个职责:

职责 实现方式
消费 Stream Taskfor await 循环,cancel 时 Task.cancel()
映射事件 EventMapper.map() 纯函数
配对工具内容 toolContentMap: [String: ToolContent]
持久化 EventStoring 协议 + SwiftData 实现
内存管理 500 条滑动窗口 + 按需分页加载

整条管线在 @MainActor 上运行,SwiftUI 通过 @Observable 自动响应变化。视图层不需要知道 Stream 的存在,不需要知道 SDK 的类型,只需要处理 AgentEventToolContent

下一篇看事件时间线——TimelineView 怎么渲染 18 种事件、怎么做虚拟化、怎么处理流式文本和滚动行为。


系列文章

相关链接

 
Page 1 of 4
Next