0%

langchain4j Tools支持

一些大语言模型(LLM)除了生成文本之外,还可以触发动作。这个被称为”工具”或”函数调用”的概念,它允许 LLM 在必要时调用一个或多个通常由开发者定义的可用工具。工具可以是任何东西:网络搜索、调用外部 API,或执行特定的代码片段等。LLM 实际上不能自己调用工具,相反,它们在响应中表达调用特定工具的意图(而不是以纯文本形式响应)。

所有支持工具的 LLM 可以在此处找到(参见”Tools”列)。
并非所有 LLM 对工具的支持程度都一样,理解、选择并正确使用工具的能力在很大程度上取决于具体的模型和它的能力。有些模型可能完全不支持工具,而另一些则可能需要精心设计的提示词或额外的系统指令。


LangChain4j 工具抽象层级

LangChain4j 为使用工具提供了两个抽象层级:ChatModel级别和AI—Services级别。

ChatModel 级别API

在底层,你可以使用 ChatModel 的 chat(ChatRequest) 方法。StreamingChatModel 中也有类似的方法。

在创建 ChatRequest 时,你可以指定一个或多个 ToolSpecification。

ToolSpecification 是一个包含工具所有信息的对象:

  • 名称:工具的名称
  • 描述:工具的用途说明
  • 参数:工具接受的参数及其描述(使用 JsonObjectSchema)

建议尽可能提供关于工具的更多信息:清晰的名称、全面的描述以及每个参数的描述等。

创建 ToolSpecification 有两种方式:

  1. 使用 builder:ToolSpecification.builder().name("...").description("...").parameters(...).build()
  2. 使用 JSON Schema:通过 JsonObjectSchema 定义参数

你可以在此处找到关于 JsonObjectSchema 的更多信息。

ToolSpecification 可以使用 toJson() 和 fromJson() 方法进行 JSON 序列化和反序列化。这很有用,例如,当你想将工具规范存储在数据库中或通过网络传输时。

默认情况下,使用专用的 Jackson ObjectMapper 进行 JSON 转换。你可以通过 SPI 实现自己的 ToolSpecificationJsonCodec,方法是实现 ToolSpecificationJsonCodecFactory 并将其注册到 META-INF/services/dev.langchain4j.spi.agent.tool.ToolSpecificationJsonCodecFactory。

一旦你有了 List<ToolSpecification>,就可以调用模型:

1
2
3
4
5
ChatRequest request = ChatRequest.builder()
.messages(userMessage)
.toolSpecifications(toolSpecifications)
.build();
ChatResponse response = model.chat(request);

如果 LLM 决定调用工具,返回的 AiMessage 将在 toolExecutionRequests 字段中包含数据。在这种情况下,AiMessage.hasToolExecutionRequests() 将返回 true。根据 LLM 的不同,它可以包含一个或多个 ToolExecutionRequest 对象(有些 LLM 支持并行调用多个工具)。

每个 ToolExecutionRequest 应包含:

  • 工具名称:要调用的工具的名称
  • 参数:以 JSON 格式提供的参数

你需要使用 ToolExecutionRequest 中的信息手动执行工具。

如果你想将工具执行的结果发送回 LLM,你需要为每个 ToolExecutionRequest 创建一个 ToolExecutionResultMessage,并将其与所有之前的消息一起发送:

1
2
3
4
ToolExecutionResultMessage resultMessage = ToolExecutionResultMessage.from(
toolExecutionRequest,
"124" // 工具执行的结果
);

ToolExecutionResultMessage 也可以携带非文本内容,如图像。除了使用 text(),你还可以使用带有 contents() 的构建器:

1
2
3
4
ToolExecutionResultMessage resultMessage = ToolExecutionResultMessage.builder()
.toolExecutionRequest(toolExecutionRequest)
.contents(ImageContent.from(imageUrl))
.build();

一旦你有了 List<ChatMessage>,就可以再次调用模型。

流式工具调用

如果 LLM 决定调用工具,在最终的 onCompleteToolCall(CompleteToolCall) 回调被调用之前,通常会多次调用 onPartialToolCall(PartialToolCall) 回调,表明该工具调用的流式传输已完成。

请注意,并非所有 LLM 提供商都支持流式部分工具调用。有些提供商(例如 Bedrock、Google、Mistral、Ollama)只返回完整的工具调用。在这些情况下,onPartialToolCall 回调不会被调用——只会调用 onCompleteToolCall。

以下是单个工具调用的流式传输可能的样子:

