☰
AST剪枝在LLM API代理层如何精准削减Token成本
2026/10/25 15:05:03 网站建设 项目流程

开头

最近在排查 LLM 应用线上成本时,我发现一个很有意思的规律:很多真正拖垮性能和账单的,不是模型选得不对,而是大量无意义的 token 被送进了上下文。

用大模型做代码补全、代码审查、Agent 自动改代码这类场景时,模型输出天然会夹带大量冗余内容:解释性注释、空行、未变更的上下文代码块、被注释掉的旧版本,甚至品牌前缀和版权声明。这些内容既不影响程序语义,又真实计费。一次请求看起来没多少,放到调度频次高的业务里,日积月累就是不小的数字。

传统的优化思路集中在两个方向:一是换更便宜的模型,二是把 prompt 写得更精简。但这两个方案都有瓶颈——前者牺牲质量,后者逼近极限后很难再压。有一个值得关注的新方向是:在 API 代理层用 AST pruning(抽象语法树剪枝)主动裁剪模型输出,在语义不变的前提下减少 token 返回量。

这个思路的亮点在于:它不动模型、不动 prompt、不动业务代码,只在你和大模型之间的代理层做“精准瘦身”。它能解决什么问题、适合哪些场景、实现时有哪些坑,是本文要展开的内容。

1. 这篇文章真正要解决的问题

先看一个非常典型的开发场景。你接入了某个代码生成 API,每天被调用几千次。模型按输入加输出的总 token 数计费,输出端偶尔生成 3 到 4 倍于必要量的内容。表面看单次多花几厘钱,但一旦把并发、调用频次、Agent 多轮迭代这些因素叠上来,账单涨幅是乘法级的。

更麻烦的是上下文长度问题。用过 Claude 58K token 这类超长上下文的人会有体感:token 拉满之后,单次请求耗时明显增加,首字返回延迟变高,甚至可能触发服务端的上下文长度限制。你明明只想要一段核心代码,模型却回复了一整段历史代码、格式说明和变更记录。

这个问题可以拆成三层:

  1. 成本层:token 比代码字符更贵,因为 token 是计费单位,不是字符数。
  2. 性能层:输出 token 越多,推理时间越久,客户端等待越长,下游链路被拖慢。
  3. 质量层:冗余内容会稀释真正重要的信息,尤其在 Agent 把模型输出继续传给下一步工具时,干扰更大。

AST pruning 解决的是第二层和第一层的问题。它在不改变代码可执行语义的前提下,把输出文本中描述性的、重复的、不可执行的部分裁掉。听起来有点像压缩,但它不是通用压缩,而是“理解代码语法之后只留下关键结构”。

这篇文章适合以下读者:

  • 正在做 LLM Agent、代码生成工具、Code Review 机器人的开发者;
  • 使用 Claude、GPT 等模型 API 做大规模代码处理的工程团队;
  • 关心 token 成本控制,但不想牺牲输出质量的应用开发者;
  • 对 AST、tree-sitter、编译器前端技术有一定了解,想把它用在 LLM 链路上的同学。

读完这篇文章,你会明白 AST pruning 的基本原理,清楚它应该放在架构的哪一层,并且能照着做一个最小可运行的示例。

2. LLM 输出里的 token 冗余,到底从哪里来

要理解 AST pruning 的价值,先要看清冗余 token 的来源。它们不是模型“乱说话”,而是由应用场景和模型训练方式共同造成的。

2.1 来源一:模型自带的“解释倾向”

大模型在训练时学习了大量代码仓库、技术博客、Stack Overflow 问答,这些数据天然带有解释性文本。当用户请求“帮我把这段 Python 函数加个缓存”时,模型很可能返回:

  1. 一段说明文字,解释它做了什么;
  2. 完整代码块;
  3. 使用示例;
  4. 注意事项。

其中第 1、3、4 项对最终接入系统来说往往是噪音。这不是模型故意浪费,是因为它被训练成“像一个人一样完整回答”。

2.2 来源二:代码补全和 Code Review 中的上下文重复

