AST:从 Java 代码理解抽象语法树

AST(Abstract Syntax Tree,抽象语法树)把代码中的变量声明、运算、方法调用和条件分支表示成节点,用节点之间的关系记录它们怎样组成程序。编译器、静态检查器和重构工具都可以在这套结构上工作。

比如,要把方法名 sendMail 改成 enqueueMail,搜索替换能找到所有同名文字,但下面这段方法体片段中的三处需要分别判断:

▼
java
复制代码
String message = "sendMail"; client.sendMail(user); sendMail(user);

第一处是字符串内容,后两处是方法调用:一个写出了接收者 client,另一个没有显式接收者。语法树会记录这些区别。要确认哪一处调用需要改名,还得通过符号和类型解析,找到它对应的方法声明。

下面用 Java 展开介绍,节点命名和可运行示例采用 JavaParser。

从一行声明看 AST

先看一个 Java 局部变量声明:

▼
java
复制代码
int total = price + count * 2;

虽然 + 先出现在文本里,count * 2 却是加法的右操作数。这个分组关系在简化的 AST 中是这样的:

▼
mermaid
复制代码
flowchart TD A["VariableDeclarator:变量声明项"] -->|type| TY["PrimitiveType:int"] A -->|name| T["SimpleName:total"] A -->|initializer| P["BinaryExpr:加法"] P -->|left| X["NameExpr:price"] P -->|right| M["BinaryExpr:乘法"] M -->|left| C["NameExpr:count"] M -->|right| N["IntegerLiteralExpr:2"]

图中省略了类、方法和语句等外层结构,以及源码位置信息。运算符被合并进运算节点的标签;在 JavaParser 中,它们由 BinaryExpr.Operator 枚举表示。

VariableDeclarator 记录声明的类型、名字和初始化表达式。加法和乘法节点都有 left、right 两个操作数字段。price、count 是名字表达式,2 是整数字面量。声明里的 total 则由 SimpleName 保存,和表达式中对名字的使用有所区别。

左右子节点的位置决定了操作数顺序,把乘法子树移到别处则会改变分组。分析和改写代码时,需要遵守这些字段的约定。

可以直接使用 JavaParser 观察这些对象。下面两个完整示例使用 JDK 17 及以上版本和 JavaParser 3.28.2。在 Maven 项目的 <dependencies> 中加入:

▼
xml
复制代码
<dependency> <groupId>com.github.javaparser</groupId> <artifactId>javaparser-core</artifactId> <version>3.28.2</version> </dependency>

javaparser-core 负责解析源码、遍历和修改语法树。JavaParser 项目说明。

第一个示例只解析声明右侧的表达式,保存为 InspectAst.java:

▼
java
复制代码
import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.expr.BinaryExpr; public class InspectAst { public static void main(String[] args) { BinaryExpr expression = StaticJavaParser .parseExpression("price + count * 2") .asBinaryExpr(); System.out.println(expression.getClass().getSimpleName()); System.out.println(expression.getOperator().name()); System.out.println(expression.getLeft()); System.out.println(expression.getRight() .asBinaryExpr().getOperator().name()); } }

输出依次是 BinaryExpr、PLUS、price、MULTIPLY。

这一步只构建表达式节点,所以 price 和 count 即使尚未声明,也能被解析。它们是否已定义、这段表达式在当前 Java 类中是否合法,要在后续结合上下文检查。

从字符到语法树

从源码到 AST,通常要经过词法分析和语法分析。

词法分析先把字符识别成 Token,也就是有类别的词法单元。上面那行代码的主要 Token 可以写成:

▼
text
复制代码
KEYWORD(int) NAME(total) OP(=) NAME(price) OP(+) NAME(count) OP(*) INTEGER_LITERAL(2) SEMICOLON(;)

这些标签用于示意,不对应某个解析器的完整 Token 定义。Token 列出了名字、数字和运算符,还没有表达它们之间的操作数关系。

语法分析器接着依据语言的语法规则组织这些 Token,构建赋值、运算、调用等节点。运算符优先级和结合性会在这个过程中影响树的形状。

例如,一个简化的算术语法可以写成:

▼
text
复制代码
expression := term (("+" | "-") term)* term := primary (("*" | "/") primary)* primary := NUMBER | NAME | "(" expression ")"

expression 处理加减,term 处理乘除,primary 处理数字、名字和括号中的表达式。解析 price + count * 2 时,右侧的 term 会先把 count * 2 组织成一个整体,再作为加法的右子树。

