跳到主要内容

数据截至 (上游 commit daa7624a2755)

工具调用:从 @Tool 反射到 round-trip 循环

30 秒导读: 大模型只会输出文字,不会真的查数据库、发邮件。这一章讲 LangChain4j 怎么把一个普通 Java 方法变成模型能「调用」的工具:先用反射把方法签名翻成 JSON Schema 发给模型,再用一个循环把模型吐出的「调用请求」落成真实的方法调用,并把结果塞回对话继续下一轮。

同组其它章:核心抽象层 讲模型接口与消息模型,AiServices 讲一个 Java 接口怎么变成一次 LLM 调用,本章是 AiServices 里「工具」那一支的深挖。


1. 这是什么(零基础也能懂)

一句话定义: 工具调用(tool calling / function calling)= 让模型在回答之前,先说「我要调用 getWeather("上海")」,由框架真的去执行这个方法,再把结果喂回模型。

解决什么问题。 模型的知识是训练时冻结的,而且它没有手脚。你问「上海今天几度」,它只能编。给它装一个 getWeather 工具,它就能先要数据、再回答。

给谁用。 任何写 Java 后端、想让 LLM 触碰真实系统(数据库、内部 API、文件、第三方 SaaS)的人。

它能做什么(功能清单):

  • @Tool 注解的方法自动变成模型能看见的工具描述。
  • 自动解析模型返回的 JSON 参数,强转成 Java 类型,调用方法。
  • 自动跑多轮:模型可以连着调好几个工具,直到它满意为止。
  • 工具报错时把错误文本回喂给模型,让它自己纠错重试。
  • 工具太多时,让模型先「搜工具」再调工具。
  • 工具不一定来自你的代码——可以来自 MCP 服务器,或来自 Agent Skills。

用起来什么样。 一个最小可用例子:

// 示意,非源码
class WeatherTools {
@Tool("返回某个城市当前气温,单位摄氏度")
int getTemperature(@P("城市名,如 Shanghai") String city) {
return 27; // 真实实现会去查 API
}
}

interface Assistant {
String chat(String userMessage);
}

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(new WeatherTools()) // 就这一行,方法变成工具
.build();

assistant.chat("上海今天热吗?");

你写的只有 @Tool 那一行。剩下的——生成 schema、发给模型、解析模型的调用请求、反射调用、把结果写回对话——全在框架里。

一句话直觉。 把工具调用想成远程过程调用,只不过调用方是个不太靠谱的实习生:它可能把参数名拼错、可能调一个根本不存在的方法、可能一次调五个。所以框架的绝大部分代码不是「怎么调」,而是「它乱来时怎么办」。


2. 顶层全景(它大概怎么转)

整条链路分两个阶段:装配期(把工具变成 schema)和运行期(round-trip 循环)。

怎么读下面这张图:从左到右是装配期,工具从三种来源汇入同一个 ToolServiceContext;右边的循环是运行期。

装配期(每次 AI Service 调用开始时跑一遍)
┌──────────────┐
│ ① 你的 @Tool │──反射──┐
│ 注解方法 │ │
└──────────────┘ │
┌──────────────┐ ├──▶ ┌───────────────────┐
│ ② ToolProvider│───────┤ │ ToolServiceContext │
│ (MCP/Skills)│ │ │ effectiveTools │ ← 这轮真发给模型的
└──────────────┘ │ │ availableTools │ ← 全部候选(可被搜到)
┌──────────────┐ │ │ toolExecutors │ ← 名字 → 执行器
│ ③ 手动注册的 │───────┘ │ returnBehaviors │ ← 名字 → 返回策略
│ Spec+Executor│ └───────────────────┘
└──────────────┘
运行期(executeInferenceAndToolsLoop)
┌───────────────────────────────────────────┐
│ │
▼ │
发 ChatRequest ──▶ 模型回 AiMessage ──▶ 有工具调用? ─否─▶ 结束,返回最终回答
▲ │是
│ ▼
│ 执行工具(顺序或并发)
│ │
│ ▼
│ 结果写回 chatMemory
│ │
│ ▼
│ 该提前返回吗?─是─▶ 直接把工具结果给调用方
└──── 重算 effectiveTools ◀────否───┘

部件一句话职责:

部件干什么在哪个文件
ToolSpecifications反射:@Tool 方法 → ToolSpecification + JSON Schemalangchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java
ToolService装配所有工具来源 + 跑 round-trip 循环langchain4j/src/main/java/dev/langchain4j/service/tool/ToolService.java
ToolServiceContext一次调用内的工具快照(effective / available / executors).../service/tool/ToolServiceContext.java
DefaultToolExecutor把 JSON 参数强转成 Java 参数并反射调用.../service/tool/DefaultToolExecutor.java
ToolSearchService工具太多时,让模型先搜再用.../service/tool/search/ToolSearchService.java
McpToolProvider从 MCP 服务器拉工具langchain4j-mcp/src/main/java/dev/langchain4j/mcp/McpToolProvider.java
Skills把 Agent Skills 变成 activate_skill 等工具langchain4j-skills/src/main/java/dev/langchain4j/skills/Skills.java

主线走一遍(高层)。 DefaultAiServices 在发第一个请求前调 ToolService.createContext(...) 拿到工具快照(DefaultAiServices.java:283-284),把 effectiveTools() 塞进 ChatRequestParameters;拿到第一个 ChatResponse 后,交给 executeInferenceAndToolsLoop 接管(DefaultAiServices.java:345-354),循环结束后再走解析/护栏那一套。


3. 核心原理

3.1 注解到 schema:反射怎么写出 JSON Schema

