OPPO员工:工位对面坐了一个O16职级的女孩,每天上班乐呵呵的,两口子每年100多万,确实没理由上班不乐呵(附Agent面试题)。
国庆的整个假期,给我的感觉很割裂。
割裂到国庆已经过去了两天,我还没有回过来神。
拿起手机,我看到的是,各种AI博主在晒进步;放下手机,大家谈论的还是家长里短,你家孩子打算在哪里上学啊?你们公司有没有二十五六岁的小伙子啊?
于是当我刷到题目这条帖子的时候,笑了。

每年100多万收入,在一个口碑相对不错的公司,必须乐呵。
这里多说几句,在我看来,用不了多久,Agent 的重心就会从桌面端回到手机端,看到这里的小伙伴记得提前做好技术储备,等涨薪的时候你会来感谢我的。目前其实已经出现了这种趋势,所谓的 Personal Agent,如果只能在 PC 端,使用场景还是太少了,因为很多人压根就不用电脑。
而手机厂商,无疑具备了天然的优势。
拿 Oppo 来说,ColorOS 17 的发布材料是这样介绍的,用户可以通过他们的 Agent 小布直达 200 多项支付宝服务,可以调用腾讯地图生成出行攻略,以及发送微信、拨打音视频电话等。

当然了,前提条件是,手机的操作系统本身要具备调用各种 APP 的权限,然后这个 Personal Agent 还能在有限的硬件条件下,完成相当不错的推理,调用各种工具,帮我们完成本该属于我们人去做的操作。
想象一下。经过我们授权后,Agent 能积累我们的偏好、历史事件和个人资料,还做好上下文压缩,就真的很爽了,比如说,你腰围是 31 码,Agent 帮你买裤子的时候绝不会买 32 码的。买错了还能帮你退换货,提前买好运费险。
当然了,端侧 AI 的最大问题是耗电,OPPO 也在做自己的端侧全模态 Omni 模型。

很期待了,好吧。
接下来,继续上科技狠活,有面试需求,或者想要继续深耕 Agent 知识的小伙伴记得认真读下去,非常肝,全文 9000 多字。
content
01、分片合并后为什么用 Kafka 异步解析,而不在上传请求里同步完成?
“如果在上传请求里同步解析,这一个请求要依次完成文件下载、文字提取、文本分块、分块 Embedding、把向量写入 ES 这几步。几百页的扫描版 PDF 还要先做 OCR,光这一步就要很久。Nginx 或者网关等不到响应,就会先断开连接。”
“所以派聪明的合并接口只做两件事。先把文件分片合成一个完整的文件,再往 Kafka 发一条消息,消息里带着文件 MD5、文件名、上传人的用户 ID 和组织标签,然后接口马上返回。提取文字、文本分块、转成向量这些耗时的工作,交给后台的 Kafka 消费者去做。”

“Kafka 在这里先解决的是削峰。后台同时能解析的文档数量是有上限的,处理不过来的消息就在 Kafka 里排队,不会全部挤进内存。解析名额满了以后,新来的任务等一段时间还拿不到名额,就抛出异常,让 Kafka 过一会儿重新投递这条消息,任务不会丢失。”
“任务要在 Kafka 里排队,就得保证排队期间消息不会丢失。Kafka 会把消息持久化到磁盘上;生产者设置了 acks=all,消息要写进所有副本才算发送成功;同时还开启了幂等,网络重试时同一条消息不会在 Kafka 里存两份。派聪明应用重启以后,Kafka 里的消息还在,消费者从上次处理到的位置接着读就行。”
“同步解析就没有这个保障。进程一旦崩溃,正在进行的解析就中断了,也没有任何地方记录这个文档有没有解析完,之后会不会再去解析它。”
消费失败和重复消费怎么处理?
“消费失败先重试。派聪明的消费者处理失败后会等 3 秒再试,最多重试 4 次。还是失败,就把消息转到死信队列,同时把文件记录里的向量化状态改成失败,错误原因也写进数据库。用户在页面上能看到失败原因,点一下重新解析,系统会再往 Kafka 发一条重新解析的消息。”

