一个 Java 后端的 AI Agent 落地全景:工具 · RAG · MCP · 评测 · 护栏 · 可观测(全可复现数字)

6 阅读17分钟

「高并发实测」系列第 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-rate 100/s × 20s。

一句话结论:把大模型当成一个延迟高、会胡说(hallucination)、按 token 计费、无状态的下游服务,后端那套老手艺——超时、重试、限流、缓存、熔断、对账——直接全部复用。给工具层加一层本地缓存 + 一个自研熔断器,编排层 p95 从 836ms 降到 12.34ms(约 68 倍),缓存命中率 97.7%;而比性能数字更大的收获是:评测只看终答是不够的,过程轨迹必须单独一把尺子。

如果只记三句话:

  1. 工具层加"熔断 → 缓存 → 真调 → 写缓存 → 结构化降级"五步咽喉,下游挂了模型也不会编造;
  2. 并发保护用拒绝而不是排队(舱壁满员直接 503),拒绝不占线程不烧 token;
  3. 评测三路互补(关键词 / LLM 裁判 / 轨迹),先怀疑量具,再怀疑裁判,最后才怀疑系统。

〇、一分钟带走版

阶段要解决的问题核心产出关键数字
phase1Agent 能不能工作@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 倍)

agent-six-phases.png

全文关键词: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,两个设计点:

  1. 带引用:上下文按 【资料N|来源:X】 编号注入,要求模型句末复述 (资料N),两侧对得上才信。输出形如:"同济大学在上海,(资料2)"——引用编号由注入顺序决定,这是"可验证幻觉抑制"里成本最低的一档。
  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=40.55语料 10 块,交出 40%
topK=4 + rerank0.50精排救不了指标定义
topK=20.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。

agent-breaker.png

3.3 Semaphore 舱壁:拒绝,不要排队

模型并发上限设 3,tryAcquire() 拿不到立即 503。为什么拒绝而不是排队?按容量算:3 个并发额度 ÷ 4.7s ≈ 0.64 req/s,12 秒只能消化 7.6 个——排队只是让第 8 个人一直等到超时。

四个实现细节,每个都是踩出来的:

  1. tryAcquire() 放在 try 外——Semaphore.release() 没有"必须先 acquire"的校验,放 try 内会凭空多出许可;
  2. rejected 的计时放在 acquire 之前——0ms 的拒绝请求不污染延迟分布;
  3. 用 chatResponse() 而不是 content() 拿响应——后者丢 Usage,token 统计就断了;
  4. finally 里 release,防许可泄漏。

实测 6/s 到达打上限 3:72 个请求里 65 个(90.3%)被秒挡,只有约 7 个真进了模型。

agent-bulkhead.png

3.4 压测结论:p95 836ms → 12.34ms,但要交代前提

缓存 A/B(k6 开环 constant-arrival-rate 100/s × 20s = 2000 请求,0 失败):

场景p95命中率
缓存关836ms—
缓存开12.34ms97.7%

agent-cache-ab.png

⚠️ 必须交代的口径:这是编排层压测——我特意做了一个绕开 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 的能力边界 = 工具清单。 模型不会发明工具,只会拿现有工具硬凑你的需求。评测只看终答是不够的,过程轨迹必须单独一把尺子。

agent-runaway-before-after.png

五、phase6:三路评测 + CI 门禁

终答评测有两把尺子都不够,最终落成三路互补:

路线原理弱点(实测样本)
keyword关键词包含,确定性会把"猜对"记满分——活样本:keywordHit=true 而 toolsCalled=[]
judgeLLM-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 累加,乘外置单价直接得到烧钱速率(占位单价,非账单)。

八、十问十答(速查版,答案都在上文对应小节)

  1. Java 技术栈做 Agent 靠谱吗? 靠谱。Spring AI 工具循环 + MCP starter 补齐生态;本文全部能力都是 JVM 侧实现。
  2. Agent 怎么做可观测性? Micrometer 四类指标(工具成败/缓存/LLM 结果/延迟)+ OTel 全链路 tracing 进 Langfuse;p95 交给压测工具。
  3. LLM 并发限流怎么做? Semaphore 舱壁 + tryAcquire 非阻塞,拿不到立即 503——拒绝优于排队(§3.3 的容量算术)。
  4. 为什么 Agent 限流要拒绝而不是排队? 排队 4 秒和拒绝体验无区别,但拒绝不占线程不烧 token(§3.3)。
  5. 下游工具挂了怎么防止模型编造? 结构化降级 {unavailable:true} + 系统提示"照实说查不到"(§3.1)。
  6. 熔断和降级什么关系? 降级管"失败后返回什么",熔断管"还要不要再试",必须成对(§3.1/3.2)。
  7. 怎么防模型无限调用工具? 轨迹记录器 + 25 次硬上限 + 提示劝退,两半护栏(§4.1)。
  8. 评测为什么至少要三路? keyword 会把猜对记满分、judge 有假阴性且自带噪声、trajectory 才能看见过程——互为补充(§五)。
  9. temp=0 评测就稳定了吗? 不是。实测仍抖 ±0.05,组间方差是组内 2.2 倍——中位数 + spread 纪律不可省(§5.1)。
  10. 语义缓存怎么防"答非所问的命中"? 余弦阈值 + 维度指纹双条件,识别不出不否决(§六)。

九、局限与下一步(如实写)

  • 全部数字来自自建实验环境,绝对值不代表任何生产容量;沙盒业务接口为匿名只读,量级有限。
  • 单机笔记本环境,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 评测把"异常调用模式"纳入监控维度。

附录 · 缩写表

缩写全称 / 含义
RAGRetrieval-Augmented Generation,检索增强生成
RAGASRAG 评测框架(faithfulness / answer relevancy / context precision / context recall)
MCPModel Context Protocol,模型上下文协议(工具解耦与发现)
RRFReciprocal Rank Fusion,多路检索排名融合(k=60 为常用常数)
BM25词法检索算法(Redis Stack RediSearch 内置)
SSEServer-Sent Events,服务端推送
舱壁(Bulkhead)并发隔离:限制同时进入某资源区的请求数,满员直接拒绝
熔断器(Circuit Breaker)连续失败达阈值后快速拒绝,冷却后放探针试探恢复
语义缓存(Semantic Cache)以向量相似度判定"同一问题"的答案缓存,带维度护栏防串味
trajectory工具调用轨迹评测:比对"调了什么工具、什么顺序、几次"
judgeLLM-as-judge:用另一个模型实例按 rubric 给回答打分
LangfuseLLM 可观测平台(trace / dataset / 评测)

十一、系列目录

篇主题链接
1Redis String 三种编码实测juejin.cn/post/7681171487518097442
2线程池 CallerRunsPolicy 退化实测juejin.cn/post/7683387359011110975
3Agent 工程化:可观测/舱壁/熔断/开环压测juejin.cn/post/7684615074136981544
4RAG 评测三层反转:precision@4=0.55 是尺子错juejin.cn/post/7694123609883869227
5本文:AI Agent 落地全景本篇

八股可背,结论跑出。