【LangChain4J-04】Tool 工具的使用

【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)、exchangeopen.er-api.com,带 10 分钟缓存)、metalsgold-api.com)、holiday(Nager.Date)、translate(MyMemory)、web(DuckDuckGo+维基);
  • 需授权 APItrain(极速数据 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
}

关键约定ToolMetacode 同时承担三个角色------

  1. 发给 Ollama 的 function 名称
  2. ToolRegistry注册 key
  3. 前端勾选时回传的 工具标识

因此「前端勾选的 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 工具循环)

这是整个工具能力的「大脑」。它 不依赖任何具体工具 ,只认 ToolMetaChatTool 两个抽象:

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 ChatToolexecute() 写真实逻辑;外部 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 条注意)

开闭原则实战:假设要加一个项目里还没有的「查油价」工具。

  1. 📝 ToolMeta 加一个常量

    java 复制代码
    OIL("oil", "查油价", "查询国际原油价格(美元/桶)",
        List.of(p("market", "油种", "text", false, "如 Brent / WTI,默认 Brent")));
  2. 🧩 写一个 @Component 实现 ChatTool

    java 复制代码
    @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 条注意

  1. 🌐 接外部 API 时复用 HttpSupport.get(...),并做好异常兜底 ------execute() 里 catch 后返回友好文本,别让异常中断对话;
  2. 📝 getCode() 必须ToolMeta 常量 code 完全一致(大小写敏感),否则注册表按 code 找不到实现;
  3. 🧪 验证:启动后 GET /api/chat/tools 确认出现 → 提一个明确触发该工具的问题(如"布伦特原油现在多少钱")→ 看后端日志 🔧 模型决定调用工具📨 [工具] ✅ 执行完成

💡 从加常量到可用,只动 2 个文件、加 1 个类,其余核心类一行不用碰。


🩺 十五、排错 & 注意事项

现象 可能原因 处理
模型 不调工具,直接回答 qwen3:4b-instruct 对 function calling 支持弱 / 问题不需要工具 确认模型支持;问题足够明确时本就无需工具,属正常
工具结果回灌后模型仍乱答 Ollama 要求 tool 消息的 nametool_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 自动回退。

相关推荐
Fnetlink136 分钟前
解析SDWAN供应商光联世纪口碑佳的原因
网络·人工智能·安全
南城以南溫暖如初14738 分钟前
树洞交友系统架构设计与匿名聊天实战指南
java·spring boot·mysql·系统架构·vue·mybatis·交友
MartinYeung539 分钟前
[论文学习]动态红队测试DAS:破解医疗大语言模型静态评估的“信任陷阱”
人工智能·学习·语言模型
JavaPub-rodert42 分钟前
TrustGraph 详解:把知识图谱、Ontology、GraphRAG 和 AI Agent 串成一套系统
人工智能·知识图谱
lemon_sjdk44 分钟前
JavaFX 响应式核心:从属性绑定、失效通知到生命周期管理
java
迷迭香yy1 小时前
回测过拟合检测体系从样本内外到组合稳健性评估 IG50免费开源股票数据API接口
服务器·开发语言·数据库·人工智能·python
数字融合1 小时前
透明化数字孪生:未来医疗的核心平台
大数据·人工智能·virtualenv
richard_first1 小时前
Transformer 与大语言模型:第7章 Multi-Head Attention 多头注意
人工智能·深度学习·机器学习
大江东去浪淘尽千古风流人物1 小时前
【HMD-Poser】CVPR2024 头显端实时全身动捕:可伸缩稀疏观测、LSTM+Transformer 时空解耦与在线体型估计
人工智能·lstm·transformer·vr·人体姿态估计