聊一下复杂 Agent 架构的性能优化设计

背景:为什么 Agent 系统的性能优化如此困难?

与传统的 Web 服务不同,AI Agent 系统的请求处理链路具有几个独特的特征,使得性能优化变得格外复杂,我们在开发业务整合Agent时,遇到了这些问题:

第一,请求生命周期极长。 一个用户请求可能触发 5~20 轮 LLM 推理循环,每轮推理又可能并行调用多个工具,端到端耗时从数秒到数分钟不等。传统 Web 服务的"请求-响应"模型下的很多优化假设在这里不再成立。

第二,资源消耗随对话轮次线性增长。 每轮推理都会往上下文中追加消息(用户输入 + AI 回复 + 工具调用结果),Prompt 体积持续膨胀,同时每轮结束还需要将完整状态持久化------这两者都会随轮次增加而恶化。

第三,冷启动问题被放大。 Agent 系统通常采用进程级隔离(每个会话一个独立进程),新会话到来时需要 fork 进程、加载模型定义、初始化沙箱环境......这一系列操作的叠加使得首条消息延迟动辄 1~3 秒。

第四,外部依赖多且不可控。 LLM API 延迟波动大(2s-15s)、沙箱容器创建成本高(5-10s)、配置中心网络抖动......系统自身的优化再好,也容易被这些外部依赖拖累。

基于此,我们对项目做了一些性能优化


方向一:启动路径重构 ------ 从"全量就绪"到"分层就绪"

问题本质

Agent 子进程的启动链路通常很长:读取配置中心 → 建立数据库连接 → 初始化存储客户端 → 加载 Agent 定义 → 创建沙箱连接......这些操作串行执行,任何一环慢了都会拖累整体。

更关键的是,这些操作中有很多是非关键路径------数据库连接没建好不代表不能开始处理消息,存储客户端没初始化完也不影响前几轮推理。

架构方案:分层就绪策略

核心思想是将启动过程拆分为关键路径非关键路径

ini 复制代码
┌─────────────────────────────────────────────┐
│              传统模式 (串行就绪)               │
│                                             │
│  [配置加载] → [DB连接] → [存储] → [沙箱]     │
│                                         ↓    │
│                                    发送 ready │
│              总耗时: ~1.2s                    │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│           分层就绪模式 (先跑后补)              │
│                                             │
│  [配置快速恢复] ──→ 立即发送 ready (~50ms)   │
│        ↓                ↓                   │
│   [后台: DB]       [后台: 存储客户端]         │
│                                             │
│  首条消息延迟: ~50ms (vs 1200ms)             │
└─────────────────────────────────────────────┘

关键设计点

  • 配置继承而非重新拉取 :父进程将已加载的配置序列化后传给子进程,子进程直接反序列化到内存,跳过所有网络 I/O。这类似于操作系统的 fork 语义------子进程继承父进程的地址空间。
  • 后台初始化 + 守卫等待:非关键组件在后台异步初始化,业务逻辑通过守卫函数按需等待。如果某次请求恰好需要在 DB 就绪前访问,守卫函数会阻塞直到初始化完成;否则完全无感。
  • 自动重试兜底:对于偶发失败的基础设施(如数据库连接),内置重试机制而不是直接报错退出。

实践数据

指标 优化前 优化后
子进程启动到可服务时间 ~1200ms ~50ms
首条消息 P99 延迟 1.5~3s <200ms
因基础设施抖动导致的启动失败率 ~2% 0%(重试兜底)

方向二:状态管理优化 ------ 从"每轮重建"到"增量复用"

问题本质

Agent 的推理引擎(我们称之为 AgentLoop)是一个有状态的复杂对象,内部维护着完整的多轮对话历史、工具调用的请求-响应配对关系、运行时 Skill 激活状态等。

传统做法是每轮对话结束后销毁旧实例、创建新实例。这在逻辑上最简单,但代价是:

  • 每轮都要重新解析 Agent 定义文件
  • 丢失了结构化的工具调用上下文(LLM 可能因此重复执行上一轮的工具)
  • 对象创建和 GC 带来的额外开销

架构方案:实例复用 + 增量更新

css 复制代码
┌──────────────────────────────────────────┐
│          传统模式: 每轮重建                 │
│                                          │
│  第1轮: new AgentLoop() → 推理 → 销毁      │
│  第2轮: new AgentLoop() → 推理 → 销毁      │
│  第3轮: new AgentLoop() → 推理 → 销毁      │
│                                          │
│  问题: 第2轮 LLM 不知道第1轮执行了什么工具   │
└──────────────────────────────────────────┘

