跳到主要内容

4 篇博文 含有标签「LLM」

查看所有标签

LLM Function Calling 静默失败?枚举漂移与 Prompt 膨胀

· 阅读需 13 分钟

在生产环境给记账助手发一句补录指令时,模型明明返回了工具调用、参数也是合法 JSON,功能却没有执行——用户只看到一句「没识别到」。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,说人话就能记,AI 负责抽取金额、类目与账户。排查清楚后发现,这不是一次偶发故障,而是两类都会静默发生的结构性问题,分开写成本文的两个场景。

TL;DR​

  • 场景一:工具定义里的枚举与服务端校验的枚举是两处手写的常量,发生了漂移。模型按「说明书」发了合法值,校验端却拒绝它;又因为数量校验挂在数组层,一个非法候选拖垮整卡。修法是枚举单源引用 + 逐项容错。
  • 场景二:为了减少无效候选,我们在 prompt 里教模型「每个候选必须带全参数」。教学让输出变长,小模型的坏 JSON 率从约 6% 升到 12-28%。修法是撤回教学、校验放宽,缺参交给下游兜底。
  • 两条规则:成对出现的枚举必须同一常量单源;教学只教路由、不教抽参。

先分清两张面:模型看到「定义面」,代码守着「校验面」​

Function calling 里,工具定义与参数校验是两套独立代码,模型只见到前者——所有静默失败都发生在这两套代码的失配处。

模型并不真正执行函数。每次调用,你的服务端把工具定义(JSON Schema 格式)发给模型,模型返回一个工具名加一段参数 JSON,真正执行前由你的代码做校验。这意味着同一个「合法取值集合」至少存在于两个地方:发给模型的定义里,和服务端的校验代码里。

我们说的「静默失败」,指的是这类症状:接口不报错、调用有返回、参数看起来是合法 JSON,但功能就是没执行;用户侧只收到一句兜底话术,换一种说法就是「工具调用没生效」「功能偶发失灵」。错误细节只在服务端日志里,如果没有日志埋点,这类问题可能长期存在而不被发现。

LLM 工具调用两类静默失败的修复前后链路对比

场景一:模型按 Schema 发的合法枚举值,为什么被整卡拒绝?​

当工具定义里的枚举与服务端校验枚举不是同一个常量时,模型按定义面发的「合法值」一定被校验面拒绝,而数组层的数量校验会把单项非法放大成整卡作废。

现象:一条只在日志里可见的失败链​

我们的路由模型有一类「消歧」工具:当用户输入存在歧义时,模型不直接执行,而是调用一个元工具输出 2-4 个候选意图,渲染成可点选的卡片让用户确认。每个候选里有一个 tool 字段,取值必须是真实存在的动作工具名。

某天生产日志里出现一条路由失败:用户说「请缺失日期,批量写入」,模型确实调用了消歧工具,arguments 是合法 JSON——但整张候选卡被判非法,用户收到的是「没识别到」的兜底话术。更迷惑的是对照现象:同一句话,用户在第六轮换了个说法,路由就通过了。这说明不是模型抽风,而是某个特定取值触发的确定性失败。

根因:两处手写的枚举,和挂在数组层的数量校验​

排查后找到两个叠加的成因。

成因一:枚举漂移。 工具定义里,候选的 tool 字段枚举用的是「全量工具名集合」;而服务端校验用的枚举,是从全量集合里剔除了一个仅限图片链路的批量工具后的子集——因为这个工具不该被模型提名,提名它会绕过图片链路的专属契约。问题是这两个集合写在两处、各自维护:

// 定义面:发给模型的 JSON Schema(修复前)
tool: { type: 'string', enum: [...ALL_TOOLS] } // 全集

// 校验面:服务端 zod(修复前,另一处手写)
const ALLOWED = ALL_TOOLS.filter((t) => t !== 'batch_split') // 子集
tool: z.enum(ALLOWED)

模型看到的「说明书」说 batch_split 是合法值,就老老实实地用了它;校验面说不合法。模型按你给的 Schema 行事,却被你的校验拒绝——这不是模型犯错,是两张面在说谎。

成因二:数组级校验的整卡作废。 候选列表的数量约束挂在数组层:

options: z.array(OptionSchema).min(2).max(4)

