跳到主要内容

3 篇博文 含有标签「LLM」

查看所有标签

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

· 阅读需 6 分钟

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

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

TL;DR

DeepSeek / Qwen 等思考模型的推理文本与正文共享同一份 max_tokens 预算。结构化小输出调用如果不显式关闭 thinking,推理链会独自吃满预算:finish_reasonlengthcontent 返回空字符串,而解析失败的重试循环又只记异常不记解析错误——静默重试耗尽后整体降级,全程不报一条错。两件事要做:结构化调用显式关闭 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 返回 lengthcontent 是空字符串,接口却正常返回 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_reasonlength 说明输出预算被打满(大概率是推理占的),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 返回 lengthcontent 为空,且不抛任何异常。关闭 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能力落地于真实商业场景。

合作咨询