问题出在哪
先看一段代码。模型通过 Function Calling 返回参数,代码这么写:
args=json.loads(tool_call.function.arguments)ifargs["include_refunded"]:query=build_query(include_refunded=True)这一段要看清一个前提:只有当 schema 把参数声明成string、或者漏写type时,模型才会给出字符串。声明为boolean时它给的是真布尔——这一点后面有实测。
arguments里的值是带引号的字符串"false":
{"include_refunded":"false"}这行进了 Python 之后,json.loads把它变成字符串'false',而 Python 里非空字符串是真值:
bool('false') = True bool('False') = True bool('FALSE') = True bool('0') = True bool('no') = True bool('') = False六个里只有空字符串是假。所以if args["include_refunded"]走进True分支,退款订单被算了进去。
没有任何报错。类型错了,但 Python 不吭声,数字就这么错了。
一个想当然的推断
初稿的结论曾是「模型经常把布尔参数写成字符串」。这听起来合理:模型输出文本,字符串布尔是常见现象,网上的文章也这么说。
但这句话经不起追问:有实测支撑吗?
没有。于是做了一组实测:三个模型 × 四个自然语言提示词 × 参数声明为type: boolean=12 次调用。下面只贴了其中一个模型的 4 条(完整 12 条在证据文件里):
布尔 ✓ False (bool) 查一下最近的订单,不要包含已退款的。 957ms 布尔 ✓ True (bool) 列出订单,退款的那些也一起给我。 1068ms 布尔 ✓ False (bool) 我要看不含退款的订单,最近 10 条。 862ms 布尔 ✓ False (bool) 帮我查订单,包含退款和不包含退款的都查一次… 1449ms12 次全部返回真布尔。字符串 0 次。
这个前提错了。所以又做了第二组实验:字符串到底在什么条件下出现?
决定返回类型的是 schema,不是模型
固定同一个提示词,只改 schema 里那个参数的写法:
| 参数写法 | 三个模型的返回 |
|---|---|
① 声明type: boolean | 3 个都是bool |
② 声明type: string | 3 个都是str |
| ③ 不声明 type,只给描述 | 2 个bool,1 个str(不一致) |
| ④ 描述里带引号示例 | 3 个都是bool |
结论很清楚:模型老实按 schema 声明返回。
第 ④ 种要说清它的局限:那一组只改了description的文字,type: boolean一直在,所以不能据此说「描述比示例权重高」——起作用的一直是type。它只证明了一件事:描述里出现带引号的示例写法,不会让模型改用字符串。
所以真正的问题不在模型。问题在于:schema 声明了什么类型,与代码运行时实际拿到什么类型,这两件事之间没有校验。
关于模型名,说明一句:deepseek-flash、deepseek-v4-pro、glm-5.3-flashx是调用时实际传的model参数值,不是化名。本文实验日期为 2026 年 10 月,走的是各厂商 OpenAI 兼容接口,全部为非流式调用(流式返回未测,流式下tool_calls的分片拼装是另一套逻辑)。模型版本会随厂商更新,隔一段时间复现可能得到不同结果——这正是这类结论必须附实验时间的原因。
第 ③ 种最该盯住:三个模型给出两种类型。这一组只测了 3 个样本,不足以断言「哪个模型一定偏向哪种」,但足以说明会出现不一致——而代码里若写着if isinstance(v, bool),它就只在部分模型上正确,不会报错,只会在换模型时突然失效。
后果:数字错了,但没有报错
用 6 条订单数据(其中 1 条已退款)对比两种写法:
天真写法 if args["include_refunded"]: → 命中 6 条 合计 3779.5 显式归一 to_bool(args["include_refunded"]) → 命中 5 条 合计 3691.5差 1 条订单、88.0 元。没有异常抛出,日志里也不会出现任何错误。
左边是现在多数的走法:值进了if,非空字符串判为真,退款被算进去,没有任何提示。右边是归一之后的结果。
怎么改
不要信「模型应该给对类型」,也不要信「schema 声明了运行时就不必校验」。在边界上做一次显式归一。
defto_bool(value)->bool:"""把模型给的值归一到布尔。认不出来就抛错,不猜。"""ifisinstance(value,bool):returnvalueifisinstance(value,int)andvaluein(0,1):returnbool(value)ifnotisinstance(value,str):raiseValueError(f"无法当布尔处理:{value!r}")low=value.strip().lower()iflowin("true","1","yes","on"):returnTrueiflowin("false","0","no","off"):returnFalseraiseValueError(f"无法当布尔处理:{value!r}")它的行为:
to_bool('false') = False to_bool('true') = True to_bool('False') = False to_bool('1') = True to_bool('FALSE') = False to_bool('yes') = True to_bool('0') = False to_bool(True) = True to_bool('no') = False to_bool(False) = False to_bool('off') = False to_bool(1) = True to_bool(0) = False大小写、数字、缩写都归一掉,"false"不再可能变成True。
注意最后那条路径:认不出来就raise,不返回False。
两个容易被忽略的边界
None和空字符串要拒绝,不要默认成False。
to_bool(None) -> 拒绝: 无法当布尔处理: None to_bool('') -> 拒绝: 无法当布尔处理: '' to_bool('maybe') -> 拒绝: 无法当布尔处理: 'maybe'这两者语义不同:None通常是模型没给这个参数(等于没表态),空字符串是没有信息。把它们当成False,等于替用户做了「不包含退款」的决定。宁可抛错,让它暴露出来。
不要把json.loads和eval相提并论。
初稿写过「用eval或json.loads兜底就等于把任意输入交给解析器」。这句话是错的。json.loads不执行代码,它的失败方式是抛JSONDecodeError;eval会执行代码。两者风险差着量级,并列会让人误判。
json.loads在本文语境里要防的是另一件事:它不校验类型。{"include_refunded": "false"}是合法 JSON,它会老老实实给你一个字符串——这正是类型缺口溜进来的地方。
抛错之后,这一轮工具调用怎么办
to_bool抛出ValueError,工具函数就失败了。在 agent 循环里,这一轮工具调用拿不到结果——接下来怎么做?
能把错误说清楚就回传给模型,让它改。Function Calling 的标准做法是把工具结果
(包括错误)作为一条tool消息写回对话,模型看到之后可以重新调一次:
try:include=to_bool(args["include_refunded"])exceptValueErrorasexc:return{"error":str(exc),"hint":"该参数只接受 true/false"}这条错误信息回了模型,它有机会把"maybe"改成true或false重试。
但有两类不该让它自愈。
一是语义没表态的情况:None或空字符串表示模型没给这个参数,或者给了个空值。
这不是拼写问题,回传过去它可能随便猜一个值来满足格式要求——而「随机猜一个」
比直接失败更糟,因为猜错不会报错。
二是涉及金额、权限、删除这类不可逆动作的参数。这种时候重试的代价高于失败,
应当直接中断并让调用方看到,不要指望模型自己纠对。
所以判断标准是:拼写、大小写这类可纠正问题可以回传重试;语义没表态和涉及金额、权限、删除这类不可逆动作的参数必须当场停。
上手动作
第一步,查代码里有没有这个缺口。Windows 上用 PowerShell:
# 找把模型参数直接丢进条件判断的地方Get-ChildItem-Recurse-Filter*.py|Select-String-Pattern'if\s+args\[','if\s+args\.get\(','bool\(args\['不要只搜bool\(——那会命中所有普通类型转换代码,噪声远大于信号,
跑一遍基本定位不到真正的类型缺口。要搜的是参数直接进条件判断的写法。
Linux 或 macOS:
grep-rn"if args\["--include=*.pygrep-rn"args.get("--include=*.py第二步,对照本文第 ③ 种 schema 写法检查自己的 tool 定义。每个布尔、数字、枚举参数是不是都写了type。少写一个type,你就落在「不同模型给不同类型」那个区间里。
第三步,凡是来自模型的值进业务分支之前,过一次显式归一。