Agent 工程化实测:p95 从 836ms 降到 12ms,而真正的收获是发现瓶颈根本不在 Agent 这层

「高并发实测」系列第 3 篇。

环境:单机笔记本,Docker 跑 MySQL / Mongo / Redis / MinIO,后端服务(Java 17 / Spring Boot 3.4)+ Spring AI 接百炼,Agent 服务单独 8084 端口。k6 开环 constant-arrival-rate 100/s × 20s,5 个城市名做热点工作集。这是我自己搭的实验环境,不是生产系统。

上一篇讲了线程池在高负载下会把"异步"静默退化成同步;这一篇讲一个 Java 后端眼里的 AI Agent------教程都停在"调通 API",而线上要的那一半:可观测、限并发、熔断、压测,几乎没人教。

〇、一分钟带走版

一个只查真实数据的问答 Agent(工具 → HTTP → 后端 → MySQL/Mongo),我把后端最熟的那几件护栏一件件加上去,同一份压测脚本做 A/B:

护栏 实现 实测效果
可观测 Micrometer + 应用内自采延迟/成败/token/成本 延迟一度算出 5.8e12 毫秒,被迫换口径(第二节)
下游降级 RestClient 连接/读超时 + 结构化 unavailable 下游指死端口 → 秒失败、不雪崩,模型如实说查不到
LLM 舱壁 Semaphore(3) + tryAcquire 失败即 503 6/s 打到上限 3 → 65/72 = 90.3% 被秒挡,只有约 7 个真进模型
熔断 手写 Breaker:CLOSED→OPEN→HALF_OPEN 下游宕机连打 20 次:前 5 次真失败,后 16 次快速拒绝
本地缓存 Caffeine,挂在工具层 p95 836ms → 12.34ms(约 68 倍) ,命中率 97.7%

但这一篇最值钱的不是那 68 倍。是加缓存之前我先测了一次不含 LLM 的纯编排层 ,发现:网关自己中位数 5.2ms,p95 却 762ms------长尾全在本机后端那一段,Agent 层是无辜的 。这一步推翻了我的默认假设:我以为要优化的是"AI 那一层",结果该优化的是它下面那个普通的 HTTP 调用。

而"再调一下阈值让 p95 变绿"这个诱惑,我忍住了(第六节)。

全文关键词:Spring AI、可观测性、Semaphore 舱壁、熔断器、Caffeine 缓存、k6 开环压测。

一、场景:Agent 跑通了,然后呢?

先摆一下我做的东西。业务背景很朴素:一个数据查询类业务 ,用户问"杭州有哪些""排名第三的是哪个",Agent 不许凭自己的记忆答------必须调工具去查后端真实数据,再组织成人话

一次请求的链路长这样:

bash 复制代码
用户提问
  → ChatClient(Spring AI)→ 模型决定调哪个工具
  → 工具层 ToolGateway → RestClient → 后端 /wxma/**  →  MySQL / Mongo
  → 结果回填给模型 → 模型出终答

到"能答对"这一步,一个下午就够了,网上教程全是这一段。但我按后端的标准一看,四个问题一个没解:

  1. 它到底慢在哪? 一次问答几秒起步,我连"是模型慢还是我下游慢"都说不出来。
  2. 下游挂了会怎样? 我的第一反应是抛异常,第二反应是更可怕的:模型会不会因为拿不到数据就编一个排名出来
  3. 谁来限流? 模型是按 token 付费的,而且一次请求要占住 servlet 线程四秒以上------这不就是我上一篇文章里那个"阻塞 I/O 长期占住线程"的经典场景吗
  4. 能扛多少 QPS? 没人告诉我,包括教程作者。

于是我把这四件事,按后端的常规做法一件件补上。补完最大的意外是:这四件事没有一件是"AI 技术",全是十年前的老手艺。

二、可观测:先让它可被度量(不然优化都是玄学)

AgentMetrics 里几类指标:

指标 类型 用途
agent.tool.invoke{tool,outcome} Counter 工具成败比,下游健康度
agent.cache.result{tool,result} Counter hit / miss_ok / miss_fail / circuit-open
agent.llm.outcome Counter ok / rejected / error,分母是"被挡了多少"
agent.chat.latency 应用内自采 mean/max 模型往返耗时
token 用量 → 成本 Counter × 单价 烧钱速率,进 /snapshot 直接看

这里踩了个坑,值得单说。 我一开始用最"标准"的做法:Timer.record(nanos, NANOSECONDS),然后读 takeSnapshot().percentileValues().value()。结果快照里打出一个 5.8e12 毫秒 的延迟------换算下来是一百八十多年。

原因是单位口径打架:record 时显式传了 NANOSECONDS,而读分位数时我按毫秒理解,Micrometer 那边又按它自己的 base unit 解释,三个口径凑一起就没人能对上了。我的处理不是"再调一次单位试试",而是换掉可信源 :应用内只自采 mean/max(AtomicLong 存纳秒,输出时 /1e6),p95/p99 全部交给 k6 这个权威源算

这条经验我觉得比缓存那 68 倍更通用:分位数是压测工具的活,不是应用埋点的活。应用只需要知道自己"平均多少、最差多少、拒绝多少次"。

还有一个坑更隐蔽:被舱壁拒绝的请求,别把 0 时长灌进延迟分布 。503 是几毫秒返回的,一旦混进 Timer,中位数立刻变得非常好看------你的报表会开始骗你 。我给拒绝单独开了 countOutcome,只计数、不进分布。

三、降级:下游挂了的正确表现,是说"查不到"

Agent 的下游挂了会怎样?普通服务的表现是一串 500。Agent 多一层风险:模型拿到一个异常,它可能照样给你编一个看起来很像样的答案。 在这类场景里,一个编造的数字比一句"查不到"恶劣得多。

所以这里要做两件事,一件在代码,一件在提示词:

scss 复制代码
// ToolGateway.call() ------ 异常不往外抛,吞成结构化"不可用"
try {
    List<Map<String, Object>> result = loader.get();
    br.onSuccess();  metrics.recordTool(tool, true);
    if (cache != null) cache.put(key, result);
    return result;
} catch (RuntimeException e) {
    br.onFailure();  metrics.recordTool(tool, false);
    return unavailable(tool, e.getClass().getSimpleName());   // {unavailable:true, reason:...}
}

配套的 RestClient 必须带连接超时和读超时TOOL_TIMEOUT_MS,默认 2500ms,压测时我调到 1000ms)。没有超时的降级等于没有降级------异常要等 OS 层 TCP 超时才来,那一二十秒里 servlet 线程照样被占着。

然后系统提示里明确告诉模型:拿到 unavailable 就照实说"暂时查不到",不许用自己的记忆补。

验证方式很直接:把后端基址指到一个没人监听的死端口。结果 {unavailable:true, reason:ResourceAccessException},秒级返回,不雪崩,模型回的是"目前查不到该校数据"。这一条是我最满意的实测:它证明的不是我的代码多强,而是"胡说"这条路被堵住了。

四、LLM 舱壁:一个 Semaphore,挡住九成流量

一次真实问答的模型往返,我的埋点量出来是 mean 4665ms / max 4806ms(1393 输入 + 375 输出 token)。

四秒看起来不长,但它就是把 Tomcat 线程一条条借出去不还------这跟上一篇那个"阻塞 I/O 长期占住线程"的坑是同一件事,只是这次不用去翻线程池参数,直接限并发最省事:

typescript 复制代码
public Result chat(String q) {
    if (!bulkhead.tryAcquire()) {           // Semaphore(llm-max-concurrent),默认 3
        metrics.countOutcome("rejected");   // 只计数,不进延迟分布(第二节那条)
        return new Result("rejected", "系统繁忙(超过 LLM 并发上限),请稍后重试。");
    }
    long t0 = System.nanoTime();
    try { /* 真调模型 */ ... }
    finally { bulkhead.release(); }
}

注意是 tryAcquire() 不等待------不排队。Agent 场景下排队四秒和拒绝的本质一样,但拒绝能立刻给用户一个明确信号,还顺手保住了付费模型的额度。

k6 打 6/s × 12s = 72 个请求,舱壁上只留 3 个位置:

总请求 72
被秒挡 503 65 / 72 = 90.3%
真进模型 约 7 个

这 7 个左右完全能从原理复算出来:3 个并发额度、每次往返约 4.7 秒 → 吞吐 ≈ 3 / 4.7 ≈ 0.64 req/s ,12 秒 ≈ 7.6 个。实测 7 个,对得上。