zod 对数组的校验是每个元素都过、任何一个失败整个数组就失败。于是一个非法候选(比如那个 batch_split)会拖垮整张卡——其余三个完全合法的候选陪葬。上层只留了一句兜底话术,真相只在日志里。

修复:枚举单源,然后逐项容错​

第一步,让两张面引用同一个常量(这个修复只有一行):

const DISAMBIGUABLE_TOOLS = ALL_TOOLS.filter((t) => t !== 'batch_split')

// 定义面
tool: { type: 'string', enum: [...DISAMBIGUABLE_TOOLS] }
// 校验面
tool: z.enum(DISAMBIGUABLE_TOOLS)

第二步,把「一个坏元素废整个数组」改成「坏元素只剔自己」:先用 preprocess 做逐项校验,非法项剔除并记日志,剔完再做数量下限判断:

import { z } from 'zod'

const OptionSchema = z.object({
tool: z.enum(DISAMBIGUABLE_TOOLS),
label: z.string().min(1).max(30),
args: z.record(z.unknown()).optional(),
})

// 逐项容错:形状非法的候选剔除,不拖垮整卡;剔除动作记日志便于观测
function filterOptions(raw: unknown): unknown {
if (!Array.isArray(raw)) return raw
return raw.filter((item) => OptionSchema.safeParse(item).success)
}

const ArgsSchema = z.object({
// min 变成「剔后」下限:剔光才整卡拒绝,走兜底话术
options: z.preprocess(filterOptions, z.array(OptionSchema).min(1).max(4)),
})

这里有个边界要交代:逐项容错会静默丢弃非法候选,所以剔除动作必须记 warn 日志,否则坏候选消失得无声无息,观测面就瞎了。另外 min 的语义从此变成「剔后下限」——全部候选被剔光时才整卡拒绝,那时确实没有可展示的东西,走兜底是合理的。

这个「一个多余字段/取值拖垮整条输出」的问题,和我们之前踩过的 Zod .strict() 校验 LLM 输出静默失败 是同族:对完全受控的客户端输入,严格校验是对的;对模型输出,容错要给到单项粒度。

场景二:加了「参数必填」教学后,坏 JSON 率从 6% 翻到 12-28%​

对负载长度敏感的元工具,强制候选带全参数的教学会让输出变长,直接推高小模型的坏 JSON 率——同一组用例 A/B 实测,从约 6% 升到 12-28%。

出发点合理的教学​

场景一修复后,我们又发现新问题:有些候选上了卡,用户点选后才发现缺少必填参数,只能再追问一轮。于是很自然地加了一条教学——「每个候选的 args 必须带全该工具的必填参数,无法预填完整参数的候选不要提名」——并且在三处同时注入:系统提示的规则、工具定义的参数描述、few-shot 示例的注释。

// few-shot 示例(后来撤回的教学)
{
input: '删掉它',
tool: 'suggest_options',
note: '指代不明 → 消歧;每个候选 args 必须带全该 tool 必填参数(如 delete_record 需 domain),
无法预填完整必填 args 的候选不提名',
}

现象:验证闸三连红​

我们有一条验证闸:固定的一组路由用例,每次改动后批量重跑,失败率超阈值就红灯。教学上线后闸连续三轮失败,失败形态高度一致——全部是消歧工具调用的 JSON 格式错误(截断、多余的闭合括号),不是参数校验失败;错误频次从每轮 2 例涨到 5 例,逐轮递增。

归因:旧构建 A/B 对照,把变量钉死​

一开始的怀疑方向是「教学文案本身误导了模型」。但真要下结论,得做对照:新旧两个构建,同一组 4 句用例,每句各跑 16 次,统计坏 JSON 率。

构建坏 JSON 率
旧构建(无 args 必填教学)约 6%(16 次中 1 次;全量闸上同样偶发 1 例)
新构建(三处 args 必填教学)12-28%

6% 那一例值得说一句:旧构建在全量闸上也会概率性飘红,这是小模型的既有底噪,不是新改动引入的。真正的信号是 6% 到 12-28% 的跳变——教学让每个候选都背上了完整参数,消歧工具的输出显著变长,而小模型在更长输出上的 JSON 格式错误率会放大。这个变量关系,单看一轮红绿是看不出来的。

