
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 原始管线的设计思路我最初的管线设计其实很直接分四个阶段采集阶段遍历仓库读取所有代码文件、配置文件、依赖清单。解析阶段对每个文件做语法解析提取函数、类、导入关系。关联阶段构建跨文件的调用图把函数之间的引用关系连起来。审计阶段把关联后的信息分批送给模型让模型判断潜在问题。这个设计在小仓库上跑得很好因为文件少、调用关系简单每一批的上下文都能控制在窗口以内。但它的致命缺陷在于关联阶段是全局的。它不管仓库多大都要先把整个调用图建出来然后再分批送模型。调用图本身是内存里的数据结构序列化成文本后体积会膨胀好几倍。更麻烦的是审计阶段每分析一个函数都要把它的调用上下文一起带上。一个被调用 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而是想明白了一件事审计管线的核心能力不是分析而是上下文管理。谁能把上下文管好谁就能在真实仓库上跑得动。