要解决的小问题。 模型只认 JSON Schema。你写的是 Java 方法签名。中间要有一次翻译。

思路。 一个 @Tool 方法 = 工具名 + 描述 + 一个 JsonObjectSchema 参数对象。方法名当工具名,注解文本当描述,每个参数当 schema 的一个 property。

入口是 toolSpecificationFrom(ToolSpecifications.java:126-133),四件事一次做完:

return ToolSpecification.builder()
.name(getName(tool, method)) // 注解没写 name 就用方法名
.description(getDescription(tool)) // @Tool 的 value() 用 \n 拼接
.parameters(parametersFrom(method.getParameters()))
.metadata(getMetadata(tool)) // 解析 @Tool(metadata="{...}") JSON
.build();

真正有意思的是 parametersFrom(ToolSpecifications.java:153-243)。它做三件事,依次讲。

第一件:跳过框架注入的参数。 有些参数不是给模型填的,是框架自己塞的,不能出现在 schema 里(ToolSpecifications.java:161-165):

参数形态谁来填
@ToolMemoryId Object memoryId框架填当前 chat memory id
InvocationParameters 及其子类框架填本次调用的自定义参数
LangChain4jManaged 的实现类框架从 context.managedParameters()
InvocationContext框架填整个调用上下文

对应的填值逻辑在 DefaultToolExecutor.prepareArguments(DefaultToolExecutor.java:211-261),两边一一对应。

第二件:必填性怎么算。 这是最容易踩的一条规则,源码只有五行(ToolSpecifications.java:168-175):

boolean isOptional = Optional.class.equals(parameter.getType());
boolean hasDefaultValue = pAnnotation != null && !P.NO_DEFAULT.equals(pAnnotation.defaultValue());
boolean isRequired = !isOptional && !hasDefaultValue
&& Optional.ofNullable(pAnnotation).map(P::required).orElse(true);

翻成表:

参数写法出现在 required 数组里吗模型不给值时
String city(无注解)对象类型传 null;原始类型抛异常
@P(required = false) Integer nnull
Optional<String> unitOptional.empty()
@P(defaultValue = "5") int limit传解析后的默认值 5

三条要点:

  • @P.required 默认是 true(P.java:102),所以不写注解 = 必填
  • defaultValue 一旦设置就等价于 required=false——它只是把「缺省时填什么」从 null 换成你给的值。哨兵常量是 P.NO_DEFAULT(P.java:139),一串带 \0 的怪字符串,用来区分「没设默认值」和「默认值是空串」。
  • 官方明说 1.x 有一个不对称:必填参数缺失时,原始类型会抛 ToolArgumentsException,对象类型只是悄悄传 null(P.java:88-98 的 javadoc),计划在 2.0 统一。

第三件:参数名与递归类型。 参数名优先取 @P(name=...),否则取反射拿到的名字(ToolSpecifications.java:177-180)。这里有个 Java 特有的坑:不加 -parameters 编译选项时,反射只能拿到 arg0arg1,模型会一脸茫然;Quarkus / Spring 默认开了这个选项,裸 Maven 项目往往没开(P.java:30-40)。

每个参数的类型翻译交给 jsonSchemaElementFrom(ToolSpecifications.java:244-277)。它先处理描述——@Pvalue()description() 是同一件事的两个别名,同时写两个直接抛异常(ToolSpecifications.java:250-254);再拆 Optional<T> 的泛型参数,拿到真实类型(ToolSpecifications.java:265-274);最后委托给 JsonSchemaElementUtils.jsonSchemaElementFrom(签名见 langchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.java:48-53)去递归展开 POJO。

递归展开要处理自引用类型(Node 里有 Node parent)。办法是一张 visited 表:VisitedClassMetadata 上有个 recursionDetected 标记(JsonSchemaElementUtils.java:579-588),一旦某个类被检测到递归,就把它提到 schema 的 definitions 里,用 $ref 引用(ToolSpecifications.java:195-209)。

两个收尾细节:

  • 一个参数都没有(或全被跳过)时,parameters 返回 null 而不是空对象(ToolSpecifications.java:202-203)——无参工具的 schema 是「没有参数」,不是「有一个空参数对象」。
  • 同一个类里两个工具重名会在 validateSpecifications 里抛 IllegalArgumentException(ToolSpecifications.java:107-124)。

3.2 工具从哪来:装配与动态刷新

要解决的小问题。 工具可能来自你 new 出来的对象,也可能来自远端 MCP 服务器,还可能「这一轮才该出现」。得有个统一收口。

三种来源,三个入口:

来源APIToolService 里的落点
注解对象.tools(new WeatherTools())tools(Collection<Object>),ToolService.java:143-152
提供器.toolProvider(mcpToolProvider)toolProvider(...),ToolService.java:116-120
手写 spec + executor.tools(Map<ToolSpecification, ToolExecutor>)tools(Map),ToolService.java:131-136

注解那条路的核心是 findTools(ToolService.java:233):扫所有具体方法,找到 @Tool 的,先跑 validateToolParameters 做配置期校验,再产出一个 AiServiceTool(spec + executor + returnBehavior 三件套,AiServiceTool.java:21-49)。

validateToolParameters(ToolService.java:158-231)是纯粹的早失败设计,四条禁令在启动时就报错,而不是等模型调用时才炸:

违规写法为什么禁
@P(required=false) int n原始类型没法表达「缺失」,建议改 Integer / Optional / 给默认值
@P(defaultValue=...) Optional<T> x两套「可缺省」机制打架,只能选一个
@P(defaultValue=...) 加在 @ToolMemoryId 等框架参数上框架参数不由模型填,默认值无意义
defaultValue 字符串解析不出目标类型直接调 DefaultToolExecutor.parseDefaultValue 试解析,失败就抛

