一次 Strata 推理循环防护的完整落地:四层防护、两条克制原则、一个必须守住的接线口。

环境 Tesla V100-PCIE-32GB + 96 GB RAM,串行跑 Qwen3.8-Flash-Next(MoE,每层 512 experts)
模型 OrcaRouter Uncensored Q4_K_M · SC117 GSQ-RCO-abliterated IQ3_S
软件 serve/server.py + engine-cuda12/(v0.1.42)
口径 所有结论来自本机实测或源码常量,逐条标注出处

 

问题:模型在“思考”里绕圈

长任务(尤其是长上下文)下,模型会在 <think> 里陷入重复:同一批校验段落反复重写,或者更隐蔽地
写成一个超长“单词”——一段两万多位的数字串,在循环同一个 11 位模式。外部看到的症状是:

  • 一直思考、迟迟不给答案;
  • 思考到 max_tokens 被硬截断,客户端收到空回答
    (日志:the reply reached max tokens while still thinking, so it has no answer);
  • 重复得越久,越难自己跳出来。

Strata 对这个问题的处理是四层:从“检测”到“换挡续写”再到“兜底收尾”,每一层都可以单独开关。

1

检测覆盖率判据找出重复的推理段落,再补一条“周期尾串”判据抓住超长单词型循环。

2

恢复不掐断输出,而是把 prompt 里的 effort 句换成低档,沿用同一前缀继续生成。

3

兜底思考写完却忘了收尾时,补一次结束标记并继续,避免把空回答交给客户端。

4

预算思考到点即插入收尾话术并闭合思考,让模型直接作答,同时预留答案空间。

第一层:检测(默认开启)

检测在服务端做,不依赖引擎。核心是一个覆盖率函数 reasoning_repeat_coverage():

  • 把推理文本切词,回溯最近 30000 词(LOOP_HISTORY_WORDS),统计 12 词窗口的 n-gram 出现次数;
  • 取最近 2000 词为窗口,计算“落在出现 ≥3 次的 12 词片段里”的比例,即 coverage;
  • coverage ≥ 0.25(LOOP_COVERAGE)判为循环;
  • 检查频率:每 512 个输出 token(LOOP_CHECK_EVERY),且只在解析器的干净边界上做
    (没有半截标签、没有跨 token 的分词),避免误判。

这个口径的好处是容忍中间穿插的大段新内容——它统计的是“最近 2000 词被旧片段覆盖的比例”,
而不是“是否出现重复”,所以正常写作里偶然的重复不会触发。

