Kimi K3 工具调用返回空参数 / 先从请求体结构对一遍

文章导读
工具调用返回空参数,先别急着换模型或加 prompt。多数情况下问题出在请求体本身:工具 schema 定义得太宽、字段描述含糊到模型无法判断该填什么,或者解析层把没通过校验的字段静默丢掉了。建议按“schema 定义 → 字段语义 → 解析容错”三段依次收窄,每一段都能用日志和返回体验证,而不是靠猜。
📋 目录
  1. 壹 把工具 schema 缩到只剩一个必填字段再试
  2. 贰 在未加工的响应里确认是空对象还是没有工具调用字段
  3. 叁 检查每个字段的描述是否让模型无法判断取值
  4. 肆 对解析层加类型校验与缺失记录
  5. 伍 用固定用例回归,确认空参数不再复现
A A

工具调用返回空参数,先别急着换模型或加 prompt。多数情况下问题出在请求体本身:工具 schema 定义得太宽、字段描述含糊到模型无法判断该填什么,或者解析层把没通过校验的字段静默丢掉了。建议按“schema 定义 → 字段语义 → 解析容错”三段依次收窄,每一段都能用日志和返回体验证,而不是靠猜。

遇到工具调用返回空对象或一段自然语言,先做三件事:把 schema 缩到一个必填字段、打印未加工的响应体、给解析层加逐字段校验。这三步能区分“模型没发起调用”“模型发起了但参数为空”“参数被解析层吞掉”三种情况,处理方向完全不同。schema 和解析层可本地复现,模型侧行为需要结合具体接入环境确认,不要一次改动多处。

把工具 schema 缩到只剩一个必填字段再试

先用最小可用 schema 排除“结构太复杂导致模型放弃填参数”这一可能。最小 schema 只需要三样东西:字段名、类型、必填标记。工具描述写一句能看懂用途的话即可,不要在这一步纠结措辞。

{
  "name": "get_weather",
  "description": "查询指定城市当天的天气",
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string"
      }
    },
    "required": ["city"]
  }
}

把这段放进请求体的 tools 数组,用你原来触发失败的那句 prompt 再发一次,把返回的 tool_calls 里 arguments 原样取出来看。对照判断如下:

  • 最小 schema 能稳定返回 {"city": "..."},原来的复杂 schema 返回 {}:问题在 schema 结构,往下看是否字段过多、嵌套过深、可选字段互相干扰。
  • 最小 schema 也返回 {}:结构和字段数量不是主因,转到第二、第三节,检查响应形态和字段描述。
  • 最小 schema 直接不返回 tool_calls:模型这次没选择调工具,属于调用决策问题,不是参数解析问题,不要在解析层上浪费时间。

原始复杂 schema 里的可选字段、嵌套对象、枚举约束,逐个加回去,每加一层发一次同样的 prompt。哪一层加回去之后 arguments 变空,问题就定位到哪一层。这个加法比一次性删改整个 schema 更好排查。

在未加工的响应里确认是空对象还是没有工具调用字段

很多“空参数”的报告,其实是解析代码先动了手。建议在 SDK 封装之前打一次原始响应体,别用封装后的对象判断。通用做法是拿到 HTTP 响应文本直接写日志或打印:

resp = client.chat.completions.create(
    model="...",
    messages=messages,
    tools=tools,
)
print(resp.model_dump_json(indent=2))   # 换成你接入方式里取原始 JSON 的方法

如果用的是自己拼 HTTP 请求,直接打印响应文本,不要先 json.loads 再打印某个字段。三种返回形态要分清楚:

Kimi K3 工具调用返回空参数 / 先从请求体结构对一遍
  1. 有 tool_calls,arguments 是空对象或空字符串:模型确实发起了调用,但没填参数。往 schema 复杂度和字段描述方向查。
  2. 没有 tool_calls 字段,finish_reason 是正常结束,content 里有文字:模型这次没走工具,返回的是自然语言。属于调用决策,检查工具描述是否说清了“什么时候该用”。
  3. 有 tool_calls,但解析后拿到 None:多半是解析层用了某个不存在的键名,或者字段被默认值覆盖。查解析代码,不查模型。

自然语言回复和空对象在业务上表现得很像——都拿不到参数——但修法完全不同。把原始响应体按请求 ID 存一份,后面回归时也有对照物。

