工具定义别靠猜:用 JSON Schema 约束入参
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇咱们用纯 Java + OkHttp 把 ReAct 循环跑通了:五个组件一装,退款场景转了四圈工具调用,查物流两圈搞定,Thought-Action-Observation 循环已经能在 IDE 里断点调试了。
但文末那张表里第一行就写着:“工具定义太简陋——description() 返回一段文本,参数格式靠大脑自己猜。”你可能跑几次没遇到问题,觉得也还行。那咱们先看看它什么时候会翻车。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.1.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
先看翻车现场
1. 参数格式靠猜,猜错了就翻车
上一篇的 ApplyRefundTool,description() 是这么写的:
发起退款申请。输入:JSON 格式,包含 orderId(订单号)和 reason(退款原因)。返回:申请结果,包含退款单号。
大脑看到这段描述后,大多数时候能正确输出 JSON:
Action Input: {"orderId":"88231","reason":"质量问题:扫地机无法回充"}
但隔几次你就会遇到这些情况:
Action Input: orderId=88231, reason=质量问题
Action Input: 88231 质量问题:扫地机无法回充
Action Input: {"order_id":"88231","refund_reason":"质量问题"}
第一种用了键值对格式,第二种直接拼字符串,第三种用了错误的字段名。三种情况的共同点是 ——invoke() 里解析 JSON 直接报错,工具返回异常信息,大脑看到错误再试一次,运气好能纠正,运气不好连试三次格式都不对,用户在那干等。
为什么会这样?因为 description() 只是一段自然语言,大脑理解自然语言有歧义。“输入:JSON 格式,包含 orderId 和 reason”——这句话对人来说很清楚,但大模型可能理解成各种格式。它不知道字段名是驼峰还是下划线,不知道值应该是字符串还是数字,不知道是不是每个字段都必填。
2. 再看一个更隐蔽的问题
QueryOrderTool 的描述是:
查询订单详情。输入:订单号(如 88231)。返回:商品名、下单时间、签收时间、订单状态、运单号。
大脑大部分时候会正确传入 88231。但偶尔你会看到:
Action Input: 订单号:88231
Action Input: orderId: 88231
Action Input: {"orderId":"88231"}
第一种在前面加了中文前缀,第二种加了字段名,第三种直接传了 JSON。invoke() 里用 input.trim() 拿到的不是纯订单号,equals("88231") 匹配失败,返回“订单不存在”。大脑看到这个结果,可能会以为订单真的不存在,给用户一个错误的回复——比翻车更危险,因为你都不知道它错了。
3. 问题的根源
两个翻车现场指向同一个根源:工具的入参描述不够精确,大脑只能靠猜。
自然语言描述有三个先天缺陷:
| 缺陷 | 表现 | 后果 |
|---|---|---|
| 格式模糊 | “输入:JSON 格式”——什么样的 JSON? | 大脑可能传 JSON、键值对、纯文本,格式不可控 |
| 字段名不确定 | “包含 orderId”——是 orderId 还是 order_id? | 解析时找不到字段,工具执行失败 |
| 约束缺失 | 没说哪些必填、什么类型、取值范围 | 大脑可能漏传参数或传了不合法的值 |
解决方案其实你已经见过了。在 RAG 系列的 Function Call(函数调用)那一篇里,模型 API 原生的工具定义就是用 JSON Schema 来描述参数的——字段名、类型、是否必填、描述,全部用结构化的格式写死,不给模型发挥的空间。
这一篇,咱们就把这套思路搬到 ReAct Agent 里来。
下面这张图把问题和解法放在一起看——左边是升级前,大脑只能靠猜;右边是升级后,JSON Schema 把格式锁死:

