Mastra Agent loop 的五个控制点:各自管什么,以及用错层会怎样
Agent loop 里"什么时候停、停得对不对"不是一个问题,是五个。Mastra 给了五个不同的机制,它们挂在链路的不同位置、回答不同的问题。把它们当成同一件事的五种写法,是这一层最常见的错误——症状不是报错,是行为看起来对但其实没生效。
下面这张表是全文的骨架。
| 机制 | 挂在哪 | 回答什么问题 | 用错的症状 |
|---|---|---|---|
maxSteps | generate options | 最多跑几轮 | 传了它,自定义 stopWhen 静默失效 |
stopWhen | generate options | 满足条件就刹车 | 拿它判答案质量,永远判不准 |
outputProcessor | 每轮 LLM 输出后、工具执行前 | 这轮输出合不合规 | 中间轮做最终校验,把 tool-call 判成"缺章节" |
isTaskComplete | 模型说完之后 | 业务上真的完成了吗 | 用 finishReason 代替它 |
abortSignal | 整次运行 | 现在必须停 | 不透传进工具,取消穿不透 |
一、maxSteps 会吃掉你的 stopWhen
这是最隐蔽的一条,因为它不报错。
const output = await agent.generate(prompt, {
maxSteps: 8,
stopWhen: ({ steps }) => { /* 你精心写的业务刹车 */ }
});看着像"两道保险",实际上只有一道。Mastra 1.49.0 的实现里,传了 maxSteps 就会优先把它转换成 stepCountIs(maxSteps) 作为停止条件,你自定义的 stopWhen 不会作为主要停止条件生效。
所以要验证自己的 stopWhen 到底跑没跑,唯一可靠的办法是不传 maxSteps,并且在回调里留下痕迹:
stopWhen: ({ steps }) => {
const toolNames = steps.flatMap((s) =>
(s.toolCalls ?? []).map((c) => String(c.toolName ?? c.name ?? 'unknown')));
const shouldStop = toolNames.includes('find_activities') || steps.length >= 3;
stopWhenDecisions.push({ stepCount: steps.length, toolNames, shouldStop });
return shouldStop;
}stopWhenDecisions 最后是空数组,说明 stopWhen 一次都没被调用——要么被 maxSteps 顶掉了,要么第一轮 LLM 请求就失败、根本没走到判断。这两种情况在日志上长得一样,不留痕就分不出来。
顺带一个字段兼容坑:不同 provider / 版本给的工具名字段可能是 toolName 也可能是 name,写 toolName ?? name ?? 'unknown' 不是防御性冗余,是必需的。
:::card 定位
stopWhen 是循环刹车,它看的是已完成的 steps,不看答案内容。用它判"答案够不够好"是层次错配——那是 isTaskComplete 和 outputProcessor 的活。
:::
二、outputProcessor 的 retry 不是自动的
processOutputStep 在每轮 LLM 响应之后、工具真正执行之前运行。它能 abort 掉这一轮:
args.abort(`最终行程缺少必要结构:${missing.join(', ')}`, {
retry: true,
metadata: { missing }
});retry: true 的语义是"把这个 reason 作为反馈写回上下文,再跑一轮"。但它有个前置条件:调用侧必须传 maxProcessorRetries,否则 Mastra 把这次 abort 当成普通 abort 处理,直接中断,不重试。
outputProcessors: [new TravelPlanQualityGate()],
maxProcessorRetries: 1,两处不在同一个文件里(一处在 processor 类内,一处在 generate options),这就是它容易漏的原因。processor 写得再对,调用侧漏了 maxProcessorRetries,行为就从"自动修正一次"退化成"直接失败"。
processor 适合做两类事,都是 Tool 做不了的:
- 能力越权拦截。没有真实下单工具的时候,模型说"已经为你预订"就是虚假承诺,必须在出口拦掉,而不是指望 prompt 约束住它。
- 交付协议校验。最终答案必须含哪些章节、哪个完成标记,是文本协议;换成
structuredOutput+ schema 也行,取决于下游是人读还是机器读。
三、isTaskComplete 不是 finishReason === 'stop'
这两个东西经常被当成一回事,但它们的主语不同:
finishReason === 'stop'—— 模型觉得自己这一轮说完了。isTaskComplete—— 业务判断整个任务完没完。
模型说完不代表业务完成。isTaskComplete 接的是 scorer,generateScore 返回 1 表示完成、0 表示继续;返回 0 时 generateReason 的文字会作为完成度反馈进入后续上下文,指导下一轮补齐。
.generateScore(async ({ run }) => {
const text = String(run.output ?? '');
const missingSections = requiredFinalSections.filter((x) => !text.includes(x));
const missingTools = requiredTools.filter((n) => !observedToolNames.has(n));
return missingSections.length === 0 && missingTools.length === 0 ? 1 : 0;
})注意这个 scorer 同时检查了两样东西:答案结构和工具是否真的调用过。后者是下一节的重点。
strategy: 'all' 表示所有 scorer 都得过;多个 scorer 互不依赖时可以 parallel: true。scorer 自己要调 LLM 或查库时,timeout 必须给——它是同步阻塞在 loop 里的。
四、别信文本里的"我查了天气"
这一条是整份 demo 里最有生产价值的判断。
模型完全可能在最终答案里写"我已经查询了天气预报",而实际上一次 get_weather_forecast 都没调用过。文本是模型的自述,不是运行记录。 要判断工具真的被调用过,只能读 runtime 事件:
const observedToolNames = new Set<string>();
onIterationComplete: (context) => {
for (const call of context.toolCalls) observedToolNames.add(call.name);
// ...
}onIterationComplete 是 Mastra runtime 的 hook,不是 Tool、也不是给 LLM 的 schema。它每轮结束触发,能拿到 iteration / finishReason / isFinal / toolCalls / toolResults / text。它的三个典型用途:
- 累计运行侧事实,喂给 scorer 当裁判依据(上面这段)。
- 异常兜底:
finishReason === 'error'时return { continue: false },否则 isTaskComplete 会因为"没答案也没工具调用"一直要求继续,把错误放大成死循环。 - 临门一脚注入:快到轮次上限还没出结果时
return { continue: true, feedback: '...' },强制再跑一轮并告诉模型这是最后机会。
把"必需工具清单"写成类型而不是散落字符串,是配套的工程约束:
type TravelToolName =
| 'get_destination_profile' | 'search_transport' | 'get_weather_forecast'
| 'find_activities' | 'estimate_trip_budget';
const requiredTools: TravelToolName[] = [ /* ... */ ];instructions 里写"必须调用这些工具"是告诉模型该怎么做;requiredTools 是在 runtime 检查它有没有真做到。前者是意图,后者是验收。生产里这类清单通常直接来自业务规则:退款前必须查订单和风控,推荐酒店前必须查库存和价格。
五、abortSignal 要穿到工具里
取消不是写在 Tool 里的开关,是作为执行控制选项传给 Agent 的:
const output = await agent.generate(prompt, {
...abortController.createExecutionAbortOptions(), // abortSignal / onAbort / errorProcessors
// ...
});但只在 Agent 层挂 signal 是不够的。工具自己在做长任务时,必须接住这个 signal:
execute: async ({ destination, startDate, days }, context) => {
await abortableSleep(delayMs, context?.abortSignal);
// 真实项目里这里是 fetch / axios / DB client,同样要把 signal 传进去
}createTool 的 execute 第二个参数就是运行上下文。真实业务里那个 abortableSleep 位置上是 HTTP 请求或数据库查询——不透传 signal,整次运行取消了,工具还在跑,连接还占着,超时也就名存实亡。
两层超时值得分开设:整次 agent 的总超时,和单次 LLM 请求的超时。它们诊断的是不同故障——前者是任务失控,后者是 provider 卡住。
一个反例:processor 在中间轮做最终校验
前面说 processor 能校验交付结构。但它每轮都跑,而 tool-call 那些中间轮根本没有最终答案:
if (args.finishReason !== 'stop') {
return args.messages; // 中间轮只记录,不做最终格式校验
}漏掉这个判断,模型刚准备调用工具、还没拿到工具结果,就被判"最终答案缺少章节"并触发 retry。表现出来是 agent 在原地打转、反复重试却越跑越差——而根因只是校验挂错了轮次。
怎么组合
回到开头那张表,五个机制的正确分工是:
- 要限制成本 →
maxSteps(注意它会顶掉 stopWhen) - 要在某个业务状态达成时停 →
stopWhen,且不要同时传 maxSteps - 要保证输出合规、能自动修一次 →
outputProcessor+maxProcessorRetries - 要业务验收而不是模型自认为 →
isTaskComplete+ scorer - 要能随时叫停 →
abortSignal,并透传进每个工具
它们不互相替代。真正判断一个 agent 是否"跑完了"的证据,永远不在模型输出的文本里,而在 runtime 事件里——toolCalls 记录了什么、scorer 判了几分、processor 拦了什么。文本只是模型对自己行为的描述,而描述可以是假的。
本文的判断来自一份 Mastra 教学 demo 的实现与注释(Mastra 1.49.0)。版本相关的行为——尤其是
maxSteps顶掉stopWhen——请在自己的版本上用留痕的方式复验,不要直接沿用结论。
来源与依据
- 旧 Wiki 原文 · Mastra Agent loop 的五个控制点不是五种写法,而是五层职责分离
收录日期:2026-10-11