现象:请求成功,内容为空

批量给博客的 112 篇文章重写 meta description,脚本跑了两天都正常。换到本机模型后,日志开始出现这种记录:

1
2
3
[1/47] 处理中... 耗时 170.4s
返回内容长度: 0
跳过(内容为空)

HTTP 200,done: true,没有任何报错,就是 message.content 是空字符串。最迷惑的地方在于它慢——一次请求跑满 170 秒才返回,看着像模型在很努力地工作,结果交了个白卷。

先怀疑的是模型没加载完、显存不够、或者 ollama 版本问题。这几个方向全查了一遍,都不是。

根因:推理 token 和正文共用同一个预算

出问题的模型是 thinking 型(具备推理链能力的那种,qwen3 系列、deepseek-r1 系列都属于这一类)。这类模型生成回复时,token 消耗分两段:

  1. 推理段(reasoning / thinking tokens)—— 模型内部先想一遍
  2. 正文段(content tokens)—— 真正返回给你的答案

关键点:这两段共用 num_predict 这个上限。

当时的请求参数是 num_predict: 2048。对普通模型来说 2048 足够写 1500 字,没问题。但对 thinking 模型,推理段就可能烧掉 1800~2000 个 token——预算在第 2000 个 token 处被掐断,而此时正文段还没开始。

所以 content 是空的,不是模型没干活,是它一直在「想」,没轮到「说」。170 秒全花在推理上。

这也解释了一个反直觉的现象:把 num_predict 调大能缓解,但会线性变慢。有人会选择调大到 8192,代价是某些本来 10 秒的任务要跑 2 分钟,因为模型会一直想到预算用完为止。

修复:显式关掉 thinking

Ollama 从 0.4 版本起在 /api/chat 支持 think 参数。关掉它,模型不再消耗推理 token,num_predict 全部用于正文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
body = {
"model": "qwen3.8-27b-iq2xxs:latest",
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_prompt},
],
"stream": False,
"think": False, # ← 关键:关闭推理段
"options": {
"temperature": 0.7,
"num_ctx": 16384, # 上下文窗口
"num_predict": 4096, # 现在全部可用于正文
},
}

改完之后同一批任务:

指标 think 默认(开) think: false
单篇 description 耗时 170s,返回空 5~13s,正常返回
整篇 2000 字文耗时 超时 82~90s
num_predict 有效利用率 ~0%(全给推理) 接近 100%

从「跑 170 秒拿回空字符串」到「13 秒拿到结果」,改的就是这一个布尔值。

参数怎么定

think: false 之后,另外三个参数值得单独说:

**num_ctx**:不要无脑拉满。16k 上下文在 11G 显存的卡上(RTX 2080 Ti)刚好;32k 会溢出到系统内存,速度断崖式下跌。判断标准是 ollama ps 里看模型是否 100% 在 GPU 上。

**num_predict**:按目标长度给,别给太大。要 2000~2600 字中文正文,4096 够用。给到 16384 不会更快,只会在模型想收尾时不允许它收尾。

**timeout**:这里有个容易被忽略的连环问题。如果这个脚本挂在定时任务里、而定时任务有「陈旧锁」机制(比如锁文件 45 分钟没刷新就判定进程已死并重新拉起),那么单个请求的 timeout 必须远小于锁阈值。最初设的是 2400 秒(40 分钟),离 45 分钟的判定线太近——一旦卡住,会被误判成锁失效,然后重复拉起进程,两个实例同时打模型。改成 1200 秒(实测耗时的 13 倍)之后才安全。

两个连坐的坑

修好 think 之后,脚本还是崩了两次,都值得记下来。

坑一:本地模型会把指令前缀抄进正文

要求模型输出 1|摘要内容 这种格式,本地小模型的输出会变成:

1
1|摘要:这是一段关于……

解析器按第一个 | 切分取右边、再 int() 左边时,左边是 1 没事。但到了第二行,模型会写成 摘要:2|内容,左边就不是数字了,int() 直接抛 ValueError。

修复方式是不假设格式严格,让解析器容忍左侧非数字:

1
2
3
4
5
6
7
8
def parse_line(line: str):
if "|" not in line:
return None
left, _, right = line.partition("|")
left = left.strip().rstrip("摘要::").strip()
if not left.isdigit(): # 左边不是序号,说明整体错位了
return None # 交给兜底逻辑,别硬解析
return int(left), right.strip()

坑二:re.subn 的替换串不能用普通字符串

这个坑更隐蔽。批量替换文章 front-matter 里的 description: 行,代码原本是:

1
text, n = re.subn(r"^description:.*$", new_desc, text, count=1, flags=re.M)

new_desc 是模型生成的描述文本。技术文章的描述里常出现正则转义符——比如讲正则的文章会写 \w、讲 Windows 路径的会写 \d。当替换串是字符串时,Python 会把它当模板解析反斜杠转义,遇到 \w 直接抛:

1
re.error: bad escape \w at position 63

后果是批量任务在第 32 篇崩掉:前 31 篇已改、后面 81 篇没动,文件处于一半新一半旧的中间态。

正确写法是用函数做替换,函数的返回值不会被解析转义:

1
2
3
4
5
text, n = re.subn(
r"^description:.*$",
lambda m: new_desc, # ← 用 lambda,别直接传字符串
text, count=1, flags=re.M,
)

验证清单

改完之后用三个指标确认真的好了:

  1. content 非空且长度符合预期(2000 字正文 ≈ 3000+ 字符)
  2. 耗时在合理区间(description 515s,整篇 80120s)
  3. ollama ps 显示模型 100% 在 GPU 上运行

如果 content 仍然为空,先查 done_reason:返回 length 说明还是预算不够(num_predict 太小或 think 没生效),返回 stop 才是正常收尾。

什么时候该把 think 开回来

不是所有场景都该关。需要严格推理的任务——数学推导、复杂条件判断、代码逻辑审查——开着 think 质量会明显更好。判断标准是:输出长度是否可预测。

输出是一段固定长度的描述、摘要、标题,这种任务关掉 thinking 收益最大,因为推理纯属浪费。输出是需要多步推演的分析结论,那就开着,并且把 num_predict 给到 8192 以上,同时在调用方接受更长的等待时间。


这套参数现在固定在博客的每日自动写作链路里,已经连续跑了多天零空返回。原始踩坑过程记录在 用本地 Ollama 模型批量重写了 112 篇文章的 meta description。