1. 日期合法、查询成功,结果也可能答非所问
小周要查2026 年 8 月每天的支付金额。
小林没有先问模型能不能答对,而是给查询工具做了一次故意“填错单子”的测试:把工具参数改成9 月 1 日到 10 月 1 日,看看查询前的代码会不会拦住。
结果,日期能解析,范围也是完整自然月,原来的日期校验全部通过。按这组参数执行,本地教学库会返回9 月的 500.00 元;小周要的 8 月,合计应该是770.00 元。
“数是从库里查出来的,怎么还是错的?”小周问。
小林把两张单子摆在一起:“数据库按执行单查得很认真。我们漏掉的是,执行单有没有照着这次的需求写。”
日期格式正确、范围合法,只能说明这组参数符合查询规则。执行前还要核对:它是否与本次请求已经确定的月份一致。
下面先给出排查清单,再保留原来的 Java 包装方案:在真正调用 MCP 查数工具之前比较月份,不一致就拒绝这次调用。
人物、订单和金额均为虚构教学数据。错月参数由程序测试主动构造,不代表观察到某个真实模型发生了这次错误,也不表示测得了模型的错月概率。
查数结果不对,按这张清单逐项对账
| 检查项 | 需要对照什么 | 发现问题后怎么处理 |
|---|---|---|
| ① 用户到底要哪一月 | 原始问题、明确的年份和月份;相对日期还要有基准时间与时区 | 年份或“上个月”的解释不明确,先澄清并保存确定后的条件 |
| ② 应用保存了什么 | 本次请求保存的startDate、endExclusive和指标口径 | 意图已经理解错,先修正意图;后面的相等判断不能补救这一步 |
| ③ 工具准备查什么 | 同一次请求、同一次工具调用的实际 JSON 参数 | 缺字段、类型错误、日期非法或不是完整自然月,拒绝查询 |
| ④ 两份日期是否一致 | 用应用保存的范围比较实际工具范围 | 不一致就挡在原工具之前;不能让模型同时提供两份“正确答案”自证一致 |
| ⑤ 检查是否真的生效 | 实际工具名、两个call入口、默认工具与请求工具的注册情况 | 防止未包装的查数入口绕过检查,并核对不同请求的月份没有串用 |
| ⑥ 最后展示了什么 | 实际查询条件、返回记录、日期范围、金额单位与最终文字 | 空结果如实说明;工具查对但文字说错,要继续检查答案生成环节 |
日期一致只是其中一项。表、字段、状态过滤和金额定义,也要按应用规则分别核对;本文这层包装不替代权限控制或其他业务校验。
可以直接复制的排查记录
请求标识 / 工具调用标识: 用户要求的年月与统计口径: 应用保存的范围:[startDate, endExclusive) 实际工具名: 实际工具范围:[startDate, endExclusive) 参数合法性:通过 / 不通过,理由是什么 与本次意图的一致性:通过 / 不通过 本次原查询工具调用次数: 实际返回行数、日期与金额口径: 最终回答是否与工具结果一致:先在本地教学测试中填写,不记录生产账号、凭据或订单明细。对于被拦截的那一次错误调用,原工具调用次数应为0;若之后发生重试,应按新的调用标识另记,不能把两次尝试混在一起。
技术基线沿用 Java 17、Spring Boot 4.1.0、Spring AI 2.0.1。完整查数流程见第六篇,本文继续使用本地 H2 教学数据。
2. 日期为什么有机会在中途变掉?
先把前面的调用过程摊开:
小周提出问题 ↓ 模型整理出查询意图 ↓ Java 校验意图里的日期 ↓ 把日期写进提示词 ↓ 模型生成工具调用参数 ↓ MCP 工具执行查询注意中间这两步:整理查询意图和生成工具调用参数。
它们不是同一个 Java 对象一路传到底。前面整理出了一份日期,后面模型又要生成一份工具参数。
比如,意图解析的结果是:
{"status":"READY","startDate":"2026-08-01","endExclusive":"2026-09-01","metric":"PAY_AMOUNT","groupBy":"DAY","clarification":""}但我们构造的工具参数却是:
{"table":"demo_orders","dateField":"pay_date","amountField":"pay_amount","groupBy":"pay_date","startDate":"2026-09-01","endExclusive":"2026-10-01"}可以把它想成两张单子:
- 第一张是需求单:这次要查 8 月。
- 第二张是执行单:工具准备查 9 月。
前面的代码检查了需求单,工具又检查了执行单,但两边都没有把这两张单子放在一起对一遍。
在提示词里写“请严格使用这个日期”有帮助,但这句话不会自动变成一段 Java 判断。
3. “日期合法”和“月份正确”,到底差在哪里?
原来的自然月检查,大致是这样的:
LocalDatestart=LocalDate.parse(startDate);LocalDateend=LocalDate.parse(endExclusive);if(start.getDayOfMonth()!=1||!end.equals(start.plusMonths(1))){thrownewIllegalArgumentException("只支持查询一个完整自然月");}它会挡住这些范围:
- 8 月 15 日到 9 月 15 日:不是从月初开始。
- 8 月 1 日到 10 月 1 日:一次跨了两个月。
但 9 月 1 日到 10 月 1 日呢?它就是一个完整的自然月,所以会通过。
这段代码回答的是“这个范围能不能查”,没有回答“这个范围是不是小周这次想查的”。
| 检查什么 | 9 月 1 日到 10 月 1 日 |
|---|---|
| 格式:能不能解析成日期? | 能,通过 |
| 范围:是不是完整自然月? | 是,通过 |
| 本次意图:是不是要查的 8 月? | 不是,应该拒绝 |
前两道检查仍然有用,只是还差第三道。
4. 先补一个小关卡:发出查询前,对一下月份
小林没有推倒重写整个助手。他先在真正调用远程查询工具之前,加一道检查:
模型给出工具参数 ↓ 取出工具参数中的日期 ↓ 与本次请求已经保存的月份比较 ├─ 一致:调用原来的 MCP 工具 └─ 不一致:拒绝这次查询继续复用第六篇的MonthRange。它是一个保存开始日期和结束日期的 record,parse负责校验完整自然月,equals可以比较两个范围是否相同。
核心逻辑其实只有几行:
MonthRangeexpected=MonthRange.parse(intent.startDate(),intent.endExclusive());MonthRangeactual=MonthRange.parse(toolStartDate,toolEndExclusive);if(!expected.equals(actual)){thrownewIllegalArgumentException("查询月份与本次意图不一致,未执行查询");}expected是需求单,actual是执行单。两张单子对不上,就先别往数据库发。
这里有个容易漏掉的细节:expected 必须来自应用已经保存的本次意图。
不要让模型同时提供“期望月份”和“实际月份”。如果它两边都填 9 月,比较当然能通过,检查就失去了意义。
这就像核对收货地址:应该拿订单里保存的地址去对快递单,不能让快递单自己再写一个“正确地址”,然后自己证明自己没写错。
5. 怎么把这道检查接到 MCP 工具前面?
在 Spring AI 里,可以把ToolCallback理解成“真正去调用工具的那个 Java 对象”。
我们给原来的工具套一层:先检查日期,再决定是否调用原对象。这个做法使用的是 Spring AI 的 ToolCallback 接口。
在第六篇客户端的com.example.shopai包下新增DateCheckedTool.java:
packagecom.example.shopai;importorg.springframework.ai.chat.model.ToolContext;importorg.springframework.ai.tool.ToolCallback;importorg.springframework.ai.tool.definition.ToolDefinition;importorg.springframework.ai.tool.execution.ToolExecutionException;importorg.springframework.ai.tool.metadata.ToolMetadata;importtools.jackson.databind.JsonNode;importtools.jackson.databind.json.JsonMapper;/** 在调用远程查数工具前,核对本次请求已经确定的月份。 */publicfinalclassDateCheckedToolimplementsToolCallback{privatestaticfinalJsonMapperJSON=JsonMapper.builder().build();privatefinalToolCallbackdelegate;privatefinalMonthRangeexpected;publicDateCheckedTool(ToolCallbackdelegate,MonthRangeexpected){this.delegate=delegate;this.expected=expected;}@OverridepublicToolDefinitiongetToolDefinition(){returndelegate.getToolDefinition();}@OverridepublicToolMetadatagetToolMetadata(){returndelegate.getToolMetadata();}@OverridepublicStringcall(Stringinput){checkDates(input);returndelegate.call(input);}@OverridepublicStringcall(Stringinput,ToolContextcontext){checkDates(input);returndelegate.call(input,context);}privatevoidcheckDates(Stringinput){try{JsonNodearguments=JSON.readTree(input);if(arguments==null||!arguments.isObject()||!arguments.path("startDate").isString()||!arguments.path("endExclusive").isString()){thrownewIllegalArgumentException("查询日期必须是字符串且不能为空");}MonthRangeactual=MonthRange.parse(arguments.path("startDate").stringValue(),arguments.path("endExclusive").stringValue());if(!expected.equals(actual)){thrownewIllegalArgumentException("查询月份与本次意图不一致,未执行查询");}}catch(RuntimeExceptionexception){thrownewToolExecutionException(getToolDefinition(),exception);}}}读这段代码时,先盯住这两句就够了:
checkDates(input);returndelegate.call(input);检查在前,执行在后。检查抛出异常,下面的远程调用就走不到。
其余代码主要是在保留原工具的行为:工具定义照旧返回,元数据照旧返回,带ToolContext的调用也照旧传下去。两个call入口都要检查,不能只守住其中一个。
另外,本系列使用 Jackson 3,因此上面的导包是tools.jackson...。不要直接和旧项目里的 Jackson 2 导包混在一起。
6. 接线时,别把原来的“直通入口”留着
包装类写完,还不代表已经生效。要让这次请求真正使用包装后的工具。
先补一个小工具类RequestTools.java:
packagecom.example.shopai;importjava.util.Arrays;importorg.springframework.ai.tool.ToolCallback;/** 每次请求建立自己的工具集合,月份不保存在共享可变字段中。 */publicfinalclassRequestTools{privateRequestTools(){}publicstaticToolCallback[]bindMonth(ToolCallback[]discovered,StringqueryToolName,MonthRangeexpected){longmatched=Arrays.stream(discovered).filter(tool->tool.getToolDefinition().name().equals(queryToolName)).count();if(matched!=1){thrownewIllegalStateException("未找到唯一的查数工具,请核对实际注册名称");}returnArrays.stream(discovered).map(tool->tool.getToolDefinition().name().equals(queryToolName)?newDateCheckedTool(tool,expected):tool).toArray(ToolCallback[]::new);}}这里只包装查数工具,找表、看字段等其他工具继续使用原来的对象。
queryToolName要用客户端实际注册的查数工具名。看启动时输出的available_tool日志来确定;有些配置会给工具名加前缀,不要想当然写死一个query。
如果一个都没找到,或者意外匹配到多个,就直接报错。不能“没包上也先凑合查”,否则检查等于白加。
然后调整第六篇的 Controller。把构造器里收到的ToolCallbackProvider留在字段中:
privatefinalToolCallbackProviderremoteTools;// 放在原来的构造器里this.remoteTools=remoteTools;构建ChatClient时,保留原来的defaultSystem,去掉.defaultTools(remoteTools)。
为什么要去掉?因为当前这条.tools(...)注册路径会把请求工具加入已有工具集合,不能指望它自动清除默认工具。原来的远程工具如果仍作为默认工具存在,就可能留下绕过日期检查的入口。相关行为可以对照 Spring AI 工具注册说明。
在ask方法确认意图为READY后,放进原有的try中:
MonthRangeexpected=MonthRange.parse(intent.startDate(),intent.endExclusive());StringqueryToolName=System.getenv("QUERY_TOOL_NAME");ToolCallback[]checkedTools=RequestTools.bindMonth(remoteTools.getToolCallbacks(),queryToolName,expected);启动前,把QUERY_TOOL_NAME配置成刚才日志里确认的实际工具名。没有配置或者名字不对,上面的匹配检查会拒绝继续。
接着,这次模型请求只注册处理后的工具集合:
Stringanswer=chatClient.prompt().tools((Object[])checkedTools).user(user->user.text(""" 用户原始问题:{question} 本次限定意图:查询每日支付金额。 已校验开始日期:{start},包含。 已校验结束日期:{end},不包含。 请使用远程工具完成查询,并解释实际结果。 """).param("question",request.question()).param("start",intent.startDate()).param("end",intent.endExclusive())).call().content();原来对空回答的检查和异常处理继续保留。上面是需要替换、插入的关键片段,不是让大家删掉其余逻辑重写 Controller。
还有一点:每次请求创建自己的DateCheckedTool,把本次月份保存在 final 字段里。
不要在一个共享工具对象里放可变的currentMonth。小周正在查 8 月,小王又发来一个 9 月请求,两个人共用一个会被改写的月份,反而可能串起来。
7. 用验收清单确认:错月参数没有到达原工具
别只问模型一次,然后看到正常答案就宣布修好了。
这个问题更适合直接构造工具参数:正确参数应该能查,错误月份必须在原工具执行前被拒绝。
教学版的本地验证复用了前面的WarehouseTools、建表脚本和 H2 教学数据:
| 本次意图 | 工具参数 | 验证结果 |
|---|---|---|
| 2026 年 8 月 | 8 月 1 日至 9 月 1 日,不含结束日 | 返回 4 个有支付记录的日期,合计 770.00 元 |
| 2026 年 8 月 | 9 月 1 日至 10 月 1 日,不含结束日 | 拒绝,原查数工具未执行 |
| 2026 年 8 月 | 8 月 1 日至 8 月 31 日,不含结束日 | 拒绝,不是完整自然月 |
| 2026 年 6 月 | 6 月 1 日至 7 月 1 日,不含结束日 | 正常执行,返回空结果 |
最后一行也很重要:查对月份,但数据库没有记录,是正常情况。不能把“查出来为空”和“查询条件错了”混为一谈。
另外还检查了非法 JSON、日期字段缺失、字段类型错误、工具名不匹配、两个调用入口、上下文透传,以及不同请求之间的月份隔离。
这组教学代码保留的本地验证记录为:Java 17 下编译运行,21 项断言通过。
这里的验证边界也说清楚:它验证的是包装逻辑、原查数工具和本地 H2 数据,没有经过真实大模型与 HTTP MCP 的完整链路。所以不能据此声称某个模型必定会选对工具,或者线上错误率已经降低了多少。
自己接入时,再勾一遍这 6 项
- 正确月份能够执行;无记录的月份正常返回空结果。
- 完整但错误的月份被拒绝,本次原工具调用次数为 0。
- 非法 JSON、缺字段、非字符串日期和非整月范围都被拒绝。
call(String)与call(String, ToolContext)两个入口都走校验,允许调用时上下文仍能传到原工具。- 工具名没有匹配或重复匹配时直接失败,没有保留未包装的查询入口。
- 两个请求分别绑定 8 月与 9 月,并发调用时各用自己的范围;交叉传入另一月参数时被拒绝。
这是一份接入后的验收要求。检查“有没有真的执行”,比只看返回文字里有没有报错更可靠。
8. 加上这道检查,就一定不会查错了吗?
还不能这么说。
这道检查守住的是:应用保存的本次意图,必须与实际发出的工具日期一致。
如果前面的意图解析本身就错了呢?
小周说:我要查 8 月 模型理解成:9 月 应用保存的意图:9 月 工具实际参数:9 月两张单子都是 9 月,这道检查当然会通过。
所以,“日期校验通过”不等于“模型已经完全理解了用户”。格式检查、范围检查、意图理解、执行参数一致性,是不同的问题。
对金额核算等更在意准确性的场景,可以在查询前先展示一段条件摘要:
将查询:2026 年 8 月,按支付日期统计每日支付金额。是否继续?
用户确认后,应用保存这份条件,再去核对工具参数。这样,比把所有希望都压在一句提示词上更容易检查。
这道检查也没有保证模型最后写出来的文字一定忠实于工具结果,最终回答的校验仍要单独考虑;它也不负责强制“先找表、再看字段、最后查询”的调用顺序。
至于日期检查失败后,是把错误交回模型让它重试,还是交给应用的异常处理,取决于具体的工具异常处理配置。我们这里必须守住的一点是:这一次错误参数不能到达原查询工具。
9. 小林这次补上的,是两张单子之间的核对
前面的文章解决了“怎么理解问题、怎么发现工具、怎么把数据查出来”。
这次补的是一个容易被忽略的小问题:
查之前理解的月份,和真正执行时拿到的月份,是不是同一个?
数据库会认真执行收到的条件,MCP 会传递工具调用参数,模型会根据上下文生成内容。
但“需求在传递过程中有没有变样”,还需要应用自己检查。
小周要查的是 8 月。
所以,9 月的 500 元即使查得再准确,也交不了这次的作业。