1
2
3
4
onPartialToolCall: {"name": "add", "arguments": "{"a": 3}
onPartialToolCall: {"name": "add", "arguments": "{"a": 37, "b": 8}
onPartialToolCall: {"name": "add", "arguments": "{"a": 37, "b": 87}"}
onCompleteToolCall: {"name": "add", "arguments": {"a": 37, "b": 87}}

如果 LLM 发起多个工具调用,index 将递增,允许你将不同的 PartialToolCall 彼此关联,并与最终的 CompleteToolCall 关联。

当完整的响应流式传输结束并调用 onCompleteResponse(ChatResponse) 时,ChatResponse 中的 AiMessage 将包含在流式传输期间发生的所有工具调用。


AI Service 级别 API

在高层抽象中,你可以用 @Tool 注解任何 Java 方法,并在创建 AI Service 时指定它们。

AI Service 会自动将此类方法转换为 ToolSpecification,并将其包含在每次与 LLM 交互的请求中。当 LLM 决定调用工具时,AI Service 会自动执行相应的方法,方法的返回值(如果有)将被发送回 LLM。你可以在 DefaultToolExecutor 中找到实现细节。


@Tool 注解详解

基本示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Calculator {

@Tool("计算两个数字的和")
public int add(
@P("第一个加数") int a,
@P("第二个加数") int b
) {
return a + b;
}

@Tool("计算两个数字的差")
public int subtract(
@P("被减数") int a,
@P("减数") int b
) {
return a - b;
}
}

方法参数

用 @Tool 注解的方法可以接受任意数量的各种类型的参数:

  • 基本类型(int、long、boolean、double 等)
  • String
  • 枚举(enum)
  • POJO(普通 Java 对象)
  • List、Set、Map
  • 多态类型
  • Optional<T>

也支持没有参数的方法。

默认情况下,如果没有指定 @P 的 name 属性,参数名称通过反射获取。但是,如果没有 -parameters javac 选项,反射会返回通用名称,如 arg0、arg1 等。参数的语义含义会丢失,这可能会让 LLM 感到困惑。

在 @P 中设置 name 在两种情况下很有用:

  1. 缺少 -parameters javac 选项时:没有 -parameters javac 选项时,Java 反射返回通用名称,如 arg0、arg1 等。设置 name 可以恢复有意义的名称。注意,像 Quarkus 和 Spring 这样的框架默认启用 -parameters,因此实际的方法参数名称会被保留,使用这些框架时通常不需要设置 name。
  2. 为 LLM 自定义名称:当你希望 LLM 看到的参数名称与开发人员在源代码中使用的名称不同时(例如,为了匹配特定的 API 契约或提供更具有描述性的名称)。

示例:

1
2
3
4
@Tool("计算平方根")
public double squareRoot(@P(name = "number", description = "要计算平方根的数字") double input) {
return Math.sqrt(input);
}

可选参数与默认值

默认情况下,所有工具方法参数都被视为 必需(required)。这意味着该参数被列在发送给 LLM 的 JSON Schema 的 required 数组中,指示 LLM 生成一个值。

可以通过使用 @P(required = false) 注解使参数变为可选:

1
2
3
4
5
6
7
8
@Tool("发送邮件")
public void sendEmail(
@P("收件人邮箱地址") String to,
@P(value = "邮件主题", required = false) String subject,
@P(value = "邮件正文", required = false) String body
) {
// ...
}

或者,你可以将参数声明为 Optional:

1
2
3
4
@Tool("查找用户")
public User findUser(@P("用户ID") Optional<String> id) {
// ...
}

复杂参数的字段和子字段默认也被视为 必需。你可以通过使用 @JsonProperty(required = false) 注解使字段变为可选:

1
2
3
4
5
public class SearchRequest {
@JsonProperty(required = false)
private String category;
private String query;
}

请注意,当与结构化输出一起使用时,默认情况下所有字段和子字段都被视为 可选。

required 标志控制发送给 LLM 的 JSON Schema(必需参数列在 Schema 的 required 数组中)。LLM 应该遵守这一点,但在实践中它可能会忽略 Schema 并省略参数。

在 LangChain4j 1.x 中,这仅针对基本类型(primitive)参数(int、long、boolean 等)进行检测——缺少基本类型会触发 ToolArgumentsErrorHandler(见下方的错误处理)。缺少的对象参数不会被验证:即使 Schema 将该参数标记为必需,null 仍会被传递给 @Tool 注解的方法。

默认值

如果你想要一个真正的回退值而不是 null(或代替基本类型参数的错误),请使用 @P(defaultValue = ...)。

@P(defaultValue = "...") 声明了一个当 LLM 省略参数时 LangChain4j 将替换的值。这是使参数变为可选并为你工具方法接收到一个合理的回退值的最简单方式。

设置 defaultValue 意味着在 JSON Schema 中为可选——无论 @P(required) 如何设置,该参数都不会被列在 Schema 的 required 数组中。LLM 被告知可以省略该参数;如果它确实省略了,LangChain4j 会在调用你的方法之前填入默认值。

支持的类型:

  • 基本类型及其包装类(int/Integer、long/Long、boolean/Boolean、double/Double 等)
  • String
  • 枚举(enum)
  • POJO(通过 JSON 反序列化)
  • List、Set、Map(通过 JSON 反序列化)

默认值字符串在 AI Service 注册时解析。如果无法转换为参数的类型,AI Service 构造将立即失败并抛出 IllegalConfigurationException,指出有问题的参数——拼写错误会在启动时而不是在第一次 LLM 调用时被捕获。

默认值仅适用于缺失的情况,不适用于错误的值。如果 LLM 提供了一个无法通过类型转换的参数(例如,为 int 提供 "banana"),类型转换错误将照常传播——默认值不会被用作回退。

默认值在每次调用时都会重新解析,因此一个工具对默认值的 List/Map/POJO 的修改不会污染后续的调用:

1
2
3
4
@Tool("添加标签")
public void addTags(@P(defaultValue = "[]") List<String> tags) {
tags.add("processed"); // 不会影响下一次调用
}

限制(在注册时会以 IllegalConfigurationException 拒绝):

  • 默认值必须是有效的 JSON(对于集合和 POJO)
  • 默认值必须能成功转换为目标类型
  • 对于基本类型,默认值不能是 null

多态参数

工具参数可以是多态类型——一种具体子类型由 LLM 在调用时决定的基础类型。密封接口(sealed interfaces)和密封类(sealed classes)无需注解即可工作;普通的抽象类和接口必须使用 Jackson 的 @JsonSubTypes 声明其子类型。发送给 LLM 的 Schema 包含对允许的子类型的 anyOf,每个子类型都有一个鉴别器属性(默认为 "type"),以便 LLM 可以传达它生成了哪种具体类型;LangChain4j 在调用你的工具方法之前将 LLM 的参数反序列化为正确的子类型。

这适用于作为参数、作为多态类型的 List/Set 以及嵌套在另一个 POJO 参数中的多态类型。

密封接口和类——无需注解:

1
2
3
4
5
6
7
8
9
10
public sealed interface Shape permits Circle, Rectangle {}

public final class Circle implements Shape {
public double radius;
}

public final class Rectangle implements Shape {
public double width;
public double height;
}

Jackson @JsonSubTypes/@JsonTypeInfo 也受支持,让你可以将线路名称与 Java 类名解耦:

1
2
3
4
5
6
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Circle.class, name = "circle"),
@JsonSubTypes.Type(value = Rectangle.class, name = "rectangle")
})
public interface Shape {}

