用 LLM 分析 TB 级 CI 日志的实战
将数 TB CI 日志喂给 LLM 进行分析的案例。展示了 LLM 在开发工具链中的实际应用,对优化 CI/CD 流程有借鉴意义。
将数 TB CI 日志喂给 LLM 进行分析的案例。展示了 LLM 在开发工具链中的实际应用,对优化 CI/CD 流程有借鉴意义。
上周,我们的 Agent 将一个偶发失败的测试追溯到了三周前的一次依赖升级。它通过自行编写 SQL 查询、在十多次查询中扫描数亿行日志,并沿着从任务元数据到原始日志输出的线索完成了调查。整个过程只用了几秒钟。
要做到这一点,Agent 需要足够完整的上下文:不是某一个日志文件,而是过去数月里的每一次构建、每一个测试、每一行日志。每周大约有 15 亿行 CI 日志和 70 万个任务流经我们的系统。所有数据都会写入 ClickHouse,压缩比达到 35:1,并且全部可以在毫秒级完成查询。
我们向 Agent 开放了一个 SQL 接口,其访问范围仅限于当前正在调查的组织。Agent 会根据问题自行构造查询,没有预定义的查询库,也没有僵化的工具 API。
LLM 很擅长 SQL。训练数据中包含海量 SQL 内容,而且 SQL 语法非常适合映射成针对数据的自然语言问题。如果使用 get_failure_rate(workflow, days) 这类受限的工具 API,Agent 就只能提出我们预先设想过的问题。SQL 接口则允许它提出我们从未考虑过的问题,而在调试新型故障时,这一点至关重要。
Agent 主要查询两类目标:
任务元数据:一个物化视图,每次 CI 任务执行对应一行数据。Agent 有 63% 的查询会使用它,用于回答“这个任务多久失败一次?”“成功率是多少?”“哪些任务最慢?”“它从什么时候开始失败?”之类的问题。
原始日志行:每行日志对应一行数据。Agent 有 37% 的查询会使用它,用于回答“把这个任务的错误输出展示给我”“这种日志模式最早是什么时候出现的?”“这条错误信息在不同运行中出现过多少次?”之类的问题。
我们分析了可观测性管道中的 8,534 个 Agent 会话和 52,312 次查询。
Agent 不会只执行一次查询便停下来。它会展开调查:先从宽泛的问题入手,再逐步深入。为了回答一个问题,所有查询累计扫描的行数如下:
一个典型问题会通过大约 3 次查询扫描 33.5 万行数据。P75 时会扫描 520 万行,P95 时会扫描 9.4 亿行。扫描量最大的原始日志会话,通常是为了追踪数月历史中的错误模式而进行的深度调查,最多会扫描 43 亿行数据。
Agent 会先进行宽泛查询,再逐步缩小范围。典型调查会从任务元数据开始:“这个 workflow 的失败率是多少?”“这个 commit 上有哪些任务失败了?”这些查询成本很低,只需针对紧凑且预聚合的物化视图扫描数据,中位数为 4.7 万行。
一旦发现值得关注的线索,它就会深入原始日志:“展示这次特定故障的 stack trace”“这条错误信息以前出现过吗?”这类查询成本更高,中位数需要扫描 110 万行,是对日志输出执行的全文扫描。但如果由人来完成,同样的搜索通常需要在 GitHub Actions 日志查看器里滚动查找好几分钟。
Agent 每个会话平均执行 4.4 次查询,但复杂调查会发起更多查询。一个 P95 会话并不是只执行一次大型查询,而是 Agent 沿着线索不断追查,一次又一次发起查询,逐步锁定根本原因。
为了让 Agent 如此快速地查询,数据必须按照适合查询的方式组织。在繁忙的一天里,最多会有 3 亿行日志流经系统。我们使用 ClickHouse 来处理这些数据。
我们系统中的每一行日志都携带 48 列元数据,包含其所属 CI 运行的完整上下文:Commit SHA、作者、分支、PR 标题、workflow 名称、任务名称、步骤名称、runner 信息、时间戳等。
如果是在传统的行式存储中,这种设计堪称疯狂。通常你会进行规范化:运行级元数据存放在一张表中,任务元数据存放在另一张表中,查询时再进行 join。把 48 列数据反规范化到每一行日志上,听起来会酿成一场存储灾难。
但在 ClickHouse 的列式存储格式中,这几乎不需要额外成本。
像 commit_message 这样的列,在一次 CI 运行产生的每行日志中都拥有相同的值,而一次运行可能生成数千行日志。ClickHouse 会按顺序存储这数千个完全相同的值。压缩算法能够识别这种重复,将它们压缩到几乎不占空间。
Agent 会提出任意问题:这一次可能按 commit 作者筛选,下一次可能按 runner 标签筛选,再下一次可能按步骤名称筛选。如果不进行反规范化,每种查询都需要一次 join。采用反规范化之后,它们都只是针对列的查询条件。
仅原始日志文本就有 664 GiB。加入全部 48 列元数据后,未压缩数据量增加到 5.31 TiB,是原始文本的 8 倍。但写入磁盘后,全部数据只占 154 GiB。ClickHouse 存储了 8 倍的数据——包括所有经过丰富处理的元数据——最终占用空间却只有原始文本自身的四分之一。
平均算下来,每行日志在磁盘上大约只占 21 字节,其中还包括全部 48 列数据。没错,确实如此。一行日志,加上它的 Commit SHA、作者、分支、任务名称、步骤名称、runner 信息以及另外 41 个字段,总共只占 21 字节。
并非所有列都能达到相同的压缩效果。每行唯一的列,例如日志文本、时间戳和行号,只能得到有限压缩,因此占据了大部分存储空间。那些会在数千行中重复出现的元数据列则几乎不占空间。
占用最高的三列——line_content、ts 和 line_number——合计占全部存储空间的 53%。其余数据基本都是重复的元数据,可以被压缩到几乎不占空间。
我们采用了几种 ClickHouse 模式来维持查询速度:
主键设计让数据能够按照我们的访问模式进行物理排序。排序顺序为 (org, ts, repository, run_id, ...),因此每次查询都会限定在一个组织和一段时间范围内,ClickHouse 无须读取其他数据便能将其全部跳过。
Skip indexes 让 ClickHouse 可以避免扫描不需要的数据。我们在 14 列上使用 bloom filters,包括 org、repository、任务名称、分支、Commit SHA 等,并在 line_content 上使用 ngram bloom filter 进行全文搜索。当 Agent 在数十亿行日志中搜索一条错误信息时,ClickHouse 会检查 ngram 索引,跳过不可能包含该搜索词的 granule,从而将全表扫描转化为有针对性的读取。
物化视图会在插入数据时预先计算聚合结果。当 Agent 问“过去 30 天里,这个 workflow 的失败率是多少?”时,答案其实已经计算完成。聚合操作是在数据写入时执行的。
Async inserts 让我们无须自行构建批处理层,也能获得很高的写入吞吐量。我们以 fire-and-forget 的方式逐条插入数据,ClickHouse 会在内部对它们进行批处理。
52,000 次查询的延迟如下:
任务元数据查询的中位响应时间为 20ms。原始日志查询的中位扫描量为 100 万行,响应时间为 110ms。
延迟大致会随着扫描行数线性增长:
扫描行数增加 10 倍,延迟约增加 10 倍。全部查询中有 60% 扫描的数据少于 10 万行,并能在 50ms 内返回。这样的速度足以让 Agent 每秒连续发起多次查询,而不必打断调查节奏。在最极端的情况下,Agent 偶尔会在单次查询中扫描超过 10 亿行数据;即使如此,这些查询的中位完成时间也只有大约 30 秒。
如果没有新鲜数据,前面的一切都无法发挥作用。Agent 需要分析刚刚失败的构建,而不是一小时前失败的构建。
GitHub API 为每个 App installation 提供每小时 15,000 次请求额度,非 Enterprise 方案则为 5,000 次。这个数字听起来很宽裕,但如果你需要在数十个活跃 repository 中持续轮询 workflow 运行、任务、步骤以及日志输出,额度很快就会变得紧张。一次 commit 可能触发数百个并行任务,而每个任务都会生成需要获取的日志。
而且,数据摄取并不是唯一需要调用 API 的操作。当 Agent 调查故障时,它还会获取 PR 元数据、读取文件 diff、发表评论以及创建 pull request。所有这些操作都会消耗同一份 15,000 次请求额度。对数据摄取限流过于严格,数据就会过时;限流不够严格,又会耗尽 Agent 完成工作所需的 API 访问额度。
早期我们就遇到过这个问题。数据摄取会一头撞上速率限制,在该小时剩余的时间内一直被阻塞,进度也会随之落后。等到它终于追上进度时,我们摄取的已经是 30 多分钟前的日志了。对于需要分析刚刚失败构建的 Agent 来说,这样的数据毫无用处。如果工程师还得等待 Agent 追上进度,他们早就已经切换上下文,开始手动调查问题了。
解决方案是限流:将请求均匀分散到整个速率限制窗口中,而不是突发式集中请求。我们将数据摄取限制在大约每秒 3 次请求,为 Agent 每小时预留大约 4,000 次请求。
我们持续保持的请求速率如下:
我们的速率限制额度随时间的变化如下:
这种锯齿形就是系统的稳定状态。每一段向下的斜线都表示我们正在消耗 API 请求额度;每一次垂直上升都表示每小时的限制被重置。在峰值期间,我们会在窗口重置前用掉大部分额度,同时仍为 Agent 留出余量。
确认限流机制可靠之后,我们将数据摄取速率又提高了大约 20%:
虚线标记了部署时间点。变更之后,额度下降得更加明显。我们在每个窗口中使用了更多可用余量,同时仍未将其彻底耗尽。由此获得了更新鲜的数据,并保留了可接受的安全空间。
我们的目标是将 P95 数据摄取延迟控制在 5 分钟以内,也就是从事件在 GitHub 上发生,到它能够在我们的系统中被查询之间的时间。绝大多数时候,延迟只有几秒钟。
我们的数据摄取管道和 Agent 都运行在 Inngest 上,它是一个 durable execution engine。无论哪一方遇到速率限制,都不会崩溃、盲目重试或陷入循环,而是会暂停执行。
GitHub 速率限制响应的 header 会准确告诉你需要等待多长时间。我们读取这个值,再增加 10% 的 jitter,避免限制重置时出现 thundering herd,然后暂停执行。此时完整状态都会被 checkpoint:包括 workflow 的处理进度、已经获取了哪些任务,以及日志分页当前进行到哪里。
等待结束后,执行会从之前中断的位置准确恢复。无须重新初始化,也不会产生重复工作。系统会像什么都没发生过一样,继续发起下一次 API 调用。
再看看另一种方案:重试逻辑、状态恢复以及去重。每个函数都必须具备 idempotent 特性,每个被中断的批次都需要进行 reconciliation。借助 durable execution,速率限制就只是一个暂停按钮。
CI 活动具有突发性。有人合并了一个大型 PR、有人切出了 release 分支,或者三个团队同时推送代码,都会形成流量高峰。我们的函数吞吐量如下:
灰线表示排队等待的工作量。在 CI 活动突发时,它会飙升到 3,000 以上。蓝线和绿线分别表示已开始和已结束的任务,它们则平稳维持在 800~1,000。执行引擎会吸收流量峰值,并以稳定速率处理工作。
数据摄取延迟随时间的变化如下:
高峰活动期间会出现尖峰,但系统能够恢复。P95 延迟不超过 5 分钟的目标得以保持:突发流量会在短时间内推高延迟,一旦队列处理完毕,延迟就会重新回落到几秒钟。
不会有人把“我们构建了一个非常出色的 rate limiter”写在产品首页上。但如果没有新鲜且可查询的数据,你的 Agent 就无法回答真正重要的问题:这是我弄坏的,还是它原本就已经坏了?