1. 声明式 Agent 到底解决了什么问题
第一次接触 LangChain4j 的声明式 Agent,很多人会误以为它只是把接口和注解拼起来的一个语法糖。但真正在项目里落地过几个 Agent 场景之后,你会发现它解决的是一个非常具体的工程痛点:如何让大模型的函数调用能力,以类型安全、可测试、低侵入的方式嵌入到已有的业务代码里。
传统做法是手动拼 Prompt、手动解析模型返回的 JSON、手动分发到对应的 Java 方法,再手动把结果塞回对话上下文。这套流程写一次两次还行,一旦 Agent 需要调用的工具超过五个,代码就会变成一大坨 if-else 和字符串拼接,维护成本急剧上升。LangChain4j 的声明式 Agent 把这一整套流程抽象成了接口加注解的形式,你只需要定义一个接口,用@Tool标注方法,框架在运行时通过动态代理生成实现类,自动完成工具描述生成、参数映射、函数回调、结果回填这一整条链路。
这篇文章适合两类人看:一类是已经在用 LangChain4j 但只会写链式调用、还没碰过 Agent 的开发者;另一类是想搞清楚声明式 Agent 底层到底怎么跑起来的中高级工程师。我会从架构设计思路讲起,把动态代理、注解解析、函数回调这几个核心环节拆开,配上可以直接跑的代码和参数说明,最后把我踩过的坑整理成排查表。读完之后你应该能自己判断:什么场景该用声明式 Agent,什么场景老老实实手写反而更稳。
2. 声明式 Agent 的整体架构设计思路
2.1 为什么选择接口加注解而不是继承基类
LangChain4j 在设计 Agent 抽象时,没有选择让用户继承一个BaseAgent类然后重写方法,而是走接口加注解的路线。这个选择背后有很实际的考量。
继承基类的问题在于耦合。一旦你的 Agent 类继承了框架的基类,这个类就没法再继承业务侧的父类了,Java 单继承的限制在这里会非常难受。而且继承意味着你必须把框架的生命周期方法、初始化逻辑都塞进同一个类里,测试的时候很难把 Agent 逻辑和业务逻辑分开 mock。
接口加注解的方案则完全解耦。你的 Agent 接口可以放在 domain 层,不依赖任何框架代码,实现类由框架在运行时动态生成。业务代码只依赖接口,单元测试时直接 mock 接口就行,根本不需要启动框架。这种设计在六边形架构或者整洁架构的项目里特别顺手,Agent 接口天然就是一个端口(Port),适配器(Adapter)由框架在运行时提供。
2.2 动态代理在其中的角色定位
动态代理是这套架构的核心机制。当你调用AiServices.builder(MyAgent.class).build()的时候,框架并不会真的去编译一个实现类,而是通过 JDK 动态代理或者字节码生成技术,在运行时创建一个实现了MyAgent接口的代理对象。
这个代理对象拦截所有接口方法的调用,然后根据方法的注解信息决定行为。如果方法上标了@Tool,代理会把这个方法注册成一个可被大模型调用的工具;如果方法是普通的对话方法,代理会走标准的 ChatLanguageModel 调用流程。整个过程对调用方完全透明,你拿到的就是一个普通的接口实例,调方法就行。
这里有个细节值得注意:代理对象在创建时会扫描接口上所有方法,把@Tool注解的方法元信息(方法名、参数类型、参数描述、返回值类型)提取出来,生成一份工具描述清单。这份清单会在每次对话时作为 function calling 的 schema 传给大模型。所以注解里的描述写得清不清楚,直接决定了大模型能不能正确选择工具。
2.3 函数回调链路的整体走向
一次完整的声明式 Agent 调用,链路大致是这样的:
- 调用方调用 Agent 接口的某个方法
- 动态代理拦截调用,把用户输入和工具清单一起发给大模型
- 大模型返回一个或多个工具调用请求(tool call)
- 代理根据工具名找到对应的接口方法,把 JSON 参数反序列化成 Java 对象
- 通过反射调用真实实现类的方法,拿到返回值
- 把返回值序列化后作为工具执行结果回传给大模型
- 大模型基于工具结果生成最终回复
- 代理把最终回复返回给调用方
这个链路里,第 4 步和第 5 步是最容易出问题的地方。参数反序列化涉及类型转换,反射调用涉及异常处理,任何一环出错都会导致整个 Agent 调用失败。后面我会专门讲这两步的排查方法。
3. 核心细节解析与实操要点
3.1 工具方法的注解配置要点
@Tool注解看起来简单,但里面的字段配置直接影响大模型的选择准确率。我一般会关注这几个字段:
name:工具名称,不填默认用方法名。建议显式指定,用下划线命名,避免大模型对驼峰命名产生歧义。value或描述字段:工具的功能描述。这是最重要的字段,要写清楚"这个工具做什么、什么时候用、输入输出是什么"。- 参数描述:每个参数上的注解描述,要说明参数的语义和取值范围。
我见过太多人把描述写成"查询用户信息"这种模糊表述,结果大模型在多个相似工具之间反复横跳。好的描述应该像这样:"根据用户ID查询用户的详细资料,包括姓名、邮箱、注册时间。当用户询问某个具体用户的信息时使用此工具。"
3.2 参数类型映射的坑
大模型返回的工具调用参数是 JSON 格式,框架需要把它映射成 Java 方法参数。这里支持的类型有限,基础类型、String、枚举、List、Map、自定义 POJO 都能处理,但有几个坑:
第一,基本类型不能用 null。如果大模型没传某个参数,而你的方法参数是int,反序列化会直接抛异常。建议所有工具方法参数都用包装类型。
第二,自定义 POJO 需要有默认构造函数和 getter/setter,否则 Jackson 反序列化会失败。
第三,枚举类型要确保大模型返回的字符串能匹配上枚举值,大小写敏感。我一般会在枚举上加@JsonCreator做兼容处理。
3.3 返回值处理的注意事项
工具方法的返回值会被序列化成字符串回传给大模型。这里有两个原则:
一是返回值要尽量简洁。如果你返回一个包含几十个字段的完整对象,序列化后的 JSON 会占用大量 token,既浪费成本又可能超出上下文限制。我通常会在工具方法里做一层裁剪,只返回大模型需要的信息。
二是返回值要结构化。纯文本返回值大模型理解起来容易出错,建议返回 JSON 字符串或者用框架支持的格式化方式。如果返回的是复杂对象,确保序列化后的结构清晰、字段名有意义。
注意:工具方法内部不要抛受检异常,框架对异常的处理策略是把异常信息作为工具执行结果回传,但格式不一定符合大模型预期。建议在工具方法内部捕获异常,返回结构化的错误信息。
4. 实操过程与核心环节实现
4.1 环境准备与依赖引入
先确认你的项目用的是 JDK 17 以上,LangChain4j 对高版本 JDK 的兼容性更好。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>版本号根据你实际使用的模型提供商调整。如果你用的是其他模型,替换对应的适配器依赖即可。注意 langchain4j 主包和适配器包的版本要保持一致,混用版本是常见的踩坑点。
4.2 定义 Agent 接口与工具方法
先定义一个业务场景:一个订单查询助手,能根据订单号查订单详情,根据用户ID查用户的所有订单。
public interface OrderAgent { @Tool("根据订单号查询订单的详细信息,包括商品、金额、状态、下单时间") OrderDetail queryOrder( @P("订单号,格式为 ORD 开头的字符串") String orderId ); @Tool("根据用户ID查询该用户的所有订单列表") List<OrderSummary> queryUserOrders( @P("用户ID,纯数字字符串") String userId ); String chat(@UserMessage String message); }接口定义好之后,需要一个实现类来提供真实的业务逻辑:
public class OrderAgentImpl implements OrderAgent { private final OrderService orderService; public OrderAgentImpl(OrderService orderService) { this.orderService = orderService; } @Override public OrderDetail queryOrder(String orderId) { return orderService.getByOrderId(orderId); } @Override public List<OrderSummary> queryUserOrders(String userId) { return orderService.listByUserId(userId); } @Override public String chat(String message) { return null; // 由框架代理实现 } }注意chat方法返回 null 是故意的,这个方法不需要我们实现,框架代理会接管它。工具方法则必须有真实实现。
4.3 构建 Agent 实例
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("API_KEY")) .modelName("gpt-4o-mini") .temperature(0.0) .build(); OrderAgent agent = AiServices.builder(OrderAgent.class) .chatLanguageModel(model) .tools(new OrderAgentImpl(orderService)) .build();这里temperature设成 0.0 是为了让工具选择更稳定。Agent 场景下创造性不是重点,确定性才是。tools方法传入的是工具方法的实现对象,框架会扫描这个对象上所有@Tool注解的方法。
4.4 参数计算与选择过程
工具方法的参数设计有几个经验值可以参考。参数数量建议控制在 3 个以内,超过 3 个大模型出错的概率明显上升。如果确实需要传很多信息,考虑把参数封装成一个 POJO,但 POJO 的字段描述要写清楚。
参数类型优先用 String,因为大模型对字符串的处理最稳定。数字类型虽然支持,但在边界值处理上容易出问题。比如订单金额,用 String 传比用 BigDecimal 传更稳妥,在工具方法内部再做转换和校验。
工具方法的执行时间也要考虑。如果某个工具方法要跑好几秒,大模型在等待期间可能会超时。建议工具方法内部设置合理的超时,或者把耗时操作拆成异步任务。
4.5 完整调用链路演示
String reply = agent.chat("帮我查一下订单 ORD20240101001 的状态"); System.out.println(reply);执行时框架内部会经历这样的过程:先把用户消息和两个工具的描述发给模型,模型判断需要调用queryOrder,返回工具调用请求,框架反射调用OrderAgentImpl.queryOrder("ORD20240101001"),拿到订单详情,序列化后回传给模型,模型生成自然语言回复。
如果你想看中间过程,可以开启框架的日志:
LoggerFactory.getLogger("dev.langchain4j").setLevel(Level.DEBUG);日志里会打印每次请求的完整 payload,包括工具 schema 和模型的工具调用请求,排查问题时非常有用。
5. 常见问题与排查技巧实录
5.1 工具不被调用或调用错误
这是最常见的问题。排查顺序建议这样走:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 模型完全不调用工具 | 工具描述太模糊 | 检查@Tool描述是否说清了使用场景 |
| 调用了错误的工具 | 多个工具描述相似 | 对比相似工具的描述,增加区分度 |
| 参数传错 | 参数描述不清晰 | 检查参数注解描述,补充格式说明 |
| 工具调用后无回复 | 返回值序列化失败 | 查看日志中的异常堆栈 |
我遇到过一次模型在两个查询工具之间反复横跳,最后发现是两个工具的描述都写了"查询订单",没有区分"单个订单"和"订单列表"。把描述改清楚之后问题就消失了。
5.2 参数反序列化失败
典型报错是Cannot deserialize value of type ...。原因通常是模型返回的 JSON 结构和 Java 类型不匹配。比如模型返回了"123"字符串,而你的参数是int,虽然 Jackson 通常能自动转换,但某些边界情况会失败。
解决办法是在工具方法参数上尽量用 String,在方法内部做转换和校验。如果必须用复杂类型,确保 POJO 有无参构造函数,字段有 getter/setter,必要时加@JsonIgnoreProperties(ignoreUnknown = true)容忍多余字段。
5.3 反射调用异常
工具方法内部抛异常时,框架会把异常信息作为工具结果回传。但如果异常信息太长或者包含敏感内容,可能影响模型判断。建议在工具方法内部统一捕获异常,返回结构化的错误对象:
@Tool("查询订单详情") public String queryOrder(@P("订单号") String orderId) { try { OrderDetail detail = orderService.getByOrderId(orderId); return objectMapper.writeValueAsString(detail); } catch (OrderNotFoundException e) { return "{\"error\": \"订单不存在\", \"orderId\": \"" + orderId + "\"}"; } catch (Exception e) { return "{\"error\": \"查询失败\", \"reason\": \"" + e.getMessage() + "\"}"; } }这样模型能理解错误并给用户合理的回复,而不是直接崩掉。
5.4 上下文膨胀问题
Agent 调用多轮之后,对话上下文会越来越长,因为每次工具调用的请求和结果都会累积。如果工具返回的数据量大,几轮之后就可能超出模型的上下文限制。
我的做法是在工具方法里做数据裁剪,只返回必要字段。另外可以配置框架的对话记忆策略,限制保留的历史轮数。LangChain4j 提供了MessageWindowChatMemory之类的实现,设置一个合理的窗口大小。
提示:工具返回的 JSON 字段名尽量短,比如用
id而不是orderIdentifier,能省不少 token。
5.5 并发调用下的线程安全
动态代理生成的 Agent 实例本身是线程安全的,但你的工具实现类不一定。如果工具方法里用了共享的可变状态,并发调用时会出问题。建议工具实现类设计成无状态的,所有依赖通过构造函数注入,方法内部不修改任何实例字段。
6. 声明式 Agent 的适用边界与选型建议
6.1 什么场景适合用声明式 Agent
声明式 Agent 最适合工具数量在 3 到 10 个之间、工具逻辑相对独立、需要快速迭代的场景。比如客服助手、内部工具查询、简单的业务流程自动化。这类场景的特点是工具边界清晰,每个工具做一件事,大模型的选择难度低。
另一个适合的场景是已有业务代码需要快速接入大模型能力。因为声明式 Agent 对业务代码侵入小,你只需要在现有 Service 上包一层接口,加几个注解就能用,不需要重构现有逻辑。
6.2 什么场景不建议用
工具数量超过 15 个时,声明式 Agent 的工具选择准确率会明显下降。这时候需要考虑分层 Agent 或者用路由机制先做工具分类。另外,如果工具之间有复杂的依赖关系,需要按特定顺序调用,声明式 Agent 的自主决策模式反而不好控制,不如手写编排逻辑。
对延迟极度敏感的场景也要谨慎。每次工具调用都是一次额外的模型往返,端到端延迟会明显增加。如果业务要求 500ms 内响应,Agent 模式基本不适用。
6.3 与手写函数调用的对比
手写函数调用的优势是完全可控,每一步都在你的代码里,调试方便,性能可优化。劣势是代码量大,工具增减时要改多处。声明式 Agent 的优势是开发效率高,工具增减只改接口和实现类,框架自动处理描述生成和参数映射。劣势是黑盒程度高,出问题时需要看框架日志才能定位。
我的经验是:原型阶段用声明式 Agent 快速验证,验证通过后如果性能或可控性不满足要求,再考虑部分手写替换。不要一上来就手写,也不要盲目全用声明式,根据实际瓶颈做取舍。
6.4 性能优化的几个方向
如果声明式 Agent 的性能不达标,可以从这几个方向优化:减少工具数量,把相似工具合并;缩短工具描述,减少每次请求的 token 消耗;工具方法内部做缓存,避免重复查询;调整模型的 temperature 和 max tokens 参数;考虑用更小的模型做工具选择,大模型只做最终回复生成。
我在一个项目里把工具描述从平均 80 字压缩到 40 字,每次请求的 token 消耗降了将近三成,工具选择准确率反而略有提升,因为描述更聚焦了。
7. 动态代理底层机制的进一步拆解
7.1 代理对象的创建时机
AiServices.builder(...).build()调用时,框架会做几件事:扫描接口方法、解析注解、构建工具描述清单、创建动态代理。这个过程是一次性的,创建好的 Agent 实例可以复用。不要在每次请求时都 build 一个新的 Agent,那样会重复扫描和解析,浪费资源。
代理对象内部持有一个DefaultAiServices实例,所有方法调用都会委托给它。DefaultAiServices根据方法类型分发:带@Tool的方法走工具注册逻辑,其他方法走对话逻辑。
7.2 方法拦截与分发逻辑
代理的InvocationHandler收到方法调用后,会检查方法是否在工具清单里。如果是工具方法,说明这是框架内部反射调用,直接执行真实实现;如果不是,说明是外部调用,走对话流程。
这个设计有个巧妙之处:同一个接口方法,外部调用和内部反射调用走不同路径。外部调用时它是对话入口,内部反射时它是工具执行。通过调用来源区分,避免了方法语义的混淆。
7.3 工具描述清单的生成
工具描述清单是声明式 Agent 的核心数据结构。框架会为每个@Tool方法生成一个ToolSpecification,包含工具名、描述、参数 schema。参数 schema 是根据方法签名和参数注解生成的 JSON Schema,会作为 function calling 的 parameters 字段传给模型。
这份清单的质量直接决定 Agent 的表现。我建议在开发阶段把生成的 schema 打印出来看看,确认参数类型、必填项、描述都符合预期。有时候注解写错了,schema 里会体现出来,比等到运行时才发现要好。
8. 写在最后的实操心得
声明式 Agent 这套东西,刚上手时会觉得挺神奇,一个接口加几个注解就能让大模型调用你的 Java 方法。但用久了会发现,它的上限取决于你对工具描述的打磨程度。框架帮你省掉的是胶水代码,省不掉的是对业务语义的清晰表达。
我个人的习惯是,每加一个工具方法,都会先想清楚三个问题:这个方法在什么场景下该被调用?它的输入输出用一句话怎么描述?它和已有工具的区别在哪里?把这三个问题回答清楚,注解里的描述自然就写好了。
另外一个小技巧:在开发阶段把模型的 temperature 设成 0,工具选择会稳定很多。等工具描述打磨到位了,再根据需要适当调高。还有,工具方法的返回值尽量用 JSON 字符串而不是对象,这样你能完全控制序列化后的结构,避免框架默认序列化带来的意外字段。
这套架构还在快速演进,后续版本可能会加入更灵活的工具路由和更细粒度的回调控制。但核心思路不会变:用声明式的方式降低大模型函数调用的接入成本,让开发者专注于业务逻辑本身。