「高并发实测」系列第 5 篇(掘金版)。
大多数 AI Agent 教程只演示"调 API + 写 Prompt",本文从 Java 后端视角补齐另一半:工具调用、RAG、MCP 解耦、护栏治理、评测闭环、可观测。全部结论来自六阶段自建实验环境(phase1~6 + 合流层)的实测数字,不是文档摘录,每个数字都有代码与产物出处。
实验环境:Java 17 + Spring Boot 3.4 + Spring AI,Docker 部署 MySQL / Redis / MongoDB / Langfuse;Agent 数据源接一个真实业务后端的沙盒接口(匿名只读,永不默认打生产);k6 开环压测
constant-arrival-rate100/s × 20s。一句话结论:把大模型当成一个延迟高、会胡说(hallucination)、按 token 计费、无状态的下游服务,后端那套老手艺——超时、重试、限流、缓存、熔断、对账——直接全部复用。给工具层加一层本地缓存 + 一个自研熔断器,编排层 p95 从 836ms 降到 12.34ms(约 68 倍),缓存命中率 97.7%;而比性能数字更大的收获是:评测只看终答是不够的,过程轨迹必须单独一把尺子。
如果只记三句话:
- 工具层加"熔断 → 缓存 → 真调 → 写缓存 → 结构化降级"五步咽喉,下游挂了模型也不会编造;
- 并发保护用拒绝而不是排队(舱壁满员直接 503),拒绝不占线程不烧 token;
- 评测三路互补(关键词 / LLM 裁判 / 轨迹),先怀疑量具,再怀疑裁判,最后才怀疑系统。
〇、一分钟带走版
| 阶段 | 要解决的问题 | 核心产出 | 关键数字 |
|---|---|---|---|
| phase1 | Agent 能不能工作 | @Tool 工具循环(递归下降求值器) | 工具循环跑通 |
| phase2 | 能不能利用知识库 | 向量库 + 带 (资料N) 引用 + RAGAS 四指标 Java 复刻 | 召回评测 83%(6 题中 5);precision@4=0.55 是指标假象(hit@1/MRR 均 1.0) |
| phase3 | 能不能接真实业务 | 真实业务 HTTP 包成 @Tool + 字段裁剪 + 多轮记忆 + SSE | 复合问题 42.8s 里工具合计仅 ~90ms(0.2%) |
| phase4 | 能不能稳定运行 | ToolGateway 五步咽喉 + Semaphore 舱壁 + 30 行自研熔断器 | p95 836ms→12.34ms(编排层,绕开 LLM),命中率 97.7%;舱壁 90.3% 秒挡 |
| phase5 | 工具能不能解耦 | MCP server/client 动态发现 + 轨迹记录器(25 次硬上限) | 工具穷举失控 100+ 次/180s → 2 次/10.6s |
| phase6 | 效果能不能回归 | 三路评测 harness + 中位数纪律 + gate.sh CI 门禁 | trajectory 50~67%→100%;judge 83%→100%(改裁判不改系统) |
| 合流 | 变成可运维工程件 | Redis Stack 三迁 + 混合检索(向量+BM25+RRF+rerank)+ 语义缓存 | 命中 0.12s vs miss ~27s(≈200 倍) |
全文关键词:Spring AI、Tool Gateway、Bulkhead 舱壁、Circuit Breaker 熔断、MCP、trajectory 评测、LLM-as-judge、语义缓存、k6 开环压测。
复现口径:全部实验在 4C8T 单机笔记本 + Docker Compose(MySQL / Redis / MongoDB / Langfuse)完成;数据源为真实业务后端的沙盒只读接口(永不默认打生产);压测为 k6
constant-arrival-rate开环模型(100/s × 20s)。本文不附带公开仓库(实验代码含商业授权框架与脱敏后的业务上下文),但每个机制的实现思路与关键参数文中均已给出,可按图索骥重写。
一、心智先立住:LLM 是个不靠谱的下游服务
做这套东西之前,我先把大模型的"服务画像"写清楚,后面所有设计都从这张画像推出来:
| 属性 | 实测值 | 后端老手艺对应 |
|---|---|---|
| 延迟高 | 一次问答 mean 4665ms(≈4.7s) | 超时、降级、异步化 |
| 会胡说 | 无工具兜底时凭常识直答 | 结构化降级 + 系统提示约束 + 评测 |
| 按 token 计费 | 单次 1393 输入 + 375 输出(占位单价约 0.005 元) | 限流按并发额度而非 QPS |
| 无状态 | 每轮全量携带历史 | 会话外置、记忆窗口 |
这张表决定了整篇的结构:每一条老手艺,都能在 Agent 链路里找到位置。
二、phase1~2:跑通最小闭环,先接知识库
phase1 只做一件事:@Tool 注解方法挂进 Spring AI 的工具循环。值得说的是计算器工具——没用任何脚本引擎。模型输出的表达式是不可信输入,接 JS/Groovy 引擎等于允许任意代码执行;我手写了一个递归下降求值器,parse() 里校验"正好吃到串尾",防止 (1+2)) 这种残串被静默算对。
phase2 接 RAG,两个设计点:
- 带引用:上下文按
【资料N|来源:X】编号注入,要求模型句末复述(资料N),两侧对得上才信。输出形如:"同济大学在上海,(资料2)"——引用编号由注入顺序决定,这是"可验证幻觉抑制"里成本最低的一档。 - 确定性召回评测:单独一套只测检索、不碰生成的题组,6 题命中 5(83%)。关键词命中 ≠ 语义正确,这个局限文末如实写。
RAGAS 四指标我在 Java 里复刻了(不引 Python 链路),复刻时有一个口径决定:faithfulness 的 claims==0 记 1.0 而不是 0——"不知道"是无幻觉不是全错,记 0 会奖励话多的答案。
2.1 第一次反转:precision@4 = 0.55,检索其实没坏
RAGAS 跑出来 context-precision@4 只有 0.55,很难看。我第一反应是检索烂了,上了 rerank——更差(0.50)。准备花钱换模型前先数了排名:hit@1 = 1.0、MRR = 1.0、delta_mrr = 0——排序早就满分。
根因两层:① 我自实现的 precision 是"位置盲"的,与 RAGAS 同名指标不是同一个东西(同名不同实);② 语料库只有 10 块,topK=4 交出整个库的 40%,这个场景下 precision@k 天花板本来就低。
教训:难看数字先问"系统差还是尺子错"。 实验数据:
| 配置 | context-precision@4 | 备注 |
|---|---|---|
| topK=4 | 0.55 | 语料 10 块,交出 40% |
| topK=4 + rerank | 0.50 | 精排救不了指标定义 |
| topK=2 | 0.70 | 收紧候选池,比开 rerank 有效 |
(hit@1 与 MRR 在三种配置下均保持 1.0 —— 排序从未坏过。)
三、phase3~4:接真实业务,然后给它穿上盔甲
phase3 把真实业务后端的沙盒接口(院校/专业/课程查询)包成工具。真实接口返回字段又多又长,直接塞给模型既费 token 又稀释注意力——加了统一裁剪 pick():字段白名单 + Top-8 截断。多轮记忆用消息窗口(20 条),用"指代词实验"验证记忆真的生效:问"那它的学费"——问题自带实体时测不出记忆(关掉记忆也能答对),只有指代词无法解析才证明历史被拼进了 prompt。
phase4 是全文的重头:护栏治理。
3.1 ToolGateway 五步咽喉
工具调用统一走一个网关,五步顺序不可换:
熔断放行 → 查缓存 → 真调(双超时) → 成功写缓存 → 失败降级
三个细节:
- 失败不写缓存:写进去等于把坏结果固化,下次命中坏缓存。
- 降级是结构化的:下游挂了返回
{unavailable:true, reason:...}而不是抛异常——模型拿到结构化行会照实说"暂时查不到",拿到 null 就可能开始编。实测把下游指向死端口,模型答的是"目前查不到",没有编造。 - 每个工具一个熔断器:故障隔离按调用对象划界。用
ConcurrentHashMap.computeIfAbsent原子创建——普通 get-then-put 会让两个线程各建一个 Breaker,失败计数各拿一半,阈值 5 名存实亡。
3.2 30 行自研熔断器
CLOSED / OPEN / HALF_OPEN 三态,synchronized 保状态迁移事务性,AtomicBoolean 显式表达"HALF_OPEN 只放 1 个探针"。一个容易漏的细节:onFailure 转 OPEN 时要重置 openedAt 和连续失败计数,否则旧计数会把刚复位的状态顶回 OPEN。(教学实现用 synchronized 足够;生产高并发建议换 CAS 状态机,或直接用 resilience4j——合流层我就是这么做的。)
实测:下游死端口连打 20 次——前 5 次真试并失败(miss_fail:5),之后 16 次快速拒绝,冷却 5 秒。快速拒绝不占线程、不烧 token。
3.3 Semaphore 舱壁:拒绝,不要排队
模型并发上限设 3,tryAcquire() 拿不到立即 503。为什么拒绝而不是排队?按容量算:3 个并发额度 ÷ 4.7s ≈ 0.64 req/s,12 秒只能消化 7.6 个——排队只是让第 8 个人一直等到超时。
四个实现细节,每个都是踩出来的:
tryAcquire()放在 try 外——Semaphore.release()没有"必须先 acquire"的校验,放 try 内会凭空多出许可;- rejected 的计时放在 acquire 之前——0ms 的拒绝请求不污染延迟分布;
- 用
chatResponse()而不是content()拿响应——后者丢 Usage,token 统计就断了; finally里 release,防许可泄漏。
实测 6/s 到达打上限 3:72 个请求里 65 个(90.3%)被秒挡,只有约 7 个真进了模型。
3.4 压测结论:p95 836ms → 12.34ms,但要交代前提
缓存 A/B(k6 开环 constant-arrival-rate 100/s × 20s = 2000 请求,0 失败):
| 场景 | p95 | 命中率 |
|---|---|---|
| 缓存关 | 836ms | — |
| 缓存开 | 12.34ms | 97.7% |
⚠️ 必须交代的口径:这是编排层压测——我特意做了一个绕开 LLM 的端点(/api/ops/school)。原因:模型单次往返 4.7 秒,不绕开它,网关/下游的毫秒级行为会被整个盖住,压测测了个寂寞。绝对值不代表生产容量。
四、phase5:MCP 解耦 + 第一次抓到"失控"
phase5 把工具从 Agent 代码里挪出去:MCP server 独立进程发布工具(协议代码零行),client 动态发现——与 phase3 的差异只有一行:defaultTools(@Tool Bean) 换成 defaultToolCallbacks(MCP 发现)。发现回来的每个工具再包一层轨迹记录器(装饰器),记录每次调用的工具名、参数、结果摘要,并设 25 次硬上限——超限返回"停止调用"的指令(软收敛)。
为什么要硬上限?javap 反编译确认:当时用的 spring-ai 版本工具循环没有 maxIterations。
4.1 失控案例:100+ 次调用,180 秒,没有答案
我问"某大学有哪些学院?请尽量列全"。模型手里有个查专业的工具,没有列学院的工具——于是它开始穷举:计算机、医学、考古、口腔……100+ 次工具调用,180 秒超时,没出答案。
关键是:终答评测根本看不见这件事(答案超时了)。是轨迹记录器先抓到调用次数异常。修复三件事:补一个 list_colleges 工具(后端接口本来就有)+ 25 次硬上限 + 系统提示"一次查全、禁止穷举"。复测:2 次调用、10.6 秒、完整学院列表。
教训:Agent 的能力边界 = 工具清单。 模型不会发明工具,只会拿现有工具硬凑你的需求。评测只看终答是不够的,过程轨迹必须单独一把尺子。
五、phase6:三路评测 + CI 门禁
终答评测有两把尺子都不够,最终落成三路互补:
| 路线 | 原理 | 弱点(实测样本) |
|---|---|---|
| keyword | 关键词包含,确定性 | 会把"猜对"记满分——活样本:keywordHit=true 而 toolsCalled=[] |
| judge | LLM-as-judge,temp=0 裁判打分 | 假阴性(见 5.2);temp=0 仍抖 ±0.05 |
| trajectory | 轨迹比对(依赖记录器) | 天生依赖被测方配合——ChatMemory 不存中间 tool 消息,轨迹必须自建 |
5.1 纪律:中位数 + 离散度
裁判有噪声:temp=0 仍抖 ±0.05,且换一批次跑,组间方差是组内的 2.2 倍。所以每题跑 3 轮取中位数,每轮独立 cid(防记忆串味),比 spread 小的差异不算数。裁判解析做三层容错(剥代码围栏 → 截大括号 → 失败降 0 分),裁判 client 不继承被测方的 system。
5.2 第二次反转:judge 83%,改裁判不改系统
CI 门禁稳定报 judge=83%(中位数,排除噪声)。逐题看裁判理由发现两处假阴性:裁判拿它自己"知道"的外部先验否定系统数据里的真值;在考研业务里要求"本科"信息。修 rubric 三条(数值以系统为准 / 限定业务域 / 仅明显错误才扣分),系统一行没动——83% → 100%。
纪律成文:先怀疑量具,再怀疑裁判,最后才怀疑系统。
5.3 第三次反转:评测被语义缓存污染
合流层加了语义缓存之后,第二次跑同一组 6 题:keyword 6/6 全对,但 tool_correct 0/6、工具轨迹为空——缓存把整条 LLM+工具链跳过了。修复:评测请求加 nocache=true。配套两道防线:语义缓存 key 带 namespace——sha1(namespace + 0x00 + normalized_question),其中 namespace = 模型@语料指纹(换模型/改语料自动失效),0x00 分隔符防内容伪造边界;gold 集版本化。
纪律:评测必绕缓存,否则量的是缓存不是系统。
5.4 gate.sh:门禁就是协议
gate.sh 从评测输出里 grep 各维中位数 PassRate,任一维 < 90 即 exit 1。评测输出格式是刻意设计的——输出格式本身就是门禁的协议,格式一变 grep 就断,等于强制评测端向后兼容。
六、合流层:语义缓存与混合检索
phase1~6 合流成产品层:Redis Stack 一套三用(向量库 / 语义缓存 / 会话记忆),检索三段式:向量 top10 → BM25 词法 → RRF(k=60) 融合 → rerank 精排 → top4。
- 为什么缓存放链路最前:命中 0.12s vs miss ~27s(≈200 倍)。命中的请求不需要 LLM——如果先占舱壁再查缓存,等于让"不用花的钱"挤兑只有 3 个座位的配额。
- 缓存护栏(facet guard):命中除余弦 ≥0.92 外还要维度指纹相等,防"问学费命中只答城市的答案"这类串味;任一侧识别不出就退回纯余弦(保命中率)。实测自然同义改写余弦 <0.92 不误命中,护栏不误伤。
- 缓存索引与 RAG 索引强制隔离:缓存问答文本和用户问题措辞几乎一样,混进 RAG 索引必然被 KNN 召回到最前——检索开始引用缓存旧答案,一个自我复制的回路。
- 确定性文档 id(sha1(来源+正文)):防重启重复灌库,实测 UUID 默认堆到 55 块,改后稳定 11 块;同时也是混合检索两路对齐的前提。
- 字节序坑:float32 向量写 Redis 忘设 LITTLE_ENDIAN 时不报错——"程序逻辑完全正确,只有字节是错的",表现为命中率怎么调阈值都不对。
七、可观测:绕开分位数坑,tracing 进 Langfuse
Micrometer 的分位数有三个口径会打架(record 单位 / 读取单位 / base unit),实测算出 5.8e12 毫秒 ≈ 184 年。结论:应用内只自采 mean/max(AtomicLong 存纳秒,输出时 /1e6),p95/p99 交给 k6 这个权威源;Micrometer Timer 只留给 Prometheus 画分布。
全链路接 OTel → Langfuse(全采样),prod profile 关闭 prompt/completion/工具内容采集——观测脱敏本身也应该是功能。token 统计从响应 Usage 累加,乘外置单价直接得到烧钱速率(占位单价,非账单)。
八、十问十答(速查版,答案都在上文对应小节)
- Java 技术栈做 Agent 靠谱吗? 靠谱。Spring AI 工具循环 + MCP starter 补齐生态;本文全部能力都是 JVM 侧实现。
- Agent 怎么做可观测性? Micrometer 四类指标(工具成败/缓存/LLM 结果/延迟)+ OTel 全链路 tracing 进 Langfuse;p95 交给压测工具。
- LLM 并发限流怎么做? Semaphore 舱壁 + tryAcquire 非阻塞,拿不到立即 503——拒绝优于排队(§3.3 的容量算术)。
- 为什么 Agent 限流要拒绝而不是排队? 排队 4 秒和拒绝体验无区别,但拒绝不占线程不烧 token(§3.3)。
- 下游工具挂了怎么防止模型编造? 结构化降级
{unavailable:true}+ 系统提示"照实说查不到"(§3.1)。 - 熔断和降级什么关系? 降级管"失败后返回什么",熔断管"还要不要再试",必须成对(§3.1/3.2)。
- 怎么防模型无限调用工具? 轨迹记录器 + 25 次硬上限 + 提示劝退,两半护栏(§4.1)。
- 评测为什么至少要三路? keyword 会把猜对记满分、judge 有假阴性且自带噪声、trajectory 才能看见过程——互为补充(§五)。
- temp=0 评测就稳定了吗? 不是。实测仍抖 ±0.05,组间方差是组内 2.2 倍——中位数 + spread 纪律不可省(§5.1)。
- 语义缓存怎么防"答非所问的命中"? 余弦阈值 + 维度指纹双条件,识别不出不否决(§六)。
九、局限与下一步(如实写)
- 全部数字来自自建实验环境,绝对值不代表任何生产容量;沙盒业务接口为匿名只读,量级有限。
- 单机笔记本环境,k6 开环 100/s 已是上限,未验证更大并发下的行为。
- judge 评测的 gold 集为自建题组,覆盖面有限;对抗题(13 题)主要覆盖工具调用与拒答路径。
- rerank 模型与 embedding 模型的选择未做横向对比,只验证了"该有 rerank"的位置(召回在前、精排在后)。
- phase1/2 的部分实验只跑了一轮,原始产物未全部落盘;文中标注"实测"的数字均以最新批次为准。
下一步(按优先级):多模型横向对比(同题跑 2~3 家模型看指标漂移);分布式压测(脱离单机,验证舱壁/熔断参数在多实例下的迁移);对抗题扩容(从 13 题到 50+,覆盖注入类攻击);长时态记忆策略(当前是消息窗口,会话记忆外置到 Redis 后的检索式记忆)。
十、安全与脱敏(必读)
- 试验与生产隔离:数据源只接沙盒只读接口,生产域名仅作契约来源、永不作默认值;压测/评测永不打生产。
- 观测脱敏:prod profile 关闭 prompt / completion / 工具内容三类采集(
include-prompt=false等三开关),Langfuse 只留结构化指标与 trace 骨架。 - 不可信输入不执行:模型输出的表达式由手写递归下降求值器执行(白名单语法),不接任何脚本引擎;工具参数走字段白名单 + 长度上限,外部文本永不拼进可执行上下文。
- 提示注入(prompt injection)的防线分层:系统提示声明工具用途边界 → 工具层参数校验 → 结构化降级兜底;trajectory 评测把"异常调用模式"纳入监控维度。
附录 · 缩写表
| 缩写 | 全称 / 含义 |
|---|---|
| RAG | Retrieval-Augmented Generation,检索增强生成 |
| RAGAS | RAG 评测框架(faithfulness / answer relevancy / context precision / context recall) |
| MCP | Model Context Protocol,模型上下文协议(工具解耦与发现) |
| RRF | Reciprocal Rank Fusion,多路检索排名融合(k=60 为常用常数) |
| BM25 | 词法检索算法(Redis Stack RediSearch 内置) |
| SSE | Server-Sent Events,服务端推送 |
| 舱壁(Bulkhead) | 并发隔离:限制同时进入某资源区的请求数,满员直接拒绝 |
| 熔断器(Circuit Breaker) | 连续失败达阈值后快速拒绝,冷却后放探针试探恢复 |
| 语义缓存(Semantic Cache) | 以向量相似度判定"同一问题"的答案缓存,带维度护栏防串味 |
| trajectory | 工具调用轨迹评测:比对"调了什么工具、什么顺序、几次" |
| judge | LLM-as-judge:用另一个模型实例按 rubric 给回答打分 |
| Langfuse | LLM 可观测平台(trace / dataset / 评测) |
十一、系列目录
| 篇 | 主题 | 链接 |
|---|---|---|
| 1 | Redis String 三种编码实测 | juejin.cn/post/7681171487518097442 |
| 2 | 线程池 CallerRunsPolicy 退化实测 | juejin.cn/post/7683387359011110975 |
| 3 | Agent 工程化:可观测/舱壁/熔断/开环压测 | juejin.cn/post/7684615074136981544 |
| 4 | RAG 评测三层反转:precision@4=0.55 是尺子错 | juejin.cn/post/7694123609883869227 |
| 5 | 本文:AI Agent 落地全景 | 本篇 |
八股可背,结论跑出。