关于支持的 @JsonTypeInfo 选项、鉴别器名称解析顺序、defaultImpl 行为、visible 标志和字段冲突检测的详细说明,请参见结构化输出下的多态类型——它们同样适用于工具参数。

递归参数(例如,一个具有 Set<Person> 字段的 Person 类)目前仅 OpenAI 支持。


返回值

用 @Tool 注解的方法可以返回任何类型,包括 void。

  • void:如果方法成功返回,则向 LLM 发送 “Success” 字符串。
  • String:返回值按原样发送给 LLM,不进行任何转换。
  • 其他类型:返回值在发送给 LLM 之前会被转换为 JSON 字符串。

返回多模态内容

工具也可以返回图像和其他非文本内容。当工具返回以下类型之一时,结果将作为多模态内容(例如图像)发送给 LLM,而不是被序列化为 JSON 文本:

  • ImageContent
  • AudioContent
  • VideoContent
  • PdfFileContent

例如,一个拍照并返回图像的工具:

1
2
3
4
@Tool("拍摄照片")
public ImageContent takePhoto() {
return ImageContent.from(camera.captureImage());
}

或者一个同时返回文本和图像的工具:

1
2
3
4
5
6
7
@Tool("分析文档")
public List<Content> analyzeDocument(String docPath) {
return List.of(
TextContent.from("文档分析完成"),
ImageContent.from(generateChart())
);
}