修复:撤教学,校验放宽,下游兜底​

裁决是三步:

  1. 三处 args 必填教学全部剥离,few-shot 只教路由方向(什么输入该走消歧、候选收敛到哪几个),不教抽参。
  2. 校验放宽为「仅校有 args 的候选」:候选带了 args 就用该工具自身的 schema 预演校验,值非法照旧剔除;args 缺失则容忍上卡。
  3. 缺参交给下游确定性出口兜底:用户点选缺参候选时,服务端返回 422 并附引导文案,原卡保留,用户补一句即可。
// 仅对已给出 args 的候选做同源校验:值非法仍剔,缺失容忍上卡
if (option.args !== undefined && !parseToolCall(option.tool, JSON.stringify(option.args))) {
dropped.push(option.tool) // 剔除 + warn 日志
continue
}

为什么敢容忍缺参上卡?因为下游兜底是确定性的——它不依赖模型自觉,而是服务端代码的固定分支。教学想预防的问题已经被代码接住了,教学本身就只剩成本(更长的输出、更高的坏 JSON 率)。这和思考模型吃满输出预算导致空回复是同一族问题:小模型的结构化输出对负载长度敏感,别的静默坑见 DeepSeek/Qwen 结构化调用返回空的排查。

沉淀成两条规则​

LLM 工具调用的可靠性靠两条规则保障:成对出现的枚举必须同一常量单源引用,prompt 教学只教路由方向不教参数抽取。

规则一:成对出现的枚举,必须同一常量单源引用。 只要一个取值集合同时出现在「发给模型的定义」和「服务端的校验」里,就禁止两处各自手写——从同一个常量展开。这条对枚举之外的任何成对约束(字段列表、数量上限)同样成立。

规则二:教学只教路由,不教抽参;判定教学去留用旧构建对照。 给模型的每一条教学都在为输出加长度,而小模型的格式错误率随输出长度上升。路由方向(什么输入走什么工具)是低成本的引导,值得教;参数完整性这类能被确定性校验接住的问题,交给代码兜底,别用 prompt 教。判断一条教学是否该留,别拿单轮测试的红绿下结论——新旧构建对同一组用例各跑十几轮,看坏 JSON 率的跳变才有说服力。

注意事项

逐项容错不是免费的:剔除动作必须记日志(我们用 warn 级别带上被剔的工具名),否则非法候选静默消失,观测面就瞎了。同理,「剔后数量下限」的语义要在代码注释里写清楚,避免后来者把 min 值当原始数量约束改回去——整卡校验一旦回归,场景一的症状会原样复发。

常见问题​

大模型工具调用失败怎么办?​

先查两类静默失败:一是工具定义里的枚举与服务端校验枚举是否引用同一个常量,漂移时模型按 schema 发的合法值会被整卡拒绝;二是 prompt 是否强制模型输出完整参数,负载变长会推高小模型坏 JSON 率。实测案例中坏 JSON 率从约 6% 升到 12-28%,枚举单源加逐项容错后回落。

function calling 的工作原理是什么?​

模型不真正执行函数:它只输出符合工具 JSON Schema 的工具名和参数 JSON,执行与校验都发生在你的服务端。定义 Schema 与校验 Schema 是两套代码,本文两个场景——枚举漂移导致整卡拒绝、教学加码导致坏 JSON 率从 6% 涨到 12-28%——都发生在这两套代码的失配处。

怎么提高大模型工具调用的成功率?​

三件事:数组类参数用逐项校验,单项非法剔除该项而不是整卡作废;枚举等成对出现的取值集合用同一常量单源引用;prompt 教学只教路由方向不教参数抽取,缺参交给下游确定性校验兜底。同一组用例 A/B 对照实测,这三条能把坏 JSON 率从 12-28% 拉回 6% 基线。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

DeepSeek / Qwen 结构化调用成功却返回空?显式关闭 thinking 防推理吃满输出预算

· 阅读需 6 分钟

在对生产数据批量跑 LLM 语义校验时,2449 次调用全部「成功」返回,但结果全部降级为默认值,日志里几乎没有失败记录。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。商品标题优化链路里,需要让 LLM 对「关键词 × 商品」组合做语义校验:每批词一次调用,只返回一个小小的 JSON 判定。这本该是最简单的一类 LLM 调用,结果首跑 2449 对全部走了兜底降级,Layer2 一次有效 LLM 判定都没产生。