JSON Schema:把参数说清楚的标准方式
1. 什么是 JSON Schema
JSON Schema 是一套用 JSON 格式来描述 JSON 数据结构的标准。简单说,它是 JSON 的说明书——告诉你一段 JSON 里应该有哪些字段、每个字段是什么类型、哪些必填。
举个例子,applyRefund 工具需要的入参是这样的 JSON:
{"orderId": "88231", "reason": "质量问题:扫地机无法回充"}
用 JSON Schema 来描述这段 JSON 的结构:
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "订单号,如 88231"
},
"reason": {
"type": "string",
"description": "退款原因,需说明具体问题"
}
},
"required": ["orderId", "reason"]
}
这段 Schema 把三件事说得明明白白:
- 字段名:
orderId和reason,驼峰命名,不是order_id,不是refund_reason。 - 类型:都是
string,不是数字,不是布尔。 - 必填:
required里写了两个字段,一个都不能少。
大脑看到这段 Schema,就不需要猜了 ——格式是 JSON 对象,字段名是确定的,类型是确定的,必填也是确定的。输出结果的一致性会大幅提升。
2. 常用的 Schema 关键字
在工具定义里,你用到的 JSON Schema 关键字不多,掌握这几个就够了:
| 关键字 | 作用 | 示例 |
|---|---|---|
type | 值的类型 | string、number、integer、boolean、object、array |
properties | 对象里包含的字段 | {"orderId": {"type": "string"}} |
required | 必填字段列表 | ["orderId", "reason"] |
description | 字段的自然语言说明 | "订单号,如 88231" |
enum | 枚举值,限定取值范围 | ["refund", "exchange"] |
items | 数组里每个元素的类型 | {"type": "string"} |
这些关键字足以覆盖比特严选智能体的五个工具的所有参数定义。更复杂的 Schema 特性(oneOf、$ref、pattern 等)在工具定义场景里几乎用不到,不需要学。
重新设计 Tool 接口
1. 上一篇的接口
先回顾上一篇的 Tool 接口:
public interface Tool {
String name();
String description();
String invoke(String input);
}
三个方法:我叫什么、我能干什么、执行。description() 返回一段自然语言文本,参数格式全靠这段话来传达。
2. 加上参数 Schema
现在要把参数定义从自然语言升级到 JSON Schema。最直接的做法是加一个方法,返回参数的 Schema:
public interface Tool {
String name();
String description();
String parameters();
String invoke(String input);
}
description() 还是返回一句话概述工具的用途,但不再承担描述参数格式的任务。参数的精确定义全部交给 parameters() 返回的 JSON Schema 字符串。
有些工具不需要参数(比如 getCurrentTime),parameters() 返回空 Schema 或空字符串就行。为了减少重复代码,给它一个默认实现:
public interface Tool {
String name();
String description();
default String parameters() {
return "";
}
String invoke(String input);
}
不需要参数的工具就不用覆写 parameters(),需要参数的工具覆写它返回自己的 Schema。
3. 五个工具的参数 Schema
现在把比特严选的五个工具逐个补上 Schema。
查询订单工具——只需要一个订单号:
public class QueryOrderTool implements Tool {
@Override
public String name() {
return "queryOrder";
}
@Override
public String description() {
return "查询订单详情,返回商品名、价格、下单时间、签收时间、订单状态、运单号。";
}
@Override
public String parameters() {
return """
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "订单号,如 88231"
}
},
"required": ["orderId"]
}""";
}
@Override
public String invoke(String input) {
String orderId = ToolUtils.extractField(input, "orderId");
if ("88231".equals(orderId)) {
return "{\"orderId\":\"88231\",\"product\":\"比特 S10 Pro 扫地机\","
+ "\"price\":1999,\"orderTime\":\"2026-06-20\","
+ "\"signTime\":\"2026-06-22\",\"status\":\"已签收\","
+ "\"trackingNo\":\"SF1234567890\"}";
}
return "{\"error\":" + ToolUtils.toJsonString("订单不存在:" + orderId) + "}";
}
}
注意 invoke() 的变化:上一篇直接拿 input.trim() 当订单号用,现在改成从 JSON 里提取 orderId 字段。既然告诉大脑要传 JSON,invoke() 里也得按 JSON 来解析——前后一致,不留歧义。
ToolUtils.extractField()是个工具方法,后面的从入参到解析小节会统一实现,这里先看效果。
退款申请工具——两个必填字段,这正是上面翻车的重灾区:
public class ApplyRefundTool implements Tool {
@Override
public String name() {
return "applyRefund";
}
@Override
public String description() {
return "发起退款申请,返回退款单号和预计到账时间。";
}
@Override
public String parameters() {
return """
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "订单号"
},
"reason": {
"type": "string",
"description": "退款原因,需说明具体问题,如 质量问题:扫地机无法回充"
}
},
"required": ["orderId", "reason"]
}""";
}
@Override
public String invoke(String input) {
String orderId = ToolUtils.extractRequiredField(input, "orderId");
if (orderId.isBlank()) {
return ToolUtils.missingRequiredField("orderId");
}
String reason = ToolUtils.extractRequiredField(input, "reason");
if (reason.isBlank()) {
return ToolUtils.missingRequiredField("reason");
}
return "{\"success\":true,\"refundId\":\"RF20260629001\","
+ "\"orderId\":" + ToolUtils.toJsonString(orderId) + ","
+ "\"reason\":" + ToolUtils.toJsonString(reason) + ","
+ "\"message\":\"退款申请已提交,预计 1-3 个工作日到账\"}";
}
}
对比上一篇的 description(),参数信息从一段自然语言变成了结构化的 Schema:字段名锁死 orderId 和 reason,类型锁死 string,两个都是 required。invoke() 里也要跟着做必填校验,缺 orderId 或 reason 时直接返回明确错误,避免出现参数没拿到却仍然“退款成功”的假成功。大脑看到这个 Schema,输出 {"order_id":"88231"} 这种错误字段名的概率会骤降。
查物流工具——输入是运单号:
public class QueryLogisticsTool implements Tool {
@Override
public String name() {
return "queryLogistics";
}
@Override
public String description() {
return "根据运单号查询物流轨迹,返回承运商、物流状态和轨迹详情。";
}
@Override
public String parameters() {
return """
{
"type": "object",
"properties": {
"trackingNo": {
"type": "string",
"description": "快递运单号,如 SF1234567890"
}
},
"required": ["trackingNo"]
}""";
}
@Override
public String invoke(String input) {
String trackingNo = ToolUtils.extractField(input, "trackingNo");
if ("SF1234567890".equals(trackingNo)) {
return "{\"trackingNo\":\"SF1234567890\",\"carrier\":\"顺丰速运\","
+ "\"status\":\"已签收\",\"traces\":["
+ "{\"time\":\"2026-06-20 18:20:00\",\"desc\":\"快件已揽收\"},"
+ "{\"time\":\"2026-06-21 09:10:00\",\"desc\":\"快件到达上海转运中心\"},"
+ "{\"time\":\"2026-06-22 11:35:00\",\"desc\":\"快件已由本人签收\"}"
+ "]}";
}
return "{\"error\":" + ToolUtils.toJsonString("未找到物流轨迹:" + trackingNo) + "}";
}
}
检索知识库工具——输入是用户的查询文本:
public class SearchKnowledgeTool implements Tool {
@Override
public String name() {
return "searchKnowledge";
}
@Override
public String description() {
return "检索比特严选知识库,返回匹配的售后政策、常见问题或产品信息。";
}
@Override
public String parameters() {
return """
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词或问题,如 扫地机 退货政策"
}
},
"required": ["query"]
}""";
}
@Override
public String invoke(String input) {
String query = ToolUtils.extractField(input, "query");
return "{\"query\":" + ToolUtils.toJsonString(query) + ","
+ "\"matched\":\"七天无理由退货政策\","
+ "\"content\":\"签收次日起 7 天内,商品外观和主要配件完整,"
+ "可申请退货;质量问题需先进行售后检测。\"}";
}
}
获取当前时间工具——不需要参数,用默认的空 parameters() 就行:
public class GetCurrentTimeTool implements Tool {
@Override
public String name() {
return "getCurrentTime";
}
@Override
public String description() {
return "获取当前日期和时间,无需任何参数。";
}
@Override
public String invoke(String input) {
String now = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"));
return "{\"currentTime\":\"" + now + "\"}";
}
}
五个工具全部改完。核心变化就一个:参数定义从 description() 里的自然语言,搬到了 parameters() 里的 JSON Schema。description() 只管说自己能干什么,parameters() 专管说要传什么参数,职责分离。
升级 ToolRegistry
1. 旧版 buildToolList() 的问题
上一篇的 buildToolList() 只拼了工具名和描述:
1. queryOrder - 查询订单详情。输入:订单号(如 88231)。返回:商品名、下单时间、签收时间、订单状态、运单号。
2. queryLogistics - 根据运单号查询物流轨迹……
大脑只看到一行文本,参数的精确格式全靠理解这句话。现在有了 parameters() 返回的 JSON Schema,buildToolList() 要把 Schema 也塞进去。
2. 新版 buildToolList()
public class ToolRegistry {
private final Map<String, Tool> tools = new LinkedHashMap<>();
public void register(Tool tool) {
Objects.requireNonNull(tool, "tool must not be null");
tools.put(tool.name(), tool);
}
public String execute(Action action) {
if (action == null || action.toolName() == null || action.toolName().isBlank()) {
return "{\"error\":\"未解析到可执行的工具名称\"}";
}
Tool tool = tools.get(action.toolName());
if (tool == null) {
return "{\"error\":\"未找到工具:" + action.toolName() + "\"}";
}
return tool.invoke(action.toolInput() == null ? "" : action.toolInput());
}
public String buildToolList() {
StringBuilder sb = new StringBuilder();
int index = 1;
for (Tool tool : tools.values()) {
sb.append(index++).append(". ")
.append(tool.name())
.append("\n 描述:")
.append(tool.description())
.append("\n");
String params = tool.parameters();
if (params != null && !params.isBlank()) {
sb.append(" 参数 Schema:")
.append(params.strip())
.append("\n");
} else {
sb.append(" 参数:无\n");
}
sb.append("\n");
}
return sb.toString();
}
}
变化只在 buildToolList() 里:每个工具除了名称和描述,还把参数 Schema 原样输出。大脑在系统提示词里看到的工具列表变成了这样:
1. queryOrder
描述:查询订单详情,返回商品名、价格、下单时间、签收时间、订单状态、运单号。
参数 Schema:{"type":"object","properties":{"orderId":{"type":"string","description":"订单号,如 88231"}},"required":["orderId"]}
2. applyRefund
描述:发起退款申请,返回退款单号和预计到账时间。
参数 Schema:{"type":"object","properties":{"orderId":{"type":"string","description":"