┌──────────────────────────────────────────┐
│          优化模式: 实例复用                │
│                                          │
│  第1轮: new AgentLoop() → 推理 → 保留      │
│  第2轮: updateContext() → 推理 → 保留      │
│  第3轮: updateContext() → 推理 → 保留      │
│                                          │
│  优势: 完整 tool_use/tool_result 配对保留   │
└──────────────────────────────────────────┘

关键设计点

  • 只更新变量,不重建对象:每轮只需刷新 traceId、messageId、用户 token 等本轮变量,其他状态原样保留
  • 闭包过期问题的解决:由于工具执行器是以闭包形式绑定在引擎实例上的,跨轮复用时必须确保闭包捕获的是最新一轮的用户上下文。我们使用了一个"可变引用容器"来解决这个问题
  • 孤立状态修复:当上一轮的工具执行被中断(用户取消/超时),消息历史中会出现"孤儿工具调用"(有请求无响应)。我们在每轮推理开始前自动检测并修补这类不一致

实践数据

指标 优化前 优化后
每轮引擎重建开销 50~200ms <1ms
LLM 重复执行工具的概率 ~15%(3+ 轮对话) 0%
因孤儿状态导致的 API 报错 偶发 400 错误 0%

方向三:Prompt 工程优化 ------ 从"全量注入"到"智能裁剪"

问题本质

Agent 的能力来源于 Prompt 中注入的 Skill(技能)定义。一个典型的 Skill 定义文件会引用大量子文件:表单描述、操作说明、字段约束、示例数据、城市列表......

原始方案将这些引用全部内联展开到 Prompt 中。结果是:

  • 单个 Skill 激活时 Prompt 体积可达 500KB+
  • Token 消耗巨大(176K tokens),LLM 处理速度显著下降
  • 部分大文件(如 329KB 的城市列表)对推理几乎零价值

架构方案:4 层防护的智能裁剪引擎

我们将 Skill 内容的内联过程设计为一个有损但可控的裁剪管道:

yaml 复制代码
原始 Skill 定义 (SKILL.md)
        │
        ▼
  ┌─ 防护层 1: 单文件上限 (16KB) ─┐
  │   超限 → 替换为 📎 占位符       │
  │   通过 → 继续下一层             │
  └──────────────┬────────────────┘
                 ▼
  ┌─ 防护层 2: 总量预算 (64KB) ────┐
  │   累计超限 → 后续文件全部占位    │
  │   未超限 → 继续递归展开          │
  └──────────────┬────────────────┘
                 ▼
  ┌─ 防护层 3: 深度限制 (3 层) ────┐
  │   达到深度 → 停止展开            │
  │   未达到 → 递归处理子引用         │
  └──────────────┬────────────────┘
                 ▼
  ┌─ 防护层 4: 压缩后截断 (8K tokens)│
  │   上下文压缩后重注入时截断防膨胀   │
  └──────────────┬────────────────┘
                 ▼
        最终 Prompt (~16KB)

关键设计点

  • 排除判据完全基于文件大小这一客观指标,不依赖文件名猜测或人工维护黑名单。任何新 Skill 的任何文件只要超限即自动排除
  • 被排除的内容不会丢失:以资源目录提示的形式告知 LLM 文件位置和大小,LLM 可通过沙箱读取工具按需获取
  • 倒序拼接引擎:所有替换操作收集完毕后,按位置倒序一次性拼接,避免正则替换的位置偏移问题和多次替换的误伤

实践数据

指标 优化前 优化后 降幅
单 Skill 激活时 Prompt 大小 ~528KB ~16KB 97%↓
Token 消耗估算 ~176K tokens ~5.3K tokens 97%↓
LLM 首个 token 延迟 25s <1s ~80%↓

方向四: heavyweight 资源池化 ------ 从"用完即弃"到"三层复用"

问题本质

Agent 系统中有几类重量级资源,创建成本极高但复用价值也很大:

  • 沙箱容器:创建需 5~10s(镜像拉取 + 容器启动 + 环境初始化)
  • Worker 进程:fork + V8 编译 + 依赖加载需 200~700ms
  • LLM 连接:虽然单次创建不贵,但高频调用下累积可观