检查每个字段的描述是否让模型无法判断取值

字段名是给程序看的,description 是给模型看的。只写“城市”这种描述,模型无法判断是填“杭州”还是“浙江省杭州市”,遇到边界输入就容易干脆不填。每个字段的描述建议包含三件事:类型、取值范围、一个示例值。

改写前:

"properties": {
  "city": {"type": "string", "description": "城市"},
  "date": {"type": "string", "description": "日期"}
}

改写后:

"properties": {
  "city": {
    "type": "string",
    "description": "城市中文名,只写市级名称,不带省份和后缀,例如:杭州、成都"
  },
  "date": {
    "type": "string",
    "description": "查询日期,格式 YYYY-MM-DD,只接受今天或未来的日期,例如:2025-06-01"
  }
},
"required": ["city", "date"]

对照组要控制变量:同一句 prompt、同一个模型、只换 description 文本,各发一次,比较 arguments 里的字段是否齐全、格式是否符合预期。如果改写后仍然空,说明这个字段对当前输入本来就该由用户澄清,考虑在工具描述里明确写“信息不足时先追问,不要凭猜测填值”。

Kimi K3 工具调用返回空参数 / 先从请求体结构对一遍

另外提醒一点:必填字段越多,模型凑不齐时越容易整体返回空。如果某个字段只是偶尔需要,建议从 required 里拿掉,不要把“可选”硬写成必填。

对解析层加类型校验与缺失记录

解析层最容易出的问题是“静默丢弃”:args.get("city") 拿不到就返回 None,上层当空值处理,日志里什么都不留。改成逐字段校验,缺失和类型错误都要落日志。

import json, logging

log = logging.getLogger("tool_call")

SPEC = {
    "city": str,
    "date": str,
}

def parse_arguments(raw_arguments, request_id, tool_name):
    try:
        args = json.loads(raw_arguments) if raw_arguments else {}
    except json.JSONDecodeError as e:
        log.warning("tool_args_bad_json request_id=%s tool=%s err=%s raw=%r",
                    request_id, tool_name, e, raw_arguments[:200])
        return None

    missing = [k for k in SPEC if k not in args]
    wrong   = [k for k, t in SPEC.items() if k in args and not isinstance(args[k], t)]

    if missing or wrong:
        log.warning(
            "tool_args_invalid request_id=%s tool=%s missing=%s wrong_type=%s raw=%r",
            request_id, tool_name, ",".join(missing) or "-", ",".join(wrong) or "-",
            raw_arguments[:200],
        )
        return None

    return {k: args[k] for k in SPEC}

日志格式用固定键值对,方便后面 grep 和统计。至少要带:请求 ID、工具名、缺失字段列表、类型错误字段列表、原始 arguments 的前若干字符。原始内容建议截断,避免把长参数整段写进日志。校验失败返回 None,让上层走“参数不全需要澄清”的分支,而不是拿一个半残的对象继续执行。

用固定用例回归,确认空参数不再复现

改完 schema 和解析层,需要一组固定 prompt 反复跑,否则只能靠下一次线上出错来判断有没有修好。准备三条用例,覆盖正常输入、含糊输入、超长多约束输入。

  • 用例一:正常单字段。prompt 为“帮我查一下杭州今天的天气”。预期:发起 get_weather 调用,city 为“杭州”,参数校验通过。失败判定:没有 tool_calls,或 city 缺失、为空串。
  • 用例二:含糊输入。prompt 为“明天那边天气怎么样”。预期:要么发起调用并使用上一轮上下文里的城市,要么不调工具、直接追问城市。失败判定:返回 {} 这种空参数调用,或既不追问也不调用。
  • 用例三:多约束长输入。prompt 里同时包含城市、日期和额外无关信息。预期:只填 schema 里定义的字段,多余信息不硬塞进参数。失败判定:字段类型错误(例如日期填成中文)、出现 schema 未定义的键、或整体返回空对象。

每条用例跑三到五次,看的是稳定性而不是单次通过。把三次运行的原始 arguments 和解析日志一起存下来,出现失败时对比是模型侧返回空、还是解析层判空,两种情况在日志里能直接分开。回归用例本身也建议写进仓库的测试目录,schema 一改就重跑,避免下次改字段描述又把这批用例跑坏。