遇到括号时,解析器会递归解析其中的表达式,这样就能逐层处理嵌套。LLVM 的教学语言 Kaleidoscope 就结合了递归下降和运算符优先级解析来构建 AST,具体过程见 LLVM 的解析器教程。

结合性也会影响树的形状。Java 中的 a - b - c 对应 (a - b) - c,这个分组会在解析时确定下来。

有些解析器直接构建 AST,有些先生成解析树或具体语法树,再做转换。生成完整的具体语法树,并不是构建 AST 的必经步骤。

「抽象」抽掉了什么?

AST 通常会省略后续分析用不到的书写细节,保留它们表达的语法关系。price + count * 2 中的优先级,已经落实为乘法子树与加法节点的父子关系。

如果改成 (price + count) * 2,树就会变化:根部变成乘法,加法成为它的左子树。括号字符可以不保留,但括号对结构造成的影响必须保留。

这种省略发生在识别语法之后。Java 的分号、大括号和括号会影响语句如何结束、代码块的范围,以及表达式怎样分组。解析器先根据这些符号确定结构,再用节点和字段保存结果。

AST 的「抽象程度」没有统一标准。JavaParser 会用 EnclosedExpr 保留显式括号,javac 的公开 Tree API 也提供了 ParenthesizedTree。所以 price + count * 2 和 price + (count * 2) 虽然运算分组相同,在这些树中却可以有不同结构。JDK 的 AST 接口定义。

要尽量原样保留空白和注释,可以考虑具体语法树 CST 或其他无损语法表示,也可以把 AST 与原始 Token、文本范围结合起来。JavaParser 的 LexicalPreservingPrinter 会随树的修改调整词法布局,尽量保留原来的排版。JavaParser 的打印方式说明。

不同工具也有各自的 AST 格式。JavaParser 的 BinaryExpr 和 javac 的 BinaryTree 都能表示二元表达式,但节点接口、子字段和附加信息不同。接入工具时,需要确认这些约定是否兼容。

这些表示关注的问题不同:

表示主要关注的问题
源码文本原文写了哪些字符?
Token 序列字符组成了哪些词法单元?
CST/具体语法树代码按具体语法怎样组成?保留哪些细节取决于实现。
AST/抽象语法树有哪些语法构造?它们怎样嵌套和组合?
CFG/控制流图执行可能沿哪些路径前进、分支或循环?
面向优化或代码生成的 IR如何用适合后续处理的操作和关系表示程序?

IR 泛指「中间表示」,AST 本身也可以看作一种高层 IR。编译器常把它继续转换成更适合后续分析、优化或代码生成的表示。

名字、绑定和类型

NameExpr 记录了 price 这个名字。要知道它引用哪个声明、是什么类型、使用前是否已经赋值,还需要名称解析、类型分析或数据流分析。

先看一个同名变量的例子:

▼
java
复制代码
class Pricing { double discount = 0.8; double discountedPrice(double price, double discount) { return price * discount; } double defaultDiscount() { return discount; } }

假设要把字段 discount 重命名为 defaultRate。discountedPrice 的同名参数不应跟着改名,defaultDiscount 中对字段的引用则应当修改。

AST 会记录这些名字的位置,以及它们与类、方法和参数声明的嵌套关系。工具还要按照 Java 的作用域规则建立符号表,把引用关联到对应的声明,这种关联通常称为绑定。重命名应针对某个绑定,修改属于它的声明和引用;只比较名字节点里的字符串,仍可能误改同名变量。

Java 的加号可以用于数值相加,也可以用于字符串拼接;int 和 long 参与运算时还涉及数值提升。解析出 left + right 之后,要判断运算是否合法、结果是什么类型,就得继续分析操作数。

在 javac 中,JavacTask.parse() 提供语法树,analyze() 完成分析。之后可以通过 Trees.getElement(...) 查询节点对应的声明元素,通过 getTypeMirror(...) 查询类型信息。JavacTask 文档、Trees 文档。

JavaParser 也区分源码解析和符号求解。本文使用的 javaparser-core 负责解析和操作树;要解析声明和类型,可以配置 JavaSymbolSolver,并提供所需的源码与依赖信息。JavaParser 对符号求解器的说明。

一些编译器会把名称解析结果和类型信息附加到 AST 上,形成带语义信息的 AST;另一些会把这些结果放进单独的表或中间表示里。此时,除了语法树的父子关系,还会出现指向声明、类型等对象的关联。