这些资源的共同特点是:创建昂贵、有状态、可安全复用

架构方案:带软绑定的三层池化

以沙箱为例,我们设计了类似数据库连接池的管理架构:

scss 复制代码
                  acquire(sessionId)
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
        ┌─────────┐ ┌────────┐ ┌────────┐
        │ ① 幂等  │ │ ② 软绑定│ │ ③ LRU  │
        │  命中   │ │  命中   │ │  空闲池 │
        └────┬────┘ └───┬────┘ └───┬────┘
             │          │          │
             ▼          ▼          ▼
        已持有?    上次用的还在?   有空闲的?
        → healthCheck → 复用    → healthCheck → 复用
             
        以上都没命中?
             │
             ▼
        ④ 池未满? → 创建新的
        ⑤ 池满了? → FIFO 排队等待

三层命中的设计哲学

  • ① 幂等命中:该 session 已经持有一个沙箱且仍存活,直接复用(最高优先级)
  • ② 软绑定命中:该 session 上次使用的沙箱当前处于空闲状态,优先分配给它(避免环境污染------不同用户的命令历史、安装的包不应混在一起)
  • ③ LRU 空闲池:通用的最近最少使用策略,取最可能"热"的资源

配套的生命周期保障

光有池化还不够,还需要解决两个衍生问题:

问题 A:平台回收 。云平台的容器服务通常有空闲超时机制(如 30 分钟无活动自动回收)。我们的解法是心跳保活------定期向沙箱发送轻量级心跳信号重置计时器。

问题 B:脏数据残留 。沙箱被回收或失效后重建时,旧的缓存数据(已安装的包列表、上传记录等)可能导致后续操作走错路径。我们的解法是缓存三路清理------在任何销毁/失效路径上清空所有实例级缓存。

实践数据

指标 优化前 优化后
沙箱获取 P99 延迟 5~10s(新建) <2s(池化命中)
同 session 多轮复用率 0%(每次新建) >60%(软绑定)
因平台回收导致的重建频率 每 30 分钟/个 接近 0(心跳保活)

方向五:持久化策略优化 ------ 从"同步强一致"到"最终一致性"

问题本质

Agent 的快照持久化是一个"必要之恶"------它保证了断点恢复能力,但代价高昂:

  • 每轮推理结束时都要将完整的对话历史序列化后写入存储
  • 随着对话轮次增长,快照体积从几十 KB 膨胀到数 MB
  • 写入目标可能是数据库(UPSERT)或对象存储(OSS 上传),两者都不快

在我们的系统中,一轮推理的快照保存耗时分布如下:

对话轮次 快照大小 序列化 存储写入 总计
1~3 轮 ~50KB ~2ms ~20ms ~25ms
4~10 轮 ~200KB ~8ms ~30ms ~40ms
11~20 轮 ~1MB ~40ms ~80ms ~120ms
21+ 轮 5MB+ ~200ms 200ms~2s 200ms2s

对于一个 10 轮的工具调用密集型对话,累计快照开销可达 0.5~2 秒------这还不算 LLM 本身的推理时间。

架构方案:Debounce 异步批量写入

核心思路是利用时间局部性------连续的多轮推理之间间隔很短(通常 <2s),没必要每轮都立即持久化:

scss 复制代码
┌─────────────────────────────────────────────┐
│           同步模式 (当前)                      │
│                                             │
│  第1轮推理结束 → await saveSnapshot() → 继续  │
│  第2轮推理结束 → await saveSnapshot() → 继续  │
│  第3轮推理结束 → await saveSnapshot() → 结束  │
│                                             │
│  用户感知: 每轮多等 25~200ms                 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│           Debounce 异步模式                   │
│                                             │
│  第1轮结束 → scheduleSave() → 不等待 → 继续  │
│  第2轮结束 → scheduleSave() → 不等待 → 继续  │
│  (2s 窗口内只有最后一次实际写入)              │
│  第3轮结束 → flushSave() → 强制写入 → 结束   │
│                                             │
│  用户感知: 每轮多等 <1ms                     │
└─────────────────────────────────────────────┘

关键设计点

  • Debounce 窗口:2s 内的多次 dirty 标记只触发一次实际写入
  • 强制 flush 点 :推理循环正常结束(turn_end)或异常中断(abort/cancel)时强制刷盘,保证最终一致性
  • 降级选项:对可容忍少量丢失的场景,甚至可以做成纯 fire-and-forget(进一步简化)

