尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LangChain4j Tool Calling实战:只读调用与结构化输出解析

LangChain4j Tool Calling实战:只读调用与结构化输出解析 去年开始给团队做Java侧的Agent能力预研试过几个框架之后LangChain4j成了我保底的选择。倒不是说它什么都好而是它在“快速跑通一个Demo”和“深入追踪每次调用到底发生了什么”之间给了比较顺滑的路径。这篇记录是LangChain4j Tool实验系列的第46篇重点记录两条链路一条是只读调用也就是让模型在不动任何数据的前提下查询信息拿到结果后继续推理另一条是结构化输出即让模型的最终回答落成一个强类型对象而不是一段无法约束的自由文本。如果你已经能把LangChain4j跑起来开始做Tool Calling又对返回结果的格式不稳定感到头疼这篇应该能给你一些可直接抄作业的参考。1. 实验定位只读Tool和结构化输出到底解决什么问题1.1 这次实验要验证的三件事工具调用Tool Calling本身不是新鲜事但工程落地时你会发现真正让开发头疼的往往不是“模型会不会调工具”而是调完之后的三件事。第一模型是不是真的按我预期的方式调用了工具。比如用户问“帮我看看10086这个账号最近消费怎么样”理想情况下模型应该调用查询工具并把参数解析为正确的userId而不是把“10086这个账号”整个字符串塞进去。第二工具返回的数据量是不是可控。有的开发图省事工具内部直接把整张表返回给模型结果对话上下文瞬间被撑爆后面的回答质量直线下降。第三模型最终输出的结果能不能稳定对应到业务字段。很多时候我们要的不是一段话而是一个对象里面包含userId、订单数、总金额这些字段要能直接落库或者传给下游系统。所以这篇实验就围绕这三件事展开。只读调用解决的是“安全、可控地把数据交给模型”结构化输出解决的是“从模型手里稳定拿回结构化结果”。1.2 为什么选LangChain4j而不是Spring AI我知道很多Java开发者会纠结LangChain4j和Spring AI怎么选。我的判断是如果你已经在Spring生态里且只需要简单的聊天补全Spring AI够用但如果你要深入控制Tool Calling链路要看懂每一次请求的结构化输出LangChain4j更合适。LangChain4j对Tool这层的抽象比较薄模型请求体和响应体几乎透明。你打开logRequests(true)之后能清楚看到模型发出了什么样的tool_calls工具结果以什么角色返回给模型。这种透明性在做实验时非常关键因为它能让你把问题快速定位到“是模型理解错了”还是“工具返回错了”还是“解析层崩了”。Spring AI的封装更偏向“开箱即用”但出问题时排查链路要长一些。这篇文章的所有实验不依赖Spring容器纯Java Maven就能跑这也是LangChain4j的一个优势。1.3 实验工程的组织方式我用的是一个模拟订单系统的场景。仓库层用内存Map模拟工具类只暴露查询方法没有写方法。工程结构大致是tools/UserQueryTools.java所有只读工具方法的定义model/OrderInfo.java、model/UserInfo.java工具返回的数据对象assistant/OrderAssistant.java通过AiServices生成的接口Main.java组装模型、工具、接口并触发调用后面所有实验代码都从这个结构出发方便你复现。2. 搭实验台依赖清单、模型配置与第一个Tool2.1 依赖与模型配置Maven项目里加两个依赖就够版本我用的是1.0.0-beta1新版本API基本没变。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version1.0.0-beta1/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version1.0.0-beta1/version /dependency模型用OpenAI兼容协议的端点我实验时用的是gpt-4o-mini你自己换成可用的兼容服务即可。ChatLanguageModel model OpenAiChatModel.builder() .baseUrl(https://your-endpoint.example.com/v1) .apiKey(System.getenv(LLM_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.0) .logRequests(true) .logResponses(true) .build();这里有两个关键设置temperature(0.0)和两个日志开关。实验阶段务必打开日志否则你只能看到最终结果看不到模型内部是怎么调用工具的。temperature归零是保证结构化稳定性的基础后面我会专门说到。2.2 第一个只读Tool方法工具方法用Tool注解标注类没有任何父类或接口约束就是一个普通的Java类。public class UserQueryTools { private final UserRepository userRepository; private final OrderRepository orderRepository; public UserQueryTools(UserRepository userRepository, OrderRepository orderRepository) { this.userRepository userRepository; this.orderRepository orderRepository; } Tool(查询用户基本信息该操作为只读调用不会修改任何数据返回用户的会员等级与当前积分) public UserInfo getUserInfo(Description(用户唯一ID通常是数字字符串) String userId) { return userRepository.findById(userId); } Tool(查询某个用户的最近订单列表该操作为只读调用不会产生数据变更返回订单号、金额、状态和创建时间) public ListOrderInfo getRecentOrders( Description(用户唯一ID通常是数字字符串) String userId, Description(返回最近多少条订单默认3条范围1-10) int limit) { return orderRepository.findRecent(userId, limit); } }注意方法上的Tool描述还有参数上的Description。这两处文字看起来不起眼实际对模型行为的控制力极强。我在实验里对比过描述写得含糊时模型会把参数理解错描述里明确写了“只读调用不会修改任何数据”之后模型在不确定的情况下也更愿意调用工具来做事实核查。2.3 组装AiServicesLangChain4j的AiServices负责把接口、模型和工具绑到一块。接口方法名随意关键是返回类型。interface CustomerAssistant { String chat(String userMessage); } CustomerAssistant assistant AiServices.builder(CustomerAssistant.class) .chatLanguageModel(model) .tools(new UserQueryTools(userRepository, orderRepository)) .build(); String answer assistant.chat(用户10086这个月消费情况怎么样); System.out.println(answer);运行之后如果日志里出现了tool_calls记录说明模型确实先调用了getRecentOrders拿到结果后才最终回答。这一步跑通整个Tool链路就算搭起来了。2.4 验证调用链验证不是看最终输出而是看日志。打开logRequests(true)之后请求体里会有tools数组里面是LangChain4j根据Tool注解自动生成的JSON Schema。响应里会出现tool_calls字段里面是模型决定调用的函数名和参数。我第一次跑的时候遇到一个乌龙模型没有调用任何工具直接回答“用户10086本月消费情况需要查询后才知道”。排查发现是我把Tool描述里的“用户唯一ID”写成了“用户的唯一料ID”中间有个错别字模型理解出现了偏差。改回正常描述后调用链路立刻通了。这类问题在日志里一眼就能看出来。3. 只读调用不是“不加写”那么简单参数、描述与幂等设计3.1 只读的语义约定幂等、无副作用、可重复执行LangChain4j没有一个叫ReadOnlyTool的内置注解所以“只读”在框架层面其实是靠开发者的行为约定来保证的。那这个约定包含哪些内容我归纳为三点幂等、无副作用、可重复执行。工具被模型调用多少次、什么顺序调用我们不可控。如果方法内部有状态变化哪怕只是缓存写入重复执行可能会产生不一样的结果。这跟函数的幂等性要求是一样的。另一个点是异常处理只读查询如果抛异常模型拿到的是一条错误消息它可能据此编造一个答案。所以我在设计时给每个查询方法都做了兜底查不到就返回空对象而不是抛异常。只读语义还有一层实际好处如果你要把工具暴露给第三方系统或者在网关层做权限控制只读方法可以被放进白名单不需要审计写入逻辑。这个在工程上省很多事。3.2 参数描述是LLM的“说明书”我做过一个对照实验同一个getRecentOrders方法第一次参数描述写的是“用户ID”第二次写成“用户唯一ID通常是数字字符串比如10086”。结果第二次模型解析参数的准确率明显上升而且不再尝试把“这个用户”传进参数。原因很简单。LLM做函数调用本质上是根据函数名、参数名和描述来猜测意图。参数名加上泛泛的描述只是给了模型“猜”的依据而参数描述则是把“猜”变成了“几乎确定”。参数描述最好包含四类信息字段的业务含义、类型或格式、取值范围、示例值。比如Description(返回最近多少条订单默认3条范围1-10)模型就会在这个范围内取数而不是随便传一个50。特别提醒参数描述不要写“必须、一定”这类无信息量的词模型对这类词不敏感。要给的是具体约束比如“范围1-10”模型才能真正理解边界。3.3 返回结果给上下文减负只读工具最容易踩的坑是返回结果过大。我在一次实验里让工具直接返回用户的全量消费记录结果日志里光工具返回内容就占了几千token模型后面的回答开始“胡言乱语”因为它已经被大量数据淹没。控制返回体量有三个办法。第一个是限制查询范围就是上面说的limit参数。第二个是在工具内部做聚合把“用户本月消费总额”这种计算放在工具方法里完成返回给模型的只是一个数字而不是一堆订单明细。第三个是精简字段工具返回值里只放模型要做决策所必需的字段。实验下来最稳妥的做法是让工具返回“已经加工过的结论”而不是“原始数据”。模型擅长的是基于结论做表达和推理不擅长从几万行数据里自己做汇总硬要它做要么算错要么上下文爆炸。3.4 只读Tool与写操作混用时的保护实际业务里工具往往不只读。如果同一个工具类里既有查询方法又有写方法建议做两层保护。第一层是在方法描述里明确标注“只读”或“写入”第二层是在工具类内部做运行时检查。第一层很容易理解描述写清楚之后模型会优先选择只读工具来完成信息收集不会因为图省事而直接调用写工具。第二层我采用的方式是给写方法增加一个特殊的参数校验比如要求调用方传入一个操作者ID并在工具内校验该ID是否有权限。这样即使模型误调用了写工具也会在执行前因为权限校验失败而中止。这两个工具在实验中的表现对比其实很有意思模型在没有明确业务诉求时几乎不会去碰带“写入”描述的方法。这说明Tool描述直接影响模型的工具选择策略。4. 结构化输出的解析链路从POJO到JSON Schema再到最终对象4.1 返回类型即契约结构化输出在LangChain4j里的用法非常直观接口方法的返回类型是什么模型最终就应该输出什么。这个“什么”可以是String可以是简单对象也可以是嵌套很深的POJO。record OrderSummary( Description(用户ID) String userId, Description(用户当前会员等级) UserLevel userLevel, Description(订单总数) int totalOrders, Description(订单总金额单位元) double totalAmount, Description(最近一笔订单号没有则为空字符串) String lastOrderId ) {} enum UserLevel { BRONZE, SILVER, GOLD, PLATINUM } interface AnalysisAssistant { OrderSummary analyze(String userMessage); }当接口方法返回OrderSummary时LangChain4j会在请求模型时加入结构化输出约束要求模型在完成工具调用后把最终答案整理成JSON并填充到OrderSummary对应的字段上。模型返回JSON后LangChain4j用Jackson反序列化成Java对象。这个机制最大的价值是工具调用和结构化输出形成了一条完整流水线。模型先从只读工具里拿到数据再基于数据生成一个可被下游直接使用的对象。整条链路是强类型的而不是让下游解析自然语言。4.2 用Record设计输出结构输出结构的设计直接影响解析成功率。我建议用Java的Record而不是Class原因有三个代码简洁、天然不可变、Jackson反序列化原生支持。设计字段时要注意几点。字段名要语义清晰最好跟业务术语一致每个字段都加上Description描述这能显著提升模型填字段的准确率字段类型要简单直接能用int就用int能避免复杂嵌套就避免。我在实验中发现Description的作用在结构化输出里甚至比在工具参数里还重要因为模型生成JSON时字段描述就是它的唯一依据。还有一个细节布尔字段非常容易翻车。模型有时会在JSON里输出true这种字符串导致反序列化失败。实验后我的建议是能不用布尔就不用布尔换成枚举或者整数来表示状态成功率会高很多。4.3 枚举、嵌套与列表的实测表现我专门测了三种稍微复杂的字段类型枚举、嵌套对象、列表。枚举方面LangChain4j会把枚举值加进JSON Schema的enum字段。模型基本能按照枚举值输出但偶尔会输出小写或带空格的值比如把GOLD写成gold。解决方法是给枚举字段加描述“取值为BRONZE/SILVER/GOLD/PLATINUM”并在代码里用Jackson的READ_UNKNOWN_ENUM_VALUES_AS_NULL兜底。嵌套对象方面输出结构里如果有一个字段是另一个对象模型会在该字段位置生成一个JSON对象。实验中发现只要内层对象字段清晰解析成功率比较高。但如果内层对象本身又有复杂嵌套模型可能偷懒直接给个空对象或者省略字段。列表方面模型对列表的理解还可以但要注意列表元素不能太复杂。我有一个实验让列表元素是包含四个字段的对象结果时有字段缺失。后来把列表元素改成只含两个字段解析立刻稳定。4.4 解析失败与兜底策略结构化输出不是百分百成功。LangChain4j在解析失败时会自动把错误信息返回给模型要求模型重新生成这个重试机制会自动执行。但如果模型连续几次都生成不了合法JSON最终会抛异常。我的兜底策略分三层。第一层是让结构化输出字段尽量简单从源头降低解析失败概率。第二层是在接口方法签名上不要用太底的中间类型比如避免MapString, Object这种否则模型无法从类型信息里推断字段约束。第三层是在调用方加一个try-catch解析失败时回退到普通文本调用先保证用户体验再事后排查。实际跑下来只要温度设为0、字段描述齐全、返回结构不刻意堆复杂嵌套解析失败的概率非常低。我在连续几十次实验里只遇到两三次失败而且都是因为枚举值不匹配。5. 实验数据说话LangChain4j Tool Calling的边界和坑5.1 工具描述不具体时模型在乱猜这个坑我踩得最深。有一次实验工具方法叫getData参数是String param描述是“获取数据”。模型在调用时把用户消息里的“消费情况”直接当成了param的值传入了“消费情况”这个字符串而不是用户ID。原因不复杂模型不知道param到底该填什么只能从上下文里找一个最像的文本填入。这不是模型蠢而是你给的信息不足以让它做出正确判断。工具名和参数名对模型来说只是符号真正起决定作用的是描述。后来我把方法名改成getUserOrders参数改成userId描述写清楚模型就再也没有传错过。类似的坑也出现在工具描述里。如果你只写“查询订单”没有写返回什么字段、是否只读、按什么条件查模型可能在某些情况下选择不调用工具而是直接凭训练数据里的记忆编答案。写清楚“不修改数据”之后模型明显更愿意在不确定时调用工具。5.2 空结果与null的幻觉问题工具返回空结果时模型的幻觉问题会放大。实验里我让getRecentOrders返回一个空列表模型居然在最终回答里描述了用户最近的三笔订单金额还编得有模有样。这是因为模型在训练数据里见过太多类似的用户查询它倾向于生成一个“正常”的回答而不是承认自己没查到数据。对策是双管齐下。一是在工具实现里查不到就返回明确标记比如把totalOrders设为0、lastOrderId设为空字符串而不是返回null。二是在结构化输出的字段描述里写明“当查询不到数据时返回0或空字符串不要猜测”。这两条配合起来模型会倾向于如实反映查询结果而不是自己发挥。对null的处理同样重要。我建议所有工具返回值和结构化输出字段都避免null尽量用默认值。因为模型对null的理解不稳定它可能认为“null就是没有”也可能认为“null表示未知”两种理解会导致完全不同的回答。5.3 并行工具调用的顺序依赖LangChain4j配合OpenAI新模型时工具调用是支持并行的。也就是说用户问“用户10086的资料和最近订单”模型可能在一次请求里同时发起getUserInfo和getRecentOrders两个调用。这两个工具互相独立顺序无所谓所以结果正常。但如果你设计的工具之间存在依赖关系比如必须先拿到userId才能查订单而工具签名里模型又拿不到正确ID就很容易出问题。模型可能在第一个工具还没返回时就调用第二个然后拿一个null或空值去查。这意味着设计工具接口时尽量不要让工具之间产生运行时依赖。如果确实有依赖就把依赖逻辑放同一个工具方法内部一次调用把两个信息都拿回来。我还发现一个细节并行调用时工具结果的顺序和请求顺序不一定一致。如果你的代码逻辑依赖工具调用结果的顺序一定要按工具名或参数做匹配不要假设顺序。5.4 这套组合最适合落在什么场景做了一堆实验之后我对“只读调用 结构化输出”的组合有了比较清晰的定位。它最适合两类场景。一类是“数据查询类Agent”比如业务助手、报表助手、客服工作台。用户用自然语言提问模型通过只读工具查询业务系统最终输出一个可直接渲染到前端页面的结构化对象。这类场景天然要求工具不写数据天然要求结果稳定。另一类是“决策辅助类Agent”比如智能推荐、风险判断。模型先通过只读工具收集候选信息然后输出一个包含选项ID、评分、推荐理由的结果对象。结构化输出保证下游系统能直接消费这个结果而不需要从文本里抽取字段。反过来如果场景需要模型写数据、需要多轮自由对话、需要工具之间频繁联动这套组合就要谨慎使用。并不是所有Agent业务都适合把输出强约束成对象自由文本在某些场景下的流畅性是结构化输出替代不了的。这个系列实验做到这里我最大的体会是Tool Calling这个能力代码上接入不难难的是把工具描述、参数约束、返回体量、输出结构这些细节一点点调到位。LangChain4j给了很好的底层可见性剩下的就靠我们这些做工程的人在实际调用日志里一遍一遍抠细节。你可以先按这篇的记录把最小实验跑通然后把工具描述改成自己业务的再压几轮问题试试大概率会碰到和我不完全一样的坑。没有关系日志开起来一步一步看模型怎么调用、怎么填参数、怎么生成结果问题基本都能找到答案。
返回列表