createContext(ToolService.java:457-464)是每次 AI Service 调用的装配入口,三步顺序很讲究:

createContextFromStaticToolsAndProviders ← 静态工具 + 非 dynamic 的 provider 全部展开


toolSearchService.adjust(...) ← 若配了工具搜索:把大部分工具从 effective 里摘掉


refreshDynamicProviders(...) ← dynamic provider 现在跑一次,拿到本轮该有的工具

关键区分在 ToolServiceContext 的两个列表(ToolServiceContext.java:53-78):

  • effectiveTools() —— 这一次请求真发给模型的工具。
  • availableTools() —— 配置过的全部工具,可以被工具搜索「找到」后再进 effective。

没配工具搜索时两者相同(ToolService.java:460-463)。

ToolProvider 分静态和动态,靠 isDynamic() 区分(ToolProvider.java:42-44)。静态的一次调用只跑一次;动态的每轮 LLM 调用前都重跑(ToolService.refreshDynamicProviders,ToolService.java:776-830)。它有一条明确的单调性约定,写在 javadoc 里(ToolService.java:766-775):

动态 provider 返回的工具只增不减——一旦某个工具进了 context,这次 AI service 调用的余下过程它都在。

实现上靠 if (!newToolExecutors.containsKey(tool.name())) 去重(ToolService.java:811),并且没变化时返回原对象而不是新建(ToolService.java:821-823),省掉无谓的拷贝。

工具重名的兜底在 addTools(ToolService.java:512-524):用 putIfAbsent 检测,撞名直接抛 IllegalConfigurationException

3.3 核心循环:executeInferenceAndToolsLoop

这是整章的心脏,一个 while (true),全文在 ToolService.java:548-692

它要解决的小问题。 模型可能连续调好几轮工具。每一轮都要:执行、把结果写回对话、决定要不要再问一次模型。

循环体逐段拆(按源码顺序):

① 熔断 roundTripsLeft-- == 0 → 抛异常 ToolService.java:566-570
② 记消息 aiMessage 写进 chatMemory 或 messages ToolService.java:574-578
③ 出口 没有 toolExecutionRequests → break ToolService.java:586-591
④ 执行 execute(requests, executors, ctx) ToolService.java:588-589
⑤ 归集 逐个转 ToolExecutionResultMessage、
发 ToolExecutedEvent、记 returnBehavior ToolService.java:593-624
⑥ 补偿 有错且有可补偿的 → 回滚 + 改写记忆 ToolService.java:626-630
⑦ 写回 结果消息进 chatMemory ToolService.java:637-642
⑧ 提前返回? shouldReturnImmediately → 直接 return ToolService.java:645-652
⑨ 重算工具 refreshDynamicProviders + addFoundTools ToolService.java:656-668
⑩ 再问一次 chatModelInvoker.apply(chatRequest) ToolService.java:679-682

四个值得单独说的点:

熔断。 maxToolCallingRoundTrips 默认 100(ToolService.java:99),超了直接抛「Something is wrong, exceeded N tool calling round trips」。注意它数的是模型返回带工具调用的轮数,不是工具个数——一轮里模型调五个工具只算一次。

记忆写回的双轨。chatMemorychatMemory.add(...);没有就往本地 messages 列表加,并且先复制一份再加(ToolService.java:574-578),避免污染调用方传进来的列表。

每轮重算 effectiveTools。 循环底部把新的 effectiveTools() 覆盖进 ChatRequestParameters(ToolService.java:666-668)。这是「工具集合会随对话演进」的落点——动态 provider 新增的工具、工具搜索刚找到的工具,都在这里进入下一次请求。

同一处还有一个 RAG 相关的细节:当 storeRetrievedContentInChatMemory 为 false 时,发给模型的消息里最后那条 user message 会被换回原始用户消息,而不是带检索内容的增强版(ToolService.java:681-682,用 UserMessage.replaceLast)——检索到的大段上下文只在第一轮用,不重复占后续轮次的 token。

token 用量累加。 aggregateTokenUsage = TokenUsage.sum(aggregateTokenUsage, ...)(ToolService.java:681-682),所以 Result.tokenUsage() 是整个多轮过程的总和,不是最后一次的。

3.4 ReturnBehavior:什么时候不再问模型

要解决的小问题。 有些工具的返回值就是最终答案(比如「生成一张图」「下单成功返回订单号」)。再让模型复述一遍既慢又可能失真。

三种取值:

取值含义
TO_LLM默认。结果回喂模型,循环继续
IMMEDIATE本轮所有工具都是 IMMEDIATE/IMMEDIATE_IF_LAST 时,直接返回
IMMEDIATE_IF_LAST这个工具排在本轮最后一个时,直接返回

判定函数短得可以整段看,shouldReturnImmediately(ToolService.java:753-764):

if (anyToolErrored) return false; // 有错必须回喂,让模型自己修
if (returnBehaviors.isEmpty()) return false;
if (last == IMMEDIATE_IF_LAST) return true; // 规则一:看最后一个
return all(rb -> rb == IMMEDIATE || rb == IMMEDIATE_IF_LAST); // 规则二:全员豁免

两条规则,ReturnBehavior.java:14-43 的 javadoc 给了完整 18 行真值表。挑三行体会差异:

本轮工具的行为序列结果命中哪条规则
[TO_LLM, IMMEDIATE_IF_LAST]立即返回规则一:最后一个是 IF_LAST
[IMMEDIATE_IF_LAST, TO_LLM]继续循环两条都不满足
[IMMEDIATE, IMMEDIATE_IF_LAST]立即返回规则二:全是豁免类

