实现一个简单的工具调用DEMO

实现一个简单的工具调用DEMO

本文围绕 Page9 的 Java Demo,梳理一个工具调用程序从启动、注册工具到处理模型请求并返回结果的整体设计。重点放在各个组件如何衔接,以及应用怎样控制工具的校验和执行。

代码仓库位置:Gitee · toxplore/ai · Page9

一、程序设计整体流程图

先从程序运行顺序看整体结构。启动时,应用创建工具定义并注册到 Agent;每轮交互中,模型可以直接回答,也可以请求调用工具。工具结果回到模型后,流程继续循环。

工具调用 Demo 的基本流程 程序配置候选工具并接收问题,将消息和工具定义发送给模型。模型直接回答则结束;提出工具调用则由应用校验并执行,将带有调用 ID 的结果交回模型继续循环,直到模型回答或达到轮数上限。 启动时创建并注册工具ToolSpec:名称、用途、参数 Schema、Java 执行函数Agent 按名称存入 Map 工具注册表 用户输入问题追加到当前对话历史Agent.run(userInput) 应用提供候选工具工具名、用途、参数 JSON Schematools + tool_choice=auto 请求模型决定下一步模型可以直接回答或提出工具调用DashScopeModel.complete(…) 返回了 tool_calls?parseApiReply() 否:普通文本回答 展示模型最终答复本轮结束,等待下一条输入 是:模型提出调用请求 解析调用信息call_id · 工具名 · JSON 参数ToolCall 工具已注册?工具白名单 是 校验工具参数必填项、额外参数、基础类型validateArguments() 参数校验通过?按工具 Schema 检查 是:执行 Java 函数 执行对应工具ToolExecutor.execute(…)计算器或读取本机日期 否:未注册工具错误 否:参数校验错误 组装工具结果消息成功结果或错误信息,role=tooltool_call_id = 原 call_id 模型轮数已达 5 轮?防止循环不结束 是 达到上限,停止本轮 否:继续下一轮,请求模型处理工具结果

注册表是流程的起点:应用启动时创建工具定义,交给 Agent 按名称保存。此后同一份注册表既用于向模型提供候选,也用于应用查找模型请求的本地执行函数。

下面按图中的顺序拆解各个设计环节。

二、按流程拆解关键设计

1. 定义工具:把说明和 Java 执行函数绑定

每个 ToolSpec 包含工具名称、用途说明、参数 JSON Schema 和本地执行函数。Demo 定义了计算器和本机日期两个工具,前者接收算式,后者不需要参数。

工具定义 = 名称 + 用途描述 + 参数 Schema + Java 执行函数

calculator(expression: string)
get_current_date()

以计算器为例,工具名 calculator 与 Java 函数 Calculator.calculate(...) 在注册时绑定。模型只看到工具说明和参数定义,不会拿到这个 Java 函数。

2. 注册工具:应用维护唯一的工具表

程序启动时把工具列表传给 Agent。Agent 按工具名把它们放入 Map<String, ToolSpec>,形成注册表;重复的工具名会被拒绝。这个表有两个用途:提供本轮候选,以及在模型提出调用后查找对应的本地执行函数。

3. 提供候选:把工具定义放进模型请求

每轮调用模型时,程序从注册表取出可用工具列表。百炼适配器将每个工具转换为 Chat Completions 的 tools 项,包含 name、description 和 parameters,并设置 tool_choice 为 auto,表示让模型自行判断是否需要工具。

{
  "type": "function",
  "function": {
    "name": "calculator",
    "description": "计算只包含数字、括号和 + - * / 的算式。",
    "parameters": {
      "type": "object",
      "properties": { "expression": { "type": "string" } },
      "required": ["expression"],
      "additionalProperties": false
    }
  }
}

这是模型可见的“工具菜单”。参数 Schema 告诉模型要提供怎样的输入,也供应用在执行前校验参数。

4. 读取模型响应:判断是否调用以及调用什么

程序读取 choices[0].message.tool_calls。如果没有工具调用,模型返回的文本就是最终答复;如果有调用,程序解析每项中的工具名、参数和调用 ID。

{
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": {
      "name": "calculator",
      "arguments": "{\"expression\":\"123 * 456\"}"
    }
  }]
}
返回字段应用怎样使用
function.name作为注册表查找键,决定请求哪个本地工具。
function.arguments解析为参数对象,校验后传给执行函数。
id作为 call_id 保存,之后用于关联工具结果。

5. 应用校验并执行:模型请求不等于允许执行

runTool() 先检查调用 ID,再按工具名在注册表查找。未注册的工具不会执行。找到工具后,validateArguments() 根据 Schema 检查必填字段、额外字段和基础类型;校验通过才调用注册时绑定的 Java 函数。

模型提出

“调用 calculator,参数是 expression=123*456。”这是调用请求,不是 Java 命令。

应用执行

查到注册项、校验参数后,应用调用 Calculator.calculate() 并取得结果。

未知工具、参数校验失败或执行过程发生异常时,程序不会把请求当成任意函数执行;它会生成一段错误结果,让模型知道本次工具调用没有成功。

6. 返回结果:用 tool_call_id 对应原调用

