复杂度优化)
开发 Agent Platform踩了一次真实的线上超时故障做 Agent Platform 的时候最怕的不是功能不够而是线上莫名其妙地慢下来。那天下午三点监控大屏的响应曲线突然像心电图一样抖起来核心接口的 P99 延迟从 120ms 一路飙到 18 秒错误率从 0.1% 冲到 15%。那是我第一次真正意义上在 Agent 平台这类偏“重调度”的系统里正面遭遇超时故障。排查完才发现根子不在数据库也不在网络而是在一段看起来人畜无害的上下文拼接逻辑里。这篇文章就是把当时完整的排查过程、根因分析、修复方案以及事后复盘整理出来给正在做或者准备做 Agent 类平台的同学一个参考。1. Agent Platform 的整体设计与架构背景1.1 平台定位与核心功能拆解先说下这个平台是干什么的。Agent Platform 本质上是一个面向多智能体协作场景的调度与运行平台上层接业务方下层接大模型 API 和各种企业内部工具。核心功能可以拆成四个模块Agent 生命周期管理、任务编排引擎、工具注册与路由、上下文持久化与快照。生命周期管理负责 Agent 的创建、启动、暂停和销毁每个 Agent 有独立的运行空间和沙箱约束。任务编排引擎是最核心的模块它要把用户的一句话拆分成多个子任务然后按照依赖关系分发给不同的 Agent 执行。工具注册与路由解决的是“这个 Agent 该用哪个工具”的问题比如查询订单状态和查询物流轨迹底层调的是两套完全不同的服务路由层要做参数转换和鉴权透传。上下文持久化模块则负责记录每一轮对话和每一次工具调用的入参、出参方便随时回溯和续跑。这个平台的调用链路有点长用户请求先打到 API Gateway然后到 Orchestrator 做意图识别和任务拆分再分发到 Agent RuntimeAgent Runtime 在执行过程中要不断和上下文服务、工具路由、模型代理交互。节点一多任何一个环节抖动都会被放大。当时在架构选型时就考虑过这个风险但还是低估了上下文模块在高并发下的性能退化速度。1.2 技术选型背后的取舍逻辑技术栈上主体用的是 Java 17 Spring Boot 3RPC 框架选了 gRPC消息异步化用的 RSocketAgent 之间的事件通知走了内存消息总线。存储方面对话消息和历史快照放在 MongoDB向量检索部分用了一个开源的向量数据库。之所以选 Java 而不是 Go 或者 Python一个重要原因是团队对 JVM 生态的监控链更熟悉而且像 Flowable 这类成熟的流程引擎可以直接复用任务编排的基础功能不用重复造轮子。分布式追踪接的是 OpenTelemetry日志统一走 ELK指标用 Prometheus Grafana。这套组合在常规业务系统里已经跑得很稳但 Agent 平台有个特点单次请求的处理时间远长于普通 HTTP 接口。一次任务编排可能涉及上百次模型调用和工具调用单请求耗时从几秒到几十秒都很正常。这意味着超时控制不能用传统的“一刀切”策略必须区分“交互式请求”和“后台任务”两条链路分别设计。当时在架构文档里写得很清楚但实际落地时交互式链路上还是混入了不少“重逻辑”。2. 线上超时故障的完整排障过程2.1 故障现象与报警细节故障发生的时间点是在下午三点左右从监控看崩溃不是瞬间的而是花了大约 20 分钟慢慢恶化。最开始是某个特定路由的 P99 延迟从 120ms 涨到 1.2s我们当时以为是偶发抖动没太在意。结果 10 分钟后错误率开始抬头接着整个交互式链路都开始超时上游网关大量返回 504。报警群瞬间活跃起来一堆人开始刷屏问是不是数据库出问题了。我先做了三件事第一看 Grafana 面板确认影响范围排除了全局限流和机房网络问题第二查 ELK 日志里超时请求的分布特征第三看 JVM 监控确认 GC 是否异常。印象很深的一个细节是初步看下来 GC 表现还算健康Full GC 没有明显的尖峰堆内存使用率稳定在 60% 左右。CPU 却出现了异常四核容器的 CPU 直接打满而且是完全打满的那种。CPU 打满但 GC 正常这个组合很有迷惑性。在 Java 应用里这种情况要么是死循环要么是某种激烈的 CPU 自旋要么是正则回溯要么是序列化/拷贝开销爆炸。随后我通过 Arthas 的thread命令抓现场发现大量线程阻塞在一个ContextSnapshotManager.createSnapshot方法上而且线程状态为 RUNNABLE。用stack命令打印了几组线程栈确认它们的调用路径完全一致都是在执行上下文快照创建逻辑。2.2 从服务链路排查到代码定位拿到线程栈后问题范围从“整个平台故障”缩小到了“上下文快照创建异常”。这时候我重新翻了一遍调用链追踪数据发现超时请求全部集中在多轮对话续跑这个场景特别是那些上下文数量超过 20 轮的 Agent 任务。看日志里的耗时分布serializeContext和deepCopyMessages这两个子操作在单次请求里的耗时占比竟然超过了 80%。为了进一步缩小范围我在测试环境复现了场景模拟一个 Agent 执行 30 轮工具调用每轮写入 5 条消息然后持续创建连续快照。压测结果显示第 1 轮快照耗时只有 2ms第 10 轮是 30ms到第 20 轮直接跳到 900ms第 30 轮到了 6 秒以上。这个曲线非常典型几乎是指数级的上涨。看到这个曲线的时候我基本已经确定了根因方向存在 O(n) 甚至 O(n²) 级别的复杂度爆炸而且大概率是数据重复拷贝引起的。随后我用 JProfiler 挂到测试环境看createSnapshot方法的内部调用树。内存分配曲线里byte[] 数组的分配量呈陡增趋势字符串 char[] 也居高不下。点开堆栈追踪后看到了一个具体的循环结构每次创建快照都会遍历整个会话的完整消息列表并且对每条消息执行一次 JSON 序列化然后把这些序列化后的字节流再放进一个新的列表——也就是说从第 1 轮到第 n 轮每一轮产生的消息都会在后续每一轮被完整拷贝一遍总工作量是 1 2 ... n 的累加复杂度天然就是 O(n²)。3. 根因剖析与故障背后的技术原理3.1 问题代码的实际形态简化还原后的问题代码如下一段比较典型的“小步快跑但欠考虑”的代码public ContextSnapshot createSnapshot(String sessionId) { // 获取该会话的全部消息 ListChatMessage allMessages messageRepository.findBySessionId(sessionId); // 深度拷贝一份避免后续修改影响快照 ListChatMessage deepCopy new ArrayList(); for (ChatMessage msg : allMessages) { ChatMessage copy new ChatMessage( msg.getSessionId(), msg.getRole(), // 这里对 content 做了一次完整字符串拷贝 msg.getContent(), msg.getTimestamp(), // 又把工具调用的结果也拷贝了一份 msg.getToolCallResult() ); deepCopy.add(copy); } // 序列化成 JSON 存到快照表 String json objectMapper.writeValueAsString(deepCopy); snapshotStore.save(new Snapshot(sessionId, json)); return new ContextSnapshot(sessionId, deepCopy); }表面看这个函数没有明显死循环也没有不合理的锁但问题就出在“每次创建快照都全量拷贝所有历史消息”这行思路上。平台的产品需求里规定了“每个任务节点执行完成后要记录一份快照用于流程回放和异常续跑”这个需求本身没毛病但当时的实现没做增量处理导致每执行一个新节点整个会话的上下文都会像拍全家福一样全部重新拷贝一遍。如果只有 5 轮工具调用容量差异还不明显。一旦 Agent 的流程里有循环推导、多轮自我纠错或者一个任务拆成几十个子步骤消息列表的长度就会快速膨胀。每个消息里除了用户输入、模型输出还嵌入了工具返回的完整 JSON 数据单条消息体积动辄几十 KB。当列表里有几百条消息时每一次快照就要拷贝几百 MB 的临时对象CPU 和内存都扛不住。3.2 为什么这个场景特别容易触发超时这个故障放在普通 CRUD 系统里可能永远不会发生但在 Agent 平台里几乎是必然发生的原因有三个特殊性。第一个特殊性是“上下文无边界增长”。Agent 的执行模式不像普通 API 那样一次请求一次响应它是多轮推理和多次工具调用的循环。很多 Agent 框架为了保持“记忆完整”会把所有历史消息都塞进下一轮的上下文窗口不做裁剪也不做摘要。产品上为了回放能力又要求每步快照。这两个需求叠加实际上制造了一个无上限的累积结构。第二个特殊性是“深度拷贝与序列化的成本被低估”。开发时容易把deepCopyMessages想成一个简单的列表遍历觉得每轮多拷几百个对象无所谓。但实际运行时每个对象里都嵌着大字符串和嵌套结构浅拷贝和深拷贝的差距是数量级的。再加上用 Jackson 做序列化时反射调用本身有额外开销字段越多越明显。当时那台机器上单次快照序列化 200 条消息的耗时已经超过 3 秒这个时长在一些 RPC 框架里已经足够触发客户端超时。第三个特殊性是“长请求加剧了连接池和线程池的占用”。Agent 平台的交互式链路本来线程持有时长就比其他系统长如果其中 80% 的时间都浪费在 CPU 空转和重复拼接上线程就长期卡在同一个方法里。线程没办法及时归还给 Tomcat 线程池导致后续请求排队。排队的请求又在等线程最终入口网关注入超时后开始重试重试又带来更多请求雪崩效应就出现了。4. 修复方案、实施过程与效果验证4.1 增量上下文与滑动窗口方案修复思路不是简单地优化拷贝性能而是从机制上消除重复劳动。第一步把“全量快照”改成“增量快照 基础快照”。每个会话在首次执行时生成一份基础快照后续每个节点只保存“基于上一个节点的差异数据”。这样单个快照的体积从几百 MB 降到几 KB创建快照的工作量从 O(n²) 直接降到 O(1)。第二步引入“滑动窗口上下文管理”。模型调用时不再每次都拿全量历史消息而是动态取出最近 W 条关键消息加上一个“前置摘要”。摘要由专门的压缩逻辑生成比如用摘要模型把前面的长对话浓缩成 200 字以内的核心事实。这个策略在不改变用户可感知的语义质量的前提下大幅压缩了每次模型请求的 payload 大小。第三步也是比较关键的一步把“快照创建”从请求主链路里剥离出去。之前快照是在任务节点执行完毕时同步完成的导致响应时间被快照耗时直接拉长。改造后任务节点只负责把“需要持久化的增量数据”发到一个内存队列由独立的后台线程异步落库。交互链路本身不再等待快照持久化完成接口耗时一下子就降下来了。这里需要注意一个边界异步化之后如何保证数据不丢我当时的方案是把写队列的确认与节点状态机的“已提交”状态绑定节点在持久化确认前不会推进到下一个状态这样即使进程崩溃也能从上一个已确认节点恢复。4.2 线程池隔离与熔断降级兜底机制上消除了重复拷贝但操作上还需要增加一层防护避免以后再出类似问题拖垮全局。当时我在 Agent Runtime 内部做了线程池隔离策略编排、模型调用、工具调用、上下文处理各用独立的线程池每个线程池的队列长度和最大线程数单独配置。这个改造的思路和舱壁模式类似。以前所有任务共用 Tomcat 的工作线程哪个环节出现性能劣化都会抢占公共资源。隔离之后就算上下文模块再次出问题也只会打满“上下文线程池”模型调用和工具调用线程池还能继续处理请求。线程池隔离的代价是线程数量的增加不能盲目贪大我压测后得出的经验值是模型调用池 8 线程、工具调用池 12 线程、上下文处理池 4 线程、编排主线程池 16 线程整体线上观察下来 CPU 基线还降了 10%。同时在工具路由层加了一层熔断器。熔断条件有两条单工具调用的平均耗时超过 2 秒或者错误率超过 20%。当熔断器打开后后续请求直接返回一个降级提示不再真正发起工具调用。熔断恢复采用半开策略每 20 秒放 1 个探活请求成功则逐步恢复流量。这个兜底让平台从“完全不可用”变成了“局部功能降级”对业务的冲击小了很多。4.3 压测结果与线上优化数据修复完成后我在测试环境重新跑了一遍相同的压测场景优化前后的数据对比如下场景优化前 P99优化后 P99错误率变化30 轮工具调用 每轮快照18.2s780ms15% - 0.2%20 轮对话续跑6.5s420ms8% - 0.1%高并发 50 路同时触发编排全部超时950ms100% - 0.5%单个快照的创建耗时从最开始的 6 秒降到了 20 毫秒左右快照存储的磁盘占用也从每会话 50MB 降到了 2MB。上线观察了一周接口 P99 稳定在 400~800ms 区间CPU 峰值从打满降到 35% 到 45% 之间内存分配速率也明显回落。这里有一个血泪教训必须单独说任何优化上线前都一定要回归验证“上下文恢复”这个能力。增量快照虽然写入快了但恢复时需要把基础快照和所有增量数据合并成一个完整的上下文列表这个合并逻辑如果写不好很容易出现消息顺序错乱或者重复。我就在上线后的第二天发现某条 Agent 任务回放时把工具调用的返回结果贴到了用户消息之前排查半天是因为增量数据里的 timestamp 精度不够导致排序时把相同时间戳的消息排反了。后来把所有增量快照都增加了一个自增序号用数据库的自增主键保证顺序才彻底解决。5. 常见问题排查速查表与避坑经验5.1 Agent 平台超时故障排查速查表这次故障之后我总结了一张速查表后续排查同类问题都是先对照这个表格做初步筛选再决定要不要深入分析。现象特征优先排查方向常用工具参考信号CPU 打满但 GC 正常序列化、深拷贝、正则回溯Arthas thread / stack线程栈是否集中在同一个自研方法CPU 打满且 GC 频繁堆内存溢出、大对象分配JProfiler / MATbyte[] 分配曲线是否陡增线程阻塞等待数据库连接池、HTTP 连接池耗尽线程池监控等待获取连接的线程数是否接近上限响应时间呈阶梯式上升消息队列积压、任务调度堆积队列深度监控消费速率是否跟不上生产速率局部路由超时上游服务抖动、限流配置错误全链路追踪跨服务调用的耗时占比分布这些信号之间有重叠所以最好把几个维度的数据交叉验证。比如线程阻塞等待和数据库连接池耗尽经常同时出现只看线程栈容易误判为数据库慢查询其实根因是连接池最大连接数配小了真正卡住的点是获取连接时的排队等待。5.2 从这次事故里提炼的三条防范策略第一Agent 平台的上下文模块必须单独进行“规模压测”。普通接口用 1000 并发压测就行但上下文模块要专门压“单请求内迭代次数”。我建议最少覆盖三种模式单 Agent 30 轮工具调用、双 Agent 协作 20 轮对话、异常恢复场景下 10 次连续快照。这三种模式跑一遍基本能暴露大多数性能和正确性问题。第二超时配置不能全局统一。Agent 平台要区分“短任务”和“长任务”。短任务比如单轮意图识别超时控制在 1 秒以内长任务比如多 Agent 协作执行一个复杂的分析报告可能要几分钟。如果所有请求都用同一个超时时间短任务会被长任务拖慢重试频率长任务又容易因为某个下游组件偶发抖动而提前失败。正确做法是在 API Gateway 层按路由分组分别配置超时时间在 gRPC 调用链上再用带超时传递的 deadline 机制逐层传递剩余时间避免每一层都重置超时时钟。第三核心链路上的“能力增强”一定要做开关控制。比如上下文压缩、摘要生成、快照异步化这些优化上线时默认关闭通过配置中心按 Agent 类型逐步放量。当时我们把上下文压缩功能先在内部测试 Agent 上跑了一周确认没有影响多轮对话的语义完整性才逐步放量到生产整个过程非常稳。6. 后续规划与个人复盘心得这次故障之后我对 Agent 平台的设计有了更强的敬畏心。回头去看前期的架构评审并不是没人提出过“上下文全量拷贝”的风险但当时大家普遍认为“单次请求数据量不大、实现简单优先”把这个问题压后处理了。现在我的观点是在 Agent 类系统里凡是和“历史”“累计”“回放”挂钩的逻辑都要在第一天就按增量思维来设计因为 Agent 的执行轮数天然是无上限的它比普通系统更容易踩中复杂度爆炸的陷阱。我个人在实际操作中最深的一点体会是故障排查时宁可先多抓几份线程栈也不要急着翻日志猜原因。当时如果只盯着监控面板和日志关键词我可能会在数据库慢查询和网络抖动之间来回打转很久。Arthas 在线打印线程栈这个动作直接把排查时间从小时级压缩到分钟级。现在我已经养成了习惯任何 Java 服务上线前都会准备好 Arthas 的启动脚本和常用命令手册线上出问题第一时间就能介入。最后再分享一个小技巧如果你也要做 Agent 平台建议在开发环境就构造一个“压力火柴人”——一个专门用于搅乱系统的测试 Agent它会执行超长循环、调用超大数据量的工具、在上下文里塞满重复文本。每次发版前让这个火柴人跑一遍它比任何静态代码审查都更能暴露系统的性能极限。我这次故障如果能早一周引入这种测试智能体很可能在上线前就已经把问题抓出来了。希望这篇复盘能帮你在 Agent 平台的建设中少踩一次类似的坑。