ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

代码审计管线优化:从908K tokens到210K的上下文管理实战

代码审计管线优化:从908K tokens到210K的上下文管理实战

1. 一次真实仓库接入,把审计管线推到极限

1.1 从玩具仓库到真实仓库的转折点

做代码审计工具的人都有一个心照不宣的默契:测试用例永远用自己写的小仓库,几十个文件、几百行代码,跑起来飞快,日志干净,结果好看。这种状态持续了挺长时间,直到某天我把一个真实开源仓库接进管线,整个系统当场就撑不住了。

那个仓库不算特别夸张,中等规模,语言混杂,历史提交多,依赖树深。但就是这么一个东西,让我的审计管线吐出了908K tokens的中间产物。这个数字不是最终报告的长度,而是管线在分析过程中累积的上下文总量。换句话说,管线在处理这个仓库时,把大量原始代码、依赖信息、调用关系、历史变更全部塞进了上下文窗口,最后直接爆掉。

这件事让我意识到一个很现实的问题:审计管线的瓶颈从来不在模型能力,而在上下文管理。你可以在小仓库上跑出漂亮的结果,但真实世界的仓库会把你所有的假设全部打碎。这篇文章就是这次排查的完整记录,包括问题怎么暴露、根因怎么定位、管线怎么改造,以及我踩过的那些坑。

适合谁看?如果你正在做代码审计、静态分析、AI 辅助代码理解,或者任何需要把大仓库喂给模型的工具,这篇内容应该能帮你少走一些弯路。如果你只是好奇 908K tokens 是怎么来的,也可以往下看,我会把每一步的 token 消耗拆开讲。

1.2 908K tokens 到底意味着什么

先把这个数字具象化一下。908K tokens,按英文代码平均 1 token 约等于 3 到 4 个字符来算,大概是 270 万到 360 万个字符。一个中等规模的开源仓库,源码加上配置文件、文档、测试用例,纯文本量大概在 50 万到 200 万字符之间。也就是说,管线产生的 token 量是原始代码量的好几倍。

多出来的部分来自哪里?我后来做了详细的归因分析,大致分布是这样的:

来源占比说明
原始源码约 22%仓库本身的代码文件
依赖与锁文件约 18%依赖树展开后的完整信息
调用关系图约 25%函数级调用链的序列化结果
历史变更约 15%提交记录与 diff 摘要
中间推理约 20%模型在分析过程中的思考与重试

这个分布很说明问题。真正需要分析的源码只占两成出头,剩下八成都是管线自己"制造"出来的上下文。调用关系图和中间推理这两项加起来占了 45%,它们不是仓库本身的内容,而是管线在处理过程中生成的中间产物。这些产物被反复塞回上下文,形成了滚雪球效应。

提示:如果你的审计管线 token 消耗远超源码本身,问题大概率不在仓库,而在管线的上下文策略。

2. 管线架构与上下文膨胀的根因拆解

2.1 原始管线的设计思路

我最初的管线设计其实很直接,分四个阶段:

  1. 采集阶段:遍历仓库,读取所有代码文件、配置文件、依赖清单。
  2. 解析阶段:对每个文件做语法解析,提取函数、类、导入关系。
  3. 关联阶段:构建跨文件的调用图,把函数之间的引用关系连起来。
  4. 审计阶段:把关联后的信息分批送给模型,让模型判断潜在问题。

这个设计在小仓库上跑得很好,因为文件少、调用关系简单,每一批的上下文都能控制在窗口以内。但它的致命缺陷在于:关联阶段是全局的。它不管仓库多大,都要先把整个调用图建出来,然后再分批送模型。调用图本身是内存里的数据结构,序列化成文本后体积会膨胀好几倍。

更麻烦的是,审计阶段每分析一个函数,都要把它的调用上下文一起带上。一个被调用 50 次的工具函数,它的上下文会被重复送 50 次。这种重复在小型仓库里不明显,但在真实仓库里就是灾难。

