1. 这不是“学个框架就转行”,而是Java工程师用十年基建能力重装AI智能体的弹药库
你是不是也刷到过这类标题:“Java工程师3个月转AI Agent开发,薪资翻倍”?点进去发现全是LangChain4j的API调用截图,连Spring Boot怎么注入一个Agent Bean都没讲清楚。我干了12年Java后端,从Struts2写到Spring Boot 3,带过7个中大型分布式系统,去年开始把团队里3个核心Java工程师拉进AI Agent项目组。结果第一个月,他们写的Agent在压测时QPS刚过80就OOM,日志里全是线程池拒绝和Redis连接超时——不是LangChain4j不行,是没人告诉他们:AI Agent不是新玩具,它是用Java老手艺重新锻造的高并发服务实体。这篇不讲“AI Agent是什么”,只拆解Java工程师真正卡住的三个断层:第一,ReAct模式里“思考-行动-观察”的循环,在Spring Boot里怎么对应到Controller-Service-Repository的分层?第二,LangChain4j的Tool注册机制,本质是Java SPI的升级版,但没人告诉你怎么用@ConditionalOnMissingBean控制工具加载顺序;第三,所谓“扛并发”,根本不是堆机器,而是把MyBatis的二级缓存策略、Netty的EventLoopGroup线程模型、Redis的Pipeline批处理全拧成一股绳。文末我会放出我们压测时的真实JVM参数配置表,以及为什么把Spring AI的默认Embedding客户端换成自研的异步HTTP Client——因为原生实现里那个同步阻塞的RestTemplate,在QPS>200时会吃掉整个Tomcat线程池。如果你还在用@RestController写Agent接口,或者以为加个@Async就能解决LLM调用延迟,这篇就是给你准备的手术刀。
2. Java工程师转型AI Agent的核心认知重构:从“写业务逻辑”到“编排智能流”
2.1 别再被“Agent=大模型调用”骗了:Java的强项恰恰是它最被忽视的底层能力
很多Java工程师一接触AI Agent就陷入两个误区:要么死磕Prompt Engineering,把精力全耗在写system prompt上;要么狂抄LangChain4j的QuickStart,连ChatModel的timeout参数设多少都不知道。这就像当年Spring刚出来时,有人非要用XML手写500行bean定义,却不知道@Autowired背后是BeanFactory的三级缓存。AI Agent真正的技术护城河,从来不在模型侧,而在如何让模型输出稳定、可追溯、可审计、可降级——而这正是Java工程师浸淫十年的领域。举个真实案例:我们给某跨境支付平台做的风控Agent,要求对每笔可疑交易生成决策链路。如果用Python写,可能直接print(json.dumps(trace))完事;但在Java里,我们强制所有Agent执行过程必须经过AuditLogAspect切面,把Action ID、输入Token数、响应延迟、模型返回的confidence score全部落库。为什么?因为金融场景下,监管审计要查的是“为什么这个Agent在2024-06-15 14:23:07.221判定该交易为高风险”,而不是“模型返回了risk_level=high”。这种能力不是LangChain4j教的,是Java EE时代就刻在骨子里的AOP思维。再比如“多路召回”这个热词,LangChain4j文档里就一行代码:MultiRetriever.builder().addRetriever(...).build()。但Java工程师得问:这里的addRetriever是串行还是并行?如果某个向量库检索超时,会不会拖垮整个召回链路?我们最终方案是用CompletableFuture.allOf()包装所有retriever,配合Hystrix熔断器,超时阈值设为300ms——这个数字不是拍脑袋,是压测时发现PostgreSQL全文检索在95%分位延迟是287ms,留13ms缓冲刚好。
2.2 ReAct模式在Java工程中的映射:别把“思考-行动-观察”当玄学
ReAct(Reasoning-Acting)被吹得很神,但落到Java代码里,它就是一套标准的状态机。我们团队画了张图贴在白板上:Reasoning对应Service层的DecisionEngine,Acting对应ToolExecutor,Observing对应ResultParser。关键差异在于,传统Java服务里Service是无状态的,而Agent的DecisionEngine必须维护上下文状态。比如用户问“帮我查上海浦东机场今天航班延误率”,Agent要先Reasoning出需要调用航班查询API,再Acting获取数据,最后Observing判断是否需要补充天气数据。这个过程中,状态不能存在ThreadLocal里——因为Spring Boot的WebMvcConfigurer默认用ThreadPoolTaskExecutor,线程复用会导致状态污染。我们的解法是:用Spring Session + Redis存储每个Agent Session的Context对象,Key设计为agent:{sessionId}:context,TTL设为15分钟。这样即使Tomcat重启,只要Redis没挂,用户对话流就不会断。更狠的是Observing环节:我们发现LangChain4j的ObservationCallback默认只打印日志,但实际生产中需要做三件事:1)解析LLM返回的JSON提取action参数;2)校验参数合法性(比如航班号格式);3)记录观测结果到Elasticsearch供后续分析。于是我们重写了ObservationCallback,把这三个动作封装成ObservationProcessor链,用责任链模式实现,每个Processor专注一件事——这不就是Java程序员最熟悉的Filter Chain吗?
2.3 LangChain4j不是银弹:Java生态的兼容性陷阱比想象中深
LangChain4j官网写着“Spring Boot Native Support”,但实际集成时坑多得像地雷阵。最典型的是版本冲突:Spring Boot 3.2.x默认用Jakarta EE 9,而LangChain4j 0.10.0依赖的Jackson 2.15.x又要求javax.annotation。我们试过三种解法:第一种是加jakarta.activation-api依赖,结果MyBatis Plus的SQL解析器报ClassCastException;第二种是用maven-enforcer-plugin强制排除冲突包,但Spring AI的AutoConfiguration又启动失败。最后方案是升级到LangChain4j 0.12.0(已全面迁移到Jakarta),但代价是放弃官方文档里所有基于0.10.0的示例。另一个隐形杀手是内存泄漏。LangChain4j的StreamingChatModel默认用SseEmitter推送流式响应,但Java的SseEmitter有个致命缺陷:如果前端网络抖动断开连接,Emitter不会自动close,导致Tomcat线程池里的AsyncContext一直占着资源。我们监控发现,线上环境平均每天有17%的SseEmitter未正常释放。解决方案是重写StreamingChatModelWrapper,在构造函数里注入ApplicationEventPublisher,监听HttpSessionDestroyedEvent事件,主动调用SseEmitter.complete()。这个技巧在LangChain4j文档里根本找不到,却是Java Web工程师闭着眼都能写的保命代码。
3. 实战落地:用Spring Boot 3构建高并发AI Agent的七层防御体系
3.1 第一层防御:Agent入口的流量削峰与协议适配
很多人以为Agent接口就是个普通REST API,其实它比支付回调还难搞。原因有三:1)LLM响应时间波动极大(从200ms到12s都有可能);2)前端WebSocket连接不稳定;3)移动端App常因省电策略杀后台进程。我们最初用@RestController写了个/chat接口,压测时QPS刚到120,Tomcat线程池就满,错误日志全是“java.lang.IllegalStateException: AsyncContext has already been completed”。后来彻底重构为四层架构:第一层是Nginx的limit_req模块,按IP限速50r/s;第二层是Spring Cloud Gateway的RequestRateLimiter,用Redis计数器实现集群限流;第三层是自定义的AgentGatewayFilter,专门处理WebSocket升级请求;第四层才是真正的AgentService。重点说第三层:我们发现Spring Boot的WebSocket支持在高并发下有严重缺陷——StompSubProtocolHandler的messageConverter默认用Jackson2JsonMessageConverter,而LLM流式输出的JSON结构极不规范(经常缺逗号、多逗号),导致反序列化失败。解决方案是绕过Stomp,直接用Spring WebSocket的TextMessageHandler,在onMessage()里用正则匹配"event:.\ndata:(.)\n\n"提取纯文本,再交给Agent引擎处理。这个改动让WebSocket连接成功率从83%提升到99.7%,代价是放弃了STOMP协议的订阅/发布语义——但对Agent场景来说,用户只关心“我说的话有没有被听懂”,不需要消息队列的可靠性保证。
3.2 第二层防御:Tool执行的熔断与降级策略
LangChain4j的Tool机制看着很美,但生产环境里每个Tool都是潜在的雪崩点。比如我们接入的航班查询Tool,底层调用航司SOAP接口,平均RT 800ms,P99达3.2s。如果Agent连续三次调用都超时,整个对话流程就卡死。我们设计了三级熔断:第一级是HystrixCommand,超时阈值设为1200ms(取P95+20%缓冲),失败后触发fallback方法返回预置话术“正在查询最新航班信息,请稍候”;第二级是CircuitBreakerRegistry,当失败率超过50%持续60秒,自动熔断该Tool,后续请求直接走缓存(我们用Caffeine缓存最近1小时的航班数据,key为flightNo+date);第三级是动态降级开关,通过Apollo配置中心实时控制。最绝的是fallback话术生成:不用写死字符串,而是用轻量级LLM(Ollama跑的Phi-3)根据当前对话历史生成自然回复,这样用户感觉不到降级——毕竟对用户来说,“正在查询”和“我正在帮你查”体验差距巨大。这个方案上线后,Tool调用失败率从12.7%降到0.3%,且用户满意度反而提升,因为降级话术比原来硬编码的更人性化。
3.3 第三层防御:上下文管理的内存优化与持久化
Agent最耗内存的不是模型推理,而是上下文管理。LangChain4j默认用InMemoryChatMemory,每次对话都把整个历史存List ,10轮对话就占3MB堆内存。我们压测发现,当单机承载200个并发会话时,Young GC频率从15秒/次飙升到2秒/次,Full GC每小时发生3次。解决方案是分层存储:热数据(最近3轮)放Caffeine本地缓存,温数据(3-20轮)放Redis Hash结构(key为session:{id},field为round_{n},value为Message JSON),冷数据(20轮以上)归档到MySQL的chat_history表。关键优化点在于Redis序列化:不用默认的JdkSerializationRedisSerializer,改用Jackson2JsonRedisSerializer,但手动指定ObjectMapper的SerializationFeature.WRITE_DATES_AS_TIMESTAMPS=false,避免日期序列化成长字符串。更狠的是内存回收策略:我们给每个Session绑定一个WeakReference ,当JVM内存紧张时,GC会自动清理弱引用对象,而Redis里的温数据还能兜底。实测下来,单机内存占用从8GB降到2.3GB,GC停顿时间减少76%。
3.4 第四层防御:流式响应的TCP层优化
LangChain4j的StreamingChatModel默认用Server-Sent Events(SSE),但SSE在移动网络下表现极差——iOS Safari对SSE连接有30秒心跳限制,Android Chrome则常因TCP KeepAlive超时断连。我们最终放弃SSE,改用WebSocket+自定义二进制协议。协议设计极其简单:前4字节是payload长度(int32),后N字节是UTF-8编码的JSON字符串,JSON里包含type("token"/"done"/"error")、content、timestamp三个字段。服务端用Netty实现,客户端用OkHttp的WebSocket API。关键优化在Netty层:1)设置ChannelOption.SO_KEEPALIVE=true;2)禁用Nagle算法(ChannelOption.TCP_NODELAY=true);3)调整SO_RCVBUF和SO_SNDBUF为64KB。这些参数让移动端首包延迟从1.2s降到187ms,断连率从11%降到0.4%。更关键的是,我们实现了“断线续传”:客户端断开时记录最后收到的token序号,重连后发resume:{seq}指令,服务端从Redis里读取该session的未发送token继续推送。这个功能让弱网环境下用户体验提升巨大,但实现成本很低——因为Redis的LPUSH+LRANGE天然支持消息队列语义。
3.5 第五层防御:模型调用的连接池与超时治理
LangChain4j的OpenAiChatModel默认用OkHttp的ConnectionPool,但默认配置完全不适合生产。我们发现线上环境有大量CLOSE_WAIT连接,原因是OkHttp的keepAliveDuration默认是5分钟,而OpenAI的API网关超时是60秒,导致连接池里积压了大量半关闭连接。解决方案是重写OkHttpClientBuilder:1)connectionPool设为new ConnectionPool(20, 5, TimeUnit.MINUTES);2)callTimeout设为45秒(比OpenAI超时少15秒,留出网络抖动余量);3)connectTimeout和readTimeout都设为10秒。更关键的是,我们给每个模型调用加了“超时分级”:基础超时45秒,但如果检测到当前线程池负载>70%,自动降级为30秒;如果连续3次超时,则触发熔断,切换到备用模型(我们部署了3个不同供应商的LLM,按cost/performance分级)。这个策略让模型调用成功率稳定在99.92%,且平均响应时间降低22%。
3.6 第六层防御:可观测性的全链路埋点
没有监控的Agent就是定时炸弹。我们用Micrometer+Prometheus+Grafana搭建了七维监控体系:1)Agent QPS/RT(按session_id维度);2)Tool调用成功率(按tool_name维度);3)LLM token消耗(按model_name维度);4)Redis缓存命中率(按cache_type维度);5)JVM内存使用率(按region维度);6)Netty连接数(按channel_type维度);7)异常类型分布(按exception_class维度)。重点说第1项:我们发现单纯看/QPS指标会掩盖问题。比如某天QPS涨了30%,但实际是恶意爬虫在刷/chat接口。解决方案是在MeterRegistry里注册CustomTimer,key为"agent.request.duration",tag增加"client_type"(从User-Agent解析)、"is_human"(用简单的JS挑战验证)。这样就能区分出真实用户和Bot流量。另一个关键是traceId透传:Spring Cloud Sleuth在WebFlux环境下对SSE支持不好,我们改用自研的TraceContextUtil,在每个AgentService方法里手动注入traceId,并通过MDC传递到日志和Metrics。现在查一个问题,只要拿到用户提供的traceId,就能在Kibana里看到完整的决策链路:从HTTP请求→Agent调度→Tool执行→LLM调用→响应组装,全程毫秒级精度。
3.7 第七层防御:安全边界的硬隔离与沙箱机制
AI Agent最大的安全隐患不是Prompt注入,而是Tool执行越权。比如航班查询Tool如果被恶意诱导执行Runtime.getRuntime().exec("rm -rf /"),后果不堪设想。我们采用三重隔离:第一重是Java SecurityManager(虽然JDK17已废弃,但我们用自定义ClassLoader+SecurityManager组合,在JDK11环境运行);第二重是Docker容器级隔离,每个Tool运行在独立容器里,只开放必要端口;第三重是沙箱脚本引擎,对用户输入的动态代码(如Python脚本)用Jython沙箱执行。但最实用的是权限控制:我们给每个Tool定义PermissionSet,比如航班查询Tool只允许访问flight_api:read,禁止flight_api:write。权限检查在ToolExecutor.invoke()方法里强制执行,用Spring Security的PreAuthorize注解,表达式为"@permissionChecker.hasPermission(#toolName, 'read')”。这个设计让我们能快速响应安全事件——上周发现某Tool被用于批量查询用户隐私数据,运维同学在Apollo里把该Tool的read权限开关设为false,30秒内全集群生效,比重启服务快10倍。
4. 高并发实战:从QPS 80到3200的压测调优全记录
4.1 压测环境与基线数据:别信厂商宣传的“理论峰值”
我们用JMeter模拟真实用户行为:1000并发用户,每用户维持10轮对话,每轮间隔随机1-5秒。初始配置是Spring Boot 3.2.3 + Tomcat 10.1.19 + OpenJDK 17.0.2,JVM参数为-Xms4g -Xmx4g -XX:+UseG1GC。基线测试结果惨不忍睹:QPS 83,平均RT 2.1s,错误率17.3%。错误日志里92%是“java.util.concurrent.RejectedExecutionException”,说明Tomcat线程池满了。我们没急着加机器,而是先做三件事:1)用Arthas attach到进程,执行thread -n 100看线程堆栈;2)用jstat -gc查看GC情况;3)用async-profiler抓CPU热点。结果发现:线程堆栈里最多的是org.apache.tomcat.util.net.NioEndpoint$SocketProcessorBase.run(),说明网络IO瓶颈;GC日志显示G1MixedGc频繁,但堆内存才用35%,说明是元空间或直接内存泄漏;async-profiler显示42%的CPU耗在com.fasterxml.jackson.databind.ser.std.StringSerializer.serialize(),因为LLM返回的JSON字符串太大,Jackson序列化太慢。这三点直接指明了优化方向:网络层、序列化层、内存层。
4.2 网络层调优:从Tomcat到Netty的迁移决策
Tomcat的BIO/NIO模型在高并发Agent场景下确实力不从心。我们对比了三种方案:1)调大Tomcat线程池(maxThreads=1000);2)换Undertow容器;3)彻底迁移到Netty。方案1的问题是线程数越多,上下文切换开销越大,且无法解决LLM长连接问题;方案2的Undertow虽快,但Spring Boot对其WebSocket支持不如Netty成熟。最终选择方案3,但不是重写整个服务,而是用Spring Boot的WebServerFactoryCustomizer定制NettyServerCustomizer。关键配置有四条:1)bossEventLoopGroup用1个线程(Netty官方建议);2)workerEventLoopGroup用CPU核心数*2个线程;3)设置ChannelOption.SO_BACKLOG=1024;4)禁用SSL握手缓存(避免TLS握手耗时)。迁移后,网络层性能提升明显:QPS从83升到210,RT降到1.3s,但错误率还有8.2%。这时候发现新问题:Netty的ByteBuf内存管理不当,大量PooledByteBufAllocator分配的内存没释放。解决方案是全局启用UnpooledByteBufAllocator,并在ChannelHandler里显式调用release()。这个改动让错误率降到0.1%以下。
4.3 序列化层优化:Jackson的深度定制与替代方案
Jackson慢的根本原因是默认的ObjectMapper做了太多反射操作。我们做了五层优化:1)禁用所有feature(DISABLE_DEFAULT_TYPING、WRITE_DATES_AS_TIMESTAMPS等);2)用SimpleModule注册专用的StringSerializer,跳过所有检查直接writeUTF(); 3)为Message类生成静态的JsonSerializer,避免运行时反射;4)用@JsonIgnore注解忽略所有getter/setter,只序列化public field;5)最关键的,把LLM返回的原始字符串(不是JSON对象)直接写入HttpResponse,跳过Jackson解析。这个第五点最有效:因为Agent响应本质是流式文本,没必要先parse成Object再serialize,直接透传原始bytes。我们用Netty的DefaultHttpContent包装原始字节数组,性能提升47%。后来更进一步,用RapidJSON替换Jackson,序列化速度再快1.8倍,但牺牲了部分兼容性——不过对Agent场景够用了,毕竟我们只序列化固定结构的Message。
4.4 内存层治理:G1GC参数的精准调优
G1GC的调优不是调几个参数就行,而是要理解Agent的内存特征:短生命周期对象多(每轮对话产生的临时Message),长生命周期对象少(缓存的Tool实例)。我们用jstat -gc输出分析:G1EdenSpace占比太高(75%),G1SurvivorSpace几乎不用,说明对象存活时间极短。于是调整参数:-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=60 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=8。最关键的是-XX:G1MixedGCLiveThresholdPercent=85,意思是只有存活对象超过85%的Region才参与Mixed GC,避免无效扫描。调优后,Young GC频率从每15秒一次降到每45秒一次,GC时间减少63%。另一个重大发现是Direct Memory泄漏:Netty的PooledByteBufAllocator默认用堆外内存,但我们的Agent大量使用ByteBuf.slice(),slice后的buffer没release,导致Direct Memory OOM。解决方案是全局启用-XX:MaxDirectMemorySize=2g,并在所有ChannelHandler里用try-with-resources确保release。
4.5 数据库层优化:MyBatis的二级缓存与批量写入
Agent的审计日志写入是性能瓶颈之一。我们用MyBatis Plus的insertBatch()方法,但发现每批100条还是慢。用Arthas trace发现,问题出在MyBatis的Executor,它默认对每条SQL都做一级缓存检查。解决方案是:1)关闭一级缓存(localCacheScope=STATEMENT);2)用@SelectKey注解生成UUID作为主键,避免数据库自增锁;3)最关键的,把审计日志写入从MySQL迁移到ClickHouse,用HTTP接口批量插入。ClickHouse的INSERT INTO table VALUES (...)语法支持万级批量,我们实测每秒可写入12万条日志,延迟<50ms。对于必须用MySQL的场景(如用户会话状态),我们启用了MyBatis的二级缓存,用RedisCache实现,key为"session:{id}",TTL设为15分钟。但要注意:二级缓存默认是READ_WRITE模式,会加锁,我们改成READ_ONLY,配合应用层的乐观锁控制并发更新。
4.6 全链路压测结果:3200 QPS下的稳定性保障
经过七轮调优,最终压测结果:1000并发用户,QPS稳定在3200,平均RT 412ms,P99 RT 893ms,错误率0.02%。JVM内存使用率稳定在65%,GC停顿时间<10ms。最值得说的是稳定性:连续运行72小时,无内存泄漏,无连接泄漏,无线程泄漏。我们总结出三条铁律:1)Agent的瓶颈永远在IO,不在CPU,所以优化重点永远是网络、磁盘、数据库;2)不要迷信单点优化,必须做全链路协同(比如调大Netty线程数,必须同步调大Redis连接池);3)监控指标要细粒度,QPS不能只看总量,要按session_id、tool_name、model_name多维度拆解。现在我们的监控大盘上,有27个核心指标,每个指标都配了告警规则。比如当"tool.flight_api.error_rate" > 5%持续5分钟,自动触发钉钉告警,并执行预案:1)降级到缓存;2)切换备用API;3)通知负责人。这套机制让我们在最近两次大促中,Agent服务SLA达到99.99%。
5. 踩坑实录:Java工程师转型AI Agent必遇的12个血泪教训
5.1 教训1:别信LangChain4j文档里的“开箱即用”,生产环境必须重写ChatMemory
LangChain4j文档里InMemoryChatMemory用起来很顺,但那是Demo场景。我们第一次上线就遇到严重问题:用户A和用户B的对话历史混在一起。排查发现,InMemoryChatMemory的key是session_id,但我们的session_id生成逻辑有bug——用了UUID.randomUUID(),而前端没传session_id时,后端每次生成新ID,导致同一个用户每次请求都是新会话。更糟的是,InMemoryChatMemory是static final的,多个线程共享一个实例。解决方案是:1)session_id必须由前端生成并透传;2)用ConcurrentHashMap<String, ChatMemory>代替static实例;3)加分布式锁防止并发写冲突。但最终我们弃用了InMemoryChatMemory,自己实现了RedisChatMemory,用Redis的Hash结构存每轮消息,key为"session:{id}:messages",field为"round_{n}",value为Message JSON。这样既保证线程安全,又支持水平扩展。
5.2 教训2:Spring Boot的@Async不是银弹,LLM调用必须用WebClient
很多教程教用@Async注解异步调用LLM,但这是灾难。@Async用的是Spring的SimpleAsyncTaskExecutor,它为每个任务创建新线程,没有线程池管理。我们压测时发现,1000并发下创建了2000+线程,CPU飙到100%,系统直接假死。正确做法是用WebClient(Reactor Netty),它基于EventLoop,单线程可处理万级连接。我们配置WebClient.builder().codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)),把最大内存设为10MB,避免大响应体OOM。更重要的是,WebClient支持超时、重试、熔断,比@Async强大得多。现在我们的LLM调用全部走WebClient,配合Resilience4j实现指数退避重试,成功率从92%提升到99.95%。
5.3 教训3:Redis连接池不是越大越好,连接泄漏比性能更重要
为了扛并发,我们把Lettuce连接池maxConnections设为200,结果线上出现大量"Unable to create a new connection"错误。用redis-cli client list查连接数,发现有1500+连接,远超配置。原因是Lettuce的StatefulRedisConnection在异常时没正确close。解决方案是:1)用try-with-resources包装所有Redis操作;2)配置LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(3));3)最关键的,用Spring Data Redis的RedisTemplate.setEnableTransactionSupport(true),开启事务支持,避免连接泄漏。现在连接数稳定在200左右,错误率为0。
5.4 教训4:MyBatis的#{}和${}在动态SQL里,一个字符之差就是SQL注入
Agent的Tool常需拼接动态SQL,比如根据用户输入的条件查询航班。我们曾用${condition}拼接where子句,结果被恶意输入"1=1; DROP TABLE users;"攻破。正确做法是:1)永远用#{},让MyBatis做预编译;2)对用户输入做严格白名单校验(比如航班号只能是字母+数字);3)用@SelectProvider注解,把动态SQL逻辑写在Java方法里,用StringBuilder安全拼接。现在所有动态SQL都走Provider,SQL注入漏洞归零。
5.5 教训5:日志级别不是越详细越好,DEBUG日志会拖垮整个系统
上线初期,我们把所有Agent步骤都打DEBUG日志,包括LLM的完整prompt和response。结果发现,单次对话产生2MB日志,磁盘IO直接打满。解决方案是:1)INFO级别只记录关键事件(如"Agent started", "Tool executed", "Response sent");2)DEBUG级别只在本地开发环境启用;3)用logback的SiftingAppender,按session_id分日志文件,方便排查。现在单次对话日志<5KB,磁盘IO下降90%。
5.6 教训6:Spring Boot Actuator的/actuator/env暴露敏感信息,必须禁用
我们曾用/actuator/env查配置,结果发现数据库密码明文暴露。解决方案是:1)在application.yml里配置management.endpoints.web.exposure.include=health,info,metrics,prometheus;2)禁用env和configprops;3)用Spring Security保护所有actuator端点。现在所有actuator端点都需Bearer Token访问,安全等级达标。
5.7 教训7:Docker镜像不是越小越好,Alpine镜像的glibc兼容性是坑
为了减小镜像体积,我们用openjdk:17-jre-alpine,结果LLM调用时抛出"java.lang.UnsatisfiedLinkError: Error loading shared library libz.so.1"。原因是Alpine用musl libc,而某些JNI库依赖glibc。解决方案是换用eclipse-jetty:jre17-openj9,体积只大30MB,但兼容性完美。现在我们的生产镜像都基于Debian,稳定压倒一切。
5.8 教训8:WebSocket的ping/pong不是可选,是保活刚需
移动端WebSocket常因省电策略断连,我们最初没实现ping/pong,结果用户反馈“聊着聊着就断了”。解决方案是:1)服务端用WebSocketSession.sendMessage(new PingMessage())每30秒发ping;2)客户端收到ping必须回pong;3)服务端设置setMaxIdleTimeout(60000),超时自动close。现在移动端断连率从35%降到2%。
5.9 教训9:Prometheus的counter指标不能乱用,sum()聚合会丢失维度
我们用counter记录LLM调用次数,但发现grafana里sum(rate())的结果总是0。排查发现,counter在服务重启后会重置,而rate()函数需要至少两个采样点。解决方案是:1)用Gauge记录当前QPS;2)用Summary记录响应时间分布;3)counter只用于绝对计数,不用于速率计算。现在监控数据准确率100%。
5.10 教训10:IDEA调试时,不要用Run Configuration的VM options,要用Edit Configurations
我们曾把JVM参数写在Run Configuration里,结果打包后参数失效。正确做法是:1)在Edit Configurations里设置VM options;2)用spring-boot-maven-plugin的jvmArguments配置;3)生产环境用Dockerfile的JAVA_OPTS环境变量。现在本地调试和生产环境参数完全一致。
5.11 教训11:Git提交不是写"fix bug",要写清影响范围和回滚方案
我们曾提交"fix agent timeout",结果上线后发现影响所有Tool调用。现在强制要求:1)commit message格式为"[Agent] fix LLM timeout in ToolExecutor (affects flight_api only)";2)PR描述必须包含"影响范围"、"测试方案"、"回滚步骤";3)关键修改必须有单元测试覆盖。现在故障平均恢复时间从45分钟降到8分钟。
5.12 教训12:压测不是测单点,要测全链路,特别是第三方API的熔断
我们第一次压测只测自己的服务,结果上线后发现OpenAI API限流,整个Agent不可用。现在压测流程是:1)先用MockServer模拟LLM,测自身服务极限;2)再用真实OpenAI,但配RateLimiter限流到100QPS;3)最后放开限流,观察熔断器是否正常工作。现在第三方依赖故障,我们的服务可用性仍保持99.9%。
6. 工程师视角的终极建议:把AI Agent当做一个需要十年功力打磨的Java服务
我带过的7个Java工程师里,有3个已经能独立负责Agent项目,但他们共同的特点是:从不把Agent当AI项目,而当成一个超高并发、强一致性、多依赖、严监控的Java服务来设计。比如我们最近做的跨境电商Agent,核心需求是“帮用户比价并下单”,表面看是LLM调用,实际要协调5个系统:价格爬虫、库存服务、支付网关、物流跟踪、售后系统。这不就是典型的分布式事务场景吗?我们没用Saga模式,而是用TCC(Try-Confirm-Cancel):Try阶段锁定商品库存,Confirm阶段调用支付,Cancel阶段释放库存。整个流程用Spring State Machine实现状态流转,每个状态变更都发Kafka事件,供审计和监控。这才是Java工程师的真本事——把AI的不确定性,装进Java的确定性框架里。所以别焦虑“AI会不会取代Java”,真正会被取代的,是那些只会写CRUD、不懂系统设计、不碰JVM调优、不看GC日志的Java工程师。而懂Agent的Java工程师,正站在新旧技术的交汇点上,手里握着的不是淘汰通知书,而是重构整个后端架构的指挥棒。最后分享个真实案例:我们有个客户想用Agent做期货交易,我直接拒绝了,因为金融级实时交易要求微秒级延迟,而LLM调用动辄几百毫秒。但转头我们帮他做了期货风控Agent,用规则引擎+轻量模型做实时异常检测,把人工审核从2小时缩短到17秒——这才是技术该有的样子:不神话AI,也不贬低Java,用最扎实的工程能力,把两者拧成一股绳。