提前返回后返回什么? 循环把 immediateToolReturn(true) 打进结果(ToolService.java:652),真正决定返回值的是 DefaultAiServices.java:356-404,逻辑是一棵小决策树:

immediateToolReturn == true
├─ 返回类型是 Result<T>? ──▶ 包一个 content=null、finishReason=TOOL_EXECUTION 的 Result
├─ 返回类型是 void? ──▶ 返回 null
└─ 其它类型
├─ 所有 resultObject 都是 null ──▶ 返回 null
├─ 恰好一个非 null 且类型能对上 returnType ──▶ 返回那个对象
└─ 否则 ──▶ 抛 illegalConfiguration,
提示你把返回类型改成 Result

注意 ReturnBehavior 的 javadoc 说 IMMEDIATE 系「只允许用在返回 Result 的 AI service 上」(ReturnBehavior.java:48-51),而实现比这宽松一点:void 和「单个非 null 且类型匹配」这两种也能过,只有落到最后一档才抛异常。

3.5 执行器与容错

要解决的小问题。 模型给的是一坨 JSON 字符串,方法要的是 intUUID、枚举、POJO。而且模型可能给错、方法可能抛异常、工具名可能是幻觉。

参数强转。 DefaultToolExecutor.prepareArguments(DefaultToolExecutor.java:211-262)按参数逐个处理,核心分支在 DefaultToolExecutor.java:245-258:

参数是 Optional<T>? ──▶ createOptional:null → empty,否则强转后包起来
参数值非 null? ──▶ coerceArgument 强转
参数值是 null
├─ 有 @P(defaultValue) ──▶ parseDefaultValue
├─ 是原始类型 ──▶ 抛「Required parameter ... is missing」
└─ 其它 ──▶ 保持 null

coerceArgument(DefaultToolExecutor.java:321-408)是一长串类型分派,里面几处宽容设计值得注意:

  • 枚举先按原样 Enum.valueOf,失败了再试一次大写(DefaultToolExecutor.java:330-338)——模型爱写小写。
  • 数字允许字符串形态,"27" 能转成 int(DefaultToolExecutor.java:410-424getDoubleValue);但整数类会先检查没有小数部分、再检查边界(getBoundedLongValue,DefaultToolExecutor.java:435-444),27.5int 会明确报错而不是悄悄截断。
  • 布尔反而不宽容:"true" 字符串直接抛异常,必须是真的 JSON boolean(DefaultToolExecutor.java:348-355)。
  • POJO / 集合统一走「转成 JSON 再转回目标类型」(DefaultToolExecutor.java:393-396404-407),借 Jackson 做类型对齐。

parseDefaultValue(DefaultToolExecutor.java:298-319)的规则稍微特别:String / 枚举 / UUID 按字面量用;其它类型先当 JSON 解析再强转。所以 @P(defaultValue = "[]") 给的是空列表,@P(defaultValue = "[]")String 参数则是字面的两个方括号。

返回值怎么变成给模型的文本。 toText(DefaultToolExecutor.java:197-209)三条规则:void → 字符串 "Success";String → 原样(null 变成字符串 "null");其它 → JSON 序列化。另外如果返回的是 Image / Content / Content 集合,会走 toContents 变成多模态内容而不是文本(DefaultToolExecutor.java:179-195)。

三层错误处理。 三种失败,三个处理点:

失败类型触发点默认行为怎么改
工具名不存在(幻觉)toolExecutors.get(name) == null,ToolService.java:967-969HallucinatedToolNameStrategy.THROW_EXCEPTION 抛异常中断AiServices.hallucinatedToolNameStrategy(...)
参数解析失败ToolArgumentsException,ToolService.java:1001-1002原样重抛,中断整个调用AiServices.toolArgumentsErrorHandler(...)
方法体抛异常其它异常,ToolService.java:1003-1004打一条 warn 日志,把异常 message 当结果回喂模型AiServices.toolExecutionErrorHandler(...)

两个默认处理器就写在类顶部(ToolService.java:72-89),对比很能说明设计意图:参数错 = 你的 schema 或模型有硬问题,fail fast;执行错 = 业务偶发失败,让模型看到错误自己重试。默认的执行错误处理器返回 ToolErrorHandlerResult.text(errorMessage),循环随后把它包成 isError=true 的结果消息(ToolService.java:1007-1011),而 shouldReturnImmediately 见到任何 error 都强制继续循环(ToolService.java:730-733)——错误必然被模型看到。

统一入口是 executeWithErrorHandling(ToolService.java:958-1010),注意它传给 handler 的是 getCause(e)(剥了一层的 cause),原始异常放在 ToolErrorContext.rawError() 里备查(ToolExecutionErrorHandler.java:32-34)。

并发执行。 开关是 AiServices.executeToolsConcurrently(),落到 ToolService.executeToolsConcurrently(ToolService.java:348-357),默认用 DefaultExecutorProvider 的共享线程池。分派逻辑有个小优化(ToolService.java:839-844):

if (executor != null && toolRequests.size() > 1) { // 只有一个工具时不值得开线程
return executeConcurrently(...);
} else {
return executeSequentially(...);
}

并发实现用 LinkedHashMap 存 future 再按序 get(ToolService.java:850-868),所以结果顺序仍然等于模型给出的调用顺序——这对 IMMEDIATE_IF_LAST 的「最后一个」语义是必要前提。

3.6 事务式补偿:@CompensateFor

要解决的小问题。 模型一轮调了三个工具:扣库存、扣款、发货。第三个失败了,前两个已经生效。怎么办?

