架构分析 · 2026-08-18
DeepSeek Harness 架构解剖
一个把「AI 编程助手」的核心循环压缩到 1500 行、把整个产品做成一份 165 行配置文件的系统。它真正值得学的地方不在 AI,而在于它把架构规矩变成了机器能执行的东西。
这是什么
DeepSeek Harness 是一个 agent harness——给大模型装上手脚的那层软件。模型本身只会读文字、吐文字;要让它能改代码、跑命令、查资料,需要有人负责:把上下文喂给它、解析它想调用哪个工具、真的去执行、把结果再喂回去、记录整个过程、在上下文塞不下时想办法。这层「脚手架」就是 harness。
市面上大多数这类系统长成一个大主程序,功能以模块形式挂在上面。这个项目走了完全相反的路:它没有主程序。连「模型适配器」「工具注册表」「会话记录」「主循环」本身都是平等的插件,从配置文件里装配起来。
测试代码比源码还多。所谓「主循环」只占全部源码的 0.7%——其余全是可替换的插件。
结论
这是一套「把规矩变成代码」的工程系统,AI agent 只是它的第一个产物。
如果你只想带走一句话:这个项目最独特的地方,是它反复地把「团队约定」升级成「机器强制」——不是写在文档里让人自觉遵守,而是让违反约定的代码编译不过或者检查不过。四个核心观念撑起了整座建筑,下面四节各讲一个。
而它付出的代价也真实存在:219 个包的强制样板、大量从未兑现的抽象、几处文档已经和代码对不上。这些我在第 10 节逐条说明,每一条都自己验证过。
观念一:一切皆插件
普通软件像一栋楼:有地基、承重墙,改动要小心。这个项目更像一块公告板——所有零件平等地钉在上面,每个零件写着「我需要什么」和「我提供什么」,框架负责撮合。没有承重墙,所以任何零件都能摘下来换掉,包括那个看起来最核心的。
产品是一份配置文件,不是一个程序
同一套代码要跑出好几种形态:网页版、命令行一次性任务、给编辑器用的后台服务、给别的程序调用的 SDK。传统做法是加一堆 if 分支或者维护几个入口文件,时间一长谁也说不清某个形态到底装了什么。
一个完整的 AI 编程助手,就是一份 165 行的 YAML 清单——列出要装哪些插件、每个插件怎么配。启动时系统先把根配置清空成一个空列表,再按顺序叠加:
基础包 → 形态包(网页/命令行) → 用户的个人配置 → 机器级配置 → 命令行临时覆盖
每一层都是「补丁」:按 id 找到某一行,替换它的配置,或者插入新行。没有任何一层是「那个主配置文件」,它们地位平等。
聪明
「装了什么」变成了可以打印、可以 diff、可以逐层追溯的数据。想知道某一行配置是哪一层带来的?系统提供的查看命令复用装配时的同一个函数,对「前 1 层」「前 2 层」…各算一次结果再两两比对——所以打印出来的东西和实际跑起来的东西不可能不一致。
还有个细节很实在:叠加时不是「从共享基座里省略」某些插件,而是显式写 disabled: true 并注明理由。省略的行会在某人重排或合并配置的那天悄悄回来;显式关闭是能搜到、扛得住重排的明确陈述。
「我需要什么」代替「谁先谁后」
插件系统的经典麻烦是启动顺序:A 要用 B,B 要用 C,谁先初始化?手工排序的清单一旦有人插队就崩。
每个插件只声明「我需要哪几个服务」。框架给每个插件算一个「就绪状态」——依赖全都在位就激活,任何一个消失就自动卸载。加载顺序不是被编排出来的,是从依赖声明里涌现出来的。
更进一步:这个就绪状态记的不只是「依赖在不在」,还包括「依赖是由哪个实例提供的」。所以把某个服务换成另一个实现时,即使服务名字没变,依赖它的插件也会自动重启一次。
聪明
这让「热替换」成为常态而非特例。开发时改一个文件,只有受影响的那棵子树重新加载,进程不用重启。
每次注册都返回一张「退票」
插件卸载时最容易出事:注册的事件监听器没摘、开的文件没关、起的定时器没停。而且卸载可能发生在初始化还没跑完的时候。
框架规定:任何会改变世界的动作都必须通过统一的注册接口,它返回一张「退票」。卸载一个插件 = 把它的所有退票反序执行一遍。
嵌套的情况也处理了:在一个注册动作内部再注册,子退票会自动从插件的总清单里「转挂」到父退票名下——于是嵌套的清理天然按嵌套结构回收。异步初始化每一步之前都检查状态是否已变,所以卸载能中断进行中的初始化。
聪明
「怎么清理」不再是每个插件作者的自觉,而是框架层面的单一机制。这个项目还专门给上游框架的这部分补了三个重入漏洞——都是「卸载在初始化半途到达」引发的那一类。
插件是怎么和 agent 接起来的
这是最容易卡住的地方,因为它和「主程序调用模块」的直觉正好相反。
先把一个误解去掉:不存在一个叫 agent 的东西「使用」插件。主循环自己就是一个插件,和别的插件平级。它之所以能跑起来,是因为它和其他插件之间有三条固定的通道——而这三条通道的控制方向各不相同,这正是容易糊涂的原因。
放到一次真实的执行里
把上面三条通道叠在一次「模型请求 + 工具调用」的过程上,就是下面这张图。中间那列是主循环写死的骨架——七个步骤,顺序固定;左右两列是插件挂上来的东西。
三个容易误解的点
为什么「工具定义」既连②又连⑥
同一个工具注册表被读了两次,用途不同:第②步读它是为了把工具的说明书写进提示词(告诉模型有哪些工具可用),第⑥步读它是为了真的执行。
这不是冗余,而是一条重要的约束:两次读的必须是同一份清单。项目里所有和「可见性」有关的问题——提示词里列了什么、能执行什么、生成的 SDK 里有什么——都走同一个解析函数。否则就会出现「告诉模型有这个工具,实际调用时说不存在」这种让模型彻底放弃自我修正的状况。
「拦截」不是回调,是可以中途接管的链条
右列那些插件不是「被通知一下」。以第③步为例:主循环把「我打算发给模型的这几条消息」交出去,每个监听的插件可以选择传给下一个、改写后传下去、或者直接否决这一步。压缩策略就是在这里发现上下文要满了,先去做一轮摘要。
这个模式在 Web 框架里叫中间件(洋葱模型),项目沿用了所用框架自带的名字 waterfall——不是它发明的概念。
同一个插件可以只对一个会话生效
注册不一定是全局的。每个活着的 agent 都有自己的一份注册作用域,插件可以只往某一个 agent 的作用域里注册。于是同名的东西会「就近覆盖」——某个会话的 bash 工具可以是一个受限版本,而其他会话看到的还是原来的。
这就是「一个会话换一套能力组合」的实现方式:一份预设配置只挂载一次,各个会话通过作用域的父子关系加入它,而不是每个会话各挂一份。
一句话总结
主循环提供固定的时间骨架(什么时候装配提示词、什么时候发请求、什么时候执行工具);插件提供骨架上的所有内容(有哪些工具、提示词写什么、用哪个模型)和骨架上的所有策略(这次能不能发、这个工具能不能执行、结果要不要改写)。
所以「加一个功能」在这个项目里的标准答案不是「改主循环」,而是「写一个插件,挂到某个已文档化的接入点上」。
观念二:日志即真相
会话不是存在某个变量里的一段对话,而是一本只能往后写、不能涂改的流水账。每次要问模型问题时,都从这本账重新算一遍「模型应该看到什么」。所以「模型看到的」永远等于「账上记的」——没有第二份真相。
模型历史是算出来的,不是存出来的
如果对话历史存在一个数组里,那么「重放一个会话」「fork 一个会话」「从崩溃恢复」「给 UI 渲染」就各自需要一套逻辑,而它们迟早会不一致——模型看到的和你在界面上看到的对不上,这类 bug 极难查。
只有一本流水账。要发请求时,把账从头折叠一遍算出消息列表。UI、重放、fork、持久化全都基于同一本账的不同折叠方式。
规矩写成一句话:「模型可见 ⟺ 已记录」。任何会进入模型请求的东西——包括工作区说明、时间信息、系统提示词本身——都必须先成为账上的一条记录。想加一种新的模型可见输入?先定义一种新的日志事件。
聪明
项目里有一段代码专门守这条规矩:每次发请求前断言「即将发出的消息列表」和「重放日志算出来的消息列表」逐字节相同,不同就报错。这把一条口号变成了可执行的检查。
但要注意:我核实过,这套检查默认不在产品里运行——它只在测试和 demo 里挂载。详见第 10 节。
上下文压缩靠「遮蔽」,不靠删除
对话变长后必须压缩,否则塞不进模型的上下文窗口。但常见做法是把旧消息替换成摘要——于是用户已经看到的对话在界面上凭空消失了。
不撕账页。压缩时追加一条特殊记录:「以下第 12 到第 47 条,以这份摘要为准」。
然后同一本账有两种读法:模型面会尊重这条遮蔽记录,读到摘要;人类面只读「原本就是追加进来的」记录,完全无视遮蔽。
聪明
一份数据、两种投影,各取所需,且两者都不需要额外的存储或同步。压缩是个纯粹的「加法」操作,因此永远可回溯——你能确切知道当时摘要掉了哪些内容。
读到不认识的记录,宁可拒绝也不跳过
软件升级后,旧版本读到新版本写的日志怎么办?「跳过不认识的」是最常见的做法,也是最危险的——它会静默地恢复出一个被掏空的会话,而使用者毫无察觉。
默认相反:读到不认识的记录类型就拒绝重建整个会话,除非那条记录被作者显式标记为「可忽略」。
聪明
两种失败模式里,选了代价小的那个。忘记打标记 ⇒ 过度拒绝(不方便,但看得见);反过来 ⇒ 静默数据丢失(看不见,且不可逆)。默认方向的选择本身就是设计。
观念三:类型即法律
这是我认为整个项目最值得抄的部分,而且和 AI 完全无关——任何有类型系统的语言都能用。
让返回类型使「组合律」自动成立
工具执行前要过一串安全检查(权限、沙箱、用户策略)。经典写法是一条「中间件链」,每个环节可以放行、拒绝,或者交给下一个。
但中间件链有个隐蔽的坑:顺序会影响结论。一个插件如果把自己插到队伍最前面,就能抢在别人的「拒绝」之前放行。这类 bug 不报错、无声无息,而且往往是安全相关的。
项目把安全检查拆成两种东西并存:
- 中间件链——保留,用来做需要「包裹」的事情:超时、重试、埋点。
- 守卫——一种新东西。它的类型只允许返回「拒绝理由」或者「我没意见」,压根没有「放行」这个选项。
于是守卫的组合就是一个无序集合上的「有一个说不行就是不行」。无论注册顺序如何、无论谁插队,结论完全相同。
聪明
「顺序无关」这条性质通常写在文档里靠人遵守,这里被编码进了类型签名。想违反它,代码根本编译不过。评审时不需要再检查这一类问题——编译器已经检查了。
这是把「不变量」从注释提升为语言构造的教科书式例子。它可以直接迁移到任何权限系统、校验管线、策略引擎。
默认值必须显式「落地」
「超时时间没传就用 60 秒」这种默认值,通常散落在实现内部(timeout ?? 60000)。结果没人说得清一次调用真正用了什么参数,调试时只能翻源码。
把「填默认值」提升成一个公开的、命名的转换步骤:从「请求」(字段大多可选)转成「规格」(字段全部必填)。执行函数的类型只接受规格,所以一个没填完的请求根本传不进去。
填默认值的那一步同时负责钳制上限——比如用户要求 10 小时超时,会被静默压到部署允许的最大值,而不是抛错或者放行。
聪明
「一次调用实际用了什么参数」变成一个可以打印、可以记日志、可以断言的具体值。默认值策略集中在一个函数里,而不是散落成十几个 ??。
把「出口白名单」写成类型
后端要往浏览器转发一部分内部事件。哪些能转、哪些不能转,通常是一个数组加一句注释——然后转发代码和前端订阅代码各维护一份,慢慢就漂了。
白名单是一个常量数组,被类型系统约束(数组里的每一项必须是真实存在的事件、且形状符合可转发的要求)。运行时的转发循环和前端合法的订阅键集都从这一个数组派生。
聪明
加一个事件是一行改动,且这一行同时被类型检查,两端不可能漂移。这个模式适用于任何「内外边界的允许清单」。
包结构由语法分析器强制
这个项目吃过一次真实的亏:某个插件文件多写了一行「默认导出」。加载器的规则是「有默认导出就只取默认导出」,于是这个插件的依赖声明被连带丢弃,插件在一个「什么服务都没有」的环境里启动,服务一连上真实编辑器就崩。
当时这个包有 178 个绿色单元测试和 100% 行覆盖率。为什么没测出来?因为所有测试都是手工搭建插件对象的,而「取默认导出」那段逻辑只有真实加载器才会走。
事故之后加了两道防线。其一:一个检查脚本用 TypeScript 的语法分析器逐个解析每个包,强制它们满足一串结构要求——包括「不得有默认导出」。219 个包全部合规。
其二写进了测试规范:每个对外可见的插件必须有一个走「真实加载路径」的测试。手搭对象的测试不算数。
聪明
这是「事故 → 机器化防线」的完整闭环。而且顺带得出了一条更普适的教训:行覆盖率证明代码跑过了,不证明功能按发布出去的样子工作。
观念四:接缝
项目管一个「可替换的能力」叫 seam(接缝)。一个接缝必须凑齐三样:接口声明(定义这个能力是什么)、至少一个实现、至少一个使用者。三者缺一不叫接缝。
说白了这就是「面向接口编程」,只不过用 npm 包边界表达。真正的价值在于它带来的连锁效应:文件系统和进程执行共用一套「执行世界」的抽象,所以把它们指向远程沙箱,Bash、终端、语言服务会一起搬过去,不需要给每个功能各写一个远程版本。
沙箱:约束力是「报告出来的」,不是假定的
不同操作系统的进程隔离能力差别很大。假定「我调用了沙箱所以现在是安全的」会在某些平台上变成危险的错觉。
沙箱接口只有一个方法:把你正要执行的那串命令交出去,换回一串替换命令,外加三项事实:
- 约束力等级:
完全还是部分。「部分」是真实存在的状态——Windows 上因为硬链接可以让一个文件从多个路径访问,被静态判定为部分;Linux 的内核级方案则按实际协商到的内核版本自行探测。 - 这个后端自己的「拒绝话术」。不同沙箱拒绝时输出的错误文本不一样,所以调用方只匹配本次实际使用的那个后端返回的话术——取所有后端的并集会声称某些后端根本产生不了的拒绝。
- 怎么区分「沙箱本身坏了」和「命令被拒了」。
聪明
最后一条来自一次真实事故:Linux 内核沙箱在只能部分约束时会打印一行提示,这行提示被误判成了「子进程执行失败」。修法是把它显式列进「信息性输出」,在判定致命错误之前先排除掉。
更普适的原则是:「基础设施坏了」必须优先于「操作被拒了」判定,因为前者意味着操作根本没有发生。这两种情况给调用方的含义天差地别。
接缝不留「万能通道」
包一个复杂协议(比如语言服务器协议)时,最省事的做法是「提供几个常用操作,再加一个通用的原始调用接口以防万一」。
语言服务接缝只暴露四个操作:跳转到定义、查找引用、跳转到实现、悬停信息。结果类型也是封闭的两种。没有原始协议调用,没有进程控制,没有文档管理。想加第五个操作,得同时改接口、所有实现和使用方,编译器会盯着你改完。
聪明
那个「以防万一」的万能通道一旦存在,接缝就名存实亡——所有人都会绕过归一化的接口直接用它,几个月后你就没法换实现了。宁可窄而封闭。
但这套抽象只兑现了一半
项目一共声明了 26 个接缝。我数了一下:其中 14 个只有一个实现,1 个在仓库内没有实现。也就是说 58% 的接缝从来没有真正「换」过——按项目自己写的规矩(「每个抽象都要有当前的所有者和需求」),这些属于投机性抽象。
真正回本的是这几个:委派子任务(6 个实现,涵盖进程内派生、进程内新建、以及调用外部的 Claude Code / Codex 命令行)、联网搜索(4 个)、模型适配 / 命令执行 / 文件系统(各 3 个)。
循环怎么转
抛开插件外壳,实际的工作循环分三层,术语很干净:
- 回合(turn)——一次「把待处理输入排空」。用户发一句话开一个回合,直到没有任何事情欠着才结束。
- 步(step)——一次模型请求,加上这次请求引发的所有工具调用。一个回合包含零到多个步(模型说「我要先看看文件」就是一步,看完再答又是一步)。
- 轮(round)——更外层的策略迭代,比如「让一个全新的 agent 再试一次」。轮数归策略所有,和回合不是一回事。
三个词各有明确所指,不混用。这看起来是小事,但在一个有子任务、后台任务、目标追踪的系统里,「这次执行算第几次」如果说不清,超时、重试、计费全都会出错。
工具并发执行,但结果按模型期望的顺序提交
模型一次可能要求调用五个工具。串行跑太慢,并行跑又会打乱结果顺序——而模型是按它列出的顺序理解这些结果的。
派发可以重叠,但提交严格按序:调度器只沿着「连续完成」的位置往前推进。第三个先跑完也得等前两个。
两个额外的讲究:每次准备启动下一个调用前重新判定它能否并发——所以运行中如果工具注册表变了,能对尚未启动的调用形成一道屏障;被中断时给没来得及执行的调用补写一条合成的错误结果,保证这段日志重放时仍然完整(每个调用都有对应的结果)。
聪明
「执行顺序」和「呈现顺序」被彻底解耦。这个模式在任何需要向外部呈现确定性顺序、内部又想并发的场合都成立。
Code Mode:把所有工具变成一份 SDK 文档
模型每调一个工具就要来回一轮:它输出调用请求 → 系统执行 → 结果塞回上下文 → 再问模型。做十件事就是十轮,token 消耗和延迟都很可观。
换一种呈现:给模型的工具列表里只有一个工具叫「运行代码」,同时在系统提示词里附上所有工具的 TypeScript(或 Python)接口声明。模型写一段程序,一次跑完全部十件事。
关键在于程序里的每个工具调用仍然回流到同一条安全管线——权限、沙箱、审批一个不少,且每次子调用都记进日志。但只有模型自己策展过的最终输出进入对话历史。
聪明
它没有为了性能开一条绕过安全检查的旁路——那是这类优化最常见的堕落方式。策略层完全复用,变的只是「呈现给模型的形态」。
取消操作有三个来源,融成一个
一次执行可能因为三种原因需要中止:用户按了停止、这个插件被卸载、整个系统在关闭。分开处理会漏掉组合情况,尤其是「初始化跑到一半时卸载」。
三个来源融进一个取消信号,而且在任何资源被创建之前就注册好,配一套「反序拆除」流程(且保证并发调用只执行一次)。
聪明
「先建资源再建清理逻辑」是泄漏的常见来源——中间那个窗口里出事就没人管了。这里把顺序反过来:清理逻辑先就位,哪怕它当时还没有东西可清理。
正交的结果各自独立上报
一个命令可以同时「超时了」并且「退出码是 0」——因为它捕获了终止信号然后正常退出。如果把「超时」这个标志嵌套在「失败」分支里报告,调用方会把一次被腰斩的运行读成干净的成功。
「是否超时」「收到什么信号」「退出码」各占一个独立字段,谁也不嵌套在谁的分支里。
聪明
这条被项目写进了一份叫「防御性模式」的清单——里面每一条都是真实发生过的 bug 类型,写成防止它复发的规则。因为条目是「挣来的」而不是头脑风暴出来的,这份清单一直很短,所以真的有人读。
工程体系
这部分和 AI 无关,但可能是整个项目最可迁移的资产。核心思路一句话:几乎每条写在协作规范里的规矩,都有一个脚本机械执行它;而这些脚本本身是有单元测试的正经代码。
生成器兼任「过期检查器」
从代码自动生成的文档(API 目录、依赖图、配置清单)总会过期。于是又要写一个检查器来验证它们是否最新——然后检查器自己和生成器慢慢漂移,出现「检查通过但文件是错的」。
不写第二个程序。检查 = 用同一个生成器加个参数,在内存里重新生成一遍,和仓库里的文件逐字节比对。
聪明
「检查」和「生成」在定义上不可能不一致。这是消除一整类 bug 的结构性做法,而不是修补。
分类型的检查必须「失败闭合」
「所有公开函数都要写文档注释」这类检查,需要遍历代码里的各种导出形式。语言一升级冒出个新语法,检查器不认识,走进 default 分支——静默放行。从这一刻起它退化成抽样,而没人会发现。
遇到不认识的构造就报错,并在错误里说明「我不认识这个,请扩展我」。
聪明
完备性检查的全部价值就是那个保证:「未被检查的东西不可能存在」。一个静默放行的分支会在你毫不知情时把这个保证抽掉。
真实日志就是测试用例
测试 AI agent 很贵:每次都真调模型,慢且不确定。于是要「录制回放」,而这通常意味着发明一套录制格式。
不发明格式。会话日志本身就是回放素材。录制 = 真跑一次,把日志文件留下。
配三条经验:无法从日志推导时要大声报错并指明补救方式,不能悄悄降级;测试结束时断言「录制内容被完整消费」(少跑几次模型调用的测试仍然会通过所有输出比对,只是记录变短了);系统提示词这类又大又常变的内容只在一个场景里逐字比对,其他场景重建后比对——否则改一次提示词会搅动上百个期望文件,评审就变成橡皮图章了。
聪明
一个 fixture 不可能描述系统实际产生不出的运行——因为它就是系统产生的。同时这份文件还兼任「人类评审时看的那份 diff」。
决策记录:1372 篇,格式由机器检查
「为什么当初这么设计」是最容易蒸发的知识。设计文档要么没人写,要么写完就过期变成误导。
每个非琐碎改动必须在同一个 PR 里附一篇决策记录。分类编码在文件路径里(状态/类别/日期-标题),两个维度都在代码里闭合——想加一个类别,得改代码。
三条硬规矩:「考虑过的替代方案」是必填章节;明令禁止建中央索引文件(那种文件必然过期);已完成的历史记录进入密码学封存的归档区,所有文档检查跳过它,并声明「归档记录永远不是当前行为的依据」。
聪明
「一个没记下它击败了什么的决策,是在邀请重新辩论」——强制填写替代方案,直接针对设计文档最没用的那种写法。
而「冻结归档、且明确它不是权威」解决了另一个问题:旧设计记录作为历史有用,作为依据有毒。分开对待,并且让这个区分可执行。
可学清单
策略钩子如果只有「拒绝/弃权」两种返回,组合就顺序无关。把这条性质写进类型而不是文档,让编译器而非评审去保证。
把散落的 ?? 默认值 提升成一次公开的转换:从「大多可选的请求」转成「全部必填的规格」,执行函数只接受后者。
并发跑,按调用方期望的顺序提交。中断时给未执行的部分补写合成结果,保证记录完整。
不要为了压缩而删历史。追加一条遮蔽记录,让「机器面」和「人类面」成为同一份数据的两种读法。
在「过度拒绝」和「静默丢数据」之间,默认选前者——后者不可见也不可逆。
「超时」「信号」「退出码」各占一个字段,绝不嵌套。否则调用方会把腰斩的运行读成干净成功。
「门锁坏了」和「你没权限」对调用方是完全不同的含义,判定顺序必须是前者优先。
加个参数在内存里重新生成、逐字节比对。永远不要为生成物单独写一个检查器。
遇到不认识的构造就报错。静默放行会在语言升级的那天把检查悄悄降级成抽样。
不发明第二套录制格式;并断言录制内容被完整消费。
其余场景重建后比对。否则一次改动搅动上百个期望文件,评审必然变成橡皮图章。
并把并发上限、重试次数这类数字所防止的失败模式写在数字旁边——否则下一个人会把它「优化」掉。
路径编码分类、禁止中央索引、归档区冻结且明确不作为当前依据。
共享基座里显式关闭并注明理由。省略的行会在某人重排配置的那天悄悄回来。
截断一份 JSON 快照会产出看起来合法、实则残缺的输入。
宁可只暴露四个封闭操作。留一个「原始调用」逃生口,几个月后你就换不动实现了。
产品形态变成数据。前提是你真的有多个形态要维护。只有一个形态时,这层间接性是纯成本。
只在确实会换实现的能力上做。这个项目 26 个接缝里 15 个从未换过——按它自己的规矩,那些是投机性抽象。
前后端各自生成校验器,网络上不传类型信息。收益大,但会把构建顺序钉死成不可重排的固定序列。
值得抄的是「模型看到的必须等于日志重放出来的」这一条断言本身;不值得抄的是给两百个包各配一个断言槽位。
比仓库级百分比好(大文件无法补贴小文件),但只有在你愿意为每条排除项写理由时才成立。
有 17 个包源码不足 100 行。「人格设定」整个包的运行时是一次函数调用;「工具呈现模式」是 6 行。给会话起名字这件事用掉了 4 个包 1408 行,其中两个除了一个匿名函数完全逐字节相同。
1078 个校验用的边车文件,每篇文档改动要动三个文件,而检查器自己承认「无法判断两侧是否真的在说同一件事」。
185 个文件被标记为「重复检测请忽略」——为了满足所有权检查而被迫复制的代码,反过来要求把重复检测关掉。这是个自相矛盾的信号。
代价与弱点
下面每条我都自己跑命令验证过。这些不是「架构不好」,而是这套架构真实的账单,以及几处文档已经和代码对不上的地方。
那套漂亮的运行时断言,默认不在产品里跑
219 个包每一个都有断言模块的槽位。但其中 184 个(84%)是空的——只带一句「本包无运行时不变量,因为……」的说明;只有 35 个真的写了检查。
更关键的是:我在所有出厂配置里都没找到这套断言系统被装载。它只在单元测试(通过一个巧妙但脆弱的框架级注入自动挂载)和一个演示程序(只挂 4 个)里运行。这是测试期机制,不是生产期防线。
那个「强制每个包都有槽位」的检查,真实收益是「逼每个包想一遍有没有值得断言的东西」,而不是两百道防线。
那张旗舰关系图不是真的从代码生成的
项目里有一份「能力接缝全景图」,顶着「由脚本生成,请勿手改」的横幅。但它的内容来自生成脚本里一个手写的 56 条数据数组。唯一的机器校验只比对「服务名字的集合」对不对得上,至于「谁实现了它、谁在用它」这些内容从不校验。
实测漂移:这张表引用的 130 个包名里,有 9 个根本不存在(有的是改名后没跟上,有的凭空捏造)。逐字节比对的「过期检查」看不见这些——因为数据本身就写在脚本里。
公平地说,文档末尾确实注明了「服务从代码发现、角色在脚本里人工分类」。它没撒谎,但「生成即可证为真」的推论对这份文件不成立。
浏览器那一侧的后端是个不可扩展的巨石
有一个 3744 行的手写文件——全仓库最大的运行时文件,直接依赖 27 个内部包,其中一个函数返回的对象字面量就有约 1700 行方法。
这里没有注册机制:任何能力想让浏览器能调用它,都必须去编辑这个函数。这和项目其余部分「一切通过注册」的模式正好相反,而且它恰好也在覆盖率检查的排除名单里。
存在包依赖环,而自动生成的依赖图按构造看不见它
实测存在一个四个包的循环依赖。生成依赖图的脚本只读一种依赖声明(并在文档里称其为「运行时依赖的权威信号」),于是 1089 条边入图,另外 205 条边完全不入图——而闭合这个环的两条边正在这 205 条里。整套代码健康检查里也没有环检测。
「每个文件 100% 覆盖率」的分母是精心策划的
排除名单让约 23% 的代码行不进入统计,其中包括整个「让 AI 修改自身运行时」的子系统(1.5 万行)和上面那个 3744 行的巨石。
更说明问题的是一处细节:一条解释性注释挂在一个路径根本不存在的排除项上——包组改名时新增了排除行,注释却留在了旧路径旁边。所以两个最大的实际排除项,看上去是「没写理由」的。
三处文档已经和代码对不上
- 仓库结构图(每个协作者开工必读的那份)列着一个不存在的目录名,而真实存在的那个包组在图里根本没出现。同一个错误名字还被写进了测试配置,成了一条永远不会命中的排除项。
- 「发布前特殊政策」章节写着「首次打标签发布时删除本节」和「目前没有外部使用者」。但仓库已经有了发布标签,且 219 个包全部配置为公开发布。这个已经过期的章节仍在指挥每个人的兼容性决策。
- 一条被声称由 Git 钩子守住的格式规矩(文件末尾恰好一个换行),我实测那个钩子并不检查这种情况。当前代码库确实没有违例——靠的是纪律,不是那道检查。
「配置错误要大声失败」有个例外
规范写着「配置错误要在加载时大声失败,绝不静默跳过找不到的引用」。但配置叠加的实现对四类「找不到目标」都是打个警告然后跳过,而负责收集警告的参数默认是个空函数,命令行启动时也没传。
结果:用户配置里打错一个 id 就是静默的空操作——正是那次「文件系统插件被永久关闭」事故的同一类失败。(当前出厂配置里没有悬空目标,所以这是潜在问题而非现存问题。)
词汇祛魅
公平起见,有两个听起来很有创意的词其实是常规做法:
「waterfall」不是这个项目发明的,是它所用框架自带的分发模式,语义就是标准的洋葱式中间件。「capability seam」读下来就是「端口与适配器」模式,只不过用包边界表达,而且我在所有检查脚本里找不到任何一个强制这个三件套结构——它是被命名和绘图的约定,不是被机器强制的机制。
真正非常规的,是它旁边那个「类型上单调的守卫」,以及一套精巧的「作用域事件路由」机制。
还有一处纯量级的账单:检查脚本本身约 28,600 行代码,它自己就是有 bug 的软件;协作规范正文有 1900 词的常驻篇幅预算;一次常规改动可能要同时动决策记录、中文对照、配对校验文件、包说明文档、类型粘贴块和一份重新生成的目录。
可信度说明
这份报告的结论来自三种证据,可信度不同:
| 来源 | 可信度 | 覆盖了什么 |
|---|---|---|
| 我自己完整读了实现文件 | 高 | 主循环三个文件、会话日志的投影机制、作用域机制、工具注册表的事件与类型、命令执行接缝、断言系统与它的强制脚本、各种配置文件、框架的本地修改清单、四篇事故复盘、文档规范与决策记录规范 |
| 并行的精读子任务 | 中 | 框架内核与加载器、模型接缝、文件系统、沙箱、子任务编排、提示词装配与压缩、类型化 RPC、组合与自修改、工程体系。我核对了关键论断,未逐条复核所有引用 |
| 对抗性审查后我另行验证 | 高 | 第 10 节的每一条都是这样得来的:由审查提出,我自己跑命令确认属实 |
覆盖范围。第一轮 11 个精读任务中有 7 个因网络中断丢失,拆成更小范围重跑后 11/11 全部完成。累计覆盖 15 个子系统。
两处我没有下结论:子任务的「持续会话」机制(一个 1483 行的文件,精读时明确跳过了实现体);以及 Web 前端那 7 万行代码(只读了架构文档)。报告里凡涉及这两处的说法都很克制。
已知的报告缺陷:一个子任务给出的行号是错位的(它引用的行号超出了那个文件的实际长度),机制描述正确但引用不可信;另一个把决策记录的「总文件数」当成了「活跃数」。这类计数我在正文里都重新数过。
代码索引
如果你之后要打开代码对照,这里是本文各节对应的位置。展开查看。
核心循环与会话日志
packages/core/agent-loop/src/——index.ts(生命周期与三源取消融合)、agent.ts(回合/步驱动,第 341 行是「模型历史唯一来源」那处调用)、tool-calls.ts(并发调度与按序提交)、invariant.ts(「模型可见 ⟺ 已记录」断言)。
packages/core/session/src/surface.ts——追加/遮蔽两种投影。types.ts 第 405-422 行是「读到不认识的记录就拒绝」那条契约。
类型即法律的三处
单调守卫:packages/core/tools/src/index.ts 第 703-711 行(类型定义与那段解释性注释)、第 1117-1128 行(组合逻辑)。
请求/规格分离:packages/shell/shell/src/index.ts 第 84-99 行。
类型化出口白名单:packages/api/remotes/src/remote-events.ts。
包结构 AST 检查:scripts/package-invariants.ts。
插件框架(vendored)
vendor/cordis/src/——reflect.ts(服务解析:只沿「上级」方向查找,这解释了那次导出事故的第二个 bug)、fiber.ts(生命周期、退票机制、就绪状态)、events.ts(四种分发模式)。
vendor/loader/src/ 与 vendor/include/src/——配置到插件树、补丁算法、!!js 表达式求值范围。
vendor/README.md——18 条编号的本地修改,其中若干条是对上游框架生命周期/重入问题的真实修复。
接缝与执行世界
docs/capability-seams.md——全部 26 个接缝的表格(注意第 10 节指出的:这张表的数据是手写的)。
packages/sandbox/、packages/fs/、packages/shell/、packages/lsp/——分别是沙箱、文件系统、命令执行、语言服务。
工程体系
scripts/——129 个脚本,run-gates.ts 是调度器,verify-* 是检查,gen-* 是生成器(加 --check 即变成过期检查)。
.agents/notes/——1372 篇决策记录及其规范(README.md)。
docs/postmortem/——四篇事故复盘,其中 0001(默认导出)和 0002(配置表达式)是本文多次引用的那两次。
docs/defensive-patterns.md——那份「每条都是真实 bug」的清单。