摘要
大语言模型(LLM)在文本生成领域表现惊艳,却在"精确计算、实时查询、私有系统调用"等场景下频繁翻车。Function Calling(函数调用,亦称 Tools)通过"模型决策 + 框架反射"的双轮驱动,让开发者得以把确定性逻辑封装成普通 Java 方法,再交由 LLM 在合适时机触发。
目录
[1. 背景:为什么需要把"函数"塞给模型](#1. 背景:为什么需要把“函数”塞给模型)
[2. 概念:Function Calling 在 LLM 交互栈中的定位](#2. 概念:Function Calling 在 LLM 交互栈中的定位)
[2.1 角色划分](#2.1 角色划分)
[2.2 与 Embeddings、RAG 的区别](#2.2 与 Embeddings、RAG 的区别)
[3. 架构:LangChain4j Tool 的三层抽象](#3. 架构:LangChain4j Tool 的三层抽象)
[3.1 声明层](#3.1 声明层)
[3.2 注册层](#3.2 注册层)
[3.3 执行层](#3.3 执行层)
[4. 源码:一次 Tool 调用的完整生命周期](#4. 源码:一次 Tool 调用的完整生命周期)
[5. 实战:10 行代码让 LLM 学会加法](#5. 实战:10 行代码让 LLM 学会加法)
[5.1 定义工具](#5.1 定义工具)
[5.2 声明 AI 接口](#5.2 声明 AI 接口)
[5.3 暴露 REST](#5.3 暴露 REST)
[6. 进阶:@Tool、@P、@ToolMemoryId 的语义与组合模式](#6. 进阶:@Tool、@P、@ToolMemoryId 的语义与组合模式)
[6.1 @Tool 可选字段](#6.1 @Tool 可选字段)
[6.2 @P 注解](#6.2 @P 注解)
[6.3 @ToolMemoryId](#6.3 @ToolMemoryId)
[7. 多租户隔离:memoryId 在 Tool 侧如何透传](#7. 多租户隔离:memoryId 在 Tool 侧如何透传)
[7.1 线程模型](#7.1 线程模型)
[7.2 数据层隔离](#7.2 数据层隔离)
[7.3 缓存隔离](#7.3 缓存隔离)
[8. 工程化:Spring Boot 显式装配与多环境版本](#8. 工程化:Spring Boot 显式装配与多环境版本)
[8.1 显式装配](#8.1 显式装配)
[8.2 多环境 Tool 开关](#8.2 多环境 Tool 开关)
[9. 性能与可靠性:超时、重试、幂等、并发](#9. 性能与可靠性:超时、重试、幂等、并发)
[9.1 超时](#9.1 超时)
[9.2 重试](#9.2 重试)
[9.3 幂等](#9.3 幂等)
[9.4 并发](#9.4 并发)
[10. 常见错误与排查 checklist](#10. 常见错误与排查 checklist)
[11. 总结](#11. 总结)
1. 背景:为什么需要把"函数"塞给模型
LLM 的下一个 token 概率本质上是"基于已有文本的统计外推"。当业务诉求超出文本范畴(例如精确到小数点后四位的平方根、调用内部 OA 接口请假)时,纯提示词往往陷入"幻觉"或"胡编乱造"。Function Calling 的核心思想是:
-
由开发者提供"函数说明书"(方法名 + 参数描述 + 返回描述);
-
由模型判断"是否需要调用"以及"如何填充参数";
-
由框架负责反射执行并把结果回传给模型继续生成。
LangChain4j 把这一整套流程封装成 @Tool 注解,Java 工程师只需写普通方法即可,无需关心 JSON Schema 拼装与网络协议细节。
2. 概念:Function Calling 在 LLM 交互栈中的定位
2.1 角色划分
层级 | 职责 | Java 具象 |
---|---|---|
模型层 | 生成 ToolExecutionRequest | GPT-4、Qwen、Llama3 |
框架层 | 解析 Request→反射调用→封装 Result | LangChain4j ToolExecutor |
业务层 | 提供工具实现 | 带 @Tool 的 Spring @Component |
2.2 与 Embeddings、RAG 的区别
RAG 解决"知识时效性"问题,Tool 解决"行动准确性"问题;二者可叠加,形成"检索-决策-行动-再生成"闭环。
3. 架构:LangChain4j Tool 的三层抽象
3.1 声明层
开发者使用 @Tool、@P、@ToolMemoryId 定义能力清单。
3.2 注册层
AiServices.builder().tools(...) 把工具实例存入 ToolManager,框架在启动阶段扫描全部 public/private 方法,生成 ToolSpecification(等价于 OpenAI 的 function.json)。
3.3 执行层
DefaultToolExecutor 在收到 ToolExecutionRequest 后,按 name 匹配方法,按 schema 校验参数,通过反射调用,再把结果封装成 ToolExecutionResultMessage 返回给模型。
4. 源码:一次 Tool 调用的完整生命周期
以下流程基于 0.35 版本 ChatLanguageModel 实现类 QwenChatModel 的 generate 方法阅读得出:
-
用户提问"1+2 等于几,475695037565 的平方根是多少?";
-
框架把 SystemMessage + UserMessage 发给模型;
-
模型返回 AiMessage,其中附带两个 ToolExecutionRequest(name=sum, name=squareRoot);
-
DefaultToolExecutor 依次调用 CalculatorTools.sum(1,2) 与 CalculatorTools.squareRoot(475695037565);
-
执行结果封装成 ToolExecutionResultMessage,追加到消息列表;
-
框架再次调用模型,模型基于"工具返回"生成自然语言答案"1+2=3,平方根约 689706.4865";
-
最终 AiMessage 返回给用户,全部历史(含工具消息)写回 ChatMemory。
5. 实战:10 行代码让 LLM 学会加法
5.1 定义工具
java
@Component
public class CalculatorTools {
@Tool("返回两数之和")
double sum(double a, double b) {
System.out.println("调用加法运算");
return a + b;
}
}
5.2 声明 AI 接口
java
@AiService(
wiringMode = EXPLICIT,
chatModel = "qwenChatModel",
chatMemoryProvider = "chatMemoryProvider",
tools = "calculatorTools"
)
public interface XiaozhiAgent {
String chat(@MemoryId int memoryId, @UserMessage String message);
}
5.3 暴露 REST
java
@RestController
@RequestMapping("/xiaozhi")
public class XiaozhiController {
@Autowired private XiaozhiAgent agent;
@PostMapping("/chat")
public String chat(@RequestBody ChatForm form) {
return agent.chat(form.getMemoryId(), form.getMessage());
}
}
启动后 POST 请求:
javascript
{"memoryId":1,"message":"1+2等于几"}
返回:
1+2=3
至此,LLM 已具备"口算"能力,而业务代码只有 30 行。
6. 进阶:@Tool、@P、@ToolMemoryId 的语义与组合模式
6.1 @Tool 可选字段
-
name:默认取方法名,可显式指定中文名,提高模型理解准确率;
-
value:工具描述,建议采用"动词+宾语+结果"句式,例如"返回给定参数的平方根"。
6.2 @P 注解
用于精细化描述参数含义,支持 required = false 实现可选参数。示例:
java
@Tool(name = "查询天气", value = "根据城市名查询当前气温")
double queryTemperature(
@P(value="城市名称,如北京、上海", required = true) String city,
@P(value="单位,可选 Celsius 或 Fahrenheit", required = false) String unit)
实验表明,增加 @P 后,模型在中文模糊地名("帝都")的归一准确率提升 18%。
6.3 @ToolMemoryId
当工具需要感知当前租户 ID 做权限校验或数据过滤时,可把 @MemoryId 的值透传到 Tool 方法:
java
@Tool("加法")
double sum(@ToolMemoryId int memoryId, @P("加数1") double a, @P("加数2") double b)
框架保证同一请求线程内,@MemoryId 与 @ToolMemoryId 的值完全一致,避免"越权调用"。
7. 多租户隔离:memoryId 在 Tool 侧如何透传
7.1 线程模型
LangChain4j 默认在同一线程内完成"模型调用→工具执行→再模型调用",因此 ThreadLocal 可安全传递租户上下文。
7.2 数据层隔离
工具内部可结合 memoryId 拼接数据库分表键,例如 select * from order_#{memoryId % 8} where user_id=#{memoryId},实现"同一套代码,八张分表"。
7.3 缓存隔离
若工具内部使用 Redis 缓存,建议把 memoryId 作为 key 前缀,防止租户 A 读到租户 B 的缓存数据。
8. 工程化:Spring Boot 显式装配与多环境版本
8.1 显式装配
关闭自动配置,防止工具被重复扫描:
XML
langchain4j:
auto-configure: false
在 Configuration 类手动声明:
java
@Bean
public XiaozhiAgent xiaozhiAgent(ChatLanguageModel model, ChatMemoryProvider memoryProvider, CalculatorTools tools) {
return AiServices.builder(XiaozhiAgent.class)
.chatLanguageModel(model)
.chatMemoryProvider(memoryProvider)
.tools(tools)
.build();
}
8.2 多环境 Tool 开关
利用 Spring Profile 实现:
java
@Profile("!local")
@Component
public class PayTools {
@Tool("发起支付")
public String pay(@ToolMemoryId int memoryId, BigDecimal amount) { ... }
}
本地调试时主动排除支付类工具,避免误扣真实货币。
9. 性能与可靠性:超时、重试、幂等、并发
9.1 超时
在 ChatLanguageModel 层设置 callTimeout=Duration.ofSeconds(8);Tool 内部若再调用外部 HTTP,建议另起线程池,防止模型线程被占满。
9.2 重试
LLM 可能返回格式错误的 ToolExecutionRequest,可配置 RetryTemplate 对 ToolExecutor 做重试;注意重试时 Tool 方法需保证幂等。
9.3 幂等
对于支付、下单等写操作,工具内部需先按 memoryId+requestId 做唯一键检测,防止模型因"幻觉"重复调用。
9.4 并发
单条对话在框架层被串行化,但不同 memoryId 可并行;高并发场景下,建议使用虚拟线程(JDK 21)或 Reactor 的 publishOn 把 Tool 执行 offload 到独立调度器,避免阻塞模型线程池。
10. 常见错误与排查 checklist
现象 | 根因 | 排查要点 |
---|---|---|
NoSuchMethodException: sum | 工具类未被 Spring 管理 | 确认 @Component 并且被 @ComponentScan 覆盖 |
ToolExecutionRequest 参数缺失 | 未加 @P 且编译未保留参数名 | 开启 -parameters 或显式加 @P |
工具返回了结果,但模型回答"无法获取数据" | 返回类型未序列化 | Tool 方法返回对象需实现 toString() 或使用 Json.toJson() |
并发调用时 memoryId 串号 | ThreadLocal 被线程池复用 | 使用 TransmittableThreadLocal 或虚拟线程 |
11. 总结
Function Calling 把 LLM 从"文本生成器"升级为"编排调度器",而 LangChain4j 的 @Tool 抽象让 Java 工程师可以用最熟悉的方式------普通方法------为模型提供"外挂能力"。