实践数据(规划中,基于方案推演)

指标 当前 (同步) 优化后 (debounce)
单轮额外开销 25~2000ms <1ms (schedule only)
10 轮对话累计写入次数 10 次 1~3 次
数据安全性 强一致 最终一致性 (turn_end flush)

方向六:预热与预分配 ------ 从"按需创建"到"提前储备"

问题本质

Agent 系统中有两类"冷启动"场景特别影响用户体验:

  1. 新会话的首条消息:需要 fork Worker 进程,用户要等 500ms~2s
  2. 首次激活某个 Skill:需要读磁盘 + 解析 Markdown 引用树,用户要等 50~200ms

这两类延迟虽然只在"第一次"出现,但第一印象决定用户体验

架构方案:两级预热

Level 1 --- Worker 预热池

在服务启动时或流量低谷期,提前创建 N 个空闲 Worker 进程放入池中。新会话到来时直接从池中取出一个绑定,免去 fork 开销:

makefile 复制代码
传统: 用户消息 → fork Worker (~500ms) → 处理
预热: 用户消息 → 取池中 Worker (<10ms) → 处理

Level 2 --- Skill 内容预加载

在 bootstrap 阶段预解析所有 Skill 定义文件并缓存结果。Worker 首次激活某个 Skill 时直接从内存读取:

makefile 复制代码
传统: 首次激活 Skill → 读磁盘 → 解析 Markdown → 递归展开引用 → 缓存
预热: 首次激活 Skill → 内存缓存命中 (0ms)

关键设计点

  • 预热池需要有淘汰机制:空闲超过阈值的 Worker 自动销毁释放内存
  • 预热 Worker 是通用的:不绑定特定会话或域,首次分配时才懒加载对应配置
  • 预热规模需要动态调节:根据当前并发量和历史峰值自动调整池大小

实践数据(部分已实施,部分规划中)

指标 优化前 优化后 (预热命中)
新会话首条消息延迟 500ms~2s <50ms
首次 Skill 激活延迟 50~200ms <5ms
额外内存开销 0 50100MB (5 个预热 Worker)

方向七:请求编排并行化 ------ 从"串行流水线"到"有向无环图"

问题本质

当一条用户消息进入 Agent 系统,在到达推理引擎之前,需要经过一系列前置处理步骤:

scss 复制代码
用户消息
  → 安全意图检测 (~20~200ms)
  → 会话获取/创建 (~10~50ms)
  → 历史消息加载 (~10~50ms)
  → 用户画像查询 (~20~100ms)
  → 权限校验 (~5~20ms)
    ↓
  进入推理引擎

这些步骤在传统实现中是严格串行 的------上一步完成后才开始下一步。但仔细观察会发现:其中很多步骤之间没有数据依赖关系。安全检测不依赖会话是否已创建,历史消息加载不依赖画像是否已查到。

串行执行的总耗时 = 各步骤耗时之和 = 65~420ms

如果这些无依赖的步骤能并行执行,总耗时 = 最慢的那一步 ≈ 200ms(通常是安全检测)

架构方案:依赖分析 + 并行编排

核心思路是对请求编排阶段的所有操作进行依赖分析,将无依赖的操作打包为并行批次:

ini 复制代码
┌─────────────────────────────────────────────┐
│           传统模式: 严格串行                  │
│                                             │
│  [安全检测] ─→ [会话] ─→ [历史] ─→ [画像]   │
│    200ms       50ms     50ms      100ms     │
│              总计: ~400ms                    │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│           优化模式: 分批并行                  │
│                                             │
│  Batch 1 (并行, 无互相依赖):                 │
│  ├─ [安全检测] ──── 200ms                   │
│  ├─ [会话获取] ──── 50ms                    │
│  ├─ [历史消息] ──── 50ms                    │
│  └─ [用户画像] ──── 100ms                   │
│              ↑ 取 max                       │
│              Batch 1 耗时: ~200ms            │
│                     │                        │
│  Batch 2 (依赖 Batch 1 结果):               │
│  └─ [权限校验 + 最终组装] ── 20ms            │
│                                             │
│              总计: ~220ms (省 45%)           │
└─────────────────────────────────────────────┘