TL;DR​

DeepSeek / Qwen 等思考模型的推理文本与正文共享同一份 max_tokens 预算。结构化小输出调用如果不显式关闭 thinking,推理链会独自吃满预算:finish_reason 变 length、content 返回空字符串,而解析失败的重试循环又只记异常不记解析错误——静默重试耗尽后整体降级,全程不报一条错。两件事要做:结构化调用显式关闭 thinking;解析失败时先记 finish_reason,别只 catch 异常。

问题现象​

下面这段最小复现代码展示了整个过程(需要 pip install openai 和一个支持 thinking 的模型):

import os
from openai import OpenAI

client = OpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com",
)

resp = client.chat.completions.create(
model="deepseek-reasoner",
messages=[
{
"role": "user",
"content": (
'判断下面的关键词是否适合写入商品标题,'
'只返回 JSON:{"suitable": true} 或 {"suitable": false}。\n'
"关键词:summer women dress\n"
"商品:floral midi dress for women"
),
}
],
max_tokens=2000, # 推理段与正文共享这份预算
)

print("finish_reason:", resp.choices[0].finish_reason)
print("content:", repr(resp.choices[0].message.content))

thinking 开启时的典型输出:

finish_reason: length
content: ''

HTTP 层一切正常:没有超时、没有 5xx、SDK 不抛异常。如果外层是「解析失败就重试、重试耗尽就兜底」的循环,日志里最后只会留下一条「重试耗尽」的 warning——看起来就像偶发网络问题。

根因​

思考模型的推理段不单独计预算。 DeepSeek、Qwen 等模型的思考内容(reasoning content)与最终正文共用 max_tokens 这一个上限,没有独立的「推理预算」字段。

结构化小输出调用的预算往往设得小。 一个布尔判定预期输出只有几十个 token,max_tokens 给到 2000 已经绰绰有余——但推理链的长度完全不可控,一旦它先吃掉 2000 个 token,模型就再也没有机会输出正文:finish_reason 返回 length,content 是空字符串,接口却正常返回 200。

工程侧还有一个放大器。 空字符串不是合法 JSON,但很多重试循环只 catch 网络与 API 异常,解析失败被当成「这一轮没结果」静默重试。这类吞掉异常的静默失败在重试循环里尤其难查:重试 N 次全部是同一个根因,日志里却只有最后一条兜底 warning,极易误判为网络抖动。

解决方案​

第一步:结构化输出调用显式关闭 thinking​

布尔判定、JSON 抽取、分类打标这类调用不需要多轮推理,按供应商传参关闭:

def thinking_disabled_extra_body(provider: str) -> dict:
"""结构化小输出调用:按供应商显式关闭 thinking。"""
if provider == "deepseek":
return {"thinking": {"type": "disabled"}}
if provider == "qwen":
return {"enable_thinking": False}
return {}


resp = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=2000,
extra_body=thinking_disabled_extra_body("deepseek"),
)

关闭之后,2000 token 预算全部留给 JSON 判定本身。实测修复后重跑,2449 个词×商品对全部正常返回判定结果——而修复前整批静默降级,日志里只有 3 条「重试耗尽」warning。

第二步:别让解析失败静默发生​

即使关掉了 thinking,也要把「解析失败」变成一条带证据的日志,下次任何原因导致的空输出都能在一条日志里定位:

import json
import logging

logger = logging.getLogger(__name__)


def parse_judgment(resp) -> dict | None:
content = resp.choices[0].message.content
try:
return json.loads(content)
except (TypeError, json.JSONDecodeError):
# 关键:记录 finish_reason 与原始 content,而不是只记异常
logger.warning(
"LLM 输出解析失败: finish_reason=%s content=%r",
resp.choices[0].finish_reason,
content,
)
return None

排查 LLM 降级时,先看 finish_reason:length 说明输出预算被打满(大概率是推理占的),stop 才是正常结束。这一步比翻异常日志有效得多。

如果你的返回 JSON 还要过一层 schema 校验,在 TypeScript 项目里用 Zod 时留意另一个校验 LLM 输出静默丢字段的坑。

注意事项

