返回内容里看不到工具调用,先别急着改提示词。通常要做的是把两种情况分开:模型这一侧有没有生成调用结构,解析这一侧有没有把生成的结构认出来。成本最低的第一步是打印未被截断的原始返回,看 finish_reason 和工具调用字段是不是空的;只有在原始返回里确实找不到任何调用痕迹时,才需要回头检查工具定义和提示词模板。下面按「先看原始输出、再看解析与模板对齐、最后加日志和回归」的顺序展开。
原始返回里存在 tool_calls 或函数调用 JSON,问题通常落在解析层:字段名对不上、层级取错、arguments 是字符串没反序列化。原始返回里连调用痕迹都没有,才优先怀疑工具定义和提示词模板有没有把调用规则讲清楚。两端都要留原始样本,否则偶发失败没法复现。适用边界:不同接入方式的字段命名可能不同,以你实际收到的原始 JSON 为准,不要照搬别人的字段名。
打印模型原始输出,确认调用到底有没有被生成
在调用代码里保留完整返回,不要只打印你关心的那个字段,也不要在打印前做切片或 json.dumps 后截断。通用骨架如下,替换成你实际的客户端调用即可:
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools, # 若你的接入方式不用 tools 参数,换成对应写法
)
msg = resp.choices[0].message
print('FINISH:', resp.choices[0].finish_reason)
print('CONTENT_TYPE:', type(msg.content).__name__)
print('CONTENT:', repr(msg.content))
print('TOOL_CALLS:', repr(getattr(msg, 'tool_calls', None)))
print('RAW_BODY:', resp.model_dump_json()) # 或打印中间层捕获的 HTTP 响应体观察位置有三个。finish_reason 若是 tool_calls 一类取值,说明模型已经按调用格式结束;若是普通的 stop,而 content 里只有一段自然语言,说明模型没生成调用。其次看 content 正文里有没有内联的 JSON 或自定义标签,有些模型会把调用写进正文而不是结构化字段。最后看 tool_calls 是空列表还是 None,这两者在后续解析里往往走向不同分支。如果 SDK 已经帮你把调用解析成对象,建议同时打开客户端的调试日志,拿到真正的响应体再对照一次。
把工具定义结构与解析器读取的字段逐项对齐
这一步不是改代码,而是拿工具定义原文和解析代码里的读取 key 做两列对照:左边写你实际传给模型的工具定义完整 JSON,右边写解析器取的字段路径,逐项标出差异。
工具定义一侧 解析器读取一侧
name getattr(fn, 'name', None)
parameters.properties fn['parameters']['properties']
required fn['parameters'].get('required', [])
arguments(字符串) json.loads(fn['arguments'])常见的不匹配有两类。一是层级错位,定义里是 function.name,解析代码按顶层 name 取,取到 None,调用被静默丢弃或直接抛 KeyError。二是先写后错,定义写法与解析写法在开发中各自改过,字段名不一致。字段确实缺失时的表现通常是:解析结果为空、调用被丢弃、或者只留下 name 没有 arguments。不要凭猜测补未公开字段,一律以你实际收到的原始 JSON 为准。可以先写一段最小提取函数,把解析路径集中在一处,方便对照:
def extract_calls(message):
calls = getattr(message, 'tool_calls', None) or []
out = []
for c in calls:
fn = getattr(c, 'function', None) or c
name = getattr(fn, 'name', None) or (fn.get('name') if isinstance(fn, dict) else None)
args = getattr(fn, 'arguments', None) or (fn.get('arguments') if isinstance(fn, dict) else None)
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
log_bad_sample(c)
continue
if not name or args is None:
log_bad_sample(c)
continue
out.append({'name': name, 'arguments': args})
return out检查提示词模板里工具说明的位置与格式
如果接口本身支持工具参数,工具说明应该走参数,不要再在提示词里重复描述,重复容易造成格式冲突。如果你的接入方式只能靠提示词传递,就要确认说明放在哪一段。系统段写法骨架:
[system]
你是任务助手。
可用工具(JSON Schema):
{"name": "get_weather", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}
调用规则:需要调用时必须输出 {"name": ..., "arguments": {...}},不要用自然语言描述调用过程。用户段写法骨架:
[user]
请查询北京今天的天气。
可用工具:get_weather(city)
需要调用时按上述格式输出。观察差异的方法很直接:同一条用户输入、同一份工具定义,只改工具说明所在段落,各跑一次,比较原始输出里的调用结构、finish_reason 和 arguments 是否可解析。如果系统段版本稳定产出调用结构、用户段版本变成自然语言描述,那说明模板位置确实影响了输出结构,而不是解析层的问题。这一步只做对照,不要一次改多个变量。
对解析失败加一层可观测记录
偶发失败最难查,因为现场没留下。建议在调用出口处统一记录三样东西:原始输出、解析异常、请求参数。骨架如下,日志字段按你的日志库调整:
def call_agent(payload, expect_tool=False):
try:
resp = client.chat.completions.create(**payload)
msg = resp.choices[0].message
calls = extract_calls(msg)
if expect_tool and not calls:
logger.warning('no_tool_call', extra={
'request': redact(payload),
'raw': resp.model_dump_json(),
'finish_reason': resp.choices[0].finish_reason,
})
return calls
except Exception:
logger.exception('parse_failed', extra={'request': redact(payload)})
raise验证方式:用一个明确不含调用的用例触发一次,确认日志里能看到 raw、finish_reason 和 request 三个字段,并且原始输出没有被截断。参数里如果带密钥、用户标识或隐私文本,先用 redact 处理再落盘。样本保留下来之后,后面的复现和回归才有依据。
用固定用例回归验证
准备两个用例,工具定义始终传入。用例 A 应当产生调用,例如「查一下北京今天天气」,工具集里只有 get_weather。用例 B 不应当产生调用,例如「你好,介绍一下你自己」。判定标准写清楚:
- 用例 A:解析出恰好一个调用,name 与工具定义中的名称一致,arguments 能被解析为对象且包含必填字段。
- 用例 B:解析出零个调用,finish_reason 为正常结束,正文有可读回复,而不是空内容。
两个用例各连续跑若干次,看是否稳定一致。出现偶发失败时,回到上一节的日志里找对应的原始输出样本,再判断是模型没生成还是解析层漏认。修复动作一次只改一处,改完重跑同一组用例,才能确认是哪个环节带来的变化。