“有重试就可能出现重复消费。如果消费者处理到一半崩溃了,或者处理完了但还没来得及提交位移就重启了,Kafka 会认为这条消息没处理过,重启后再投递一遍。”
“ES 里的数据好处理。ES 里每条记录的 ID 由文件 MD5 和分块序号拼接成,同一个文本分块不管写入几次,ID 都一样,后写入的会覆盖先写入的,不会出现两条一样的向量。”
“麻烦的是 MySQL 里存文本分块的表,这张表是新增写入的,重复消费会插入一批重复的分块记录,Embedding 接口也会被重复调用。所以消费者拿到消息后,先查询文件的向量化状态,已经是完成,说明这条消息之前处理过了,直接跳过。还没完成的,先按照文件 MD5 删除这个文件的旧分块,再重新解析和写入。”

临时分片什么时候清理?
“合并成功后立刻清理。文件分片存在 MinIO 里,合并时调用 MinIO 的 composeObject 接口,由 MinIO 服务端直接把分片拼成完整文件。确认合并出来的文件确实存在以后,再删除三样东西,MinIO 里的分片、MySQL 里的分片上传记录、Redis 里记录上传进度的 bitmap。合并失败就什么都不删除,分片还在,用户再点一次合并就能重新合并,不用重新上传。”
“不用等到解析完再删除。解析失败重试时,读取的是合并后的完整文件,用不上分片,留着只会浪费存储空间。”

“还有一类分片永远不会合并,用户上传了一半就关掉了页面。这部分要靠定时任务兜底,定时扫描文件记录表,找出长时间一直处于上传中状态的文件,把它在 MinIO 里的分片、MySQL 里的分片上传记录和 Redis 里的位图一起删掉。Redis 位图也记得在创建时设一个过期时间,不然没合并的文件留下的位图永远不会被删除,一直占着 Redis 的内存。”
02、两个请求同时触发文件合并,会产生什么问题?
“两个线程同时合并同一组文件分片,先合并完的线程会按流程把分片删掉,后到的线程再去读这些分片时就会报错。其实文件已经合并成功了,用户看到的却是合并失败。”
“就算两次合并都成功,后面也会往 Kafka 发两条消息,同一个文件会被解析两遍、向量化两遍。MySQL 里还会多一份重复的文本分块,用户提问时同一段内容会被检索出来两次,白白浪费 topK 的名额。”

怎么保证同一个文件只被合并、解析和入库一次?
“先确定幂等。派聪明的文件记录表上有一个唯一索引,由文件 MD5 和用户 ID 两个字段组成,同一个用户上传同一个文件,表里永远只有一条记录。两个请求同时插入记录时,后插入的那条会因为违反唯一索引而报错,代码捕获这个异常,再把已经存在的那条记录查出来用就行。”
“记录唯一后,再用记录上的状态字段控制谁能合并。状态有三个值,0 表示上传中,1 表示已完成,2 表示合并中。合并接口做的第一件事,是执行一条带条件的更新语句,把状态从上传中改成合并中。”
UPDATE file_upload SET status = 2 -- 改成合并中
WHERE id = ? AND status = 0 -- 前提是当前状态还是上传中
“只有当前状态还是上传中,才把它改成合并中。两个请求同时执行这条语句,数据库会给这一行加锁,让两条语句排队执行。先执行的请求把状态改成了合并中,返回的影响行数是 1,它继续往下合并;后执行的请求发现状态已经不是上传中,什么也没改,影响行数是 0。”
“影响行数是 0 的请求会再查询一次状态。状态是已完成,就直接返回成功和文件下载地址;状态还是合并中,就返回 HTTP 409,前端过一会儿再来查询结果。”
“合并过程中抛了异常,就用同样的带条件更新,把状态从合并中改回上传中,用户可以再点一次合并。合并成功以后,把状态改成已完成,之后再来的合并请求都会直接拿到成功结果。合并文件和发 Kafka 消息只有改成功状态的那个请求才会执行,状态从上传中改成合并中只能成功一次,所以消息只会发一次,文件也只会被解析和入库一次。”
为什么不用 Redis 分布式锁?
“分布式锁只能挡住同一时刻到达的请求,挡不住一前一后到达的重复请求。比如第一个请求合并完释放了锁,因为网络重试晚到的第二个请求又拿到了锁,它照样会再合并一遍,所以拿到锁以后还得再查询一次状态。既然最后都要靠状态判断,不如直接用带条件的更新 SQL,保证只有一个请求能合并。这样系统少依赖一个 Redis 锁,也不用担心锁的过期时间到了、合并还没做完,锁被别的请求拿走的问题。”