思路。 这是分布式事务里的 Saga 补偿模式(每个正向操作配一个反向操作,失败时逆序执行反向操作)。LangChain4j 的做法是加一个 @CompensateFor("toolName") 注解方法。

// 示意,非源码
@Tool("扣减库存")
void reserveStock(String sku, int qty) { ... }

@CompensateFor("reserveStock") // 参数类型必须和被补偿工具一致
void releaseStock(String sku, int qty) { ... }

装配期做的校验findCompensatingActions(ToolService.java:272-343),四道关:

检查位置
引用的工具名必须存在ToolService.java:282-291
被补偿的必须是 @Tool 注解方法(DefaultToolExecutor),MCP 之类的动态工具不支持ToolService.java:292-301
补偿方法的参数类型必须和工具一致,或者只有一个 ToolExecution 参数ToolService.java:303-319
校验失败时先把异常存起来,只有真开了 compensateOnToolErrors(true) 才抛ToolService.java:265-270283-288

最后一条是个体贴设计:没开补偿功能的人,不会因为一个写错的 @CompensateFor 而启动失败。

运行期三步走(触发条件:本轮有工具报错,且有已成功执行的可补偿工具,ToolService.java:626-631):

compensateToolsActions 逆序遍历已成功的可补偿执行,逐个调补偿方法
ToolService.java:716-727 补偿本身失败只 log.warn,不打断


rewriteChatMemoryForCompensatedTools 按 message id 定位历史里的工具结果消息,
ToolService.java:683-702 整条替换成「已回滚」文本,chatMemory.set 写回


rewriteCurrentResults 本轮还没写进记忆的结果消息,同样替换
ToolService.java:670-681

妙在哪。 补偿不只是「撤销副作用」,还改写了模型看到的历史。替换文本是:

Tool 'X' was executed successfully but was rolled back due to failure of tool 'Y'

并且把 isError 置为 true(rolledBackResultMessage,ToolService.java:706-726)。所以下一轮模型看到的不是「扣库存成功、扣款失败」,而是「扣库存已回滚、扣款失败」——它不会基于一个已经不成立的事实继续推理。

顺序上也有讲究:先改写记忆(处理历史轮次的消息),再改写本轮的 resultMessages,然后才把本轮结果写进记忆(ToolService.java:628611614-620)。两批消息一个在记忆里、一个还在手上,所以要两套代码。

3.7 工具太多怎么办:ToolSearchStrategy

要解决的小问题。 几百个工具全塞进 prompt,又贵又让模型挑花眼。

思路。 别把工具目录全给模型,给它一个**「搜工具」的工具**。模型先搜,搜到了再调。这是把 RAG 的思路套到工具上。

怎么转:

第 1 轮请求 effectiveTools = [tool_search_tool] + 标了 ALWAYS_VISIBLE 的工具

▼ 模型调 tool_search_tool(terms=["weather","forecast"])
ToolSearchExecutor 跑 strategy.search(...)
│ 结果里带一个 attributes: {"found_tools": ["getWeather", ...]}

addFoundTools 把找到的工具塞进 effectiveTools


第 2 轮请求 effectiveTools = 上一轮 + getWeather... 模型现在能调它了

装配侧是 ToolSearchService.adjust(ToolSearchService.java:45-56),三步:

  1. calculateEffectiveTools(ToolSearchService.java:58-109):只放行元数据里 searchBehavior == ALWAYS_VISIBLE 的工具,加上搜索工具本身,再加上历史消息里已经搜到过的工具。
  2. calculateSearchableTools(ToolSearchService.java:111-116):可用工具减去已生效工具 = 可被搜的池子。
  3. 给搜索工具挂一个 ToolSearchExecutor(ToolSearchService.java:169-198),它把搜索结果的工具名写进 ToolExecutionResultattributes,key 是 "found_tools"

这个 attribute 是整套机制的接力棒:循环里 ToolSearchService.addFoundTools 读它把工具加进 effective(ToolService.java:664-666ToolSearchService.java:127-167);而下次全新的 AI service 调用,calculateEffectiveTools 又从历史 ToolExecutionResultMessage 的 attributes 里把它读回来(ToolSearchService.java:76-83)。源码里这个常量旁边留了注释「do not change, will break backward compatibility」(ToolSearchService.java:36-37)——因为它会被序列化进持久化的对话记忆。

两种现成策略:

策略模型给什么怎么打分文件
SimpleToolSearchStrategyterms: ["weather","city"] 词表名字命中 +2、描述命中 +1,累加后降序取 top-N(默认 5)langchain4j/src/main/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategy.java:96-132
VectorToolSearchStrategyquery: "查天气的工具" 自然语言name: description 拼成文本做向量检索langchain4j/src/main/java/dev/langchain4j/service/tool/search/vector/VectorToolSearchStrategy.java:107-143

向量版有个务实的小设计:它每次搜索都现建一个 InMemoryEmbeddingStore(VectorToolSearchStrategy.java:124-125),而不是维护一个长期索引——因为可搜工具集合每次都可能不同。代价是每次都要 embed 一遍所有工具描述,所以默认套一层 ToolCachingEmbeddingModel(VectorToolSearchStrategy.java:77-83)。

缓存实现有个跨两个类才成立的约定(ToolCachingEmbeddingModel.java:42-80):它把待 embed 的文本分成「命中缓存」和「没命中」,只把没命中的发给真模型,回来时用 if (i != 0) 跳过第一条不缓存。这个 i未命中列表的下标,不是原列表的。之所以能对,是因为调用方永远把查询放在原列表第 0 位(VectorToolSearchStrategy.java:111-112)、而查询永远不在缓存里,所以它必然是未命中列表的第 0 个。

