一个 Java 开发者从零构建两个 LLM Agent 应用的实战记录。项目背景:Gym-Agent(体育场馆预约平台的 AI 助手,Spring Boot 3 + LangChain4j + pgvector RAG)和 Job-Agent(求职全链路 Agent,Spring Boot 3 + LangChain4j + Planner-Executor 架构)。所有踩坑均来自真实代码,细节略有脱敏。
事情的起点
去年年中我给自己挖了个坑:给一个跑了两年的体育场馆预约系统(Spring Boot 3 + MyBatis + Vue3 的老组合)加一个 AI 助手,后来这个项目叫 Gym-Agent。需求听起来很朴素——用户说"帮我看看明天下午有没有羽毛球场",系统能听懂、能查、能约。
AI 这块的技术选型没纠结太久。Java 圈里能打的 Agent 框架就那么几个,对比了一圈之后选了 LangChain4j 1.13.1。打动我的是文档里的一个例子:用 AiServices 把一个接口、一个模型、几个工具拼起来,Agent 就活了。
GymAgentAssistant assistant = AiServices.builder(GymAgentAssistant.class)
.chatModel(chatModel) // OpenAI 兼容接口
.chatMemoryProvider(memId -> new RedisChatMemory(memId))
.tools(queryTools, bookingTools, ragTools)
.build();
String answer = assistant.chat("明天下午有羽毛球场吗?", userId);
真的就这么几行。接的是 OpenAI 兼容 API,baseUrl + apiKey 配上就能指向任意云端模型。Demo 跑通那天晚上,我在工位上反复问它"明天能约乒乓球吗""帮我订周五的场",看着日志里模型规规矩矩地调用 queryVenues、生成预约草稿,心里想的是:Agent 开发的门槛,已经被框架抹平了。
三个月后回头看,这个想法错得离谱。
框架真正替你解决的问题
先说公道话,LangChain4j 干的活是有价值的,而且是那种不体面的脏活:
- 模型协议封装。OpenAI 兼容格式的请求拼装、流式响应解析、错误重试,接火山引擎、通义千问都是换个
baseUrl的事。 - Function Calling 的序列化。
@Tool注解标在方法上,参数用@P写描述,框架自动生成 JSON Schema 发给模型;模型返回 tool call 之后,框架反射调用方法、把结果再喂回模型。这一进一出自己手写 HTTP 的话,光是参数校验和异常兜底就够喝一壶。 - 对话记忆容器。
ChatMemoryProvider按 memoryId 隔离对话,窗口截断也是现成的。 - EmbeddingStore 抽象。pgvector 实现即插即用,RAG 的最小可用版本二十行代码。
这些如果都自己写,项目会从"做 Agent"变成"做 SDK"。所以问题从来不是"框架有没有用",而是——它的边界在哪里。下面是我撞过的四面墙。
第一面墙:@Tool 没有"元数据"
@Tool 注解能携带的信息只有 name 和 description,而这两个字段是给模型看的。但我的系统需要另一套信息,是给系统自己看的:
- 这个工具需要登录态吗?
- 风险等级是什么?只读、写入、还是不可逆?
- 需要用户二次确认吗?
- 要不要限流?
- 参数的校验规则是什么?
这些信息模型不需要知道,但权限系统、审计日志、安全策略全都依赖它。框架没有这个概念,那就自己造:
public interface GymAgentToolExecutor {
String toolCode();
String toolName();
String description(); // 给模型看的
RiskLevel riskLevel(); // READ / WRITE / IRREVERSIBLE,给系统看的
boolean needLogin();
boolean needConfirm();
int rateLimitSeconds();
List<ToolParamDefinition> paramDefinitions();
ToolResult execute(ToolContext ctx, Map<String, Object> params);
}
原来的工具类(GymQueryTools、GymBookingTools 这些)退化成一层薄壳,@Tool 方法只负责接参数,转手丢给对应的 executor。再写一个 GymAgentToolRegistry,@PostConstruct 里扫描所有 GymAgentToolExecutor 实现类自动注册——写到这里我突然意识到,我这是照着 Spring 的套路,在框架之上又发明了一遍 IoC。
这部分代码不复杂,但它决定了后面所有安全设计(草稿确认、权限拦截、限流)能不能落地。工具的元数据是 Agent 治理的地基,而框架对此一无所知。
第二面墙:记忆不是 ChatMemory
MessageWindowChatMemory 给你一个滑动窗口,仅此而已。用户上一句说"羽毛球馆",下一句说"那个场地的价格呢",模型能接住,因为还在窗口里。但窗口外的世界一片漆黑:跨会话、长周期偏好、记忆冲突,全都得自己搓。这一块后来在 Job-Agent 里长成了一整个 memory/ 包,踩的坑足够单独写一篇(见本系列第三篇)。
第三面墙:可观测性是个黑盒
AiServices 返回的是动态代理,请求进去、答案出来,中间模型调了哪个工具、参数是什么、耗时多少,框架外面看不见。等你想加日志的时候,会发现一脚踏进 Spring AOP 和框架反射机制打架的泥潭——这个坑我 debug 到凌晨一点,值得单开一篇(见本系列第五篇)。最后的结果是搓了一套 AgentToolLogHelper + AgentTraceContext,用 traceId 把一次提问触发的多次工具调用串成一棵树。
第四面墙:框架没有"执行前"的钩子
Agent 时代最要命的安全问题是:模型决定调用一个有副作用的工具时,系统没有官方途径说"等一下,先问问用户"。LangChain4j 的工具执行是直线型的:模型发出 tool call,框架反射执行,结果返回。想在中间插一道人机确认关卡,只能靠自己的双层工具设计——草稿与确认分离,这整套东西(包括后来 Job-Agent 里升级成的"行动确认中心")都是手搓的,见第二篇。
第二个项目给出的最终答案
后来做 Job-Agent(求职全链路 Agent,覆盖简历准备、岗位匹配、投递跟进、模拟面试),LangChain4j 升到 1.15.1 并换用官方 Spring Boot Starter,我学乖了,第一天就把架构分层定死:
Job-Agent-Bootstrap/ // 业务编排层
└── agent/
├── intent/ // Planner:意图拆解
├── plan/ // 计划管理、状态机
├── executor/ // AgentToolInvoker + 三层执行结果
├── guardrail/ // 注入防护、越权拦截、PII 脱敏
├── memory/ // 记忆提取、冲突检测、版本历史
└── schema/ // 工具四层 Schema
Job-Agent-Infra-Ai/ // LangChain4j 被压在这一层
LangChain4j 被压在 Infra-Ai 一层,只干模型交互这一件事。项目跑了一年,回头估一下代码量:和 LangChain4j 直接相关的配置与调用,占比不到 5%。剩下 95% 是 Planner、执行器、护栏、记忆、可观测、Eval——Agent 的骨架,全是自己搓的。
所以现在再看到"要不要手搓 Agent"的争论,我的看法是:这个问题本身是个伪命题。不管你用不用框架,你都在手搓 Agent——框架只是替你搓了最不重要的那一小块(模型交互层),而真正的难点(治理、安全、记忆、评估)它一行都不会替你写。
选型时真正该问的不是"哪个框架功能多",而是两个更实际的问题:**它的抽象和你的业务贴不贴?越界的时候好不好绕过去?**这一年用下来,LangChain4j 的抽象边界大体是清晰的,出问题的时候(比如上面那面 AOP 的墙)也有绕的余地。丑话说在前面:1.x 早期版本 API 变动不小,从 1.13 升到 1.15 我跟了两次升级,每次都要小改一圈。框架是拿来省力的,不是拿来信仰的。
下一篇讲这个系列里最惊心动魄的一件事:Agent 未经确认,直接帮用户把场地订了。