压测里有个坑 :k6 把 503 计入 http_req_failed,所以那个 rate<0.01 的阈值一定是红的。但这里的 503 是准入控制在动作 ,不是故障。要评估护栏效果,看自定义的 guardrail_rejected,别看 http_req_failed否则你会去"修"一个正在正常工作的东西。

五、熔断:手写 60 行,只为把它讲清楚

生产上该用 resilience4j,我手写了一个最小版 Breaker(CLOSED / OPEN / HALF_OPEN),因为我想让状态机在自己眼皮底下是可解释的:

sql 复制代码
CLOSED    放行,累加连续失败;达到阈值(5) → OPEN
OPEN      冷却期内一律快速拒绝(不让慢/挂的下游继续吃线程);冷却到 → HALF_OPEN
HALF_OPEN 只放一个探针;成功 → CLOSED 复位,失败 → 再 OPEN

allowRequest() / onSuccess() / onFailure()synchronized------demo 里我要的是简单且正确,不是高吞吐,这属于有意识的取舍,不是偷懒。

真实验证(TOOL_TIMEOUT=1000,基址指死端口 59999,连打 20 次):

阶段 次数 行为
真试并失败 5 miss_fail: 5 → 熔断 OPEN
快速拒绝 16 breaker.rejects: 16,返回 reason: circuit-open

这里得停一下:5 + 16 = 21,跟"连打 20 次"对不上。 因为这两个数是 /snapshot 里的累计计数器 ,不是逐次序列------5 次真试每次都耗满 1 秒超时,而熔断冷却只有 5 秒,所以很可能在我手点还没打完时冷却就到点了,中间会插入探针请求(探针失败会再次 OPEN 并继续累加)。我当时的快照没留逐次日志,所以这里只报两个计数,不给你画时间线。 一个对不上的数字标出来,比我顺手圆过去有用。

价值不在省了那十几次请求,在于下游挂掉时我不再拿自己的线程去陪葬 :请求秒回,servlet 线程不堆积,模型秒拿到"查不到"。熔断和第三节的降级是一对,单独任一个都不完整------只降级不熔断,每次请求都要先傻等 1 秒超时;只熔断不降级,熔断打开时模型拿到的是 null,就可能开始编。

六、压测:先证明"不是我的锅",再决定优化什么

上篇结尾我立了个 flag:固定 VU 的闭环压测有协同遗漏------系统变慢时施压速率会自动掉下来,把长尾藏起来。这次一上来就用开环:

yaml 复制代码
executor: 'constant-arrival-rate',
rate: 100, timeUnit: '1s', duration: '20s',
preAllocatedVUs: 100, maxVUs: 200,

而且我故意把 LLM 摘出去:单独开一个只走"工具 → 下游 HTTP → 降级"的编排端点,不碰模型。原因很简单------模型按 token 付费、单跳四秒多,拿它压测只会压出模型厂商的限流,压不出我自己的系统。

第一版结果(还没有缓存):

指标
请求 2000,失败 0,checks 100%
med 5.29ms
p95 762ms
p99 1.2s
阈值 p95<250 🔴 红

中位数 5 毫秒、p95 七百多毫秒 ------这个形状本身就在说话:网关代码不是瓶颈(否则 med 也难看),慢的是一小撮请求卡在下游(本机后端服务在 100/s 下变慢)。

到这里有个很实在的诱惑:把阈值从 250 调到 1000,报告就绿了。我没干。 因为这张表真正的价值是它告诉我下一步该动哪儿------给工具层加缓存、加熔断、加并发上限,而不是给仪表盘换个颜色。

加完缓存,同一份脚本、同一个工作集,A/B:

指标 A:无缓存 B:Caffeine 开 变化
p95 836ms 12.34ms ↓ 约 98.5%(约 68 倍)
med 5.2ms 1.9ms ↓ 63%
max 1.91s 676ms 冷启动 / 缓存过期穿透
失败 0 0 一致性没变
缓存 关闭 hit 1954 / miss_ok 46 命中率 97.7%
p95<250 🔴 🟢 由红转绿

68 倍听起来像吹牛,其实特别朴素:查询结果几乎不变,我却每次都跑一趟后端 。缓存没有让任何一次下游调用变快------它只是把 97.7% 的调用消灭了。这跟"优化"是两回事,是"减少工作量"。

顺手呼应上一篇的结论:在这台 CPU 紧张的单机上,做减法等收益,加并发不等。

七、如果继续做,我会排这几件事