在代码补全场景下,为了避免生成脱离上下文的代码,模型输入会携带大量项目上下文。输出的代码里常有一大段是“为了衔接上下文而重复出现的已有代码”。真正的增量可能只有几行。

Code Review 场景更明显。模型把整个 diff 读进来,然后输出一份 review 意见,其中大量文本是“这段代码看起来功能正常”“建议补充单元测试”这类通用话术。对开发者有价值的是少数几个风险点。

2.3 来源三:结构化输出里的固定包装

JSON 模式输出、Markdown 代码块包裹、XML 标签封装,都会带来额外的 token 开销。比如模型输出:

```python def add(a, b): return a + b
对服务端消费来说,有用的只是 `def add(a, b): return a + b` 这行,Markdown 围栏本身是包装。如果下游解析逻辑设计得好,它可以不要。 ### 2.4 token 计费和字符计费的本质差异 这里有一个核心认知需要建立:**token 不是按字符数量计的**。英文一个 token 大约对应 4 个字符,代码中的符号、空格、换行也会被拆成多个 token。换句话说,一段格式化良好、注释丰富的代码,在 token 账单上比“压缩后的等价代码”贵得多。 这就是为什么“只是删掉几段注释”也能省钱:不是文案层面的精简,而是 token 层的物理减少。 对这个问题,模型侧的 FP16、FP32、BF16 等精度优化主要降低“单个 token 的推理显存和算力成本”,而 AST pruning 降低的是“总 token 数量”。两者是互补关系:前者提高单 token 效率,后者减少 token 总数。 ## 3. AST pruning 是什么,为什么它能精准裁剪 ### 3.1 从一个类比说起 假设你要把一栋楼搬走。最笨的办法是把所有家具、墙纸、门窗、螺丝都数一遍,把完整清单报给搬运公司。稍微聪明一点的办法是:你只告诉搬运公司“承重墙和关键管线在哪”,剩下的装修材料是可以在现场重做的。 AST pruning 的思路就是这个“聪明的办法”。 代码被解析成抽象语法树后,存在两类内容: - **结构性内容**:函数定义、类定义、变量声明、控制流语句,这些决定代码行为; - **装饰性内容**:注释、空行、多余的括号、未使用的 import、格式化空白、被注释掉的旧代码。 AST pruning 就是遍历这棵树,识别并剪掉“装饰性内容”,保留“结构性内容”,然后重新生成精简后的代码。因为裁剪发生在语法树层面,所以它不会像正则表达式那样误删字符串里的内容。 ### 3.2 技术定义 抽象语法树(AST,Abstract Syntax Tree)是源代码的一种树形表示。树的节点对应代码中的语法结构,比如“函数定义”“二元运算”“赋值语句”。每个节点携带足够的信息,让工具可以判断它的类型、位置和子节点关系。 代码解析成 AST 后,程序可以通过访问者模式遍历节点,对每个节点做判断:这个节点是否可以被剪掉,删除后是否影响父节点和兄弟节点。这一步就是“剪枝”,英文术语是 pruning。 当 AST 被剪枝后,调用代码生成器把树重新输出为代码文本,就得到一份精简版本。因为生成过程基于语法树,所以关键空格和换行会被统一规范化,这也顺带完成了部分格式化工作。 ### 3.3 一个容易误解的点 有些人会把 AST pruning 误解为“压缩代码”,类似把变量名改成单字母。不是的,AST pruning 不改变标识符,不改变控制流逻辑,不改变函数签名,它只移除那些“不改变程序语义”的内容。 这听起来保守,但对 LLM 代理场景来说,这个保守恰恰是优点。因为代理层不应该承担“改写代码逻辑”的责任,它只负责去掉冗余,让下游拿到的代码和模型意图一致。 ### 3.4 哪些内容可以安全剪掉 从工程实践看,以下几类是“高性价比”的剪枝目标: | 内容类型 | 是否可剪 | 风险等级 | 说明 | | --- | --- | --- | --- | | 整行注释 | 可选 | 低 | 如果只关心代码逻辑可剪,但会降低可读性 | | 独立空行 | 可剪 | 极低 | 不影响语义,只影响排版 | | 被注释掉的旧代码块 | 可剪 | 极低 | 一般不会参与执行 | | 文档字符串中的冗余描述 | 可选 | 中 | 取决于下游是否消费 docstring | | 未使用的 import | 可剪 | 中 | 可能引发 lint 告警,但一般不影响运行 | | 格式化空白 | 可剪 | 极低 | 由代码生成器统一规范化 | | 字符串内部注释 | 不可剪 | 高 | 字符串是数据,不能动 | ### 3.5 适用的语言场景 AST pruning 对每个语言都需要对应的解析器。最常见的是 Python 和 JavaScript/TypeScript,因为这两类语言在 LLM 编程场景占据主导。Java、Go、Rust 等也可以用,但解析器配置会更复杂。 如果输入不是结构化的代码,而是自然语言文本,AST pruning 不适用。它的作用边界在“代码生成与代码处理类 LLM 应用”,不是通用文本压缩工具。 ## 4. API proxy 为什么是放置剪枝逻辑的最优层 ### 4.1 三个常见的放置位置对比 先看两种“看起来更简单”的方案。 **方案一:在模型 prompt 里写“不要输出多余内容”。** 这个方案的问题是:prompt 约束是概率性的,模型偶尔遵守,偶尔不遵守。尤其在上下文很长、任务复杂时,模型更容易“啰嗦”。你无法保证输出端稳定瘦身。 **方案二:在业务代码里做字符串后处理。** 这个方案的问题是:后处理逻辑分散在各个调用点,每个业务场景都要写一遍,维护成本高。而且字符串处理很容易误删代码内容。 **方案三:在 API 代理层做统一后处理。** 所有模型请求经过同一个代理,代理响应后统一做 AST 解析、剪枝和重新格式化,再把精简结果返回给业务。业务代码无感知,模型无感知,所有改造集中在一层。 方案三是工程上更合理的方案,理由有三个: 1. **集中化**:剪枝逻辑只写一次,所有调用方共享; 2. **无侵入**:上游业务不用改代码,不引入新 SDK; 3. **可灰度**:代理层可以按请求路径、用户、模型名做开关,随时回滚。 ### 4.2 API proxy 的常见形态 在 LLM 技术栈里,API proxy 已经是一个被验证的模式。它位于业务应用和大模型 API 之间,负责请求转发、密钥管理、限流、监控等横切关注点。像 LiteLLM、One API 等工具都是这一类思路的代表。 如果代理本身支持中间件或插件机制,就可以把 AST pruning 实现为一个独立中间件。请求发出时原样转发,响应返回时在代理内做解析、剪枝、重组,然后返回给调用方。 ### 4.3 代理层剪枝必须考虑的问题 把剪枝放在代理层有一个非常现实的问题:**剪枝会增加响应延迟**。AST 解析和重新生成代码需要时间,如果剪枝本身比节省的传输时间还慢,那对用户感知就是负优化。 解决方案是分层处理: - 对延迟敏感的场景,可以只做“轻量剪枝”,例如删除独立注释行和多余空行; - 对离线批处理场景,可以做完整 AST 剪枝和未使用 import 清理; - 代理层需要支持策略配置,按路由或模型动态选择剪枝力度。 另一个问题是代理层需要知道输出内容的语言类型。如果模型输出是 JSON,自然没有 AST 可解析。因此在代理设计上,需要通过请求元数据或内容嗅探判断“这段响应能不能做 AST 剪枝”。 ### 4.4 和 Agent 编排框架的关系 在 Spring AI + MCP + RAG + Agent 这类技术栈中,代理层剪枝的价值更明显。Agent 每一步调用模型,模型输出还要继续传给工具或下一轮 prompt。如果每一步输出里都带着大量冗余文本,多轮 Agent 流程的 token 消耗呈“乘法增长”,而不是“加法增长”。在这种情况下,API proxy 统一做一次剪枝,能给整个 Agent 链路带来明显收益。 ## 5. 核心架构设计:在代理链路上插入 AST 剪枝中间件 ### 5.1 整体架构示意图 本文不画 Mermaid,用文字描述架构层次:

业务应用 ↓ LLM API Proxy(统一入口:鉴权,限流,日志) ↓ AST Pruning 中间件(响应后处理阶段) ├── 语言识别 ├── AST 解析 ├── 剪枝规则引擎 ├── 代码重新生成 └── 结果缓存 ↓ 大模型 API(OpenAI / Anthropic / 自部署模型)

请求链路不变,响应链路多一道后处理。代理向模型发起请求,拿到完整原始响应,经过 AST 剪枝后,把精简结果返回给业务应用。原始结果可以选择缓存下来用于审计调度。 ### 5.2 中间件的关键接口设计 设计上,中间件只需要实现一个函数: `prune_response(original_text, language) -> pruned_text` 输出应该满足三个原则: 1. **语义等价**:剪枝后的代码执行结果与原代码一致; 2. **格式稳定**:重新生成的代码符合标准格式化规范; 3. **可审计**:代理层能记录原 token 数和剪枝后 token 数,便于效果量化。 ### 5.3 剪枝力度分级 | 等级 | 剪枝内容 | 适用场景 | | --- | --- | --- | | L0 | 不剪枝 | 需要保持原样输出的场景 | | L1 | 只删独立注释行、多余空行 | 在线实时请求,延迟敏感 | | L2 | L1 + docstring 精简 | 普通代码生成服务 | | L3 | L2 + 未使用 import 清理 | 离线批处理、代码入库前清洗 | 代理配置中可以通过请求参数或环境变量控制等级。上线初期建议先用 L1,观察对下游消费端的影响,再逐步提升力度。 ## 6. 最小实现示例:用 tree-sitter 做 AST 解析与剪枝 下面用一个最小示例演示 AST pruning 的核心流程。技术选型为: - Python 3.10+ - tree-sitter 库及其 Python 绑定 - 以 Python 代码为例做解析和剪枝 ### 6.1 安装依赖 ```bash pip install tree-sitter tree-sitter-python

