跳转到内容

代码智能

Control Center 让智能体有四种在阅读之外理解代码的方式。它们回答的是真正不同的问题,而之所以是四种而不是一种,是因为没有任何单一机制能回答所有这些问题。

机制 回答的问题 代价
代码图 这里有什么,以及什么依赖于它 一次索引;查询开销低
语言服务器 这段代码真的能编译吗,这个类型是什么 每个项目一个服务器进程
结构化搜索 这种形状出现在哪里 每个文件一次解析
调试器 在这一刻、这个栈帧里,什么是成立的 一个被暂停的进程

代码图由 tree-sitter 构建:符号、调用边、影响半径。它快、开销低,但它对类型一无所知。它无法告诉你刚写下的调用能否编译、那个字段是否存在于该对象上,或者你的重命名是否漏掉了一处再导出。

没有语言服务器,智能体要到跑测试时才发现问题——或者永远发现不了。它写下看起来合理的东西,继续往下做,而失败会在三轮之后冒出来,还挂在另一处改动上。

所以真正的收益其实不在于 lsp 工具本身,而在于 write、edit 和 apply_patch 都被包了一层:每次写入之后,文件都会经过语言服务器,新引入的诊断会被并入工具结果。智能体在弄坏构建的那一刻就知道自己弄坏了,无论它有没有想到要去问。

重复智能体已经被告知过的内容,比什么都不说更糟:它会让结果里塞满噪声,智能体学会一眼略过,而那条真正的新错误也随之被略过。每一次运行都有一份台账记录已经报告过什么,以严重级别、代码和消息为键——而不是以位置为键,因此仅仅向下移动了四行的诊断不会被当作新增重新报告。

启动时不会拉起任何服务器。服务器在第一个需要它的请求到来时才启动,而这几乎总是对它名下的某个文件的第一次编辑,空闲的服务器则会被清扫掉。从不接触 Dart 的主机永远不会启动分析器。

检测是一个交集:检出目录里必须存在根标记,并且该二进制文件必须能被解析到。只有标记,只能说明“这个项目是 Python”,说明不了服务器是否已安装;只有可解析的二进制文件,只能说明主机上有工具链,说明不了这个检出。只有两者同时成立,查询才可能成功,而一个总是失败的工具比一个根本不存在的工具更糟。

grep 匹配的是字符。找名称时它是对的工具,找形状时它是错的那个。

结构化模式由与该文件相同的语法解析,因此 dispose($X) 无论怎么加空格、怎么换行或包裹都能找到这个调用,也绝不会匹配字符串字面量或注释里出现的同样字符。

让它不只是“多绕几步的正则”的特性在于:同一个模式里重复出现的元变量必须捕获相同的文本:

if ($X != null) $X.dispose()

它会匹配“销毁自己刚刚检查过的那个对象”的守卫,而不会匹配销毁别的东西的守卫。这是纯文本手段根本无法表达的一类 bug。

没有第二个引擎。cc_natives 已经为代码图加载了 tree-sitter 及其语法,还带有编译查询缓存和语言表。匹配器是纯 Dart 实现的,作用于本来就要被解析的那些语法树。再造一个结构化引擎,就意味着第二个解析器、第二套需要在每个平台上构建和部署的语法,以及对“什么是一个调用表达式”的两个不同答案。

语法只定义了整个文件是什么。把 dispose(x) 当作 Dart 顶层内容裸着解析时,它是一个完全合法的函数签名——一个名为 dispose、接受参数 x 的声明——解析过程中不会有任何报错来提醒你不对劲。基于这种解析构建的匹配器会去找声明,一个调用也找不到,于是对一个从未按它表面样子被解读的模式报告“无匹配”。

因此,模式会被逐个放进一整套包裹上下文中尝试——语句、表达式、类成员、顶层——第一个接受它的就被采用。没有任何上下文接受的片段会被报告为无法解析,而不是被搜上一圈、然后悄无声息地查无结果。

结构化重写是智能体能做的最“宽”的编辑:一个模式、四十个文件,而没有人会逐行读结果——模型不会,提出要求的人也不会。把它写下去然后报告“完成”,意味着对这次改动的第一次认真审视要等到事后看 git diff。

所以 ast_edit 报告的是它将会做什么——数量、文件、匹配到的位置——只有 resolve 提交之后,改动才真正落地。这也正是为什么在有任何内容到达磁盘之前,会先有一个 diff 和一个“接受/丢弃”摆在人面前。

提交是全有或全无的,每个文件都会与暂存时捕获的内容比对。在暂存和提交之间,智能体可能手工改过其中一个文件、一次诊断处理可能重写过它,或者格式化工具可能碰过它——照旧提交会丢掉那些工作,而 diff 看起来却像是有意为之。任何不一致都会拒绝整个改动,因为只应用了一半的结构化重写会留下一棵在两种形状下都编译不过的树。

调试器能回答哪些打印语句回答不了的问题

标题为“调试器能回答哪些打印语句回答不了的问题”的部分

打印语句的循环是:加一行打印,把整个东西再跑一遍,读输出,删掉打印。每问一个问题都要付出跑一整轮测试的代价,而且它只能回答你在运行之前就想到要问的那个问题。

一个被暂停的栈帧能一次回答关于那一刻的所有问题——每个局部变量、每个调用者,以及在该栈帧自身作用域里求值的表达式。对于一个卡死的进程,或者每十次运行才复现一次的失败,这就是耗掉一下午和花一分钟的区别。

调试适配器拥有一个被暂停的进程,它占着那个进程所占有的一切——一个端口、一把锁、一个数据库连接。无人操控的会话就是一种泄漏,会比启动它的那一轮活得更久。所以这里有硬性 TTL、每个对话恰好一个会话,第二次 launch 会被拒绝,而不是悄悄替换掉第一个:一个没结束第一个会话就启动第二个的智能体,几乎可以肯定已经跟丢了第一个。

Debug Adapter Protocol 与 Language Server Protocol 共用 Content-Length 分帧,除此之外再无共同之处——不同的关联键、不同的失败形态,还有第三种在 LSP 里没有对应物的消息类型(事件)。分帧只存在于一处;两种协议各自架在它之上。

把分帧写两遍,就意味着对“一条消息跨了两个数据块”“一个数据块里装了三消息”和“服务器在第一帧之前往 stdout 写了点东西”各有两套答案,而其中只有一套会被修。