方向 为什么
多轮取中位数 + 归档 k6 summary 见下面的局限,这是我最想补的一条
闭环 vs 开环对照 同一套系统跑两遍,量化协同遗漏到底低估了多少 p95
缓存加单飞(single-flight) 过期瞬间会有一波并发一起回源,max 676ms 很可能就是这么来的
换 resilience4j 手写的 Breaker 是用来讲机制的,生产要的是被淬炼过的实现
接 Langfuse 现在我只知道"慢/成/败",还不知道"这一轮对话烧了多少、在哪一步跑偏"

最后一条是评测 ,它比上面所有工程活都更"AI 特有",也确实给了我一个意外:只测最终答案是测不出问题的。 模型完全可以不调工具、凭常识就把"排名第三是谁"蒙对------这一次它蒙对了,下一次它就是蒙错。要抓这个,得评测调用轨迹(trajectory:有没有调、参数对不对)。这条线我单独写一篇。

八、局限(照例先说)

  • 每个版本只跑了一轮 (100/s × 20s)。68 倍这个效应量远超单机运行间波动,方向不会翻,但精确倍数我不打包票。第二篇那种 ±40% 的跨批次方差在这里同样存在,正确做法是多轮取中位数------已列进第七节。
  • 原始 k6 summary 我当时没归档到文件 ,只留了终端输出。这篇文章里的数字来自我自己的实验记录,第三方无法复算。下次起所有压测一律 --summary-export 落盘,这条我现在就记下了。
  • 命中率是工作集的函数 :5 个城市名、20 秒、2000 请求,是个高度偏斜的热点模型。真实查询分布更平,命中率会更低、收益会更小。这张表证明的是"缓存能消灭长尾",不是"你的系统能有 97.7%"。
  • 环境是笔记本 + Docker 单机,下游后端和 Agent 服务抢同一批 CPU。所以 A/B 的差异里混着资源争抢,绝对值不代表任何生产容量。
  • 熔断那组(5 真试 + 16 快拒)不是压测,是手工连打的功能验证,别把它当吞吐数据读;而且它只有累计计数、没有逐次日志(第五节那段已标出 5+16 对不上连打次数)。
  • 缓存 TTL、超时、并发上限全是环境变量可调,我报的是当次跑的取值,换参数结论会变。

写在最后

这三篇跑下来,我最大的感触是:AI 应用工程化这件事,对后端来说几乎没有新东西。

可观测、超时、舱壁、熔断、缓存、开环压测------每一个都是分布式系统课本里的老词。真正新的只有一点:链路末端那个东西会自己编话,所以"降级"不只是保系统,还得保住"不胡说"。 其余部分,就是把后端那点老手艺,搬到一个新场景里再练一遍。

会做 Agent 的人不一定愿意压测,愿意压测的人不一定碰 Agent。我刚好站在中间。

八股可以被背,结论只能被跑出来。这是「高并发实测」系列第三篇。上一篇:《高并发写入实测:把最重的 MySQL 改回同步,QPS 反而涨了 38%》。再上一篇:《别再死背 44 字节了:Redis String 三种编码压测》。

相关推荐
仍然.1 小时前
SpringCloud---Seata
spring boot·后端·spring cloud
写后端的胖头鱼1 小时前
【高频面试题】分布式锁在项目中的应用
java·分布式·后端·分布式锁·高频面试题
凤山老林1 小时前
Spring Boot + OpenSearch 实战:搞定全文检索、向量召回与混合排序
spring boot·后端·全文检索·向量·opensearch·全文索引
IT_陈寒2 小时前
Vite的HMR怎么突然罢工了?原来是我漏了这个配置
前端·人工智能·后端
BingoGo2 小时前
一个ChatGPT 超级省额度方案!用 TaskQuay 连接网页 ChatGPT 和本地 Codex
人工智能·后端
QQ_21696290962 小时前
基于微服务架构的店铺管理系统的设计与实现
大数据·spring boot·后端·spring·微服务·小程序·架构
芒鸽2 小时前
把 Agent 运行时做成插件系统:agent-harness(openJiuwen Rust版) 的实践
开发语言·后端·rust
CHHH_HHH2 小时前
【Linux系统篇】进程间通信揭秘:从管道到共享内存
linux·服务器·c语言·开发语言·后端·ubuntu
mldong2 小时前
Node 开发者也有自己的轻量工作流引擎了:npm i 一行,5 分钟跑通一条审批流
javascript·后端·typescript