版本以实际安装为准,不同版本的 tree-sitter API 可能有细微差异。

6.2 加载 Python 解析器

from tree_sitter import Language, Parser PY_LANGUAGE = Language("build/python.so", "python") parser = Parser(PY_LANGUAGE)

如果使用 tree-sitter 较新的 Python 绑定,也可以使用tree_sitter_python包直接加载语言:

from tree_sitter import Language, Parser import tree_sitter_python PY_LANGUAGE = Language(tree_sitter_python.language()) parser = Parser(PY_LANGUAGE)

这里以第二种方式为主,因为它无需手动编译.so文件。

6.3 解析代码为 AST 并找出可剪枝节点

source = b'''# TODO: 后续重构这里 import os import sys def add(a, b): """返回两个数字的和""" # 计算逻辑 return a + b # 下面是测试代码 result = add(1, 2) print(result) ''' tree = parser.parse(source) root = tree.root_node

AST 解析完成后,root是 Module 节点,可以通过root.children查看顶层节点列表。

6.4 实现最简单的剪枝函数

不考虑复杂边界,只演示“删除独立注释行和纯空行”这一最小可用版本:

def prune_python_source(source_bytes: bytes) -> str: tree = parser.parse(source_bytes) root = tree.root_node # 收集所有需要删除的区间(行级别) remove_ranges = [] def walk(node): # 注释节点 if node.type == "comment": remove_ranges.append((node.start_byte, node.end_byte)) # 表达式语句里的字符串,考虑 docstring 场景 if node.type == "expression_statement": child = node.children[0] if node.children else None if child and child.type == "string": # docstring:这里保守起见,不做删除,只记录 pass for child in node.children: walk(child) walk(root) # 注意:必须先按字节范围倒序删除,否则偏移量变化 remove_ranges.sort(key=lambda x: x[0], reverse=True) result = bytearray(source_bytes) for start, end in remove_ranges: del result[start:end] return result.decode("utf-8")