03、扩展到十万人规模的企业,系统最需要加强哪三个方面?
“我会先加强检索。十万人用这个系统,绝大部分请求是来提问的,每次提问都要先检索,检索一慢,所有人都能感觉到。”
“派聪明现在把所有人的文本分块都放在一个 ES 索引里,每个用户只能查到自己有权限的文档,这是靠每次查询时加过滤条件实现的。ES 默认一次查询要扫描索引的所有分片。数据量上去以后,第一件事是按组织做路由(routing),让同一个部门的文本分块都存进同一个分片,这个部门的人查询时只需要访问这一个分片。”
“向量检索也要调整。派聪明现在会先召回 topK 的 30 倍作为候选,最多 300 条,再经过融合排序和重排序选出最终结果。到了十万人并发,候选数量要配合 HNSW 的参数重新压测(HNSW 是 ES 做向量近似检索时用的索引算法),找到一个召回率和耗时都能接受的值。”

“以后索引结构要升级,比如换一个 Embedding 模型,所有文档都要重新写一遍索引,这时用索引别名做不停机切换。查询和写入都通过别名访问索引,新索引在后台建好,检查文档条数和 MySQL 里的一致以后,一次操作就把别名从旧索引指向新索引,用户感觉不到切换。旧索引保留下来,新索引有问题还能切换回去,这一步派聪明已经做好了。”
“检索的性能满足需求以后,下一个瓶颈是文档处理。企业里一次批量导入几万份文档很正常,Kafka 主题要增加分区,消费者跟着增加实例。解析也要从主应用里拆分出来单独部署,OCR 很耗 CPU,不能和聊天接口抢同一台机器的资源。拆分出来以后,还能根据 Kafka 里积压的消息数量,自动增加或减少解析服务的实例数。”
“文档处理变快以后,压力就转移到了模型调用上。解析时要调用 Embedding 接口,回答问题要调用大模型,十万人集中提问,第三方模型服务给的调用额度很快就会用完。派聪明现在有三层 Token 预算控制,分别是每个用户每天的额度、全系统每分钟的额度和全系统每天的额度。扩展时还要按部门分配额度,避免一个部门把所有人的额度用光,再接入第二家模型供应商,一家出故障时切换到另一家。”