各供应商关闭 thinking 的参数并不统一:DeepSeek 用 {"thinking": {"type": "disabled"}},Qwen 用 {"enable_thinking": False},OpenAI o 系列则是 reasoning_effort 相关参数。接入新供应商前先查文档,别假设参数通用。

如果某供应商的 thinking 无法关闭,就必须按实测推理长度调大 max_tokens,否则同样的静默降级还会发生。

常见问题​

如何关闭 DeepSeek 的 thinking 输出?​

OpenAI 兼容接口通过 extra_body 传 {"thinking": {"type": "disabled"}};Qwen 用 {"enable_thinking": False}。布尔判定、JSON 抽取这类结构化小输出调用不需要推理链,默认关掉最稳,预算全部留给正文。

为什么 DeepSeek API 调用成功但返回空内容?​

思考模型的推理段与正文共享 max_tokens,预算被推理吃满后 finish_reason 返回 length、content 为空,且不抛任何异常。关闭 thinking 或按实测推理长度调大 max_tokens,排查时先看 finish_reason 再看异常日志。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

LLM 批量校验全量走 fallback?容量门控超限的全有全无陷阱

· 阅读需 5 分钟

生产环境首跑一个 LLM 批量校验任务,日志一片绿、状态成功——但检查输出发现 2449 个待校验对象全部标成了降级标记,实际 LLM 调用次数为零。「语义校验默认开启」的功能,等于一次都没开过。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;这个校验任务跑在其数据管道的标题优化环节。

TL;DR​

容量门控按「预计量 ≤ 上限」做全有全无判断:试设的 cap 是 400,生产实际是 2449 个词×商品对,超限 → 整批降级、零 LLM 调用,且任务状态照样是成功。教训两条:容量上限必须用生产实测规模校准;超限降级应按单位粒度(分组/排队/截断)进行,并让「fallback 率 100%」这种异常可被观测。

问题现象​

任务的 Layer2 是 LLM 语义校验,入口有一个容量门控:

def semantic_validate(pairs, cap=400):
if len(pairs) > cap:
# 超限:整批降级,一次 LLM 都不调
return [mark_overflow(p) for p in pairs]
return [llm_validate(p) for p in pairs]

生产首跑结果:

待校验词×商品对:2449/2449 全部 语义校验方式='overflow'
LLM 实际调用:0 次
任务状态:success(无任何报错)

如果只看「跑完没有」,一切正常;只有检查输出列的分布,才发现功能整体失效。

根因​

两层问题叠加。第一层是数值:cap 试设 400,而生产规模是 60 个市场词×同类目商品 + 50 个本店词×商品、共 92 个商品,对数直接到 2449——预估和实测差了一个数量级。第二层是结构:门控是全有全无,超限即整批降级。「这是一个容量约束」的初衷,实际效果是「超限 = 功能整体关闭」,而且降级发生在数据列里、不抛错不打日志,完全静默。

这类「看起来成功、实际没干活」的静默失败和 DeepSeek thinking 吃满输出预算导致空回复静默兜底是同一个家族:错误被兜底逻辑消化,表面上永远 success。

解决方案​

步骤 1:用生产实测规模校准 cap​

上线前先统计真实待处理量,别用拍脑袋的预估值:

# dry-run:只统计规模,不产生 LLM 调用
python -c "from pipeline import build_pairs; print(len(build_pairs(shop='prod')))"

实测 2449 → cap 设 3000(约 1.2~2 倍余量),同时确认超大店铺超出时仍有降级路径,不会撞墙。

步骤 2:把调用粒度从「总量」改为「分组」​

按商品分组调用,让调用次数随商品数线性增长,而不是随 词数×商品数 的乘积暴涨:

def semantic_validate(pairs, cap):
groups = group_by_product(pairs) # 92 商品 → ~92 次调用/轮
results = []
for g in groups:
if within_budget(g, cap): # 按组判断,不整批放弃
results.extend(llm_validate(g))
else:
log.warning("capacity gate: group degraded",
extra={"size": len(g), "cap": cap})
results.extend([mark_overflow(p) for p in g])
return results

本例校准后第三跑实测 2449/2449 全部走 LLM 校验;更大的店铺超出时按组降级,不再一损俱损。

步骤 3:让降级可观测​

