知识块 · 版本 1块链接查看来源查看知识状态待读取
01

Mastra Agent loop 的五个控制点:各自管什么,以及用错层会怎样

Agent loop 里"什么时候停、停得对不对"不是一个问题,是五个。Mastra 给了五个不同的机制,它们挂在链路的不同位置、回答不同的问题。把它们当成同一件事的五种写法,是这一层最常见的错误——症状不是报错,是行为看起来对但其实没生效。

下面这张表是全文的骨架。

机制挂在哪回答什么问题用错的症状
maxStepsgenerate options最多跑几轮传了它,自定义 stopWhen 静默失效
stopWhengenerate options满足条件就刹车拿它判答案质量,永远判不准
outputProcessor每轮 LLM 输出后、工具执行前这轮输出合不合规中间轮做最终校验,把 tool-call 判成"缺章节"
isTaskComplete模型说完之后业务上真的完成了吗用 finishReason 代替它
abortSignal整次运行现在必须停不透传进工具,取消穿不透
知识块 · 版本 1块链接查看来源查看知识状态待读取
02

一、maxSteps 会吃掉你的 stopWhen

这是最隐蔽的一条,因为它不报错。

ts
const output = await agent.generate(prompt, {
  maxSteps: 8,
  stopWhen: ({ steps }) => { /* 你精心写的业务刹车 */ }
});

看着像"两道保险",实际上只有一道。Mastra 1.49.0 的实现里,传了 maxSteps 就会优先把它转换成 stepCountIs(maxSteps) 作为停止条件,你自定义的 stopWhen 不会作为主要停止条件生效。

所以要验证自己的 stopWhen 到底跑没跑,唯一可靠的办法是不传 maxSteps,并且在回调里留下痕迹:

ts
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 的活。 :::

知识块 · 版本 1块链接查看来源查看知识状态待读取
03

二、outputProcessor 的 retry 不是自动的

processOutputStep 在每轮 LLM 响应之后、工具真正执行之前运行。它能 abort 掉这一轮:

ts
args.abort(`最终行程缺少必要结构:${missing.join(', ')}`, {
  retry: true,
  metadata: { missing }
});

retry: true 的语义是"把这个 reason 作为反馈写回上下文,再跑一轮"。但它有个前置条件:调用侧必须传 maxProcessorRetries,否则 Mastra 把这次 abort 当成普通 abort 处理,直接中断,不重试。

ts
outputProcessors: [new TravelPlanQualityGate()],
maxProcessorRetries: 1,

两处不在同一个文件里(一处在 processor 类内,一处在 generate options),这就是它容易漏的原因。processor 写得再对,调用侧漏了 maxProcessorRetries,行为就从"自动修正一次"退化成"直接失败"。

processor 适合做两类事,都是 Tool 做不了的:

  • 能力越权拦截。没有真实下单工具的时候,模型说"已经为你预订"就是虚假承诺,必须在出口拦掉,而不是指望 prompt 约束住它。
  • 交付协议校验。最终答案必须含哪些章节、哪个完成标记,是文本协议;换成 structuredOutput + schema 也行,取决于下游是人读还是机器读。
知识块 · 版本 1块链接查看来源查看知识状态待读取
04

三、isTaskComplete 不是 finishReason === 'stop'

这两个东西经常被当成一回事,但它们的主语不同:

  • finishReason === 'stop' —— 模型觉得自己这一轮说完了。
  • isTaskComplete —— 业务判断整个任务完没完。

模型说完不代表业务完成。isTaskComplete 接的是 scorer,generateScore 返回 1 表示完成、0 表示继续;返回 0 时 generateReason 的文字会作为完成度反馈进入后续上下文,指导下一轮补齐。

ts
.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 里的。

知识块 · 版本 1块链接查看来源查看知识状态待读取
05

四、别信文本里的"我查了天气"

这一条是整份 demo 里最有生产价值的判断。

