ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理实训全攻略:从词法分析到递归下降的迷你编译器实现

编译原理实训全攻略:从词法分析到递归下降的迷你编译器实现 1. 为什么一个简单的编译器仍然是实训项目里的“硬骨头”又到实训季后台收到不少留言问的都是同一个题目——“编译原理实训一个简单语言的编译程序设计与实现”。有人觉得这是老掉牙的课设随便找个C语言子集抄一抄就行也有人对着龙书看了三遍真到自己动手写词法分析器时还是不知道怎么下手。我的看法很直接编译原理实训是所有计算机专业课程设计里性价比最高、也最能暴露基本功底的项目之一它几乎把数据结构、形式语言、算法设计、软件工程全串在了一条线上。先把这个项目“是什么、能做什么”说清楚。你要做的不是去实现Java或Python那种工业级编译器而是针对一门你自己定义的小型语言写出从源代码到目标代码或中间代码的完整编译链路。通常包括五个核心环节词法分析把字符串流拆成Token流语法分析依据文法把Token流组织成语法树语义分析检查类型、变量声明、作用域等静态约束中间代码生成生成三地址码、四元式或抽象语法树目标代码/解释执行或生成汇编或直接在虚拟机上跑结果。听起来环节很多但每个环节都可以控制在一个合理的复杂度范围内。这门实训最妙的地方在于它逼你把“语言”这种抽象事物拆成一套可计算的形式化流程。哪怕你只是实现一个支持整数运算、变量赋值、if和while循环的迷你语言整条链路走通之后你再回头看任何一门真实语言的报错信息理解都会深一个层次。这个题目适合谁我建议三类人认真对待一是正在修编译原理课、想通过实训把理论落地的学生二是准备找工作、想在简历上写一个“从零实现编译器”项目的同学三是纯粹对“程序是怎么被机器理解的”这件事好奇、愿意花两周时间亲手验证的爱好者。如果你只想水一个学分复制粘贴别人的代码也能过但你将错过一次非常难得的“建立计算机系统全局观”的机会。下面我按自己实际带实训、也亲手写过多个迷你编译器的经验把整个实现过程拆开讲包括每一步的设计逻辑、常见坑点和可以直接抄作业的代码骨架。2. 先定语言规格别一上来就写代码先回答这四个问题做编译器最容易犯的第一个错误就是没想清楚要编译的语言长什么样就急着写词法分析器。词法分析器、语法分析器都是围绕着语言规格展开的语言定义没定后面的代码全是在沙滩上盖楼。我建议你动手前先用自然语言把下面四个问题回答清楚。2.1 语言的数据类型和运算体系实训级别的语言不需要搞面向对象也不需要泛型甚至不需要浮点数。最稳妥的选择是只支持整型配合加减乘除四则运算和括号。这样词法分析只需要识别整数常量语法分析只需要处理算术表达式的优先级中间代码生成也不需要考虑类型转换。如果你想稍微增加一点挑战可以加入布尔类型和比较运算符、、、!这样一来if和while的条件判断会更自然。这里有一个很重要的设计原则语言特性不是越多越好而是越自洽越好。每加一个特性词法、语法、语义、代码生成四个环节都要配套支持。比如你想支持字符串那么词法分析器就要处理字符串字面量的转义中间代码就要考虑字符串的内存管理解释器里还要管理字符串对象的生命周期。对两周的实训周期来说这是非常沉重的负担。2.2 语句与控制结构我个人推荐的语句集合是这样的var x;声明变量x 表达式;赋值语句if (条件) 语句和if (条件) 语句 else 语句while (条件) 语句循环print 表达式;输出语句{ 语句序列 }复合语句块。这套语句集合能覆盖结构化编程的全部核心概念顺序、选择、循环、变量、输入输出。你做完之后甚至可以拿它写一个计算斐波那契数列的小程序验证编译器是否真正可用。2.3 文法的表达形式语法分析阶段你需要把语言结构用文法写出来。我建议用EBNF扩展巴科斯范式来描述因为它比普通BNF更贴近递归下降分析的实现方式。比如算术表达式的EBNF可以写成expr - term { (|-) term } term - factor { (*|/) factor } factor - number | ( expr ) | identifier这种写法直接对应了运算符的优先级加减法在expr层乘除法在term层括号和原子在factor层。后面我会详细讲怎么把这个EBNF翻译成递归下降的代码。2.4 程序的入口与输出约定建议把语言设计成“脚本式”没有main函数从文件第一行顺序执行到最后一行。这样解释器/编译器的主控逻辑最简单也最贴近实训验收时的直观感受。输出统一用print语句打印整数或字符串。把这些定好之后你就有了一份“语言参考手册”——哪怕它只有一页纸也足够支撑后续所有的开发工作。这也是我想强调的编译器开发中文档先行不是流程形式而是工程必需。3. 词法分析器Token是编译器所有环节的地基词法分析是整个编译器的第一步也是最容易拿分、最容易出错的一步。它的任务简单说就一句话把源代码字符串切成一个个有意义的单词Token并扔掉注释和空白字符。3.1 Token的类型定义在Java里通常用一个枚举加上几个字段来表示Tokenenum TokenType { NUMBER, // 整数常量 IDENTIFIER, // 变量名 KEYWORD, // var, if, else, while, print OPERATOR, // - * / ! LPAREN, RPAREN, LBRACE, RBRACE, // ( ) { } SEMICOLON, // ; EOF // 文件结束 } class Token { TokenType type; String lexeme; // 原始文本 int line; // 所在行号用于报错 public Token(TokenType type, String lexeme, int line) { this.type type; this.lexeme lexeme; this.line line; } }有个细节容易被忽略所有关键字和运算符都必须用字符串精确匹配而不是简单地按字符判断。比如是两个等号是一个等号如果词法分析器在读到第一个时就直接返回那么后面遇到就会解析成两个独立的赋值号语法分析时直接崩掉。3.2 基于状态转移的手写扫描器我建议初学者不要用正则库来切Token而是手写一个基于字符状态转移的扫描器。原因有两个一是实训一般不允许引入复杂的外部依赖二是手写扫描器能让你真切理解“最长匹配”原则。核心逻辑伪代码如下while (pos source.length()) { char c source.charAt(pos); if (Character.isWhitespace(c)) { pos; continue; } if (Character.isDigit(c)) { int start pos; while (pos source.length() Character.isDigit(source.charAt(pos))) pos; addToken(TokenType.NUMBER, source.substring(start, pos)); continue; } if (Character.isLetter(c)) { int start pos; while (pos source.length() Character.isLetterOrDigit(source.charAt(pos))) pos; String word source.substring(start, pos); if (keywords.contains(word)) addToken(TokenType.KEYWORD, word); else addToken(TokenType.IDENTIFIER, word); continue; } // 处理运算符和分隔符 switch (c) { case : addToken(TokenType.OPERATOR, ); pos; break; case -: addToken(TokenType.OPERATOR, -); pos; break; // 注意和、!和!…… case : if (pos 1 source.length() source.charAt(pos 1) ) { addToken(TokenType.OPERATOR, ); pos 2; } else { addToken(TokenType.OPERATOR, ); pos; } break; // 其他字符类似 } }3.3 词法错误处理不要一错就停实训验收时老师一定会拿错误输入来测试。词法层面的错误最典型的是非法字符比如中文字符、、#等未定义的符号。我的建议是扫描器遇到非法字符时输出一条包含行号的错误信息然后跳过该字符继续扫描而不是直接终止整个编译。这样做的好处是一轮编译能暴露尽可能多的词法错误体验更接近真实编译器。但要注意如果错误太多语法分析阶段就没有意义了所以通常的做法是记录错误数量超过一定阈值比如20个才强制终止。3.4 标识符与关键字的区分新手最常犯的错就是把关键字和普通标识符混在一起处理。正确的做法是先用“字母开头、后跟字母数字”的规则扫描出一个完整的词再查关键字表。也就是说关键字本质上是一种特殊的标识符只是被预留了。这个顺序不能反否则ifx这种变量名就会被错误地识别为关键字。我在实训指导时经常打这个比方词法分析器像是一个图书管理员他在书架上看到一串文字先整体念出来再查是不是“馆内禁词”如果是就打上特殊标签否则就当作普通书名处理。思路清晰之后代码自然就不会写乱。4. 语法分析递归下降从EBNF到可运行的Java代码语法分析是编译器中最核心、也最锻炼思维的一环。我在这里只讲一种实现方式——递归下降分析因为它是手写编译器最自然、最容易调试的方法也是绝大多数实训项目的评分标准中“允许使用”的方式。它的核心思想是把文法中的每个非终结符都实现成JAVA语言中的一个函数。4.1 消除左递归在做递归下降之前有一个理论坎必须过文法不能含有左递归。什么是左递归比如expr - expr term | term这个文法的问题在于如果直接翻译成函数parseExpr()函数第一行就要调用parseExpr()永远递归下去直接栈溢出。解决办法是改写文法把左递归转换成右递归。上面这个文法可以改写为expr - term exprTail exprTail - term exprTail | ε但这样写出来的函数会多一个“尾巴”函数处理起来略繁琐。更实用的做法是用EBNF的{ }表示重复也就是我在第2节里写的那种形式expr - term { (|-) term } term - factor { (*|/) factor } factor - number | ( expr ) | identifier这种写法下{ }表示“零次或多次”翻译成代码就是一个while循环。4.2 递归下降的代码骨架我直接给出一套可以跑的Java骨架。首先需要一个“向前看一个Token”的辅助机制也就是所谓的LL(1)分析class Parser { private ListToken tokens; private int pos; private Token peek() { return tokens.get(pos); } private Token next() { return tokens.get(pos); } private boolean check(TokenType type) { return peek().type type; } private boolean match(TokenType type) { if (check(type)) { pos; return true; } return false; } private void expect(TokenType type, String message) { if (!match(type)) { throw new ParseException(message 但遇到了 peek().lexeme); } } }有了这几个基础方法语法函数写起来就非常直观// expr - term { (|-) term } private void parseExpr() { parseTerm(); while (check(TokenType.OPERATOR) (peek().lexeme.equals() || peek().lexeme.equals(-))) { next(); // 消费运算符 parseTerm(); } } // term - factor { (*|/) factor } private void parseTerm() { parseFactor(); while (check(TokenType.OPERATOR) (peek().lexeme.equals(*) || peek().lexeme.equals(/))) { next(); parseFactor(); } } // factor - number | ( expr ) | identifier private void parseFactor() { if (match(TokenType.NUMBER)) { // 处理数字 } else if (match(TokenType.IDENTIFIER)) { // 处理标识符 } else if (match(TokenType.LPAREN)) { parseExpr(); expect(TokenType.RPAREN, 缺少右括号); } else { throw new ParseException(无法识别的表达式起始符号: peek().lexeme); } }这里有个容易踩的坑match函数在成功时会消费Token失败时不会。这个语义必须统一否则同一个Token会被多个分支重复消费导致逻辑错乱。我在第一次写的时候就在这里栽过跟头排查了一下午最后发现是match在失败时把pos给移了。4.3 语句的语法分析表达式的骨架搭好之后语句层面就轻松了。以if语句为例private void parseIf() { expect(TokenType.KEYWORD, if); // 期望消耗掉if关键字 expect(TokenType.LPAREN, if后缺少左括号); parseExpr(); expect(TokenType.RPAREN, if条件后缺少右括号); parseStatement(); if (match(TokenType.KEYWORD) peek().lexeme.equals(else)) { parseStatement(); } }注意if语句的else是可选的所以要用match判断而非expect。这类“悬空else”问题在嵌套if时非常经典递归下降分析法天然规避了它的歧义else总是和最近的未匹配if结合。这一点也是教材里经常考的知识点自己写出代码后理解会更透。4.4 语法错误恢复让报错更“像样”语法分析的错误处理决定了你的编译器在老师眼里是“玩具”还是“作品”。最简陋的做法是抛异常中止整轮编译只报一个错。稍微好一点的做法是同步恢复当发现语法错误时跳过若干个Token直到遇到一个“同步标记”——通常是分号、}或者某个关键字——然后再继续分析。比如赋值语句出错可以这样处理private void parseAssignOrExprStatement() { try { parseExpr(); expect(TokenType.SEMICOLON, 语句末尾缺少分号); } catch (ParseException e) { reportError(e.getMessage(), peek().line); synchronize(); // 跳到下一个分号或右花括号 } }同步恢复能让一次编译输出多条错误信息更接近GCC、Clang这类真实编译器的用户体验在实训答辩时也是一个明显的加分项。5. 语义分析与符号表变量没声明就该在这里拦住很多实训项目做到语法分析就草草收尾了直接把语法树打印出来交差。但严格来说没有语义分析的编译程序是不完整的。语义分析的任务是在语法正确的前提下检查程序“意思”是否正确。5.1 符号表编译器里的“户口本”符号表是语义分析的核心数据结构。它记录每个变量的名字、类型、作用域以及后面生成代码时需要用到的一些属性。对实训级别的语言符号表可以做得非常简单class SymbolTable { private MapString, Integer vars new HashMap(); public void declare(String name, int line) { if (vars.containsKey(name)) { throw new SemanticException(变量 name 重复声明行号 line); } vars.put(name, 0); } public void check(String name, int line) { if (!vars.containsKey(name)) { throw new SemanticException(变量 name 未声明行号 line); } } }如果你设计的语言支持{}代码块那还要考虑作用域嵌套。实现上可以用一个栈来模拟class ScopedSymbolTable { private DequeMapString, Integer scopes new ArrayDeque(); public void enterScope() { scopes.push(new HashMap()); } public void exitScope() { scopes.pop(); } public void declare(String name, int line) { MapString, Integer top scopes.peek(); if (top.containsKey(name)) { throw new SemanticException(变量 name 在当前作用域重复声明); } top.put(name, 0); } public void check(String name, int line) { for (MapString, Integer scope : scopes) { if (scope.containsKey(name)) return; } throw new SemanticException(变量 name 未声明); } }5.2 类型检查即使只有int也要检查别以为语言只支持int就不需要类型检查了。如果词法分析允许123abc这种非法数字或者语法上允许把if和数字相加语义阶段就该拦下来。另外如果你做了和两种运算符还可以在语义分析阶段检测“把赋值表达式当作值来用”的错误。在我个人的实训评分标准里语义分析占的比重很高。老师通常不会要求你做完整的类型推导但至少要能检查出“使用未声明变量”和“重复声明变量”这两类典型错误。这两点做到语义分析这一项基本就是优秀了。5.3 抽象语法树的构建如果你希望在实训报告里展示“语法树”这个成果我建议在语法分析阶段构建一棵简单的AST。每个节点是如下结构abstract class ASTNode {} class NumberNode extends ASTNode { int value; } class VarNode extends ASTNode { String name; } class BinaryOpNode extends ASTNode { String op; ASTNode left; ASTNode right; } class AssignNode extends ASTNode { String varName; ASTNode expr; } class IfNode extends ASTNode { ASTNode cond; ASTNode thenBranch; ASTNode elseBranch; } class WhileNode extends ASTNode { ASTNode cond; ASTNode body; } class PrintNode extends ASTNode { ASTNode expr; }构建AST的好处是语法分析和后续的代码生成、解释执行彻底解耦。语法分析只负责“树建得对不对”解释器只关心“树上怎么走”各自的职责清晰调试起来也容易定位问题。6. 三种“执行”路径解释器、中间代码与汇编怎么选实训的最后一个大环节是如何让你写的程序“跑起来”。这里有三条路复杂度递增产出效果也递增你需要根据实训要求和个人能力来选择。6.1 方案一直接写解释器执行AST这是最简单、最稳妥的方案。既然已经构建了AST就可以写一个递归求值器直接对语法树求值。class Interpreter { private MapString, Integer env new HashMap(); public int eval(ASTNode node) { if (node instanceof NumberNode) { return ((NumberNode) node).value; } if (node instanceof VarNode) { String name ((VarNode) node).name; if (!env.containsKey(name)) { throw new RuntimeException(变量未初始化: name); } return env.get(name); } if (node instanceof BinaryOpNode) { BinaryOpNode bin (BinaryOpNode) node; int left eval(bin.left); int right eval(bin.right); switch (bin.op) { case : return left right; case -: return left - right; case *: return left * right; case /: if (right 0) throw new RuntimeException(除零错误); return left / right; // 比较运算符类似 } } if (node instanceof AssignNode) { AssignNode assign (AssignNode) node; int value eval(assign.expr); env.put(assign.varName, value); return value; } if (node instanceof IfNode) { IfNode ifNode (IfNode) node; if (eval(ifNode.cond) ! 0) { eval(ifNode.thenBranch); } else if (ifNode.elseBranch ! null) { eval(ifNode.elseBranch); } return 0; } if (node instanceof WhileNode) { WhileNode whileNode (WhileNode) node; // 注意这里要防止死循环 while (eval(whileNode.cond) ! 0) { eval(whileNode.body); } return 0; } // PrintNode 类似 return 0; } }这个方案的优点是直观、调试容易你可以在关键节点打印中间值缺点是不够“编译”比较难在答辩时体现编译原理的理论深度。6.2 方案二生成三地址码或四元式如果你想在原理上更进一步可以生成三地址码或四元式。三地址码的每条指令最多包含三个地址两个操作数、一个结果例如t1 a b t2 t1 * c x t2对应的四元式表示为(, a, b, t1)、(*, t1, c, t2)、(, t2, _, x)。生成三地址代码的过程本质上是在语法分析或语义分析阶段边遍历AST边产生指令序列。比如对a b * c这个表达式按照运算符优先级会先处理乘法再处理加法生成三条指令。我在实训里会让学生用下面的结构存储中间代码class Quadruple { String op; // 操作符 String arg1; // 第一个操作数 String arg2; // 第二个操作数 String result; // 结果变量 }然后用一个动态数组保存所有四元式。最后再写一个非常简单的虚拟机循环执行这些四元式。这样做的好处是你的项目结构更加接近真实编译器的“前端 中端 后端”分层答辩时也可以清晰地向老师解释每一层的作用。6.3 方案三生成目标汇编代码这是最有野心、也最耗时的方案。如果选的简单语言支持整型运算和控制流可以生成MIPS汇编或x86汇编然后用模拟器如MARS、SPIM运行。不过我的建议是除非老师明确要求生成汇编否则不要轻易选这条路。原因很现实汇编代码生成涉及寄存器分配、栈帧管理、标签跳转等大量底层细节工作量可能是解释器方案的三倍以上。在两周的实训周期里你很可能把时间都耗在调试汇编格式上反而没有精力优化前端。6.4 我的推荐路线如果是自己安排实训节奏我比较推荐“前端完整 解释执行”的组合词法分析、语法分析、语义分析完整实现AST构建完整实现解释器作为后端直接执行AST时间有余量再把解释器升级为“先翻译成三地址码再执行三地址码”。这样既能保证项目在实训截止日期前顺利完成又保留了继续深入的空间。我在带实训时经常说一句话先跑通再跑好。一个能完美运行的小编译器远胜于一个只写了开头的大编译器。7. 实训报告与答辩老师最想看的不只是代码很多人忽略了一个事实实训成绩里报告和答辩往往占40%甚至更高的比重。代码写得再好讲不清楚等于白做。根据我多次参与评审的经验老师最关注的是以下四个点。7.1 设计文档要画出“编译流程全景图”第一页不要放代码而是画一张编译流程图源代码 → 词法分析 → Token流 → 语法分析 → 语法树 → 语义分析 → 中间代码 → 执行结果。每个环节旁边标注输入输出和关键数据结构。老师扫一眼这张图就知道你是否真正理解了编译器的整体架构。7.2 重点讲清楚“为什么这么设计”答辩时的常见问题套路是这样的为什么用递归下降而不是LR分析——答递归下降实现简单、错误定位准确适合实训级语言LR需要借助YACC等工具反而失去了手写实现的意义。怎么处理运算符优先级——答通过文法分层加减法在低层乘除法在高层括号在最深层。怎么处理变量作用域——答用符号表栈进入代码块时压栈离开时弹栈。遇到错误怎么恢复——答同步标记法跳到下一个分号或右花括号继续分析。这些问题都不难但如果你没有真正自己写过很容易答得支支吾吾。我的建议是答辩前把每个模块的“一句话原理”写在卡片上反复自问自答几遍。7.3 准备测试用例报告一定要附上测试用例而且不只是正例还要有反例。比如正例计算斐波那契数列、求1到100的和、嵌套if判断反例未声明变量、重复声明变量、缺少分号、括号不匹配、除零错误。每个反例都要截图或记录报错信息证明你的编译器“知道自己在什么地方错了”。这在评分时是非常有说服力的证据。7.4 报告里用表格呈现测试结果测试类型输入代码片段期望结果实际结果状态词法错误a b报错非法字符报错非法字符通过语法错误a 1 报错表达式不完整报错表达式不完整通过语义错误print c;报错变量c未声明报错变量c未声明通过运行结果斐波那契数列输出前10项正确输出通过这个表格一放老师的印象分会明显提高因为它证明你做了系统性的功能验证而不是只跑了一次成功案例就交差。8. 我在实训里踩过的坑五条能帮你省一周时间的经验最后分享几个我自己在带实训和写编译器时反复遇到的实际问题每一个都是真实消耗过我大量时间的“坑”。8.1 最容易犯的错忘了处理“空语句体”写while或if时如果语句体是空块{}递归下降的很多实现会直接崩溃因为它在进入parseStatement()时发现既不是if也不是while更不是标识符而是一个右花括号。解决方法是在parseStatement()开头判断如果遇到了}就当作一个空语句处理。这个小细节我在批改作业时发现大概有三分之一的学生会踩中。8.2 死循环陷阱while循环条件永远不会变解释器执行while时如果AST的构造导致循环体内变量的修改没有反映到环境中就会死循环。比如你忘了在while体执行完后重新读取循环条件或者在符号表里把变量值存在了另一个map里导致赋值语句修改的是一份拷贝。调试这类问题的方法很简单在解释器里加一个“最大执行步数”上限比如10万步超过就报错。这个调试手段成本极低收益极高。8.3 分号与换行的处理实训语言里分号是语句终止符。有些同学在词法分析时把换行符\n当作语句结束结果在写多行表达式时各种错乱。我强烈建议换行一律当空白处理语句终止只认分号。这不仅让语言更接近C/Java的风格也让词法分析器保持简单一致。8.4 比较运算符链式处理如果你支持了、、等运算符注意它们在EBNF中的层级。很多同学把所有比较运算符都塞在expr层结果导致1 2 3这种表达式被解析成(1 2) 3运行时因为布尔值被当成了整数而莫名其妙。更好做法是单独加一层relExpr专门处理比较运算并规定比较运算的结果是整数1或0这样后续的if条件直接用“非0即真”判断就行。8.5 启动参数与文件读取最后一个坑其实和编译原理无关但非常影响实训体验。很多同学的编译器入口写死了文件路径要么是用IDE直接运行要么是命令行参数解析写得太复杂。我的建议是主程序只接受一个参数就是源文件路径然后统一按UTF-8读取文件内容再交给词法分析器。尽量不要在语言里支持从控制台交互式输入那个工作量很大而且和实训主题偏离。把精力省下来多打磨一下报错信息更划算。9. 扩展方向如果你的实训想冲击“优秀”如果你的基础足够好前面的内容一周就做完了还剩下一周时间我建议按下面的优先级做一些扩展每一个都能显著提升项目的完整度。加上单行注释//和多行注释/* */这是词法分析的小升级但非常实用增加break语句控制循环退出涉及语法分析、AST节点、解释器三处同步修改增加负数常量支持允许-123作为独立的数字字面量需要调整词法扫描逻辑生成带行号的错误信息并高亮出错位置这是很多真实编译器的基本交互增加程序性能统计比如“编译耗时、执行步数、Token数量、语法树节点数”放在程序末尾输出实训报告里写起来很出彩引入中间代码优化的基础思想比如常量折叠1 2在编译期直接算成3。我个人觉得常量折叠是性价比最高的扩展。它不需要改动词法和语法分析只需要在AST构建完成后、解释执行之前增加一次树遍历把所有BinaryOpNode替换成NumberNode如果两个子节点都是数字常量的话。这段代码不到30行但对理解“优化”这件事的帮助很大而且答辩时讲起来非常有深度。最后再聊一点心得体会。编译原理这门课本科阶段真正动手写过编译器的人和只看书的人收获是截然不同的。看书看到的是结论写代码遇到的是问题结论可以背问题只能靠调试去解决。而调试编译器这件事会迫使你同时调动程序语言理论、数据结构知识和工程调试能力这种综合训练在其他课设里很难体会到。哪怕你以后不做编译器方向这个实训项目留下的“系统思维”也会一直在后续的工程实践中起作用。希望这份拆解能让你少走一些弯路。如果你在写词法分析器或者递归下降的时候卡住了欢迎带着具体的报错信息来和我讨论——大多数编译器的bug其实都藏在你对 Token 流的错误假设里。
返回列表