最近手头有个Java后端项目要接大模型能力,一开始我直接调HTTP接口,后来发现要处理对话历史、工具调用、结构化输出这些琐碎事,实在麻烦。后来切到LangChain4j,整体清爽了不少,尤其是把Function calling和系统预设角色配合起来用,基本能做到让模型按照我们想要的路径去操作业务系统。这篇东西就是把这套玩法从思路到代码,再到坑位,完整梳理一遍,希望给同样在Java生态里做AI功能的同学一点参考。
标题里的“针对性操作”是重点。单纯聊天谁都会接,真正能落到业务里的,是大模型主动去调你的方法、查你的数据。LangChain4j这东西,官方定义是Java版的LangChain,但它并不是简单照搬Python生态,而是更贴近Java开发者的习惯。0.31.0之后API稳定了不少,底层核心模块和ai4j也是同源演进。配合System Prompt预设角色,相当于给模型一个“职业身份”和“行为准则”,再通过Function call给它“手和脚”,组合起来才能干实事。
1. 项目定位与思路拆解
1.1 LangChain4j在Java AI开发中的位置
Java开发做AI应用,痛点一直很明确:Python生态的工具链丰富,但很多企业的核心系统是Java写的,团队也以Java为主。你不可能为了一个对话功能专门起一个Python服务,还得做跨语言通信。LangChain4j典型的价值就是让Java工程师在不切换语言的前提下,完成大模型应用的开发。
它和直接调OpenAI SDK的区别在于抽象层次。直接调SDK,你拿到的是消息列表和返回的文本,一切自由但也一切靠自己。用LangChain4j,它帮你把对话历史、消息角色、工具调用、流式输出这些通用逻辑封装好了。更关键的是它提供了“聊天记忆”的接口,你可以用内存、数据库或者Redis存储历史,在多轮对话里不用自己拼JSON。
从设计上看,LangChain4j并不是把LangChain的所有内容都复制到Java,而是按Java习惯重构。比如AiServices这类入口,用起来很像Spring的Service。它还支持Spring Boot Starter,可以在Spring项目里直接注入。如果你用过MyBatis或者Spring Data,会发现它的编程模型很亲切。
1.2 为什么会选“Function call + 系统预设角色”组合
单纯把大模型接入系统,收获的往往是个“聊天机器人”,用户问什么模型答什么,答错了你也拦不住。但业务场景需要的是“可控”。系统预设角色加Function call,就是两个维度的控制。
系统预设角色控制的是模型的“人设”和“边界”。你可以告诉它“你是一个订单查询助手,只能查询当前登录用户自己的订单,拒绝越权请求”,这比在用户输入里反复提示要有力得多。Function call控制的是模型的“行为出口”。模型不直接生成最终结果,而是决定调用哪个函数、传什么参数。真正查数据库、调接口的活还是你的代码来做,模型只负责理解语义和生成调用计划。
这两个组合起来的价值是:第一,安全性更高,因为模型不直接接触数据库,只是生成查询参数;第二,可解释性更强,因为每次调用函数都有日志,你能看到模型到底想干什么;第三,业务隔离更干净,模型能力只通过函数暴露,不会“越狱”到其他接口。
我这里说的“针对性操作”,指的是让模型在特定场景下,只执行特定的函数,而不是天马行空。比如在“订单查询”场景里,用户说“帮我看看昨天买了啥”,模型应该解析出用户ID和时间范围,然后调用searchOrders函数。它不应该自作主张去调用“天气查询”函数,除非你在系统提示里明确允许。这就需要System Prompt和函数声明配合好。
2. 系统预设角色:让模型先“入戏”
2.1 System Prompt的基础写法与加载方式
System Prompt就是对话开始时你给模型设定的系统级指令。LangChain4j里,构建消息时可以直接加入SystemMessage。一个最简单的例子:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("your-api-key") .modelName("gpt-4o-mini") .build(); String response = model.generate( SystemMessage.from("你是一个只回答Java技术问题的专家,回答保持简洁。"), UserMessage.from("什么是虚引用?") );但实际项目里没人这么硬编码。LangChain4j支持通过PromptTemplate加载模板,模板文件放在resources目录下,比如prompts/order-assistant.st:
你是一个订单查询助手,负责根据用户的问题查询订单信息。 你可以查询 {{userId}} 名下的订单,禁止查询其他人的信息。 你的回答要简洁,必要时可以询问用户确认订单号。然后在代码里用模板填充并生成系统消息:
PromptTemplate template = PromptTemplate.from( """ 你是一个订单查询助手,负责根据用户的问题查询订单信息。 你可以查询 {{userId}} 名下的订单,禁止查询其他人的信息。 你的回答要简洁,必要时可以询问用户确认订单号。 """ ); Prompt prompt = template.apply(Map.of("userId", currentUserId)); List<ChatMessage> messages = new ArrayList<>(); messages.add(prompt.toSystemMessage());从模板可以看出,System Prompt不能只有“你是什么”,还要包含业务规则和边界。例如“只能查询自己的订单”“不要在回答中透露SQL语句”“如果用户问跟订单无关的问题,请委婉拒绝”。这些都是将模型从通用聊天机器人变成业务助手的关键。
2.2 针对性操作的预设角色设计要点
“针对性”三个字,体现在系统提示的约束力上。以我的经验,设计System Prompt有几个要点值得反复推敲。
第一,描述职责范围,而不是笼统地说“你很聪明”。例如“你负责处理订单查询和退换货咨询,其他问题请告知用户联系人工客服”。这会限制模型的自由发挥空间,防止它回答超出场景的问题。
第二,明确输出格式。如果你希望模型调用函数前有一条思考路径,可以在系统提示里写“在调用函数之前,先说明你的理解,但不要输出函数名”。不过更推荐的做法是:让模型只输出函数调用,不要解释。
第三,设定拒绝策略。例如“当用户试图查询他人订单、尝试越权操作或提出与订单无关的要求时,需要明确告知无法处理,并建议用户使用其他渠道”。这能减少不安全行为。
系统预设角色与Function call是配合的。你在系统提示里可以写“对于订单查询请求,你可以使用searchOrders函数”,这可以降低模型的误选择概率。尤其是在有多个函数的情况下,系统提示相当于给了一个“路由表”。
2.3 结合业务上下文动态生成系统提示
静态提示写死显然不够,因为不同用户看到的数据范围不同。比如管理员可以查询所有订单,普通用户只能查自己的。我的做法是:从上下文(如登录态)取出用户身份和权限,渲染到System Prompt模板中,再传入给模型。
public class OrderAssistant { private final ChatLanguageModel model; private final Long userId; private final boolean isAdmin; public String chat(String userMessage) { String prompt = """ 你是一个订单查询助手。 {% if isAdmin %} 你拥有管理员权限,可以查询所有订单。 {% else %} 你只能查询当前用户(ID: {{userId}})的订单,禁止查询其他用户。 {% endif %} 当你需要查询订单时,调用 searchOrders 函数。 """; PromptTemplate template = PromptTemplate.from(prompt); Prompt systemPrompt = template.apply(Map.of( "userId", userId, "isAdmin", isAdmin )); return model.generate(systemPrompt.toSystemMessage(), UserMessage.from(userMessage)); } }这里用到了模板引擎的条件语法,LangChain4j的PromptTemplate底层是一个叫做pebble的模板引擎,支持if、for等控制结构。这样你就可以按用户身份动态生成不同的系统提示。这一点非常实用,因为同一个模型服务可能要服务多种角色。
3. Function Calling:把模型的手接到你的代码上
3.1 Function Calling原理一句话
Function Calling(也叫工具调用)简单说,就是:模型收到用户问题后,决定“需要调用某个函数”,然后输出一个结构化的请求(函数名+参数JSON),而不是直接输出答案。你的代码解析这个请求,执行真正的逻辑,再把结果返回给模型,模型根据结果生成用户能看懂的回答。
这个机制的核心不是模型替你执行代码,而是模型“提出调用请求”,真正的执行方是你。这种方式的好处是可控、安全,可以接入任何系统。
3.2 LangChain4j声明函数的两种方式
LangChain4j支持两种声明函数的方式。一种是通过@Tool注解直接标记Java方法;另一种是使用ToolSpecification手动声明。
方式一:@Tool注解
这是最推荐的方式,代码侵入小,阅读直观。
public class OrderTools { @Tool("根据用户ID和订单状态查询订单列表") public List<Order> searchOrders(@ToolParam("用户ID") Long userId, @ToolParam("订单状态:CREATED, PAID, SHIPPED, COMPLETED, CANCELLED") String status) { // 实际查询逻辑 return orderService.search(userId, status); } }然后通过AiServices绑定:
OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .build();这样定义之后,模型在需要查询订单时,会自动调用searchOrders,并把推断出的参数传进来。
方式二:ToolSpecification手动声明
当你的函数名、参数需要动态指定,或者不想定义Java方法时,可以手动声明。
ToolSpecification specification = ToolSpecification.builder() .name("searchOrders") .description("根据用户ID和订单状态查询订单") .addParameter("userId", JsonObjectSchema.builder() .stringType() .description("用户ID") .build()) .addParameter("status", JsonObjectSchema.builder() .stringType() .description("订单状态") .build()) .build();这种方式更底层,适合需要精细控制或动态装配工具的场景。不过说实话,日常项目里用@Tool就足够了,代码更少且不容易出错。
3.3 参数解析与类型转换的坑
Function calling里最头疼的就是参数解析。模型输出的JSON并不总是完全符合你的Java类型。尤其碰到日期、枚举、Long类型时,容易翻车。
我的经验是:参数类型优先用字符串和数字这类简单类型。对于日期,让模型直接传“yyyy-MM-dd”字符串,你在方法内部再解析。对于枚举,与其让模型传枚举名,不如在@ToolParam描述里明确列出可枚举的值。
例如:
@Tool("查询订单") public List<Order> searchOrders( @ToolParam("用户ID,数字") Long userId, @ToolParam("订单状态,只能是以下值之一:CREATED、PAID、SHIPPED、COMPLETED、CANCELLED") String status ) { OrderStatus orderStatus = OrderStatus.valueOf(status); // ... }如果模型传了一个不存在的状态,valueOf会抛异常。所以我的函数里会加一层兜底:
try { orderStatus = OrderStatus.valueOf(status); } catch (IllegalArgumentException e) { orderStatus = OrderStatus.UNKNOWN; }另一个坑是模型可能会把必填参数留空。这种情况下,函数方法应该做空值校验,并返回一个明确提示给模型,比如“参数不完整,请询问用户订单状态”。模型拿到这个返回值后,会继续向用户追问。
3.4 结合系统角色的联动逻辑
用了System Prompt后,模型的行为会更收敛。但要让它们配合默契,还需要在System Prompt里明确告诉模型“在什么条件下调用什么函数”。
举例来说,你在System Prompt里写了:“当用户想查看订单时,请调用searchOrders函数。” 之后用户问“我的东西发货了吗”,模型会识别出这是订单查询,且“发货”对应物流状态,但由于你没有物流查询函数,它就会调searchOrders把订单状态拉出来,再根据状态回答用户。这里面,模型其实做了两步推理:第一步判断需要什么信息,第二步判断哪个函数能提供这个信息。
如果模型没有权限调用某个函数,你可以在System Prompt里明确禁止。例如“如果没有用户明确授权,不要调用deleteOrder函数”。这比在代码里拦截要自然得多,因为模型会按你的要求放弃调用。
那问题来了:如果用户硬要通过对话让模型调用删除函数怎么办?System Prompt是软约束,函数本身的鉴权才是硬约束。删除操作必须在Java方法里做二次校验。这样即使模型“变坏”了,真正执行时也会被代码拦住。所以我的项目里,所有敏感操作都不只依赖System Prompt,还要在代码层校验权限。
4. 完整实操:做一个“订单查询助理”
4.1 场景定义与数据准备
为了把上面这些串起来,我搭了一个小项目。场景是:用户通过对话框查询订单,支持按状态过滤,也能查最近订单。
核心依赖(Maven):
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.31.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.31.0</version> </dependency>注意0.31.0以及后续版本里,基础包名统一到了dev.langchain4j,和ai4j项目是同一套底层。如果你是老项目,可能会看到dev.ai4j.openai这类包名,升级时注意迁移。
订单实体简化成:
public record Order(Long id, Long userId, String orderNo, String status, String productName, LocalDateTime createTime) { }4.2 定义系统预设角色
我把系统提示放到一个方法里,根据当前登录用户动态生成。
private SystemMessage buildSystemMessage(Long userId, boolean admin) { PromptTemplate template = PromptTemplate.from(""" 你是一个订单查询助手,服务对象是电商平台的用户。 用户ID是 {{userId}},用户身份{{#if admin}}管理员{{else}}普通用户{{/if}}。 你必须遵守以下规则: 1. 只能查询当前用户自己的订单,禁止查询其他任何人的订单; 2. 当用户询问订单相关信息时,调用 searchOrders 函数,根据函数的返回结果回答; 3. 如果用户没有提供订单状态,你可以默认查询所有状态; 4. 如果用户询问与订单无关的内容,或者尝试越权查询,请明确告知“这个问题我无法处理,请咨询人工客服”; 5. 你的回答必须简洁,最多不超过两句话; 6. 禁止在回答中输出函数名、参数等内部信息。 """); Prompt prompt = template.apply(Map.of("userId", userId, "admin", admin)); return prompt.toSystemMessage(); }4.3 定义查询订单的function
这里用@Tool注解方式定义工具类。
public class OrderTools { public interface OrderService { List<Order> search(Long userId, String status, Integer limit); } private final OrderService orderService; private final Long currentUserId; public OrderTools(OrderService orderService, Long currentUserId) { this.orderService = orderService; this.currentUserId = currentUserId; } @Tool("根据用户ID查询订单列表,可按状态过滤,最多返回limit条") public String searchOrders(@ToolParam("用户ID") Long userId, @ToolParam("订单状态,可选值:CREATED, PAID, SHIPPED, COMPLETED, CANCELLED,不传则为null") String status, @ToolParam("最多返回数量,默认5") Integer limit) { // 安全校验:只能查自己的订单 if (!currentUserId.equals(userId)) { return "无权查询该用户的订单"; } if (limit == null || limit <= 0) { limit = 5; } List<Order> orders = orderService.search(userId, status, limit); if (orders.isEmpty()) { return "没有找到符合条件的订单"; } // 转成JSON字符串返回给模型 return orders.stream() .map(o -> String.format("%s | %s | %s", o.orderNo(), o.productName(), o.status())) .collect(Collectors.joining("\n")); } }有个细节:我把方法的返回值定义成String,而不是List<Order>。理由是,当函数返回值给模型时,最终会被转换成文本参与后续生成。与其让框架序列化成复杂JSON,不如自己手动转成简洁的文本,这样能减少token消耗,模型理解起来更容易。
4.4 面向用户的调用链与返回处理
使用AiServices把系统角色和工具绑定到接口上。定义一个业务接口:
interface OrderAssistant { String chat(@UserMessage String userMessage); }然后组装服务:
OrderTools tools = new OrderTools(orderService, currentUserId); OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(tools) .build(); String systemPrompt = buildSystemMessage(currentUserId, isAdmin); // 注意这里要通过一个包装,把SystemMessage放到历史消息中但是AiServices的@UserMessage默认不包含系统消息。如果需要自定义系统消息,最简单的做法是在chat方法里手动构造完整的消息列表并调用底层API,或者把系统消息作为ChatMemory的一部分。
我为了演示底层结合SystemMessage与Tool,选择直接使用model.generate:
List<ChatMessage> messages = new ArrayList<>(); messages.add(buildSystemMessage(userId, isAdmin)); messages.add(UserMessage.from(userInput)); Response<AiMessage> response = model.generate(messages); if (response.content().hasToolExecutionRequests()) { // 模型请求调用函数 List<ToolExecutionRequest> requests = response.content().toolExecutionRequests(); // 这里需要分发给工具类,LangChain4j在AiServices中已封装,但底层可以这样处理 for (ToolExecutionRequest request : requests) { String name = request.name(); String args = request.arguments(); // 根据名称调用对应方法 if ("searchOrders".equals(name)) { // 解析参数JSON,调用方法,然后把结果组成ToolExecutionResultMessage } } // 最后把结果加入消息列表,再请求一次模型生成最终回答 }如果项目需要尽快落地,还是推荐直接用AiServices,因为它自动负责工具调度。手动方式主要用于理解原理。既然标题强调“针对性操作”,就是想让你理解底层,所以我两手都写了。
完整的AiServices写法:
OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(tools) .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) .build();然后每次调用时,系统消息需要放在ChatMemory里初始化。可惜AiServices没有直接提供addSystemMessage,所以你需要自行创建一个SystemMessage并预置到memory中:
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10); memory.add(buildSystemMessage(userId, isAdmin)); OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(tools) .chatMemory(memory) .build();之后直接调用assistant.chat("查一下我发货的订单"),框架会内部处理函数调用和记忆维护。这个是我实际项目里用的方案。
5. 常见问题与排查实录
5.1 函数调用不触发或误触发
这是最常遇到的问题。用户明确说“查一下订单”,模型却直接回复“好的,请稍等”,压根没调用函数。原因多数是函数描述写得不够清楚,或者System Prompt里没有引导。
排查的方法是打开langchain4j的日志,查看模型返回的原始响应。如果是gpt-4o-mini,一般比较听话。你也可以把withResponseFormat或temperature调低一点。但更有效的办法是:在系统提示里增加一句“如果需要查询订单,务必调用searchOrders函数,不要直接回答”。我试过之后,触发率显著提升。
误触发则相反,用户说“我想退货”,模型却调了searchOrders。这是因为退货也需要查订单,所以不算完全误触发。真正的误触发是用户问“今天天气怎么样”,模型调了订单函数。解决办法是在System Prompt里限定函数使用范围:“只有在用户明确表达查询订单意图时,才能调用searchOrders函数;其他请求一律拒绝。”
5.2 参数解析报错
模型生成的参数JSON有时会多出或缺少字段。例如传入{ "userId": 1, "status": "", "limit": 3 },空字符串转枚举就会报错。所以我建议在函数内部对所有参数做空值兜底。
用Jackson解析参数的话,注意字段名要和@ToolParam的变量名匹配。如果用了record,某些情况下反射会出问题,我遇到过一次。后来工具类里的方法参数都改成普通类,避免record带来的反射兼容性问题。
再一个问题是Long类型的userId容易传成字符串"1",Jackson默认能转换,但如果严格模式会报错。建议在ObjectMapper里配置ALLOW_COERCION_OF_SCALARS为true,或者干脆把参数都定义成String,内部再转换。
5.3 上下文窗口与多轮对话的token控制
系统提示本身会占一定token。如果模板很长,再加上历史消息,很容易超出模型上下文限制。我的做法是:把System Prompt压缩到最精简,把不常用的规则挪到程序逻辑里,而不是全部塞给模型。
多轮对话时,MessageWindowChatMemory.withMaxMessages(10)会保留最近10条消息,这基本够用。如果业务需要更长的历史,建议用持久化ChatMemory,参考官方提供的PersistentChatMemory实现,把消息存到数据库或Redis里。
另外,函数返回内容如果很长(比如几百条订单),会直接膨胀上下文。所以我的函数通常只返回摘要,比如前5条,或者只返回总数加上几条示例。剩下的细节让用户通过“加载更多”来查。
5.4 与外部系统交互时的超时与重试
工具函数里如果调用了外部接口,比如订单服务的RPC,就要注意超时和重试。模型调用函数时,用户还在等结果,你的函数如果耗时太长,会拉低体验。
我的策略是:函数方法内设置2~3秒超时,加上1次重试;如果仍然失败,就返回一个明确的错误文本,比如“订单服务暂时不可用,请稍后再试”。模型会把这句话转化为对用户的友好提示。这种情况下,函数返回内容就是给模型看的,模型会理解并重新组织语言。
还有一个常见问题:函数调用过程中如果抛出异常,LangChain4j默认会把异常信息返回给模型。这可能会暴露内部代码细节。建议在@Tool方法里catch所有异常,返回安全的错误提示,避免把堆栈信息传给模型。
下表是几个典型问题的速查:
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 函数不触发 | 函数描述含糊,System Prompt未引导 | 优化@Tool的description,在System Prompt中明确触发条件 |
| 函数误触发 | 多个函数语义重叠 | 细化函数描述,增加“只有……才”的限制 |
| 参数缺失或类型不一致 | 模型推断错误 | 用String接收参数,内部解析;空值兜底 |
| 模型把敏感信息返回给用户 | System Prompt缺少脱敏规则 | 在函数返回前做数据脱敏,并在System Prompt中禁止输出内部信息 |
| 多轮对话后响应变慢 | 历史消息太多 | 限制ChatMemory窗口大小,精简函数返回内容 |
| 工具调用抛异常导致对话中断 | 未捕获异常 | 在@Tool方法内catch所有异常,返回安全提示 |
5.5 一个小技巧:让“函数结果”二次加工后再返回给用户
模型拿到函数返回的原始文本后,会基于这些文本生成回答。你可以利用这一点做数据脱敏或格式化。比如函数返回的是“订单号,商品,状态”,模型会自动生成“您的订单A123已发货,商品为无线鼠标”这样的回答。
但模型也可能“多嘴”,把不该说的也说出来。比如函数内部返回了用户ID,模型可能会把它当成普通信息。解决办法有两个:第一,函数返回的文本里不要包含多余字段;第二,在System Prompt中明确说“不要透露函数返回的原始数据,只转述结果”。
我实际项目里,函数返回的都是已经脱敏后的展示文本。敏感字段在函数内部就过滤掉,从源头保证不泄露。
6. 后续可以扩展的方向
如果你看完这篇,已经能把System Prompt和Function call跑通,那这个架子其实可以承载很多玩法。LangChain4j还支持RAG,比如配合Milvus做混合检索,把知识库内容作为上下文提供给模型。和本文的思路结合起来就是:先通过系统预设角色定义专业身份,再用函数调用操作结构化数据,最后通过RAG引入非结构化知识。
“怎么写skill博客”这个热词,本质上也是类似的套路:把某类场景的System Prompt、Tool定义、回复策略打包成一套可复用的“技能”,写出来分享。其实LangChain4j官方文档里,关于工具和系统提示的示例已经比较完善了,但架构层面如何取舍,还是得结合业务自己摸索。
我个人的体会是:别一开始就追求大而全的Agent框架。先把一个最小闭环跑通——确定角色、定义两三个函数、处理好错误场景,就已经能应对不少实际需求。等跑顺了,再逐步加记忆、加检索、加更多工具。
最后分享一个小细节:系统提示里的措辞用“必须”“禁止”比“建议”“不要”更有效。模型对强指令词的服从度更高。我在多次测试里发现,明确写出“你必须调用searchOrders函数才能回答订单问题”之后,模型的调用率几乎接近100%。这比你在代码里费劲兜底高效得多。