这篇能做出什么
按下面的步骤走完,你会得到一条可复现的工具调用修复链路:先跑一份小规模回归集,拿到“工具选对率、参数可解析率、参数匹配率”三个基线数字;再按“请求 Schema → 对话模板 → 工具解析器 → 约束解码 → 回归测试”逐层定位;最后把修复后的配置固化成启动参数和测试脚本。典型效果是:原来模型把“查天气”调成“搜新闻”、把 unit 填成中文“摄氏度”、或者返回一段带 <tool_call> 的纯文本,现在能稳定输出合法的 tool_calls,并且每次改 prompt 或换模型都能用同一套用例回归。
前置条件清单
- 一台能跑 vLLM 的机器,模型已下载到本地路径,例如
/models/your-model。 - 已安装 vLLM 与 OpenAI Python SDK,命令以官方文档当前版本为准。
- 准备 20~50 条真实用户问法,覆盖每个工具的“该用”和“不该用”场景。
- 明确模型家族对应的工具调用格式。vLLM 的
--tool-call-parser可选值随版本变化,以官方文档当前版本为准;常见的有hermes、mistral、llama3_json、pythonic等。 - 一个能发 HTTP 请求的终端,默认服务端口用
8000。
第一步:先跑基线,不要先改 prompt
启动一个带工具调用能力的服务。下面命令里的模型路径、parser 名称按你的模型替换:
```bash
vllm serve /models/your-model \
--served-model-name tool-model \
--host 0.0.0.0 \
--port 8000 \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--chat-template /path/to/tool_chat_template.jinja
```
如果模型 tokenizer 自带的 chat template 已支持 tools,可以省略 --chat-template。但一旦出现“工具定义像没进 prompt”的现象,就要显式指定模板。
准备回归集 cases.jsonl,每行一条:
```jsonl
{"id":"weather-1","messages":[{"role":"user","content":"北京明天出门要带伞吗?"}],"expected_tool":"get_weather","expected_args":{"city":"北京","unit":"celsius"}}
{"id":"weather-2","messages":[{"role":"user","content":"帮我查一下上海今天多少度"}],"expected_tool":"get_weather","expected_args":{"city":"上海","unit":"celsius"}}
{"id":"ticket-1","messages":[{"role":"user","content":"登录一直报 500,帮我提个高优先级工单"}],"expected_tool":"create_ticket","expected_args":{"title":"登录报 500","priority":"high"}}
```
用 Python 跑基线,统计工具名准确率、参数 JSON 可解析率、参数子集匹配率:
```python
import json, requests
URL = "http://localhost:8000/v1/chat/completions"
TOOLS = json.load(open("tools.json", encoding="utf-8"))
def norm(v):
if isinstance(v, str):
return v.strip().replace("市", "")
return v
def subset_match(expected, actual):
for k, v in expected.items():
if k not in actual or norm(actual[k]) != norm(v):
return False
return True
total = tool_ok = json_ok = arg_ok = 0
for line in open("cases.jsonl", encoding="utf-8"):
case = json.loads(line)
total += 1
body = {
"model": "tool-model",
"messages": case["messages"],
"tools": TOOLS,
"tool_choice": "auto",
"temperature": 0,
"max_tokens": 512,
}
r = requests.post(URL, json=body, timeout=60).json()
msg = r["choices"][0]["message"]
calls = msg.get("tool_calls") or []
if not calls:
print(case["id"], "NO_TOOL_CALL", msg.get("content", "")[:200])
continue
name = calls[0]["function"]["name"]
if name == case["expected_tool"]:
tool_ok += 1
try:
args = json.loads(calls[0]["function"]["arguments"])
json_ok += 1
except Exception:
print(case["id"], "BAD_JSON", calls[0]["function"]["arguments"][:200])
continue
if subset_match(case["expected_args"], args):
arg_ok += 1
else:
print(case["id"], "ARG_MISMATCH", args)
print({"total": total, "tool_acc": tool_ok/total, "json_ok": json_ok/total, "arg_acc": arg_ok/total})
```
先记录这三个数字,后面每改一层都重跑同一份用例。
第二步:把失败样本分到四层
拿到失败样本后,不要笼统说“模型不准”,按下面四层归类:
1. 请求层:工具名、描述、参数 schema 写得太泛,模型分不清该用哪个工具。
2. 模板层:chat template 没有把 tools 渲染进 prompt,模型根本看不到工具定义。
3. 解析层:模型输出了工具调用格式,但 --tool-call-parser 与模型家族不匹配,服务返回的 tool_calls 为空。
4. 解码层:参数类型、枚举值、必填字段没有约束,模型自由发挥导致 JSON 不合法或字段缺失。
一个快速判断方法:看响应里的 finish_reason 和 message.content。如果 tool_calls 为空,但 content 里出现 <tool_call>、[TOOL_CALLS] 之类的标记,优先查解析器和模板;如果 tool_calls 有值但 arguments 不是合法 JSON,优先查 schema 和约束解码。
第三步:修工具 Schema,让“选哪个”和“填什么”都无歧义
工具描述要写“什么时候用、什么时候不用”,不要只写“查询数据”。参数尽量用 enum、required、additionalProperties: false 收口。示例:
```json
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市当前或未来天气。仅当用户询问天气、温度、降雨、穿衣建议时使用;不要用于新闻、交通、股票查询。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市中文名,例如“北京”“上海”,不要带省份前缀"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位。中文用户默认使用 celsius"
}
},
"required": ["city", "unit"],
"additionalProperties": false
}
}
}
```
同时做三件事:
- 工具名用动词开头,近义工具要拉开描述差异,例如
search_docs只查内部文档,search_web只查公开网页。 - 工具数量多时,先做一层路由或分组,不要把几十个工具一次性塞进请求。
- 参数里能枚举就枚举,能写
pattern就写pattern,例如工单优先级用["low","medium","high"]。
改完 schema 先只重跑回归集,不要同时改解码参数,否则无法判断收益来自哪一层。
第四步:对齐 chat template 与 tool-call-parser
模板和解析器是一对。模板决定工具定义以什么格式进入 prompt,解析器决定从模型输出里怎么抽 tool_calls。两者不匹配的典型现象是:模型明明在按格式输出,接口却返回空 tool_calls。
检查方法:
```bash
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "tool-model",
"messages": [{"role":"user","content":"北京天气"}],
"tools": [{"type":"function","function":{"name":"get_weather","description":"查天气","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],
"tool_choice": "auto",
"temperature": 0
}' | python -m json.tool
```
如果 tool_calls 为空,换一个与模型家族匹配的 parser 再试;如果换 parser 后仍为空,检查启动日志里是否有模板渲染或工具解析相关警告。不要凭模型名字猜 parser,直接查官方文档当前版本的对应表。
第五步:用约束解码收口参数
工具调用链路里,不建议对整个 tool_calls 输出做强 JSON 约束,因为这会和模型特有的工具调用特殊 token 冲突。更稳的做法是“两阶段”:
- 阶段 A:只让模型选工具名。可以用
guided_choice(当前版本支持时)或response_format的 JSON Schema 约束一个只含tool字段的对象。 - 阶段 B:拿到工具名后,用该工具的参数 schema 做
guided_json或response_format约束,只生成参数。
阶段 B 的请求示意:
```json
{
"model": "tool-model",
"messages": [
{"role": "user", "content": "北京明天出门要带伞吗?"},
{"role": "assistant", "content": "{\"tool\":\"get_weather\"}"},
{"role": "user", "content": "只输出 get_weather 的参数 JSON"}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "get_weather_args",
"strict": true,
"schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city", "unit"],
"additionalProperties": false
}
}
},
"temperature": 0
}
```
约束 schema 尽量保持简单:少用深层 oneOf/anyOf,避免运行时编译失败;字符串枚举、数字范围、必填字段通常就够。若使用 vLLM 的 guided_json 扩展参数,确认当前版本的 OpenAI 兼容接口支持该字段,具体以官方文档为准。
第六步:把回归测试固化下来
修复后把评测脚本放进 CI 或每次发版前的手动清单。建议至少保留这些指标:
- 工具选择准确率:只看
function.name。 - 参数 JSON 可解析率:
arguments能否json.loads。 - 参数 schema 通过率:用 JSON Schema 校验器跑一遍。
- 参数匹配率:期望参数是否为实际参数的子集,字符串做去空格、去“市”等归一化。
- 混淆矩阵:记录“期望工具 → 实际工具”,看清是把天气错成新闻,还是把工单错成搜索。
回归时固定 temperature=0,并保留每次请求的原始 content、tool_calls、finish_reason。如果 finish_reason 是 length,先加 max_tokens,不要误判成模型能力问题。
常见坑与排错
| 现象 | 优先查 | 处理 |
|---|---|---|
| 启动报错提示需要 tool parser | 启动参数 | 同时加 --enable-auto-tool-choice 和 --tool-call-parser |
tool_calls 为空,content 里有工具标记 | 解析器/模板 | 换匹配模型家族的 parser,显式指定支持 tools 的 chat template |
| 模型答“我无法查询天气” | 模板 | 工具定义没有渲染进 prompt,检查模板是否处理 tools 字段 |
| 工具名对了,参数多出字段 | Schema | additionalProperties: false,并把不必要字段从 properties 去掉 |
| 参数枚举值写成中文 | Schema/解码 | 用 enum 收口,描述里给默认值示例 |
| JSON 被截断 | 解码 | 提高 max_tokens,看 finish_reason 是否为 length |
| 约束解码报 schema 编译失败 | 解码 | 简化 schema,减少深层联合类型,先用小 schema 验证 |
| 多工具场景只返回一个调用 | 请求参数 | 检查 parallel_tool_calls 设置与用例预期 |
下一步建议
先把这份回归集扩到每个工具至少 10 条“该用/不该用”样本,再加入多轮对话和AI 词典:并行工具调用">并行工具调用。线上采样时记录解析失败率、参数校验失败率和工具名混淆矩阵,按周回放失败样本。工具数量继续增长时,加一层轻量路由:先用小模型或规则把候选工具缩到 3~5 个,再走完整工具调用链路。这样每次换模型、改模板、调 schema,都有数字可对比,不靠感觉判断“好像准了”。