这个约定源码写了一半:if (i != 0) 那行旁边就有说明注释 // index 0 is for search query, it should not be cached(ToolCachingEmbeddingModel.java:71),类 javadoc 也声明了「查询的向量永远不缓存」(:18-19)。没被写下来的是反向那一半——「调用方必须把查询放在第 0 位」这条,缓存类既不校验也没有签名约束。违反它的后果不只是多缓存一条:工具描述数量有限,而查询文本是无界的,一旦查询被写进这个从不自动清理的缓存,javadoc 里「工具数量有限所以内存泄漏风险极小」的前提就不成立了(:20-24)。

两个策略共有的一个反直觉默认:参数错误时它们抛的是 ToolExecutionException 而不是 ToolArgumentsException(SimpleToolSearchStrategy.java:176-184)。理由写在 builder 的 javadoc 里(SimpleToolSearchStrategy.java:278-292):默认配置下 ToolArgumentsException中断整个调用,而搜工具搜错了显然应该让模型重搜一次。想要严格模式就设 throwToolArgumentsExceptions(true)

3.8 外部工具来源之一:MCP

它是什么。 MCP(Model Context Protocol,模型上下文协议)是一套让外部进程/服务把工具暴露给 LLM 应用的 JSON-RPC 协议。装了 MCP 客户端,你就能用别人写好的工具服务器,不用自己写 @Tool

接入点只有一个: McpToolProvider 实现了 ToolProvider(McpToolProvider.java:35),所以对 ToolService 来说它和别的 provider 没区别。

provideTools 做的事(McpToolProvider.java:165-226):

for 每个 McpClient
client.listTools() → List<ToolSpecification>

├─ filter 过滤(用【原始】工具名)
├─ toolNameMapper / toolSpecificationMapper 改名或改描述
├─ 在 alwaysVisibleToolNames 里的 → 打上 ALWAYS_VISIBLE 元数据
└─ new McpToolExecutor(client, originalSpec.name())

└── 关键:锁的是【原始】名字

那个「锁原始名」是个必要的小心思(McpToolProvider.java:192-195):你可以把 MCP 服务器的 search 改名成 github_search 给模型看,但真正发回服务器的调用必须用 searchMcpToolExecutor.sanitizeToolName 在执行前把请求里的名字换回去(McpToolExecutor.java:45:52)。

还有一个容错开关 failIfOneServerFails,默认 false(McpToolProvider.java:53):某台 MCP 服务器挂了只 warn,其它服务器的工具照常可用(McpToolProvider.java:201-205)。

schema 的反向翻译。 @Tool 那条路是 Java → JSON Schema;MCP 这条路是 JSON Schema → LangChain4j 的 JsonSchemaElement 对象树,由 ToolSpecificationHelper 负责(langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.java:44-69toolSpecificationListFromMcpResponse)。递归函数 jsonNodeToJsonSchemaElement(ToolSpecificationHelper.java:86)处理了几个真实世界的脏细节:

  • $ref 同时支持 draft-07 的 #/definitions/X 和 2019-09 的 #/$defs/X(extractReferenceKey,ToolSpecificationHelper.java:266-275;$defs/definitions 的收录在 :129-136)。
  • "type" 缺失时默认当成 object(ToolSpecificationHelper.java:108-112)。
  • "type": ["integer","string","null"] 这种数组式多类型,数组形态先被摊平(去掉 "null",ToolSpecificationHelper.java:400-415);schema 级的 anyOf 组合(以及 MCP SEP-2106 允许 object+anyOf 并存时只保留 object 的取舍)在 ToolSpecificationHelper.java:87-100
  • MCP 的 annotations(readOnlyHintdestructiveHint 等)和 _meta 被塞进 ToolSpecification.metadata()(ToolSpecificationHelper.java:62-66processMcpToolAnnotations :284-308 / processMcpToolMetadata :308),不进 schema。

客户端与传输分层。 DefaultMcpClient 管协议语义,McpTransport 管字节怎么走:

McpToolProvider ──▶ McpClient(DefaultMcpClient)──▶ McpTransport
initialize / listTools / start / initialize /
executeTool executeOperationWithResponse

McpTransport 接口只有六个方法(langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/McpTransport.java:10-58),javadoc 特意区分了 start(建连接、必要时拉起子进程)和 initialize(发 MCP 的 initialize 消息协商能力),两者必须按序调用。DefaultMcpClient.initialize 正是这个顺序(DefaultMcpClient.java:256)。

两种传输(旧版 HTTP+SSE 的 HttpMcpTransport 已随 2026-07 规范更新删除):

传输怎么连何时用文件
StdioMcpTransportProcessBuilder 拉起子进程,走 stdin/stdout本地工具服务器langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java:26:63
HttpMcpTransport旧版 HTTP+SSE(先开 SSE 通道拿到 postUrl 再 POST)已删除——随 MCP 2026-07-28 规范更新移除,只剩 Streamable HTTP 一种 HTTP 传输(commit 7f4e99fd6 删除了 transport/http/HttpMcpTransport.java)
StreamableHttpMcpTransport新版 Streamable HTTP,带 mcpSessionId、可选 subsidiary SSE 通道远程服务器的推荐选择langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/StreamableHttpMcpTransport.java:43-64

(仓库里还有一个 .../transport/websocket/WebSocketMcpTransport.java,本章未展开。)

执行一次 MCP 工具DefaultMcpClient.executeTool(DefaultMcpClient.java:691-760),两个健壮性细节:

  • 空参数被规范成 "{}" 再解析(DefaultMcpClient.java:695-699),否则 Jackson 会炸。
  • 超时不是简单抛异常:先给服务器发一条 McpCancellationNotification 取消掉这次操作,再返回一个预先构造好的超时文本结果给模型(DefaultMcpClient.java:731-745)。也就是说超时对模型表现为一次正常的工具返回,它可以据此重试或换路。