给降级路径埋点,并对异常比例告警(如 fallback 率 > 50%)。降级是安全网,不是掩体——它应该被看见,而不是替你掩盖超限。

注意事项

  • 容量类参数(cap、并发、批量大小)上线前必须用生产实测规模校准;测试环境的小样本永远撑不出生产数量级。
  • 全有全无门控只适合「成本硬上限」场景,且必须伴随显式告警;否则它就是一颗静默关闭功能的开关。
  • 降级动作要落在独立可查询的字段/指标上(本例是 语义校验方式 列),验收时先看分布、再看对错。
  • LLM 输出还有一类静默失败来自结构化校验,见 用 Zod 校验 LLM 输出却静默失败?别用 .strict()。

常见问题​

LLM 管道里的 fallback 机制应该怎么设计?​

降级粒度尽量小——按条或按组降级,而不是整批放弃;降级动作必须留痕(标记列、日志、指标)并配置告警。全有全无式门控一旦触发等于整个功能关闭,只适合成本硬上限场景,且要显式报警。

LLM 批量任务的容量上限怎么定?​

不能拍脑袋。先在生产规模或等比样本上跑一次 dry-run 统计实际待处理量,上限设为实测值的 1.5~2 倍,并随业务规模增长定期复核。预估与实测差一个数量级,是这类事故的标配。

怎么发现 LLM 任务被静默降级了?​

任务状态往往仍是成功,必须检查输出:统计降级标记列的占比、核对实际 LLM 调用次数是否与预期一致。fallback 率异常(尤其 100%)应配置告警,把静默失败变成显式信号。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

用 Zod 校验 LLM 输出却静默失败?别用 .strict()

· 阅读需 7 分钟

用 Zod 给 LLM 的 tool_call / function call 输出做校验,模型偶尔多吐一个字段——比如你只定义了 amount / category,它顺手填了个 note——整条校验就挂了,动作被静默丢弃,用户只收到一句「没识别到」,实则是一条 tool_call 被 whole-reject。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,说人话就能记,AI 自动抽取金额、类目、账户;用户说「删掉昨天那杯咖啡」时,模型在 delete locator 里多塞了个 note: "咖啡" 想按备注定位。

TL;DR​

Zod 的 .strict() 等于「对象不许有任何未知键,多一个就报错」。这套约束适合校验你完全控制的客户端,但 LLM 的 function call 输出是模型生成的、本质不可控——它会填入自己「以为该有」的字段,尤其当多个 tool 共用相似 schema 时。一个无关字段就把整条 tool_call 杀掉,校验返回 null,动作静默丢失。解法:去掉 .strict(),用 Zod 默认的 strip(静默删除未知键)容错,配合 safeParse 兜底。

问题现象​

delete/update 的 locator schema 定义了几个已知字段,但用 .strict() 收紧:

import { z } from "zod";

// ❌ 危险:带 .strict()
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).strict(); // ← 未知键一律报错

// 解析 LLM 的 tool_call 参数
function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
return null; // ← 整条 tool_call 被丢弃
}
return parsed.data;
}

用户说「删掉昨天那杯咖啡」,模型给出(合理但多了一个字段的)输出:

{
"date": "昨天",
"noteContains": "咖啡",
"note": "咖啡"
}

模型同时填了 noteContains(schema 内)和 note(schema 外,它以为该有)。.strict() 对 note 这个未知键直接判失败,parseToolCall 返回 null,这条 delete 动作被静默丢弃——用户收到「没识别到」,实际是被整条拒绝。

根因​

.strict() 改的是 Zod 对未知键的策略,而 LLM 输出天然会带未知键。

Zod z.object() 对未知键有三种策略:

写法未知键行为适合场景
默认(strip)静默删除LLM 输出、宽松外部输入
.strict()报错(unknown key)你完全控制的客户端 API
.passthrough()保留原样下游要用未知键时

.strict() 的设计意图是「契约严格性」——服务端定义了什么字段,客户端就该只给什么,多给即违约。这套逻辑对传统 API 成立,因为客户端是开发者写的、可以要求守约。

