Ollama 调 API 返回空 content?thinking 模型吃光 num_predict 的排查与修复
现象:请求成功,内容为空
批量给博客的 112 篇文章重写 meta description,脚本跑了两天都正常。换到本机模型后,日志开始出现这种记录:
1 | [1/47] 处理中... 耗时 170.4s |
HTTP 200,done: true,没有任何报错,就是 message.content 是空字符串。最迷惑的地方在于它慢——一次请求跑满 170 秒才返回,看着像模型在很努力地工作,结果交了个白卷。
先怀疑的是模型没加载完、显存不够、或者 ollama 版本问题。这几个方向全查了一遍,都不是。
根因:推理 token 和正文共用同一个预算
出问题的模型是 thinking 型(具备推理链能力的那种,qwen3 系列、deepseek-r1 系列都属于这一类)。这类模型生成回复时,token 消耗分两段:
- 推理段(reasoning / thinking tokens)—— 模型内部先想一遍
- 正文段(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 | body = { |
改完之后同一批任务:
| 指标 | 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 | def parse_line(line: str): |
坑二: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 | text, n = re.subn( |
验证清单
改完之后用三个指标确认真的好了:
content非空且长度符合预期(2000 字正文 ≈ 3000+ 字符)- 耗时在合理区间(description 5
15s,整篇 80120s) ollama ps显示模型 100% 在 GPU 上运行
如果 content 仍然为空,先查 done_reason:返回 length 说明还是预算不够(num_predict 太小或 think 没生效),返回 stop 才是正常收尾。
什么时候该把 think 开回来
不是所有场景都该关。需要严格推理的任务——数学推导、复杂条件判断、代码逻辑审查——开着 think 质量会明显更好。判断标准是:输出长度是否可预测。
输出是一段固定长度的描述、摘要、标题,这种任务关掉 thinking 收益最大,因为推理纯属浪费。输出是需要多步推演的分析结论,那就开着,并且把 num_predict 给到 8192 以上,同时在调用方接受更长的等待时间。
这套参数现在固定在博客的每日自动写作链路里,已经连续跑了多天零空返回。原始踩坑过程记录在 用本地 Ollama 模型批量重写了 112 篇文章的 meta description。