并非所有 LLM 提供商都支持多模态工具结果。目前支持工具结果中图像的提供商包括 Anthropic、Amazon Bedrock 和 Google AI Gemini。如果工具返回非文本内容,其他提供商将抛出 UnsupportedFeatureException。


将 AI Service 作为工具使用

AI Service 也可以作为其他 AI Service 的工具使用。这在许多智能体(agentic)用例中很有用,其中一个 AI Service 可以寻求另一个更专业的 AI Service 的帮助来执行特定任务。例如,定义了以下 AI Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface MathAgent {
String solve(String problem);
}

interface EmailAgent {
void send(String to, String subject, String body);
}

interface SearchAgent {
String search(String query);
}

interface RouterAgent {
String route(String request);
}

RouterAgent 可以配置为将其他 3 个 AI Service 作为工具使用,它们是在特定领域的专家,将用户请求路由到其中之一。

将 AI Service 作为其他 AI Service 的工具使用是一个强大的功能,可以构建复杂的智能体系统。然而,这种方法也有一些重要的缺点需要注意:

  • 延迟增加:每次工具调用都会产生额外的 LLM 交互
  • 复杂性增加:调试和跟踪变得更加困难
  • 成本增加:更多的 LLM 调用意味着更多的 token 消耗

@Tool 注解的属性

任何用 @Tool 注解的 Java 方法并在构建 AI Service 时显式指定的,都可以由 LLM 执行:

1
2
3
4
5
6
Calculator calculator = new Calculator();

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(calculator) // 显式指定工具
.build();

当调用 ask 方法时,会发生 2 次与 LLM 的交互,如前面章节所述。在这两次交互之间,squareRoot 方法会被自动调用。

@Tool 注解具有以下字段:

字段 描述
value / description 工具的描述(两者可互换)
name 工具的名称(可选,默认为方法名)
returnBehavior 控制工具结果的返回行为(见下方”立即返回”章节)

根据工具的不同,LLM 即使没有任何描述也可能很好地理解它(例如,add(a, b) 是显而易见的),但通常最好提供清晰且有意义的名称和描述。这样,LLM 就有更多信息来决定是否调用给定工具以及如何调用。


方法继承与覆盖

具体的 @Tool 注解方法会从父类和接口继承。当你将一个工具对象传递给 AI Service 时,LangChain4j 会从该对象的类、所有父类(直到但不包括 Object)以及所实现接口中的 default 和 static 方法中发现 @Tool 方法。

子类可以覆盖父类的 @Tool 方法。在这种情况下,只使用子类的版本——包括其 @Tool 注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Parent {
@Tool("返回问候语")
public String greet() {
return "Hello";
}
}

class Child extends Parent {
@Override
@Tool("返回正式的问候语")
public String greet() {
return "Good day";
}
}

在这里,LLM 将看到一个名为 greet 的单一工具,描述为”返回正式的问候语”。

如果子类声明了一个与父类方法同名但参数不同的方法(重载,而非覆盖),则两个方法都会被发现。由于工具名称必须唯一且默认为方法名,你必须至少给其中一个一个显式的名称:

1
2
3
4
5
6
7
8
9
class Parent {
@Tool("发送消息")
public void send(String message) {}
}

class Child extends Parent {
@Tool(name = "send_urgent", description = "紧急发送消息")
public void send(String message, boolean urgent) {}
}

如果两个方法解析为相同的工具名称,将在 AI Service 创建时抛出 IllegalArgumentException。


@P 注解

方法参数可以选择性地使用 @P 注解。

@P 注解具有以下可选字段:

字段 描述
value / description 参数的描述(两者可互换)
name 参数的名称(覆盖反射获取的名称)
required 是否为必需参数(默认 true)
defaultValue 当 LLM 省略参数时使用的默认值

name 属性覆盖 LLM 将看到的参数名称。在两种情况下设置 name 很有用:

  1. 缺少 -parameters javac 选项:Java 反射返回通用名称如 arg0、arg1 等。设置 name 可以恢复有意义的名称。
  2. 为 LLM 自定义名称:当你希望 LLM 看到与开发人员在源代码中使用的名称不同的参数名称时。

description 和 value 可以互换——它们都设置 LLM 将看到的参数描述。当只需要描述时,使用简写 value 形式:

1
2
3
4
@Tool("加法")
public int add(@P("第一个数字") int a, @P("第二个数字") int b) {
return a + b;
}

当同时需要名称和描述时,使用命名属性:

1
2
3
4
5
6
7
@Tool("发送邮件")
public void sendEmail(
@P(name = "recipient", description = "收件人邮箱") String to,
@P(name = "subject", description = "邮件主题") String subject
) {
// ...
}

类和字段的描述可以使用 @Description 注解指定:

1
2
3
4
5
6
7
8
@Description("表示一个用户实体")
public class User {
@Description("用户的唯一标识符")
private String id;

@Description("用户的显示名称")
private String name;
}

请注意,放置在 enum 值上的 @Description 没有效果,也不会包含在生成的 JSON Schema 中:

1
2
3
4
5
public enum Status {
@Description("任务已完成") // 此描述不会出现在 Schema 中
COMPLETED,
PENDING
}

InvocationParameters(调用参数)

如果你希望在调用 AI Service 时将额外数据传递到工具中,可以使用 InvocationParameters:

1
2
3
4
5
InvocationParameters params = new InvocationParameters();
params.put("userId", "user-123");
params.put("sessionId", "session-456");

assistant.chat("帮我查一下订单", params);

在这种情况下,LLM 不知道这些参数;它们仅对 LangChain4j 和用户代码可见。

InvocationParameters 也可以在 AI Service 的其他组件中访问,例如:

  • 工具方法(@Tool 注解的方法)
  • RAG 组件(如 ContentRetriever、RetrievalAugmentor)
  • 聊天记忆(ChatMemory)

参数存储在一个可变的、线程安全的 Map 中。

数据可以在 AI Service 的单个调用期间在 InvocationParameters 内的 AI Service 组件之间传递(例如,从一个工具到另一个工具,或从 RAG 组件到工具)。

类似地,@Tool 注解的方法可以接受 InvocationContext 参数以获取有关 AI Service 调用的信息:

1
2
3
4
5
@Tool("获取用户信息")
public String getUserInfo(InvocationContext context) {
String userId = (String) context.invocationParameters().get("userId");
return userService.findById(userId);
}

如果你的 AI Service 方法有一个用 @MemoryId 注解的参数,你也可以用 @ToolMemoryId 注解 @Tool 方法的参数:

1
2
3
4
5
6
7
8
9
10
interface Assistant {
String chat(@MemoryId String memoryId, String message);
}

class MyTools {
@Tool("保存笔记")
public void saveNote(@ToolMemoryId String memoryId, String note) {
// memoryId 会自动从 Assistant.chat() 传递过来
}
}

提供给 AI Service 方法的值将自动传递给 @Tool 方法。如果你有多个用户和/或每个用户有多个聊天/记忆,并且希望在 @Tool 方法中区分它们,这个功能很有用。


并发工具执行

默认情况下,当 LLM 一次调用多个工具时(也称为并行工具调用),AI Service 会顺序执行它们。如果你希望工具并发执行,可以在构建 AI Service 时调用 executeToolsConcurrently() 或 executeToolsConcurrently(Executor):

1
2
3
4
5
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(calculator)
.executeToolsConcurrently() // 启用并发执行
.build();

如果启用这些选项之一,工具将使用默认或指定的 Executor 并发执行。


访问已执行的工具

如果你希望在 AI Service 调用期间访问已执行的工具,可以通过将返回类型包装在 Result 类中轻松实现:

1
2
3
Result<String> result = assistant.chat("37 加 87 等于多少?");
System.out.println("响应: " + result.content());
System.out.println("工具执行: " + result.toolExecutions());

在流式模式下,你可以通过指定 onToolExecuted 回调来实现:

1
2
3
4
5
assistant.chat("37 加 87 等于多少?",
onToolExecuted(toolExecution -> {
System.out.println("工具已执行: " + toolExecution.toolName());
})
);

以编程方式指定工具

使用 AI Service 时,也可以通过编程方式指定工具。这种方法提供了很大的灵活性,因为工具可以从外部来源(如数据库和配置文件)加载。

工具名称、描述、参数名称和描述都可以使用 ToolSpecification 进行配置:

1
2
3
4
5
6
7
ToolSpecification toolSpec = ToolSpecification.builder()
.name("get_weather")
.description("获取指定城市的天气信息")
.parameters(JsonObjectSchema.builder()
.addStringProperty("city", "城市名称")
.build())
.build();

对于每个 ToolSpecification,需要提供一个 ToolExecutor 实现来处理 LLM 生成的工具执行请求:

1
2
3
4
ToolExecutor executor = (request, context) -> {
String city = request.arguments().get("city").asText();
return weatherService.getWeather(city);
};