2.2 上下文膨胀的三个放大器

我把导致 908K tokens 的原因归结为三个放大器,它们单独看都不致命,但叠加在一起就爆了。

第一个放大器是依赖树的完全展开。很多仓库的依赖清单里写着几十个直接依赖,但每个直接依赖背后还有自己的依赖。如果管线把依赖树完整展开,一个中等项目很容易展开出上千个包。每个包的元信息、版本、许可证、已知问题,全部塞进上下文,这一项就能吃掉十几万 tokens。

第二个放大器是调用图的文本序列化。调用图在内存里是节点和边,但送给模型时必须转成文本。我当时的做法是把每个函数的完整签名、参数、返回值、被调用位置全部列出来。一个函数平均产生 200 到 300 tokens 的描述,一万个函数就是两三百万 tokens。虽然后来做了分批,但分批的边界没处理好,导致跨批次的函数被重复描述。

第三个放大器是模型的重试与反思。当模型对某个函数的判断不确定时,管线会让它重新分析。每次重试都会把之前的上下文再送一遍,有时候还会加上"你上次的分析是……请重新考虑"这样的提示。重试率在真实仓库上高达 30%,这一项直接让 token 消耗翻倍。

2.3 为什么小仓库测试发现不了

这里有个很值得说的点:为什么我在小仓库上测试了那么久,都没发现这些问题?

因为小仓库的规模不足以触发任何一个放大器。依赖树只有几个包,调用图只有几十个节点,模型几乎不需要重试。所有问题都被规模掩盖了。这就像用一杯水测试水坝的抗压能力,永远测不出问题。

我后来总结了一条经验:审计管线的测试必须用真实规模的仓库,而且要选那种"脏"的仓库——语言混杂、依赖多、历史长、有大量自动生成代码。干净的示例仓库只会给你虚假的信心。

3. 排查过程:从爆掉到定位根因

3.1 第一次爆掉时的现场

第一次跑真实仓库时,管线在审计阶段跑到第 47 批就停了。日志显示上下文长度超过了模型窗口限制。我当时的第一反应是"分批逻辑有问题",于是把批次大小从 50 个函数降到 20 个。重跑,跑到第 89 批又停了。

这时候我意识到不是批次大小的问题,而是累积上下文的问题。管线在每一批处理完后,会把结果追加到一个全局的"审计上下文"里,下一批处理时会带上这个全局上下文。批次越小,批数越多,全局上下文累积得越厉害。降批次大小反而让情况更糟。

这个发现让我重新审视了整个管线的数据流。原来我以为的"分批"只是把输入分批,但输出是累积的。输入分批、输出累积,这个组合在长时间运行后必然爆掉。

3.2 用 token 计数定位消耗大户

为了搞清楚 token 到底花在哪里,我在管线的每个阶段都加了 token 计数。具体做法是在每次调用模型前,用分词器统计当前上下文的 token 数,并打上阶段标签。跑完一次后,把日志按阶段聚合,得到了前面那张分布表。

这个计数过程本身也有坑。我一开始用的是模型自带的分词器,但发现它和实际计费的分词方式有细微差异。后来改用通用的分词库做估算,虽然不精确,但足够定位问题。关键是相对比例,不是绝对数值。

定位下来,最大的消耗户是调用图的序列化。我原本以为依赖树是大头,结果调用图占了四分之一。原因是我在序列化时把每个函数的完整源码也带上了,而不是只带签名和摘要。一个函数平均 30 行代码,一万个函数就是 30 万行,这个量级直接爆炸。

3.3 根因确认:三个设计缺陷

排查到最后,确认了三个设计缺陷:

缺陷一:全局累积上下文。管线维护了一个不断增长的全局上下文,每批结果都往里追加。这个设计在批数少时没问题,批数一多就失控。

缺陷二:调用图序列化粒度过细。把函数完整源码塞进调用图描述,而不是只保留签名和关键行。这导致同样的代码在调用图和源码两个地方各出现一次。