执行结果被包装成一条 role=tool 消息,并带回原来的调用 ID。这样模型能知道结果对应哪一次工具请求。

{
  "role": "tool",
  "tool_call_id": "call_abc123",
  "content": "56088"
}

应用把这条消息追加到对话历史,再次请求模型。模型可以根据结果继续提出下一项工具调用,也可以直接组织最终答复。若一轮返回多个工具调用,程序会分别执行,并用各自的 tool_call_id 返回结果。

三、关键代码位置

流程环节关键实现
工具定义ToolSpec、CALCULATOR、CURRENT_DATE
建立工具注册表Agent 构造函数按工具名保存定义
提供候选工具Agent.run()、toApiTools()、DashScopeModel.complete()
解析模型调用parseApiReply() 生成 ToolCall
校验与执行runTool()、validateArguments()、ToolExecutor.execute()
把结果交回模型Message.toolResult()、toApiMessages()、Agent.run() 下一轮循环

四、运行 Demo

先运行离线模式,可以在不调用模型 API 的情况下观察程序闭环。离线模型会固定模拟一次计算器调用;它不会根据你输入的问题进行推理。

cd "D:\data\ai学习\ai\Page9"
mvn compile exec:java "-Dexec.args=--demo"

体验模型实际选择工具时,在 PowerShell 中设置百炼 API Key 和控制台里可用的模型名,再启动真实模式:

$env:DASHSCOPE_API_KEY = "你的百炼 API Key"
$env:DASHSCOPE_MODEL = "控制台中可用的模型名"
mvn compile exec:java

可以尝试“计算 123 × 456”“今天的日期是什么”或分成多步的算式。终端会显示提供给模型的候选工具、模型的调用请求、应用执行结果和后续模型轮次。输入 /reset 清空本次进程内对话,输入 /quit 退出。

五、Demo 与实际项目

这个 Demo 把工具调用核心闭环压缩在一个 Java 文件中,便于沿着数据流学习。实际项目的工具不一定是本地计算函数,也可能连接其他子系统的 API、企业服务或操作系统能力。变化的是工具背后的执行方式,整体闭环仍然相同。

调用其他子系统的 API

例如把订单查询封装为 get_order_status(orderId)。模型提出调用后,应用检查工具是否已注册、参数是否有效,以及当前用户是否有权查看该订单;随后由 Java API 适配器调用订单子系统,把响应整理成适合模型读取的结果,再作为工具消息交回模型。Java 可以用 HttpClient 发送 HTTP 请求,也可以使用目标系统提供的 SDK。

模型请求 get_order_status(orderId)
    ↓
应用校验工具、参数与用户权限
    ↓
Java API 适配器请求订单子系统
    ↓
整理响应并关联 tool_call_id
    ↓
结果交回模型

API 密钥和服务凭据由应用在服务端安全保存并附加到请求中,不应放进提示词或让模型生成。生产代码还要处理超时、服务错误、限流和重试;对可能重复产生副作用的请求,需要设计幂等保护。

调用操作系统工具

应用也可以把有限的系统能力包装成工具,例如查询磁盘空间、读取允许目录中的文件列表。能用 Java 标准库完成时,优先直接调用 Java API;确实需要启动外部程序时,可使用 ProcessBuilder,并由应用限定可运行的程序和参数。

不要开放一个让模型传入任意命令字符串的通用执行入口。应使用名称明确、范围有限的工具,校验每个参数,并设置执行超时、工作目录、输出大小和操作系统权限;高影响或不可逆操作还应要求用户确认。

系统和工具变多后使用 MCP

当需要连接多个独立服务时,可以通过 MCP 等协议统一工具的发现和调用。MCP Server 暴露工具名称、说明和输入 Schema,客户端先获取可用工具,再通过 tools/call 请求执行;这能统一连接方式,但不会自动替代应用的身份认证、授权和业务校验。可参考 MCP 工具规范。

共同的控制边界:模型负责提出调用请求;应用或工具服务负责身份认证、授权、参数校验和实际执行。按最小权限开放工具,对删除、付款、发送消息等高影响操作增加确认、限流和审计。OWASP 将过多功能、权限或自主性带来的风险称为 Excessive Agency,参见 OWASP LLM06:2025。

当前 Demo 的 JSON Schema 校验只覆盖必填字段、额外字段和基础类型,并不是完整的 Schema 校验器。实际项目还要按具体 API 和系统能力补齐业务规则、权限检查、失败处理、超时、重试、审计和并发控制。

最重要的设计边界是:模型能够提出工具调用,但应用保留工具注册、权限校验和执行控制权。无论工具在本机、另一个子系统,还是 MCP Server 后面,应用都应在执行前做自己的检查。

总结

用户提出问题后,应用先把注册好的工具说明和对话一起交给模型。模型若返回调用请求,应用就按工具名查注册表、校验参数并运行对应的 Java 函数;工具结果带着原 call_id 回到对话中,模型再决定继续调用还是回答用户。

因此,工具调用不是模型直接执行本地代码,而是一种由模型提出请求、应用受控执行、模型读取结果并继续处理的协作闭环。Page9 的计算器和日期工具,就是这套机制的最小可运行例子。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注