LangChain4j 还提供了 DefaultToolExecutor,它可以自动调用 Java 对象上的方法并处理参数映射:

1
ToolExecutor executor = new DefaultToolExecutor(calculatorObject);

一旦我们有了一个或多个(ToolSpecification,ToolExecutor)对,我们将每对包装在 AiServiceTool 中并传递给 AI Service:

1
2
3
4
5
6
7
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(List.of(
new AiServiceTool(toolSpec1, executor1),
new AiServiceTool(toolSpec2, executor2)
))
.build();

动态工具(ToolProvider)

使用 AI Service 时,也可以为每次调用动态指定工具。你可以配置一个 ToolProvider,每次调用 AI Service 时都会调用它,并提供应该包含在当前请求中的工具。ToolProvider 接受 ToolProviderRequest(包含 UserMessage、聊天记忆 ID 和 InvocationParameters),并返回包含当前 AI Service 调用工具的 ToolProviderResult。

以下是仅当用户消息包含”booking”一词时才添加 get_booking_details 工具的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
ToolProvider toolProvider = (request) -> {
if (request.userMessage().text().contains("booking")) {
return ToolProviderResult.builder()
.add(bookingToolSpec, bookingExecutor)
.build();
}
return ToolProviderResult.builder().build();
};

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.toolProvider(toolProvider)
.build();

可以在同一 AI Service 调用中混合使用静态(包括 @Tool 注解的方法和以编程方式配置的工具)和动态指定的工具。在这种情况下,所有静态和动态工具会合并在一起。

在构建 ToolProviderResult 时,你可以使用 ToolProviderResult.builder() 将工具标记为立即返回。.add(ToolSpecification, ToolExecutor, ReturnBehavior) 重载接受 TO_LLM、IMMEDIATE 或 IMMEDIATE_IF_LAST 中的任意一个。


工具搜索(Tool Search)

当使用大量工具时,在每个请求中发送所有工具可能会显著增加 token 使用量并降低模型性能。为了解决这个问题,LangChain4j 提供了一种工具搜索机制,允许工具由 LLM 自身动态发现,而不是预先暴露所有工具。

核心思想很简单:

  1. 不是将所有工具发送给 LLM,而是只发送一个特殊的”工具搜索”工具
  2. LLM 可以使用这个搜索工具来查找与其任务相关的工具
  3. 找到的工具随后对 LLM 可见,可用于后续交互

这使得可扩展的、token 高效的、由模型驱动的工具发现成为可能。

工具搜索流程通常如下所示:

  1. LLM 收到用户的请求以及工具搜索工具
  2. LLM 决定调用工具搜索工具,并提供搜索条件
  3. ToolSearchStrategy 根据搜索条件查找匹配的工具
  4. 匹配的工具被添加到 LLM 可见的工具集合中
  5. LLM 现在可以调用新发现的工具
  6. 如果需要,LLM 可以再次搜索更多工具

之前找到的工具会在多次工具搜索调用中累积。每次 LLM 调用工具搜索工具时,新匹配的工具会被添加到 LLM 可见的现有工具集合中(它们是合并的,而不是替换的)。这意味着 LLM 可见的工具列表可以随时间增长。找到的工具保持对 LLM 可见,直到其对应的 ToolExecutionResultMessage 从 ChatMemory 中淘汰,并且至少到 AI Service 调用结束。

如果未配置 ChatMemory,找到的工具仅在 AI Service 调用期间对 LLM 可见。

ToolSearchStrategy 接口

工具搜索通过 ToolSearchStrategy 接口实现:

1
2
3
4
5
6
public interface ToolSearchStrategy {
/**
* 根据请求查找匹配的工具
*/
ToolSearchResult search(ToolSearchRequest request);
}

ToolSearchStrategy 负责:

  • 接收 LLM 的搜索请求(包含搜索条件)
  • 查找与条件匹配的工具
  • 返回匹配的工具集合

LangChain4j 目前提供了 2 种开箱即用的实现:

  1. AllToolsToolSearchStrategy:使所有工具对 LLM 可见(相当于禁用工具搜索)
  2. KeywordMatchingToolSearchStrategy:基于关键词匹配查找工具

有关这些类的更多详细信息,请参阅 Javadoc。

你也可以实现自定义策略。

配置工具搜索

工具搜索在 AI Service 级别配置:

1
2
3
4
5
6
7
ToolSearchStrategy strategy = new KeywordMatchingToolSearchStrategy(toolRegistry);

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(allTools)
.toolSearchStrategy(strategy)
.build();

