最近把一个内部系统从裸调 OpenAI 风格接口迁移到 langchain4j 上,并且把推理底座换成了本地 vLLM 部署的大模型,顺带把原先散落在业务代码里的 API 调用统一收成 Tool 插件。整个过程踩了不少坑,也把 langchain4j 和 vLLM 这套组合的很多细节摸清了。这篇把整个对接思路、工具调用机制、实操步骤和排错经验完整记录下来,给同样需要 Java 技术栈 + 本地模型 + 工具调用的团队做一个可直接上手的参考。
这套方案解决的是三个核心问题合流:数据敏感所以模型必须私有化部署;后端是 Java 所以不想为了 LLM 引入一套 Python 微服务;业务系统里有大量已有 API,希望大模型能像人一样“查完再答”,也就是 API tools 插件调用。三件事叠在一起,langchain4j 对接本地 vLLM 模型 + 工具调用就成了非常顺理成章的选型。
1. 项目定位与背景认知
1.1 这个项目到底解决什么问题
先说场景。内部有一个知识库平台,用户会问“帮我查一下订单 xxx 的物流状态”“设备 yyy 最近一次告警是什么时候”这类问题。这些信息都存在内部系统里,靠 LLM 训练数据根本不可能知道答案,必须实时调用业务 API 去拿数据。
如果只做一个“能聊天”的机器人,直接调 vLLM 的接口就能实现。但一旦涉及工具调用,事情就复杂了。调用工具意味着大模型不能只输出文本,它要先输出一段“结构化指令”告诉系统需要调用哪个工具、传什么参数,系统再真正去执行 API,把结果返回给模型,模型再基于结果生成最终回复。这个来回协议涉及的东西不少,比如工具描述怎么定义、参数用什么 JSON Schema 表达、模型返回的 tool_call 怎么解析、执行结果怎么回填。
这些协议细节自己去抠也可以,但很浪费时间。langchain4j 的价值就是把这些协议封装成 Java 友好的 API,让开发者只关心两件事:定义工具方法、接收最终结果。中间那套多轮往返、消息组装、JSON 解析,框架全包了。
1.2 为什么选 langchain4j 而不是自己拼 HTTP 客户端
早期我们试过直接用 HttpClient 调 vLLM 的 /chat/completions 接口,代码写到一半就发现问题很多。第一,多轮对话需要自己维护 messages 列表,每轮新增 user、assistant 消息,还得把工具调用结果按协议格式插回去,消息结构不统一就存在兼容性隐患。第二,流式输出要自己解析 SSE 分帧,工具调用和普通输出混在一个流里时要识别 event 类型。第三,每个模型厂商的消息格式大同小异,想切换模型就得改业务代码。
换成 langchain4j 之后,这些成本基本都消失了。它的体系里有几个非常核心的抽象:ChatLanguageModel负责统一模型交互,ChatMemory负责管理多轮消息历史,ToolSpecification和@Tool注解负责描述工具,AiServices负责把工具自动接进对话流程。你换模型只需要换一个 builder 或改配置文件,业务侧的对话服务和工具定义完全不动。
另外我比较看重的一点是 langchain4j 不是套了个壳就完事,它内部对 OpenAI 兼容接口的适配比较完整,很多边界情况都处理过,比如模型返回多个 tool_call 的并行处理、结果回填时机、空 tool_call 兜底。这比自己写的流程稳定得多。
1.3 为什么推理底座选本地 vLLM
既然要工具调用,模型的推理质量和结构化输出稳定性很重要。vLLM 在私有化部署这个大前提下面有几个明显优势:一是高吞吐和 PagedAttention 机制,长上下文、多并发场景下显存利用比普通推理服务好很多;二是原生兼容 OpenAI API 协议,特别适合配合 langchain4j 这类通过 OpenAI 适配器对接的框架,集成成本低;三是它在工具调用这条线上持续迭代,对主流模型的支持比较及时。
这里也要说一句公道话,本地部署不是没有代价。GPU 资源是硬成本,模型效果还得自己做评测。但我们内部实践下来的结论是,对工具调用和私有数据查询这类任务,本地 7B~14B 级别的模型配合精心设计的工具描述,已经能解决绝大多数问题。相比把内部 API 暴露给云端模型,数据安全的收益远大于那点效果差距。
还要提一下,真要去对比其他本地推理方案,比如 llama.cpp、Ollama,也不差,但 vLLM 在兼容 OpenAI 协议这一块做得最彻底,很多参数比如response_format、tool_choice、parallel_tool_calls都支持到位,这恰恰是 langchain4j 底层会用到的东西,选它对接最省事。
2. 环境准备与核心架构设计
2.1 整体架构与请求链路
这套方案的完整链路是这样:JVM 应用里跑着 langchain4j 客户端,业务侧用OpenAiChatModel指向 vLLM 暴露的 OpenAI 兼容端口,vLLM 后端加载本地模型权重完成推理。一次带工具调用的完整请求会经历:
用户消息先由 langchain4j 组装成协议报文,报文里包含对话历史和工具规范;vLLM 收到后让模型内部先“思考”,如果模型认为需要调用工具,就返回带tool_calls字段的消息;langchain4j 根据工具名找到对应 Java 方法,反射调用并把参数绑定进去;工具方法执行真实 HTTP 请求调用内部 API 拿到数据;langchain4j 再把执行结果作为tool消息回传给模型;模型基于结果生成最终文本答案。
链路里最关键的一点是 langchain4j 在适配器里已经把 OpenAI 风格的 tools 协议拿捏住了,vLLM 只要在/chat/completions接口上返回符合 OpenAI 格式的响应,剩下的框架都能消化。
2.2 vLLM 服务启动与模型选型
启动 vLLM 服务本身不复杂,但工具调用相关的参数要理解透。以我实际环境为例:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name local-llm \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 1 \ --max-model-len 8192 \ --enable-auto-tool-choice \ --tool-call-parser hermes--served-model-name决定 langchain4j 配置里的 model 名称,我用local-llm统一映射,方便以后换底层模型不改业务配置。--enable-auto-tool-choice允许模型自动决定是否使用工具,--tool-call-parser要根据模型家族选择,Hermes 系列用hermes,Qwen 系列在部分新版本里可以用qwen解析器,老版本不支持的话要手动指定。
模型选型这里是很多新手容易忽略的雷区。工具调用不是随便一个模型都能干。SFT 指令微调过带 <tool_call> 格式的模型才能稳定输出结构化工具指令,比如指令微调版本的 Qwen、Llama 3.1、Hermes、Mistral 部分模型。如果选了一个纯基座模型或通用 Chat 模型且没有做工具调用训练,即使协议对了模型也大概率只会把工具描述当成普通文字读,输出一堆无效内容。
关于显存和性能,--max-model-len不建议盲目设大,工具调用场景下每轮对话里要塞系统提示、工具描述、历史消息、中间结果,上下文消耗很快,但设得太大显存又吃紧。我们 7B 模型用 8192 长度配单卡 24G 显存比较从容,14B 模型建议--max-model-len调低到 4096 或者用双卡张量并行。
2.3 Java 依赖与基础配置
Java 项目的依赖添加很简单,使用 langchain4j 的 OpenAI 适配模块就够了。Maven 坐标大致是这样:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.0.0</version> </dependency>版本号现在迭代很快,建议根据自己的 Spring Boot 版本选一个兼容的稳定版本,不必追新。
然后是创建模型客户端的标准写法:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("local-not-required") .baseUrl("http://127.0.0.1:8000/v1") .modelName("local-llm") .temperature(0.2) .logRequests(true) .logResponses(true) .timeout(Duration.ofSeconds(60)) .build();几个配置的坑提前说一下。apiKey这里随便填一个字符串就行,vLLM 默认不校验,但 langchain4j 的适配器会要求这个字段非空。baseUrl一定要带/v1后缀,否则路径拼接会 404。logRequests和logResponses调试阶段强烈建议开着,你能直接看到发出去的 payload 长什么样,排查问题会快很多。
2.4 先验证服务连通性
写 Java 代码之前,先用 curl 把 vLLM 服务验证一遍,避免把 Java 环境问题和模型服务问题混在一起排查。
curl http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local-llm", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 128 }'能正常返回 JSON 响应且里面有模型生成的文本,说明服务本身没问题。接下来再验证工具调用能力,在请求里加一个简单的 define 工具让模型调用,看响应里有没有tool_calls字段。这一步能提前暴露模型是否支持工具调用,不用等 Java 侧调试到一半再发现是模型的锅。
3. API Tools 插件调用机制拆解
3.1 一次工具调用的完整生命周期
工具调用这个概念对很多 Java 开发来说一开始有点抽象,我习惯用一个生活类比来讲。想象你有一个秘书,你问他“王总最近三天有没有空约个饭”,他不可能凭脑子记住王总的日程,他的做法是先决定“需要查一下王总的日历”,然后让一个同事去打开日历软件查数据,拿到结果后再组织语言回答你。大模型就是这个秘书,工具方法就是那个同事,langchain4j 是中间协调的人。
整个流程拆成四段。第一段,模型接收用户请求,内部生成决策,这个决策可能是不用工具直接答,也可能是“我需要调用 get_calendar 这个工具,参数是王总、最近三天”。第二段,langchain4j 收到包含tool_calls的响应,在本地根据工具名匹配@Tool方法,自动做参数绑定和类型转换。第三段,Java 方法真实执行,返回结果;langchain4j 把结果包装成 tool 消息继续发给模型。第四段,模型看到工具结果后生成最终回答,也可能看到结果发现数据不够,再发起一次新的工具调用,形成多轮循环。
这个循环不是无限进行的,业务代码里必须设置最大迭代次数,防止模型陷入“不断调用工具、不断拿结果、不断再调用”的死循环。常见的限制是单次请求最多 5~10 轮工具调用,超过就强制中断。
3.2 用 @Tool 注解定义插件
langchain4j 中最常用的工具定义方式是把带@Tool注解的方法暴露给模型。举个例子,假设要调用一个订单查询 API:
public class OrderTools { private final OrderClient orderClient; public OrderTools(OrderClient orderClient) { this.orderClient = orderClient; } @Tool("根据订单号查询订单物流状态") public String getOrderLogistics(String orderId) { LogisticsResult result = orderClient.queryLogistics(orderId); return result.toJson(); } }这里有三个关键细节。第一是工具描述@Tool("...")必须写清楚这个方法什么时候该用、查的是什么,模型选工具本质上是语义匹配,描述越精确工具选择越准。第二是参数定义,框架会利用方法签名和参数类型的反射自动生成 JSON Schema,参数名尽量用完整语义化单词,比如orderId要比id更容易让模型正确理解。第三是返回值,建议直接返回 JSON 字符串,让模型能结构化理解,不要返回一个复杂对象再依赖框架序列化。
如果想让模型知道每个参数的详细含义,可以在参数上补充@Description注解:
@Tool("根据订单号查询订单物流状态") public String getOrderLogistics( @Description("订单号,例如 DD20240001") String orderId) { ... }3.3 底层协议如何映射
LangChain4j 在把@Tool方法发送出去之前,会把它转换成 OpenAI 协议里的tools数组。每个 tool 包含 type 和 function 两个部分,function 里面的 description 来自注解,parameters 来自方法签名生成的 JSON Schema。vLLM 收到这个数组后,模型会根据用户问题、对话历史和这些工具描述,决策是否需要以tool_calls字段返回。
这个映射过程对使用者透明,但有两点要知道。第一,工具描述和参数描述的文本质量直接影响模型决策,这部分没有任何框架能替你优化,量化地说就是多写半行描述,可能少踩十个解析错误。第二,vLLM 在传递工具时对parameters的处理与 OpenAI 原版协议存在兼容差异,实测下来大多数情况没问题,但遇到模型输出required字段缺失这类问题时,优先检查 JSON Schema 生成是否符合格式要求。
3.4 AiServices 与手动调用两种实现姿势
langchain4j 提供两种使用工具的方式。第一种是低阶手动方式,最能看清协议细节,但不适合直接上生产:
ChatResponse response = model.chat(ChatRequest.builder() .messages(...) .toolSpecifications(ToolSpecifications.toolSpecificationsFrom(OrderTools.class)) .build()); if (response.aiMessage().hasToolExecutionRequests()) { List<ToolExecutionRequest> requests = response.aiMessage().toolExecutionRequests(); // 手动执行工具,构造 ToolExecutionResultMessage 回传 }第二种是高阶方式,使用AiServices自动处理整个工具执行循环。这是我最推荐生产环境使用的方案:
interface Assistant { String chat(String userMessage); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools(orderClient), new WeatherTools(weatherClient)) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer = assistant.chat("帮我查一下订单 DD20240001 的物流状态");这段代码里,AiServices通过代理机制拦截chat方法调用,自动完成模型交互、工具选择、工具执行、结果回传、最终回答生成。里面也可以加chatMemory来记住会话历史,这对多轮“问完订单再追问物流”的场景很重要。
从实战经验看,低阶方式适合排查问题和深度定制,比如你想在工具执行前做权限校验、想改变量名、想在结果回传前清洗数据,那就需要自己控制循环流程。高阶方式代码量少、维护成本低、可靠性高,优先级更高。
4. 实操:从零到一跑通本地模型工具调用
4.1 启动 vLLM 并确认工具调用能力
实操第一步是把 vLLM 服务启动起来。我用的是 Docker 方式启动,这样环境干净、可移植性好:
docker run --runtime nvidia --gpus all \ -v /data/models:/models \ -p 8000:8000 \ vllm/vllm-openai:latest \ --model /models/Qwen2.5-7B-Instruct \ --served-model-name local-llm \ --enable-auto-tool-choice \ --tool-call-parser qwen \ --max-model-len 8192启动之后重点看一眼启动日志,有没有出现"tool calling"、"tool-parser"相关字样。如果启动参数里模型解析器配置错误,vLLM 会拒绝启动或者提示 fallback 到不支持工具模式,这时候就得调整参数重来。
确认服务起来后,用一条带工具定义的请求验证:
curl http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local-llm", "messages": [{"role": "user", "content": "查一下订单 DD001 的物流"}], "tools": [{ "type": "function", "function": { "name": "get_order_logistics", "description": "根据订单号查询订单物流状态", "parameters": { "type": "object", "properties": { "orderId": {"type": "string", "description": "订单号"} }, "required": ["orderId"] } } }] }'如果响应里出现tool_calls字段,说明模型和解析器都正常;如果模型直接输出一段绕过工具的文字,说明工具调用没有生效,后面 Java 代码就不用白写了。
4.2 在 Spring Boot 工程里接入客户端
接下来说 Spring Boot 集成。实际项目里我会把模型客户端的构建放到一个配置类里,后续换模型只动配置:
@Configuration public class LlmConfig { @Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .apiKey("local-not-required") .baseUrl("http://127.0.0.1:8000/v1") .modelName("local-llm") .temperature(0.2) .maxTokens(2048) .timeout(Duration.ofSeconds(120)) .build(); } }在 Spring 容器里注册ChatLanguageModel后,业务 Service 可以直接注入这个 Bean,不再碰 HTTP 细节。AiServices的创建也在配置阶段完成,把所有工具 Bean 注入进去:
@Bean public Assistant assistant(ChatLanguageModel model, OrderTools orderTools, WeatherTools weatherTools) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(orderTools, weatherTools) .chatMemory(MessageWindowChatMemory.withMaxMessages(30)) .build(); }这样 Controller 层只需要一个Assistant接口,业务清爽得很。
4.3 把 API 服务封装成插件
工具方法最忌讳的是把一堆业务逻辑堆在一个类里。我在实践中的做法是抽象出一个工具接口,每个 API 对接一个实现类,注册在一起。比如我可以定义:
public interface ToolPlugin { String name(); String description(); String execute(String paramsJson); }但 langchain4j 的@Tool跟这个抽象不完全是同一回事,这里我建议以@Tool方法为最小单元,组织上按业务域拆分多个工具类。比如订单相关一组,设备告警一组,天气一组。不要写一个几百行的大工具类,模型在匹配工具名时容易混,而且后续扩展会越改越乱。
还有一种业务场景是我在项目里实际遇到的:一个工具方法内部要调用多个内部微服务接口。比如“查用户下所有订单并关联物流”,@Tool方法内部先调用户服务拿订单列表,再遍历订单调物流服务,最后聚合成一个 JSON 返回。这种组合逻辑放在工具实现里是允许的,但要注意耗时太长会被 vLLM 超时打断,我的经验是对内部 API 设置独立超时时间,避免一个慢接口拖垮整个对话。
4.4 多工具、长对话场景的运行与结果处理
当工具数量超过一两个之后,模型的选择准确性会成为关键瓶颈。实测下来有三件事能显著改善效果。
第一,工具名要有清晰领域前缀,比如order_get_logistics、device_get_alert,这种命名比getLogistics、getAlert更容易让模型区分。第二,描述里多用动词和名词组合,比如“查询”“创建”“更新”,模型对动词敏感度高。第三,如果工具参数有枚举取值或者字符串格式,一定在描述里写清楚,否则模型会自由发挥导致参数校验失败。
长对话场景要注意消息窗口的累积。每个工具调用会带来至少两条额外消息,一个assistant的tool_calls消息、一个tool的执行结果消息,工具一多或者追问次数一多,上下文很快膨胀。我建议MessageWindowChatMemory的窗口不要开太大,20 条左右足够维持上下文,再结合系统提示词压缩历史关键信息。
另外要注意多工具并发返回的解析。vLLM 在部分配置下可以并行返回多个工具调用,langchain4j 对这种情况是支持的,工具方法会被依次执行,所有执行结果会被一起回传模型。我在实战中遇到过一次模型同时调订单查询和天气查询的场景,两个工具各自执行成功,模型把结果合并成了一段自然语言回答,体验相当不错。
5. 常见问题与排查经验
5.1 模型答非所问但工具调不通
这个问题出现的频率最高。表现是用户提问应该触发工具调用,模型却直接给了一段自己编的答案,比如“根据我的知识,这个订单应该是已发货”。原因是模型没有可靠地生成tool_calls结构。
排查顺序建议是这样。先检查 vLLM 启动参数,确认开启了--enable-auto-tool-choice,并且--tool-call-parser跟模型匹配。再看模型本身是否支持工具,拿 curl 请求直接验证。排除模型问题后,检查 langchain4j 侧是否真的把工具描述传给了模型,打开logRequests看 payload 里tools数组是否完整。
我踩过一个很典型的坑是工具方法所在类没有被 spring 管理,或者AiServices创建时tools参数传的是类对象而不是实例对象,导致工具注册缺胳膊少腿,模型那边收到空的 tools 数组,自然调不了。看logRequests一眼就发现。
5.2 工具参数解析失败
工具方法签名是String getOrderLogistics(String orderId),但模型实际返回的可能包含非字符串字段,或者返回order_id而不是orderId,这种情况会导致参数绑定失败或值为 null。
从根子上说,模型对参数名的理解来自 field 名和描述,所以参数命名必须稳定且语义一致。如果在多语言场景下模型更习惯下划线命名,可以在@Tool方法的参数上使用别名或者在描述里点名。还有一招是简化参数类型,尽量只用字符串、数字、布尔值和简单对象,嵌套对象越多模型生成越容易出格式错误。
如果解析失败已经发生了,langchain4j 会抛异常并把错误消息返回给模型,模型会尝试重新调用工具。实测里这个自我纠错能力大部分时候能兜住,但不可依赖,最稳的还是把工具描述写得毫无歧义。
5.3 上下文长度与显存问题
工具调用场景的上下文膨胀比纯聊天快很多,常见的问题有两个方向:一是max-model-len设置合适但请求内容被截断,模型丢失关键的tools描述;二是显存溢出,服务直接 OOM 重启。
显存排查建议先看 vLLM 日志里的 GPU 内存分配情况,再用nvidia-smi确认当前占用。如果溢出,优先调低--max-model-len,其次考虑--tensor-parallel-size加卡分布式推理,再次考虑升级到带PagedAttention优化的新版本。上下文被截断则跟推理服务无关,要在 Java 侧做消息裁剪,一般做法是只保留最近若干轮,或者把工具名称精简到只传必要描述。
5.4 超时、并发与生产化注意点
本地 vLLM 服务在高并发下响应时间波动很大,模型排队时间长,Java 侧超时设置必须留余量。我实际用的timeout是 120 秒,单次模型推理通常在 3~15 秒内,工具调用多轮时总时长能到 30~60 秒,这个余量是够的。但如果你的服务域名走网关,网关超时也要同步调大。
并发这块 vLLM 本身支持并发请求,但要合理设置--max-num-seqs来限制 batch 大小,否则显存会被撑爆。Java 侧我用的是普通线程池调用,没有特别处理请求排队。真要上生产且并发较高,建议在前面加一层 API 网关做限流和排队,不要让所有请求一下子压到 GPU 上。
生产环境还有个隐蔽问题:模型返回结果偶尔会包含tool_calls和文本混半的响应,langchain4j 对这种情况的处理是尽量提取 tool_call 并忽略杂质,但极少数情况下会丢失文本。我的兜底方案是在工具执行前把原始响应记录到日志,如果用户反馈异常再把日志捞回来分析,这个习惯帮我排查了不少偶发问题。
5.5 实战中踩过的坑速查
| 现象 | 可能原因 | 检查手段 |
|---|---|---|
| 模型不调用工具 | 启动参数漏开工具模式 | curl 带 tools 请求验证 |
| 工具执行报参数错误 | 参数名或描述与模型预期不一致 | 查看工具生成的 JSON Schema |
| 多轮对话后记忆丢失 | 消息窗口太小 | 调大MessageWindowChatMemory |
| 显存溢出重启 | max-model-len 过大 | nvidia-smi 监控后调参 |
| 工具结果格式混乱 | 工具方法返回非结构化文本 | 工具方法内改为返回 JSON 字符串 |
| 请求超时 | 模型排队过长 | 调大 timeout,限制并发 |
6. 个人经验与扩展建议
这套组合跑通之后,我发现最花时间的其实不是写 Java 代码,而是把工具描述打磨到“模型一看就懂”的程度。第一次做的时候我以为工具越多越好,结果模型在十几个工具之间反复横跳,光选错工具就浪费了好几轮对话。后来我把工具列表精简到每个业务域一个工具类,每个方法描述里都写清楚适用条件和参数约束,选择准确率才稳定上来。
还有一点很想分享的是日志价值。langchain4j 的logRequests和logResponses不是锦上添花,而是排查问题的必备工具。很多你以为的框架 bug,最终都在原始 payload 里找到了答案。生产环境我不建议全量开日志,但可以针对特定用户或者特定工具做动态开关,既能定位问题又不至于日志爆炸。
如果后续要扩展,我建议把 RAG(检索增强生成)也接进来。这套 langchain4j + vLLM 的底座其实天然支持 embedding 模型和向量库,把企业内部知识文档切成 block 丢进去,用户提问时先检索相关片段,再结合工具调用一起交给模型回答,系统价值会比单纯工具调用大很多。我自己下一步的计划就是在这个方向上继续踩坑,等摸到更多细节再继续记录。