先说下我为什么折腾这个事。团队里有个 Java 后端项目需要接入大模型做智能对话和自动化操作但业务数据全部要求留在内网云端 API 这条路直接堵死。我们就在内网 GPU 机器上部署了本地推理服务模型推理用的是 VLLM因为它在吞吐和显存管理上确实比同类方案强出一截Java 侧我选了 langchain4j不需要额外起一套 Python 服务直接嵌进现有工程。整个链路最核心的部分是把 VLLM 当成 OpenAI 兼容的服务接入 langchain4j并且让大模型在对话过程中自主决定调用我们预先注册的 API 工具。这套流程跑通之后相当于把会说话的大模型变成了能干活的后端助手可扩展性比纯对话高太多了。如果你正好也在做类似的事——Java 应用接本地大模型、需要 function calling / tools 插件调用、数据不想出内网——那这篇文章应该能帮你少走不少弯路。我会把部署参数、代码细节、工具调用机制以及我在实测中遇到过的几个真实问题全部写清楚不是网上那种只给结论不解释原因的文章。1. 为什么是 VLLM langchain4j而不是其他组合先聊选型。这个组合不是随手抓的我在动手之前确实对比过几套方案。1.1 VLLM 在本地推理里的优势本地跑大模型推理服务的选择无非 VLLM、TGI、llama.cpp 这几个主流方案。VLLM 用的是 PagedAttention 显存管理机制能把显存碎片利用起来同等显卡下能支撑的并发请求明显更多。实际测试中我们用一张 24GB 显存的卡跑 7B 量级的开源对话模型VLLM 的吞吐大概是 llama.cpp 的几倍而且它对 OpenAI 兼容协议的实现非常完整/v1/chat/completions 这个接口直接就有意味着客户端可以像调云端 API 一样调它。这一点对接下来的 langchain4j 接入非常关键。因为 langchain4j 内置的 OpenAi 模块就是按 OpenAI 协议写的VLLM 把协议兼容性做足了两边就能无缝对上不需要自己写一层适配。1.2 langchain4j 在 Java 生态里的位置项目是纯 Java 技术栈如果为了接大模型单独引一套 Python 服务运维成本和数据链路都会变复杂。langchain4j 的定位就是给 Java 开发者用的 LLM 集成框架它对接 OpenAI 兼容服务只需要几个依赖加几行配置内部还封装好了聊天会话、消息历史、工具调用这些逻辑。对比过 Spring AI 和直接 HTTP 调用最终选 langchain4j 的核心原因有两个AiServices 这套抽象对工具调用支持得很完整我用Tool注解写一个 Java 方法框架自动把方法名、参数、描述序列化成模型能读懂的 schema不需要手工拼 JSON。它支持模型响应直接映射到 Java 对象对后端开发来说心智成本低。如果你只是做一次性的接口测试那直接 HTTP 请求也行但一旦涉及多轮对话、工具循环这些工程化问题还是框架省心。1.3 和其他方案的取舍对照方案优点缺点适用场景Python LangChain生态最大、社区资料多需要跨语言维护Java 服务要额外起 Python 进程无 Java 约束的全 Python 项目Spring AI和 Spring Boot 集成自然工具调用等高级能力早期支持不够细只用简单对话的场景langchain4jJava 原生、工具调用封装好、协议兼容强社区比 Python 生态小一些Java 后端需要 function calling 的场景直接 HTTP 调 VLLM零依赖、完全可控多轮对话、工具循环全部要自己写内部私有协议或简单 demo我的结论是Java 业务系统接 VLLMlangchain4j 是最均衡的选择。接下来就是实际部署和接线。2. 本地 VLLM 服务部署命令、参数和最容易踩的坑在开始写 Java 代码之前得先把 VLLM 服务跑起来并且保证它对外的接口形态是OpenAI 兼容的。这一步很多人不在意到后面排查代码问题排查半天结果发现是服务启动参数配错了。2.1 硬件选型和显存估算如果是 7B 参数量的开源对话模型FP16 精度下权重大概占 14GB 显存再加上 KV Cache 和激活值一张 24GB 的卡比如常见的 4090 或 A5000基本够用并发请求数量不要调太高就行。如果是 13B 模型建议直接上 48GB 或 80GB 的卡或者用 AWQ 量化把显存占用压下来。我们当时就是一张 24GB 卡跑 7B 模型--gpu-memory-utilization设置到 0.9实测下来单卡并发 8 个请求没有明显 OOM。2.2 启动命令实战vllm serve /data/models/Qwen2.5-7B-Instruct \ --served-model-name local-model \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.9 \ --host 0.0.0.0 \ --port 8000 \ --api-key local-demo-key \ --enable-auto-tool-choice \ --tool-call-parser hermes \ --max-model-len 8192这条命令里的参数每个都不是随便加的--served-model-name local-model这是对外暴露的模型名。客户端请求里model字段必须写local-model否则 VLLM 会报模型不存在的错。我们内部统一约定这个值避免每台机器路径不一样导致的混乱。--api-key local-demo-key给服务加个静态密钥。VLLM 要求客户端在 Authorization 头里带上这个值langchain4j 配置里的apiKey会和它对应。--enable-auto-tool-choice--tool-call-parser hermes这两个参数是工具调用能否生效的前提。第一个参数让 VLLM 自动决定返回工具调用还是普通文本第二个参数告诉 VLLM 用哪个解析器来解析模型输出的工具调用格式。不同模型家族对应的解析器不一样Qwen 系列一般用hermesLlama 系列可能要用mistral。如果解析器选错模型输出的工具调用就会被 VLLM 强行往下走默认生成逻辑你收到的是一个普通文本回复而不是结构化的工具调用指令。2.3 验证服务是否就绪启动日志里看到类似Application startup complete的字样后先用 curl 打一发确认接口通不通curl http://127.0.0.1:8000/v1/chat/completions \ -H Authorization: Bearer local-demo-key \ -H Content-Type: application/json \ -d { model: local-model, messages: [{role: user, content: 你好介绍一下你自己}], max_tokens: 128 }如果返回了正常的choices数组说明 VLLM 基本可用。这里有个细节网络上很多教程在客户端填baseUrl时不带/v1服务端虽然做了兼容但为了少出问题建议统一把/v1写在客户端地址里VLLM 的--api路由默认就是/v1开头。2.4 部署时容易忽略的两个点第一--max-model-len不要贪大。序列长度越长KV Cache 占用越高并发能力越低。如果只是做对话和工具调用8192 一般够用硬拉到 32K 反而会因为显存不够导致请求排队甚至失败。第二如果你要用 docker 启动 VLLM记得加端口映射并且确认容器内外的--api-key是同一个。我见过有人 docker 启动时忘了映射 8000 端口Java 代码连到的是宿主机上另一个旧服务排查了很久才发现打错了端口。3. langchain4j 接入 VLLMOpenAI 兼容接口的配置细节服务端准备好了接下来就是 Java 侧的对接。这里我把过程拆成依赖引入、模型构建、通信验证三步每一步都有明确的执行依据。3.1 依赖引入pom.xml 里加两个核心依赖dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency这里需要解释一下为什么是langchain4j-open-ai而不是langchain4j-vllm。VLLM 对外提供的接口是 OpenAI 兼容协议langchain4j 的 OpenAi 模块就是用来对接这类服务的。换句话说在我们这个场景里客户端眼里 VLLM 就是一个私有化的 OpenAI 服务协议兼容意味着所有用 OpenAi 模块写的代码可以直接跑。如果你拿的是比较新的 langchain4j 版本部分模块包名和版本号会有调整建议以 Maven 仓库里实际能拉到的版本为准。选版本的时候尽量选稳定版不要追最新的快照版工具调用这类功能在不同版本之间行为差异挺大。3.2 构建模型连接OpenAiChatModel model OpenAiChatModel.builder() .baseUrl(http://192.168.1.100:8000/v1) .apiKey(local-demo-key) .modelName(local-model) .temperature(0.3) .timeout(Duration.ofSeconds(60)) .logRequests(true) .logResponses(true) .build();这一段有四个字段需要额外注意baseUrl必须带/v1。VLLM 对不带/v1的路径有时会返回 404这算是最常见的接入报错之一。apiKey不需要是真实云端密钥但必须和 VLLM 启动时传的--api-key保持一致。VLLM 不认识你自定义的 key 时会直接返回 401。modelName对应--served-model-name设置的名字这里写local-model。logRequests(true)和logResponses(true)是我强烈建议开启的配置。刚开始接入时你需要看清楚实际发送给 VLLM 的请求体长什么样、返回体长什么样这两个开关能省掉很多抓包的时间。生产环境记得关闭否则日志会涨得很快。先写一小段验证代码String answer model.generate(你好请简单介绍下你自己); System.out.println(answer);能正常返回内容说明 langchain4j 和 VLLM 的网络链路已经通了。3.3 一个容易误解的地方聊天历史很多人在这个阶段会问为什么同样的提问多轮对话后模型好像忘了之前的内容。其实 VLLM 本身是无状态的每次请求都是独立的对话的上下文完全靠客户端把历史消息一起发过去。langchain4j 里ChatMemory的作用就是帮你在内存里维护这份历史消息列表每次调用时自动携带。ChatMemory chatMemory MessageWindowChatMemory.builder() .maxMessages(20) .build();个人实践经验是20 条历史消息对大多数业务对话够用但如果你还会让模型记忆工具调用的中间结果建议把这个值控制在 20 到 30 之间太长会让 token 消耗快速增加而且模型容易把早先的无关信息混进当前决策里。到这里普通对话已经没问题了。但我们的目标是让模型主动调用工具所以接下来才是重头戏。4. API tools 插件调用的核心从 Tool 注解到动态 ToolSpecification工具调用function calling的完整链路是用户提问 → langchain4j 把可用工具的 JSON Schema 发给 VLLM → VLLM 判断需要调用某个工具时返回结构化的tool_calls→ langchain4j 解析后反射调用你的 Java 方法 → 把方法返回值回传给 VLLM → VLLM 结合工具结果生成最终回复。这个链路里VLLM 只负责决策真正的执行发生在 Java 进程里。所以工具调用的可靠性一半取决于模型对工具描述的理解一半取决于注册工具时描述写得够不够清楚。4.1 最简单的工具注册Tool 注解先定义一个工具类public class MoneyTools { Tool(查询指定用户的账户余额) public BigDecimal getBalance(P(用户ID) String userId) { // 这里调内部账户服务的接口 return accountService.getBalance(userId); } Tool(根据用户ID查询最近交易记录limit代表返回条数) public ListTransaction getTransactions(P(用户ID) String userId, P(返回条数默认5) int limit) { return transactionService.listByUser(userId, limit); } }Tool里的描述文字非常关键它会变成模型决策时的依据。比如getTransactions的注释里明确写了limit的含义模型才知道该传什么值。如果你写的是查询交易这种模糊描述模型很可能不知道该在什么场景下调用它或者参数填错。组装服务interface Assistant { String chat(String userMessage); } Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .tools(new MoneyTools()) .build(); String result assistant.chat(帮我查一下用户 A1001 的账户余额);AiServices会自动把MoneyTools里的所有Tool方法序列化成工具列表并在每一轮对话时附加到请求里。langchain4j 处理了工具调用的循环你不需要手动解析tool_calls再手动回传结果。4.2 工具参数的类型支持P注解里可以补充更细的描述比如枚举范围、格式示例。模型读懂了参数的约束填错参数的几率会小很多Tool(按日期范围查询订单) public ListOrder queryOrders(P(开始日期格式为 yyyy-MM-dd) String startDate, P(结束日期格式为 yyyy-MM-dd) String endDate) { return orderRepository.findByDateRange(startDate, endDate); }实践出来的经验是参数描述一定要把格式写清楚比如日期就是yyyy-MM-dd金额就是数字。VLLM 虽然能理解自然语言但日期这种格式不写清楚它真会给你生成五花八门的写法。4.3 动态工具ToolSpecification 的适用场景Tool适合工具固定不变的场景。如果你的工具列表是动态的比如不同用户身份能看到不同的操作权限就需要用ToolSpecification动态构建ToolSpecification queryOrderSpec ToolSpecification.builder() .name(query_order) .description(根据订单号查询订单状态和详情) .addParameter(orderId, JsonObjectSchema.builder() .stringSchema() .description(订单号) .build()) .build();然后在真正发消息时把这份 specification 传给模型OpenAiChatRequest request OpenAiChatRequest.builder() .messages(...) .toolSpecifications(queryOrderSpec) .build(); ResponseAiMessage response model.generate(request);动态工具的价值在于你可以根据登录用户、当前上下文、业务开关来组装不同的工具集合。比如普通用户只注册query_order管理员额外注册refund_order模型能调用的边界就被精确控制住了而不是一股脑把所有工具暴露给所有会话。需要注意一点无论用Tool还是ToolSpecification最终发给 VLLM 的都是一样的 JSON Schema 结构。langchain4j 在这两种方式下做了统一的序列化处理所以你不需要担心动态方式会破坏协议兼容性。4.4 VLLM 端必须满足的前提条件服务端启动时如果没加--enable-auto-tool-choice你会发现模型收到带 tools 的请求后大概率直接忽略工具输出一段普通文本。排查时第一件事就是回去看启动命令里有没有这个参数。另外VLLM 版本对工具调用的支持迭代很快。老版本可能只支持部分解析器或者对tools字段的校验很严格遇到奇怪的报错先升级 VLLM 到较新版本再试。4.5 一个完整的最小可复现示例把上面的内容串成一段可直接运行的逻辑public String runAssistant(String userMessage) { // 1. 构建模型连接 OpenAiChatModel model OpenAiChatModel.builder() .baseUrl(http://192.168.1.100:8000/v1) .apiKey(local-demo-key) .modelName(local-model) .build(); // 2. 构建带工具调用的服务 Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new MoneyTools()) .build(); // 3. 调用并返回结果 return assistant.chat(userMessage); }执行这句话runAssistant(帮我查一下用户 A1001 最近 3 笔交易记录);正常情况下会经历模型认为需要调用getTransactions→ Java 执行方法 → 结果返回模型 → 模型基于结果生成回答。整个过程日志里能看到tool_calls的相关输出。5. 实测踩坑记录VLLM 工具调用兼容性问题与排查路径代码能跑通不意味着稳了。我在这个环节踩过好几个坑这里按现象 — 排查 — 解决的方式记录方便你对照排查。5.1 VLLM 返回普通文本不触发工具调用现象请求里带了 tools但模型回复是一段纯文字并没有走工具逻辑。排查步骤第一步看 VLLM 启动命令里是否包含--enable-auto-tool-choice。我当时第一版部署命令没加模型完全无视工具。第二步确认模型本身是否支持 tool calling。并不是所有开源模型都原生支持 function calling。Qwen 2.5 系列、Llama 3.1 系列都支持但一些较小的模型或早期版本模型可能不支持即便 VLLM 有解析器也没用。验证方法是直接拿 curl 发一个带tools字段的请求观察返回内容里有没有tool_calls。第三步检查请求里工具描述是否过于含糊。模型不调用工具的另一个常见原因是它看不出该用哪个工具把Tool描述写得更具体、参数约束写得更清楚后触发率会明显提升。5.2 返回了 tool_calls但 langchain4j 解析失败或参数为空现象VLLM 确实返回了tool_calls但 Java 侧收到的AiMessage里工具列表为空或者方法执行时参数缺失。排查步骤先用logRequests(true)和logResponses(true)打开日志看完整的请求响应。我遇到过一次的情况是VLLM 返回的tool_calls里function.arguments字段是 JSON 字符串内部包含参数但 langchain4j 版本与返回结构不完全兼容导致解析不出参数。升级 langchain4j 到更新版本后解决。另一个常见问题是--tool-call-parser选错。VLLM 的解析器要按模型家族选。用 Qwen 系列却配了mistral解析器确实会出现工具调用数据解析错乱的问题。我的建议是先去 VLLM 的文档里确认所选模型该搭配哪个 parser不要一律抄网上的hermes。5.3 工具执行结果回传后模型装没看见现象Java 方法执行成功了结果也回传给了 VLLM但最终回复是我暂时无法查询订单信息之类的车轱辘话明明结果已经在上下文里了。这种情况多半是历史消息被截断或者工具结果消息格式出了问题。查一下ChatMemory的maxMessages设置如果设置太小早期消息会被挤掉模型看不到工具调用的输入和输出自然给不出正确的最终回答。另外记得把工具执行结果作为user或tool角色消息回传这一层 langchain4j 在AiServices里会自动处理但如果你用的是底层model.generate()手写循环就一定要检查消息角色拼装得对不对。5.4 并发压测时出现偶发超时和 500VLLM 在高并发下会先进请求队列如果你客户端请求 timeout 设置太短比如默认 10 秒很容易在模型思考超时的那一瞬间抛异常。代码里把timeout加到 60 秒甚至 120 秒同时服务端用--max-num-seqs控制最大并发序列数。我这里推荐的做法是客户端加一层超时重试逻辑但不能让重试导致重复提交写操作类工具。5.5 curl 直连测试是排错的第一利器遇到任何工具调用相关的问题不要先怀疑代码先用 curl 手工模拟一次完整的对话过程原始 JSON 返回能告诉你 80% 的事实。下面是一个带 tools 的请求示例curl http://127.0.0.1:8000/v1/chat/completions \ -H Authorization: Bearer local-demo-key \ -H Content-Type: application/json \ -d { model: local-model, messages: [ {role: user, content: 查一下用户 A1001 的余额} ], tools: [ { type: function, function: { name: getBalance, description: 查询指定用户的账户余额, parameters: { type: object, properties: { userId: {type: string, description: 用户ID} }, required: [userId] } } } ], tool_choice: auto }如果这个请求直接返回tool_calls说明 VLLM 链路是通的问题在 Java 侧如果返回普通文本问题就在 VLLM 部署参数或模型选型。这个分流判断能节省大量排查时间。6. 最后想说的整套方案跑起来之后我最大的感受是VLLM langchain4j 这套组合完全可以在 Java 业务线里承担生产级别的 AI 自动化任务。工具调用的稳定性虽然受模型本身能力影响但在选对模型、描述清晰、参数合理的前提下实测的成功率足够支撑内部系统使用。如果你准备在生产环境推进建议先从只读类工具开始比如查询订单、查余额等模型调用链路彻底稳定了再逐步放开写操作类工具比如审批、下单、退款并给这类工具加上人工确认机制。最后分享一个小技巧把 VLLM 的启动命令、模型Serving 名称、默认解析器、API Key 统一记在项目 README 里。代码可以随时改但环境配置一旦多人协作就很容易出现代码一样、环境不一致的问题。把这组信息固化下来后面的人接到这个项目时环境复现会顺畅得多。