Agent 调用高并发优化分享

标签:#AI工程 #Agent #高并发 #调用链 #工程实践

Agent 调用高并发优化分享

在这里插入图片描述

最近压了一轮 Agent 调用。

最早看到的结果并不好看:千级并发打进来,成功和失败差不多对半。页面上像是请求超时,日志里也很容易把锅甩给入口,觉得是入口没扛住。

后面把阶段拆开看,才发现入口只是表象。很多调用已经进了系统,只是卡在了后面的某一段:上下文还没准备好,模型请求排着队,工具执行占着窗口,事件还在写,客户端还在轮询或订阅状态。任何一段慢下来,用户看到的都是"这次 Agent 调用失败了"。这种链路如果没有背压,压力会沿着调用链往后传,最后把模型、工具、写入和观察链路一起拖住。

Agent 调用和普通接口最大的差别就在这里。

普通接口大多是一进一出。请求进来,查点数据,调一两个服务,返回。Agent 调用更像一段会展开的执行过程:先读上下文,再请求模型;模型输出时可能提出工具调用;工具跑完以后,结果还要回到模型;中间还要不断给客户端推状态;最后再把关键过程和最终结果存下来。

我后来把它从"一次请求"改成"一条会持续运行的调用链"来处理。这个判断影响了后面所有改动。

先看一段本地记录

下面这段是我整理后的本地压测记录。配置按脱敏口径写成资源受限的单机环境,重点不是公布真实机器参数,而是说明这轮不是靠堆资源把数字顶上去。

压测对象:Agent 调用链
机器口径:资源受限的单机环境
并发量级:千级
早期现象:成功和失败接近对半
失败表现:客户端等待超时、运行态查询不稳定、部分调用终态回收慢
主要瓶颈:完整调用链并发失控,模型、工具、事件写入、客户端观察互相影响
调整方向:接入轻量化、执行池限流、事件分层、异步补写、上下文预算
调整后:千级并发稳定完成,单次调用持久化事件从几十条降到个位数级别

这段记录里有两个点比较有价值。

第一,机器资源不宽裕。按资源受限的单机口径去看,优化重点不在机器规格,而在调用链治理。入口、执行池、事件写入、上下文装配这些边界收住以后,同样的资源能承载更稳定的 Agent 调用。

第二,失败不是单点失败。入口、模型、工具、写入、观察任何一段抖动,最后都会表现成一次 Agent 调用失败。只看接口成功率或者总耗时,很容易把问题修到错误位置。

普通 Agent 调用记录和高并发 Agent 调用治理,也不是一回事。

维度 普通 Agent 调用记录 高并发 Agent 调用治理
记录目标 说明调用发生过 判断调用能不能稳定执行
主要字段 开始、结束、结果 排队、执行窗口、上下文、模型、工具、事件、观察
事件处理 过程事件尽量记下来 实时过程和长期事实分层
异常处理 失败后查日志 排队可恢复、写入可补偿、观察可重放
性能判断 看总耗时 拆阶段耗时和资源占用
扩展依据 请求量上升就扩容 单机边界清楚后再复制执行单元

这张表也是我后面改造时的分界线:普通记录解决"有没有",高并发治理解决"稳不稳"。

这几个边界为什么重要

只盯入口并发,很容易把问题看成“流量太大”。Google SRE 在级联故障的讨论里提到,过载会沿着系统往下传,容量规划能降低风险,但不够,入口限流和单个任务的限压也要一起做。Agent 这类长链路调用更明显,因为模型、工具、写入和观察不是独立的,它们会互相放大压力。

重试也不能靠运气。Stripe 在幂等请求文档里强调,创建和更新类操作如果要安全重试,就要带幂等键,避免重复创建或重复更新。放到 Agent 里,队列恢复、事件补写、客户端重连都属于同一类问题:同一件事重复一次,系统还能不能保持一致。

上下文同样不是越多越好。Anthropic 的 prompt caching 和 context windows 文档都在提醒一件事:长对话里,重复前缀、持续增长的历史和有限的上下文窗口会直接影响成本和行为。Agent 的上下文装配、裁剪、摘要压缩,本质上就是在控制这条边界。

单机先跑清楚

从专业角度看,Agent 调用到千级并发,肯定已经是高并发。继续往小几千、大几千推,也肯定要上多机器。执行节点要横向扩展,入口要分流,状态和观察链路也要能拆出去。

但我没有一上来就堆机器。

单机没跑清楚,多机器只是把混乱复制几份。你会看到更高的吞吐,但很难回答几个基础问题:一个节点到底能稳定跑多少条 Agent 调用链;慢在模型,还是慢在工具;上下文准备有没有拖慢整体;事件写入有没有形成写入放大;客户端观察有没有反过来压住服务端。

所以单机阶段我先看一件事:一条 Agent 调用从进来到完成,哪些地方会占资源,哪些地方会排队,哪些地方必须限制并发。