v0.1.42 补上了“超长单词”这一种(上游 #1753)在词频统计之前,先取推理结尾 2048 字符(LOOP_TAIL_CHARS),检查它是否为周期
≤64 字符(LOOP_TAIL_PERIOD)的完全循环;命中直接判 coverage = 1.0。这条很关键:两万位的数字串在切词后只是一个词,词频永远涨不上去,token 级的长跑计数也涨不上去
(词表里没有多位数字 token),前两种方法都看不见它。

另一条独立的检测是 repeat_stop_tokens(上游 #606):同一个 token 连续重复 N 次
就立刻结束(记为 length)。它抓的是“退化成单 token 复读机”的极端情况——上游某个 issue 里出现过
36689 个 !。本机配置显式写了 256(引擎内置默认也是 256,这里把它写死,
避免被上游改默认值影响)。

日志长这样:

the thinking repeated the same passages (coverage=1.000) at 2048 tokens: ended as "length"
the reply repeated one token ('!') 256 times in a row: ended as "length"

第二层:恢复 —— 不是“掐断”,而是“换挡续写”

reasoning_loop_recovery: "recover"(上游 #728)是这套方案的主角。检测到循环时它不停,
而是把这条输出接下去:

  1. 取出本次请求的 prompt token ids;
  2. 在第一个 system 段里,把 xhigh 的 effort 句整段替换成 low 的 effort 句
    (focused_recovery_prompt())——按 token 精确切片,不重新解码/编码 prompt,
    所以 prompt 里字面的 </think> 或视觉标记不会被误伤;
  3. 把“已生成的 token”原样接在替换后的 prompt 后面,从同一个前缀继续生成。

效果:同一段推理的上下文没丢(前缀还在引擎里,不必全量重算),但模型被明确要求“简洁地推理”,
于是收敛并给出答案。日志:

repeated reasoning detected at 2048 tokens (coverage=1.000); resuming the same output
with the low-effort instruction (reasoning_loop_recovery)

几个必须知道的边界

  • 每次请求只恢复一次(判断条件里带 not recovery_count),避免无限“恢复—再绕圈”。
  • 带图片的请求不做恢复(if not emb)。视觉请求走别的路径。
  • 恢复时只提高两个采样旋钮:temperature 抬到至少 1.0、
    presence_penalty 抬到至少 1.5;不动客户端给的 top_p / top_k,
    而且拷贝请求字典、不修改原对象。
  • 记录在 /metrics 的 reasoning_recoveries 与 trace 的
    reasoning_repeat_coverage 里,便于事后核对。

该参数的取值是 false | "stop" | "recover"。我们选 recover:
"stop" 只是当场结束,用户拿不到答案;recover 才有产出。

第三层:兜底 —— 把思考“关掉”,保证有答案

reasoning_close_retry: true(上游 #1053)。有些时候模型不是绕圈,而是
在 </think> 之前就停住了(写完了思考、忘了收尾),客户端会拿到空回答。
开启后,服务端检测到“停在思考里、没有答案、没有工具调用”,就补齐一次 </think>
并继续生成(判断条件带 not close_retried,也只补一次)。日志:

the reply ended inside its thinking with no answer: closing the thinking once and
continuing (reasoning_close_retry)

第四层:思考预算 —— 时间到就强制收尾

reasoning_budget_tokens: 65536(上游 #123)。当思考 token 数到达预算、且处在干净边界时,
服务端插入一段收尾话术并闭合思考,让模型直接给答案:

I have thought about this long enough; time to give my answer.

两个细节:

  • 只有客户端的 max_tokens 大于预算值时,预算才生效;所以它不会和客户端的输出上限打架。
  • 它会为答案预留空间(ANSWER_RESERVE_MIN = 512)——即使预算被裁到贴近上限,
    也留出回答的余地。官方行为保证:到点强制作答,不会返回空响应。

最容易踩的坑:EFFORT_TEXT 必须和模板逐 token 一致

recover 靠“在第一个 system 段里精确匹配 xhigh 句”来定位替换点。而这几句
(以及 low/medium 句)来自模型 pack 里的聊天模板,不是服务端的常量。本机两个 pack 都用
froggeric v22.4 模板(template_version = "qwen3.8-froggeric-v22.4"),它写的是:

effort 模板原句
xhigh Reason carefully, validate important assumptions, consider plausible alternatives, and prioritize correctness. Reasoning may use Simplified Chinese or English according to task requirements and efficiency.
low Keep reasoning brief and focused. Use Simplified Chinese or English according to task requirements and efficiency.
medium Reason carefully and efficiently. Use Simplified Chinese or English according to task requirements and efficiency.

而 serve/server.py 里的 EFFORT_TEXT 默认还是旧模板的
"Reasoning effort is set to ..." 三句。两边对不上时,recover 不报错、直接静默失效
——这是最阴的坑。所以本机做了一件事:把 serve/server.py 的 EFFORT_TEXT 改成模板原句,
并同步改了 serve/test_server.py 里对应的一条断言。

配套还加了一个离线自检脚本 data/_recover_check.py:对每个 pack 渲染模板 → 分词 →
调用 focused_recovery_prompt,断言返回非 None。两个 pack 都必须是
splice non-None=True:

orca-q4_k_m    xhigh sentence in prompt=True   splice non-None=True   OK
sc117-iq3_s    xhigh sentence in prompt=True   splice non-None=True   OK
相邻的坑:升级会把这个补丁弄丢主程序升级(git checkout <tag>)不会带过来这个本地补丁。升级到 v0.1.42 时必须
手工重放,否则 recover 又静默失效。补丁留了备份
(bak-0.1.40.3/local-effort-text.patch),serve/server.py +
serve/test_server.py 单测(本次 v0.1.42 下 309 项全过)一起兜住。

采样参数:按官方“思考模式”口径

两个模型的 sampling 段完全一致(客户端可零改动切换模型):

参数 值 说明
temperature 1.0 官方思考模式推荐
top_p 0.95 官方推荐
top_k 20 官方推荐
min_p 0.0 官方推荐
presence_penalty 0.0 官方推荐;recover 时临时抬到 ≥1.5
repetition_penalty 1.0 官方推荐(= 不加惩罚)

注意这里的逻辑:平时不靠惩罚硬压重复(能少伤及正常表述),而是
只在确认绕圈的那一次续写里临时提高 temperature 与 presence penalty——“该出手时才出手”。

刻意不做的事:不放大 MTP 草稿

--suffix-draft 与 --lookup-chain 这两类“草稿加速”功能,本质是
加速重复文本的续写。当模型已经进入重复状态时,它们会放大这次重复,让绕圈更难停。
所以本机:

  • --spec 8 保持默认;
  • 不开 --lookup-chain;
  • --suffix-draft 保持默认。

实测结论也支持这一点:这些参数对无重复的长文档没有正向收益,反而在重复场景下有害。

落地清单

位置 键 值 作用
strata-*.json reasoning_loop_recovery "recover" 检测到循环 → 换挡续写(一次)
strata-*.json reasoning_close_retry true 思考未收尾 → 补一次 </think>
strata-*.json reasoning_budget_tokens 65536 思考到点 → 强制收尾作答
strata-*.json repeat_stop_tokens 256 单 token 复读机 → 立刻结束
strata-*.json sampling 官方思考模式 6 项 见上表
serve/server.py EFFORT_TEXT froggeric v22.4 三句 recover 生效的前提
engine args --spec / --lookup-chain / --suffix-draft 8 / 关 / 默认 不放大重复

外加两个工程性保障:serve/test_server.py 断言 + data/_recover_check.py 离线自检,
让“配置看起来对、实际没生效”这件事在启动前就能被抓住。

怎么确认它真的在工作

  1. 服务端控制台/日志出现 repeated reasoning detected at N tokens (coverage=...)
    + resuming ... low-effort instruction;
  2. /metrics 或 trace 里 reasoning_recoveries 增长、reasoning_repeat_coverage 有值;
  3. 出现 closing the thinking once and continuing (reasoning_close_retry) 说明兜底也在位;
  4. 跑 data/_recover_check.py,两个 pack 都必须 splice non-None=True。

小结

治理推理循环不是“调一个参数”,而是四层配合:内置的覆盖率检测负责发现
(含超长单词这种隐蔽形态),recover 负责在不丢上下文的前提下换挡并产出答案,
reasoning_close_retry 保证思考一定能收尾,reasoning_budget_tokens 给出最终的硬上限。
再加上“采样只在恢复时临时加力”和“不放大 MTP 草稿”两条克制原则,以及 EFFORT_TEXT 与模板
逐 token 一致这个必须守住的接线口。

一句话先学会认路(检测),再学会换挡(recover),最后才谈刹车(预算)。

Strata · Qwen3.8-Flash-Next 本机部署笔记文中出现的常量与行为均来自 serve/server.py 与上游 issue 编号,实测数字来自本机日志与
/metrics。