“需要重新设计的模块主要是两个。一个是解析模块,拆分成独立的服务。另一个是模型调用,现在是业务代码直接调用模型接口,要改成统一经过一个模型网关,限流、重试、熔断、计费都在网关里做。基础设施方面,要扩容 ES 集群和 Kafka 集群,对象存储改成多节点部署,再加一套监控,重点看 Kafka 消息积压量、检索耗时和模型调用失败率。”
04、如何用 CompletableFuture 发起和编排多个并行任务?
“先用 supplyAsync 提交每个任务,每提交一个任务,会立即拿到一个 CompletableFuture。再用 allOf 把这一组 CompletableFuture 合成一个,完成就能说明所有任务都结束了。allOf 返回的是 CompletableFuture<Void>,本身不带任何任务的结果,所以等它完成以后,还要按提交顺序遍历原来那一组 CompletableFuture,逐个调用 join 取出结果,这样结果顺序和任务的提交顺序是一致的。”
// 自己建线程池:4 个线程,队列最多排 100 个任务,排满了由提交任务的线程自己执行
ExecutorService pool = new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy());
// 每个任务都放进自己的线程池执行,单个任务 30 秒超时,超时和异常都转成失败结果
List<CompletableFuture<Result>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(task::run, pool)
.orTimeout(30, TimeUnit.SECONDS)
.exceptionally(e -> Result.failed(task, e)))
.toList();
// 等全部任务结束,再按提交顺序取出结果
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<Result> results = futures.stream().map(CompletableFuture::join).toList();
某个子任务超时或失败时怎么处理?
“allOf 有个特点,只要有一个任务抛了异常,allOf 也会以异常结束,这时对它调用 join 会直接抛出异常,其他任务的结果也就拿不到了。所以每个任务都要先把自己的异常处理掉。orTimeout 给单个任务设置超时时间,到时间还没完成,这个任务就以 TimeoutException 结束;exceptionally 再把超时和其他异常都转换成一个表示失败的结果。这样每个任务都会正常给出一个结果,allOf 一定能正常完成,汇总时成功的任务拿到数据,失败的任务带着失败原因。”
“要注意,orTimeout 只是把这个 CompletableFuture 标记成超时结束,不会停掉线程池里正在执行这个任务的线程。任务代码自己要能响应线程中断,或者定期检查一个取消标记,发现超时就主动退出,不然已经超时的任务还会一直占着线程池里的线程。”

“PaiCLI 并行执行工具时也是这个思路。PaiCLI 是一个在终端里运行的编程 Agent,大模型一次可能要求调用好几个工具,比如同时读三个文件。读文件、搜索代码这类只读工具最多 4 个同时执行,这一批工具共用一个 90 秒的超时,到时间还没执行完的工具会被取消,它的结果写成工具执行超时、已取消,和其他工具的结果一起返回给大模型。一个工具出错只影响它自己那一条结果,大模型看到失败原因,可以换个参数再调用一次。”
不指定线程池时,任务默认在哪个线程池执行?
“不指定线程池时,任务在 ForkJoinPool.commonPool 里执行。这是整个 JVM 共用的一个线程池,默认线程数是 CPU 核数减 1。如果机器只有一两个核,算出来的线程数不到 2,CompletableFuture 就不用这个公共线程池了,改成每提交一个任务就新建一个线程去执行。”
“commonPool 适合计算量大、执行很快的任务。调用模型接口、请求网络这类任务,大部分时间都在等网络返回,放进 commonPool 的话,几个慢请求就能把它的线程全部占用,同一个 JVM 里其他用到 commonPool 的代码,比如 parallelStream,也会跟着卡住。PaiCLI 启动 MCP 服务时,会同时启动好几个,为此单独建了一个最多 8 个线程的线程池,代码注释里写明了不用 commonPool 的原因。有些 MCP 服务要通过 npx、uvx 命令启动,第一次启动要先下载依赖包,会长时间占着线程。”

05、工作队列配成无界队列,任务持续堆积会发生什么?
“线程池接收任务的顺序是固定的。当前线程数少于核心线程数,就新建一个线程执行任务;线程数达到核心线程数以后,新任务放进工作队列排队;队列放满了,才继续新建线程,直到线程数达到最大线程数;线程数也到了最大值,才执行拒绝策略,比如抛异常或者丢弃任务。”
“无界队列是没有容量上限的队列,永远放不满。所以队列满了以后才会发生的事情,比如说新建线程、执行拒绝策略,都不会发生。最大线程数配多少都没用,线程池里始终只有核心线程在执行任务。”