这一步做完以后,多机器扩展才有意义。后面扩的不是一团看不清的逻辑,而是一组已经知道边界的执行单元。

接入要轻,执行要排

在这里插入图片描述

早期的问题之一,是调用一进来就很快展开完整执行。千级请求一起进来,等于千级 Agent 同时抢上下文、模型、工具和写入资源。

我先把接入阶段变轻。

用户发起调用后,系统只做几件小事:创建调用记录,返回调用 ID,写入已接收或排队状态,让客户端知道这次调用已经进入系统。完整执行交给后面的执行池消费。这个阶段的目标不是完成任务,而是把调用可靠地纳入调度面。

这个改动看起来朴素,但对 Agent 很有用。因为 Agent 后面不是一个短任务,它可能要跑模型、跑工具、写多轮事件。如果入口直接放开,后面每个环节都会被同一波流量打满。

接住调用以后,还要保证它不会被重复执行。队列里会记录已经入队的调用,执行池只领取自己该处理的任务。进程重启或发布切换后,系统会把还停在排队状态的调用重新捞出来,放回执行队列。这里的重点是幂等和可恢复,不能只把它当成一次普通的后台任务提交。

伪代码大概是这样:

onAgentRequest(input):
  # 接入阶段只做轻量记录,不展开完整 Agent 链路
  callId = createCall(status = "queued")

  # 入队前做去重,避免同一次调用被重复执行
  if not queue.contains(callId):
    queue.push(callId)

  return {
    callId,
    status: "queued"
  }

executionLoop():
  while active:
    callId = queue.take()

    # 执行槽是并发闸门,拿不到就回到队列等待
    if not acquireExecutionSlot(callId):
      queue.retryLater(callId)
      continue

    # 只有进入执行槽的调用才会开始上下文、模型、工具链路
    runAgentChain(callId)

普通调用记录通常只回答"这次调用有没有发生过"。高并发 Agent 调用还要多回答一个问题:异常之后,这条记录还能不能继续执行。

限住完整链路

入口能接住千级,不代表系统可以同时跑千级完整 Agent。

一次完整 Agent 调用会占住上下文准备、模型请求、工具请求、状态写入、结果汇总、客户端状态同步。入口放过去以后,重活都在后面。

所以我限制的是完整调用链的并发。

入口先收住,队列排住,执行池按固定容量消费。这样千级请求不会一起冲进模型和工具层,系统也不会因为一波流量把所有阶段同时拖慢。执行池实际上承担的是并发闸门,保证完整 Agent 链路在可控水位里运行。

发布时还要处理新旧执行单元。Agent 调用可能跑很久,如果新旧版本同时领取同一批任务,就会出现重复执行或状态交叉。现在只有当前激活的执行单元继续领取新调用,旧执行单元把手上的调用处理完就退场。

这类边界不显眼,压测时却很救命。很多高并发问题,最后都落在这些地方:该排队的地方没排,该限住的地方没限住。

模型和工具分开算

一开始只看总耗时,几乎没有判断价值。

Agent 里模型和工具的压力完全不同。模型通常慢,而且会占住执行窗口。工具调用次数不固定,有的任务一次都不调,有的任务会连续调好几轮。再叠上上下文准备、结果写回、客户端观察,总耗时变长时,你很难知道该改哪里。

后来我把阶段指标拆开:接收后排队多久,上下文准备多久,第一次模型请求什么时候发出,模型什么时候开始输出,工具什么时候开始规划,第一个工具什么时候开始跑,最终结果什么时候落地。

拆完以后,定位变得直接很多。

排队长,看执行池和调度。上下文慢,看读取、裁剪和缓存。模型慢,看模型请求和输出。工具慢,看工具规划、执行和结果回写。客户端看到失败,也先别急着算 Agent 执行失败,还要看查询、订阅、轮询有没有问题。

普通 Agent 调用记录记开始、结束、结果,低并发时够用。到千级并发以后,这种记录太粗。你需要知道慢在哪一段,否则很容易修错地方。

流式过程别全进历史

在这里插入图片描述

Agent 调用会产生很多过程事件。

模型开始输出,输出一小段,又输出一小段。模型准备调工具,工具开始执行,工具返回进度,工具返回结果。然后 Agent 进入下一步。

这些信息对用户有用,对排查也有用,但不应该每一条都写成长期历史。

早期压测里,单次 Agent 调用可能产生几十条需要持久化的事件。并发一上来,事件写入自己就成了压力源。这个问题本质上是写入放大:一次 Agent 调用还没结束,过程片段已经把存储层打得很忙。后面我做了两件事。

先合并模型流式片段。模型本来就是一小段一小段吐出来,如果每段都写一条历史,高并发下写入量会很夸张。现在按时间窗口和长度合并,再形成可保存的事件。

再区分实时过程和长期事实。流式片段、工具进度优先走实时流,让用户看到过程。关键状态、关键工具结果、最终结果才进入长期保存。实时流解决观察体验,长期事实解决审计、恢复和复盘,两者不要混在同一条写入路径里。