但 LLM function calling 颠覆了这个前提:

  1. 输出来自模型生成,不是开发者写的客户端。 模型基于 schema 的 description 和示例猜测该填什么,跨域复用的 schema(比如 locator 在 budget / mood / todo 多个域共用)更会让它混淆,填入「它以为该有」的字段。
  2. 字段填错是常态,不是异常。 模型偶尔多吐一个 note、少吐一个可选字段,是 LLM 应用的预期行为,不该用「整条失败」来惩罚。
  3. 失败被静默吞掉。 safeParse 失败后返回 null,上游拿到 null 只能笼统地说「没识别到」,真正的根因(一个 unknown key)藏在 parsed.error 里没人看。
LLM 输出 { date, noteContains, note }
│
▼
.strict() 遇到未知键 note
│
▼
safeParse → { success: false }
│
▼
parseToolCall 返回 null(动作丢弃)
│
▼
用户收到「没识别到」(实则 whole-reject)

解决方案​

1. 去掉 .strict(),用默认 strip 容错​

// ✅ 推荐:不带 .strict(),Zod 默认 strip 未知键(静默删除)
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
});
// 模型多吐的 note 会被静默删掉,已知字段照常解析

去掉 .strict() 后,「删掉昨天那杯咖啡」正常解析为 { date, noteContains },多余的 note 被 strip,delete 动作正确执行。

2. 如果未知键本身有用,用 .passthrough() 显式保留​

当模型多吐的字段其实承载了你想用的语义(比如它填 note 是想表达「按备注定位」),别丢,保留下来再决定怎么消费:

const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).passthrough(); // 保留未知键,parsed.data.note 仍可读

更好的做法是把它收编成已知字段——发现模型反复填某个未知键,说明 schema 缺了这个能力位,补上(比如这里的 noteContains 就是收编「按备注定位」需求后新增的)。

3. 失败要可观测,别静默返 null​

无论哪种策略,safeParse 失败时都要把具体的 error 落日志,而不是吞成 null:

function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
// 把 Zod 的具体报错(哪个键、什么问题)落日志,便于定位
logger.warn(
{ raw, issues: parsed.error.issues },
"locator parse failed"
);
return null;
}
return parsed.data;
}

这样真出问题时,日志里有完整的 issues(含 unknown key 的路径),而不是一句无从下手的「没识别到」。

修完后,「删掉昨天那杯咖啡」→ delete_record { locator: { noteContains: "咖啡" } } 正确解析,不再静默丢失。

注意事项​

注意事项

  • .strict() 适合校验「你控制的客户端」,不适合「LLM 生成的输出」。 判断标准:数据来源是你写的代码 → 可以 strict;数据来源是模型生成 → 用默认 strip 或 passthrough。
  • strip 会丢失未知字段。 如果那个字段承载了模型的意图(如例子里的 note),用 .passthrough() 保留,或直接收编成已知字段,别让意图被静默删掉。
  • 永远用 safeParse 而非 parse。 parse 校验失败会抛异常,在 tool 调度链里可能中断整个流程;safeParse 返回结果对象,失败可控。
  • LLM tool schema 设计要给容错空间。 字段尽量 .optional()、description 写清用途、提供 few-shot 示例;预期到模型会「多填/少填」,schema 层就该兜得住。

常见问题​

为什么 Zod .strict() 会让 LLM 输出校验失败?​

.strict() 要求对象不含任何未知键,多一个就抛 unknown key 错误。LLM 的 function call 输出是模型基于 schema description 猜测生成的,常会填入它「以为该有」的字段(尤其跨域复用的 schema),一旦命中未知键,.strict() 就让整条校验失败、整个 tool_call 被丢弃。

用 Zod 校验 LLM function calling 输出应该用 strict 吗?​

不建议。.strict() 适合校验你完全控制的客户端(开发者写的代码可以要求守约),但 LLM 输出不可控、多填少填是常态。去掉 .strict() 用 Zod 默认的 strip(静默删除未知键)容错性更好;如果未知键承载了想用的语义,用 .passthrough() 保留,或直接收编成已知字段。

Zod 默认对未知字段是 strip 还是报错?​

默认是 strip——静默删除未知键、不报错;.strict() 改为遇到未知键就报错;.passthrough() 改为保留未知键原样。校验 LLM 这类不可控输出时推荐默认 strip 或 passthrough,避免 .strict() 因一个无关字段杀掉整条数据。


CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询