“任务进来的速度一直比处理速度快,队列就会越排越长,最先出问题的是响应时间。新任务要排在前面几万个任务后面,等轮到它执行时,调用方早就因为超时放弃了。”
“接着出问题的是内存。工作队列里每个任务都占一个节点,任务对象还引用着请求参数、文档内容这些占内存较多的对象。这些对象排队时间一长,会被 JVM 移到老年代。老年代的空间越来越小,Full GC 越来越频繁,停顿就会越来越长,最后内存不够用,抛出 OOM。OOM 发生时,队列里还没执行的任务就会全部丢失。而且这种 OOM 往往是任务堆积了一段时间以后才出现,排查时很难第一时间想到是线程池队列的问题。”

Executors.newFixedThreadPool 为什么有这个坑?
“newFixedThreadPool 内部创建的是一个不指定容量的 LinkedBlockingQueue,默认容量是 Integer.MAX_VALUE,实际上等于没有上限。阿里巴巴的 Java 开发手册明确规定不允许用 Executors 创建线程池,原因就是这个。”
“正确的做法是直接 new ThreadPoolExecutor,给工作队列设置容量上限,再明确指定拒绝策略。处理用户请求的任务可以用 CallerRunsPolicy,队列满了以后,由提交任务的那个线程自己执行这个任务。提交任务的线程忙着执行任务,就没空继续提交新任务,提交速度自然就降下来了。后台可以统计下这类丢掉也没关系的任务,队列满了可以直接丢弃,同时打一条警告日志,方便事后排查。”

06、模型每秒最多接受 50 个请求,怎么保证调用不超过这个数?
“如果服务只部署了一个实例,用令牌桶算法就够了。系统每秒往桶里放 50 个令牌,每次调用模型之前先拿走一个令牌,拿不到就等待或者直接返回失败。桶的容量要设得小一点,比如只装 5 个,这样即使空闲了很久,桶里也存不下多少令牌,任意一秒内发出的请求都不会明显超过 50 个。Guava 的 RateLimiter 是现成的单机实现,但它默认能存下 1 秒的令牌,桶容量不能直接设置,对突发流量要求严格时,可以换成支持设置桶容量的 Bucket4j。”
“服务一般会部署好几个实例。每个实例各自限制每秒 50 个,几个实例加起来就超过 50 了。把 50 按实例数平均分给每个实例也不好用,每次增加或减少实例都要改配置,而且请求不一定平均落到每个实例上,有的实例名额不够用,有的实例名额用不完。所以计数要放在所有实例都能访问的同一个地方,一般用 Redis。”

为什么全局限流要用 Lua 脚本?
“很多项目用 Redis 做固定窗口计数。用一个固定的 key 记录请求数,每来一个请求,就用 INCR 命令把计数加 1;如果加完是 1,说明这是新窗口的第一个请求,再用 EXPIRE 命令给这个 key 设置 1 秒过期,key 过期被删除以后,计数就从头开始;计数超过 50 就拒绝请求。问题是 INCR 和 EXPIRE 是分两次发给 Redis 的,如果 INCR 执行成功后应用实例刚好崩溃了,EXPIRE 没有执行,这个 key 就永远不会过期,之后所有请求都会被拒绝。”
“固定窗口还有一个问题。窗口是按整秒切分的,第 0.9 秒放进去 50 个请求,到了第 1.1 秒已经是新窗口,计数从 0 开始,又放进去 50 个,0.2 秒内实际发出了 100 个请求,模型那边照样会拒绝。”
“所以我会用 Lua 脚本在 Redis 里实现令牌桶。Redis 里存两个值,桶里剩余的令牌数和上次补充令牌的时间。每个请求执行一次脚本,脚本先根据距离上次补充过了多久,算出要补多少令牌,再判断够不够扣一个,够就扣掉,并把新的令牌数和时间写回去。这几步写在同一个 Lua 脚本里,Redis 执行一个脚本期间不会穿插执行其他客户端的命令,中间不会被别的请求插进来。时间用 Redis 的 TIME 命令获取,不用各个应用实例自己的系统时间,避免几台机器时钟不一致,把令牌算错。”

