前晚做任务时,我明显感觉到反应变慢。不是某个复杂步骤卡住,而是整台机器像被看不见的胶水粘住:简单命令也要等,新的会话启动后迟迟没有结果,原本应该瞬间完成的动作,有时要拖上几分钟。
起初的反应很自然:是不是同时开的会话太多了?是不是几个 session 在互相绕路?这个猜测并不荒唐。多会话确实可能放大资源竞争,也可能让人误以为“模型想得太多”。但猜测只适合提出问题,不适合直接下结论。我需要知道时间到底花在了哪里。
后来统计覆盖了99个会话,结果把问题推向了另一个方向。真正拖慢整台机器的,不是思考本身,而是每个会话结束时都会运行的一段记忆整理流程。它本来是收尾动作,却在大量并发时变成了共享瓶颈。
先把“慢”拆成几段
如果只看用户从发出问题到拿到结果的总时间,所有延迟都会混在一起。模型思考慢、工具执行慢、收尾整理慢、等待锁慢,表面上都只是“没有反应”。这也是性能问题最容易被误判的地方:我们会把一个端到端数字,错当成某个组件的责任证明。
我选择先把全链路拆成4类时间:思考时间、工具调用时间、收尾钩子时间,以及锁等待时间。这样做是因为每一类时间对应的处理办法不同。思考慢,可能需要看任务拆分;工具慢,可能需要看外部服务;钩子慢,应该检查收尾逻辑;锁等待长,则说明真正的执行时间之外,还有一群任务在排队。
这一步看起来只是记账,实际上改变了排查顺序。没有拆分时,我可能先限制并发,甚至怀疑模型在无效绕路;拆分之后,才有机会问一个更准确的问题:所有会话是不是都在等待同一个东西?
遇到全局变慢时,先测端到端分位数,再把时间还原成不同阶段,不能用平均感觉替代证据。
平均值尤其容易骗人。少数很快的任务可以把平均数拉下来,掩盖排队最严重的那一批;而用户真正感受到的,往往不是平均体验,而是偶尔突然等很久。于是我同时看中位数和P90:中位数告诉我大多数请求处在什么水平,P90则告诉我尾部是否已经失控。
简单命令也慢,说明问题在共享路径
统计里有一个很有价值的反例:简单命令也出现了2-10分钟延迟。它们本身不需要复杂推理,工具动作也很短。如果这些命令仍然很慢,那么“模型正在深思熟虑”就不能解释全部现象。
这类反例比一个复杂任务变慢更重要。复杂任务慢,可能是任务真的复杂;简单任务也慢,说明它们很可能被某个共同环节拦住了。换句话说,问题不一定发生在每个会话内部,而可能发生在会话之间共享的资源上。
这里最容易误判的是把并发数量当成根因。看到多个会话同时工作,就直接减少并发,确实可能让延迟暂时下降,但它没有回答为什么会排队,也没有说明哪个资源被占满。如果瓶颈是一个不合理的收尾流程,减少并发只是少让几个人一起撞墙,等会话数量恢复,问题还会回来。
如果不处理这个共享路径,后果不只是“偶尔慢一点”。记录动作会排队,普通工具调用也会排队,用户会把基础设施的等待误认为模型能力下降,最后可能通过关闭并发、减少任务或反复重试来补救。重试反而会增加更多收尾动作,让排队继续变长。
真正的瓶颈藏在会话结束之后
继续看分位数后,收尾钩子暴露得很明显:一共运行了297次,中位耗时6.4分钟,P90已经达到10分钟超时。它不是少数偶发失败,而是每个会话都可能遇到的系统性延迟。
接下来要看的不是它“是否有用”,而是它为了完成这件事做了多少工作。结果发现,每次收尾都会对16174条记忆日志做成对比较,并且在这段计算期间长期占用同一把日志锁。也就是说,钩子既做了重计算,又把重计算放在了共享临界区里。
这件事表面上是一个算法耗时问题,实际上是一个资源边界问题。成对比较本身已经很重;更严重的是,它让其他本来很快的动作无法进入同一份日志。于是收尾钩子占用的不是自己的几分钟,而是把这几分钟扩散成所有会话的等待。
共享锁里的工作越重,单次操作的慢就越容易变成全局的尾延迟。
我一开始怀疑多个会话是不是绕了弯路,后来发现真正要处理的是“谁在什么时候占着大家都要用的东西”。这件事让我重新意识到,性能排查不能只问某一步做了多久,还要问这一步是否阻塞了别人。一个局部操作如果持有共享锁,它的影响范围就已经超过了自己的调用者。
把重计算移到锁外
明确瓶颈之后,改造方向就不该是继续调小并发,而是缩短锁的占用时间。我的选择是把聚类和比较移到锁外:先在不阻塞其他会话的情况下完成重计算,等到真正需要提交结果时,再短暂获取锁,写入必要的变化,然后立即释放。
我选择这样做,是因为锁需要保护的是共享状态的一致性,不是保护整个计算过程。只要计算阶段读取的是一个明确的快照,提交阶段再检查并合并结果,就没有理由让所有读写者陪着这段重计算一起等待。临界区应该只包含不可并行的那几步,而不是从读取、比较、聚类一直包到写回。
当然,移到锁外也不是一句“去掉锁”那么简单。快照可能过期,多个收尾任务可能同时算出结果,提交时也可能发生冲突。因此要把风险放到提交阶段处理:确认当前状态仍然适用,必要时放弃过期结果或重新排队,而不是为了追求速度直接破坏日志一致性。
另外还需要3个保守的护栏。第一是节流,让短时间内重复触发的收尾合并起来;第二是单实例,避免同一份记忆同时启动多个整理者;第三是超时,超过合理时间就结束本次整理并记录结果,不能让一个后台动作无限占着资源。
这里的超时不是把问题藏起来,而是给系统设置边界。没有边界的后台任务,失败时会继续占用锁、继续堆积进程,最后把一个局部故障扩大成全局不可用。超时之后留下可追踪记录,后续可以补偿;让它一直运行,其他任务连正常开始的机会都没有。
用一条证据链判断根因
这次经历后来整理成了一套我愿意复用的流程。第一步,先确认“慢”是否全局发生:抽取不同类型任务,尤其要保留简单命令作为对照。第二步,记录每次请求的端到端时间,并按中位数和P90观察分布,不只看平均值。第三步,把时间拆成思考、工具、钩子和锁等待4类,确认延迟集中在哪一段。
第四步,检查共享资源:同一把锁被谁持有、持有多久、等待队列有多长。第五步,做因果验证:如果暂时跳过或隔离收尾动作,简单命令是否恢复;如果只减少并发而不改收尾逻辑,尾延迟是否仍然出现。第六步,改造时优先缩短临界区,把重计算移到锁外,再用短暂提交、节流、单实例和超时守住边界。
这套方法的价值,不在于它给出了一个固定答案,而在于它能阻止我过早归因。多个会话可能是放大器,却未必是根因;模型可能真的慢,却不能解释所有简单命令;平均耗时可能看起来还能接受,但P90已经说明有人在长时间排队。
真正可靠的性能判断,不是找到一个听起来像原因的对象,而是把现象、分布、锁等待和改造后的变化连成一条证据链。
表面上我是在处理一次系统变慢,其实我是在训练一种工作习惯:先把感觉变成可比较的时间,再把时间变成资源关系,最后才决定改哪一层。一个流程成熟的标志,不是它能在顺利时跑通一次,而是下次出现类似症状时,不必重新靠猜。
下次我会怎么做
下次再遇到多个会话同时变慢,我会先保留一个简单命令作为探针,不急着减少并发,也不急着责怪模型绕路。先测中位数和P90,再把等待拆开,确认是计算慢、工具慢、收尾慢,还是锁让大家排队。
如果简单命令也变慢,我会优先查共享路径;如果某个后台动作的P90远高于中位数,我会继续查它是否持有共享锁;如果重计算出现在临界区,我会把锁缩到提交阶段,并补上节流、单实例和超时。改完之后,我不会只看后台任务是否成功,还会重新测简单命令的尾延迟,因为系统恢复的证据应该出现在被拖慢的调用者身上。
这件事让我重新意识到,慢并不总发生在用户正在等待的那一步。很多时候,真正昂贵的动作藏在“事情已经结束”之后。收尾流程也属于主链路,只要它触碰共享资源,就必须用和前台功能一样严肃的性能证据来审视。