背景:为什么 Agent 系统的性能优化如此困难?
与传统的 Web 服务不同,AI Agent 系统的请求处理链路具有几个独特的特征,使得性能优化变得格外复杂,我们在开发业务整合Agent时,遇到了这些问题:
第一,请求生命周期极长。 一个用户请求可能触发 5~20 轮 LLM 推理循环,每轮推理又可能并行调用多个工具,端到端耗时从数秒到数分钟不等。传统 Web 服务的"请求-响应"模型下的很多优化假设在这里不再成立。
第二,资源消耗随对话轮次线性增长。 每轮推理都会往上下文中追加消息(用户输入 + AI 回复 + 工具调用结果),Prompt 体积持续膨胀,同时每轮结束还需要将完整状态持久化——这两者都会随轮次增加而恶化。
第三,冷启动问题被放大。 Agent 系统通常采用进程级隔离(每个会话一个独立进程),新会话到来时需要 fork 进程、加载模型定义、初始化沙箱环境……这一系列操作的叠加使得首条消息延迟动辄 1~3 秒。
第四,外部依赖多且不可控。 LLM API 延迟波动大(2s-15s)、沙箱容器创建成本高(5-10s)、配置中心网络抖动……系统自身的优化再好,也容易被这些外部依赖拖累。
基于此,我们对项目做了一些性能优化
方向一:启动路径重构 —— 从"全量就绪"到"分层就绪"
问题本质
Agent 子进程的启动链路通常很长:读取配置中心 → 建立数据库连接 → 初始化存储客户端 → 加载 Agent 定义 → 创建沙箱连接……这些操作串行执行,任何一环慢了都会拖累整体。
更关键的是,这些操作中有很多是非关键路径——数据库连接没建好不代表不能开始处理消息,存储客户端没初始化完也不影响前几轮推理。
架构方案:分层就绪策略
核心思想是将启动过程拆分为关键路径和非关键路径:
┌─────────────────────────────────────────────┐
│ 传统模式 (串行就绪) │
│ │
│ [配置加载] → [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 带来的额外开销
架构方案:实例复用 + 增量更新
┌──────────────────────────────────────────┐
│ 传统模式: 每轮重建 │
│ │
│ 第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 内容的内联过程设计为一个有损但可控的裁剪管道:
原始 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 延迟 | <1s | ~80%↓ |
方向四: heavyweight 资源池化 —— 从"用完即弃"到"三层复用"
问题本质
Agent 系统中有几类重量级资源,创建成本极高但复用价值也很大:
- 沙箱容器:创建需 5~10s(镜像拉取 + 容器启动 + 环境初始化)
- Worker 进程:fork + V8 编译 + 依赖加载需 200~700ms
- LLM 连接:虽然单次创建不贵,但高频调用下累积可观
这些资源的共同特点是:创建昂贵、有状态、可安全复用。
架构方案:带软绑定的三层池化
以沙箱为例,我们设计了类似数据库连接池的管理架构:
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 |
对于一个 10 轮的工具调用密集型对话,累计快照开销可达 0.5~2 秒——这还不算 LLM 本身的推理时间。
架构方案:Debounce 异步批量写入
核心思路是利用时间局部性——连续的多轮推理之间间隔很短(通常 <2s),没必要每轮都立即持久化:
┌─────────────────────────────────────────────┐
│ 同步模式 (当前) │
│ │
│ 第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 系统中有两类"冷启动"场景特别影响用户体验:
- 新会话的首条消息:需要 fork Worker 进程,用户要等 500ms~2s
- 首次激活某个 Skill:需要读磁盘 + 解析 Markdown 引用树,用户要等 50~200ms
这两类延迟虽然只在"第一次"出现,但第一印象决定用户体验。
架构方案:两级预热
Level 1 — Worker 预热池
在服务启动时或流量低谷期,提前创建 N 个空闲 Worker 进程放入池中。新会话到来时直接从池中取出一个绑定,免去 fork 开销:
传统: 用户消息 → fork Worker (~500ms) → 处理
预热: 用户消息 → 取池中 Worker (<10ms) → 处理
Level 2 — Skill 内容预加载
在 bootstrap 阶段预解析所有 Skill 定义文件并缓存结果。Worker 首次激活某个 Skill 时直接从内存读取:
传统: 首次激活 Skill → 读磁盘 → 解析 Markdown → 递归展开引用 → 缓存
预热: 首次激活 Skill → 内存缓存命中 (0ms)
关键设计点:
- 预热池需要有淘汰机制:空闲超过阈值的 Worker 自动销毁释放内存
- 预热 Worker 是通用的:不绑定特定会话或域,首次分配时才懒加载对应配置
- 预热规模需要动态调节:根据当前并发量和历史峰值自动调整池大小
实践数据(部分已实施,部分规划中)
| 指标 | 优化前 | 优化后 (预热命中) |
|---|---|---|
| 新会话首条消息延迟 | 500ms~2s | <50ms |
| 首次 Skill 激活延迟 | 50~200ms | <5ms |
| 额外内存开销 | 0 |
方向七:请求编排并行化 —— 从"串行流水线"到"有向无环图"
问题本质
当一条用户消息进入 Agent 系统,在到达推理引擎之前,需要经过一系列前置处理步骤:
用户消息
→ 安全意图检测 (~20~200ms)
→ 会话获取/创建 (~10~50ms)
→ 历史消息加载 (~10~50ms)
→ 用户画像查询 (~20~100ms)
→ 权限校验 (~5~20ms)
↓
进入推理引擎
这些步骤在传统实现中是严格串行的——上一步完成后才开始下一步。但仔细观察会发现:其中很多步骤之间没有数据依赖关系。安全检测不依赖会话是否已创建,历史消息加载不依赖画像是否已查到。
串行执行的总耗时 = 各步骤耗时之和 = 65~420ms
如果这些无依赖的步骤能并行执行,总耗时 = 最慢的那一步 ≈ 200ms(通常是安全检测)
架构方案:依赖分析 + 并行编排
核心思路是对请求编排阶段的所有操作进行依赖分析,将无依赖的操作打包为并行批次:
┌─────────────────────────────────────────────┐
│ 传统模式: 严格串行 │
│ │
│ [安全检测] ─→ [会话] ─→ [历史] ─→ [画像] │
│ 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项目性能优化的一些思考,如果你也有类似的开发经历,欢迎交流~~~