返回技术文章

用了 LangChain4j 之后,我为什么还是感觉在"手搓"Agent

框架的能力边界与四个撞墙瞬间

约 6 分钟读完 LLM 应用
用了 LangChain4j 之后,我为什么还是感觉在"手搓"Agent 封面

一个 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 未经确认,直接帮用户把场地订了。