公司动态
AI编程成本控制:手写递归下降解析器实现mini C++解释器
看到“I Spent 2B Tokens Writing a C Compiler So You Dont Have To”这样的标题时很多人的第一反应是这得烧多少钱答案可能已经不重要但这件事背后藏着一个更值得关注的技术信号——AI 已经能够在 C 编译器这种最高难度的系统工程里持续产出具备可运行性的代码了。C 编译器之所以被称为“程序员的珠穆朗玛峰”是因为它同时涉及词法分析、语法分析、语义分析、中间表示、优化、代码生成等多个层次任何一个环节出错都会立刻反馈为编译失败或运行时错误。这篇文章不打算复刻那个烧 token 的实验而是想把其中最值得借鉴的部分拆开token 成本是怎么被消耗掉的C 编译器为什么是 AI 编程的试金石以及如何用 AI 低成本实现一个简化版 C 解释器。文章面向两类读者一类是刚接触 C、想用 AI 辅助学习编译原理的初学者另一类是已经在实际项目中尝试 AI 编程、但被 token 消耗和代码质量反复折磨的开发者。读完你会理解 token 计费的基本逻辑掌握一套控制 AI 编程成本的工程方法并且拿到一份可以亲手运行、扩展的 mini C 解释器源码。1. 背景2B Tokens 写 C 编译器这个“亏本买卖”值得看什么1.1 C 编译器为什么公认难写先明确一个前提编译器不是普通业务系统。普通的 Web 服务是“接收请求、处理数据、返回结果”边界清晰编译器则是一个把人类可读文本逐步降级为机器指令的流水线。以 C 为例代码要经过预处理、词法分析、语法分析、语义分析、生成抽象语法树、中间代码优化、目标代码生成、链接等多个阶段。而 C 的语法又是出了名的复杂模板在编译期执行、重载决议要考虑参数匹配的优先级、函数名查找涉及 ADLArgument-Dependent Lookup、表达式解析要区分左值和右值。任何一个阶段没做好用户写出的合法代码就可能被拒绝或者更糟——编译通过但运行结果错误。正因如此主流 C 编译器如 GCC、Clang/LLVM 都是几十年持续投入的工程项目。LLVM 的代码量在百万行级别很多优化 pass 的复杂度堪比小型操作系统模块。过去我们要向新手解释“写一个编译器很难”需要列很多抽象概念现在只需要说“有人花了 20 亿 token 让 AI 写了一个 C 编译器”大家立刻能感受到这个任务的体量。1.2 AI 写编译器这件事为什么会成为话题如果只是随便让 AI 生成一个“加法计算器”不会有人在意。但“编译一个真实编程语言的编译器”意味着 AI 要同时解决以下问题如何把字符串切分成 token如何用递归下降或 LALR 等算法处理嵌套的语法结构如何表达并求值抽象语法树如何处理变量作用域如何在错误发生时给出清晰的诊断信息。这些问题环环相扣非常考验 AI 对“任务整体性”的把握。所以 2B Tokens 这个数字本身不是重点重点是它证明了AI 已经具备“在长链路工程任务上持续产出可用代码”的能力。但请注意可用不等于完美。AI 生成的编译器可能在特定测试用例下运行良好一旦遭遇未在上下文中出现过的边界情况就会暴露出问题。这也引出了本文最核心的观点AI 是强大的编码加速器但不等于系统设计师代码可以交给 AI 生成架构和边界条件必须由人来把控。1.3 对普通 C 开发者的启示看到这类标题普通开发者容易陷入两个极端要么觉得“AI 这么强我不用学 C 了”要么觉得“这不过是烧钱噱头我不需要了解”。实际上AI 写编译器这件事最大的启示是掌握基础原理的人可以让 AI 十倍速完成机械编码不懂原理的人连给 AI 下达正确指令都困难。举个例子。如果你想实现一个解释器不知道“递归下降”和“抽象语法树”这两个概念你只能让 AI“写一个能计算的东西”得到的代码大概率不可维护。反之如果你知道一个表达式求值器由词法分析、语法分析和求值三个模块组成你就能把任务拆给 AI并清晰指出每个模块的输入输出。这种“用原理指导 AI 编码”的能力才是 AI 时代 C 开发者的核心竞争壁垒。2. Token 到底是怎么烧掉的先理解成本模型2.1 Token 是什么大语言模型处理文本时并不是按“字符”计算而是把文本切分成一个个 token。Token 可以理解为模型的基本语义单元英文单词通常 1 个 token 占 0.7 到 1 个词中文一个汉字大约 0.6 到 1 个 token代码场景中一个符号、一个关键词都可能单独成 token。各家模型对 token 的定义略有差异但核心规则是一致的输入内容、输出内容都会消耗 token。用之前有读者问“TPMTokens Per Minute是什么”简单说它是衡量 API 调用速率限制的指标计算公式通常是TPM 输入 token 输出 token 的总和也就是说一次对话请求里你粘贴的代码、报错日志、AI 返回的代码和解释全部计入 token。很多人在不知不觉中把大量 token 浪费在反复粘贴大段报错日志、让 AI 输出“小作文式分析”上。2.2 为什么编译器项目是 Token 消耗大户编译器项目消耗 token 主要有四个原因。第一项目体积大。一个完整的编译器即使简化到教学级别也需要几百行代码如果涉及多文件、测试用例和构建脚本整体代码量很容易突破数千行。第二强依赖上下文。修改语法分析器时AI 必须理解词法分析器产出的 token 类型修改代码生成时AI 又要回溯前端的语法树定义这种前后依赖导致每次对话都必须携带大量上下文。第三错误循环多。编译器代码对逻辑正确性要求极高一个小的解析错误可能导致 AI 反复修改同一段逻辑这种现象在 AI 编程中叫“修复循环”是 token 浪费的大头。第四测试用例体积大。验证编译器行为需要输入样例和预期输出这些也算 token。2.3 用脚本粗略估算 Token 消耗在实际工程中我们可以用“字符数 / 4”作为英文代码场景下 token 数的粗略估算。下面是一个 Python 小脚本可以统计目录下 C/C 源文件的字符数和估算 token 数# token_estimate.py # 用途粗略估算 C/C 源文件的 token 消耗帮助控制 AI 编程成本 import os import sys def count_chars_in_file(path): with open(path, r, encodingutf-8, errorsignore) as f: return len(f.read()) def estimate_tokens(paths): total_chars 0 file_count 0 for path in paths: if os.path.isfile(path): total_chars count_chars_in_file(path) file_count 1 elif os.path.isdir(path): for root, _, files in os.walk(path): for f in files: if f.endswith((.cpp, .h, .hpp, .c, .cc)): full os.path.join(root, f) total_chars count_chars_in_file(full) file_count 1 # 英文代码场景下1 token 约等于 3-4 个字符 est_tokens total_chars // 4 print(f文件数量: {file_count}) print(f字符总数: {total_chars}) print(f估算 token 数: {est_tokens}) print(f估算输入输出往返次数: {est_tokens * 2}单次全量重发场景) if __name__ __main__: estimate_tokens(sys.argv[1:] or [.])这个脚本只能提供直觉参考不能替代真实的 token 统计。真正想控制成本还是要从任务拆分入手也就是后面第 6 节的内容。3. 环境准备AI 编程 C 工具链3.1 本地 C 开发环境无论你使用哪款 AI 工具最终代码都要在本地编译运行。为了让范例代码能直接跑起来推荐以下环境组合组件推荐选项说明操作系统Windows 10/11、Ubuntu 20.04、macOS本文命令以通用 g 为例编译器gMinGW-w64 / GCC、clang、MSVC建议支持 C17IDEVS Code C/C 扩展或 CLionVS Code 轻量适合教学AI 工具对话式大模型、代码补全插件按需选择不绑定具体品牌如果你是在 Keil MDK 这类嵌入式 IDE 中使用 ARM Compiler则环境会有所不同。比如常见的报错Target Target 1 uses ARM-Compiler Default Compiler Version 5 which is not available本质原因是工程指定的编译器版本没有安装或路径未配置。解决思路是在 IDE 中重新指定编译器路径并确认工程文件中的编译器版本与已安装版本匹配。许可证相关问题必须通过正规渠道申请合法授权不要轻信网上所谓的“破解方案”。3.2 VS Code 基本 C 编译配置如果使用 VS Code 编写 C最简单的方案是配置一个编译 task。下面是一个最小化的tasks.json示例放在项目的.vscode目录下{ version: 2.0.0, tasks: [ { label: C 编译运行, type: shell, command: g, args: [ -stdc17, -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: { kind: build, isDefault: true } } ] }说明-stdc17指定语言标准-g生成调试信息${file}是当前打开的源文件输出文件名使用源文件同名.exe。如果你使用 clang把command换成clang即可。3.3 项目目录规划本文实战环节会生成一个单文件解释器。为了让代码结构更清晰建议按下面结构组织mini-cpp-interpreter/ ├── .vscode/ │ └── tasks.json ├── token_estimate.py └── mini_interpreter.cpp单文件方案更适合教学演示真实项目建议拆成lexer.h/cpp、parser.h/cpp、main.cpp后续扩展会更容易。4. 核心思路把编译器拆成 AI 能处理的任务块4.1 编译器实现的基本流水线在给 AI 下达任务前先在心里建立一张编译器实现地图。一个教学级解释器通常包含四个模块模块职责输入输出词法分析器把源码字符串拆成 token 序列a 1 2;IDENT(a) ASSIGN INT(1) PLUS INT(2) SEMI语法分析器按语法规则组装表达式结构token 序列抽象语法树或直接求值求值器/执行器计算表达式、维护变量环境语法树数值结果/状态变更错误处理对非法输入给出提示任意阶段异常可读的错误信息4.2 为什么递归下降解析器适合 AI 生成编译原理教材中常见两种语法分析方案自底向上的 LALR 解析器生成器如 yacc/bison和自顶向下的递归下降解析器。对 AI 来说递归下降有三个明显优势第一代码结构和文法规则一一对应出现问题时容易定位第二不需要额外学习工具链纯手写代码即可运行第三手写解析器可以随时插入错误提示和调试代码。所以本文实战部分采用递归下降方案。如果你和 AI 协作记得在提示词中明确写“使用递归下降解析”否则 AI 可能会给你生成一个依赖 flex/bison 的方案增加不必要的构建复杂度。4.3 如何把任务拆给 AIAI 编程最常见的失败方式是“一次性让 AI 生成整个项目”然后在几千行代码中寻找一个错误。更好的方式是把任务拆成三到四轮对话第一轮让 AI 给出整体方案和文件清单。 第二轮让 AI 生成词法分析器只关注 token 类型定义与切分逻辑。 第三轮让 AI 生成语法分析器基于已约定的 token 类型。 第四轮让 AI 生成主程序并组装测试用例。每一轮都以上一轮的输出为上下文这样既控制单次 token 消耗又便于定位问题。接下来我们就按照这个流程让 AI 辅助实现一个简化版 C 解释器。5. 实战AI 辅助写一个简化 C 解释器5.1 需求定位我们不追求实现完整 C 标准而是实现一个能处理以下语法的最小解释器支持整数常量 支持加减乘除与括号 支持变量赋值与读取 支持 print 表达式; 语句输出结果 所有语句以分号结尾。示例输入a 1 2 * 3; b (a 4) / 2; print a; print b; 10 - 3;预期输出7 5 7其中10 - 3;作为普通表达式语句直接输出计算结果7。5.2 提示词模板让 AI 输出完整可编译代码如果你手头有对话式 AI 工具可以直接使用下面的提示词模板你是一名资深 C 编译器工程师。请用 C17 实现一个极简解释器支持 1. 整数四则运算与括号 2. 变量赋值x 表达式; 3. print 表达式; 语句输出整数结果 4. 语句以分号结尾。 要求 - 输出完整、可编译的 C 源码文件 - 使用递归下降解析不依赖第三方库 - 词法分析、语法分析、执行器分模块编写 - 对除零、未定义变量给出清晰错误信息 - 不输出额外解释只输出代码。注意“不输出额外解释只输出代码”这句话能帮你省下大量 token。如果 AI 输出中包含设计说明可以让它重新生成反复几次后AI 会学会“少说废话”。5.3 词法分析器实现词法分析器的任务很简单把源码字符串变成 token 数组。我们定义的 token 类型包括整数、标识符、运算符、括号、分号和print关键字。// 文件路径mini_interpreter.cpp // 词法分析器将源码字符串切分为 token 序列 #include iostream #include string #include vector #include map #include cctype #include stdexcept enum class TokenType { INT, IDENT, ASSIGN, SEMI, PRINT, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, END }; struct Token { TokenType type; int value 0; std::string name; }; class Lexer { public: explicit Lexer(const std::string src) : src_(src) {} std::vectorToken tokenize() { std::vectorToken tokens; while (pos_ src_.size()) { char c src_[pos_]; if (std::isspace(static_castunsigned char(c))) { pos_; continue; } if (std::isalpha(static_castunsigned char(c))) { tokens.push_back(readIdent()); continue; } if (std::isdigit(static_castunsigned char(c))) { tokens.push_back(readNumber()); continue; } switch (c) { case : tokens.push_back({TokenType::PLUS}); pos_; break; case -: tokens.push_back({TokenType::MINUS}); pos_; break; case *: tokens.push_back({TokenType::STAR}); pos_; break; case /: tokens.push_back({TokenType::SLASH}); pos_; break; case (: tokens.push_back({TokenType::LPAREN}); pos_; break; case ): tokens.push_back({TokenType::RPAREN}); pos_; break; case : tokens.push_back({TokenType::ASSIGN}); pos_; break; case ;: tokens.push_back({TokenType::SEMI}); pos_; break; default: throw std::runtime_error(std::string(无法识别的字符: ) c); } } tokens.push_back({TokenType::END}); return tokens; } private: std::string src_; size_t pos_ 0; Token readNumber() { int val 0; while (pos_ src_.size() std::isdigit(static_castunsigned char(src_[pos_]))) { val val * 10 (src_[pos_] - 0); pos_; } return {TokenType::INT, val}; } Token readIdent() { std::string name; while (pos_ src_.size() std::isalnum(static_castunsigned char(src_[pos_]))) { name.push_back(src_[pos_]); pos_; } if (name print) { return {TokenType::PRINT}; } Token t; t.type TokenType::IDENT; t.name name; return t; } };这段代码的关键点有三个一是遇到换行和空格直接跳过二是把多位数累加成整数三是把print单独识别为关键字其余字母开头的内容一律当作变量名。如果你希望支持、等比较运算符需要在switch中增加多字符匹配逻辑。5.4 递归下降解析与执行语法分析器采用递归下降方式。我们定义了四个层级expr加减、term乘除、factor整数/变量/括号。在执行过程中直接对表达式求值并把变量存到std::map中。这样省去了生成 AST 的步骤逻辑更紧凑。// 释义器核心递归下降解析器 执行器 class Parser { public: explicit Parser(const std::vectorToken tokens) : tokens_(tokens) {} void parseProgram() { while (current_ tokens_.size()) { Token tok tokens_[current_]; if (tok.type TokenType::END) { break; } if (tok.type TokenType::PRINT) { current_; int value parseExpr(); std::cout value std::endl; expect(TokenType::SEMI); } else if (tok.type TokenType::IDENT current_ 1 tokens_.size() tokens_[current_ 1].type TokenType::ASSIGN) { std::string varName tok.name; current_ 2; int value parseExpr(); vars_[varName] value; expect(TokenType::SEMI); } else { int value parseExpr(); std::cout value std::endl; expect(TokenType::SEMI); } } } private: std::vectorToken tokens_; size_t current_ 0; std::mapstd::string, int vars_; Token peek() { if (current_ tokens_.size()) { return tokens_.back(); } return tokens_[current_]; } void expect(TokenType type) { if (current_ tokens_.size() || tokens_[current_].type ! type) { throw std::runtime_error(语法错误期望一个分号或其他符号); } current_; } int parseExpr() { int left parseTerm(); while (true) { if (peek().type TokenType::PLUS) { current_; left parseTerm(); } else if (peek().type TokenType::MINUS) { current_; left - parseTerm(); } else { break; } } return left; } int parseTerm() { int left parseFactor(); while (true) { if (peek().type TokenType::STAR) { current_; left * parseFactor(); } else if (peek().type TokenType::SLASH) { current_; int divisor parseFactor(); if (divisor 0) { throw std::runtime_error(除零错误); } left / divisor; } else { break; } } return left; } int parseFactor() { Token tok peek(); if (tok.type TokenType::INT) { int val tok.value; current_; return val; } if (tok.type TokenType::IDENT) { std::string name tok.name; current_; auto it vars_.find(name); if (it vars_.end()) { throw std::runtime_error(未定义变量: name); } return it-second; } if (tok.type TokenType::LPAREN) { current_; int val parseExpr(); if (peek().type ! TokenType::RPAREN) { throw std::runtime_error(缺少右括号); } current_; return val; } throw std::runtime_error(语法错误无法解析该表达式); } };递归下降解析的核心是“每个语法非终结符对应一个函数”。parseExpr处理最低优先级的加减法parseTerm处理乘除法parseFactor处理最小的语法单元。遇到括号时parseFactor递归调用parseExpr从而实现任意嵌套的表达式解析。这里有一个常见误区在处理a b c这类连续赋值时我们的简化版不支持因为parseExpr返回的是整数而不是左值引用。如果你后续想扩展链式赋值需要引入 AST 和左值/右值概念。5.5 主程序与运行验证最后是主函数它把词法分析器和语法分析器串起来并在捕获到异常时输出错误信息。int main() { std::string source R( a 1 2 * 3; b (a 4) / 2; print a; print b; 10 - 3; ); try { Lexer lexer(source); std::vectorToken tokens lexer.tokenize(); Parser parser(tokens); parser.parseProgram(); } catch (const std::exception e) { std::cerr 错误: e.what() std::endl; return 1; } return 0; }把 5.3、5.4、5.5 的代码按顺序放到同一个文件mini_interpreter.cpp中然后编译运行g -stdc17 -o mini mini_interpreter.cpp ./mini预期输出7 5 7第一个7来自print a第二个5来自print b第三个7来自表达式语句10 - 3。基于这个基础你可以继续扩展增加if分支、while循环、函数调用甚至把抽象语法树显式建出来为后续代码生成打基础。6. 控制 Token 消耗的工程方法6.1 拆任务一次只让 AI 做一个模块从第 5 节的实战可以看出把任务拆成“词法分析器—语法分析器—主程序”三部分后每一轮的代码量都控制在 100 行左右。这样即使 AI 某次输出完全不可用重新生成的代价也很低。反之如果你让 AI 一次性输出 1000 行编译器代码出现一个逻辑 bug 时排查成本会成倍增加。6.2 用需求文档降低重复描述成本在多轮 AI 对话中最常见的 token 浪费是“每轮都重复项目背景”。解决方法是在第一轮让 AI 根据需求生成一份简短的接口契约文档后续对话统一引用这份契约。比如在前面的项目中接口契约可以写成Lexer::tokenize() - vectorToken Parser::parseProgram() - void Token 类型: INT, IDENT, ASSIGN, SEMI, PRINT, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, END这样在让 AI 增加“比较运算符”时你只需要说“在原有 TokenType 中增加 EQ、NEQ在 parseExpr 底部增加比较层级”而不需要重新粘贴整个文件。6.3 测试闭环是最大的节省很多开发者让 AI 写完代码就结束然后拿到线上出现 bug又回到 AI 里粘贴报错。这个过程会消耗大量 token而且修复往往只针对当前报错。更高效的方式是要求 AI 在输出代码后同时生成一组最小测试用例。比如本项目中就可以要求 AI 补充以下断言int x 1 2 * 3; // 期望 7 int y (x 1) * 2; // 期望 16有了测试用例模块开发完成后立即验证错误在早期暴露后期 token 消耗自然下降。6.4 限制 AI 的输出格式AI 默认会输出解释性文字这是 token 浪费的原因之一。我们可以在提示词末尾添加格式约束例如输出格式要求 1. 只输出代码不输出解释 2. 代码放在一个代码块中 3. 如果没有错误不要输出“成功”等多余内容。如果你使用的是 API还可以通过max_tokens参数限制输出长度。例如单次输出不超过 2048 token这样可以防止 AI 在回答末尾“宕机式”地多写几段话。6.5 常见策略对比策略节省的 token副作用适用场景每次只粘贴关键报错行中信息不足AI 可能误判报错信息较短时先用 ls/grep 定位日志中需要本地操作日志冗长的服务端场景让 AI 直接改 diff 而不是重写全文件高需要标注修改位置已有代码库迭代维护接口契约文档高前期多花一轮对话多轮迭代项目7. 常见问题与排查思路7.1 问题现象与应对速查问题现象常见原因解决思路AI 生成的代码编译报undefined reference函数声明与定义不一致或缺少链接库检查声明、定义和链接参数优先编译到 .o 再链接递归下降解析陷入死循环解析器在某分支没有消耗 token或词法分析漏掉结束符在每个解析函数入口打印当前 token检查是否存在无限递归VS Code 中智能提示失效未配置includePath或compilerPath在 C/C 扩展设置中指定编译器路径和头文件目录Keil 中提示 ARM-Compiler 不可用工程指定的编译器版本未安装在 Project Manage 中重新指定编译器路径使用合法授权环境AI 生成的代码在旧环境编译失败使用了 C17/20 特性在提示词中锁定-stdc17或统一项目标准token 消耗远超预期反复粘贴全量代码、让 AI 无边界重构截取关键信息使用接口契约约束上下文7.2 典型案例递归下降解析死循环在实现第 5 节解释器时最容易遇到的问题是“程序运行卡死”。常见原因有两个一个是parseExpr的循环条件写错导致无限循环另一个是词法分析器在遇到END后仍被调用。排查时可以在parseFactor开头加一行调试输出int parseFactor() { Token tok peek(); std::cerr 当前 token: static_castint(tok.type) std::endl; // ... }看到日志停在哪一行就能定位是哪个函数没有消耗 token。7.3 典型案例AI 生成代码的版本兼容问题AI 训练数据中包含不同时期的 C 代码它可能在一次回答中使用 C17 的std::optional下一次又使用 C11 的初始化方式。如果你在 Keil 这类嵌入式环境中使用 ARM Compiler 5更要警惕版本差异。遇到类似 Java 中 Lombok 报出 “not using a compiler supported by lombok” 这样的错误时本质也是工具链版本不匹配。建议在项目文档中固定 C 标准和编译器版本出现奇奇怪怪的语法错误时先确认代码是否超出了项目锁定的语言版本。8. 最佳实践AI 写 C 的几条铁律8.1 先把 C 标准写进规则无论你使用哪款 AI 工具第一句提示词都应该包含语言标准。原因很简单C11、C14、C17、C20 的写法差异非常大不锁定标准AI 可能给你输出一个语法上正确、但在你的编译环境下无法运行的代码。推荐写法请使用 C17 编写禁止使用 C20 特性。8.2 小文件优先于大文件AI 在生成几百行的小文件时代码质量通常更高。如果项目最终需要单文件发布可以先让 AI 生成多个逻辑模块再由人工合并。这样既能保持代码可维护性又能降低 AI 的单次出错概率。8.3 明确内存所有权在 AI 生成的 C 代码中最常见的隐患是裸指针和手动delete。AI 为了“完成任务”可能写出能编译但会造成内存泄漏的代码。建议在提示词中写优先使用 std::unique_ptr、std::shared_ptr 和值语义避免裸 new/delete。8.4 语义边界必须人工审查AI 能写出语法正确的解析器但它对项目语义一无所知。比如“除零”是否应该抛出异常、“未定义变量”是报错还是返回默认值这些业务决策必须由人确认。AI 的代码只是在你的定义框架内“填空”框架本身就是你的核心工作量。8.5 建立代码评审与测试习惯不要因为代码是 AI 生成的就跳过评审和测试。实际上AI 代码更需要自动化测试来兜底。尤其对于 C 这种不设防的语言一个内存越界可能不会立刻崩溃而是在生产环境运行几个月后才爆发。所有 AI 生成的代码都必须经过编译警告检查、单元测试和代码评审才能进入主干。8.6 尊重许可证与工具链合规编译器、IDE 和第三方库都受许可证约束。遇到 Keil 的 ARM Compiler 许可证问题、IDE 组件安装失败