适用对象:所有通过 API 使用 DeepSeek-V4-Flash(含 Vision 变体)并开启思考模式的开发者,DSH(DeepSeek Harness)用户可直接跳到「用 DSH 的:现在就能装」一节。
我的 agent 跑在官方 DeepSeek-V4-Flash 上,思考模式开着(这本来就是默认),reasoning effort 是 high。有一天我拉日志,被一条思考流吓到了:一轮思考,4927 行,约 1.6 万字符,其中 98.6% 是重复的废话。
前 42 行完全正常:解析任务、发现路径有分叉、决定「两个都查」,行动清单都列好了。然后它不再前进,在第 43 行说了句「嗯,停止思考。」——之后的 4877 行,一直在宣告停止:
执行。
执行。
好,执行。停止思考。
光「执行。」这三个字,出现了 1254 次。它反复说停,一次也没停,最后被 max_tokens 硬生生掐断。
我一开始以为是自己的 prompt 写得不好,或者集成有 bug。查完才发现,这事怪不到我头上。
一、这是模型层的问题,官方仓库里就有同款记录
DSH(DeepSeek Harness,DeepSeek 官方开源的 agent harness,@deepseek-ai 域,MIT 协议)的 GitHub Discussions 里有两个记录:
- v4.1-flash 在 high/max effort 下出现「好。执行。好。」这类低熵循环,和我的样本同款;
- 更夸张的一条,单次调用重复了约 42 万字符,连结束信号都没有。
学术侧也定了名。arXiv 论文《Circular Reasoning: Understanding Self-Reinforcing Loops in Large Reasoning Models》把这个失败模式叫循环推理:大推理模型先用自己的话反复重述同一内容,最后退化成逐字复读。
顺带说一句官方生态的现状:DSH 官方的 guard 家族只有 repeat-tool-reminder(工具重复提醒)和 timeout-policy(超时),都在工具调用层。reasoning 增量在它的协议翻译层是原样透传、零检测。也就是说,思考循环这一层,官方自己也在硬扛。
所以结论是:谁用 Flash、开着思考模式,谁都可能抽到这张卡。任务越长、分叉越多、effort 档位越高,越容易中。
二、先算钱:循环到底烧多少
DeepSeek API 单价低,但低单价架不住这个烧法。账分两层。
第一层是单轮。思考模式下 reasoning 是计入 max_tokens 的——我实测过 reasoning_tokens 把 8192 吃满、正文 0 字符、finish_reason=length 的情况。也就是说一轮循环烧掉一整份输出预算,买回来的全是复读。你在别的场景遇到过「模型想了一万 token 然后什么都不答」,多半就是它。
第二层是复利,这层更狠。V4 的思考模式强制要求把历史轮次的 reasoning_content 原样传回去,剥掉这个字段直接报 400。于是这笔循环垃圾会作为 input 在之后的每一轮再计费一次,直到被上下文压缩甩掉。一个十几轮的长会话,一次循环的垃圾能被重复计费十几次。
上面那条 42 万字符的官方记录,烧一次的钱够正常用很多天。
我装了熔断之后的对照数字:真实循环样本(15963 字符),触发点在第 540 字符,循环废料只留下 159 字符,单轮省 97%;而且历史回传里只剩「有效思考 + 几行循环 + 标记」,下一轮的 input 同步瘦掉两个数量级。顺带说明,97% 这个数是「线上不再见垃圾」的口径;断开后服务端是否还在后台继续生成计费,我没法验证,但延迟和回传两头的节省是实打实的。
三、走不通的路,帮你省时间
弯路不展开,只留结论。
Prompt 纪律走不通。 把「思考写到判断即止、禁止填充」写进 system prompt,能压住大半废话,压不住循环。而且有个反直觉的发现:让思考「简短」的规则本身会喂循环——推演链被它压掉之后,思考里没有任何可以推进的东西,反而更容易在短句上打转。别给模型的思考过程定「从简」的规矩。
清洗历史思考再回传,只能解决一小半。 实测 API 只校验字段在场、不校验内容,清洗是安全的。它能挡住垃圾逐轮恶化,但单轮循环照样发生:循环不是模仿历史来的,是它在空窗期自己长出来的。
采样参数可以直接跳过。 思考模式下 DeepSeek 官方把采样全锁了:temperature 不生效,top_p 被钳在 0.95–1.0,presence/frequency_penalty 不生效,top_k 字段不存在。唯一真旋钮是 reasoning_effort。至于 max_tokens,那只是兜底:它把「无限循环」变成「烧满预算后结束」,没有恢复,这一轮照样全废。
四、真正的解法:思考流熔断
思路换一下:不指望模型自觉,在自己的代码里动手。思考流是一个 token 一个 token 流出来的,你的代码本来就要逐段消费它,那就一边收一边看,发现它在复读,立刻掐断,注入提醒,马上重发一次调用。整个机制分三部分:检测、动作、恢复。
4.1 为什么敢掐,掐在哪里
为什么敢掐?因为决策在循环开始之前就写完了。我那个样本里,有效思考只占前面 1.4%,后面全是复读,掐掉不损失任何信息。掐断后让它带着完整决策直接进动作通道,实测执行零损伤:该查的查了,参数写错还会自己纠。
插在哪里?流式消费点,也就是你的代码接收 reasoning delta 的地方。注意必须用流式接口——非流式拿到完整响应时钱已经花完了,熔断没有下手的时机。检测本身要做成纯内存操作:一个句子窗口加一个指纹字典,不碰数据库、不发网络请求,热路径无感。
4.2 四判据完整规格
四个判据先到先切。下面是我在用的起步参数,全部做成配置项。
| # | 判据 | 规格 | 抓什么 |
|---|---|---|---|
| ① | 逐字复读 | 最近 8 句,去重后 ≤5 种句式 | 「执行。执行。执行。」 |
| ② | 语义指纹 | 每句取前 60 字符,剥序号、标点、空白后归一化哈希;最近 24 句内同指纹 ≥3 次 | 换皮复读:「现在执行。」「接下来执行。」「好,执行。」 |
| ③ | 长度上限 | 思考累计 >12000 字符 | 想得过长(含逃过①②④的温和循环) |
| ④ | 尾部周期 | 最后 256 字符内存在周期 ≤64 的重复模式,且周期须含 ≥2 个不同字符 | 轮转逃逸:「好。/执行。/好。/执行。」轮着来 |
每条都有防误伤的细节,这些细节决定这套东西能不能常开:
- 判据①的阈值是实测标定出来的。 循环区里混着变体,8 句窗口的去重种类在 3~5 之间波动,阈值定 3 会在边缘漏;干净样本最小是 7,定 5 留了 2 的安全边际。
- 判据②要设最短指纹门槛(8 字符)。 「嗯。」「好。」这类短碎句在正常思考里本来就高频,短指纹在正常样本和循环样本之间峰值重叠,没有区分度;剥掉短指纹之后两者才分开。行首序号要归一化(「23.」「28.」「33.」本质是同一个段落换序号),不然列表式思考全是假指纹。
- 判据②必须窗口化,不能全程累计。 长思考里合法地自我重访很常见,开头出现过的段落在 6000 字符后又出现两次,不该触发。
- 判据③的 12000 不是拍的。 实测中文 reasoning 约 1.5~2.0 字符/token,8192 max_tokens ≈ 1.37 万字符,12000 在 88% 位置——比硬截早点触发,还能留一次恢复机会。如果你上调 max_tokens,这个值要跟着动(约等于 1.5 字符/token × 新预算 × 0.88),不然误伤面会放大。
- 判据④的「周期须含 ≥2 个不同字符」是防
====分隔线的。 没有这条,markdown 重度用户天天误报。 - 代码围栏内不计数。 合法引用的重复性代码不该参与判定。判据④的尾部窗口同样要排除围栏区段。
- 切句规则:中英文句号、换行,外加「!?;……」。漏了「!」就会给「执行!执行!」这种一行连发的形态留逃逸口。
- 一次调用内只触发一次,触发后本轮回合不再重复触发,防止恢复轮又被自己掐了。
- 定阈值的原则:①②④宁松勿紧,误判的代价只是多花一轮思考,漏判的代价是烧光整轮预算。③反过来宁高勿低——它触发时模型没在循环,只是想得长,掐早了会伤真正的难题。
实测的误伤数据:3 个干净样本全程零触发;1 个边缘碎念样本在进度 69% 处触发了一次,可接受;2 个重度碎念样本全部抓到。
4.3 触发之后:掐断、注入、续跑
三个动作,顺序不能乱:
- 停止读取上游流;
- 把已生成的思考截断、原样入历史,尾部加系统标记(比如
[思考熔断:复读触发]),你和前端都知道这段思考被动过; - 注入一条提醒消息,立刻重新发起调用。
提醒文案分三级递进,级数按「同一轮第几次触发 / 本会话累计几次」取最高:
- L1 首次:「检测到你在重复同样的思考。停止思考,你的判断已经完整,直接输出工具调用或给出结论。」
- L2 同轮再犯:「决策依据完整保留在上方,无需重新思考,立即输出工具调用;若确无动作可做,直接给出最终回答。」
- L3 会话累计 ≥2 次:「停止思考。直接给出结论,或向用户说明卡点。」
判据③触发时用独立文案(「思考过长,收敛结论并行动」),不要指控它复读——它没复读,只是想得长,错误指控会催它草率收场。
两个工程坑,我踩过之后才在社区插件的源码里找到印证:
- 熔断信号不要用异常传。 你代码里的
except Exception兜底会把这次「恢复动作」当成故障,直接终结本轮。熔断不是故障,用返回值里的标记位传。 - 消费端要在「空回复重试」分支之前检查标记位。 被掐断的那轮正文必然是空的,让空回复分支抢先处理,你会得到三连误伤:误报「模型返回空回复」、下一轮思考被关掉、每轮一次的重试额度白烧。恢复轮也不要顺手把思考关掉,思考必须照常开。
4.4 参考实现
下面是核心检测器,Python,约 90 行,可直接抄走改造。动作侧(停读、截断、注入、续跑)接在你自己的调用循环里,骨架附后。
import re
from collections import Counter
SENT_SPLIT = re.compile(r"[。.!!??;;\n…]+") # 句切分
NUM_PREFIX = re.compile(r"^\s*\d+([.)、])\s*") # 行首序号(列表换皮)
NOISE = re.compile(r"[\s,,、::「」『』\"'()\[\]()—\-·]") # 归一化剔除
class ThinkLoopGuard:
"""思考流熔断检测器。
feed() 逐段喂 reasoning 增量;返回 None=正常,dict=触发。
每次调用新建一个实例,随调用销毁;同一次调用内只触发一次。"""
def __init__(self, window=8, unique_max=5, # 判据一
fp_window=24, fp_repeat=3, fp_min=8, # 判据二
max_chars=12000, # 判据三
cycle_span=256, cycle_len=64): # 判据四
self.window, self.unique_max = window, unique_max
self.fp_window, self.fp_repeat, self.fp_min = fp_window, fp_repeat, fp_min
self.max_chars = max_chars
self.cycle_span, self.cycle_len = cycle_span, cycle_len
self.buf = "" # 未成句尾巴
self.tail = "" # 围栏外原文尾部(判据④用)
self.sents, self.fps = [], []
self.n, self.checked_at = 0, 0
self.in_fence, self.fired = False, False
def _fp(self, s):
s = NUM_PREFIX.sub("", s.strip())
s = NOISE.sub("", s)
return None if len(s) < self.fp_min else hash(s[:60])
def _cycle(self, tail):
n = len(tail)
for p in range(2, self.cycle_len + 1):
if n < 3 * p:
break
block = tail[-p:]
if len(set(block)) < 2: # ==== 这类全同字符不算
continue
if tail[-3 * p:] == block * 3: # 尾部至少连续重复 3 个周期
return True
return False
def feed(self, delta):
if self.fired:
return None
self.n += len(delta)
if self.n > self.max_chars: # 判据三:O(1),每个 delta 都查
return self._fire("max_chars")
for seg in delta.split("```"): # 围栏内内容不计数
if not self.in_fence:
self.buf += seg
self.tail = (self.tail + seg)[-self.cycle_span:]
self.in_fence = not self.in_fence
if self.n - self.checked_at < 50: # 每 50 字符跑一次句级检测(热路径节流)
return None
self.checked_at = self.n
pieces = SENT_SPLIT.split(self.buf)
self.buf = pieces.pop() # 最后一段是未成句尾巴
new = [p for p in pieces if p.strip()]
if not new:
return None
self.sents.extend(new)
for s in new:
fp = self._fp(s)
if fp is not None:
self.fps.append(fp)
w = self.sents[-self.window:] # 判据一
if len(w) >= self.window and len(set(w)) <= self.unique_max:
return self._fire("verbatim")
fw = self.fps[-self.fp_window:] # 判据二
if fw and Counter(fw).most_common(1)[0][1] >= self.fp_repeat:
return self._fire("fingerprint")
if self._cycle(self.tail): # 判据四
return self._fire("cycle")
return None
def _fire(self, criterion):
self.fired = True
return {"criterion": criterion}
调用侧的骨架(关键顺序都在注释里):
guard, reasoning, verdict = ThinkLoopGuard(), "", None
for delta in stream: # 你的流式循环
if delta.reasoning_content:
reasoning += delta.reasoning_content
verdict = guard.feed(delta.reasoning_content)
if verdict:
break # 1) 停读上游
if verdict: # 注意:先查标记位,再做空回复兜底
history.append({"role": "assistant", "content": "",
"reasoning_content": reasoning + f"\n[思考熔断:{verdict['criterion']}]"})
messages.append(reminder(next_level())) # 2) 注入提醒(带来源标记,别让模型当成用户发言)
return call_llm(messages) # 3) 立刻续跑,思考照常开
再补一个实验结论,能再省一笔:API 只要求历史里至少一轮带 reasoning_content,不要求每轮都带。只保留最近几轮的思考再回传,既瘦身,又缩小了模型自我模仿的面。
五、用 DSH 的:现在就能装
DSH 本体是 DeepSeek 官方开源的(@deepseek-ai 域,MIT),但正如上面说的,官方 guard 只管工具调用层,思考循环官方还没有出防护。现在能做两件事。
第一件:装第三方守卫插件顶上。 以下是 npm 上实际存在、针对 DSH、且在活跃维护的(包元信息 2026-09 查证):
| 插件 | 版本 | 管什么 |
|---|---|---|
@argszero/cordis-plugin-thinking-loop-guard |
0.1.9(2026-09-20 更新) | 思考循环专项:观测 llm/stream 流,识别三种循环形态(纯思考无动作 / 复述旧料 / 低熵自重复),warn→steer→cancel 三级处理 |
@goodandready/dsh-agent-loop-guard |
0.2.18(2026-09-29 更新) | 工具调用循环守卫,fail-closed,进展感知(同指纹的成功不算进展,防假进展重置) |
安装(DSH 的插件经由 pnpm 落地,命令在 apps/cli/src/args.ts 里定义):
dsh plugin add @argszero/cordis-plugin-thinking-loop-guard
dsh plugin add @goodandready/dsh-agent-loop-guard
# 指定 profile:dsh plugin --profile tui add <包名>
装完注意两点:argszero 的形态判定发生在调用结束后,能拦住大部分循环但不是流内实时掐;goodandready 那个只管工具调用层。纯思考流的流内实时熔断,目前还是要按本文第四节自己动手——这也是两个插件和我这套方案的关系:互补,不重复。
第二件:去官方催。 官方仓库在 github.com/deepseek-ai/deepseek-harness,讨论区已有 #5976(低熵循环)和 #2848(42 万字符重复)两案,去 +1 附上你的样本,比等官方自己发现快。找更多插件可以翻 GitHub 的 dsh-plugin 话题。
六、其他情况
自研 API 客户端:按第四节装。清单版——持久化 reasoning_content(契约硬要求)→ 流式消费点插纯内存检测 → 四判据起步值 → 掐断/注入/重调走标记位不走异常 → 标记位检查放在空回复分支之前 → 记一个 [think-loop] 计数器,跑一周看触发率再调参。
只用网页或 App:坦白讲,你碰不到 harness,熔断装不了。能做的有三件:把 effort 从 max/high 往下调一档(官方讨论区报循环的基本都在这两档,调低应该能降概率,代价是思考深度,我没做过系统对比);别把一个又大又模糊的任务塞给一句话,拆开问;一旦发现对话进入循环,直接新开会话——循环垃圾会在历史里被回传,越聊越糟。剩下的,只能给官方报反馈等模型侧修。
自部署(vLLM/SGLang 等):除了本文的 harness 层方案,推理框架层另有重复解码类 bug 的先例,排查时两层分开看。
参考
- DeepSeek 官方 API 文档(V4 系列与思考模式说明):https://api-docs.deepseek.com
- Circular Reasoning: Understanding Self-Reinforcing Loops in Large Reasoning Models(arXiv 2601.05693):https://arxiv.org/html/2601.05693v1
- DSH(DeepSeek Harness)官方仓库与讨论区:https://github.com/deepseek-ai/deepseek-harness
- DSH 官网与下载:https://www.dsh.so
- 插件 @argszero/cordis-plugin-thinking-loop-guard:https://www.npmjs.com/package/@argszero/cordis-plugin-thinking-loop-guard
- 插件 @goodandready/dsh-agent-loop-guard:https://www.npmjs.com/package/@goodandready/dsh-agent-loop-guard
- GitHub 话题(DSH 插件发现):https://github.com/topics/dsh-plugin
- Fix DeepSeek 400: Pass reasoning_content Back(回传契约与兼容层处理):https://www.anrouter.com
举手提问