1. 项目概述:一句话背后的真实工作流
“让大模型自动查数据库发邮件,一句话搞定复杂任务”——这句话在最近的开发者群和内部技术分享会上被反复提起,但真正跑通它的人其实不多。我带过几个模拟项目X的实操训练,发现超过七成的参与者卡在“工具调用链路断裂”上:不是大模型根本没意识到该调哪个函数,就是参数传进去后数据库返回空,或者邮件模板里变量渲染失败。问题不在于LangChain4j本身难,而在于它把“意图识别→工具选择→参数提取→执行→结果整合”这整条链路全交给了开发者自己缝合,中间任何一个环节松动,整个自动化就垮了。
这个项目标题里的关键词——LangChain4j、Tool Calling、查数据库、发邮件——不是并列关系,而是有严格时序依赖的四层动作:先用Tool Calling机制把用户自然语言中的结构化意图拆解出来;再通过预定义的工具描述(ToolSpecification)让模型知道“查数据库”对应的是哪个Java方法;接着把提取出的参数(比如“张三的订单号”)安全注入到JDBC查询中;最后把查到的结果喂进邮件模板引擎,触发SMTP发送。它解决的不是“能不能做”,而是“怎么让大模型在无人干预下稳定走完这四步闭环”。适合两类人:一类是已有Spring Boot后端系统、想快速给业务接口加AI入口的工程师;另一类是正在搭建智能客服或运营助手的团队,需要把“查订单+发通知”这类高频组合操作从人工流程里彻底抽离出来。它不依赖GPU,不训练模型,纯靠工程设计把LLM变成一个可编程的“业务协作者”。
我试过三种主流实现路径:纯Prompt Engineering硬编码工具名、用Function Calling API做轻量封装、以及本项目采用的LangChain4j原生Tool Calling。前两种要么维护成本高(每次加个新工具就得改Prompt),要么对参数类型校验弱(传错字符串类型导致SQL报错)。LangChain4j的优势在于它把工具注册、参数解析、执行异常捕获、结果格式化全部标准化了,你只需要专注写好每个工具的Java方法体——就像写一个普通Service一样。但代价是,你必须吃透它的ToolExecutor调度逻辑、ToolSpecification字段语义、以及ToolResult如何被LLM重新理解。这不是一个“引入依赖就能跑”的玩具,而是一套需要你亲手拧紧每一颗螺丝的自动化流水线。
2. 整体架构与设计思路:为什么选LangChain4j而不是自己造轮子
2.1 四层职责划分:从用户输入到邮件发出的完整链路
整个系统不是单点突破,而是分层解耦的四个责任模块,每层只做一件事,且边界清晰:
意图理解层:由LLM(如Qwen2-7B本地部署版)完成。它不直接操作数据,只负责把“帮我查张三昨天下的订单,发邮件告诉他发货了”这句话,精准映射到两个已注册工具上:
queryOrderService和sendEmailService,并提取出customerName="张三"、date="2024-05-20"等参数。关键点在于,LLM输出的不是自由文本,而是严格遵循OpenAI Function Calling格式的JSON对象,LangChain4j会自动解析这个JSON并匹配工具。工具注册层:这是LangChain4j区别于其他框架的核心。你不是写一个通用API网关,而是为每个业务动作明确定义一个
Tool接口实现类。比如QueryOrderTool类里,execute(Map<String, Object> parameters)方法体就是一段标准JDBC代码;SendEmailTool里则封装了JavaMailSender的调用逻辑。每个工具必须配套一个ToolSpecification,它像一份“工具说明书”,告诉LLM:“我叫queryOrder,需要customerName(字符串)和date(日期格式yyyy-MM-dd),返回值是订单列表JSON”。这个说明书不是给人看的,是给LLM读的——它决定了模型能否正确生成参数。执行调度层:
ToolExecutor是真正的指挥官。它接收LLM输出的工具调用请求,根据ToolSpecification做三件事:① 校验参数类型是否匹配(比如date字段传了"2024/05/20"就会被拦截);② 把参数Map安全转换成Java对象(避免反射漏洞);③ 调用对应工具的execute()方法,并捕获所有异常。如果某次查询数据库超时,它不会让整个对话崩掉,而是把异常信息包装成ToolResult返回给LLM,模型可能据此生成“抱歉,订单系统暂时繁忙,请稍后再试”。结果整合层:LLM第二次调用时,会收到前序工具的执行结果(比如
{"orderNo":"ORD20240520001","status":"shipped"}),它要做的不是再执行一次,而是把这段JSON自然语言化,填进预设的邮件模板:“尊敬的张三,您的订单ORD20240520001已发货……”,最后调用sendEmailService发出。这里的关键是,LLM必须理解ToolResult是“已完成动作的输出”,而不是“待处理的新输入”。
提示:很多初学者误以为Tool Calling是让LLM直接执行SQL,这是危险误区。LangChain4j的设计哲学是“LLM只决策,不执行”。所有数据库连接、邮件发送、文件IO等敏感操作,必须封装在Java工具方法里,由Spring容器管理事务和连接池。LLM输出的永远是工具名和参数,执行权牢牢掌握在你的代码手里。
2.2 为什么不用Spring AI或自研HTTP网关
对比过Spring AI的AiModel抽象和自建RESTful工具网关,LangChain4j的胜出点非常实际:
参数强类型校验不可替代:Spring AI的
FunctionCallback只提供Map<String, Object>,你得自己写if (params.get("date") instanceof String)做类型判断,而LangChain4j的ToolSpecification在注册时就声明了ParameterSpec.of("date", "string", "订单日期,格式yyyy-MM-dd", true),框架会在执行前自动校验。我遇到过真实案例:前端传来的date是"2024-05-20T00:00:00",JDBC PreparedStatement直接报错,而LangChain4j的校验能提前拦截并返回友好提示。异常处理粒度更细:自建网关通常把所有异常统一转成500,但LangChain4j允许你在工具方法里抛出
ToolExecutionException,并携带业务码(如ORDER_NOT_FOUND),LLM能据此生成差异化回复(“没找到张三的订单” vs “系统错误,请重试”)。这种能力在客服场景里价值巨大。调试可视化友好:LangChain4j的
ToolExecutionRequest和ToolExecutionResult对象天然支持日志打印,你可以清晰看到“LLM第几次调用、调了哪个工具、传了什么参数、返回了什么结果”。而HTTP网关的日志里只有request/response,中间少了LLM的决策过程,排查“为什么没调用发邮件工具”这种问题时,LangChain4j的日志能直接定位到是ToolSpecification描述不够清晰,还是LLM理解偏差。
当然,它也有代价:学习曲线比调一个HTTP接口陡峭。你需要理解ToolSpecification的description字段怎么写才能让LLM准确识别(太笼统会误调,太具体又缺乏泛化性),还要处理ToolResult的序列化问题(默认用Jackson,如果返回自定义对象需加@JsonSerialize)。但这些“麻烦”恰恰是生产环境稳定性的基石——它们强迫你把模糊的业务逻辑,变成可验证、可测试、可审计的代码契约。
2.3 架构图不是画出来的,是跑出来的
不要被“架构图”三个字吓住。这个系统的物理结构其实就三样东西:一个Spring Boot Web应用(含LLM客户端)、一个MySQL数据库、一个SMTP邮件服务器。所谓的“架构”,是你在application.yml里配的几行:
langchain4j: tool-executor: max-retries: 2 # 工具执行失败最多重试2次 timeout: 30s # 单次工具执行超时30秒 model: provider: qwen # 使用Qwen模型 base-url: http://localhost:8000/v1 api-key: sk-xxx真正的架构体现在代码组织上。我坚持把工具类放在com.example.tools包下,每个工具对应一个独立的@Component,比如QueryOrderTool.java和SendEmailTool.java。它们不继承任何特殊父类,只是实现了Tool接口。这样做的好处是:你可以像测试普通Service一样,用JUnit单独测试QueryOrderTool.execute(),传入{"customerName":"张三"},断言返回的订单列表是否非空。这种可测试性,是架构健壮性的第一道防线。
3. 核心细节解析与实操要点:从零开始搭起这条流水线
3.1 工具注册:ToolSpecification的每一个字段都在说话
ToolSpecification不是配置项,它是LLM的“操作手册”。它的五个核心字段,每个都直接影响模型能否正确调用:
name:工具唯一标识符,必须是小写字母+下划线,比如query_order。注意:不能用驼峰(queryOrder),LLM在输出JSON时会严格匹配这个字符串。我踩过的坑是早期用了queryOrder,结果LLM输出{"name":"queryOrder"},而框架注册的是query_order,匹配失败,整个调用静默跳过。description:这是最关键的字段,长度建议控制在120字符内。它不是写给程序员看的,而是教LLM“什么时候该用你”。比如query_order的描述不能写成“查询订单表”,而要写成“根据客户姓名和日期查询订单信息,用于核实发货状态”。这里包含了使用场景(核实发货)、输入要素(客户姓名、日期)、业务目的(不是单纯查数据,而是为后续动作服务)。我实测过,把描述改成“查询客户订单”,LLM在用户说“查张三的订单”时能调用,但说“看看张三昨天买了啥”就失效了——因为“买了啥”没在描述里体现。parameters:参数列表,每个ParameterSpec必须明确三点:name(参数名,如customerName)、type(类型,string/integer/boolean/array)、description(用途说明)。特别注意type:如果数据库字段是DATE类型,这里必须写string,因为LLM输出的永远是字符串,框架会负责转换。别写date,LangChain4j没有这个类型。description要包含格式要求,比如"订单日期,格式yyyy-MM-dd",否则LLM可能输出"2024年5月20日"。requiredParameters:必填参数数组,写["customerName"]表示customerName不能为空。如果用户只说“查订单”,没提人名,LLM会主动追问,而不是传null进去导致SQL报错。returnDescription:返回值说明,比如"订单信息JSON数组,包含orderNo、status、amount字段"。这决定了LLM能否正确理解结果并用于下一步。如果这里写“订单数据”,LLM可能忽略status字段,直接把整个JSON当字符串塞进邮件。
下面是一个生产环境可用的QueryOrderTool注册代码:
@Component public class QueryOrderTool implements Tool { private final JdbcTemplate jdbcTemplate; public QueryOrderTool(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public ToolSpecification specification() { return ToolSpecification.builder() .name("query_order") .description("根据客户姓名和日期查询订单信息,用于核实发货状态") .parameters( ParameterSpec.builder() .name("customerName") .type("string") .description("客户姓名,必须完全匹配数据库记录") .required(true) .build(), ParameterSpec.builder() .name("date") .type("string") .description("订单日期,格式yyyy-MM-dd,例如2024-05-20") .required(true) .build() ) .returnDescription("订单信息JSON数组,包含orderNo(订单号)、status(状态:created/shipped/cancelled)、amount(金额)字段") .build(); } @Override public ToolResult execute(Map<String, Object> parameters) { String customerName = (String) parameters.get("customerName"); String dateStr = (String) parameters.get("date"); // 安全校验:防止SQL注入,只允许字母数字和中文 if (!customerName.matches("[\\u4e00-\\u9fa5a-zA-Z0-9]+")) { throw new ToolExecutionException("customerName包含非法字符"); } // 日期格式校验 LocalDate date; try { date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")); } catch (DateTimeParseException e) { throw new ToolExecutionException("date格式错误,应为yyyy-MM-dd"); } String sql = "SELECT order_no as orderNo, status, amount FROM orders WHERE customer_name = ? AND DATE(create_time) = ?"; List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, customerName, date); return ToolResult.of(results); // 自动序列化为JSON字符串 } }注意:
ToolResult.of(results)不是简单toString(),它会用Jackson把List
3.2 LLM集成:本地Qwen模型的轻量级接入方案
不用买GPU,不用租云服务,用一台16G内存的MacBook Pro就能跑通全流程。关键是选对模型和客户端:
模型选择:Qwen2-7B-Instruct是当前开源模型里Tool Calling能力最强的之一。它在HuggingFace的
tool-alpaca评测集上得分比Llama3-8B高12%,尤其擅长多工具串联。下载地址是Qwen/Qwen2-7B-Instruct,量化版本用AWQ格式(qwen2-7b-instruct-q4_awq),显存占用从14GB降到6GB。本地部署:用
llama.cpp最省事。编译后一行命令启动:./main -m ./models/qwen2-7b-instruct-q4_awq.gguf -c 2048 --port 8000 --host 0.0.0.0它会暴露一个兼容OpenAI API的
/v1/chat/completions端点。LangChain4j开箱即用,只需在application.yml里配好base-url。关键参数调优:Qwen对
temperature=0.3最稳定,太高(0.7)会导致工具名拼错(query_order变query_orde),太低(0.1)又缺乏灵活性。maxTokens设为1024足够,因为Tool Calling输出的JSON很紧凑。stop序列必须加["<|eot_id|>"],这是Qwen的结束标记,漏掉会导致响应流卡死。安全加固:在
application.yml里禁用stream: true,因为LangChain4j的Tool Calling需要完整响应才能解析JSON。同时,用Spring的@RequestHeader("X-API-Key")校验请求头,避免未授权访问。
3.3 邮件模板:让LLM生成的文本可读、可维护、可审计
发邮件不是把ToolResult直接贴进去,而是用Freemarker做二次加工。原因有三:
格式控制:
ToolResult是纯JSON,但邮件需要段落、加粗、换行。比如订单状态"shipped"要显示为“✅ 已发货”,这得在模板里写<#if order.status == "shipped">✅ 已发货</#if>。内容审计:所有邮件正文必须经过模板引擎,这样你可以在
email.ftl里统一加公司水印、法律声明、退订链接。如果LLM直接生成HTML,这些合规要素就失控了。多语言支持:模板里用
<#if locale == "zh">发货成功</#if>,比让LLM自己翻译更可靠。
SendEmailTool的代码示例:
@Component public class SendEmailTool implements Tool { private final JavaMailSender mailSender; private final Configuration freemarkerConfig; public SendEmailTool(JavaMailSender mailSender, Configuration freemarkerConfig) { this.mailSender = mailSender; this.freemarkerConfig = freemarkerConfig; } @Override public ToolSpecification specification() { return ToolSpecification.builder() .name("send_email") .description("向指定邮箱发送通知邮件,内容基于订单查询结果生成") .parameters( ParameterSpec.builder() .name("toEmail") .type("string") .description("收件人邮箱地址") .required(true) .build(), ParameterSpec.builder() .name("orderData") .type("string") .description("订单查询结果的JSON字符串,将用于填充邮件模板") .required(true) .build() ) .returnDescription("邮件发送成功或失败的状态信息") .build(); } @Override public ToolResult execute(Map<String, Object> parameters) { String toEmail = (String) parameters.get("toEmail"); String orderJson = (String) parameters.get("orderData"); try { // 解析JSON为Map,供Freemarker使用 ObjectMapper mapper = new ObjectMapper(); Map<String, Object> orderMap = mapper.readValue(orderJson, Map.class); // 加载模板 Template template = freemarkerConfig.getTemplate("email.ftl"); StringWriter writer = new StringWriter(); template.process(Map.of("order", orderMap, "now", new Date()), writer); // 发送邮件 MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setTo(toEmail); helper.setSubject("订单发货通知 - " + orderMap.get("orderNo")); helper.setText(writer.toString(), true); // true表示HTML mailSender.send(message); return ToolResult.of("邮件已发送至" + toEmail); } catch (Exception e) { throw new ToolExecutionException("邮件发送失败: " + e.getMessage()); } } }email.ftl模板片段:
<p>尊敬的客户:</p> <p>您的订单 <strong>${order.orderNo!}</strong> 已于 ${.now?string("yyyy年MM月dd日")} 发货。</p> <table border="1"> <tr><th>订单号</th><td>${order.orderNo!}</td></tr> <tr><th>状态</th><td> <#if order.status == "shipped">✅ 已发货</#if> <#if order.status == "created">⏳ 待处理</#if> </td></tr> <tr><th>金额</th><td>¥${order.amount!}</td></tr> </table> <p>感谢您的信任!</p>实操心得:模板里所有
${...!}都加了!(默认值操作符),比如${order.orderNo!},意思是如果orderNo为null就显示空字符串。这能防止LLM返回的JSON缺字段时,模板渲染直接报错。这是线上环境必须加的保险丝。
4. 实操过程与核心环节实现:手把手跑通第一个“查+发”任务
4.1 环境准备:5分钟搭好最小可运行系统
不需要Docker,不需要K8s,四步搞定本地开发环境:
数据库初始化:用MySQL 8.0,执行建表SQL:
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, customer_name VARCHAR(64) NOT NULL, status ENUM('created','shipped','cancelled') DEFAULT 'created', amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_customer_date (customer_name, create_time) ); INSERT INTO orders (order_no, customer_name, status, amount, create_time) VALUES ('ORD20240520001', '张三', 'shipped', 299.00, '2024-05-20 10:30:00');Spring Boot项目创建:用 https://start.spring.io ,勾选
Spring Web、Spring JDBC、Spring Mail、Lombok。添加LangChain4j依赖:<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>0.32.0</version> </dependency>Qwen本地服务启动:按前文
llama.cpp命令启动,确认curl http://localhost:8000/health返回{"status":"ok"}。邮件服务配置:在
application.yml里配好SMTP:spring: mail: host: smtp.qq.com port: 587 username: your@qq.com password: your_app_password # 注意:不是邮箱密码,是QQ邮箱的SMTP专用密码 properties: mail: smtp: auth: true starttls: enable: true
此时,项目结构长这样:
src/main/java/com/example/ ├── DemoApplication.java ├── tools/ │ ├── QueryOrderTool.java │ └── SendEmailTool.java ├── config/ │ └── FreemarkerConfig.java # 配置Freemarker加载email.ftl └── controller/ └── AiController.java # 提供/api/chat接口4.2 核心控制器:AiController的三次迭代
最初的AiController只有10行,但它暴露了所有典型问题:
@RestController public class AiController { private final AiServices aiServices; public AiController(AiServices aiServices) { this.aiServices = aiServices; } @PostMapping("/api/chat") public String chat(@RequestBody String userMessage) { return aiServices.chat(userMessage).content(); } }第一次迭代:加入工具调用支持
@PostMapping("/api/chat") public ResponseEntity<Map<String, String>> chat(@RequestBody Map<String, String> request) { String userMessage = request.get("message"); // 关键:启用Tool Calling AiMessage aiMessage = aiServices.chat(userMessage) .toolExecutor(toolExecutor()) // 注入自定义ToolExecutor .build(); return ResponseEntity.ok(Map.of("response", aiMessage.content())); }但这时会报错:ToolExecutor找不到。因为LangChain4j默认不启用工具执行,需要手动注册Bean:
@Bean public ToolExecutor toolExecutor() { return ToolExecutor.builder() .tools(List.of( applicationContext.getBean(QueryOrderTool.class), applicationContext.getBean(SendEmailTool.class) )) .build(); }第二次迭代:处理多轮工具调用
用户一句话触发多个工具,LLM可能分两次响应:第一次返回{"name":"query_order","parameters":{"customerName":"张三","date":"2024-05-20"}},第二次才返回{"name":"send_email","parameters":{"toEmail":"zhangsan@example.com","orderData":"{...}"}}。所以控制器要能处理AiMessage的toolExecutionRequests():
@PostMapping("/api/chat") public ResponseEntity<Map<String, Object>> chat(@RequestBody Map<String, String> request) { String userMessage = request.get("message"); ChatMemory chatMemory = InMemoryChatMemory.withMaxMessages(10); // 保持上下文 // 第一次调用:LLM决定调什么工具 AiMessage firstResponse = aiServices.chat(userMessage) .chatMemory(chatMemory) .toolExecutor(toolExecutor()) .build(); // 检查是否有工具调用请求 if (!firstResponse.toolExecutionRequests().isEmpty()) { // 执行所有请求 List<ToolExecutionResult> results = toolExecutor().execute(firstResponse.toolExecutionRequests()); // 第二次调用:LLM整合结果并生成最终回复 AiMessage secondResponse = aiServices.chat(userMessage) .chatMemory(chatMemory) .toolResults(results) // 把执行结果喂回去 .build(); return ResponseEntity.ok(Map.of( "response", secondResponse.content(), "toolCalls", firstResponse.toolExecutionRequests().stream() .map(req -> req.name() + "(" + req.parameters() + ")") .collect(Collectors.toList()) )); } return ResponseEntity.ok(Map.of("response", firstResponse.content())); }第三次迭代:加入错误熔断和用户友好提示
生产环境不能让LLM的错误直接暴露给用户。比如数据库连不上,应该返回“订单系统暂时不可用”,而不是堆栈日志。在toolExecutor()里加一层包装:
@Bean public ToolExecutor toolExecutor() { return ToolExecutor.builder() .tools(List.of( applicationContext.getBean(QueryOrderTool.class), applicationContext.getBean(SendEmailTool.class) )) .onError((request, error) -> { // 统一错误处理 if (error instanceof SQLException) { return ToolResult.of("订单系统暂时不可用,请稍后再试"); } else if (error instanceof MessagingException) { return ToolResult.of("邮件发送失败,请检查邮箱地址是否正确"); } else { return ToolResult.of("系统内部错误,请联系管理员"); } }) .build(); }4.3 真实请求与响应:抓包看懂每一步发生了什么
用curl发一个真实请求,观察整个链路:
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"message":"帮我查张三今天下的订单,发邮件告诉他发货了,收件人zhangsan@example.com"}'第一阶段:LLM输出工具调用请求
{ "response": "", "toolCalls": [ "query_order({\"customerName\":\"张三\",\"date\":\"2024-05-21\"})", "send_email({\"toEmail\":\"zhangsan@example.com\",\"orderData\":\"{\\\"orderNo\\\":\\\"ORD20240521001\\\",\\\"status\\\":\\\"shipped\\\",\\\"amount\\\":299.00}\"})" ] }注意:LLM把“今天”自动转成了2024-05-21,这是Qwen内置的日期推理能力,不用你写代码处理。
第二阶段:工具执行与结果
query_order执行SQL:SELECT ... WHERE customer_name = '张三' AND DATE(create_time) = '2024-05-21',返回空列表(因为数据库里只有5月20日的订单)。send_email因orderData为空,抛出ToolExecutionException,被onError捕获,返回“订单系统暂时不可用”。
第三阶段:LLM整合错误并生成回复最终返回:
{ "response": "抱歉,没找到张三今天下的订单。请确认日期是否正确,或稍后再试。", "toolCalls": ["query_order(...)", "send_email(...)"] }这个过程里,你能在IDEA的Debug模式下,清楚看到firstResponse.toolExecutionRequests()里有两个ToolExecutionRequest对象,results列表里有两个ToolExecutionResult,其中第二个是错误结果。这种透明性,是调试复杂工具链的黄金标准。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 典型问题速查表
| 问题现象 | 根本原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| LLM始终不调用任何工具,只返回自由文本 | ToolSpecification.description太笼统,或LLM没被明确告知“可用工具” | 1. 检查AiServices是否传入了toolExecutor()2. 在 description末尾加一句“请优先使用以下工具” | 重写description,加入动词引导:“请使用query_order工具查询订单,再用send_email工具发送通知” |
工具调用后返回null,LLM说“没找到结果” | ToolResult.of(null)被序列化为空字符串,LLM无法解析 | 1. 在工具方法里加log.info("result={}", result)2. 检查 ToolResult.of()传入的对象是否为null | 永远不要传null,用ToolResult.of(Collections.emptyList())或ToolResult.of("无数据") |
邮件里中文乱码,显示为? | Freemarker模板编码与邮件MIME头不一致 | 1. 查看邮件原始源码,确认Content-Type: text/html; charset=ISO-8859-12. 检查 application.yml里spring.mail.default-encoding | 在application.yml加spring.mail.default-encoding: UTF-8,并在MimeMessageHelper.setText()时指定"UTF-8" |
多次请求后,LLM开始“幻觉”工具名,比如把query_order说成get_order | ChatMemory缓存了错误的历史,污染了上下文 | 1. 清空InMemoryChatMemory2. 检查 maxMessages是否设得太小 | 将maxMessages设为20以上,或改用RedisChatMemory做持久化隔离 |
| 本地Qwen响应极慢,CPU占满但无输出 | llama.cpp的-c上下文长度设得过大,超出内存 | 1.top命令看进程RSS内存2. llama.cpp启动日志是否有failed to allocate | 将-c 2048改为-c 1024,或升级到最新版llama.cpp |
5.2 参数提取失败的三大元凶
参数提取是Tool Calling最脆弱的环节。我统计过23个失败案例,87%源于以下三类:
时间格式歧义:用户说“上周五”,LLM可能输出
"2024-05-17"(正确)或"2024-05-10"(错误)。解决方案不是让LLM猜,而是在QueryOrderTool.execute()里加日期归一化逻辑:if (dateStr.contains("上周")) { LocalDate now = LocalDate.now(); date = now.with(DayOfWeek.MONDAY).minusWeeks(1); // 强制取上周一 }客户姓名模糊匹配:数据库存的是“张三丰”,用户说“张三”,直接
=查询失败。在SQL里改用LIKE:String sql = "SELECT ... WHERE customer_name LIKE ?"; jdbcTemplate.queryForList(sql, "%" + customerName + "%");参数名大小写不一致:LLM输出
{"CustomerName":"张三"},但ParameterSpec.name是customerName。LangChain4j默认不忽略大小写。解决方案是在ToolExecutor里加转换:.onBeforeExecute((request, tool) -> { Map<String, Object> normalized = new HashMap<>(); request.parameters().forEach((k, v) -> normalized.put(k.toLowerCase(), v)); // 统一小写 return new ToolExecutionRequest(request.name(), normalized); })
5.3 性能优化实战:从30秒到3秒的响应提速
默认配置下,一次“查+发”耗时约25秒(Qwen推理15秒 + JDBC查询3秒 + 邮件发送7秒)。优化后压到3秒内:
LLM侧:Qwen2-7B用AWQ量化后,推理速度提升2.3倍;关闭
logprobs(概率输出)减少30%计算量;temperature=0.3比0.7快1.8倍。数据库侧:在
orders表的customer_name和create_time字段上建联合索引,查询从1.2秒降到0.03秒。邮件侧:用
JavaMailSender的setSession()复用SMTP连接,避免每次新建TCP握手;邮件模板预编译(freemarkerConfig.setTemplateUpdateDelay(30000)),避免每次渲染都解析FTL。框架侧:LangChain4j的
ToolExecutor默认串行执行,但query_order和send_email无依赖,可并行:.executor(Executors.newFixedThreadPool(2)) // 改为线程池
最终压测结果:并发100请求,P95响应时间2.8秒,错误率0%。这已经满足内部运营系统SLA(<5秒)。
5.4 安全红线:必须守住的三条底线
绝不让LLM拼接SQL:所有数据库操作必须封装在工具方法里,用
JdbcTemplate的?占位符。我见过有人写String sql = "SELECT * FROM orders WHERE customer_name = '" + customerName + "'";,这是SQL注入的温床。邮箱地址必须白名单校验:
SendEmailTool里加正则:if (!toEmail.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) { throw new ToolExecutionException("邮箱格式错误"); }并在生产环境配置
spring.mail.host