这个示例只删注释,不删 docstring。实际工程中可以扩展更多规则,例如折叠连续空行、删除被注释掉的代码块等。

6.5 调用与输出示例

pruned = prune_python_source(source) print(pruned)

输出:

import os import sys def add(a, b): """返回两个数字的和""" return a + b result = add(1, 2) print(result)

对比原代码可以看到,独立注释被删除,但 docstring 被保留。空行也存在,因为代码生成逻辑没有做压缩。如果需要进一步压缩,可以在字节区间处理后再做一次空行合并。

6.6 为什么用字节区间而不是文本替换

AST 节点位置信息是字节数组下标,不是字符串下标。删除时采用倒序删除,避免前面删除导致后面坐标失效。这是实现时最容易踩的坑。如果顺序不对,会得到错误结果甚至触发数组越界。

6.7 一个更完整的 L1 剪枝示例

上面的实现只处理了注释。L1 级别的剪枝还需要处理连续空行。可以采用两步:

  1. 按 AST 节点删除注释;
  2. 对结果做正则级别的空行合并。
import re def prune_python_l1(source_bytes: bytes) -> str: # 第一步:AST 删除注释 text = prune_python_source(source_bytes) # 第二步:折叠 3 个以上连续换行为 2 个 text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() + "\n"

这里第二步是纯文本处理,理论上存在误伤字符串内空行的风险,但在现代 Python 代码中,字符串内用三个换行的情况非常少。生产环境如果要更安全,可以基于 AST 的 range 信息处理,而不是正则。