工具列表默认可缓存(listToolsretrieveWithPossibleCaching,DefaultMcpClient.java:661-679),需要时用 evictToolListCache() 主动失效(DefaultMcpClient.java:681-683)。

3.9 外部工具来源之二:Agent Skills

它是什么。 Agent Skills 是一种「技能 = 一个 Markdown 说明书 + 若干附属资源」的组织方式。langchain4j-skills 实现了其中的 tool-based 集成方式:技能内容在构造期全部读进内存,模型在推理期只能通过工具读到它们,碰不到文件系统(Skills.java:31-41 的类 javadoc 明确了这一点)。

渐进式披露分三层,一层比一层贵:

第 0 层 系统提示词里只有名字和描述
formatAvailableSkills() 生成 <available_skills><skill><name>…
Skills.java:227-242
│ 模型判断「这个任务好像要用 pdf-editing 技能」

第 1 层 调 activate_skill(name="pdf-editing")
执行器返回 skill.content()(整份 SKILL.md)
ActivateSkillToolExecutor.java:26-45
│ 结果消息带 attributes: {"activated_skill": "pdf-editing"}

第 2 层 调 read_skill_resource(skill, path) 读附属文件
ReadResourceToolExecutor.java:26-54
——以及:该技能自带的工具此刻才出现在工具列表里

Skills.toolProvider() 返回的是一个匿名的动态 provider(Skills.java:161-201)。它的 isDynamic() 只在「有技能自带工具」时才返回 true(Skills.java:197-200)——没有技能级工具就没必要每轮重跑,这是个便宜的优化。

第 2 层那个「技能自带工具此刻才出现」是怎么做到的?靠和工具搜索同一个套路——attribute 接力:

  • ActivateSkillToolExecutor 在结果里写 attributes: {"activated_skill": skillName}(ActivateSkillToolExecutor.java:40-44,常量同样标了「do not change, will break backward compatibility」)。
  • 动态 provider 每轮扫描消息历史,把所有带这个 attribute 的结果消息里的技能名收集起来(Skills.getActivatedSkillNames,Skills.java:204-218)。
  • 只有已激活技能的 toolProviders() 才被调用,产出的工具合并进结果(Skills.java:179-194)。

管理工具本身(activate_skillread_skill_resource)被显式打上 METADATA_SEARCH_BEHAVIOR = ALWAYS_VISIBLE(Skills.java:127144),这样即使同时开了工具搜索,模型也永远看得见入口。

两个执行器的错误信息都是给模型看的自纠正提示,不是给人看的堆栈:技能名不存在时把可用技能名全列出来(ActivateSkillToolExecutor.java:33-38),资源路径不存在时把可用路径全列出来(ReadResourceToolExecutor.java:40-46)。


4. 巧妙之处(可借鉴的技术)

用消息 attributes 当跨轮状态通道。 工具搜索的 "found_tools"(ToolSearchService.java:36-37)和技能激活的 "activated_skill"(ActivateSkillToolExecutor.java:15)都不是存在某个 session 对象里,而是挂在 ToolExecutionResultMessage.attributes() 上。好处:状态随对话记忆一起持久化,换个进程恢复对话,已搜到的工具、已激活的技能自动还在。代价:常量名变成了兼容性契约,两处源码都写了「不要改」的注释。

改写历史让模型不基于错误前提推理。 补偿回滚不只撤销副作用,还把记忆里那条「成功」的工具结果换成「已回滚」并置 isError=true(ToolService.java:706-726)。这比「回滚了但不告诉模型」可靠得多。

两个错误处理器的默认值刚好相反。 参数错 fail fast、执行错回喂模型(ToolService.java:72-89)。分界线是「这是配置问题还是运行时问题」——前者重试一万次也不会好,后者常常重试就好。

校验分两级:能早报的早报,不该报的先憋着。 validateToolParameters 在注册时就把 @P 的非法组合拦掉(ToolService.java:158-212);而 @CompensateFor 的配置错误先存进 compensatingToolMisconfiguration 字段,只在真的开启补偿时才抛(ToolService.java:265-270)。

单工具不开线程。 executor != null && toolRequests.size() > 1 才走并发(ToolService.java:873),注释写得很直白:只有一个工具时另起线程不划算。

动态工具只增不减。 refreshDynamicProviders 明确不删工具,且无变化时返回原对象(ToolService.java:811791-793)。「只增」保证了模型不会在下一轮突然找不到上一轮用过的工具。


5. 边界与局限

  • 必填校验不对称。 必填的对象类型参数缺失时静默传 null,只有原始类型会报错。官方在 P.java:88-98 承认这是 1.x 的行为,计划 2.0 修。
  • @CompensateFor 只支持 @Tool 注解方法。 MCP 工具、手工注册的 ToolExecutor 都不支持,校验时直接拒绝(ToolService.java:292-301CompensateFor.java:19-22)。
  • 补偿失败只写日志。 compensateToolsActions 里补偿动作自己抛异常时只 log.warn,不会升级为调用失败(ToolService.java:747-749)。也就是说框架保证「尽力回滚」,不保证「回滚成功」。
  • 幻觉工具名默认直接炸。 HallucinatedToolNameStrategy 这个枚举目前只有 THROW_EXCEPTION 一个值(HallucinatedToolNameStrategy.java:9-22),想要「告诉模型这工具不存在、让它重选」必须自己传一个 Function
  • 向量工具搜索每次现建索引。 VectorToolSearchStrategy.search 每次都新建 InMemoryEmbeddingStore 并 embed 全部可搜工具(VectorToolSearchStrategy.java:122-125),靠缓存救性能;工具规模很大时这仍是每轮的固定开销。
  • 工具向量缓存从不自动清理。 ToolCachingEmbeddingModel 的 javadoc 承认这是有意的简化,理由是工具数量有限(ToolCachingEmbeddingModel.java:20-24);只有 clearCache() 能手动清。这个理由成立的前提是 §3.7 那条「查询放第 0 位」的调用约定被遵守。
  • ReturnBehaviorSearchBehavior@CompensateFor 均标了 @Experimental,签名可能变。
  • 旧版 HTTP+SSE 传输已删除(HttpMcpTransport 类随 MCP 2026-07-28 规范更新整体移除,只剩 StreamableHttpMcpTransport),别再找它了。