超出的请求怎么排队,怎么避免排队无限增长?
“拿不到令牌的请求不能无限等下去。每个实例里放一个有容量上限的等待队列,比如最多只排两秒内能处理完的请求数,队列满了,新请求直接返回失败,提示用户当前请求较多、请稍后再试。这种做法叫快速失败,用户马上能得到反馈,不用干等。”
“排队的请求还要带一个截止时间。假如用户请求的超时时间是 30 秒,一个请求已经排了 25 秒,就算现在拿到令牌,调用模型也来不及在剩下的 5 秒内返回,这种请求直接丢掉,把令牌留给还来得及处理的请求。”
“队列里还要区分优先级。用户正在页面上等结果的问答请求排在前面,文档向量化这种后台任务排在后面。额度紧张时,可以先暂停 Kafka 消费者处理后台的向量化任务,把额度留给用户的问答请求。”

07、解析任务卡在中间状态,怎么发现并恢复?
“先要能判断出任务卡住了。如果任务只有处理中、已完成、失败这几个状态,一个处于处理中的任务,可能是真的还在处理,也可能是处理它的进程早就崩溃了,单看状态分不出来。所以文件记录表里要再加三个字段,开始处理时间、最后心跳时间和重试次数。心跳就是消费者定期更新的一个时间,消费者每处理完一批文本分块,就把最后心跳时间更新成当前时间。”
“然后加一个定时任务,定期扫描文件记录表。一个任务处于处理中,最后心跳时间却已经是好几分钟以前,说明处理它的进程很可能已经崩溃了,这个任务需要恢复。”

“系统部署了多个实例时,每个实例都在运行这个定时任务,同一个卡住的任务不能被几个实例同时恢复。恢复时同样用带条件的更新语句,把状态从处理中改成待处理、重试次数加 1,条件里除了状态,还要带上刚才查到的最后心跳时间。几个实例同时执行这条语句,只有一个能更新成功,由更新成功的实例重新往 Kafka 发一条解析消息。”
“重试次数超过上限,比如 3 次,就不再重试,把状态改成失败并发出告警,等用户来看是文件本身有问题,还是解析程序有 bug。如果不设上限,一个怎么都解析不了的文件会被定时任务反复找出来重试,每次都白白消耗资源。有了心跳、定时扫描和重试上限,任务不会丢,也不会永远卡在处理中。”
重试时怎么避免重复解析和重复入库?
“重新解析之前,先按文件 MD5 把这个文件已经写进去的文本分块全部删掉,MySQL 里的分块记录和 ES 里的向量都要删除,然后从头解析。不做断点续传,也就是不从上次中断的位置接着解析。中断时到底写进去了多少条分块记录是不确定的,从头来最简单,也不容易出错。”
“删除操作本身也可能失败,所以数据库这一层还要兜底。MySQL 的分块表加一个联合唯一索引,由文件 MD5 和分块序号两个字段组成,同一个文件的同一个分块重复插入会直接报错。ES 里每条记录的 ID 本来就是用文件 MD5 和分块序号拼成的,重复写入只会覆盖原来那条。”

08、一次性读取大文件可能造成什么问题?
“文件越大,内存占用的峰值就越高,而且是文件大小的好几倍。整个文件读进一个 byte 数组,内存里有一份;转成字符串,又多一份;再按段落 split 成很多小字符串,又多一份。同一份内容同时在堆内存里存了好几份,几个大文件同时解析,堆内存就用完了,抛出 OOM。”
“就算没有 OOM,也会影响别的功能。很大的数组和字符串会直接分配到老年代,老年代很快被占满,频繁触发 Full GC,和解析运行在同一个进程里的聊天接口也会跟着卡顿。”