配置后:

  • 只有工具搜索工具会被初始发送给 LLM
  • LLM 可以按需搜索和发现工具
  • 找到的工具在调用期间保持可见

始终可见的工具

工具搜索依赖于 LLM 理解何时以及如何搜索工具的能力。该功能的有效性在很大程度上取决于所选的模型。

某些工具可能应该始终对 LLM 可见,无论搜索条件如何。典型用例:

  • 核心工具:如数学计算、时间查询等基础工具
  • 高频工具:LLM 几乎每次都会用到的工具
  • 搜索工具本身:工具搜索工具必须始终可见

LangChain4j 通过 ALWAYS_VISIBLE 工具搜索行为支持这一点。

当工具被标记为 ALWAYS_VISIBLE 时:

  • 该工具在初始请求中立即对 LLM 可见
  • 它不需要通过工具搜索来发现
  • 所有其他工具继续遵循正常的工具搜索流程

你可以通过 @Tool 注解将工具标记为始终可见:

1
2
3
4
@Tool(value = "计算平方根", searchBehavior = ToolSearchBehavior.ALWAYS_VISIBLE)
public double squareRoot(@P("数字") double number) {
return Math.sqrt(number);
}

使用 MCP 工具(通过 McpToolProvider)时,可以通过 alwaysVisibleToolNames 配置始终可见的工具:

1
2
3
McpToolProvider provider = new McpToolProvider.Builder()
.alwaysVisibleToolNames("calculator", "current_time")
.build();

如果以编程方式配置工具,可以使用 metadata 将其标记为始终可见:

1
2
3
4
5
ToolSpecification spec = ToolSpecification.builder()
.name("calc")
.description("计算器")
.addMetadata("searchBehavior", "ALWAYS_VISIBLE")
.build();

何时使用工具搜索

工具搜索在以下情况下特别有用:

  • 你有大量工具(例如,50+ 个工具)
  • 每次调用只有一小部分工具是相关的
  • 你想减少 token 消耗
  • 你的工具集可能随时间变化

如果你只有少量工具,或者所有工具都始终相关,使用常规方法可能更简单。


立即返回(Immediate Return)

默认情况下,工具执行请求的结果会被发送回 LLM,LLM 使用该结果并进一步重新处理。然而,在某些情况下,工具执行请求产生的结果已经代表了 AI Service 调用的预期结果。在这种情况下,可以配置工具以立即/直接返回其结果,跳过 LLM 浪费资源且消耗资源的重新处理。这可以通过配置 @Tool 注解的 returnBehavior 字段来实现,如以下示例:

1
2
3
4
5
6
7
@Tool(value = "加法", returnBehavior = ReturnBehavior.IMMEDIATE)
public int add(
@P("第一个数字") int a,
@P("第二个数字") int b
) {
return a + b;
}

