先说下我为什么折腾这个事。团队里有个 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 集成自然 | 工具调用等高级能力早期支持不够细 | 只用简单对话的场景 |
| langchain4j | Java 原生、工具调用封装好、协议兼容强 | 社区比 Python 生态小一些 | Java 后端需要 function calling 的场景 |
| 直接 HTTP 调 VLLM | 零依赖、完全可控 | 多轮对话、工具循环全部要自己写 | 内部私有协议或简单 demo |
我的结论是:Java 业务系统接 VLLM,langchain4j 是最均衡的选择。接下来就是实际部署和接线。
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 系列一般用hermes,Llama 系列可能要用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 接入 VLLM:OpenAI 兼容接口的配置细节
服务端准备好了,接下来就是 Java 侧的对接。这里我把过程拆成依赖引入、模型构建、通信验证三步,每一步都有明确的执行依据。
3.1 依赖引入
pom.xml 里加两个核心依赖:
<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-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 List<Transaction> 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 List<Order> 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(); Response<AiMessage> 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 并发压测时出现偶发超时和 500
VLLM 在高并发下会先进请求队列,如果你客户端请求 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 里。代码可以随时改,但环境配置一旦多人协作就很容易出现"代码一样、环境不一致"的问题。把这组信息固化下来,后面的人接到这个项目时,环境复现会顺畅得多。