上周四晚上,我们的AI代码审查服务出了个线上Bug,我排查了一整夜,直到第二天早上才找到根因。

这个Bug很诡异,表面上看是AI返回的审查结果有问题,但排查下去发现问题比想象中复杂得多。这篇文章记录一下完整的排查过程,也给做AI应用的朋友一些参考。

故障现象

先说说故障现象。

我们的AI代码审查服务是一个集成在Git平台里的工具,开发者提交代码之后,服务会自动调用大模型API对代码进行审查,返回审查意见和改进建议。

那天晚上开始有用户反馈,说代码审查的结果不对,经常出现牛头不对马嘴的评论。比如审查的是Python代码,返回的评论却是关于Java的;或者代码里根本没有的问题,AI却煞有介事地指出来。

监控面板显示,服务的错误率没有明显升高,API调用也都正常返回了200。但用户反馈的问题确实存在,而且越来越多。

最诡异的是,这个问题不是所有用户都有,大概30%的代码审查会出问题,而且集中在某些特定的代码仓库上。

初步排查

第一步先看日志。

服务的日志显示,大模型API的调用都是成功的,返回的HTTP状态码都是200,响应时间也正常。但把返回的内容打出来看,发现有些返回的审查结果确实和提交的代码不相关。

比如有个用户提交了一段Go代码,AI返回的审查意见却是关于C++内存管理的。这明显不对,说明AI拿到的代码和用户提交的代码不一样。

我开始怀疑是代码获取的环节出了问题。我们的服务是通过Git平台的API获取提交的代码diff,然后把diff传给大模型。会不会是获取diff的时候出错了?

我查了一下代码获取的日志,发现获取到的diff是正确的,和用户提交的一致。那问题就出在把diff传给大模型的环节。

深入排查

我把传给大模型的完整prompt打出来看,发现了一个奇怪的现象。

有些prompt里的代码diff是正确的,但有些prompt里的代码diff是错的,和用户提交的完全不一样。更奇怪的是,错误的diff似乎来自其他用户的提交。

这说明出现了数据串扰,用户A的代码被当成了用户B的代码传给了大模型。

数据串扰一般是并发问题或者缓存问题。我开始检查代码里的并发逻辑和缓存逻辑。

我们的服务用了一个全局变量来临时存储当前处理的代码diff,处理完之后再清空。这个设计在单线程的时候没问题,但我们的服务是多线程并发处理的,多个请求同时处理的时候,全局变量就会被覆盖。

看起来找到了根因:全局变量导致的并发数据串扰。

但问题没那么简单

我把全局变量改成了局部变量,重新部署,以为问题解决了。

结果观察了半个小时,用户反馈还是有问题,数据串扰依然存在。

这说明全局变量只是问题的一部分,还有其他原因。

我继续排查,把注意力转向了缓存。我们用Redis做了缓存,缓存代码diff的解析结果,避免重复解析。缓存的key是仓库ID加提交ID,看起来没问题。

但我仔细看了缓存的写入逻辑,发现了一个bug:在高并发情况下,缓存的写入没有加锁,多个请求同时写同一个key的时候,可能会出现A的结果写到了B的key里。

具体来说,缓存的写入分两步:先计算key,再写入value。如果在计算key和写入value之间,另一个请求修改了共享的解析结果对象,就会导致value和key不匹配。

这是一个典型的竞态条件。我把解析结果改成了不可变对象,每次解析都创建新对象,不修改共享对象,问题就解决了。

还有第三个问题

修了缓存的问题之后,数据串扰明显减少了,但还是有少量用户反馈有问题。

我继续排查,发现了第三个问题:大模型API的流式响应处理有bug。

我们用的是大模型的流式API,响应是分块返回的。我们的代码里用了一个共享的缓冲区来接收流式响应,然后把完整的响应返回给用户。

在并发情况下,多个请求的流式响应会写入同一个共享缓冲区,导致响应内容混在一起。虽然我们用了锁,但锁的粒度不对,只锁了写入,没锁读取,还是会出现串扰。

我把共享缓冲区改成了每个请求独立的缓冲区,彻底解决了这个问题。

至此,三个问题都修复了,数据串扰完全消失,用户反馈也正常了。