此功能仅适用于具有 Result 返回类型的 AI Service。尝试在不兼容的返回类型上使用它会产生 `IllegalConfigurationException。

ReturnBehavior 选项

行为 描述
TO_LLM(默认) 工具结果发送回 LLM 进行进一步处理
IMMEDIATE 工具结果立即返回,不发送回 LLM
IMMEDIATE_IF_LAST 如果工具是 LLM 响应中的最后一个工具调用,则立即返回

IMMEDIATE_IF_LAST 适用于 LLM 用来明确标识多步操作结束的工具——例如,LLM 在一系列点击、导航等操作之后附加的 endExecutionAndGetFinalResult 工具。

如果没有 IMMEDIATE_IF_LAST,LLM 通常需要两轮才能结束执行:一轮混合了工作工具(TO_LLM)和结束工具,第二轮 LLM 在看到所有结果后单独调用结束工具。循环仅在第二轮才立即返回。

使用 IMMEDIATE_IF_LAST,一旦 LLM 将工具放在响应的最后,循环就会立即返回——每次调用节省一整轮 LLM 交互。

对于 LLM 响应 [leftMouseClick, typeText, endExecutionAndGetFinalResult],循环会在执行所有三个工具后立即返回。如果 LLM 将结束工具放在最后以外的位置(例如 [endExecutionAndGetFinalResult, leftMouseClick]),循环将继续并将所有结果发送回 LLM。

IMMEDIATE_IF_LAST 也计入 IMMEDIATE 的”全部立即”规则——仅由 IMMEDIATE 和/或 IMMEDIATE_IF_LAST 工具组成的响应无论哪个是最后一个都会立即返回(仍受无错误规则约束)。

与 IMMEDIATE 一样,IMMEDIATE_IF_LAST 仅允许在具有 Result 返回类型的 AI Service 上使用。

当 LLM 在单个响应中返回多个工具调用时,循环仅在以下两个条件同时满足时才立即返回(不将结果发送回 LLM):

  1. 所有工具调用都具有 IMMEDIATE 或 IMMEDIATE_IF_LAST 返回行为
  2. 没有工具执行出错

示例(工具按 LLM 响应中的顺序列出;(err) 标记执行出错的工具):

响应中的工具 是否立即返回?
[add(IMMEDIATE), multiply(IMMEDIATE)] ✅ 是
[add(IMMEDIATE), multiply(TO_LLM)] ❌ 否,multiply 需要发送到 LLM
[add(IMMEDIATE), multiply(IMMEDIATE), end(IMMEDIATE_IF_LAST)] ✅ 是
[add(IMMEDIATE), multiply(IMMEDIATE, err)] ❌ 否,有错误发生

有关完整的立即返回与重新处理矩阵,请参阅 ReturnBehavior 的 Javadoc。


错误处理

幻觉工具调用(Hallucinated Tool Invocation)

LLM 可能会产生幻觉,即它要求使用一个不存在的工具名称。在这种情况下,默认情况下 LangChain4j 会抛出异常报告问题,但可以通过为 AI Service 配置策略来自定义此行为。

该策略是一个 Function 的实现,定义当收到调用不可用工具的请求时应生成哪个 ToolExecutionResultMessage。例如,可以配置 AI Service 使用一个策略,向 LLM 返回一个响应,希望促使它重试不同的工具调用:

1
2
3
4
5
6
7
8
9
10
assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(calculator)
.hallucinatedToolHandler((request, context) -> {
return ToolExecutionResultMessage.from(
request,
"Tool '" + request.toolName() + "' is not available. Please use a different tool."
);
})
.build();

工具参数错误(Tool Arguments Error)

默认情况下,当工具参数出现问题时(例如,LLM 生成了无效的 JSON 或遗漏了必需参数),AI Service 将无法执行工具,因此会失败并抛出异常。

当前的默认行为(抛出异常)很少是你想要的。参数错误通常来自 LLM,当给定清晰的错误消息时,LLM 通常可以自我纠正。配置一个 ToolArgumentsErrorHandler 来返回错误文本,以便 LLM 可以使用更正的参数重试。

我们计划在 LangChain4j 2.0 中将默认行为更改为让 LLM 重试。如果这一计划中的变更会影响你的用例,请开启一个 issue。

你可以通过以下方式自定义此行为:

1
2
3
4
5
6
7
8
assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(calculator)
.toolArgumentsErrorHandler((arguments, error, context) -> {
// 返回错误信息,让 LLM 重试
return "Invalid arguments: " + error.getMessage() + ". Please check your input.";
})
.build();

目前,在 ToolArgumentsErrorHandler 中有两种处理错误的方式:

  1. 推荐(让 LLM 重试):返回错误文本,LLM 会根据错误信息纠正参数并重试
  2. 严格(停止流程):抛出异常,终止 AI Service 调用

工具执行错误(Tool Execution Error)

默认情况下,当用 @Tool 注解的方法抛出 Exception 时,Exception 的消息(e.getMessage())将作为工具执行的结果发送给 LLM。这允许 LLM 纠正其错误并在必要时重试。

当前的默认行为将原始异常消息发送回 LLM。在生产环境中,这可能会泄露内部应用程序数据:堆栈跟踪、文件路径、嵌入在错误字符串中的凭据、下游 API 响应、错误消息中的 PII 等。一旦输入到 LLM,这些内容可能会流入响应、聊天历史、可观测性管道和 LLM 提供商的日志中。

配置一个 ToolExecutionErrorHandler,返回通用消息或经过整理/净化的失败描述,并依赖日志和可观测性事件来获取底层详细信息。

我们计划在 LangChain4j 2.0 中将默认行为更改为”抛出异常并中止 AI Service 调用”。如果这一计划中的变更会影响你的用例,请开启一个 issue。

你可以通过以下方式自定义此行为:

1
2
3
4
5
6
7
8
assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(calculator)
.toolExecutionErrorHandler((exception, context) -> {
// 返回通用错误消息,不泄露内部细节
return "An error occurred while executing the tool. Please try again.";
})
.build();

与 ToolArgumentsErrorHandler 一样,ToolExecutionErrorHandler 中也有两种处理错误的方式:抛出异常或返回文本消息。