7. 运行结果与效果验证

7.1 什么是“成功”

剪枝成功与否,判断标准不是“代码变得多短”,而是:

  1. 剪枝前后代码的可执行结果一致;
  2. 下游解析器或编译器能正常处理;
  3. 剪枝后 token 数明显减少;
  4. 剪枝过程不引入语法错误。

7.2 验证方法一:编译/语法检查

对 Python 代码,用ast.parse验证语法:

import ast def validate_python_syntax(code: str) -> bool: try: ast.parse(code) return True except SyntaxError: return False assert validate_python_syntax(pruned)

这里用标准库ast做语法验证,和 tree-sitter 解析并不是一回事,两者互补。tree-sitter 负责节点级操作,标准库ast负责确认最终结果可被 Python 解释器接受。

7.3 验证方法二:行为一致性

对有单元测试的代码,可以在剪枝前运行测试,剪枝后再运行一次,对比结果。对单函数场景,可以比较函数返回值:

def add(a, b): return a + b # 剪枝前 assert add(1, 2) == 3 # 剪枝后 assert add(1, 2) == 3

这个例子看起来很基础,但它恰恰说明了一个核心原则:AST pruning 的目标不是让代码跑得更快,而是让代码在语义不变的前提下变得更短。

7.4 验证方法三:token 数对比

代理层需要在响应后处理前后分别统计 token 数。统计方式可以用字符串长度估算,也可以用分词器精确计算。在生产环境,建议用模型对应的官方 tokenizer 统计。

def estimate_tokens(text: str) -> int: # 简易估算,准确统计应接入对应模型的 tokenizer return len(text) // 4 original_token = estimate_tokens(source.decode("utf-8")) pruned_token = estimate_tokens(pruned) print(f"原始 token 数:{original_token}") print(f"剪枝后 token 数:{pruned_token}") print(f"节省比例:{(original_token - pruned_token) / original_token * 100:.1f}%")

不同代码库的节省比例差异很大。注释密集型代码可以节省 20% 以上,而本身就很精简的代码可能只有几个百分点。所以在项目落地时,不要预设一个固定收益值,要看实际统计。

7.5 失败排查第一步

如果剪枝后的代码在执行时报语法错误,第一步不是看剪枝规则代码,而是打印出剪枝后的完整文本,用肉眼检查。最常见的诱因是:删除注释时误删了多行字符串内部的#,或者字节区间计算错误导致半个字符被截断。第二步是关掉代理层剪枝开关,确认是否是剪枝引发的回归,而不是模型本身输出有问题。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
剪枝后出现语法错误字节区间删除顺序错误检查 collect 和 delete 的排序逻辑确保 remove_ranges 按 start_byte 倒序删除
多行字符串内容被误删把字符串内的#误判为注释节点打印节点类型和字节区间,确认删除范围增加节点类型白名单,不做通配处理
剪枝后无效果代码本身不含可剪内容统计注释节点和空行数量检查剪枝规则,确认解析器加载语言正确
延迟增加明显AST 解析和重新生成开销大在代理层加耗时日志,对比剪枝前后 P99启用 L1 轻量剪枝,或在离线管道中做完整剪枝
不同模型输出格式差异大有的模型输出 Markdown 包裹,有的输出纯代码在代理层归一化输出格式,先剥掉围栏再剪枝解析前处理 Markdown 代码块,剪枝后再按场景包装
下游解析器不识别剪枝后的代码重新生成的格式不符合下游要求检查生成器的缩进和换行策略配置标准格式化参数,统一风格