怎么通过流式读取和分块处理控制内存?
“派聪明用 Tika 做流式解析,Tika 是 Apache 的一个文档解析库,能从 Word、PPT、HTML 等格式的文件里提取文字。读文件的输入流外面先包一层 BufferedInputStream,缓冲区是 8KB,每次只读一小块数据。Tika 一边读一边解析,每解析出一段文字,就调用一次处理器的回调方法,把这段文字交给处理器,整个文件的文字不会一次性全部放进内存。”
“处理器里用一个 StringBuilder 暂存收到的文字,累积到大约一百万个字符,就把这些文字当成一个父块,切分成 512 个字符一段的文本分块,相邻两段重叠 100 个字符,避免一句话从中间被切断以后丢了上下文。切好的文本分块写进数据库以后,调用 setLength(0) 把 StringBuilder 清空,继续接收后面的文字。这样同一时刻内存里最多只有一个父块大小的文字。”

“向量化也按同样的思路分批做。每次从数据库里分页读出 10 个文本分块,调用一次 Embedding 接口把它们转成向量,写进 ES,然后再读下一页。整个文件的向量不会同时放在内存里。”
“PDF 用的是另一种方式,交给一个外部的命令行程序 LiteParse 去解析,OCR 也由它来做。LiteParse 运行在单独的进程里,用的是它自己那个进程的内存,不占 Java 应用的堆内存。Java 这边给它设置了 300 秒的超时,到时间还没结束,就强制结束这个进程。”
怎么避免资源泄漏和 OOM?
“读文件用的输入流放在 try-with-resources 里,代码块正常执行完或者中途抛了异常,流都会自动关闭。解析 PDF 时生成的临时文件在 finally 块里删除,解析失败也不会留下垃圾文件。合并文件分片用的是 MinIO 服务端的合并接口,合并出来的大文件不需要读进 Java 应用的内存。”
“流式解析解决的是单个大文件的内存问题,多个大文件同时解析,堆内存还是会被撑满,所以派聪明在并发上又加了两道限制。第一道是信号量,同一时刻最多 2 个文件在解析,第 3 个文件要等前面的文件解析完、释放名额。等了 120 秒还没拿到名额就抛异常,这条 Kafka 消息过一会儿会被重新投递。”
“第二道是根据堆内存使用率暂停消费。一个后台线程每秒检查一次堆内存使用率,超过 80% 就暂停 Kafka 消费者,不再拉取新消息,已经在处理的任务继续做完;使用率降到 70% 以下再恢复拉取。暂停和恢复用两个不同的阈值,是为了避免使用率在一个值附近上下波动时,消费者被反复暂停、恢复。暂停超过 5 分钟会强制恢复,同时打一条错误日志,防止消费者永远处于暂停状态。”

为什么去掉了 System.gc() 的内存检查?
“早期的做法是在解析开始前检查堆内存使用率,超过阈值就先调用一次 System.gc(),请求 JVM 做垃圾回收,回收完使用率还是高,就直接抛异常,不解析这个文件。”
“System.gc() 通常会触发一次 Full GC,整个 Java 进程的业务线程都要暂停,聊天接口也会跟着卡。而且检查只在解析开始前做一次,检查通过以后,几个大文件同时开始解析,照样能把堆内存撑满。换成暂停 Kafka 消费以后,内存紧张时根本不拉取新任务,没处理的消息都留在 Kafka 里,等内存降下来再接着处理,比抛异常以后等 Kafka 重新投递更友好。”

09、服务商大面积超时,自动重试加上用户重复点击,会带来什么问题?
“请求量会被成倍放大。假如每个请求失败后最多重试 3 次,一次点击最多会发出 4 个请求;用户等不及又点了两次,就是 12 个请求。如果网关、业务代码、模型 SDK 这几层各自都有重试,最后发给模型服务商的请求量可能是正常情况的几十倍。”
“服务商本来就是因为负载太高才超时,放大后的请求只会让它更难恢复。我们自己的系统也会出问题。每个还在等待响应的请求,都占着一个线程和一个 HTTP 连接,模型请求的超时时间一般又设得比较长,有几十秒,线程池和 HTTP 连接池很快就被占满,和模型无关的接口也拿不到线程,开始排队。”

