【LangChain4J-04】Tool 工具的使用
- [🧰 LangChain4J--Tool 工具的使用](#🧰 LangChain4J--Tool 工具的使用)
-
- [🧭 一、为什么需要工具调用](#🧭 一、为什么需要工具调用)
- [🏗️ 二、整体架构(解耦设计)](#🏗️ 二、整体架构(解耦设计))
- [⚙️ 三、实现原理(核心机制)](#⚙️ 三、实现原理(核心机制))
-
- [3.1 协议基础:Ollama 的 function calling](#3.1 协议基础:Ollama 的 function calling)
- [3.2 单轮工具调用(Tool Calling)](#3.2 单轮工具调用(Tool Calling))
- [3.3 多轮 agentic 循环(AgentToolRunner)](#3.3 多轮 agentic 循环(AgentToolRunner))
- [3.4 四个关键设计决策](#3.4 四个关键设计决策)
- [3.5 数据从哪来](#3.5 数据从哪来)
- [🔌 四、工具目录:`ToolMeta` 枚举(单一来源)](#🔌 四、工具目录:
ToolMeta枚举(单一来源)) - [📜 五、工具契约:`ChatTool` 接口](#📜 五、工具契约:
ChatTool接口) - [🗂️ 六、工具注册:`ToolRegistry`](#🗂️ 六、工具注册:
ToolRegistry) - [🧬 七、Schema 生成:`ToolSchemaBuilder`](#🧬 七、Schema 生成:
ToolSchemaBuilder) - [🔁 八、编排核心:`AgentToolRunner`(agentic 工具循环)](#🔁 八、编排核心:
AgentToolRunner(agentic 工具循环)) - [🎛️ 九、AGENT 模式接入(新增模式,零侵入其他模式)](#🎛️ 九、AGENT 模式接入(新增模式,零侵入其他模式))
-
- [9.1 抽象基类加钩子](#9.1 抽象基类加钩子)
- [9.2 AgentChatHandler 只覆盖这一处](#9.2 AgentChatHandler 只覆盖这一处)
- [9.3 枚举 + 请求字段 + 校验](#9.3 枚举 + 请求字段 + 校验)
- [🛠️ 十、内置工具一览(当前共 10 个)](#🛠️ 十、内置工具一览(当前共 10 个))
- [🖥️ 十一、前端集成(「我的工具箱」入口 + 弹窗 + 调用提示)](#🖥️ 十一、前端集成(「我的工具箱」入口 + 弹窗 + 调用提示))
-
- [11.1 左侧「🧰 工具」面板:仅展示](#11.1 左侧「🧰 工具」面板:仅展示)
- [11.2 右侧「我的工具箱」:入口按钮 + 弹窗勾选](#11.2 右侧「我的工具箱」:入口按钮 + 弹窗勾选)
- [11.3 发送时带上 tools](#11.3 发送时带上 tools)
- [11.4 实时展示「🔧 正在调用:xxx」](#11.4 实时展示「🔧 正在调用:xxx」)
- [🚦 十二、后端接口速查](#🚦 十二、后端接口速查)
- [🛣️ 十三、实现过程(从零到可用的 8 步)](#🛣️ 十三、实现过程(从零到可用的 8 步))
- [➕ 十四、如何扩展一个新工具(2 步核心 + 3 条注意)](#➕ 十四、如何扩展一个新工具(2 步核心 + 3 条注意))
- [🩺 十五、排错 & 注意事项](#🩺 十五、排错 & 注意事项)
- [🎯 十六、小结](#🎯 十六、小结)
🧰 LangChain4J--Tool 工具的使用
📗 本文档记录 Spring Boot 3 + JDK 17 + LangChain4j + Ollama(qwen3:4b-instruct) 下「大模型工具调用(Tool Calling)」功能的完整实现。
目标:让对话中的大模型可以调用 翻译、查天气、查车票、查网页、计算器、汇率、金价银价、地理编码、节假日、时间日期 等 10 个常用工具,并把工具的管理与使用做到 解耦、可扩展。
💡 阅读提示:文中 紫色 为注解 / 枚举名,蓝色 为 API / 类名,橙色 为配置参数 / 接口路径,红色 为注意事项,绿色 为正向效果。
🧭 一、为什么需要工具调用
大模型本身只会「预测下一个 token」,它:
- ❌ 不知道 实时天气、查不到最新汇率、算不清复杂表达式;
- ❌ 不会查列车时刻表、不会联网检索最新资料;
- ✅ 但 非常擅长「判断该不该调用工具、调用哪个、传什么参数」。
于是业界用 Tool Calling / Function Calling 把「模型的推理能力」和「外部确定性的能力」组合起来:
🤖 模型决定调用工具 → 🧩 后端本地执行工具拿到结果 → 📨 把结果回灌给模型 → ✍️ 模型整合出自然语言回答。
最终效果:用户只管用自然语言提问,模型自己决定调哪个工具、传什么参数,全程对用户透明。
🏗️ 二、整体架构(解耦设计)
核心思想:工具的管理、描述、执行、编排四件事彻底分离,互不耦合。
┌─────────────┐ ① 拉取目录 ┌──────────────────────────────┐
│ 前端页面 │ ──────────────▶ │ GET /api/chat/tools │
│ (chat.html) │ │ ToolRegistry.allMetas() │
│ │ │ → ToolMeta 枚举(唯一来源) │
│ 🧰 工具箱 │ └──────────────────────────────┘
│ (弹窗勾选) │
└──────┬──────┘
│ ② 发送 AGENT 请求(带勾选的 tools 列表)
▼
┌──────────────────────────────────────────────────────────────┐
│ ChatController → ChatService → ChatHandlerFactory │
│ │ 按 mode 路由 │
│ ▼ │
│ AgentChatHandler │
│ (覆盖 streamToModel 钩子) │
│ │ │
│ ▼ │
│ AgentToolRunner ◀── 编排核心 │
│ ┌──────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ToolSchemaBuilder ToolRegistry OllamaStreamClient│
│ (生成 schema) (执行工具) (直连 Ollama) │
│ │ │ │ │
│ │ ▼ │ │
│ │ ChatTool 各实现类 │ │
│ │ (共 10 个,见第十章) │ │
└───────────────────────┼──────────────┼──────────────┼────────┘
│ │ │
▼ ▼ ▼
Ollama /api/chat 外部免费 API function calling 循环
解耦要点:
| 关注点 | 归属类 | 说明 |
|---|---|---|
| 工具「长什么样」(目录) | ToolMeta(枚举) |
单一来源,无需数据库 |
| 工具「怎么执行」(契约) | ChatTool(接口) |
每个工具一个实现类 |
| 工具「谁去执行」 | ToolRegistry |
Spring 自动收集所有 ChatTool Bean |
| 工具「怎么描述给模型」 | ToolSchemaBuilder |
枚举 → Ollama function calling JSON |
| 工具「怎么编排循环」 | AgentToolRunner |
模型→调工具→回灌→再生成,不依赖具体工具 |
| 工具「在哪个对话模式触发」 | AgentChatHandler |
仅覆盖一个钩子,不改动其他模式 |
💡 开闭原则(OCP) :新增一个工具 = 加一个
ToolMeta常量 + 一个@Component实现类,其余代码零改动。
⚙️ 三、实现原理(核心机制)
工具调用不是「黑魔法」,本质是一条 「模型出主意、后端动手、结果回喂」的协商循环:
3.1 协议基础:Ollama 的 function calling
向 Ollama /api/chat 发送请求时带上 tools 数组(每个函数有 name / description / parameters),模型在生成回答时可能 输出一个特殊的增量结构 tool_calls:
jsonc
// 模型响应(流式 NDJSON 中的某一行,简化示意)
{ "message": { "tool_calls": [
{ "function": { "name": "weather", "arguments": "{\"city\":\"北京\"}" } }
]}}
关键点:模型只负责「决定调哪个函数、传什么参数」,不负责执行 。arguments 是 JSON 字符串,由后端解析后调用真实代码。
3.2 单轮工具调用(Tool Calling)
① 请求: messages + tools → ② 响应: tool_calls(weather, {city:"北京"})
↓
③ 后端执行 WeatherTool → "北京 当前天气:气温 26℃..."
↓
④ 请求: messages + assistant(tool_calls) + tool(结果) → ⑤ 响应: 最终文本回答
模型必须「看到」自己上次的 tool_calls 以及对应的 tool 结果,才能接着给出最终回答------这就是回灌(round-trip)。
3.3 多轮 agentic 循环(AgentToolRunner)
把「单轮」扩展为最多 5 轮 的循环:只要模型还在输出 tool_calls,就执行工具、回灌结果、再次请求;直到模型输出不含 tool_calls 的纯文本才结束,并把最终文本流式转发给前端。
🛡️
MAX_ROUNDS = 5是安全闸,防止模型反复要求调工具导致死循环烧算力。
3.4 四个关键设计决策
| 决策 | 原因 |
|---|---|
目录用枚举 (ToolMeta),不建数据库 |
工具数量少且固定,枚举即「单一事实来源」,零 DB 成本 |
| 参数 schema 统一声明为 string,工具内部自行转换 | 让模型少猜类型,显著降低 arguments 解析错误率 |
编排器只认抽象 (ToolMeta / ChatTool) |
AgentToolRunner 不 import 任何具体工具 → 加工具零改动 |
SSE 推 event:tool |
前端能实时显示「🔧 正在调用:weather」,再流式输出回答 |
3.5 数据从哪来
- 本地计算 (无网络):
datetime(java.time)、calculator(自写安全解析器,禁 ScriptEngine 防注入); - 外部免费 API (免 key):
weather/geo(Open-Meteo)、exchange(open.er-api.com,带 10 分钟缓存)、metals(gold-api.com)、holiday(Nager.Date)、translate(MyMemory)、web(DuckDuckGo+维基); - 需授权 API :
train(极速数据 appkey,未配置时返回明确授权提示,绝不返回假数据)。
统一由包内 HttpSupport 封装 JDK 内置 HttpClient 完成 HTTP GET(零额外依赖)。
🔌 四、工具目录:ToolMeta 枚举(单一来源)
系统 没有接入数据库 ,所有工具直接维护在后端枚举里。前端通过 GET /api/chat/tools 拉取展示与勾选。
java
// enums/ToolMeta.java
public enum ToolMeta {
TRANSLATE("translate", "翻译", "将文本翻译为目标语言",
List.of(p("text", "待翻译文本", "text", true, "需要翻译的内容"),
p("sourceLang", "源语言代码", "text", false, "如 zh / en / ja,默认 zh"),
p("targetLang", "目标语言代码", "text", false, "如 en / ja / fr,默认 en"))),
WEATHER("weather", "查天气", "查询指定城市的实时天气(温度 / 湿度 / 风速 / 天气状况)",
List.of(p("city", "城市名称", "text", true, "如 北京 / 上海 / Tokyo"))),
TRAIN("train", "查车票", "查询两站之间的列车班次与真实余票(需配置极速数据 appkey,数据同步自 12306)",
List.of(p("from", "出发站", "text", true, "如 北京"),
p("to", "到达站", "text", true, "如 上海"),
p("date", "日期", "text", false, "如 2026-09-01,可选"))),
WEB("web", "查网页", "联网检索资料(免费搜索 / 百科摘要)",
List.of(p("query", "检索关键词", "text", true, "如 量子计算 原理"))),
DATETIME("datetime", "时间日期", "查询当前时间 / 时区换算 / Unix 时间戳转日期时间",
List.of(p("timezone", "时区", "text", false, "如 Asia/Shanghai / UTC / America/New_York,默认系统时区"),
p("timestamp", "Unix秒时间戳", "text", false, "如 1787702551,填写则转换该时间戳,否则返回当前时间"))),
CALCULATOR("calculator", "计算器", "安全计算数学表达式(本地解析器,支持 + - * / % ^、括号、pi/e、sqrt/abs/sin/cos/tan/log/ln/round/floor/ceil/min/max/pow)",
List.of(p("expression", "数学表达式", "text", true, "如 (3.5*4-1)/2 或 sqrt(16)+2^3"))),
EXCHANGE("exchange", "汇率", "查询实时货币汇率并换算(免 key,open.er-api.com,带 10 分钟缓存)",
List.of(p("from", "源币种", "text", false, "ISO 货币代码,如 USD,默认 USD"),
p("to", "目标币种", "text", false, "ISO 货币代码,如 CNY,默认 CNY"),
p("amount", "金额", "text", false, "换算金额,默认 1"))),
METALS("metals", "金价银价", "查询实时国际黄金/白银价格(美元/盎司,免 key,gold-api.com)",
List.of()),
GEO("geo", "地理编码", "地名 → 经纬度/国家/时区/人口,或计算两地直线距离(免 key,Open-Meteo)",
List.of(p("name", "地名", "text", true, "如 北京 / Paris"),
p("count", "返回条数", "text", false, "默认 1,最大 10"),
p("to", "另一地名", "text", false, "可选,填写后计算两地直线距离(公里)"))),
HOLIDAY("holiday", "节假日", "查询指定国家与年份的法定节假日(免 key,Nager.Date)",
List.of(p("year", "年份", "text", false, "默认当前年份"),
p("countryCode", "国家代码", "text", false, "ISO 3166-1 两位字母,如 CN,默认 CN"),
p("month", "月份", "text", false, "1-12,可选,只返回该月节日")));
// 每个常量:code(=Ollama function 名=注册表 key) / name / description / params
// 内部类 ParamMeta:code / name / type / required / description
}
关键约定 :ToolMeta 的 code 同时承担三个角色------
- 发给 Ollama 的 function 名称;
ToolRegistry的 注册 key;- 前端勾选时回传的 工具标识。
因此「前端勾选的 code」与「模型调用的 function 名」天然对齐,不会出现对不上的情况。
📤
GET /api/chat/tools返回的 JSON(已用@JsonValue结构化,非枚举名):
json
[
{
"code": "weather",
"name": "查天气",
"description": "查询指定城市的实时天气(温度 / 湿度 / 风速 / 天气状况)",
"params": [
{ "code": "city", "name": "城市名称", "type": "text", "required": true, "description": "如 北京 / 上海 / Tokyo" }
]
}
]
📜 五、工具契约:ChatTool 接口
每个可被调用的工具都实现这个接口,职责单一:
java
// chat/tool/ChatTool.java
public interface ChatTool {
String getCode(); // 必须与对应 ToolMeta.code 一致
ToolMeta meta(); // 元数据(schema 生成 + 前端展示)
String execute(Map<String, Object> args); // 执行逻辑,返回结果文本(回灌给模型)
}
args 是模型解析出的参数(全部为字符串,工具内部自行转换),返回值会作为 tool 角色消息 回灌给模型。
🗂️ 六、工具注册:ToolRegistry
启动时 Spring 把所有 ChatTool 实现类注入进来,按 code 建索引:
java
// chat/tool/ToolRegistry.java
@Component
public class ToolRegistry {
private final Map<String, ChatTool> tools = new ConcurrentHashMap<>();
public ToolRegistry(List<ChatTool> all) { // Spring 自动收集全部 ChatTool Bean
for (ChatTool t : all) tools.put(t.getCode(), t);
}
/** 执行工具;未知工具或异常都返回友好文本,绝不抛异常中断对话 */
public String execute(String code, Map<String, Object> args) {
ChatTool t = tools.get(code);
if (t == null) return "未知工具:" + code;
try { return t.execute(args); }
catch (Exception e) { return "工具执行失败:" + e.getMessage(); }
}
/** 全部工具元数据,供 GET /api/chat/tools 下发 */
public List<ToolMeta> allMetas() { return Arrays.stream(ToolMeta.values()).toList(); }
}
🧬 七、Schema 生成:ToolSchemaBuilder
把 ToolMeta 翻译成 Ollama /api/chat 需要的 function calling JSON(参数统一按字符串传递,降低 schema 复杂度):
java
// chat/tool/ToolSchemaBuilder.java
public Map<String, Object> toSchema(ToolMeta meta) {
// { "type":"function",
// "function": { "name", "description",
// "parameters": { "type":"object", "properties", "required" } } }
}
💡 这样模型就知道「有哪些函数可调用、每个函数要什么参数、哪些必填」。
🔁 八、编排核心:AgentToolRunner(agentic 工具循环)
这是整个工具能力的「大脑」。它 不依赖任何具体工具 ,只认 ToolMeta 和 ChatTool 两个抽象:
java
// chat/tool/AgentToolRunner.java
public void runWithTools(ChatRequest request, List<String> enabledCodes,
BooleanSupplier cancelled,
OllamaStreamClient.OllamaStreamHandler outerHandler) {
List<Map<String,Object>> messages = client.toOllamaMessages(request.messages());
List<Map<String,Object>> tools = buildToolsSchema(enabledCodes); // 按勾选过滤,空=全部
if (tools.isEmpty()) { // 无可用工具 → 退化为普通对话
client.stream(request, cancelled, outerHandler);
return;
}
for (int round = 0; round < MAX_ROUNDS; round++) { // MAX_ROUNDS = 5,防死循环
if (cancelled.getAsBoolean()) return; // 每轮检查中断
// 发给 Ollama(带 tools),回调里收 token 与 tool_calls
client.streamRaw(request, messages, tools, cancelled, new ToolAwareHandler() {
// onToolCalls: 模型要求调用工具
// onComplete: 若无 tool_calls → 这是最终答案,转发给 outerHandler
});
if (!hadToolCalls.get()) break; // 已拿到最终答案,结束循环
// 把 assistant(tool_calls) 与每个工具的 tool 结果回灌,进入下一轮
appendToolCalls(messages, calls);
for (ToolCall call : calls) {
outerHandler.onToolCall(call.name(), call.arguments()); // 前端 🔧 提示
String result = registry.execute(call.name(), call.arguments());
appendToolResult(messages, call.name(), result);
}
}
}
⚠️
MAX_ROUNDS=5是安全闸:若模型反复要求调工具(如参数不对),最多 5 轮后强制结束,避免无限循环烧算力。
🎛️ 九、AGENT 模式接入(新增模式,零侵入其他模式)
采用 「新增 AGENT 模式」 方案:不动原有 7 种模式,只新增第 8 种。关键解耦点是一个 模板方法钩子 streamToModel。
9.1 抽象基类加钩子
java
// chat/handler/AbstractChatHandler.java
protected void streamToModel(ChatRequestDTO req, ChatRequest request,
BooleanSupplier cancelled,
OllamaStreamClient.OllamaStreamHandler handler) {
streamingClient.stream(request, cancelled, handler); // 默认:基础流式
}
// chat() 与 stream() 都改为调用 streamToModel(...) ------ 子类只改这一处即可改变"如何调模型"
9.2 AgentChatHandler 只覆盖这一处
java
// chat/handler/AgentChatHandler.java
@Component
public class AgentChatHandler extends AbstractChatHandler {
@Override public ChatMode supportedMode() { return ChatMode.AGENT; }
@Override
protected void streamToModel(ChatRequestDTO req, ChatRequest request,
BooleanSupplier cancelled,
OllamaStreamClient.OllamaStreamHandler handler) {
// 唯一改动点:把"基础流式"换成"带工具循环的调用"
agentToolRunner.runWithTools(request, req.getTools(), cancelled, handler);
}
// buildMessages / afterChat 复用 MEMORY 那套(固定人设 + 多轮记忆)
}
💡 这就是解耦的价值:BASIC / MEMORY / CUSTOM 等 7 种模式完全没被触碰,它们仍走默认
streamingClient.stream。新增 AGENT 只新增一个类 + 覆盖一个钩子。
9.3 枚举 + 请求字段 + 校验
java
// enums/ChatMode.java ------ 新增
AGENT("agent", "L8 · 工具调用对话", "...",
List.of("内置固定全局人设,支持多轮上下文",
"模型自主选择并调用已启用工具",
"工具结果回灌后由模型整合回答",
"工具由「我的工具箱」勾选,默认全部启用"),
true /*requiresSession*/, false, false);
java
// dto/chat/ChatRequestDTO.java ------ 新增字段
/** AGENT 模式下前端勾选启用的工具 code 列表(空/null = 启用全部) */
private List<String> tools; // 由 Lombok @Data 自动生成 getTools()
java
// validation/ChatRequestValidator.java ------ AGENT 与 MEMORY/CUSTOM 一并要求 sessionId
if (mode == MEMORY || mode == CUSTOM || mode == AGENT) { /* 必须带 sessionId */ }
🛠️ 十、内置工具一览(当前共 10 个)
| # | 工具 | code | 数据源 | 鉴权 | 说明 |
|---|---|---|---|---|---|
| 1 | 🌐 翻译 | translate |
MyMemory | 免 key | 中英/多语种互译 |
| 2 | 🌤️ 天气 | weather |
Open-Meteo | 免 key | 先地理编码城市→经纬度,再查实时天气 |
| 3 | 🚄 车票 | train |
极速数据 train/ticket | 需 appkey | 真实余票+票价,同步自 12306;未配置返回授权提示 |
| 4 | 🔎 网页 | web |
DuckDuckGo 即时答案 + 维基摘要 | 免 key | DuckDuckGo 无结果回落维基 |
| 5 | 🕒 时间日期 | datetime |
本地 java.time | 无网络 | 当前时间 / 时区换算 / Unix 时间戳互转 |
| 6 | 🧮 计算器 | calculator |
本地自写安全解析器 | 无网络 | + - * / % ^、括号、函数,禁 ScriptEngine 防注入 |
| 7 | 💱 汇率 | exchange |
open.er-api.com | 免 key | 实时汇率换算,带 10 分钟内存缓存 |
| 8 | 🥇 金价银价 | metals |
gold-api.com | 免 key | 国际金/银价(美元/盎司),一次查双价 |
| 9 | 📍 地理编码 | geo |
Open-Meteo | 免 key | 地名→经纬度/国家/时区/人口;可算两地直线距离 |
| 10 | 🗓️ 节假日 | holiday |
Nager.Date | 免 key | 指定国家年份的法定节假日,可按月过滤 |
💡 尽量接免费真实接口 :除车票外全部免 key 开箱即用;车票需在极速数据官网申请 appkey 并配置到
app.thirdparty.jisu.appkey(或环境变量JISU_API_KEY),未配置时返回明确的「需授权」提示而非假数据。
工具示例(天气,两个外部调用 + WMO 码转中文):
java
// chat/tool/WeatherTool.java(节选)
@Component
public class WeatherTool implements ChatTool {
@Override public String getCode() { return ToolMeta.WEATHER.getCode(); }
@Override public ToolMeta meta() { return ToolMeta.WEATHER; }
@Override
public String execute(Map<String, Object> args) {
String city = HttpSupport.str(args, "city");
// 1) 地理编码:geocoding-api.open-meteo.com → 经纬度
// 2) 查天气:api.open-meteo.com/v1/forecast → 温度/湿度/风速/天气状况
// 3) WMO 天气代码 → 中文描述(晴/雨/雪/雷阵雨...)
return String.format("%s(%s) 当前天气:气温 %.1f℃...", name, country, temp, ...);
}
}
通用 HTTP GET 由包内 HttpSupport 封装(JDK 内置 HttpClient,零额外依赖):
java
// chat/tool/HttpSupport.java(包私有)
static String get(String url) throws Exception { /* JDK HttpClient GET,UTF-8,10s/15s 超时 */ }
static String enc(String s) { /* URLEncoder 编码 */ }
static String str(Map<String,Object> m, String k, String d) { /* 安全取参 */ }
🖥️ 十一、前端集成(「我的工具箱」入口 + 弹窗 + 调用提示)
11.1 左侧「🧰 工具」面板:仅展示
左侧手风琴从 GET /api/chat/tools 拉取并只读展示全部可用工具(名称 + 说明),不提供勾选:
js
// renderToolList():每个工具渲染 name + description(只读)
// 是否启用 → 去右侧「我的工具箱」配置
11.2 右侧「我的工具箱」:入口按钮 + 弹窗勾选
右侧配置栏不再是罗列 checkbox,而是一个入口按钮,点击弹出弹窗多选:
html
<!-- 右侧配置栏 -->
<button id="openToolBtn" class="ghost">🧰 我的工具箱(<span id="toolCount">0</span>/<span id="toolTotal">0</span> 个工具已启用)</button>
<!-- 弹窗:列出全部工具,默认全选;底部 全选/全不选/完成 -->
<div class="modal-mask" id="toolModal">
<div class="modal">
<div class="modal-head"><h2>🧰 我的工具箱</h2><button id="toolClose">关闭</button></div>
<div class="modal-body"><div id="toolChecks"></div></div>
<div class="modal-foot">
<button id="toolAll">全选</button>
<button id="toolNone">全不选</button>
<button id="toolDone">完成</button>
</div>
</div>
</div>
js
// 状态:selectedTools(Set),首次进入默认全选(toolsInitialized 防重复初始化)
let selectedTools = new Set();
let toolsInitialized = false;
function renderToolOptions() {
// 渲染 toolChecks 弹窗内 checkbox,checked = selectedTools.has(code)
// change 事件 → 增删 selectedTools + 更新入口计数 toolCount/toolTotal
}
function getSelectedTools() { return Array.from(selectedTools); } // 空数组 = 后端视为「全部启用」
11.3 发送时带上 tools
js
function buildBody(message) {
const body = { mode: modeEl.value, message };
// ...其他字段...
if (modeEl.value === 'agent') body.tools = getSelectedTools(); // ← AGENT 模式专属
return body;
}
11.4 实时展示「🔧 正在调用:xxx」
后端在模型决定调工具时,通过 SSE 事件 event:tool 推送:
java
// controller/ChatController.java ------ SseStreamCallback.onToolCall
@Override public void onToolCall(String toolName, Map<String, Object> args) {
Map<String,Object> payload = new LinkedHashMap<>(2);
payload.put("name", toolName);
payload.put("args", args);
emitter.send(SseEmitter.event().name("tool").data(payload)); // ← event:tool
}
前端在 streamChat 里识别该事件,回答结束后在气泡底部罗列工具调用(样式与 token 数一致,多个用 || 分隔):
js
// streamChat 内
else if (ev.name === 'tool' && onTool) { onTool(JSON.parse(ev.data)); }
// 渲染:🧰 工具调用:weather(已完成) || calculator(已完成)
💡 体验:提问「北京天气怎么样」→ 后端日志出现
🔧 模型决定调用 1 个工具:[weather]→ 前端气泡底部罗列「🧰 工具调用:weather(已完成)」 → 再流式输出最终回答。
🚦 十二、后端接口速查
| 方法 & 路径 | 作用 | 关键字段 |
|---|---|---|
POST /api/chat |
非流式对话(可中断) | mode, message, sessionId, tools(AGENT), stream=false |
POST /api/chat/stream |
SSE 流式对话 (逐 token + event:tool) |
同上,stream=true |
GET /api/chat/modes |
全部模式元数据(含新增 agent) |
--- |
GET /api/chat/tools |
工具目录(本功能新增) | 返回 ToolMeta 结构化列表 |
GET /api/chat/users |
sessionId 下拉用户 | --- |
POST /api/chat/clear |
清空指定 session 记忆 | sessionId |
POST /api/chat/cancel |
非流式中断信号 | requestId |
🛣️ 十三、实现过程(从零到可用的 8 步)
以「查汇率」为例,完整走一遍从定义到可用的实施顺序(这就是本项目当时的落地顺序):
| 步骤 | 做什么 | 改哪个文件 | 说明 |
|---|---|---|---|
| ① | 定义工具目录 | enums/ToolMeta.java |
加一个枚举常量:code/name/description/params |
| ② | 定义工具契约 | chat/tool/ChatTool.java |
接口已就绪,无需改(约定 getCode() 与枚举 code 一致) |
| ③ | 实现具体工具 | 新建 chat/tool/ExchangeTool.java |
@Component implements ChatTool,execute() 写真实逻辑;外部 API 复用 HttpSupport.get() |
| ④ | 注册 | chat/tool/ToolRegistry.java |
零改动 :Spring 自动把新 Bean 注入 List<ChatTool> 并建索引 |
| ⑤ | 生成 schema | chat/tool/ToolSchemaBuilder.java |
零改动:遍历枚举自动生成 function calling JSON |
| ⑥ | 编排循环 | chat/tool/AgentToolRunner.java |
零改动 :只认抽象,自动把 tool_calls 转成可执行调用并回灌 |
| ⑦ | 接入对话模式 | chat/handler/AgentChatHandler.java |
零改动 :L8 已接通 runWithTools,新工具自动可用 |
| ⑧ | 前端联动 + 验证 | 前端零改动 / 手工验证 | 工具自动出现在「我的工具箱」弹窗;启动后先 GET /api/chat/tools 确认出现,再提问触发 |
🎯 结论:真正要动手写的只有 ① 加枚举常量、③ 写一个实现类,其余 6 步全是「零改动」。这 8 步就是整个 L8 工具能力的完整落地过程:先建目录 → 立契约 → 写实现 → 自动注册/生成/编排 → 接入模式 → 前端验证。
➕ 十四、如何扩展一个新工具(2 步核心 + 3 条注意)
开闭原则实战:假设要加一个项目里还没有的「查油价」工具。
-
📝
ToolMeta加一个常量javaOIL("oil", "查油价", "查询国际原油价格(美元/桶)", List.of(p("market", "油种", "text", false, "如 Brent / WTI,默认 Brent"))); -
🧩 写一个
@Component实现ChatTooljava@Component public class OilTool implements ChatTool { public String getCode() { return ToolMeta.OIL.getCode(); } public ToolMeta meta() { return ToolMeta.OIL; } public String execute(Map<String,Object> args) { /* 调油价 API */ } }
不需要改的 :ToolRegistry(自动收 Bean)、ToolSchemaBuilder(自动生成 schema)、AgentToolRunner(只认抽象)、AgentChatHandler(L8 已接通)、前端(GET /api/chat/tools 自动出现,默认全选进「我的工具箱」)。
3 条注意:
- 🌐 接外部 API 时复用
HttpSupport.get(...),并做好异常兜底 ------execute()里 catch 后返回友好文本,别让异常中断对话; - 📝
getCode()必须 与ToolMeta常量 code 完全一致(大小写敏感),否则注册表按 code 找不到实现; - 🧪 验证:启动后
GET /api/chat/tools确认出现 → 提一个明确触发该工具的问题(如"布伦特原油现在多少钱")→ 看后端日志🔧 模型决定调用工具与📨 [工具] ✅ 执行完成。
💡 从加常量到可用,只动 2 个文件、加 1 个类,其余核心类一行不用碰。
🩺 十五、排错 & 注意事项
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 模型 不调工具,直接回答 | qwen3:4b-instruct 对 function calling 支持弱 / 问题不需要工具 |
确认模型支持;问题足够明确时本就无需工具,属正常 |
| 工具结果回灌后模型仍乱答 | Ollama 要求 tool 消息的 name 与 tool_calls.function.name 一致 |
代码已保证一致(appendToolResult 用同一 name) |
event:tool 前端没显示 |
前端未识别 name==='tool' |
已处理;非流式模式不推 tool 事件(单段 JSON 兜底) |
| 天气/汇率/金价等返回「异常」 | 本机 无外网 或目标 API 限流 | 检查网络;外部 API 工具均依赖公网,内网环境会失败 |
| 偶发「HTTP connect timed out」 | JDK 17 HttpClient 无多 IP 回退(Happy Eyeballs 是 JDK 20+):DNS 返回多 A 记录时只连第一个,首个不可达即超时 |
换网络环境 / 在 hosts 固定可用 IP(如 172.67.215.182 date.nager.at);JDK 20+ 自动解决 |
| 车票返回「需要授权 appkey」提示 | 未配置 app.thirdparty.jisu.appkey |
在 application.yml 填入极速数据 appkey(或设环境变量 JISU_API_KEY)后重启 |
| 死循环 / 一直调工具 | 模型反复要求调工具 | MAX_ROUNDS=5 已兜底强制结束 |
编译报错找不到 streamToModel |
AbstractChatHandler 未加钩子 |
已新增;AgentChatHandler 覆盖它 |
⚠️ 重要边界 :当前实现依赖 Ollama 原生 function calling。若日后换用不支持 function calling 的模型,需要新增一层「提示词解析兜底」(从模型文本里抽
{tool:..., args:...}),本版未实现,已在AgentToolRunner留好扩展位。
🎯 十六、小结
- ✅ 工具 目录化、枚举化 (无数据库),前端
GET /api/chat/tools拉取展示与勾选; - ✅ 10 个内置工具:翻译 / 天气 / 车票 / 网页 / 时间日期 / 计算器 / 汇率 / 金价银价 / 地理编码 / 节假日,尽量接免费真实接口;
- ✅ 对话中通过 新增 AGENT 模式(L8) 使用工具,方案与既有模式解耦(只覆盖
streamToModel钩子); - ✅ 架构彻底解耦:
ToolMeta/ChatTool/ToolRegistry/ToolSchemaBuilder/AgentToolRunner各司其职,新增工具只动 2 个文件(开闭原则);
🚀 下一步可做的增强:① 工具调用结果的「可观测日志」落盘;② 不支持 function calling 模型的提示词兜底;③ 接入更多真实授权 API(如股票行情);④ 升级 JDK 20+ 获得 HttpClient 多 IP 自动回退。