BinaryExpr 这样的类名表示语法节点种类,表达式值的 int、String 则是另一层类型。声明节点中保存了 int 这种类型写法,也不代表整个程序已经通过类型检查。

遍历与改写

遍历 AST 时,可以按节点种类处理:访问方法节点,收集声明;访问调用节点,检查调用形式;访问名字节点,记录引用。许多工具使用 Visitor,也就是访问者,把这些逻辑写在不同的访问方法里。

分析词法作用域时,可以在进入作用域时建立环境,离开时恢复环境;简化算术表达式时,通常要先处理子表达式,再处理父表达式。这后一种顺序叫后序遍历。

遍历树的顺序也要与程序的执行路径区分开。静态分析可能会访问一个 if 的两个分支,而某次实际运行只会进入其中一个分支。

下面写一个完整的小变换:把指定范围内的整数常量加法和乘法,折叠为一个常量。使用前面相同的 JavaParser 依赖,保存为 FoldConstants.java:

▼
java
复制代码
import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.IntegerLiteralExpr; import com.github.javaparser.ast.visitor.ModifierVisitor; import com.github.javaparser.ast.visitor.Visitable; public class FoldConstants extends ModifierVisitor<Void> { @Override public Visitable visit(BinaryExpr node, Void arg) { super.visit(node, arg); // 先处理左右子树 BinaryExpr.Operator op = node.getOperator(); if (op != BinaryExpr.Operator.PLUS && op != BinaryExpr.Operator.MULTIPLY) { return node; } Integer left = decimalInt(node.getLeft()); Integer right = decimalInt(node.getRight()); if (left == null || right == null) { return node; } long value = op == BinaryExpr.Operator.PLUS ? (long) left + right : (long) left * right; if (value > Integer.MAX_VALUE) { return node; } return new IntegerLiteralExpr(Long.toString(value)); } private static Integer decimalInt(Expression expression) { if (!expression.isIntegerLiteralExpr()) { return null; } String text = expression.asIntegerLiteralExpr().getValue(); if (!text.matches("0|[1-9][0-9]*")) { return null; } try { return Integer.valueOf(text); } catch (NumberFormatException ignored) { return null; } } public static void main(String[] args) { Expression expression = StaticJavaParser .parseExpression("2 + 3 * 4"); Expression folded = (Expression) expression.accept( new FoldConstants(), null); System.out.println(folded); } }

输出是:

▼
text
复制代码
14

super.visit(node, arg) 先处理子节点,因此内层的 3 * 4 会先变成 12,然后外层的 2 + 12 才变成 14。ModifierVisitor 使用访问方法的返回值替换对应节点;返回原节点,就保留当前结构。参见 JavaParser 的 ModifierVisitor 实现。

这里特意只识别不带前导零的非负十进制 int 字面量,并要求结果仍在非负 int 范围内。遇到十六进制、八进制、带下划线的字面量,或者 long、浮点数、负数及溢出情况时,对应的运算节点不会被折叠;它的子表达式仍可能先被简化。像 010 这样的 Java 字面量采用八进制,不能直接按十进制读取。

Java 的整数加乘法有明确的溢出规则。示例用 long 暂存两个非负 int 的计算结果,再检查范围,避免把数学整数运算的结果错误地写回 Java 表达式。乘法规则、加法规则。

这段变换只识别两种节点组合,然后用新的常量节点替换它们。它不需要进行符号求解,也不需要执行整个输入程序。

为什么不顺手把所有 0 * expression 都替换成 0?看看这个方法体片段,其中 getValue() 返回 int:

▼
java
复制代码
int result = 0 * getValue();

getValue() 仍然会被调用,它可能产生输出,也可能抛出异常。直接把整个表达式改成 0,会连这次调用一起删掉,改变程序行为。

实际优化器还要把常量大小、计算成本和其他语言语义纳入变换规则。

哪些场景值得使用 AST?

编译器与解释器

Java 中的类、方法、变量声明、表达式和控制结构,在 AST 中都有对应的节点。编译器的名称解析和类型检查可以从这些节点出发。

之后,编译器可以把 AST 转换成其他表示,再生成目标代码。以 Java 为例,javac 会把 .java 源文件编译成 .class 文件中的字节码,随后由 JVM 解释或编译执行。参见 javac 命令文档。

如果你用 Java 编写一个小型表达式解释器,也可以直接递归求值:遇到常量就返回常量值,遇到运算节点就按语言规则计算操作数,遇到名字就查找运行环境。使用 AST 并不要求采用某一种执行方式。

静态检查与代码规范

限制某种语法或调用形式的检查规则,可以直接读取 AST。

例如,在 JavaParser 中,可以寻找 MethodCallExpr,读取方法名、接收者和实参列表,再检查调用形式是否符合要求。无论调用写成一行还是多行,参数里是否又有嵌套调用,规则都可以读取相同的节点字段。

规则也可以访问 CatchClause,检查捕获异常后是否什么都没做;或者访问方法声明,检查相应的语法约定。

要检查「字段有没有使用」「调用是否关联到某个类的方法」,还得结合绑定、类型和依赖信息。要判断某条路径是否忘记关闭资源,则需要分析执行路径。

自动化重构与 API 迁移

假设要把一千个文件里的旧 API 调用迁移到新 API,改动可能涉及调整参数顺序、把多个参数封装进请求对象,或者只修改某个库中特定方法的调用。这些改动都可以从调用节点着手:先识别目标调用,确认其绑定,再构造新节点。

例如,把 client.send(user, message) 改成 client.send(new MailRequest(user, message)) 时,工具可以构造一个对象创建节点,将原来的两个实参作为它的子节点,再把新对象作为调用实参。原有实参本身仍然是完整的子树,里面即使包含方法调用、条件表达式或多层括号,也不需要重新靠字符串寻找参数边界。

这类源码变换常称为 codemod。工程上的难点除了匹配目标,还包括注释如何保留、修改范围如何控制,以及生成代码后怎样验证行为。对于需要保留原有排版的迁移,可以选用无损语法树,或者结合节点位置做局部文本修改。

代码生成与格式化

从树生成源码时,需要根据节点结构添加必要的括号、输出实参列表,并安排缩进。

例如,乘法节点的左操作数如果是加法表达式,输出时就需要加括号。否则,原本表示 (a + b) * c 的树,可能被错误地打印成 a + b * c。

构造 JavaParser 的新树时,可以用 EnclosedExpr 表达必要的显式括号,再通过 toString() 生成源码文本。要尽量保留原始排版,可以结合 LexicalPreservingPrinter。JavaParser 的打印方式说明。

专门的格式化工具还会考虑缩进、行宽和断行。Java 生态中的 google-java-format 就利用 javac 解析源码,再按格式规则输出。google-java-format 的项目说明。

重新生成源码时,空白、注释和字面量的原始写法都可能变化。选择工具时,要区分「重新排版」和「尽量保留原文」这两种需求。

表达式、查询与文档工具

规则表达式、查询语句和文档,也可以解析成 AST。

假设业务规则采用类似 Java 的表达式 amount > 100 && level >= 3,解析器可以构建比较和逻辑运算节点。规则引擎随后根据这些节点求值,或者把它们转换成另一种规则表示。引擎还需要规定哪些节点允许执行,以及每类节点怎样求值。

SQL 工具可以利用语法结构识别表、查询条件和表达式,再继续做名称解析、校验或转换。数据库优化器通常还会把查询转换成逻辑计划、物理计划等表示,语法树只是处理过程的一部分。

Markdown 文档也可以形成 AST。目录生成器可以读取标题节点,链接检查器可以读取链接节点,转换器可以把段落、列表和代码块映射成 HTML。mdast 就定义了这类 Markdown 节点,其中标题节点具有 depth 字段,代码块与普通段落也有各自的结构。参见 mdast 规范。

为什么还需要控制流分析?

判断变量使用前是否赋值,需要知道执行经过了哪些语句。看看这段 Java 方法:

▼
java
复制代码
static int choosePrice(boolean flag) { int value; if (flag) { value = 10; } return value; }

JavaParser 能解析这段代码:方法体包含局部变量声明、IfStmt 和 ReturnStmt,赋值语句位于 IfStmt 的分支里。

但要判断 return value; 是否总能读取到已赋值的局部变量,不能只确认树中存在一条赋值语句。还得检查执行能否绕过它。

下面是这个方法的控制流示意:

▼
mermaid
复制代码
flowchart TD E["方法入口:声明 value"] --> Q{"flag"} Q -->|True| A["value = 10"] Q -->|False| R["return value"] A --> R R --> X["方法出口"]

flag 为假时,控制流可以直接到达返回语句,而 value 尚未赋值。因此,这段 Java 代码无法通过确定赋值检查,javac 会在编译时报错。参见 Java 语言规范的确定赋值规则。

控制流图 CFG 通过边显式表示这些可能的执行转移。实际的 CFG 常以基本块组织语句;上图为了便于说明,只画出了主要操作。

有了 CFG,分析器可以沿边传播「哪些变量确定已赋值」等状态,在分支合流处合并信息。循环又会带来回边:本轮末尾的状态可能影响下一轮开头,因此常需要反复传播,直到分析结果稳定。Clang 的数据流分析介绍对这一过程给出了具体解释。参见 Clang 的数据流分析文档。

在 Java 工具中,分析资源是否在所有相关路径上关闭、锁是否可能遗漏释放,也有类似需求:需要跟踪操作发生的条件,以及分支与循环对状态的影响。

工具可以直接沿 AST 做结构化分析,也可以先构建 CFG,再开展数据流分析。

为什么值得付出解析成本?

解析源码、构建和维护节点,都要花时间和内存。对于依赖程序结构的任务,这些成本通常能换来更直接的规则写法。

增加调用实参,可以操作节点列表;收集方法声明,可以访问一类节点。规则不必反复排除字符串里恰好出现的同名文字,也不必自己寻找每一层括号的边界。

空白、换行、运算符优先级、嵌套表达式等语法细节,由解析器集中处理。后续规则可以共享解析结果,不必各写一套识别逻辑,以免同一段代码在不同规则里被理解成不同结构。

同一棵树还可以供多个工具使用:检查器读取节点,重构工具修改节点,代码生成器输出源码,编辑器则可以结合源码位置展示诊断。各项任务仍要处理自己的语义要求,但语法结构可以复用。

这不意味着 AST 在任何情况下都更快。替换一个约定明确的固定标记,直接处理文本通常更省事。对大量文件做多轮语义分析时,还要考虑解析开销、内存占用、缓存和增量更新。

可以根据问题需要的信息来选择起点:

具体任务通常需要的表示或信息
替换固定的配置占位符文本处理即可
查找某种调用形式、调整实参结构AST
重命名一个变量及其引用AST,加上作用域和绑定信息
原样保留排版,只做少量语法修改无损 CST,或结合 Token、源码范围的修改机制
判断变量是否在所有路径上已赋值控制流与数据流分析,可基于 AST 或 CFG 实现
判断调用关联到哪个重载、表达式是否满足类型约束符号与类型分析

做一次具体的改造时,可以先选几段真实代码,观察解析结果,再确定目标节点、需要补充的语义信息,以及哪些写法应该保持原样。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
leikooo
作者分享
ARTS 1025: 唯一字符与同构字符串、省词可能增加阅读负担与重新估算软件的实现成本
5
🎉 1024 程序员节快乐! 祝编程导航里的每一位程序员:offer 多多,薪资翻倍,面试顺利,早日拿下心仪 offer!💻
17
原来维基百科还有专门研究「AI 味」的页面 今天偶然发现一个挺有意思的维基百科页面:https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing,专门整理 AI 写作的典型特征。 里面总结了很多我们平时经常看到的 AI 味,比如喜欢用“值得注意的是”“在当今快速发展的时代”“综上所述”,还有过度使用排比、加粗、列表,以及一些看起来很有道理,实际上没什么信息量的表达。 感觉以后可以定期翻翻这个 wiki,尤其是经常使用 AI 辅助写作的人,应该能从中发现不少所谓的 AI 味。 链接: https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing
5
ARTS 1018: 字符计数判断异位词、局部做对仍会增加维护负担与用 AI 也要理解自己在做什么
4
AI 越强,被它拒绝的代价就越大 我有时候会想:AI 越来越厉害之后,有些事情,会不会反而越来越难做? 这个想法看起来有点矛盾。AI 明明在降低门槛,让不会写代码的人做出软件,让不会画画的人生成图片,让一个人完成过去需要整支团队才能完成的工作。但今天用 Codex 的时候,AI 拒绝帮我编写 SQL 注入的请求体,而那还是我有源码、在本地跑测试的情况。那么,往后是否只会更难? 我们假设某一天,AI 说:「这个请求违规,我不能帮助你。」到那个时候,你还有多少能力,能绕过它,把事情做完? 这里的「违规」需要打个引号。平台的规则、模型的判断、法律的要求,以及一件事实际有没有造成伤害,彼此之间并不总是画得上等号。一个请求可能确实危险,也可能只是缺少上下文被误判,或者恰好落在某家公司的限制范围里。 无论是哪种情况,随着我们越来越依赖 AI,这句拒绝都可能变得越来越贵。 今天,一个程序员让 AI 帮忙写代码,AI 拒绝了,他大概率还有别的办法:查文档、找开源项目、问同行,或者自己写。麻烦一点,慢一点,但路还在。 可如果十年之后,大部分程序都由 AI 完成,情况就不一样了。 很多人从接触编程的第一天起,就习惯了描述需求、等待生成、运行结果、继续迭代。他们能做出相当复杂的产品,却未必经历过独立处理底层问题的训练。平时,这种能力结构完全够用,效率甚至更高。只有 AI 拒绝的时候,他们才会发现:平常被省掉的那段学习过程,恰好是此刻最需要的东西。 到那时,一次拒绝的成本,可能就从「今晚多花两个小时」,变成「先花半年学会这门技术」。 拿 SQL 注入举个稍显极端的例子:往后,连真正理解这类攻击如何发生、能够独立构造测试的人,会不会也越来越少? 这里也包括那些有正当需求的人。一个开发者想检查自己的旧系统,一个经过授权的安全研究员想复现漏洞,假如 AI 识别到危险模式就拒绝,而使用者又只熟悉「让 AI 帮我检查」,这项原本可以独立完成的工作,就这样卡住了。 但是安全专家不会因此消失,但这类技能可能越来越集中在少数人手里。你仍然可以找专家,只是要付出更多的钱、更多的时间,以及更高的沟通成本。 类似的困境,未必只发生在网络安全领域。 你想从一个停止维护的软件里取回自己的数据,需要解析它的旧文件格式。AI 判断这涉及规避保护措施,拒绝继续。你拥有这些数据,却没有能力把它们读出来。 你买了一台设备,厂商停止提供服务。你想修改程序让它继续运转,过程中需要处理某种访问限制。AI 不愿协助,而你所掌握的维修方式,早已只剩下「把报错发给 AI」。 你想给常用的网站写一个辅助工具,减少重复操作。平台将此归入自动化限制,AI 也据此拒绝。过去会写脚本的人可以自己判断、自己处理;未来的使用者,可能连从哪里开始都不知道。 这些例子有一个共同点:当工具同时承担了「执行工作」和「传授知识」的角色,它的拒绝就不只是挡住了一次任务,也挡住了获得这种能力的机会。 更麻烦的是,这种依赖往往是我们主动选择的,而且每一步都很合理。 既然 AI 几秒钟就能做好,为什么还要花几个月学习?既然公司可以采购成熟的 AI 服务,为什么还要养一支负责基础工作的团队?既然市场上已经很少有人手写这些东西,学校为什么还要花大量时间教? 对个人、公司和学校来说,这些选择都有现实依据。但叠加起来,整个社会保留的独立能力就可能越来越少。等到某项能力突然受到限制,我们才发现,备用方案早已在追求效率的过程中被一点点削掉了。 所以,「AI 越强,做事越便宜」可能只描述了未来的一半。 另一半是:被允许的事情越来越便宜,被拒绝的事情依然需要人工完成,却要面对更少的人才、更高的报价,以及越来越难获得的知识。两边的成本差距,会越拉越大。 有人会说,可以换一个模型,或者用本地模型。这确实是重要的选项。只要工具之间还有充分竞争,开放模型还足够可用,限制就未必能形成真正的门槛。 但备用方案也需要维护。它需要硬件、知识、软件生态,以及一群仍然理解这些系统的人。如果大多数人已经习惯了最方便的商业服务,那么「理论上还有别的办法」和「普通人实际用得起、用得明白」,之间仍然可能隔着很远。 我担心的,正是这段距离。 我不指望 AI 对任何请求都照单全收。对明显会伤害他人的行为设置限制,有充分的理由。但我们也需要关心:限制由谁定义,误判有没有申诉渠道,用户能不能换工具,以及脱离这个工具之后,还剩下多少独立行动的能力。 保留这些能力,平时看起来甚至有点低效,明明可以自动生成,还要理解原理;明明可以一键完成,还要知道手动怎么做。 但这些看似多余的知识,会在工具拒绝、失效、涨价或改变规则的那一刻,成为真正的选择权。 AI 越强,我们可能越需要留意它说「不」的那一刻。 因为那句话的分量,最终取决于:它拒绝之后,我们还能做什么。
10
下载 APP