LangChain4j声明式Agent动态代理与工具调用机制深度解析

发布时间:2026/10/20 16:26:21
LangChain4j声明式Agent动态代理与工具调用机制深度解析
1. 从一行注解说起为什么声明式 Agent 让人又爱又恨第一次在 LangChain4j 里看到Agent注解的时候我的反应大概是这也行。一个接口方法上标几个注解不写任何实现类运行起来就能自动调大模型、自动选工具、自动把结果拼回对话里。这种体验在 Java 生态里其实并不陌生——Spring 的Transactional、Feign 的FeignClient、MyBatis 的 Mapper 接口都是同一套思路用接口声明意图用动态代理在运行时补上实现。但 Agent 这个场景比前面几个都要复杂。事务代理只是包一层 begin/commitFeign 代理只是把方法调用翻译成 HTTP 请求而 Agent 代理要做的事情是把你的方法调用翻译成一次完整的大模型交互循环中间可能穿插若干次工具调用、若干轮消息追加最后还要把大模型返回的自然语言或结构化结果反序列化回你声明的返回类型。这中间的链路长度比普通 RPC 代理长了不止一个量级。所以这篇文章不打算停留在怎么用Agent写一个 Hello World的层面。那种内容官方文档已经写得很清楚了。我想拆的是更底层的东西当你调用一个声明式 Agent 接口的方法时从 JVM 的方法调用到最终拿到返回值中间到底发生了什么。动态代理是怎么把接口方法拦截下来的拦截之后是怎么构造请求的大模型返回的我要调用某个工具这个意图是怎么被解析、执行、再把结果喂回去的工具方法的参数又是怎么从大模型的输出里映射出来的这些问题搞清楚了你在实际项目里遇到工具死活不被调用参数映射总是错位多轮对话状态丢失这类问题时才有能力自己定位而不是对着日志干瞪眼。这篇文章适合两类人一类是已经在用 LangChain4j 的声明式 Agent但总觉得它是黑盒、想搞清楚内部机制的开发者另一类是自己动手写过 Agent 框架、想对比一下成熟实现思路的人。阅读前你只需要有基本的 Java 反射和动态代理概念剩下的我会从头讲。说明本文涉及的 LangChain4j 内部实现细节部分基于公开源码的常见设计模式进行合理推演具体类名和方法签名可能随版本变化重点在于理解机制而非死记 API。2. 动态代理如何把接口方法变成一次大模型调用2.1 声明式接口的本质一份给代理看的契约先看一个典型的声明式 Agent 接口长什么样。假设我们要做一个能查天气、能算数的助手interface Assistant { SystemMessage(你是一个乐于助人的助手可以调用工具来回答问题) String chat(UserMessage String userMessage); }这个接口没有任何实现类。你通过AiServices.create(Assistant.class, chatModel)拿到一个实例然后调用chat(北京今天天气怎么样)它就能返回结果。关键在于AiServices.create返回的到底是什么。答案是一个JDK 动态代理对象。LangChain4j 内部会用一个InvocationHandler的实现类来接管这个接口的所有方法调用。这个 Handler 持有的核心状态包括目标接口的Class对象、绑定的大模型实例、工具对象列表、以及每个方法上注解解析出来的配置系统消息、用户消息模板、记忆策略等。这里有个容易被忽略的点注解的解析时机。很多人以为注解是在每次调用时现读的其实不是。代理在创建阶段就会扫描接口的所有方法把SystemMessage、UserMessage、MemoryId、V这些注解解析成一份内部的方法元数据缓存起来。这样每次调用时直接查缓存避免重复反射。如果你在运行时动态修改注解虽然基本没人这么干代理是感知不到的。2.2 方法拦截后的第一件事组装 ChatRequest当chat方法被调用时InvocationHandler.invoke拿到三样东西代理对象、Method对象、参数数组。接下来它要做的是把这次 Java 方法调用翻译成一次大模型请求。翻译过程大致分几步第一步确定系统消息。从方法元数据里取出SystemMessage的内容。如果内容是静态字符串直接用如果里面含有模板变量比如{{current_date}}还要做一次变量替换。LangChain4j 支持在系统消息里注入一些运行时变量这个替换是在调用时做的。第二步确定用户消息。UserMessage标注的参数会被提取出来作为用户输入。如果参数是简单类型String、int 等直接转字符串如果是复杂对象会走序列化逻辑。这里有个细节如果方法有多个参数但只有一个标了UserMessage其余参数会被当作模板变量处理。第三步收集工具定义。代理会遍历创建时传入的工具对象把每个Tool标注的方法转成一份工具描述——包括工具名、功能描述、参数 schema。这份描述最终会以特定格式塞进请求里告诉大模型你有这些工具可以用。第四步附加历史消息。如果配置了对话记忆ChatMemory代理会根据MemoryId找到对应的历史消息列表一并放进请求。这四步做完一个完整的ChatRequest就组装好了。注意此时还没有发生任何网络调用只是把 Java 层的调用意图翻译成了大模型能理解的请求结构。2.3 为什么用动态代理而不是代码生成这里插一个设计层面的问题为什么 LangChain4j 选择运行时动态代理而不是像 gRPC 那样在编译期生成代码我的理解是三个原因。第一Agent 的配置高度动态。工具列表、记忆策略、模型参数这些东西在运行时才确定编译期生成代码很难覆盖这种灵活性。第二接口即契约的体验更好。开发者只需要写接口和注解不需要引入额外的代码生成插件、不需要管理生成产物心智负担低。第三和 Spring 生态天然契合。动态代理对象可以直接注册成 Spring Bean注入到任何地方和现有的依赖注入体系无缝衔接。代价当然也有运行时反射有性能开销注解错误只能在运行时暴露调试时堆栈里全是代理类的名字。但对于 Agent 这种本身就是 IO 密集、单次调用动辄几百毫秒的场景反射那点开销完全可以忽略。3. 大模型说我要调工具之后回调是怎么落地的3.1 工具调用的完整闭环从模型输出到方法执行请求发出去之后大模型返回的响应有两种可能一种是直接给出最终答案另一种是表示我需要调用某个工具。后一种情况就是所谓的function calling或tool calling。不同模型的表达方式不一样。有的模型在响应里带一个结构化的tool_calls字段里面包含工具名和参数通常是 JSON有的模型则是把调用意图混在文本里需要额外解析。LangChain4j 在模型适配层做了统一把各家格式归一化成内部的ToolExecutionRequest结构。拿到ToolExecutionRequest之后代理要做的事情是根据工具名在创建时注册的工具映射表里找到对应的Method和工具对象实例。解析参数 JSON把每个字段映射到方法参数上。反射调用这个方法拿到返回值。把返回值序列化成字符串包装成一条ToolExecutionResultMessage。把这条消息追加到消息列表里再次发起大模型请求。注意第 5 步这是整个机制里最关键的一点工具调用不是一次性的而是一个循环。大模型可能连续调用多个工具也可能调用一个工具后基于结果再调用另一个。代理必须持续循环直到大模型返回的是最终答案而不是工具调用请求循环才结束。3.2 参数映射JSON 到 Java 的那层窗户纸参数映射看起来简单实际是坑最多的地方。大模型输出的参数是 JSON而 Java 方法的参数有具体类型。这中间的转换要考虑几种情况。基本类型和包装类型。JSON 里的数字映射到int、long、double字符串映射到String布尔映射到boolean。这些靠 Jackson 之类的库能自动处理但要注意大模型有时会把数字输出成字符串比如count: 5需要做容错。复杂对象。如果工具方法的参数是一个 POJOJSON 对象会被反序列化成这个 POJO 的实例。这里要求 POJO 有无参构造函数和标准的 getter/setter否则反序列化会失败。集合类型。JSON 数组映射到List、Set等。泛型信息在运行时会被擦除所以 LangChain4j 需要从方法的ParameterizedType里读取泛型参数才能正确反序列化元素类型。枚举类型。大模型输出的字符串要匹配到枚举常量大小写敏感匹配不上就会抛异常。我踩过的一个坑是工具方法的参数名在编译后默认会丢失变成arg0、arg1导致参数映射时找不到对应字段。解决办法是编译时加-parameters参数或者在参数上显式加P注解指定名称。这个问题在本地 IDE 里跑没事IDE 默认可能带了-parameters一打包到服务器就炸非常隐蔽。3.3 工具描述的质量决定调用准确率大模型能不能正确选择工具、正确填参数很大程度上取决于你给的工具描述。这份描述包括工具名、功能说明、每个参数的说明。工具名要见名知义用下划线或驼峰都行但要保持一致。功能说明要写清楚这个工具做什么、什么时候用、输入输出是什么。参数说明要写清楚每个参数的含义、格式、取值范围。举个反例。假设有个工具叫query描述是查询数据参数是param1、param2。大模型看到这个基本是懵的不知道什么时候该调、参数该填什么。改成queryWeatherByCity描述根据城市名查询当前天气返回温度和天气状况参数cityName描述城市名称如北京、上海大模型的选择准确率会明显提升。这里有个经验工具描述要写得像给一个新同事看的文档。你不能假设大模型应该知道什么所有隐含信息都要显式写出来。描述里可以包含示例比如日期格式为 yyyy-MM-dd。4. 多轮对话与记忆Agent 的状态到底存在哪4.1 ChatMemory 的存储模型声明式 Agent 默认是无状态的——每次调用都是独立的请求。但实际场景里我们往往需要多轮对话这就需要引入ChatMemory。ChatMemory的核心是一个消息列表按时间顺序存储用户消息、AI 消息、工具调用消息、工具结果消息。每次请求前代理会把历史消息取出来和当前消息拼在一起发给大模型。大模型返回后新的消息又被追加进去。LangChain4j 提供了几种记忆实现MessageWindowChatMemory只保留最近 N 条消息TokenWindowChatMemory按 token 数保留还有基于持久化存储的实现。选择哪种取决于你的场景短对话用窗口记忆就够长对话要考虑 token 预算需要跨会话保持的就得持久化。4.2 MemoryId 的作用与常见误用MemoryId注解用来标识这次调用属于哪个会话。代理会根据这个 ID 去ChatMemoryStore里找对应的消息列表。如果找不到就创建一个新的。常见的误用是把MemoryId加在了错误的参数上或者同一个会话用了不同的 ID。前者导致记忆串台后者导致记忆丢失。还有一种情况是多个用户共用一个 ID那对话历史就混在一起了这在多用户场景下是严重 bug。我的建议是MemoryId用能唯一标识会话的值比如用户 ID 加会话 ID 的组合。如果只是单机测试用固定字符串也行但上线前一定要改成动态值。4.3 记忆与工具调用的交互这里有个容易被忽略的细节工具调用的中间消息要不要存进记忆。从技术上讲工具调用请求和工具执行结果都是对话的一部分存进去能让大模型在后续轮次里记得自己调过什么工具、拿到了什么结果。但存进去也有代价消息列表会迅速膨胀token 消耗增加。LangChain4j 的默认行为是把工具相关的消息也纳入记忆管理。如果你发现对话几轮之后 token 消耗异常高可以检查一下是不是工具调用消息堆积了。解决办法是调整窗口大小或者对工具消息做特殊处理。5. 流式输出与声明式 Agent 的碰撞5.1 流式场景下代理的行为差异声明式 Agent 默认是同步阻塞的调用方法等大模型返回完整结果再返回。但很多场景需要流式输出——用户希望看到字一个个蹦出来而不是等好几秒突然出现一整段。LangChain4j 支持把返回类型声明成TokenStream或FluxString之类的流式类型。这种情况下代理的行为会发生变化它不再等待完整响应而是把大模型返回的 token 流直接透传给调用方。但流式和工具调用放在一起就复杂了。因为工具调用需要暂停流、执行工具、再继续而流式输出的语义是连续的。LangChain4j 的处理方式是在流式模式下如果检测到工具调用会先缓冲当前内容执行工具然后把工具结果作为新的上下文继续流式输出。这个过程中调用方看到的流可能会有短暂的停顿。5.2 流式场景的实操注意事项如果你要用流式 Agent有几个点要注意。第一返回类型的选择。TokenStream是 LangChain4j 自己的抽象Flux是 Reactor 的。选哪个取决于你的技术栈。如果项目里已经用了 Reactor用Flux更自然否则TokenStream更轻量。第二错误处理。流式场景下错误可能发生在流的中间而不是一开始。你需要在流的onError回调里处理而不是用 try-catch 包住整个调用。第三工具调用的可见性。流式模式下工具调用的过程对调用方是透明的你只能看到最终的文本流。如果需要在 UI 上展示正在调用工具的状态得靠额外的机制比如自定义事件或日志。6. 那些文档不会告诉你的踩坑实录6.1 工具方法抛异常时的连锁反应工具方法执行时抛异常会发生什么很多人以为代理会捕获异常、返回错误信息给大模型、让大模型决定怎么办。实际情况取决于版本和配置。在较新的实现里工具异常会被包装成一条工具执行结果消息内容是异常信息然后继续循环。大模型看到工具执行失败xxx之后可能会换个方式重试也可能直接告诉用户失败了。这个行为是合理的但要注意异常信息会原样发给大模型如果异常里包含敏感信息比如数据库连接串就泄露了。所以工具方法内部要做好异常包装不要把原始异常直接抛出去。6.2 参数名丢失导致的映射失败前面提过-parameters编译参数的问题这里展开说。Java 8 之前方法参数名在编译后完全丢失。Java 8 引入了-parameters选项开启后参数名会保留在 class 文件里。但 Maven 和 Gradle 默认都不开这个选项。如果你用 Maven需要在maven-compiler-plugin里加compilerArgsarg-parameters/arg/compilerArgs。Gradle 的话在compileJava任务里加options.compilerArgs -parameters。不开这个选项的后果是工具方法的参数名变成arg0、arg1而大模型输出的 JSON 用的是真实参数名映射时对不上参数全是 null 或默认值。这个 bug 在本地跑可能没事IDE 编译时可能默认开了打包后必现排查起来很费时间。6.3 大模型幻觉出不存在的工具大模型有时会调用一个根本不存在的工具名。这种情况代理会怎么处理通常是找不到对应的Method然后抛异常或者返回错误消息。无论哪种都会打断正常的对话流程。缓解办法有两个一是把工具描述写清楚减少大模型猜的空间二是在代理层做容错找不到工具时返回一条工具不存在的消息给大模型让它重新选择。后者需要你自己扩展代理逻辑LangChain4j 默认不一定提供。6.4 循环次数失控工具调用是个循环理论上大模型可以无限循环下去——调工具、看结果、再调工具……如果大模型陷入某种死循环你的程序就会一直跑下去烧 token 烧到天亮。LangChain4j 通常会有一个最大循环次数的限制超过就强制结束。但这个默认值可能不适合你的场景。我的建议是显式配置这个上限并且在达到上限时记录日志方便事后分析是哪个环节出了问题。7. 自己动手一个最小可运行的声明式 Agent7.1 依赖与模型配置理论讲了一堆还是得跑起来才算数。下面给一个最小可运行的例子。依赖方面核心是langchain4j和某个模型提供方的适配包。模型配置通常通过一个ChatModel实例传入里面包含 API 地址、密钥、模型名、温度等参数。ChatModel model OpenAiChatModel.builder() .apiKey(System.getenv(MODEL_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .build();温度参数控制输出的随机性。做工具调用场景时温度建议调低一点0.1 到 0.3因为你需要的是稳定的工具选择和参数填充而不是创意发挥。7.2 定义工具与接口工具类就是一个普通 Java 类方法上标Toolclass Calculator { Tool(计算两个数的和) double add(P(第一个加数) double a, P(第二个加数) double b) { return a b; } }P注解用来显式指定参数名和描述这样就不依赖-parameters编译选项了更稳妥。接口定义interface MathAssistant { SystemMessage(你是一个数学助手可以调用计算工具) String ask(UserMessage String question); }创建代理MathAssistant assistant AiServices.builder(MathAssistant.class) .chatModel(model) .tools(new Calculator()) .build();调用String answer assistant.ask(3 加 5 等于多少);7.3 验证工具是否真的被调用跑通之后怎么确认工具真的被调用了而不是大模型自己算出来的两个办法。一是在工具方法里打日志。如果日志出现了说明工具被调用了。这是最直接的验证方式。二是构造一个只有工具能回答的问题。比如问当前系统时间是多少大模型自己不知道必须调工具。如果返回了正确时间说明工具链路是通的。如果工具没被调用先检查工具描述是否清晰再检查模型是否支持 function calling有些小模型不支持最后检查工具方法签名是否符合要求public、参数类型可序列化等。8. 从声明式到编程式什么时候该换条路声明式 Agent 很优雅但不是万能的。有些场景下编程式的AiServices或者更底层的 API 反而更合适。需要精细控制对话流程时。声明式 Agent 把循环逻辑封装死了你没法在中间插入自定义逻辑。如果你需要在每次工具调用前后做审计、做权限校验、做结果缓存编程式更灵活。工具数量巨大时。声明式 Agent 会把所有工具描述塞进每次请求工具多了 token 消耗会爆炸。编程式可以按需动态选择工具子集。需要自定义记忆策略时。声明式的记忆管理是黑盒编程式可以完全自己控制消息列表的增删改。调试困难时。声明式 Agent 的调用栈很深出问题时堆栈信息不直观。编程式每一步都是显式的排查更容易。我的经验是原型阶段用声明式快速验证生产阶段根据复杂度决定是否切换到编程式。不要为了优雅而优雅能解决问题的方案才是好方案。9. 关于这套架构我个人的几点体会用了大半年 LangChain4j 的声明式 Agent最大的感受是它把 80% 的常见场景做到了开箱即用但剩下 20% 的复杂场景需要你理解底层才能搞定。动态代理这层抽象在简单场景下是透明的一旦出问题不理解代理机制就很难定位。另一个体会是工具描述的质量比代码质量更重要。我见过太多人把工具方法写得漂漂亮亮但描述写得一塌糊涂结果大模型根本不会用。花在写工具描述上的时间回报率比花在优化代码上高得多。最后一点不要迷信框架的默认行为。默认的最大循环次数、默认的记忆窗口大小、默认的异常处理策略这些都是通用值不一定适合你的场景。上线前把这些参数都过一遍该改的改该加的加。踩过的坑告诉我很多线上问题不是框架的 bug而是默认配置和实际场景不匹配。这套架构还在快速演进不同版本之间 API 可能有变化。与其死记某个类名或方法签名不如把动态代理、请求组装、工具回调、记忆管理这几条主线搞清楚。主线清楚了API 怎么变你都能快速上手。