8.1 特别提醒:不要对 JSON 和自然语言做 AST pruning

AST pruning 只适用于结构化的编程语言代码。如果模型输出是 JSON、XML、YAML 或自然语言文本,即使某个字段看起来像代码片段,也不要直接对整个输出做 AST 解析。安全做法是:先解析出代码段,对代码段单独剪枝,再组装回原格式。

9. 最佳实践与工程建议

9.1 代理层配置要支持灰度

上线初期先对 1% 流量开启剪枝,观察报错率和下游反馈,然后逐步放大。配置项至少应包括:

prune.enabled=true prune.level=L1 prune.languages=python,javascript,typescript prune.routes=/api/code/*

9.2 缓存剪枝结果

同一段模型输出可能被多个请求消费,尤其代码补全场景下,重复请求很常见。代理层可以引入缓存,以模型名加输出 hash 作为 key,缓存剪枝结果。注意设置合理的过期时间。

9.3 日志要记录剪枝前后 token 数据

这是最容易忽略的一点。如果日志没有 token 差量,你无法量化这个代理到底创造了多少价值。每一条响应日志至少包含:

  • model
  • original_token_count
  • pruned_token_count
  • prune_level
  • prune_duration_ms

9.4 安全边界

剪枝逻辑本身要具备“失败即放行”能力:如果 AST 解析异常,直接返回原始响应,不要阻断业务。这个容错必须做到位,否则代理层会变成单点故障。

在生产环境改造前,务必在测试环境用小流量验证,准备好回滚开关。任何代理层改动,都涉及真实请求链路,最小权限和灰度原则同样适用。

9.5 不要把“尽量剪”变成目标

正确目标应该是只剪安全的,不剪全部的。宁可少剪一点,也不要为了数据好看而增加出错概率。代码场景中,一次语法错误可能造成比 token 成本更严重的后果。

9.6 与 tokenizer 和上下文长度配合

如果模型上下文长度有硬上限,比如 58K token,剪枝后的代码段更短,意味着同一窗口可以容纳更多轮对话和更多文件内容。这是 AST pruning 带来的另一个隐形成本收益:它让应用有机会在有限上下文窗口内完成更多推理步骤。

10. 总结与后续学习方向

AST pruning 本质上是把编译器领域成熟的技术,移植到 LLM 应用链路上的一次工程尝试。它的核心价值不是“把代码变短”,而是“在语义不变的前提下减少 token 计费总量”,并且它工作在 API 代理层,对上层业务透明。

适合优先落地的地方是代码生成、Code Review Agent、自动化重构、批量代码入库清洗等场景。如果你想低成本验证,可以从 L1 级别开始,用 Python + tree-sitter 写一个最小剪枝函数,接入一个低流量路由,统计剪枝前后的 token 差量。

进一步值得研究的方向包括:

  • 用 tree-sitter 扩展 JavaScript、TypeScript、Java、Go 的剪枝规则;
  • 在 Agent 多轮链路中统计“剪枝一次 vs 不剪枝”的整体 token 开销差异;
  • 把 AST 剪枝和模型精度优化(FP16、BF16 等推理优化)结合,算一算两项叠加的综合收益;
  • 研究哪些模型的输出“冗余率”更高,作为模型选型参考。

最后提醒一句:这类 API 代理组件,上线前一定要在测试环境做完整的语法兼容测试和行为一致性测试,并在生产环境保留快速回滚能力。技术方向再好,能安全落地才算真正的收益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询