1. 为什么值得深挖 LangChain4j 的声明式 Agent 架构第一次看到 LangChain4j 的@Agent注解时,我的反应和大多数人一样:这不就是个语法糖吗?声明一个接口,打上注解,框架帮你生成实现类,背后无非是 JDK 动态代理那一套。但真正把源码翻了一遍、又踩了几个坑之后,我发现事情远没有这么简单。这套声明式 Agent 架构背后,藏着一条从接口代理到大模型函数回调再到工具编排的完整链路,理解它之后,你对"AI 应用到底怎么调用外部能力"这件事的认知会上一个台阶。这篇文章要聊的就是这条链路。我会从设计动机讲起,拆解动态代理在其中的角色,再深入到函数回调的底层机制,最后给出可以直接抄的实操方案和排查经验。适合两类人看:一类是已经在用 LangChain4j 但只会照着文档写AiServices的开发者,另一类是想自己造一套类似 Agent 框架、需要搞清楚底层怎么设计的中高级工程师。哪怕你之前没接触过 LangChain4j,只要写过 Java 接口和注解,也能跟下来。先说结论:声明式 Agent 的本质,是把"大模型调用 + 工具执行 + 结果回填"这个循环,用动态代理封装成了一次普通的接口方法调用。你写的是assistant.chat("帮我查下天气"),框架在背后帮你完成了提示词组装、模型请求、工具识别、函数执行、多轮循环、结果解析这一整套动作。听起来简单,但每一环都有讲究,尤其是函数回调那部分,是整个架构里最容易出问题、也最值得深挖的地方。2. 声明式 Agent 到底解决了什么问题2.1 从命令式调用到声明式接口的演进在没有声明式 Agent 之前,你要让大模型调用一个外部工具,代码大概长这样:手动构造ChatLanguageModel,手动拼UserMessage和SystemMessage,手动把工具描述塞进请求,拿到响应后判断是不是tool_calls,如果是就解析参数、反射调用方法、把结果包装成ToolExecutionResultMessage再发一轮请求,循环直到模型不再要求调用工具为止。这一套写下来少说五六十行,而且每个 Agent 都要重复一遍。声明式 Agent 把这个过程压缩成了一个接口:interface Assistant { @SystemMessage("你是一个乐于助人的助手") String chat(String userMessage); }然后AiServices.create(Assistant.class, model)一行拿到实例,直接调用。框架帮你把上面那五六十行全做了。这就是声明式编程的典型价值:你描述"要什么",框架负责"怎么做"。命令式代码里那些样板逻辑——消息组装、循环控制、工具分发——全部被下沉到框架内部。这个演进路径其实和 Spring Data JPA 很像。以前写 DAO 要手写 SQL、手动映射结果集,后来你只需要声明一个findByUsername方法签名,框架帮你生成实现。LangChain4j 的声明式 Agent 走的是同一条路:方法签名即意图,注解即配置,代理即实现。2.2 动态代理在这里扮演的角色为什么是动态代理而不是代码生成?这是个值得琢磨的设计选择。代码生成(比如注解处理器 APT)在编译期就把实现类写出来,性能好、可调试,但缺点是灵活性差——工具列表、模型配置这些往往在运行时才能确定,编译期生成搞不定动态注册。而动态代理在运行时拦截方法调用,可以随时根据上下文决定怎么处理,天然适合 Agent 这种"配置驱动、运行时组装"的场景。LangChain4j 用的是 JDK 动态代理(java.lang.reflect.Proxy),这意味着你的 Agent 必须定义成接口,不能是类。这一点很多人第一次用会踩坑:写了个抽象类打注解,结果报错说不支持。原因就在这——JDK 代理只能代理接口。框架内部通过Proxy.newProxyInstance创建代理对象,所有方法调用都会被InvocationHandler拦截,然后在 handler 里完成整个 Agent 逻辑。提示:如果你的 Agent 需要继承某个类,JDK 动态代理满足不了,得换 CGLIB 那套。但 LangChain4j 目前就是接口路线,设计上更轻量,也避免了字节码增强带来的复杂度。2.3 声明式带来的三个实际收益第一个收益是可测试性。因为 Agent 是接口,你可以轻松 mock 它,单元测试里不需要真的连大模型。第二个收益是类型安全。方法参数和返回值都是强类型的,不像拼字符串那样容易出错,框架还能根据返回类型自动做 JSON 反序列化。第三个收益是可组合性。多个 Agent 接口可以互相注入,一个 Agent 调用另一个 Agent,形成层次化的智能体结构。这三点里,我觉得类型安全是最容易被低估的。举个例子,你定义一个返回WeatherReport对象的方法,框架会自动把模型的文本输出解析成这个对象,解析失败还会重试。如果换成命令式代码,你得自己写 JSON 解析、自己处理格式错误,工作量差好几倍。3. 动态代理到函数回调的完整链路拆解3.1 代理对象的创建与 InvocationHandler 拦截AiServices.create(Assistant.class, model)这行代码背后,框架做了一件关键的事:构建一个InvocationHandler,然后用它创建代理实例。这个 handler 里持有几样东西——模型引用、工具列表、聊天记忆、系统消息模板等等。当你调用assistant.chat("...")时,请求先进入 handler 的invoke方法。invoke方法里做的事情可以概括为四步:解析方法元数据(读注解、看参数)、组装请求(把用户输入、系统消息、工具描述拼成模型能懂的格式)、发起调用并处理循环(这是核心,下面细讲)、转换返回值(把模型输出变成方法声明的返回类型)。整个过程对调用方完全透明,你感觉就像在调一个普通方法。这里有个细节值得注意:框架会缓存方法元数据的解析结果。因为反射读注解是有开销的,如果每次调用都重新解析一遍,高频场景下性能会受影响。缓存之后,同一个方法的元数据只解析一次,后续直接复用。这个优化在源码里不一定显眼,但对性能敏感的应用很关键。3.2 工具注册:注解扫描与函数规范生成Agent 能调用外部工具,靠的是@Tool注解。你在某个类的方法上打@Tool,然后把这个类的实例传给AiServices.toolSpecifications()或者通过tools()注册,框架就会扫描这些方法,把每个方法转换成一个"函数规范"(Function Specification)。这个转换过程包含几个要素:函数名(默认取方法名,可以自定义)、函数描述(取@Tool的 value,这个描述极其重要,模型靠它判断什么时候该调用这个函数)、参数 schema(根据方法参数的类型和@P注解生成 JSON Schema)。生成好的函数规范会被序列化成一段结构化文本,塞进发给模型的请求里。注意:函数描述写得好不好,直接决定模型能不能正确调用你的工具。我见过太多人写@Tool("查询")这种模糊描述,结果模型要么不调用,要么乱调用。描述里应该写清楚"这个函数做什么、什么时候用、参数是什么含义",越具体越好。3.3 大模型返回 tool_calls 后的回调机制这是整个链路里最核心的一环。当你发起一次请求,模型返回的响应可能有两种:一种是普通文本,直接返回给用户;另一种是包含tool_calls的结构化响应,表示"我需要调用某个函数,参数是这些"。框架拿到tool_calls后,会做这么几件事:首先,根据函数名找到对应的ToolSpecification,进而找到实际要执行的方法。然后,把模型给的参数(通常是 JSON)反序列化成方法参数类型。接着,通过反射调用这个方法,拿到执行结果。最后,把结果包装成ToolExecutionResultMessage,追加到消息历史里,再次发起模型请求。这个"再次发起请求"就是所谓的回调循环。模型看到工具执行结果后,可能继续要求调用别的工具,也可能给出最终答案。框架会一直循环,直到模型不再要求调用工具,或者达到最大迭代次数(防止死循环)。理解这个循环是理解 Agent 的关键——Agent 不是一次模型调用,而是一个"模型思考-工具执行-模型再思考"的往复过程。3.4 多轮工具调用的循环控制与终止条件循环控制看起来简单,实际有不少坑。第一个坑是最大迭代次数。如果不设上限,模型可能陷入"调用工具-结果不满意-再调用"的死循环,烧钱又耗时。LangChain4j 允许你配置这个上限,默认值一般够用,但复杂场景要调大。第二个坑是工具执行异常的处理。如果工具方法抛异常了怎么办?直接让整个 Agent 失败,还是把异常信息作为工具结果返回给模型,让模型自己决定下一步?后者通常更优雅——模型看到"这个工具报错了",可能会换个工具或者换个参数重试。框架一般会把异常包装成错误消息回填。第三个坑是并行工具调用。有些模型一次返回多个tool_calls,理论上可以并行执行。但并行执行涉及线程安全和结果顺序问题,实现起来要小心。LangChain4j 在这块的处理策略需要根据版本确认,早期版本可能是串行执行,后续版本才支持并行。4. 实操:从零搭一个带工具回调的声明式 Agent4.1 环境准备与依赖配置先明确依赖。核心是langchain4j主包,加上你选的具体模型集成包(比如对接某家模型服务的包),再加一个日志实现。Maven 里大概是这样:<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 不兼容。我踩过一次坑:主包和集成包版本差了一个小版本,结果AiServices的某个方法签名对不上,编译直接报错。所以建议用 BOM 统一管理版本。4.2 定义工具类与 @Tool 注解的写法工具类就是一个普通 Java 类,方法上打@Tool:public class CalculatorTools { @Tool("计算两个整数的和,当用户需要做加法运算时调用") public int add(@P("第一个加数") int a, @P("第二个加数") int b) { return a + b; } @Tool("计算两个整数的乘积,当用户需要做乘法运算时调用") public int multiply(@P("第一个乘数") int a, @P("第二个乘数") int b) { return a * b; } }注意@P注解里的描述,它会进入参数的 JSON Schema,帮助模型理解每个参数的含义。参数类型尽量用基本类型或简单对象,复杂嵌套对象虽然支持,但模型生成正确 JSON 的难度会上升。4.3 声明 Agent 接口并绑定工具interface MathAssistant { @SystemMessage("你是一个数学助手,可以调用工具完成计算") String chat(String userMessage); } MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .build(); String answer = assistant.chat("帮我算一下 23 加 45 等于多少");调用chat时,框架会把用户问题、系统消息、两个工具的函数规范一起发给模型。模型判断需要调用add,返回tool_calls,框架执行add(23, 45)得到 68,回填后再请求模型,模型给出最终答案"23 加 45 等于 68"。4.4 参数计算与工具选择的实测记录我实测了几个场景,记录一下。第一个场景:问"23 加 45",模型正确调用了add,参数 23 和 45 都对。第二个场景:问"23 加 45 再乘以 2",模型先调add得到 68,再调multiply(68, 2)得到 136,两轮工具调用,循环控制正常。第三个场景:问"今天天气怎么样",模型没有调用任何工具,直接回复"我无法获取天气信息"——因为工具列表里没有天气相关函数,模型识别到了。这里有个观察:模型的工具选择能力,和函数描述的清晰度强相关。我把add的描述改成模糊的"计算",模型在"23 加 45"时反而犹豫了,有时会去调multiply。改回明确描述后恢复正常。所以别在描述上偷懒。5. 常见问题与排查技巧实录5.1 工具不被调用或调用错误的排查思路工具不被调用,先查三件事:工具有没有正确注册(tools()传了吗)、函数描述够不够清晰(模型能不能看懂)、模型本身支不支持函数调用(有些小模型或旧模型不支持 tool_calls)。这三条排查下来,八成问题能定位。调用错误(比如该调 A 却调了 B),通常是描述重叠导致的。两个工具的功能描述如果太像,模型会混淆。解决办法是把描述写得更区分化,明确各自的适用边界。另外,参数类型不匹配也会导致调用失败,比如模型生成了字符串但你声明的是 int,框架反序列化时会报错。5.2 循环不终止与最大迭代次数配置循环不终止的典型表现是:Agent 一直调用工具,迟迟不给最终答案。原因可能是工具返回的结果模型不满意,反复重试;也可能是工具描述有歧义,模型在几个工具间来回横跳。这时候最大迭代次数就是保命符。MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .maxSequentialToolsInvocations(5) .build();设成 5 意味着最多连续调用 5 次工具,超过就强制终止。这个值要根据业务复杂度调,简单场景 3 就够,复杂编排可以到 10。设太小会导致正常的多步任务被截断,设太大则失去保护意义。5.3 工具执行异常的优雅处理工具方法抛异常时,默认行为可能让整个 Agent 失败。更好的做法是在工具方法内部捕获异常,返回一个描述错误的结果字符串,让模型看到错误后自行决策。比如:@Tool("查询订单状态") public String queryOrder(@P("订单号") String orderId) { try { return orderService.query(orderId); } catch (Exception e) { return "查询失败:" + e.getMessage() + ",请确认订单号是否正确"; } }这样模型收到"查询失败"的结果后,可能会提示用户检查订单号,而不是整个流程崩掉。5.4 常见问题速查表问题现象可能原因排查方向工具完全不被调用未注册 / 模型不支持 / 描述缺失检查 tools() 配置和模型能力调用了错误的工具描述重叠 / 参数歧义细化函数描述,区分适用场景循环不终止结果不满意 / 描述歧义设置最大迭代次数,优化描述参数反序列化失败类型不匹配 / JSON 格式错检查参数类型,简化参数结构返回结果解析失败返回类型复杂 / 格式不符用简单返回类型,或自定义解析器6. 几个只有踩过才知道的经验第一个经验:函数描述是 Agent 的灵魂,不是附属品。很多人把@Tool的描述当注释随便写,结果模型调用准确率惨不忍睹。我的做法是把描述当成给一个新人看的说明书来写——他要能只看描述就知道这个函数干什么、什么时候用、参数怎么填。第二个经验:工具粒度要适中。太细会导致模型需要调用很多次才能完成任务,太粗则模型难以正确填参数。一个工具最好对应一个明确的业务动作,参数控制在 3 个以内。第三个经验:调试时打开请求日志。LangChain4j 支持配置日志级别,把发给模型的完整请求和收到的响应打出来,你能清楚看到函数规范长什么样、模型返回的 tool_calls 是什么。这是排查问题最快的手段,没有之一。第四个经验:别迷信声明式的全自动。声明式 Agent 适合工具调用逻辑相对固定的场景。如果你的 Agent 需要复杂的条件分支、动态工具选择、多 Agent 协作,纯声明式可能不够用,得结合命令式的ChatLanguageModel手动编排。声明式和命令式不是对立的,混用才是常态。这套架构我用了大半年,从最初的"照着文档抄"到现在的"知道每一步为什么这么设计",中间踩的坑基本都写在这了。动态代理那层其实不难,难的是理解函数回调循环里模型和框架的分工——模型负责决策调什么,框架负责执行和回填,两者配合得好,Agent 就稳;配合不好,就是无尽的循环和错误调用。把函数描述写清楚、把迭代上限设合理、把异常处理做优雅,这三点做到,大部分问题都能避免。
1. 为什么值得深挖 LangChain4j 的声明式 Agent 架构第一次看到 LangChain4j 的@Agent注解时,我的反应和大多数人一样:这不就是个语法糖吗?声明一个接口,打上注解,框架帮你生成实现类,背后无非是 JDK 动态代理那一套。但真正把源码翻了一遍、又踩了几个坑之后,我发现事情远没有这么简单。这套声明式 Agent 架构背后,藏着一条从接口代理到大模型函数回调再到工具编排的完整链路,理解它之后,你对"AI 应用到底怎么调用外部能力"这件事的认知会上一个台阶。这篇文章要聊的就是这条链路。我会从设计动机讲起,拆解动态代理在其中的角色,再深入到函数回调的底层机制,最后给出可以直接抄的实操方案和排查经验。适合两类人看:一类是已经在用 LangChain4j 但只会照着文档写AiServices的开发者,另一类是想自己造一套类似 Agent 框架、需要搞清楚底层怎么设计的中高级工程师。哪怕你之前没接触过 LangChain4j,只要写过 Java 接口和注解,也能跟下来。先说结论:声明式 Agent 的本质,是把"大模型调用 + 工具执行 + 结果回填"这个循环,用动态代理封装成了一次普通的接口方法调用。你写的是assistant.chat("帮我查下天气"),框架在背后帮你完成了提示词组装、模型请求、工具识别、函数执行、多轮循环、结果解析这一整套动作。听起来简单,但每一环都有讲究,尤其是函数回调那部分,是整个架构里最容易出问题、也最值得深挖的地方。2. 声明式 Agent 到底解决了什么问题2.1 从命令式调用到声明式接口的演进在没有声明式 Agent 之前,你要让大模型调用一个外部工具,代码大概长这样:手动构造ChatLanguageModel,手动拼UserMessage和SystemMessage,手动把工具描述塞进请求,拿到响应后判断是不是tool_calls,如果是就解析参数、反射调用方法、把结果包装成ToolExecutionResultMessage再发一轮请求,循环直到模型不再要求调用工具为止。这一套写下来少说五六十行,而且每个 Agent 都要重复一遍。声明式 Agent 把这个过程压缩成了一个接口:interface Assistant { @SystemMessage("你是一个乐于助人的助手") String chat(String userMessage); }然后AiServices.create(Assistant.class, model)一行拿到实例,直接调用。框架帮你把上面那五六十行全做了。这就是声明式编程的典型价值:你描述"要什么",框架负责"怎么做"。命令式代码里那些样板逻辑——消息组装、循环控制、工具分发——全部被下沉到框架内部。这个演进路径其实和 Spring Data JPA 很像。以前写 DAO 要手写 SQL、手动映射结果集,后来你只需要声明一个findByUsername方法签名,框架帮你生成实现。LangChain4j 的声明式 Agent 走的是同一条路:方法签名即意图,注解即配置,代理即实现。2.2 动态代理在这里扮演的角色为什么是动态代理而不是代码生成?这是个值得琢磨的设计选择。代码生成(比如注解处理器 APT)在编译期就把实现类写出来,性能好、可调试,但缺点是灵活性差——工具列表、模型配置这些往往在运行时才能确定,编译期生成搞不定动态注册。而动态代理在运行时拦截方法调用,可以随时根据上下文决定怎么处理,天然适合 Agent 这种"配置驱动、运行时组装"的场景。LangChain4j 用的是 JDK 动态代理(java.lang.reflect.Proxy),这意味着你的 Agent 必须定义成接口,不能是类。这一点很多人第一次用会踩坑:写了个抽象类打注解,结果报错说不支持。原因就在这——JDK 代理只能代理接口。框架内部通过Proxy.newProxyInstance创建代理对象,所有方法调用都会被InvocationHandler拦截,然后在 handler 里完成整个 Agent 逻辑。提示:如果你的 Agent 需要继承某个类,JDK 动态代理满足不了,得换 CGLIB 那套。但 LangChain4j 目前就是接口路线,设计上更轻量,也避免了字节码增强带来的复杂度。2.3 声明式带来的三个实际收益第一个收益是可测试性。因为 Agent 是接口,你可以轻松 mock 它,单元测试里不需要真的连大模型。第二个收益是类型安全。方法参数和返回值都是强类型的,不像拼字符串那样容易出错,框架还能根据返回类型自动做 JSON 反序列化。第三个收益是可组合性。多个 Agent 接口可以互相注入,一个 Agent 调用另一个 Agent,形成层次化的智能体结构。这三点里,我觉得类型安全是最容易被低估的。举个例子,你定义一个返回WeatherReport对象的方法,框架会自动把模型的文本输出解析成这个对象,解析失败还会重试。如果换成命令式代码,你得自己写 JSON 解析、自己处理格式错误,工作量差好几倍。3. 动态代理到函数回调的完整链路拆解3.1 代理对象的创建与 InvocationHandler 拦截AiServices.create(Assistant.class, model)这行代码背后,框架做了一件关键的事:构建一个InvocationHandler,然后用它创建代理实例。这个 handler 里持有几样东西——模型引用、工具列表、聊天记忆、系统消息模板等等。当你调用assistant.chat("...")时,请求先进入 handler 的invoke方法。invoke方法里做的事情可以概括为四步:解析方法元数据(读注解、看参数)、组装请求(把用户输入、系统消息、工具描述拼成模型能懂的格式)、发起调用并处理循环(这是核心,下面细讲)、转换返回值(把模型输出变成方法声明的返回类型)。整个过程对调用方完全透明,你感觉就像在调一个普通方法。这里有个细节值得注意:框架会缓存方法元数据的解析结果。因为反射读注解是有开销的,如果每次调用都重新解析一遍,高频场景下性能会受影响。缓存之后,同一个方法的元数据只解析一次,后续直接复用。这个优化在源码里不一定显眼,但对性能敏感的应用很关键。3.2 工具注册:注解扫描与函数规范生成Agent 能调用外部工具,靠的是@Tool注解。你在某个类的方法上打@Tool,然后把这个类的实例传给AiServices.toolSpecifications()或者通过tools()注册,框架就会扫描这些方法,把每个方法转换成一个"函数规范"(Function Specification)。这个转换过程包含几个要素:函数名(默认取方法名,可以自定义)、函数描述(取@Tool的 value,这个描述极其重要,模型靠它判断什么时候该调用这个函数)、参数 schema(根据方法参数的类型和@P注解生成 JSON Schema)。生成好的函数规范会被序列化成一段结构化文本,塞进发给模型的请求里。注意:函数描述写得好不好,直接决定模型能不能正确调用你的工具。我见过太多人写@Tool("查询")这种模糊描述,结果模型要么不调用,要么乱调用。描述里应该写清楚"这个函数做什么、什么时候用、参数是什么含义",越具体越好。3.3 大模型返回 tool_calls 后的回调机制这是整个链路里最核心的一环。当你发起一次请求,模型返回的响应可能有两种:一种是普通文本,直接返回给用户;另一种是包含tool_calls的结构化响应,表示"我需要调用某个函数,参数是这些"。框架拿到tool_calls后,会做这么几件事:首先,根据函数名找到对应的ToolSpecification,进而找到实际要执行的方法。然后,把模型给的参数(通常是 JSON)反序列化成方法参数类型。接着,通过反射调用这个方法,拿到执行结果。最后,把结果包装成ToolExecutionResultMessage,追加到消息历史里,再次发起模型请求。这个"再次发起请求"就是所谓的回调循环。模型看到工具执行结果后,可能继续要求调用别的工具,也可能给出最终答案。框架会一直循环,直到模型不再要求调用工具,或者达到最大迭代次数(防止死循环)。理解这个循环是理解 Agent 的关键——Agent 不是一次模型调用,而是一个"模型思考-工具执行-模型再思考"的往复过程。3.4 多轮工具调用的循环控制与终止条件循环控制看起来简单,实际有不少坑。第一个坑是最大迭代次数。如果不设上限,模型可能陷入"调用工具-结果不满意-再调用"的死循环,烧钱又耗时。LangChain4j 允许你配置这个上限,默认值一般够用,但复杂场景要调大。第二个坑是工具执行异常的处理。如果工具方法抛异常了怎么办?直接让整个 Agent 失败,还是把异常信息作为工具结果返回给模型,让模型自己决定下一步?后者通常更优雅——模型看到"这个工具报错了",可能会换个工具或者换个参数重试。框架一般会把异常包装成错误消息回填。第三个坑是并行工具调用。有些模型一次返回多个tool_calls,理论上可以并行执行。但并行执行涉及线程安全和结果顺序问题,实现起来要小心。LangChain4j 在这块的处理策略需要根据版本确认,早期版本可能是串行执行,后续版本才支持并行。4. 实操:从零搭一个带工具回调的声明式 Agent4.1 环境准备与依赖配置先明确依赖。核心是langchain4j主包,加上你选的具体模型集成包(比如对接某家模型服务的包),再加一个日志实现。Maven 里大概是这样:<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 不兼容。我踩过一次坑:主包和集成包版本差了一个小版本,结果AiServices的某个方法签名对不上,编译直接报错。所以建议用 BOM 统一管理版本。4.2 定义工具类与 @Tool 注解的写法工具类就是一个普通 Java 类,方法上打@Tool:public class CalculatorTools { @Tool("计算两个整数的和,当用户需要做加法运算时调用") public int add(@P("第一个加数") int a, @P("第二个加数") int b) { return a + b; } @Tool("计算两个整数的乘积,当用户需要做乘法运算时调用") public int multiply(@P("第一个乘数") int a, @P("第二个乘数") int b) { return a * b; } }注意@P注解里的描述,它会进入参数的 JSON Schema,帮助模型理解每个参数的含义。参数类型尽量用基本类型或简单对象,复杂嵌套对象虽然支持,但模型生成正确 JSON 的难度会上升。4.3 声明 Agent 接口并绑定工具interface MathAssistant { @SystemMessage("你是一个数学助手,可以调用工具完成计算") String chat(String userMessage); } MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .build(); String answer = assistant.chat("帮我算一下 23 加 45 等于多少");调用chat时,框架会把用户问题、系统消息、两个工具的函数规范一起发给模型。模型判断需要调用add,返回tool_calls,框架执行add(23, 45)得到 68,回填后再请求模型,模型给出最终答案"23 加 45 等于 68"。4.4 参数计算与工具选择的实测记录我实测了几个场景,记录一下。第一个场景:问"23 加 45",模型正确调用了add,参数 23 和 45 都对。第二个场景:问"23 加 45 再乘以 2",模型先调add得到 68,再调multiply(68, 2)得到 136,两轮工具调用,循环控制正常。第三个场景:问"今天天气怎么样",模型没有调用任何工具,直接回复"我无法获取天气信息"——因为工具列表里没有天气相关函数,模型识别到了。这里有个观察:模型的工具选择能力,和函数描述的清晰度强相关。我把add的描述改成模糊的"计算",模型在"23 加 45"时反而犹豫了,有时会去调multiply。改回明确描述后恢复正常。所以别在描述上偷懒。5. 常见问题与排查技巧实录5.1 工具不被调用或调用错误的排查思路工具不被调用,先查三件事:工具有没有正确注册(tools()传了吗)、函数描述够不够清晰(模型能不能看懂)、模型本身支不支持函数调用(有些小模型或旧模型不支持 tool_calls)。这三条排查下来,八成问题能定位。调用错误(比如该调 A 却调了 B),通常是描述重叠导致的。两个工具的功能描述如果太像,模型会混淆。解决办法是把描述写得更区分化,明确各自的适用边界。另外,参数类型不匹配也会导致调用失败,比如模型生成了字符串但你声明的是 int,框架反序列化时会报错。5.2 循环不终止与最大迭代次数配置循环不终止的典型表现是:Agent 一直调用工具,迟迟不给最终答案。原因可能是工具返回的结果模型不满意,反复重试;也可能是工具描述有歧义,模型在几个工具间来回横跳。这时候最大迭代次数就是保命符。MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .maxSequentialToolsInvocations(5) .build();设成 5 意味着最多连续调用 5 次工具,超过就强制终止。这个值要根据业务复杂度调,简单场景 3 就够,复杂编排可以到 10。设太小会导致正常的多步任务被截断,设太大则失去保护意义。5.3 工具执行异常的优雅处理工具方法抛异常时,默认行为可能让整个 Agent 失败。更好的做法是在工具方法内部捕获异常,返回一个描述错误的结果字符串,让模型看到错误后自行决策。比如:@Tool("查询订单状态") public String queryOrder(@P("订单号") String orderId) { try { return orderService.query(orderId); } catch (Exception e) { return "查询失败:" + e.getMessage() + ",请确认订单号是否正确"; } }这样模型收到"查询失败"的结果后,可能会提示用户检查订单号,而不是整个流程崩掉。5.4 常见问题速查表问题现象可能原因排查方向工具完全不被调用未注册 / 模型不支持 / 描述缺失检查 tools() 配置和模型能力调用了错误的工具描述重叠 / 参数歧义细化函数描述,区分适用场景循环不终止结果不满意 / 描述歧义设置最大迭代次数,优化描述参数反序列化失败类型不匹配 / JSON 格式错检查参数类型,简化参数结构返回结果解析失败返回类型复杂 / 格式不符用简单返回类型,或自定义解析器6. 几个只有踩过才知道的经验第一个经验:函数描述是 Agent 的灵魂,不是附属品。很多人把@Tool的描述当注释随便写,结果模型调用准确率惨不忍睹。我的做法是把描述当成给一个新人看的说明书来写——他要能只看描述就知道这个函数干什么、什么时候用、参数怎么填。第二个经验:工具粒度要适中。太细会导致模型需要调用很多次才能完成任务,太粗则模型难以正确填参数。一个工具最好对应一个明确的业务动作,参数控制在 3 个以内。第三个经验:调试时打开请求日志。LangChain4j 支持配置日志级别,把发给模型的完整请求和收到的响应打出来,你能清楚看到函数规范长什么样、模型返回的 tool_calls 是什么。这是排查问题最快的手段,没有之一。第四个经验:别迷信声明式的全自动。声明式 Agent 适合工具调用逻辑相对固定的场景。如果你的 Agent 需要复杂的条件分支、动态工具选择、多 Agent 协作,纯声明式可能不够用,得结合命令式的ChatLanguageModel手动编排。声明式和命令式不是对立的,混用才是常态。这套架构我用了大半年,从最初的"照着文档抄"到现在的"知道每一步为什么这么设计",中间踩的坑基本都写在这了。动态代理那层其实不难,难的是理解函数回调循环里模型和框架的分工——模型负责决策调什么,框架负责执行和回填,两者配合得好,Agent 就稳;配合不好,就是无尽的循环和错误调用。把函数描述写清楚、把迭代上限设合理、把异常处理做优雅,这三点做到,大部分问题都能避免。