6. 横向对比

同 shelf 的兄弟章节里,这三处最值得对着看:

关切本章的做法去哪看
「模型输出 → Java 对象」的另一条路工具用 JSON Schema 描述输入;结构化输出用 JSON Schema 约束输出04-structured-output-and-guardrails.md
检索也可以做成工具本章的 VectorToolSearchStrategy 就是把 RAG 套在工具目录上05-rag.md
多轮循环的更高层形态本章的循环由「模型还想不想调工具」驱动;agentic 的循环由 Planner 驱动06-agentic.md

工具与 AI Service 的接缝(createContext 何时被调、循环结果怎么进最终返回值)见 02-ai-services.md


7. 代码地图(导航索引)

主题文件路径符号名
方法 → ToolSpecificationlangchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.javatoolSpecificationFrom
参数 → JsonObjectSchema、必填性规则同上parametersFrom
单参数 → JsonSchemaElement、Optional 拆包同上jsonSchemaElementFrom
工具重名校验同上validateSpecifications
POJO 递归展开与 $reflangchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.javajsonSchemaElementFromVisitedClassMetadata
@Tool / @P / @ToolMemoryId 定义langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolPToolMemoryId
返回策略枚举与完整真值表langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ReturnBehavior.javaReturnBehavior
补偿注解langchain4j-core/src/main/java/dev/langchain4j/agent/tool/CompensateFor.javaCompensateFor
工具搜索可见性枚举langchain4j-core/src/main/java/dev/langchain4j/agent/tool/SearchBehavior.javaSearchBehavior
核心循环langchain4j/src/main/java/dev/langchain4j/service/tool/ToolService.javaexecuteInferenceAndToolsLoop
工具装配三步同上createContextcreateContextFromStaticToolsAndProvidersaddTools
注解方法扫描与早失败校验同上findToolsvalidateToolParameters
动态 provider 刷新同上refreshDynamicProviders
提前返回判定同上shouldReturnImmediately
补偿三步同上compensateToolsActionsrewriteChatMemoryForCompensatedToolsrewriteCurrentResults
错误分流与默认处理器同上executeWithErrorHandlingDEFAULT_TOOL_ARGUMENTS_ERROR_HANDLERDEFAULT_TOOL_EXECUTION_ERROR_HANDLER
并发执行同上executeConcurrentlyexecuteToolsConcurrently
工具快照(effective vs available)langchain4j/src/main/java/dev/langchain4j/service/tool/ToolServiceContext.javaeffectiveToolsavailableTools
参数强转与反射调用langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.javaprepareArgumentscoerceArgumentparseDefaultValuetoText
幻觉工具名策略langchain4j/src/main/java/dev/langchain4j/service/tool/HallucinatedToolNameStrategy.javaHallucinatedToolNameStrategy
provider 静态/动态标记langchain4j/src/main/java/dev/langchain4j/service/tool/ToolProvider.javaisDynamic
工具搜索编排与 found_tools 接力langchain4j/src/main/java/dev/langchain4j/service/tool/search/ToolSearchService.javaadjustaddFoundToolsToolSearchExecutor
关键词工具搜索.../service/tool/search/simple/SimpleToolSearchStrategy.javasearchscore
向量工具搜索.../service/tool/search/vector/VectorToolSearchStrategy.javasearchformat
工具向量缓存.../service/tool/search/vector/ToolCachingEmbeddingModel.javaembedAllclearCache
提前返回后的返回值决策langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javaimmediateToolReturn 分支(约 356-404 行)
MCP 工具接入langchain4j-mcp/src/main/java/dev/langchain4j/mcp/McpToolProvider.javaprovideToolsaddSearchBehaviorMetadata
MCP 工具执行与改名回滚langchain4j-mcp/src/main/java/dev/langchain4j/mcp/McpToolExecutor.javasanitizeToolName
MCP schema → JsonSchemaElementlangchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.javatoolSpecificationListFromMcpResponsejsonNodeToJsonSchemaElement
MCP 客户端协议层langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/DefaultMcpClient.javainitializelistToolsexecuteToolevictToolListCache
传输抽象langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/McpTransport.javastartinitializeexecuteOperationWithResponse
三种传输实现langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java.../transport/http/HttpMcpTransport.javalangchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/StreamableHttpMcpTransport.javaStdioMcpTransportHttpMcpTransportStreamableHttpMcpTransport
Skills 装配与动态 providerlangchain4j-skills/src/main/java/dev/langchain4j/skills/Skills.javatoolProvidercreateToolProvidergetActivatedSkillNamesformatAvailableSkills
技能激活 / 资源读取执行器langchain4j-skills/src/main/java/dev/langchain4j/skills/ActivateSkillToolExecutorReadResourceToolExecutor