怎么防止请求量被放大?
“重试只放在一层做,一般放在离模型最近的那一层,也就是调用模型接口的客户端代码,其他层都不重试。两次重试之间的等待时间按指数退避增长,比如第一次等 0.5 秒、第二次等 1 秒、第三次等 2 秒,再在这个时间上加一点随机抖动,避免大量请求在同一时刻一起重试。如果服务商返回 HTTP 429 并带了 Retry-After 响应头,就按这个响应头里给出的时间等待。”
“PaiCLI 调用模型的客户端就是这么做的。每个请求一共最多尝试 3 次,等待时间从 500 毫秒开始翻倍,最长 30 秒,再上下随机浮动 20%。只有 408、429、500、502、503、504 这些状态码和网络异常才重试,参数错误、API Key 不对这类重试多少次结果都一样的错误,直接返回给用户。”
“流式输出还要多注意一点。如果内容已经开始显示,中途断开就不能再重试。重试会让模型从头生成,用户会看到前半段内容出现两遍。PaiCLI 只在还没有任何内容显示给用户时才重试。”

“用户重复点击要在请求入口拦住。前端在用户点击发送以后先把按钮禁用,等结果返回再恢复。后端再按用户 ID 和会话 ID 生成一个幂等键,同一个幂等键的请求还在处理时,后到的请求不会再调用模型,直接接收前一个请求的输出。”
“最后还要给重试设一个预算。统计最近一段时间里,重试请求占全部请求的比例,超过 10% 就不再重试,这是 Google 的《SRE》书里推荐的比例。服务商正常时,偶尔失败的请求照常重试;服务商大面积故障时,重试比例很快超过 10%,重试自动停止,不会再给服务商增加压力。”
多供应商场景下怎么做超时控制、熔断、降级和故障切换?
“超时要分三种情况分别设置。连接超时是控制建立 TCP 连接时最多等多久,要设短一点,几秒都连不上,基本可以判断对方服务出问题了。读取超时是两次收到数据之间的间隔时间,流式输出时尤其重要,思考模型可能要过一会儿才输出第一个字,这个值不能设得太小。总超时是整个请求从发出到结束最多多久,不管模型输出多长,都不能超过这个时间。”
“每个供应商配一个熔断器,比如用 Resilience4j 这个库。熔断器统计最近一批请求,失败的比例或者响应太慢的比例超过设定的阈值,熔断器就进入打开状态,之后发给这家供应商的请求不再真正发出去,直接返回失败。过一段时间,熔断器进入半开状态,只放少量请求过去试一试,这些请求成功了,熔断器就回到关闭状态,恢复正常调用;还是失败,就继续保持打开。”

“线程池和 HTTP 连接池也要按供应商分开,每家供应商用自己的一份。一家供应商出故障,最多占满它自己那一份线程和连接,调用其他供应商的请求不受影响。”
“熔断器打开以后,请求自动切换到备用供应商。派聪明的大模型接了 DeepSeek 和智谱,Embedding 模型接了阿里百炼和智谱。大模型可以直接切换,Embedding 模型不能随便切换。不同模型生成的向量互相不能比较,用智谱模型生成的问题向量,去检索阿里模型生成的文档向量,查出来的结果没有意义。所以 Embedding 接口出故障时,检索降级成只用 BM25 关键词匹配;重排序服务出故障时,直接使用 RRF 融合后的排序结果(RRF 是把关键词检索和向量检索两路结果合并排序的方法)。回答质量会差一些,但用户的提问一直能得到回答。”

ending
看到一位大佬是这样说的。
自从有了AI,聪明人变的更加爱学习了,而蠢人认为学习没用了。
我们总说AI发展得够快,只要你学的够慢,很多知识就不用学了。
前提条件是你已经具备了通用的工程能力,能利用AI快速完成工作或者获取新的知识。
如果做不到,那还是劝你,继续学下去吧,否则你会发现自己被 AI 远远地甩在身后。
这种感觉可太恐怖了。
我讨厌无事可做的自己。
我讨厌有人比我强,学习还比我快。
那就是 AI。
