Agent 跑进死循环:OpenRouter 的 doom-loop detection 怎么判
一个 agent run 迟迟不结束,日志里翻上去全是同一个工具、同一份参数,返回也一模一样,账单却在往上走——这是写 agent 的人早晚会碰上的场面。OpenRouter 的 Agent SDK 给这类情况起了个名字叫 doom loop,并提供了一套检测配置。这篇只做一件事:把「怎么确认是它」「文档给了什么处置」「怎么验证处置生效」「什么情况说明根本不是它」这四段按文档字段一条条对上。
一、先确认 doomLoop 到底有没有在工作
最容易白忙一场的一步:官方文档《Doom-Loop Detection》页写明,这个检测默认是关的(off by default)。也就是说,如果你没显式打开,无论 run 循环成什么样,都不会有任何检测判定产生——你在日志里找不到判定,不代表它判定为「不是死循环」,而是它压根没在看。
打开的方式是 doomLoop: true,文档在同一段的注释里写明了这个开关对应的推荐档位是 observe@2、block@3、stop@6。这是文档写明的默认值,随版本可能变动,别把它当成长期契约。
const result = openrouter.callModel({
model: 'openai/gpt-4o',
input: 'Research this topic',
tools: [searchTool, bashTool] as const,
doomLoop: true, // recommended defaults: observe@2, block@3, stop@6
// or tune it:
// doomLoop: {
// ladder: { observe: 2, steer: false, block: 3, stop: 6 },
// text: { minRepeats: 4 }, // or `false` to disable text detection
// },
});
// Was the run stopped by detection?
const verdict = await result.getDoomLoopVerdict();
if (verdict) console.warn(verdict.message);
上面代码里的 openai/gpt-4o 是官方文档当时写的示例值,平台上有哪些模型随时在变,不要当清单用。
判定动作就落在最后三行:result.getDoomLoopVerdict() 拿到的东西为真,按文档在这段代码注释里的措辞,意思是「这次 run 是被检测停下的」,verdict.message 则是可以直接打出来看的说明。文档只写到这里,这个方法在没触发 stop、只触发了 observe 的时候返回什么,官方文档没有说明这一点,做分支时别默认它一定为空。开了检测、getDoomLoopVerdict() 却什么也没有,那这次卡住的原因就得往别处找,第五节会说去哪找。
二、它认哪三种「卡住」,以及怎么数
文档列了三类,判断你手上的现象属不属于,先对这三条:
- 重复调用你自己定义的工具。模型用同样的输入去调
tools里的某个工具,一轮接一轮。文档特别写明坏掉的调用也算——模型一直发空的{}或者非法 JSON,同样算卡住。 - 重复请求 OpenRouter 的 server tool。像 web search 这种跑在平台侧、不经过你代码的工具,你在自己的日志里根本看不到调用,检测仍然能抓到模型每一轮都在发完全相同的搜索。
- 重复同一段文本。一次回复结尾处重复的短语,或者整条回复与上一条完全相同。
计数规则是这套东西里最容易误判的部分,文档写得很直白:只有模型看到了结果又试了同一件事,streak 才 +1。所以同一轮里模型一口气发五个相同调用,算一次不算五次;中间穿插用了别的工具,不会把这个工具的 streak 清零;改了输入才算换了一件事。文档还写明同一份 transcript 总是产生同样的结果,这意味着你可以拿存下来的对话反复复现判定,而不是靠碰运气。
三、文档给出的处置:ladder 五档 + loopKey
ladder 各档的语义
ladder 里每个动作各配一个阈值,streak 达到哪个阈值就触发哪个动作,同时命中多个时,强度更高的那个动作生效。文档给的是五个动作:
| 动作 | 文档写明的效果 |
|---|---|
observe | 只记录:发出 DoomLoopDetected 事件让你知道发生过 |
steer | 在下一轮之前给 agent 一条消息,告诉它在重复自己(默认关闭) |
escalate | 下一轮换更强的模型跑、和/或强制走一次 openrouter:advisor 咨询,然后换回来(默认关闭,需要配 escalation) |
block | 拒绝执行这次重复调用,模型会拿到一条说明原因的错误 |
stop | 结束这次 run(SessionEnd.reason: 'doom_loop'),未完成的工具调用会补上干净的错误输出,保证存下的对话仍然有效、可以恢复 |
有两处容易踩:其一,文档写明文本重复与 server tool 重复没法 block——它们已经发生了,这两类会下沉到你启用了的、次强的那个动作(escalate、steer 或 observe)。其二,SDK 会检查阈值组合是否讲得通并对无效组合告警,文档举的例子是:只配 block 不配 stop,一个固执的模型可以一直重试被拦下的调用,只受 stopWhen 约束;以及把弱动作的阈值配得比强动作还高,那个弱动作永远不会触发。
想让某个工具走不一样的处置,文档写明 DoomLoopDetected 这个 lifecycle hook 能看到每一次判定,并用 overrideAction 逐事件覆盖动作(最后一个 handler 生效):某个工具重复是无害的就把 block 降成 observe,反过来也可以直接跳到 stop。
escalate 的配置块长这样:
openrouter.callModel({
model: 'z-ai/glm-5.2', // the everyday executor
input: 'Research this topic',
tools: [searchTool] as const,
doomLoop: {
ladder: { observe: 2, escalate: 3, block: 5, stop: 8 },
escalation: {
// Either or both:
model: 'anthropic/claude-opus-4.6', // run the NEXT turn on a stronger model
advisor: true, // and/or force an openrouter:advisor consult
maxEscalations: 2, // spend cap for the whole conversation
},
},
});
同样地,这里的两个模型标识是官方文档当时的示例值,不构成模型清单。maxEscalations 的语义是限定这次对话里最多升级几次,文档写明它跨暂停与恢复仍然有效;额度用完之后,较弱的那些动作接手。
判「同一件事」的口径:loopKey
默认口径是「所有输入完全相同才算同一个调用」。这个默认经常不合适,loopKey 就是用来改口径的,文档给了三种形态:函数形式(自己算一个标识,比如把搜索词 trim().toLowerCase() 之后比较,让 Cats 和 cats 算同一次搜索)、只取部分字段(比如 bash 工具只按 command 和 cwd 判定,verbose 这种字段不参与)、以及 loopKey: false——彻底不计这个工具,文档给的例子是轮询状态的工具,重复本来就是它的本职。
函数形式还有两个细节值得记:返回 null 表示跳过这一次具体调用;如果函数出了任何问题(返回 undefined、抛异常、返回没法哈希的东西),SDK 会退回到比较全部输入并打一条警告——文档写明检测不会让 run 崩掉。另外还有一种字段名数组的写法 loopKey: ['command', 'cwd'],因为它是纯数据而不是代码,能扛过缓存,也能通过 _meta['openrouter/loopKey'] 传到远端;MCP 包装的工具则通过 markMcp(tool, { loopKey }) 或者 createMCPTools 上的 loopKeys 映射来设置。
别忘了另一半:stopWhen
doomLoop 判的是「在原地打转」,stopWhen 管的是「跑得太久」,两者是分开的两个字段。《Stop Conditions》页写明:不指定 stopWhen 时,循环会一直跑到模型产出一轮没有工具调用为止,并明确建议总是显式传一个 stopWhen 来给迭代次数、成本或 token 划界。内置条件有五个:stepCountIs(n)、hasToolCall(name)、maxTokensUsed(n)、maxCost(amount)、finishReasonIs(reason);传数组则表示任一条件满足即停止。
import { OpenRouter, stepCountIs } from '@openrouter/agent';
const openrouter = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const result = openrouter.callModel({
model: 'openai/gpt-5-nano',
input: 'Research this topic thoroughly',
tools: [searchTool, analysisTool],
stopWhen: stepCountIs(5), // Stop after 5 steps
});
密钥从环境变量读。Linux/macOS 侧在 shell 里 export OPENROUTER_API_KEY="<YOUR_API_KEY>";Windows 侧 PowerShell 用 $env:OPENROUTER_API_KEY="<YOUR_API_KEY>"(当前会话有效),cmd 用 set OPENROUTER_API_KEY=<YOUR_API_KEY>——这一段是各平台通用的环境变量做法,不是 OpenRouter 官方文档的内容,文档只写了代码里读 process.env.OPENROUTER_API_KEY。
以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。
四、处置之后怎么验证
- 看判定:
getDoomLoopVerdict()是否返回了东西、verdict.message写了什么。 - 看结束原因:
stop档触发时,doom-loop 页写明是SessionEnd.reason: 'doom_loop'。这里有一处两页对不上的地方要提醒:《Lifecycle Hooks》页给出的SessionEndPayload里,reason的联合类型是'user' | 'error' | 'max_turns' | 'complete'四个值,并不含'doom_loop';同一页的内置 hook 表列了九个 hook,也没有DoomLoopDetected。两页的口径就是不一致,我们没有依据判断哪一页更新,以官方文档最新内容为准,代码里做分支时别只按其中一页写死。 - 看恢复行为:文档写明检测的记忆存在会话状态里,保存后恢复对话时 streak 仍在——前提是恢复的那次调用也传了
doomLoop。这是配置漏传就会静默失效的一处。另外,发一条新的用户消息可以清掉stop判定,但 streak 保留,模型一回头继续重复还是会被抓到。 - 先用低阈值试:《Stop Conditions》页在测试建议里写的就是先用很低的上限验证条件确实会触发,同样适用于验证 ladder 的档位配对没配对。
五、什么情况说明不是 doom loop
文档专门有一节写「这套东西抓不到什么」,如果你的现象落在下面几条里,继续调 doomLoop 是白费力气:
- 输入每次都在变。模型每次调用都塞一个新的时间戳或随机值,两次调用永远不「相同」。这不是检测失灵,是口径问题,解法是给这个工具配一个只取关键字段的
loopKey。 - 换着说法重复同一个意思。文本检测要求完全相同的重复,改写措辞不会触发。
- 被 hook 改写过的输入。文档写明重复是按模型发出的原始输入判定的,在
PreToolUse改写之前;hook 既造不出本来没有的重复,也藏不住本来有的重复。 - 你自己代码执行的调用。手动执行与客户端执行的调用会让循环暂停,并且不计入;只有 SDK 实际执行、拦下或解析失败的调用才算证据。
- 内置
task工具。文档把它当内部调用,明确豁免于 doom-loop 检测,《Async Tools》页也写了同一条(并写明它同时跳过按工具的并发与超时限制、不触发PreToolUse/PostToolUse)。
落在这几条里的 run,兜底靠的是 stopWhen——它不关心模型在不在原地打转,只按步数、工具调用、成本或 token 到点就停。还有一类现象也别错怪到 doom loop 头上:run 在停止条件触发时最后一轮的输出不对劲。《Stop Conditions》页写明,停止条件可能在模型还在发工具调用时触发,默认行为是先把这些待执行的调用跑完,再以 toolChoice: 'none' 多走一轮,并追加一条内置的收尾指令(导出名 DEFAULT_FINAL_RESPONSE_DIRECTIVE),避免以一个半截的工具调用收场;这一行为用 allowFinalResponse 调(改措辞、传空串不追加消息、传 false 直接不要这一轮)。文档还写明这最后一轮只在「停止条件打断了循环且上一条回复含可执行工具调用」时发生,自然结束、HITL/审批暂停、被中断都不会触发。至于工具轮次之后最终输出为空,文档说默认会重试一次,仍为空则以空文本收场而不抛错,想恢复严格契约要显式设 strictFinalResponse: true。这些都是收尾语义的问题,跟死循环没有关系。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。