1. 为什么值得深挖 LangChain4j 的声明式 Agent第一次看到 LangChain4j 的@Agent注解和AiServices那套写法时,我的反应是"这也太魔法了"。你只写一个接口,方法上标几个注解,框架就能自动帮你把大模型的函数调用、参数解析、结果回填全部串起来。表面上看是几行声明式代码,底下藏着的却是一整套动态代理加反射回调的机制。这套东西解决的核心问题是:让 Java 开发者用最贴近原生语言习惯的方式,把大模型能力接进业务代码。传统做法里,你得手动拼 Prompt、手动解析模型返回的 JSON、手动判断该调哪个方法、手动把结果塞回去再发起第二轮请求。一个完整的工具调用链路写下来,光胶水代码就上百行,而且每接一个新工具就要复制一遍。声明式 Agent 把这套流程抽象成了接口定义,你只描述"我要什么",框架负责"怎么做到"。适合读这篇的人有三类:一是已经在用 LangChain4j 但只会照着文档抄、想搞懂底层到底发生了什么的开发者;二是准备自研 Agent 框架、想借鉴动态代理加回调这套设计思路的架构师;三是被各种"AI 框架"概念绕晕、想找一个具体实现来打通认知的技术负责人。我会从架构设计讲到动态代理的实现细节,再讲到函数回调的完整链路,中间穿插大量实操代码和我自己踩过的坑。需要提前说明的是,下面涉及的具体实现细节,一部分来自我对框架源码的阅读,一部分是基于常见动态代理和反射实践的合理推断。框架版本迭代很快,不同版本在细节上可能有出入,但核心思路是稳定的。2. 声明式 Agent 的整体架构设计思路2.1 从命令式到声明式的范式转变要理解 LangChain4j 为什么这么设计,得先看命令式写法有多痛苦。假设你要做一个能查天气、能算数学、能查数据库的助手,命令式代码大概长这样:先定义三个工具的 JSON Schema 描述,塞进请求体;拿到模型返回后判断finish_reason是不是tool_calls;如果是,解析出函数名和参数;写一个巨大的 switch-case 分发到对应方法;执行完把结果包装成tool角色的消息追加到对话历史;再发起第二轮请求。这套流程每加一个工具就要改 switch,参数类型变了还要改解析逻辑。声明式把这一切反转了。你定义一个接口:interface Assistant { @SystemMessage("你是一个乐于助人的助手") String chat(String userMessage); @Tool("查询指定城市的天气") String getWeather(@P("城市名") String city); }然后AiServices.create(Assistant.class, model)一行拿到代理对象,直接assistant.chat("北京天气怎么样"),框架自动完成"模型决定调 getWeather、传参北京、拿到结果、再让模型总结"的全过程。这个反转的本质是:把"怎么做"的编排逻辑从业务代码里抽走,交给框架的运行时。2.2 三层职责的清晰切分我读下来觉得这套架构最漂亮的地方是职责切分。它大致分成三层:第一层是接口声明层,也就是你写的那个 interface。这一层只表达意图:有哪些能力、每个能力的语义是什么、参数叫什么。它不关心模型是谁、不关心怎么调用。第二层是代理编排层,由AiServices生成的动态代理承担。它拦截接口方法调用,负责组装 Prompt、注入工具描述、发起模型请求、解析响应、判断是否需要工具调用、执行工具、回填结果、再次请求,直到拿到最终答案。第三层是模型适配层,由ChatLanguageModel及其各种实现承担。它屏蔽了不同模型服务商的 API 差异,统一成"给一组消息、返回一条消息"的接口。这种切分带来的好处是每一层都能独立替换。换模型只动第三层,加工具只动第一层,编排逻辑的调整集中在第二层。对比那些把三者揉在一起的框架,维护成本差距是数量级的。2.3 为什么选动态代理而不是代码生成这里有个关键选型问题:实现声明式接口,业界常见两条路——运行期动态代理,或者编译期代码生成(类似注解处理器生成实现类)。LangChain4j 选了前者。动态代理的优势是零构建侵入。你不需要在 pom 里加注解处理器,不需要处理生成代码的目录,不需要担心 IDE 索引不到生成的类。AiServices.create一调用,代理对象当场就有了。对于快速迭代的 AI 应用场景,这个体验很重要,因为 Prompt 和工具定义经常改,编译期生成意味着每次改都要重新构建。代价是运行期开销和调试难度。动态代理的方法调用比直接调用慢,反射解析注解也有成本。但实测下来,相对于一次大模型请求动辄几百毫秒到几秒的延迟,代理层的开销完全可以忽略。调试难度确实存在,栈里全是$Proxy和InvocationHandler,不过框架通过良好的日志弥补了这一点。提示:如果你的场景对启动速度极度敏感,或者需要 GraalVM 原生镜像,动态代理会带来额外配置成本。这种情况下可以考虑自己写实现类,但会失去声明式的便利。3. 动态代理层的核心实现细节3.1 AiServices.create 背后发生了什么AiServices.create(Assistant.class, model)这一行是整个魔法的入口。它内部做的事情,我拆成几个关键步骤:首先,它收集接口上的所有元信息。包括接口级别的@SystemMessage、每个方法的@UserMessage、方法参数上的@V、@MemoryId、@Tool注解等。这些信息被解析成一个内部的上下文对象,我把它叫做"接口契约"。然后,它构建一个InvocationHandler。这个 handler 持有接口契约、模型实例、以及可选的记忆存储、检索器、工具执行器等组件。所有对代理对象的方法调用都会进到这个 handler 的invoke方法。最后,用Proxy.newProxyInstance生成代理对象返回。注意这里用的是 JDK 动态代理,所以你的接口必须是 interface,不能是抽象类。这也是为什么 LangChain4j 的 Agent 都定义成接口。3.2 invoke 方法的完整处理链路invoke方法是整个代理层的心脏。它大致按这个顺序处理一次调用:识别方法类型。如果是Object的toString、hashCode、equals,直接走默认实现,不触发模型调用。这个判断很关键,否则日志打印、集合操作都会意外触发大模型请求,烧钱又慢。解析方法参数。根据参数上的注解,把实参映射到 Prompt 模板里的占位符。比如@V("city")标注的参数会替换模板里的{{city}}。没有注解的参数按位置或名称匹配。组装消息列表。把系统消息、历史记忆、当前用户消息按顺序拼成List<ChatMessage>。如果配置了检索增强,还会在这里插入检索到的上下文。注入工具定义。扫描接口里所有@Tool方法,把它们的名称、描述、参数 Schema 转成模型能理解的工具描述格式,附加到请求里。发起模型调用。调用model.generate(messages, toolSpecifications)。处理响应。如果模型返回的是普通文本,直接返回。如果返回的是工具调用请求,进入工具执行循环。工具执行循环。对每个工具调用,找到对应的接口方法,用反射调用它,拿到结果,包装成ToolExecutionResultMessage追加到消息列表,然后回到第 5 步再次请求模型。这个循环会一直持续到模型不再请求工具、直接给出文本答案为止。结果转换。把模型返回的文本转换成接口方法声明的返回类型。如果返回类型是String就直接给,如果是 POJO 就尝试反序列化,如果是Result<T>就包装上 token 用量等元信息。3.3 参数解析的细节与坑参数解析这块看着简单,实际坑不少。我举几个真实遇到的场景。第一个坑是参数名丢失。Java 编译后默认不保留方法参数名,所以@P("city")这种显式命名是必须的。如果你偷懒不写,框架可能拿到的是arg0、arg1,模型根本不知道这是什么。我建议所有工具方法的参数都显式标注名称和描述,描述越清楚,模型选对工具、传对参数的概率越高。第二个坑是复杂参数类型。工具方法的参数如果是自定义对象,框架需要把它转成 JSON Schema 描述给模型。这时候对象里的字段名、类型、是否必填都会影响模型的理解。我一般会给这类对象加详细的字段注释,或者干脆拆成多个简单参数,降低模型的理解负担。第三个坑是可选参数。有些工具参数是可选的,但模型不一定知道。你需要在描述里明确写"可选,默认值是 X",否则模型可能每次都硬编一个值,或者干脆不传导致调用失败。3.4 记忆与上下文的注入时机记忆管理是 Agent 能不能多轮对话的关键。LangChain4j 里通过ChatMemory接口抽象,常见实现是滑动窗口式的消息列表。代理层在组装消息时,会把记忆里的历史消息插在系统消息之后、当前用户消息之前。这里有个容易忽略的细节:工具调用的中间消息要不要进记忆。一次带工具调用的对话,消息序列里会有AiMessage(含工具调用请求)和ToolExecutionResultMessage。如果这些也存进记忆,下一轮对话时模型会看到完整的工具调用历史,有助于理解上下文,但会快速消耗 token。我的做法是只保留最终的UserMessage和AiMessage,中间的调用细节丢弃,除非业务确实需要追溯。注意:记忆的窗口大小要结合模型上下文长度和业务复杂度调。窗口太小会丢上下文,太大会爆 token 且拖慢响应。我一般从 10 轮开始试,根据实际效果调整。4. 大模型函数回调的底层机制4.1 函数调用是怎么被模型"理解"的很多人以为模型真的"调用"了你的函数,其实不是。模型做的事情只有一件:生成一段结构化的文本,表示它想调用某个函数、传什么参数。真正执行函数的是你的代码。具体来说,你在请求里附带了工具描述(函数名、用途、参数 Schema)。模型在生成回复时,如果判断需要调用工具,就会输出一个特殊格式的响应,里面包含函数名和参数 JSON。这个响应被框架解析后,才触发实际的 Java 方法调用。理解这一点很重要,因为它解释了为什么工具描述的质量直接决定 Agent 的可靠性。模型看不到你的代码实现,它只能根据你给的文字描述来判断"这个工具是干什么的、什么时候该用、参数怎么填"。描述写得含糊,模型就会选错工具或传错参数。4.2 工具描述的 Schema 生成框架怎么把 Java 方法转成模型能理解的工具描述?核心是反射加 JSON Schema 生成。对于方法String getWeather(@P("城市名") String city),框架会生成类似这样的描述:{ "name": "getWeather", "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名" } }, "required": ["city"] } }这个 Schema 会随请求一起发给模型。不同模型服务商对工具描述的格式要求略有差异,但核心字段都是名称、描述、参数定义。框架的模型适配层负责把统一格式转成各家要求的格式。4.3 工具执行循环的终止条件工具执行循环什么时候停?答案是:当模型返回的响应里不再包含工具调用请求时。也就是说,模型自己决定"我不需要再调工具了,可以直接回答用户"。这个设计有个隐患:如果模型陷入"反复调用同一个工具"的循环怎么办?比如它一直觉得信息不够,反复查同一个接口。实际中确实会遇到。防护手段有几个:一是设置最大迭代次数,超过就强制返回;二是在工具描述里写清楚"这个工具返回的信息已经足够,不要重复调用";三是在系统消息里强调"如果已有足够信息,直接回答"。我踩过一次坑:一个查询订单状态的工具,模型因为返回结果里有个字段它不理解,反复调用想搞清楚,结果循环了七八次。后来我在工具返回值里加了明确的字段说明,问题就解决了。所以工具返回值的可读性和工具描述一样重要。4.4 多工具并行的处理有些模型支持一次返回多个工具调用请求,也就是并行调用。框架需要处理这种情况:依次或并发执行多个工具,把所有结果都追加到消息列表,再统一发起下一轮请求。这里要注意工具之间的依赖关系。如果工具 B 的参数依赖工具 A 的结果,那它们就不能并行。但模型不一定知道这个依赖,它可能一次性把两个都请求了。我的经验是,有依赖关系的工具,要么合并成一个工具,要么在描述里明确写"调用本工具前必须先调用 X 工具获取 Y 参数"。后者依赖模型的遵循能力,不是百分百可靠,所以能合并就合并。5. 完整实操:从零搭一个声明式 Agent5.1 环境准备与依赖引入先明确技术栈。我用的是 Maven 项目,JDK 17,LangChain4j 的核心包加一个模型适配包。依赖大致是:<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>版本号要统一,混用不同版本容易出现 API 不兼容。我建议用 BOM 管理版本,避免手动对齐的麻烦。5.2 定义模型实例模型实例是 Agent 的动力源。以 OpenAI 兼容接口为例:ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("你的服务地址") .apiKey("你的密钥") .modelName("你的模型名") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build();几个参数的经验值:temperature做工具调用时建议调低到 0.2 到 0.5,太高会让模型"发挥创意",选错工具;timeout要设够,带工具调用的多轮请求总耗时可能很长,我一般设 60 秒起步;如果服务商支持,开启流式响应能显著改善用户体验。5.3 定义工具接口工具接口是整个 Agent 的能力边界。我以一个"个人助理"为例:interface PersonalAssistant { @SystemMessage("你是一个个人助理,可以帮用户查天气、记待办、算数。回答简洁友好。") String chat(@MemoryId String sessionId, @UserMessage String message); @Tool("查询指定城市的当前天气") String getWeather(@P("城市名称,如北京") String city); @Tool("添加一条待办事项") String addTodo(@P("待办内容") String content, @P("优先级,可选值:高/中/低") String priority); @Tool("计算数学表达式") double calculate(@P("数学表达式,如 1+2*3") String expression); }注意chat方法上的@MemoryId,它让不同会话拥有独立的记忆。@UserMessage标注哪个参数是用户输入。工具方法上的@Tool描述要写得像给同事交代任务一样清楚。5.4 组装并创建 AgentChatMemory memory = MessageWindowChatMemory.withMaxMessages(20); PersonalAssistant assistant = AiServices.builder(PersonalAssistant.class) .chatLanguageModel(model) .chatMemoryProvider(sessionId -> MessageWindowChatMemory.builder() .id(sessionId) .maxMessages(20) .build()) .tools(new TodoTools()) // 如果工具有独立实现类 .build();这里chatMemoryProvider按会话 ID 提供独立记忆,是多用户场景的标准做法。如果工具方法直接定义在接口里,框架会通过代理调用;如果工具有独立实现类,用.tools()注册。5.5 运行与观察String reply = assistant.chat("user-001", "北京今天天气怎么样?"); System.out.println(reply);运行后,框架内部会经历:组装消息、注入工具、请求模型、模型返回"调用 getWeather(city=北京)"、执行方法、回填结果、再次请求、模型生成最终回答。整个过程对调用方完全透明。我强烈建议第一次跑的时候打开 DEBUG 日志,把每一轮的消息列表打出来看。你会直观看到消息是怎么一步步累积的,工具调用请求长什么样,结果怎么回填。这个观察过程比读十遍文档都管用。6. 常见问题与排查技巧实录6.1 模型不调用工具怎么办这是最高频的问题。模型明明该调工具,却直接编了个答案。排查顺序:先看工具描述是否清楚。描述太笼统,模型不知道什么时候该用。把"查询天气"改成"查询指定城市的当前实时天气,当用户询问天气时使用",命中率会明显提升。再看系统消息有没有引导。在系统消息里加一句"需要实时信息时,优先使用提供的工具,不要凭记忆回答",能显著改善。最后看模型能力。有些小模型对函数调用的支持很弱,换一个函数调用能力强的模型往往立竿见影。6.2 参数传错或漏传模型把参数名搞错、类型搞错、或者该传的没传。对策是在参数描述里给例子。@P("城市名称,如北京、上海")比@P("城市")好得多。对于枚举型参数,把所有可选值列出来。对于必填参数,确保 Schema 里标记为 required。6.3 工具执行抛异常工具方法内部报错,整个 Agent 调用就崩了。我的做法是在工具方法里做防御性编程,把异常转成友好的错误信息返回,而不是抛出去。比如查询失败返回"查询服务暂时不可用,请稍后重试",模型拿到这个信息后可以决定是重试还是告知用户,而不是整个链路中断。6.4 排查速查表现象可能原因排查方向模型不调工具描述不清/模型能力弱优化描述、换模型参数错误描述缺例子/类型模糊补充示例、明确类型循环调用返回值不清晰优化返回值可读性响应超时工具慢/轮次多加超时、限制迭代次数记忆混乱会话 ID 未隔离检查 MemoryId 配置代理报错接口非 interface改为接口定义6.5 几个独家避坑心得第一,工具粒度要适中。太细会导致模型要调很多次,太粗会导致参数复杂模型填不对。我一般按"一个完整的业务动作"来切分。第二,给工具起名要有语义。getWeather比gw好,searchOrderByUser比query1好。模型靠名字和描述双重判断,名字本身就是信息。第三,日志一定要打全。把每轮请求的消息列表、模型原始响应、工具调用参数和结果都记下来。出问题时这些日志是唯一的线索。我吃过没打日志的亏,线上出问题只能靠猜。第四,给 Agent 加兜底。模型可能返回空、返回乱码、或者工具全失败。在业务层包一层,异常时给用户一个体面的回复,而不是把栈信息暴露出去。7. 这套架构的边界与扩展方向声明式 Agent 不是银弹。它的强项是工具编排清晰、代码量少、易维护,弱项是复杂控制流表达困难。如果你的业务需要"根据工具 A 的结果决定走分支 B 还是 C,分支里再嵌套工具调用",纯声明式会很别扭。这种场景我一般混合使用:声明式处理标准工具调用,复杂编排用命令式代码显式控制。扩展方向上,我比较看好几个点。一是工具的动态注册,运行期根据配置决定暴露哪些工具,适合多租户场景。二是工具调用的可观测性,把每次调用的耗时、成功率、参数分布采集起来,用于优化工具描述。三是多 Agent 协作,让一个 Agent 把子任务委托给另一个 Agent,这在 LangChain4j 里可以通过把 Agent 包装成工具来实现。我自己在实际项目里体会最深的一点是:声明式 Agent 的上限取决于工具描述的质量,而不是框架本身。同样的框架,工具描述写得好的团队,Agent 可靠性可能是写得差的团队的几倍。所以与其纠结框架选型,不如把精力花在打磨工具描述和返回值上,这个投入产出比高得多。
1. 为什么值得深挖 LangChain4j 的声明式 Agent第一次看到 LangChain4j 的@Agent注解和AiServices那套写法时,我的反应是"这也太魔法了"。你只写一个接口,方法上标几个注解,框架就能自动帮你把大模型的函数调用、参数解析、结果回填全部串起来。表面上看是几行声明式代码,底下藏着的却是一整套动态代理加反射回调的机制。这套东西解决的核心问题是:让 Java 开发者用最贴近原生语言习惯的方式,把大模型能力接进业务代码。传统做法里,你得手动拼 Prompt、手动解析模型返回的 JSON、手动判断该调哪个方法、手动把结果塞回去再发起第二轮请求。一个完整的工具调用链路写下来,光胶水代码就上百行,而且每接一个新工具就要复制一遍。声明式 Agent 把这套流程抽象成了接口定义,你只描述"我要什么",框架负责"怎么做到"。适合读这篇的人有三类:一是已经在用 LangChain4j 但只会照着文档抄、想搞懂底层到底发生了什么的开发者;二是准备自研 Agent 框架、想借鉴动态代理加回调这套设计思路的架构师;三是被各种"AI 框架"概念绕晕、想找一个具体实现来打通认知的技术负责人。我会从架构设计讲到动态代理的实现细节,再讲到函数回调的完整链路,中间穿插大量实操代码和我自己踩过的坑。需要提前说明的是,下面涉及的具体实现细节,一部分来自我对框架源码的阅读,一部分是基于常见动态代理和反射实践的合理推断。框架版本迭代很快,不同版本在细节上可能有出入,但核心思路是稳定的。2. 声明式 Agent 的整体架构设计思路2.1 从命令式到声明式的范式转变要理解 LangChain4j 为什么这么设计,得先看命令式写法有多痛苦。假设你要做一个能查天气、能算数学、能查数据库的助手,命令式代码大概长这样:先定义三个工具的 JSON Schema 描述,塞进请求体;拿到模型返回后判断finish_reason是不是tool_calls;如果是,解析出函数名和参数;写一个巨大的 switch-case 分发到对应方法;执行完把结果包装成tool角色的消息追加到对话历史;再发起第二轮请求。这套流程每加一个工具就要改 switch,参数类型变了还要改解析逻辑。声明式把这一切反转了。你定义一个接口:interface Assistant { @SystemMessage("你是一个乐于助人的助手") String chat(String userMessage); @Tool("查询指定城市的天气") String getWeather(@P("城市名") String city); }然后AiServices.create(Assistant.class, model)一行拿到代理对象,直接assistant.chat("北京天气怎么样"),框架自动完成"模型决定调 getWeather、传参北京、拿到结果、再让模型总结"的全过程。这个反转的本质是:把"怎么做"的编排逻辑从业务代码里抽走,交给框架的运行时。2.2 三层职责的清晰切分我读下来觉得这套架构最漂亮的地方是职责切分。它大致分成三层:第一层是接口声明层,也就是你写的那个 interface。这一层只表达意图:有哪些能力、每个能力的语义是什么、参数叫什么。它不关心模型是谁、不关心怎么调用。第二层是代理编排层,由AiServices生成的动态代理承担。它拦截接口方法调用,负责组装 Prompt、注入工具描述、发起模型请求、解析响应、判断是否需要工具调用、执行工具、回填结果、再次请求,直到拿到最终答案。第三层是模型适配层,由ChatLanguageModel及其各种实现承担。它屏蔽了不同模型服务商的 API 差异,统一成"给一组消息、返回一条消息"的接口。这种切分带来的好处是每一层都能独立替换。换模型只动第三层,加工具只动第一层,编排逻辑的调整集中在第二层。对比那些把三者揉在一起的框架,维护成本差距是数量级的。2.3 为什么选动态代理而不是代码生成这里有个关键选型问题:实现声明式接口,业界常见两条路——运行期动态代理,或者编译期代码生成(类似注解处理器生成实现类)。LangChain4j 选了前者。动态代理的优势是零构建侵入。你不需要在 pom 里加注解处理器,不需要处理生成代码的目录,不需要担心 IDE 索引不到生成的类。AiServices.create一调用,代理对象当场就有了。对于快速迭代的 AI 应用场景,这个体验很重要,因为 Prompt 和工具定义经常改,编译期生成意味着每次改都要重新构建。代价是运行期开销和调试难度。动态代理的方法调用比直接调用慢,反射解析注解也有成本。但实测下来,相对于一次大模型请求动辄几百毫秒到几秒的延迟,代理层的开销完全可以忽略。调试难度确实存在,栈里全是$Proxy和InvocationHandler,不过框架通过良好的日志弥补了这一点。提示:如果你的场景对启动速度极度敏感,或者需要 GraalVM 原生镜像,动态代理会带来额外配置成本。这种情况下可以考虑自己写实现类,但会失去声明式的便利。3. 动态代理层的核心实现细节3.1 AiServices.create 背后发生了什么AiServices.create(Assistant.class, model)这一行是整个魔法的入口。它内部做的事情,我拆成几个关键步骤:首先,它收集接口上的所有元信息。包括接口级别的@SystemMessage、每个方法的@UserMessage、方法参数上的@V、@MemoryId、@Tool注解等。这些信息被解析成一个内部的上下文对象,我把它叫做"接口契约"。然后,它构建一个InvocationHandler。这个 handler 持有接口契约、模型实例、以及可选的记忆存储、检索器、工具执行器等组件。所有对代理对象的方法调用都会进到这个 handler 的invoke方法。最后,用Proxy.newProxyInstance生成代理对象返回。注意这里用的是 JDK 动态代理,所以你的接口必须是 interface,不能是抽象类。这也是为什么 LangChain4j 的 Agent 都定义成接口。3.2 invoke 方法的完整处理链路invoke方法是整个代理层的心脏。它大致按这个顺序处理一次调用:识别方法类型。如果是Object的toString、hashCode、equals,直接走默认实现,不触发模型调用。这个判断很关键,否则日志打印、集合操作都会意外触发大模型请求,烧钱又慢。解析方法参数。根据参数上的注解,把实参映射到 Prompt 模板里的占位符。比如@V("city")标注的参数会替换模板里的{{city}}。没有注解的参数按位置或名称匹配。组装消息列表。把系统消息、历史记忆、当前用户消息按顺序拼成List<ChatMessage>。如果配置了检索增强,还会在这里插入检索到的上下文。注入工具定义。扫描接口里所有@Tool方法,把它们的名称、描述、参数 Schema 转成模型能理解的工具描述格式,附加到请求里。发起模型调用。调用model.generate(messages, toolSpecifications)。处理响应。如果模型返回的是普通文本,直接返回。如果返回的是工具调用请求,进入工具执行循环。工具执行循环。对每个工具调用,找到对应的接口方法,用反射调用它,拿到结果,包装成ToolExecutionResultMessage追加到消息列表,然后回到第 5 步再次请求模型。这个循环会一直持续到模型不再请求工具、直接给出文本答案为止。结果转换。把模型返回的文本转换成接口方法声明的返回类型。如果返回类型是String就直接给,如果是 POJO 就尝试反序列化,如果是Result<T>就包装上 token 用量等元信息。3.3 参数解析的细节与坑参数解析这块看着简单,实际坑不少。我举几个真实遇到的场景。第一个坑是参数名丢失。Java 编译后默认不保留方法参数名,所以@P("city")这种显式命名是必须的。如果你偷懒不写,框架可能拿到的是arg0、arg1,模型根本不知道这是什么。我建议所有工具方法的参数都显式标注名称和描述,描述越清楚,模型选对工具、传对参数的概率越高。第二个坑是复杂参数类型。工具方法的参数如果是自定义对象,框架需要把它转成 JSON Schema 描述给模型。这时候对象里的字段名、类型、是否必填都会影响模型的理解。我一般会给这类对象加详细的字段注释,或者干脆拆成多个简单参数,降低模型的理解负担。第三个坑是可选参数。有些工具参数是可选的,但模型不一定知道。你需要在描述里明确写"可选,默认值是 X",否则模型可能每次都硬编一个值,或者干脆不传导致调用失败。3.4 记忆与上下文的注入时机记忆管理是 Agent 能不能多轮对话的关键。LangChain4j 里通过ChatMemory接口抽象,常见实现是滑动窗口式的消息列表。代理层在组装消息时,会把记忆里的历史消息插在系统消息之后、当前用户消息之前。这里有个容易忽略的细节:工具调用的中间消息要不要进记忆。一次带工具调用的对话,消息序列里会有AiMessage(含工具调用请求)和ToolExecutionResultMessage。如果这些也存进记忆,下一轮对话时模型会看到完整的工具调用历史,有助于理解上下文,但会快速消耗 token。我的做法是只保留最终的UserMessage和AiMessage,中间的调用细节丢弃,除非业务确实需要追溯。注意:记忆的窗口大小要结合模型上下文长度和业务复杂度调。窗口太小会丢上下文,太大会爆 token 且拖慢响应。我一般从 10 轮开始试,根据实际效果调整。4. 大模型函数回调的底层机制4.1 函数调用是怎么被模型"理解"的很多人以为模型真的"调用"了你的函数,其实不是。模型做的事情只有一件:生成一段结构化的文本,表示它想调用某个函数、传什么参数。真正执行函数的是你的代码。具体来说,你在请求里附带了工具描述(函数名、用途、参数 Schema)。模型在生成回复时,如果判断需要调用工具,就会输出一个特殊格式的响应,里面包含函数名和参数 JSON。这个响应被框架解析后,才触发实际的 Java 方法调用。理解这一点很重要,因为它解释了为什么工具描述的质量直接决定 Agent 的可靠性。模型看不到你的代码实现,它只能根据你给的文字描述来判断"这个工具是干什么的、什么时候该用、参数怎么填"。描述写得含糊,模型就会选错工具或传错参数。4.2 工具描述的 Schema 生成框架怎么把 Java 方法转成模型能理解的工具描述?核心是反射加 JSON Schema 生成。对于方法String getWeather(@P("城市名") String city),框架会生成类似这样的描述:{ "name": "getWeather", "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名" } }, "required": ["city"] } }这个 Schema 会随请求一起发给模型。不同模型服务商对工具描述的格式要求略有差异,但核心字段都是名称、描述、参数定义。框架的模型适配层负责把统一格式转成各家要求的格式。4.3 工具执行循环的终止条件工具执行循环什么时候停?答案是:当模型返回的响应里不再包含工具调用请求时。也就是说,模型自己决定"我不需要再调工具了,可以直接回答用户"。这个设计有个隐患:如果模型陷入"反复调用同一个工具"的循环怎么办?比如它一直觉得信息不够,反复查同一个接口。实际中确实会遇到。防护手段有几个:一是设置最大迭代次数,超过就强制返回;二是在工具描述里写清楚"这个工具返回的信息已经足够,不要重复调用";三是在系统消息里强调"如果已有足够信息,直接回答"。我踩过一次坑:一个查询订单状态的工具,模型因为返回结果里有个字段它不理解,反复调用想搞清楚,结果循环了七八次。后来我在工具返回值里加了明确的字段说明,问题就解决了。所以工具返回值的可读性和工具描述一样重要。4.4 多工具并行的处理有些模型支持一次返回多个工具调用请求,也就是并行调用。框架需要处理这种情况:依次或并发执行多个工具,把所有结果都追加到消息列表,再统一发起下一轮请求。这里要注意工具之间的依赖关系。如果工具 B 的参数依赖工具 A 的结果,那它们就不能并行。但模型不一定知道这个依赖,它可能一次性把两个都请求了。我的经验是,有依赖关系的工具,要么合并成一个工具,要么在描述里明确写"调用本工具前必须先调用 X 工具获取 Y 参数"。后者依赖模型的遵循能力,不是百分百可靠,所以能合并就合并。5. 完整实操:从零搭一个声明式 Agent5.1 环境准备与依赖引入先明确技术栈。我用的是 Maven 项目,JDK 17,LangChain4j 的核心包加一个模型适配包。依赖大致是:<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>版本号要统一,混用不同版本容易出现 API 不兼容。我建议用 BOM 管理版本,避免手动对齐的麻烦。5.2 定义模型实例模型实例是 Agent 的动力源。以 OpenAI 兼容接口为例:ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("你的服务地址") .apiKey("你的密钥") .modelName("你的模型名") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build();几个参数的经验值:temperature做工具调用时建议调低到 0.2 到 0.5,太高会让模型"发挥创意",选错工具;timeout要设够,带工具调用的多轮请求总耗时可能很长,我一般设 60 秒起步;如果服务商支持,开启流式响应能显著改善用户体验。5.3 定义工具接口工具接口是整个 Agent 的能力边界。我以一个"个人助理"为例:interface PersonalAssistant { @SystemMessage("你是一个个人助理,可以帮用户查天气、记待办、算数。回答简洁友好。") String chat(@MemoryId String sessionId, @UserMessage String message); @Tool("查询指定城市的当前天气") String getWeather(@P("城市名称,如北京") String city); @Tool("添加一条待办事项") String addTodo(@P("待办内容") String content, @P("优先级,可选值:高/中/低") String priority); @Tool("计算数学表达式") double calculate(@P("数学表达式,如 1+2*3") String expression); }注意chat方法上的@MemoryId,它让不同会话拥有独立的记忆。@UserMessage标注哪个参数是用户输入。工具方法上的@Tool描述要写得像给同事交代任务一样清楚。5.4 组装并创建 AgentChatMemory memory = MessageWindowChatMemory.withMaxMessages(20); PersonalAssistant assistant = AiServices.builder(PersonalAssistant.class) .chatLanguageModel(model) .chatMemoryProvider(sessionId -> MessageWindowChatMemory.builder() .id(sessionId) .maxMessages(20) .build()) .tools(new TodoTools()) // 如果工具有独立实现类 .build();这里chatMemoryProvider按会话 ID 提供独立记忆,是多用户场景的标准做法。如果工具方法直接定义在接口里,框架会通过代理调用;如果工具有独立实现类,用.tools()注册。5.5 运行与观察String reply = assistant.chat("user-001", "北京今天天气怎么样?"); System.out.println(reply);运行后,框架内部会经历:组装消息、注入工具、请求模型、模型返回"调用 getWeather(city=北京)"、执行方法、回填结果、再次请求、模型生成最终回答。整个过程对调用方完全透明。我强烈建议第一次跑的时候打开 DEBUG 日志,把每一轮的消息列表打出来看。你会直观看到消息是怎么一步步累积的,工具调用请求长什么样,结果怎么回填。这个观察过程比读十遍文档都管用。6. 常见问题与排查技巧实录6.1 模型不调用工具怎么办这是最高频的问题。模型明明该调工具,却直接编了个答案。排查顺序:先看工具描述是否清楚。描述太笼统,模型不知道什么时候该用。把"查询天气"改成"查询指定城市的当前实时天气,当用户询问天气时使用",命中率会明显提升。再看系统消息有没有引导。在系统消息里加一句"需要实时信息时,优先使用提供的工具,不要凭记忆回答",能显著改善。最后看模型能力。有些小模型对函数调用的支持很弱,换一个函数调用能力强的模型往往立竿见影。6.2 参数传错或漏传模型把参数名搞错、类型搞错、或者该传的没传。对策是在参数描述里给例子。@P("城市名称,如北京、上海")比@P("城市")好得多。对于枚举型参数,把所有可选值列出来。对于必填参数,确保 Schema 里标记为 required。6.3 工具执行抛异常工具方法内部报错,整个 Agent 调用就崩了。我的做法是在工具方法里做防御性编程,把异常转成友好的错误信息返回,而不是抛出去。比如查询失败返回"查询服务暂时不可用,请稍后重试",模型拿到这个信息后可以决定是重试还是告知用户,而不是整个链路中断。6.4 排查速查表现象可能原因排查方向模型不调工具描述不清/模型能力弱优化描述、换模型参数错误描述缺例子/类型模糊补充示例、明确类型循环调用返回值不清晰优化返回值可读性响应超时工具慢/轮次多加超时、限制迭代次数记忆混乱会话 ID 未隔离检查 MemoryId 配置代理报错接口非 interface改为接口定义6.5 几个独家避坑心得第一,工具粒度要适中。太细会导致模型要调很多次,太粗会导致参数复杂模型填不对。我一般按"一个完整的业务动作"来切分。第二,给工具起名要有语义。getWeather比gw好,searchOrderByUser比query1好。模型靠名字和描述双重判断,名字本身就是信息。第三,日志一定要打全。把每轮请求的消息列表、模型原始响应、工具调用参数和结果都记下来。出问题时这些日志是唯一的线索。我吃过没打日志的亏,线上出问题只能靠猜。第四,给 Agent 加兜底。模型可能返回空、返回乱码、或者工具全失败。在业务层包一层,异常时给用户一个体面的回复,而不是把栈信息暴露出去。7. 这套架构的边界与扩展方向声明式 Agent 不是银弹。它的强项是工具编排清晰、代码量少、易维护,弱项是复杂控制流表达困难。如果你的业务需要"根据工具 A 的结果决定走分支 B 还是 C,分支里再嵌套工具调用",纯声明式会很别扭。这种场景我一般混合使用:声明式处理标准工具调用,复杂编排用命令式代码显式控制。扩展方向上,我比较看好几个点。一是工具的动态注册,运行期根据配置决定暴露哪些工具,适合多租户场景。二是工具调用的可观测性,把每次调用的耗时、成功率、参数分布采集起来,用于优化工具描述。三是多 Agent 协作,让一个 Agent 把子任务委托给另一个 Agent,这在 LangChain4j 里可以通过把 Agent 包装成工具来实现。我自己在实际项目里体会最深的一点是:声明式 Agent 的上限取决于工具描述的质量,而不是框架本身。同样的框架,工具描述写得好的团队,Agent 可靠性可能是写得差的团队的几倍。所以与其纠结框架选型,不如把精力花在打磨工具描述和返回值上,这个投入产出比高得多。