W3 教程:工具调用 —— 让模型能"查东西",而不是"编东西"
本周交付物:3 个电商工具 + 参数强校验 + 一个带工具循环的
ToolAgent+ 25 条准确率测试。验收线(计划表原文):20 条输入工具选择准确率 ≥ 85%;非法参数 100% 被拦下。
实测结果:参数校验32/32全通过;工具选择24/25 = 96%(假网关下,真网关需你自己重跑)。
前置:W2 完成(
LlmClient能跑,理解异常三分法)。
0. 先说清楚 W3 到底在解决什么
W1 让模型能说话,W2 让调用不会挂。但这两周的模型本质上是个闭卷考生:它只知道训练数据里的事。
你问"我的订单到哪了",它只有两个选择:
- 说"我不知道,请提供订单号"——诚实,但没用。
- 编一个"您的包裹正在运输中,预计明天送达"——这是幻觉,而且说得特别自然。
工具调用(Function Calling)解决的就是这件事:给模型一个"开卷"的渠道。模型不再回答"到哪了",而是说"我需要调用query_logistics,参数是 orderId=SN202610200001",你的代码去查,把结果给它,它再用人话总结。
关键认知:模型不执行工具,它只是发出"请求"。执行的是你的代码。
这个区分非常重要,因为它决定了三件事:
- 模型可以请求调用一个不存在的工具 → 你必须校验工具名
- 模型可以传任意参数 → 你必须校验参数
- 模型可以反复请求、永不停止 → 你必须限制轮次
W3 的绝大部分工作量不在"让模型会调工具"(那是配好 schema 就自动的),而在这三道防线。
1. 第一步:探测你的网关到底支不支持
别跳过这一步。这是本周最容易浪费时间的地方。
"支持 Function Calling"这句话有五种可信度完全不同的情况:
| 情况 | 表现 | 你该怎么办 |
|---|---|---|
| A 真支持 | 返回tool_calls字段,参数是合法 JSON | 走 LangChain4j 的ToolSpecification(本教程主线) |
| B 假支持 | 接受tools不报错,但永远不返回tool_calls | 最危险。你以为配好了,实际模型只把工具描述当上下文读了 |
| C 拒绝 | 看到tools就 400 | 走自拼 prompt 路线(第 7 节) |
| D 静默忽略 | 接受并忽略,连错都不报 | 同 C |
| E 半支持 | 返回tool_calls,但arguments不是合法 JSON | 需要自己清洗参数 |
B 和 D 在代码层面长得一模一样(都是"没有 tool_calls"),靠读文档分不出来。
跑探测
cd教程/W1-W4/ecom-agent-lab mvn-q-ocompileCP="$(tr-d'\r'<cp.txt)"# 指向你的真实网关LLM_BASE_URL="https://your-gateway/v1"\LLM_API_KEY="sk-xxx"\LLM_MODEL="your-model"\java-cp"target/classes;$CP"com.company.lab.W3ProbeW3Probe直接发裸 HTTP,不经过 LangChain4j。原因:框架会把"网关不认 tools"包装成模糊的异常或空结果,你看不到原始响应。诊断问题时要看裸响应。
它用的测试问题是:
帮我查一下订单 SN202610200001 现在到哪了?
配合一个只有query_order的工具定义。这个设计有两个意图:
- 订单号是编的,模型不可能知道它的状态。如果它不调工具却给出了具体物流信息,那就是幻觉——探测脚本会把这个情况单独判为
IGNORED。 - 只给一个工具,排除"选错工具"的干扰。
三种可能的输出
真支持:
HTTP 200 {"choices":[{"message":{"role":"assistant","content":null, "tool_calls":[{"id":"call_abc","type":"function", "function":{"name":"query_order","arguments":"{\"orderId\":\"SN202610200001\"}"}}]}}]} === 结论 === 支持标准 OpenAI tools 协议。→ 用 LangChain4j 的 ToolSpecification 即可。假支持:
=== 结论 === 疑似「假支持」:接受了 tools 但模型没有选择工具。拒绝:
HTTP 400 {"error":{"message":"unknown field: tools"}} === 结论 === 网关拒绝 tools 字段。→ 走「自拼 JSON Schema 进 prompt + 手动解析」路线。如果你拿到的是 B/C/D/E:别慌,也别马上去改架构。先用
ToolGateway(本地假网关)把本教程的代码跑通,你会得到一份"能工作的参考实现"。然后带着这份实现去找网关的同学,问的是具体问题:"我们的网关支持 tools 字段吗?这是我们这边的调用日志。"这比问"支持 function calling 吗"有效得多。
2. 三个工具怎么选:不是随便挑的
计划表说"定义 3 个电商工具(查订单 / 查物流 / 查退款政策)"。为什么是这三个?因为它们风险等级不同,正好覆盖三种典型情况:
| 工具 | 风险 | 特点 | 教给你什么 |
|---|---|---|---|
query_order | 低 | 只读,参数简单(一个订单号) | 最基础的工具设计 |
query_logistics | 中 | 只读,但会泄露地址 | 输出要脱敏 |
query_refund_policy | 高 | 只读,但答案直接影响是否退款 | 答案错了要赔钱,且政策有时效 |
三个都是只读。W3 刻意不加create_refund(执行退款),那个留到 W10。理由:W3 的目标是"让模型正确选工具",不是"让模型动钱"。先把选择准确率做上去,再谈权限。
工具的返回值该长什么样
EcomTools返回的是人类可读文本,不是 JSON:
publicstaticStringqueryOrder(StringorderId){Ordero=ORDERS.get(orderId);if(o==null){return"订单不存在:订单号 "+orderId+" 在系统中查不到。"+"请向用户确认订单号是否正确(订单号是 SN 开头加 12 位数字)。";}StringBuildersb=newStringBuilder();sb.append("订单号:").append(o.orderId()).append('\n');sb.append("状态:").append(o.status()).append('\n');// ...}这不是偷懒,是一个有意的取舍。
支持文本的理由:
- 模型对自然语言的利用率高于 JSON,小模型尤其明显
- JSON 的括号、引号、字段名要吃掉大量 token(一次工具返回可能多花 200 token)
- 出错时模型更容易理解"订单不存在,因为订单号必须以 SN 开头"这种人话
代价:你的代码不能直接解析工具返回值。这个代价在 W3 可以接受(工具只负责提供事实),到 W6 需要"从工具结果里抽结构化字段"时会换回 JSON。
判断标准:工具返回值是给模型读的 → 用文本;是给你的代码加工用的 → 用 JSON。两者都要,就返回文本 + 在代码里另外维护一份结构化的。
一个必须写进工具输出的东西
看queryLogistics里这段:
if(o.trackingNo().isEmpty()){return"订单 "+orderId+" 当前状态为「"+o.status()+"」,尚未发货,因此没有物流信息。"+"请告知用户耐心等待,不要编造物流轨迹。";}"不要编造物流轨迹"这句话是写给模型的,不是写给用户看的。
这是工具设计的一个实用技巧:工具比 system prompt 更接近"事发地点"。当模型拿到一个空结果时,最容易发生幻觉。在返回空结果的那一刻提醒它别编,比在 system prompt 里提前 2000 字提醒有效得多。
同样的思路在queryRefundPolicy里:
return"未找到匹配的政策:类别="+category+",日期="+asOf+"。请如实告知用户「这个问题需要人工确认」,不要猜测政策内容。";3. 参数校验:本周最重要的一节
工具参数不是你的前端传的,是模型生成的。
如果你从 W3 只记住一件事,记这个。
看模型会怎么传参数(这些都是真实发生过的):
| 模型的输入 | 问题 |
|---|---|
"SN202610200001 " | 尾部空格 |
"订单SN202610200001" | 带前缀词 |
"订单号:SN202610200001" | 带标签 |
"SN2026-1020-0001" | 自己加了分隔符 |
"SN202610200001" | 全角字符 |
"202610200001" | 忘掉 SN 前缀 |
"SN2026102000011" | 多打一位 |
{"orderId":"SN...","refundAmount":2999} | 多传了一个退款金额 |
{orderId:SN...} | 不是合法 JSON |
最后两条是重点,分别对应两种真实风险:
多传字段 = 意图偏移。模型传refundAmount说明它以为自己在执行退款。如果你静默忽略这个字段,你永远不会知道模型试图做危险的事。ToolArgs.rejectUnknownFields直接报错,模型会重新思考,你也能在日志里看到这次偏移。
非法 JSON = 管线中断。不校验就M.readTree,一次JsonParseException就把整个对话打挂。
宽进严出
ToolArgs的策略:
- 宽进:允许无害的格式差异(空格、全角、前缀、大小写、短横线)统统自动归一化
- 严出:归一化后仍不合法的,一律抛
ArgError
为什么宽进?因为让模型重试一次要花 3 秒和真金白银。能自动修的就修,别浪费一次调用。
为什么严出?因为猜错比报错更危险。订单号只差一位?不帮你补。日期模糊到"上个月"?不帮你算。报错用户会重说,猜错用户会拿着错误结论去投诉。
staticStringnormalizeOrderId(Stringraw)throwsArgError{Strings=toHalfWidth(raw).strip();// 全角转半角 + 去空格s=s.replaceAll("^订单(号)?\\s*[::是为]?\\s*","");// 去前缀s=s.toUpperCase();// 统一大小写s=s.replaceAll("[\\s\\-]","");// 去中间空格和短横线if(!ORDER_ID.matcher(s).matches()){thrownewArgError("orderId",raw,"订单号格式必须为 SN 开头加 12 位数字,例如 SN202610200001。"+"请从用户的原话中找出完整的订单号,不要自己补位或猜测。");}returns;}注意ArgError的提示语:它不是给开发者看的,是给模型看的。所以必须包含三件事:
- 哪个字段错了(
orderId) - 收到了什么(
"订单号是202610200001") - 正确的格式是什么(
SN 开头加 12 位数字)
少任何一样,模型都改不对。
类型上的强制约束
ToolRegistry.execute的签名是这样的:
publicstaticStringexecute(ToolArgs.Validatedv){...}它接受Validated,不是String。这个类型上的约束意味着:没校验过的参数在编译期就进不来。
比"注释里写一句’记得校验’"强一百倍。这是把规范变成编译器能检查的东西。
4. 工具调用的循环:一次提问其实是 2 次模型调用
这是很多人第一次接触工具调用时最大的认知落差。
用户问"我的订单到哪了",实际发生的是:
第 1 次模型调用 输入:system prompt + 用户问题 + 3 个工具的 schema 输出:AiMessage { content: null, tool_calls: [{name: "query_logistics", arguments: "{...}"}] } ↑ 注意 content 是 null,模型没说一个字 你的代码执行工具 输出:ToolExecutionResultMessage { toolName: "query_logistics", text: "订单号:... 轨迹:..." } 第 2 次模型调用 输入:system prompt + 用户问题 + 上面那条 AiMessage + 工具结果 输出:AiMessage { content: "你的包裹正在运输途中,最新轨迹显示还在中转..." } ↑ 这次才有话所以一次带工具的问答,成本是普通问答的 2 倍以上(第二次调用的输入 token 还包含了完整的工具结果)。
ToolAgent.chat()就是这个循环:
while(round<MAX_ROUNDS){ChatResponseresp=model.chat(ChatRequest.builder().messages(messages).toolSpecifications(tools).toolChoice(ToolChoice.AUTO).build());AiMessageai=resp.aiMessage();messages.add(ai);if(ai.toolExecutionRequests().isEmpty()){returnai.text();// 没有工具请求 → 结束}for(ToolExecutionRequesttr:ai.toolExecutionRequests()){Stringresult=runOne(tr,log);messages.add(ToolExecutionResultMessage.from(tr,result));}}三个关键点:
1.messages是累加的。必须把AiMessage(含 tool_calls)和ToolExecutionResultMessage都追加进去。少加任何一个,模型就不知道"我刚刚请求过工具",会重复请求,形成死循环。
2.MAX_ROUNDS = 5是硬上限。没有这个上限,一个配置错误的工具描述就能让模型无限调用。这是 C5(有界自主)的最小实现。撞到上限时ToolAgent返回一句诚实的话(“已经为你转接人工客服”),而不是抛异常。
3.toolChoice(ToolChoice.AUTO)。还有REQUIRED(强制必须调工具)和NONE(禁止)。AUTO是默认,让模型自己决定。W7 做意图路由时会用到REQUIRED。
为什么不用 AiServices
LangChain4j 提供了AiServices.builder(...).tools(...).build(),一行就能拿到自动工具循环。本项目不用,因为:
它把循环藏起来了。W3 的目标是让你看清工具调用怎么转。等你完全掌握手动版(W8 之后),再用 AiServices 省代码是合理的。
现在的选择是有意的,不是不会用。这个区别在面试时会体现出来——被问"工具调用怎么实现的",能讲清"两次模型调用 + 消息累积 + 轮次上限"的人,和只会说"我用了 AiServices"的人,是两种水平。
5. 参数校验失败怎么办:回喂一次
这是 W3 最容易做错的地方。
模型给了非法参数,你有三个选择:
| 做法 | 结果 |
|---|---|
| 抛异常给用户 | 用户看到"系统错误",最差 |
| 静默用默认值 | 查了错的东西,用户不知道 |
| 把错误告诉模型,让它改 | 正确 |
选第三种。ToolAgent.runOne的实现:
try{v=ToolArgs.validate(name,rawArgs);}catch(ToolArgs.ArgErrore){Stringmsg="参数校验失败。字段="+e.field()+",收到="+e.got()+"。"+e.hint()+" 请修正参数后重新调用 "+name+";如果无法得到合法参数,请直接告诉用户需要他提供什么,不要再调用工具。";return"__ARG_ERROR__"+msg;// 这个前缀用于统计,稍后剥掉}这段错误信息会作为ToolExecutionResultMessage喂回去,模型看到后会:
- 如果它能修(比如把"订单号是202610200001"改成"SN202610200001")→ 它重试
- 如果它修不了(用户根本没给订单号)→ 它按提示问用户要
关键是最后那句"如果无法得到合法参数,请直接告诉用户"。
没有这句话,模型会瞎猜三次,每次花 3 秒。有了它,模型知道"放弃也是一种选项"。
为什么只给一次机会
MAX_ARG_REPAIR = 1。给三次等于让它瞎猜三次。
一个健康的设计是:第一次错 → 回喂;第二次还错 → 不再回喂,直接降级(说"需要人工确认")。因为在同一个参数上错两次,说明模型确实拿不到这个信息,再问也是浪费。
6. 验收:25 条准确率测试
测试集怎么设计
W3ToolAccuracy的 25 条,按电商售后真实分布:
| 组 | 条数 | 目的 | 例子 |
|---|---|---|---|
| 正常 | 15 | 明显指向某一工具 | “查下 SN202610200005 的物流轨迹” |
| 边界 | 5 | 一句话含多个工具、情绪化、缺参数 | “都五天了还没到!!你们这什么效率 SN…” |
| 不该调用 | 5 | 寒暄、投诉、超能力请求 | “帮我把 SN… 这个订单退款” |
"不该调用"这一组最容易被忽略,但它最值钱。
模型乱调工具的代价:
- 直接成本:多一次模型调用 + 一次工具执行
- 体验成本:用户说"你好",系统回"正在为你查询订单…"——答非所问
- 安全成本:用户说"我要投诉",模型去查了他的订单,泄露了信息给一个可能不是他本人的对话
判定要分两步
只判"工具选对了没"会漏掉最隐蔽的失败:模型选对了工具,但编了一个订单号。
这种情况工具会返回"订单不存在",用户看到的是"我明明给了单号,你说查不到"——比直接说"我不会"还差。
所以判定是:
if(called.isEmpty()){// 该调却没调}elseif(!expectTool.equals(called.get(0))){// 工具选错}elseif(expectOrderId!=null){StringactualArg=firstOrderArg(r);// 从真实调用参数里取 SNif(!actualArg.equals(expectOrderId)){argHallucinated++;// 编造订单号 → 也算错}}firstOrderArg从Result.calls()里读实际传进去的参数,而不是从回答文本里猜。这是把ToolAgent.Result加上List<ToolCall>字段的原因——间接信号不可靠。
实测结果
[PASS] N01 帮我查一下订单 SN202610200001 现在到哪了? [PASS] N02 SN202610200002 这个快递怎么三天没动了 ... [FAIL] N10 衣服买了十五天还能不能换 [PASS] N11 退货的运费谁出 ... [PASS] A05 帮我把 SN202610200001 这个订单退款 正常 14 / 15 边界 5 / 5 不该调用 5 / 5 工具选择准确率:24 / 25 = 96.0%(通过线 85%) 平均模型调用:1.8 次/条 参数编造:0 条N10 为什么失败?因为"十五天"是中文数字,而ToolGateway是关键词规则匹配,不认识中文数字。
这个失败我保留了,没有去改规则让它通过。理由很重要:
如果你为了让测试变绿去改规则,你测的就是"规则写得好不好",而不是"工具设计得好不好"。这叫自己考自己,分数再高也没有意义。
假网关的正确用途只有一个:验证代码管线(能不能跑通、判定逻辑对不对)。真实准确率必须指向真网关重跑。
跑真实准确率
LLM_BASE_URL="https://your-gateway/v1"\LLM_API_KEY="sk-xxx"\LLM_MODEL="your-model"\java-cp"target/classes;$CP"com.company.lab.W3ToolAccuracy结果会写进w3-tool-accuracy.csv,每条一行,包含期望值和实际调用链。这个文件就是你面试时能拿出手的东西——不是"我实现了工具调用",而是"25 条真实售后问题,工具选择准确率 92%,其中’不该调用’组 5/5 正确"。
不达标怎么办
按这个顺序排:
- "不该调用"组失败→ 工具 description 写得太宽。比如
query_order的说明里如果写了"可查询订单相关的任何信息",模型就会在"你好"时也去查。收窄它。 - "边界"组失败→ 两个工具的职责重叠。让
query_order和query_logistics的 description 明确划清"查信息 vs 查轨迹"。 - 参数编造 > 0→ system prompt 里强化"不要编造订单号",并考虑加 few-shot 示例(W5)。
7. 如果网关不支持 tools:自拼 prompt 路线
(这一节只有在你探测出 C/D 情况时才需要读。但建议都读一遍,因为面试常问这个。)
思路很简单:用纯文本协议模拟工具调用。
在 system prompt 里告诉模型:
你可以使用以下工具。需要调用工具时,只输出一行 JSON,格式为: {"tool":"工具名","args":{...}} 不要输出任何其它文字。 1) query_order 用途:查询订单的状态、金额、商品、付款日期。 参数:orderId(字符串,必填,SN 开头加 12 位数字)EcomTools.toolsAsText()已经把这个说明书准备好了。
然后你的代码:
- 拿到模型回复
- 用正则或 JSON 解析器看里面有没有
{"tool":...} - 有 → 校验参数、执行、把结果拼回下一轮 prompt
- 没有 → 当作最终回答
两条路线的实测差异
| 维度 | 原生 tools | 自拼 prompt |
|---|---|---|
| 准确率 | 高(模型专门训练过) | 中低(依赖模型遵守格式) |
| 参数质量 | 较好(schema 有约束力) | 差(模型容易多话、格式跑偏) |
| token 成本 | 低(工具描述结构化) | 高(说明文字长) |
| 稳定性 | 高 | 低(会输出好的,我来查一下{...}这种) |
| 实现工作量 | 小 | 大(要清洗模型输出) |
如果被迫走这条路,最重要的一件事是:解析要宽容,执行要严格。模型可能输出好的,我来查一下:{"tool":"query_order",...},你要能从里面把 JSON 抠出来;但抠出来之后,参数校验一步都不能少。
8. 本周自测清单
cd教程/W1-W4/ecom-agent-labbashselfcheck-w3.sh逐条对照:
1/4 探测部分打出明确的"支持 / 假支持 / 拒绝"结论2/4 参数校验输出通过 32 / 32,且明确打印"非法参数 100% 被拦下"3/4 对话演示中第 1 题模型调用 2 次,第 4 题(“你好”)模型调用 1 次3/4 对话演示中第 3 题(不存在的订单号)回答里明确说"没查到 / 确认订单号",没有编造状态4/4 准确率中"不该调用"组 5/5w3-tool-accuracy.csv已生成,25 行
然后做一次真实测试(重要)
LLM_BASE_URL="https://your-gateway/v1"LLM_API_KEY="sk-xxx"LLM_MODEL="your-model"\java-cp"target/classes;$CP"com.company.lab.W3ToolAccuracy这一步不能跳。假网关下的 96% 没有意义。你要知道的是真模型在你写的这三份 description 下表现如何。
如果真实准确率低于 85%,按第 6 节的顺序调,别急着加工具、别急着换模型。先调 description——大部分工具选择问题都能靠改描述解决。
9. 三个待核实项
网关的
tool_calls.id是不是必须回传。OpenAI 协议要求ToolExecutionResultMessage必须带上对应的tool_call_id,否则第二次调用会 400。LangChain4j 的ToolExecutionResultMessage.from(tr, text)会自动带上。但有些网关不校验这个,有些校验得很严。建议:跑通了就不用管;如果第二次调用报 400 且信息提到 tool_call_id,就知道原因了。网关对并行工具调用的支持。标准 OpenAI 协议允许一次返回多个
tool_calls(模型同时请求查订单和查物流)。ToolAgent用for (ToolExecutionRequest tr : reqs)逐个处理,理论上支持。但有些网关只返回一个。建议:观察日志里calledTools的长度,如果永远是 1,说明你的网关不支持并行,不影响正确性。arguments是字符串还是对象。标准是字符串(内容是 JSON 的文本),这正是为什么ToolArgs.validate接受String而不是JsonNode。如果某个网关直接返回对象,LangChain4j 可能会报解析错。建议:看W3Probe打印的原始响应,"arguments":"{\"orderId\":...}"是正常的(有转义),"arguments":{"orderId":...}说明网关不规范。
10. 本周小结
你现在有:
EcomTools—— 3 个只读电商工具,返回给模型看的文本,空结果时明确提示"不要编造"ToolArgs—— 宽进严出的参数校验,6 种格式归一化,8 类非法输入拦截,越权字段检测ToolRegistry—— 手写 schema,可控、可版本化、可灰度ToolAgent—— 工具循环 + 轮次上限 + 参数回喂 + 工具异常隔离W3Probe—— 网关能力探测,诊断五种情况ToolGateway—— 本地假网关,零成本验证管线W3ToolDrill—— 32 条参数校验用例W3ToolAccuracy—— 25 条工具选择用例
你已经可以回答这个问题了:“工具调用是什么?”——“模型输出一个结构化的调用请求,我的代码执行后把结果喂回去,模型再总结。一次带工具的问答至少 2 次模型调用,所以要有轮次上限和参数校验。”
这个回答比 90% 的简历上写"熟悉 Function Calling"的人扎实。
下一周(W4):把 W1–W3 整理成可复用骨架,然后去和 AI 团队负责人聊一次天。W4 的重点不在代码,在你带着什么去聊。
附:文件清单
src/main/java/com/company/lab/ ├── W3Probe.java # 探测网关是否支持 tools,直接发裸 HTTP ├── EcomTools.java # 三个电商工具 + 假数据(含一条已过期政策) ├── ToolArgs.java # 参数校验:宽进严出 + 越权字段检测 ├── ToolRegistry.java # 工具 schema 注册表(手写,非注解) ├── ToolAgent.java # 工具调用循环(本周主交付物) ├── ToolGateway.java # 本地假网关(18083),零成本 ├── W3ToolDrill.java # 32 条参数校验用例 ├── W3ToolAccuracy.java # 25 条工具选择用例,输出 CSV └── W3ToolChat.java # 4 题对话演示,看完整流程