关键设计点

  • Fail-open 语义:并行执行的各操作中,非关键路径(如画像加载)失败时不阻断主流程,降级为空值继续。只有关键路径(如安全拦截)才具备一票否决权
  • 容忍有限浪费 :如果安全检测最终判定该请求应被拦截,那么并行执行的会话获取、历史加载等操作确实白做了。但统计上看,拦截率通常 <5%,为 95% 的正常请求加速是值得的
  • 边界情况处理:历史消息加载使用的 sessionId 在 Batch 1 时可能还未创建(如果是新会话)。解法是先按传入的 ID 加载(大概率命中空结果),后续再由 Batch 2 补偿

适用范围扩展

这种并行化思路不仅适用于请求编排阶段,还可以推广到:

场景 可并行的操作 预期收益
配置预加载 60+ 个配置项从远程中心拉取 N×RTT → ~1×RTT
工具结果收集 多个独立工具同时执行 受限于最慢的工具
多模态内容处理 文本/图片/文件同时解析和嵌入 总耗时取 max

实践数据

指标 优化前 (串行) 优化后 (并行)
请求编排阶段耗时 ~400ms ~220ms
配置批量加载耗时 (60项) ~1000ms ~80ms
正常请求的处理延迟改善 --- ~45%↓

总结:一张优化地图

以上 7 个方向并非互相独立,它们覆盖了 Agent 系统生命周期的不同阶段:

复制代码
┌─────────────────────────────────────────────────────────┐
│                    Agent 请求生命周期                     │
│                                                         │
│  ┌──────────┐                                           │
│  │  启动阶段  │ ← 方向一: 分层就绪                        │
│  │  (冷启动)  │   方向六: Worker 预热池                    │
│  └─────┬─────┘                                           │
│        ▼                                                │
│  ┌──────────┐                                           │
│  │  编排阶段  │ ← 方向七: 并行化编排                      │
│  │ (前置处理) │                                          │
│  └─────┬─────┘                                           │
│        ▼                                                │
│  ┌──────────┐                                           │
│  │  推理阶段  │ ← 方向二: 引擎复用                        │
│  │ (多轮循环) │   方向三: Prompt 裁剪                     │
│  │          │   方向四: 资源池化 (沙箱)                   │
│  └─────┬─────┘                                           │
│        ▼                                                │
│  ┌──────────┐                                           │
│  │  持久化   │ ← 方向五: 异步 debounce 写入              │
│  │ (每轮结束) │                                          │
│  └──────────┘                                           │
│                                                         │
│  贯穿全程:                                              │
│    ├─ 方向六: Skill 预加载                               │
│    └─ 方向七: 配置批量并行加载                            │
└─────────────────────────────────────────────────────────┘

收益汇总

优化方向 核心收益 实施复杂度 风险等级
分层就绪 启动加速 96%
引擎复用 消除重复工具调用 + 每轮省 50~200ms
Prompt 裁剪 Token 消耗降 97%
资源池化 重量级资源获取从秒级降至亚秒
异步持久化 单轮省 200ms~2s
预热预分配 首条消息 <50ms
并行编排 请求编排阶段省 45%

最后

感谢你能看到这里,本文分享了关于Agent项目性能优化的一些思考,如果你也有类似的开发经历,欢迎交流~~~

相关推荐
wangruofeng2 小时前
一个 5KB 的 skill 文件,三个月砍下 9 万 star,省掉 65% 的 AI 输出 token
aigc·agent·ai编程
x-cmd2 小时前
Linus 9 年前说「模糊测试有效」——今天已成 OSS-Fuzz 时代铁律
linux·ai·rust·开源·自动化·agent·安全性测试
ujainu小3 小时前
原生性能优化:6变量实现高效桥接
开发语言·华为·性能优化·harmonyos
ArixBit3 小时前
用 Go 手撸 Agent 框架 #3:Agent 不是聊天机器人,手写第一个 ReAct 循环
go·agent
新知图书3 小时前
文档感知任务定义与分类
人工智能·agent·ai agent·智能体
ArixBit3 小时前
用 Go 手撸 Agent 框架 #2:LLM 是什么——从 token 预测到 API 调用
go·agent
武子康3 小时前
给 Coding Agent 写仓库规则:硬约束、软约束、证据门禁三类拆解 + 6 步可执行闭环
人工智能·aigc·agent
林间码客3 小时前
LLM Wiki:大语言模型世界的“百科全书”
llm·rag·wiki·llm wiki
闲余生3 小时前
花 3 个月从零写了个 AI Coding Agent,HumanEval 跑出 87.8%,和 GPT-4o 持平
agent