让长推理不再绕圈:Strata 上推理循环防护的完整落地
一次 Strata 推理循环防护的完整落地:四层防护、两条克制原则、一个必须守住的接线口。
问题:模型在“思考”里绕圈
长任务(尤其是长上下文)下,模型会在 <think> 里陷入重复:同一批校验段落反复重写,或者更隐蔽地
写成一个超长“单词”——一段两万多位的数字串,在循环同一个 11 位模式。外部看到的症状是:
- 一直思考、迟迟不给答案;
- 思考到
max_tokens被硬截断,客户端收到空回答
(日志:the reply reached max tokens while still thinking, so it has no answer); - 重复得越久,越难自己跳出来。
Strata 对这个问题的处理是四层:从“检测”到“换挡续写”再到“兜底收尾”,每一层都可以单独开关。
检测覆盖率判据找出重复的推理段落,再补一条“周期尾串”判据抓住超长单词型循环。
恢复不掐断输出,而是把 prompt 里的 effort 句换成低档,沿用同一前缀继续生成。
兜底思考写完却忘了收尾时,补一次结束标记并继续,避免把空回答交给客户端。
预算思考到点即插入收尾话术并闭合思考,让模型直接作答,同时预留答案空间。
第一层:检测(默认开启)
检测在服务端做,不依赖引擎。核心是一个覆盖率函数 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 词被旧片段覆盖的比例”,
而不是“是否出现重复”,所以正常写作里偶然的重复不会触发。
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)是这套方案的主角。检测到循环时它不停,
而是把这条输出接下去:
- 取出本次请求的 prompt token ids;
- 在第一个 system 段里,把 xhigh 的 effort 句整段替换成 low 的 effort 句
(focused_recovery_prompt())——按 token 精确切片,不重新解码/编码 prompt,
所以 prompt 里字面的</think>或视觉标记不会被误伤; - 把“已生成的 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 离线自检,
让“配置看起来对、实际没生效”这件事在启动前就能被抓住。
怎么确认它真的在工作
- 服务端控制台/日志出现
repeated reasoning detected at N tokens (coverage=...)
+resuming ... low-effort instruction; /metrics或 trace 里reasoning_recoveries增长、reasoning_repeat_coverage有值;- 出现
closing the thinking once and continuing (reasoning_close_retry)说明兜底也在位; - 跑
data/_recover_check.py,两个 pack 都必须splice non-None=True。
小结
治理推理循环不是“调一个参数”,而是四层配合:内置的覆盖率检测负责发现
(含超长单词这种隐蔽形态),recover 负责在不丢上下文的前提下换挡并产出答案,
reasoning_close_retry 保证思考一定能收尾,reasoning_budget_tokens 给出最终的硬上限。
再加上“采样只在恢复时临时加力”和“不放大 MTP 草稿”两条克制原则,以及 EFFORT_TEXT 与模板
逐 token 一致这个必须守住的接线口。
一句话先学会认路(检测),再学会换挡(recover),最后才谈刹车(预算)。
近期评论