模型完全可能在最终答案里写"我已经查询了天气预报",而实际上一次 get_weather_forecast 都没调用过。文本是模型的自述,不是运行记录。 要判断工具真的被调用过,只能读 runtime 事件:

ts
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。它的三个典型用途:

  1. 累计运行侧事实,喂给 scorer 当裁判依据(上面这段)。
  2. 异常兜底:finishReason === 'error' 时 return { continue: false },否则 isTaskComplete 会因为"没答案也没工具调用"一直要求继续,把错误放大成死循环。
  3. 临门一脚注入:快到轮次上限还没出结果时 return { continue: true, feedback: '...' },强制再跑一轮并告诉模型这是最后机会。

把"必需工具清单"写成类型而不是散落字符串,是配套的工程约束:

ts
type TravelToolName =
  | 'get_destination_profile' | 'search_transport' | 'get_weather_forecast'
  | 'find_activities' | 'estimate_trip_budget';

const requiredTools: TravelToolName[] = [ /* ... */ ];

instructions 里写"必须调用这些工具"是告诉模型该怎么做;requiredTools 是在 runtime 检查它有没有真做到。前者是意图,后者是验收。生产里这类清单通常直接来自业务规则:退款前必须查订单和风控,推荐酒店前必须查库存和价格。

知识块 · 版本 1块链接查看来源查看知识状态待读取
06

五、abortSignal 要穿到工具里

取消不是写在 Tool 里的开关,是作为执行控制选项传给 Agent 的:

ts
const output = await agent.generate(prompt, {
  ...abortController.createExecutionAbortOptions(),  // abortSignal / onAbort / errorProcessors
  // ...
});

但只在 Agent 层挂 signal 是不够的。工具自己在做长任务时,必须接住这个 signal:

ts
execute: async ({ destination, startDate, days }, context) => {
  await abortableSleep(delayMs, context?.abortSignal);
  // 真实项目里这里是 fetch / axios / DB client,同样要把 signal 传进去
}

createTool 的 execute 第二个参数就是运行上下文。真实业务里那个 abortableSleep 位置上是 HTTP 请求或数据库查询——不透传 signal,整次运行取消了,工具还在跑,连接还占着,超时也就名存实亡。

两层超时值得分开设:整次 agent 的总超时,和单次 LLM 请求的超时。它们诊断的是不同故障——前者是任务失控,后者是 provider 卡住。

知识块 · 版本 1块链接查看来源查看知识状态待读取
07

一个反例:processor 在中间轮做最终校验

前面说 processor 能校验交付结构。但它每轮都跑,而 tool-call 那些中间轮根本没有最终答案:

ts
if (args.finishReason !== 'stop') {
  return args.messages;   // 中间轮只记录,不做最终格式校验
}

漏掉这个判断,模型刚准备调用工具、还没拿到工具结果,就被判"最终答案缺少章节"并触发 retry。表现出来是 agent 在原地打转、反复重试却越跑越差——而根因只是校验挂错了轮次。

知识块 · 版本 1块链接查看来源查看知识状态待读取
08

怎么组合

回到开头那张表,五个机制的正确分工是:

  • 要限制成本 → maxSteps(注意它会顶掉 stopWhen)
  • 要在某个业务状态达成时停 → stopWhen,且不要同时传 maxSteps
  • 要保证输出合规、能自动修一次 → outputProcessor + maxProcessorRetries
  • 要业务验收而不是模型自认为 → isTaskComplete + scorer
  • 要能随时叫停 → abortSignal,并透传进每个工具

它们不互相替代。真正判断一个 agent 是否"跑完了"的证据,永远不在模型输出的文本里,而在 runtime 事件里——toolCalls 记录了什么、scorer 判了几分、processor 拦了什么。文本只是模型对自己行为的描述,而描述可以是假的。

本文的判断来自一份 Mastra 教学 demo 的实现与注释(Mastra 1.49.0)。版本相关的行为——尤其是 maxSteps 顶掉 stopWhen——请在自己的版本上用留痕的方式复验,不要直接沿用结论。

来源与依据