根因总结

这次故障的根因是三个并发问题叠加:

第一个是全局变量存储当前处理的diff,多线程并发时被覆盖。

第二个是缓存写入时的竞态条件,共享的解析结果对象被并发修改。

第三个是流式响应的共享缓冲区,多个请求的响应混在一起。

这三个问题单独存在的时候可能不会造成明显的故障,但叠加在一起,就导致了30%的请求出现数据串扰。而且因为错误率监控没有升高(API都返回了200),故障发现得比较晚。

这次排查花了一整夜,主要是因为问题比较隐蔽,而且是多个问题叠加,修了一个还有一个。

经验教训

这次故障给了我很多教训。

第一个教训是不要用全局变量存储请求相关的数据。在并发服务里,全局变量是万恶之源。每个请求的数据应该存在请求作用域里,用局部变量或者上下文传递。

第二个教训是共享对象要考虑线程安全。如果多个线程会访问同一个对象,要么加锁,要么做成不可变对象。不可变对象是更好的选择,天生线程安全,而且代码更清晰。

第三个教训是缓存的写入要保证原子性。计算key和写入value要在同一个锁里,或者用原子操作。不要分两步,中间可能被其他线程打断。

第四个教训是流式响应的处理要特别小心。流式API的响应是分块的,缓冲区的管理很容易出问题。每个请求要有独立的缓冲区,不要共享。

第五个教训是监控不能只看错误率。这次故障API都返回了200,错误率监控没有报警,但业务逻辑已经出错了。要加业务层面的监控,比如审查结果的相关性检查、用户反馈的收集等。

AI应用的特殊挑战

这次故障也让我意识到,AI应用有一些特殊的挑战。

第一个挑战是AI的输出是不确定的。同样的输入,AI可能返回不同的输出。这使得传统的测试方法不太适用,因为你不能用固定的预期输出来测试。需要用更灵活的验证方式,比如检查输出的格式、关键词、相关性等。

第二个挑战是AI的错误不容易发现。传统的程序出错会抛异常、返回错误码,很容易监控。AI出错可能只是返回的内容不对,但HTTP状态码是正常的,不容易被发现。需要专门的质量检查机制。

第三个挑战是AI应用的链路更长。从用户输入到AI输出,中间经过了数据获取、预处理、prompt构建、API调用、响应解析、后处理等多个环节,每个环节都可能出问题。排查问题的时候要逐环节检查,不能只看最终结果。

第四个挑战是大模型API的稳定性。第三方大模型API可能会有延迟、限流、返回格式变化等问题。要做好重试、降级、超时处理,不能假设API永远正常。

后续改进

故障之后,我们做了一系列改进。

第一是代码重构。把所有全局变量都去掉了,共享对象都改成了不可变的,缓存的写入加了原子性保证。整个服务的并发安全性大大提升。

第二是加了并发测试。以前的测试只覆盖了单线程场景,现在加了多线程并发测试,模拟高并发场景下的行为。

第三是加了业务监控。除了错误率,还监控审查结果的相关性、响应时间分布、用户反馈率等指标,能更快地发现业务层面的问题。

第四是加了质量检查。AI返回的审查结果会经过一个自动检查流程,检查是否和代码相关、格式是否正确、有没有明显的错误。检查不通过的会自动重试或者标记为人工审核。

第五是完善了故障应急流程。这次故障从发现到完全修复花了八个小时,主要是因为排查过程中走了弯路。我们整理了故障排查的checklist,以后遇到类似问题能更快定位。

写在最后

排查了一夜,虽然很累,但收获很大。AI应用还是个新领域,很多最佳实践还在探索中,踩坑是难免的。

每一次线上故障都是一次学习的机会,能帮你更深入地理解系统,也能帮团队建立更好的工程实践。

希望这篇文章能给做AI应用的朋友一些启发。并发安全、监控告警、质量检查,这些传统软件工程的经验在AI应用里同样重要,甚至更重要,因为AI的不确定性让问题更难发现。

线上故障不可怕,可怕的是故障之后没有总结,下次还犯同样的错误。我们已经把这次的教训都记下来了,争取以后不再犯。