这里也可以抽象成一段很简单的分流逻辑:

onAgentEvent(event):
  # 高频过程事件只进入实时流,优先保证用户看到进度
  if event.type in ["model_delta", "tool_progress"]:
    realtimeStream.publish(event)
    return

  # 关键事实进入持久化队列,用于恢复、审计和复盘
  if event.type in ["state_changed", "tool_result", "final_result"]:
    durableEvent = compact(event)
    persistenceQueue.push(durableEvent)

改完以后,单次调用的持久化事件量从几十条降到个位数级别。用户仍然能看到过程,存储层不再被过程片段拖着跑。

异步写入要能补回来

事件改成异步写以后,压力会小很多,但这不等于事情结束了。

高并发下,异步队列可能满,存储也可能短暂不可用。如果关键事件只在内存里排队,进程一重启就丢。Agent 调用又很依赖过程和终态,一旦关键事实没落地,后面的重连、查询、复盘都会出问题。

我的处理方式是,关键事件先形成批次,写到本地暂存,再进入异步持久化队列。写入成功以后删除暂存。如果队列满了,或者存储写失败,后台任务会继续扫描暂存,把没写成功的补上。

实时流管用户眼前看到的过程,本地暂存和异步补写管关键事实最后能不能落地。

普通接口未必需要做到这一步。Agent 调用会产生连续事件,用户还可能断线重连,最终状态也必须能查回来。

第一反馈提前

Agent 调用经常要等模型和工具。用户发起调用后,如果一直等到模型开始输出才看到反馈,高并发时体验会很差。

所以第一反馈不能等模型。

调用刚进入系统,客户端就能拿到调用 ID 和当前状态:已接收、排队中、准备执行。模型还没开始,工具也没开始,但用户知道系统接住了。

运行中的调用会有实时流。客户端订阅后可以持续看到事件。断线后,客户端带着上一次看到的位置回来,系统先重放最近事件,再继续推后面的事件。如果实时流已经过期,也会退回到"调用仍在运行"这类状态,而不是直接报失败。

这里我特别注意把服务端执行和客户端观察分开。服务端已经完成,不代表客户端一定看到了;客户端轮询失败,也不代表 Agent 执行失败。压测时如果把这两类失败混在一起,后面一定会改错方向。

上下文也要控预算

在这里插入图片描述

Agent 调用还有一个容易被低估的成本:上下文会越跑越大。

如果每次调用都把历史消息、历史结果、历史事件全部塞回模型,高并发下会同时放大读取成本和模型成本。调用越多,历史越大,后面的调用越慢。

每次模型调用前,我会先做上下文装配。只带这次模型需要的内容,比如最近几轮消息、和当前任务相关的结果、少量关键活动、压缩过的历史摘要。超过预算就继续裁剪旧消息、旧活动和不相关结果。再不够,就把更早的历史压成摘要。这里要控制的是上下文预算,不只是提示词长度。

这一层不需要复杂,关键是先有预算,再装上下文:

buildModelInput(thread, task):
  # 先按相关性装配上下文,而不是整段历史全量塞入模型
  context = pickRecentMessages(thread)
  context += pickRelatedResults(task)
  context += pickKeyEvents(task)
  context += loadSummary(thread)

  # 超过预算就裁剪低相关内容,避免上下文成本失控
  while estimateTokens(context) > tokenBudget:
    context = pruneLeastRelevant(context)

  # 仍然超限时,把更早的历史压成摘要
  if stillTooLarge(context):
    context = compactHistory(context)

  return context

这看起来像上下文管理,实际也是高并发治理。模型上下文不是免费的。单次调用多带一点历史,千级并发下就是成倍的读取、序列化和推理成本。

压测后清楚了什么

改完以后,千级 Agent 并发调用可以稳定完成。单次调用的持久化事件量从几十条降到个位数级别。早期成功失败接近对半的问题也压下去了。

后面继续往小几千推的时候,问题也不再糊成一团。

第一反馈慢,就看接入和客户端观察。排队长,就看执行池容量和调度。上下文慢,就看装配、裁剪和缓存。模型慢就看模型阶段,工具慢就看工具阶段。最终状态慢,就看写入队列、本地暂存和补写。客户端报失败,先分清是服务端执行失败,还是观察链路失败。

大几千并发肯定要多机器。这点不用犹豫。单机治理的价值在于把 Agent 调用链拆清楚。拆清楚以后,多机器扩展才有抓手:哪些执行单元可以复制,哪些状态要共享,哪些事件可以只走实时流,哪些事实必须持久化。

如果你也在做 Agent 调用高并发,先别只盯入口 QPS。把一次 Agent 调用拆开看:接入、排队、上下文、模型、工具、事件、终态、客户端观察。每一段都能测量,每一段都有边界,系统才有机会从千级继续往上推。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