缺陷三:重试策略没有上下文隔离。重试时把整个历史上下文重新送一遍,而不是只送当前函数的相关信息。重试率一高,消耗就失控。

这三个缺陷有一个共同点:它们都是在小规模下看起来合理、在大规模下才暴露的设计。这也是为什么真实仓库测试如此重要。

4. 管线改造:从 908K 降到可控范围

4.1 改造一:用滑动窗口替代全局累积

第一个改造是把全局累积上下文换成滑动窗口。具体做法是:只保留最近 N 批的结果摘要,更早的结果压缩成一句话的结论,不再保留细节。

这里的关键是摘要的质量。如果摘要太粗,后续批次会丢失重要信息;如果太细,又起不到压缩作用。我试了几种方案,最后用的是一种分层摘要:每个函数保留一行结论(如"函数 X 存在未校验的输入"),每个文件保留一段汇总,整个仓库保留一个全局概览。

滑动窗口的大小需要根据模型窗口和批次大小来算。假设模型窗口是 128K tokens,批次输入占 40K,那么留给历史上下文的预算大概是 60K。按每批摘要 2K 算,可以保留 30 批的历史。这个数字不是固定的,要根据实际仓库调整。

注意:滑动窗口的边界要按"逻辑单元"切,不要按固定条数切。比如一个文件的函数最好在同一批里处理完,否则跨批次的调用关系会丢失。

4.2 改造二:调用图只保留必要信息

第二个改造是重新设计调用图的序列化格式。原来的格式是这样的:

函数: processOrder 文件: src/order/processor.js 行号: 45-120 源码: function processOrder(order) { // 30 行完整代码 } 被调用: 12 次 调用者: [list of 12 callers]

改造后变成:

函数: processOrder @ src/order/processor.js:45 签名: processOrder(order) -> Result 关键行: 52(校验), 67(写库), 89(返回) 被调用: 12 次 调用者: [caller1, caller2, ...]

关键变化是去掉完整源码,只保留签名和关键行号。关键行怎么选?我的做法是让解析器标记出所有涉及外部调用、条件分支、异常抛出的行,这些行是审计的重点。这样每个函数的描述从 200 多 tokens 降到 40 左右,压缩了 80%。

有人可能会问:去掉源码后,模型还能准确判断吗?实测下来,对于大部分审计任务,签名加关键行已经足够。如果模型需要看完整源码,管线会按需加载,而不是预先塞进去。这个"按需加载"的机制是改造的关键。

4.3 改造三:重试时隔离上下文

第三个改造是重试策略。原来的重试是把整个历史上下文重新送一遍,改造后只送当前函数的相关信息加上一个简短的历史结论。

具体做法是:每次分析函数时,管线维护一个"当前函数上下文",包含函数签名、关键行、直接调用者、被调用者。重试时只重置这个局部上下文,不动全局历史。这样重试的额外消耗从"整个历史"降到"一个函数的上下文",差距是数量级的。

改造后重试率没有明显变化,但重试带来的 token 消耗降低了 90% 以上。这说明重试本身不是问题,重试时带上无关上下文才是问题。

4.4 改造效果对比

三个改造做完后,同一个仓库的 token 消耗从 908K 降到了 210K 左右。具体对比:

指标改造前改造后变化
总 token 消耗908K210K-77%
调用图占比25%8%-68%
中间推理占比20%9%-55%
重试消耗高极低-90%
审计覆盖率100%98%-2%

覆盖率掉了 2 个百分点,是因为滑动窗口和按需加载导致少量边缘函数没被分析到。这个代价可以接受,因为那 2% 大多是自动生成的代码和测试文件,审计价值本来就低。

5. 实操中的坑与排查技巧

5.1 分词器不一致导致的误判

排查过程中我踩过一个坑:用模型自带的分词器统计 token 数,结果和实际消耗对不上。后来发现是因为管线在拼接上下文时加了一些特殊标记,这些标记在不同分词器下的 token 数不一样。

解决办法是统一用同一个分词库做估算,并且在拼接上下文时避免使用特殊标记。如果必须用,就把标记的 token 数单独算出来加上。这个坑不大,但会让你对 token 消耗的判断产生偏差,进而影响改造决策。

5.2 批次边界切错导致调用关系断裂

滑动窗口改造后,我遇到过一个诡异的问题:某些函数的审计结果明显不对,模型说"没有找到调用者",但实际上调用者就在隔壁批次里。

原因是批次边界按固定条数切,把一个文件的函数切到了两个批次。前一批的函数看不到后一批的调用者,后一批也看不到前一批的被调用者。解决办法是按文件或按模块切批次,保证一个逻辑单元内的函数在同一批。如果单个文件太大,就按函数聚类切,把相互调用的函数聚在一起。

5.3 摘要丢失关键信息

分层摘要的压缩率很高,但有时候会把关键信息压掉。我遇到过一次:摘要里写"函数 A 存在输入校验问题",但没写具体是什么问题。后续批次分析调用 A 的函数时,模型不知道 A 的具体问题,导致误判。

解决办法是摘要要保留"问题类型"和"影响范围",而不是只保留结论。比如改成"函数 A 存在输入校验问题(类型:边界检查缺失;影响:所有调用 A 的写操作)"。这样后续批次能拿到足够的信息做判断。

5.4 常见问题速查表

现象可能原因排查方向解决思路
管线跑到一半爆掉全局上下文累积检查是否有全局累积变量换成滑动窗口
token 消耗远超源码量序列化粒度过细统计各阶段 token 占比压缩调用图描述
重试消耗异常高重试带全量上下文检查重试时的上下文构造隔离局部上下文
审计结果前后矛盾批次边界切错检查批次切分逻辑按逻辑单元切批
摘要后信息丢失摘要粒度过粗检查摘要模板保留问题类型和影响
分词统计不准分词器不一致对比不同分词器结果统一分词库

5.5 几条实操心得

第一,先测量再优化。我一开始想当然地以为依赖树是大头,结果测下来调用图才是。没有测量就优化,很容易优化错地方。

第二,压缩要有损但可控。完全无损的压缩做不到,也没必要。关键是知道哪些信息可以丢,哪些必须留。我的经验是:签名和关键行必须留,完整源码可以丢;问题类型必须留,具体描述可以压。

第三,按需加载比预先塞入更可靠。管线不需要在开始就把所有信息塞进上下文,而是等模型需要时再加载。这个思路转变让 token 消耗降了一个数量级。

第四,真实仓库测试不可替代。小仓库测试只能验证功能,验证不了规模。有条件的话,准备几个不同规模、不同"脏"程度的真实仓库做回归测试。

6. 后续可以继续优化的方向

这次改造把 token 消耗降到了可控范围,但还有优化空间。我目前在做的一个方向是增量审计:仓库更新时只审计变更部分,而不是全量重跑。这需要维护一个审计状态缓存,记录每个函数的审计结果和对应的代码哈希。代码没变就跳过,变了才重新审计。

另一个方向是调用图的图数据库化。现在调用图是序列化成文本送模型,未来可以存在图数据库里,模型通过查询接口按需获取子图。这样上下文里只放查询结果,不放整个图。这个改造比较大,但潜力也大。

还有一个方向是多模型分工。小模型做初筛,大模型做精审。初筛阶段用便宜的小模型过滤掉明显没问题的函数,只把可疑的送给大模型。这样能在保证覆盖率的同时降低整体成本。

这些方向我还在试验,有结果了再单独写一篇。这次排查最大的收获不是把 908K 降到了 210K,而是想明白了一件事:审计管线的核心能力不是分析,而是上下文管理。谁能把上下文管好,谁就能在真实仓库上跑得动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进