欢迎光临
我们一直在努力

AI 说“我做完了”,你敢信吗?Agent 终止判定与 Goal 自主循环

在这里插入图片描述

TL;DR(30 秒速览)

  • ReAct 循环的默认终止条件是"LLM 不再调用工具"——但不再调工具 ≠ 任务完成:输出可能不合 Schema、Goal 可能还没达成
  • EasyAI 的答案是 AgentCompletionCheck 扩展点:循环即将停止的那一刻,逐个跑完成检查;任何一个说"没做完",就注入一段 Prompt 让循环继续
  • 内置两个检查器:OutputSchemaCompletionCheck(输出合同不合格 → 带错误列表重试)、GoalCompletionCheck(Goal 还活着 → 自动续跑)
  • Goal 系统:用户一句话设定目标,Agent 自主多轮推进,通过 goal 工具显式汇报状态(完成必须附证据),轮次 + 时长双预算兜底,等用户审批的时间不计入预算
  • 核心代码:AgentCompletionCheck(37 行)+ GoalCompletionCheck(123 行)+ GoalState(102 行)+ GoalTool

前情提要:上一篇我们讲了 增量上下文压缩——对话太长时如何不丢信息。这篇回到一个更根本的问题:Agent 什么时候该停下来?


核心矛盾

ReAct 循环的经典终止条件:这一轮 LLM 没有发出工具调用 → 认为任务完成 → 停止。

这个条件在三种场景下都会失灵。

失灵场景现象后果
停得太早 LLM 说"完成了",但最终输出不合下游要求的 JSON Schema 下游系统解析失败
停得太早 用户设定了一个长任务目标,LLM 干了一轮就总结陈词 目标不了了之
停不下来 粗暴地"永远继续",又不知道何时收手 无限烧钱

理想状态是:终止判定从"LLM 的自觉"变成"可编程的检查点"——该停就停,不该停就踢回去继续干,而且要设好护栏防止踢成无限循环。


扩展点:AgentCompletionCheck

EasyAI 在 Agent 循环的"出口"处装了一道闸。接口刻意做得极小:

/**
* 当 Agent 循环即将停止(continueLoop = false)时调用的完成检查钩子。
* 可以注册多个检查器;任何一个返回 Continue,循环就恢复。
*/

fun interface AgentCompletionCheck {
suspend fun check(input: CompletionCheckInput): CompletionCheckResult
}

data class CompletionCheckInput(
val agentContext: AgentContext,
val transcript: List<EasyAiMessage>, // 完整对话记录,检查器可以审视全部上下文
val turnId: Int
)

sealed class CompletionCheckResult {
data object Done : CompletionCheckResult() // 确实完成,放行
data class Continue(val prompt: String? = null) : CompletionCheckResult() // 没完成,注入 Prompt 继续
}

循环侧的接线逻辑在 AgentLoop 里,核心语义是"原始逻辑说停的时候,先过一遍检查":

// AgentLoop:原始循环逻辑判定停止后
if (!continueLoop && services.completionChecks.isNotEmpty()) {
val checkInput = CompletionCheckInput(context, transcript.toList(), turnId)
for (check in services.completionChecks) {
val result = try {
check.check(checkInput)
} catch (e: Exception) {
// 检查器自身异常不阻塞终止——降级为 Done
CompletionCheckResult.Done
}
if (result is CompletionCheckResult.Continue) {
// 注入 Prompt 作为 UserMessage,循环继续
result.prompt?.let { appendAndNotify(UserMessage(it), transcript) }
continueLoop = true
break // 任何一个检查说继续,就继续
}
}
}

三个设计决策值得展开:

① 检查点放在"出口"而不是每轮都查。 正常轮次(LLM 还在调工具)不需要检查;只有当循环"打算停"时才触发。检查零成本地休眠,需要时才工作。

② 检查器异常 = Done。 完成检查是增强项不是关键路径——它抛异常时如果阻塞终止,等于把一个小 bug 放大成会话卡死。降级放行是刻意的保守选择。

③ 续跑靠注入 Prompt,而不是硬编码指令。 Continue(prompt) 携带的文本作为 UserMessage 进入对话记录,LLM 在下一轮"看到"这段话后自主决定怎么继续。框架负责踢回去,怎么干仍由模型决定。


检查器一:输出合同——OutputSchemaCompletionCheck

第一个内置检查器解决"输出不合格就停"的问题。当 Agent 配置了 Output Schema(下游系统需要结构化输出)时:

Agent 打算停止
→ OutputSchemaCompletionCheck:拿最后一条 Assistant 消息对 JSON Schema 校验
→ 合格 → Done,放行
→ 不合格 → Continue("你的输出不符合要求,错误如下:$.score 应为 number……")
→ 重试超过上限(默认 2 次)→ Done,有界失败,返回原结果

关键点:重试 Prompt 携带具体的校验错误列表(JSONPath 级别,如 $.score: expected number, found string),LLM 定向修复的成功率远高于"请重新输出一遍"。

这篇聚焦终止判定机制本身,Output Schema 这条线的完整防御(宽容提取、原生结构化输出、大 JSON 分块)见 让 LLM 稳定输出 JSON。


检查器二:Goal 自主循环——让 Agent 干完一个真正的目标

第二个检查器解决的问题更有野心:让 Agent 围绕一个目标自主推进多轮,用户说完需求就可以离开。

目标的生命周期

用户通过 /goal 命令设定目标:

用户:/goal 完成这只股票的回测报告,包含夏普比率和最大回撤分析

GoalCommandHandler 创建一个 GoalState 并持久化到会话:

data class GoalState(
val sessionId: String,
val objective: String, // 目标描述
val successCriteria: String? = null, // 成功标准
val constraints: String? = null, // 约束 / 非目标
val turnCount: Int = 0,
val maxTurns: Int = DEFAULT_MAX_TURNS, // 默认 10 轮自动续跑
val maxDurationMs: Long = DEFAULT_MAX_DURATION_MS, // 默认 150 分钟
val totalPausedMs: Long = 0, // 累计暂停时长(等用户的时间)
val status: GoalStatus = GoalStatus.ACTIVE,
val completionEvidence: String? = null, // 完成证据
val history: List<GoalHistoryEntry> = emptyList() // 生命周期日志(上限 20 条)
)

enum class GoalStatus { ACTIVE, COMPLETED, BLOCKED, PAUSED, LIMIT_REACHED }

接下来就是 GoalCompletionCheck 与 Agent 循环的闭环:

Turn 1:LLM 执行几步工具,没有更多工具可调,打算停止
→ GoalCompletionCheck:查 DB,Goal 还是 ACTIVE 且未超预算?
→ 是 → turnCount+1,注入 <goal_continuation> Prompt → 循环继续
Turn 2:LLM 看到续跑指令,继续推进

Turn N:LLM 调 goal 工具汇报 status="completed" 并附证据
→ Goal 状态变 COMPLETED → 下次循环打算停时,检查器查到非 ACTIVE → Done

续跑 Prompt 不是复读机

注入的续跑指令带着进度感知,让模型知道自己处在什么位置:

private fun buildContinuePrompt(goal: GoalState): String = buildString {
appendLine("<goal_continuation>")
appendLine("The goal below is still active. Continue working toward it.")
appendLine()
appendLine("<goal_objective>")
appendLine(escapeXml(goal.objective)) // XML 转义,防止目标文本注入指令
appendLine("</goal_objective>")
appendLine()
appendLine("## Progress")
appendLine("- Turns used: ${goal.turnCount}/${goal.maxTurns}")
appendLine("- Time elapsed: ${goal.elapsedMs / 1000}s/${goal.maxDurationMs / 1000}s")
appendLine()
appendLine("## Next Steps")
appendLine("1. Take the next concrete step toward the goal")
appendLine("2. Verify your progress against the goal objective")
appendLine("3. When the goal is complete, use the `goal` tool … and provide evidence")
appendLine("4. If you're blocked, use the `goal` tool with status=\\"blocked\\" …")
appendLine("</goal_continuation>")
}

"剩余预算"写进 Prompt 有一个隐含效果:模型看到轮次快用完时,会主动收敛、总结收尾,而不是拖到被硬停。

显式汇报:goal 工具

Goal 的状态变更不靠"猜",靠 LLM 显式调用 goal 工具:

Actions:
– "update_status": 变更状态(active / completed / blocked / paused)
– 标记 completed 时必须提供 evidence(完成证据)
– 标记 blocked 时必须提供 reason(阻塞原因)
– "update_objective": 根据新信息修正目标描述
– "add_evidence": 补充证据并标记完成

这是一个有历史教训的设计。早期版本用的是文本标记协议——让 LLM 在回复里写 [goal:complete] 这样的字符串,框架做文本匹配。结果:模型经常忘记写、写错格式、或在思考过程里误触发。改成工具调用后,状态变更变成了结构化的、可校验的、有参数约束的动作——"完成必须附证据"这种规则直接写进工具参数描述里,比 Prompt 里求模型自觉可靠得多。

双预算保险:轮次 + 时长

自主循环最大的风险是无限烧钱。GoalCompletionCheck 每次续跑前都过一遍限制:

private fun checkLimits(goal: GoalState): String? {
if (goal.turnCount >= goal.maxTurns) {
return "Auto-continue turn limit reached (${goal.turnCount}/${goal.maxTurns})"
}
if (goal.elapsedMs >= goal.maxDurationMs) {
return "Duration limit reached (…)"
}
return null
}

超限不是简单掐断,而是把 Goal 标记为 LIMIT_REACHED 并记录 stopReason——用户在前端能看到"目标因达到 10 轮上限而停止",而不是目标无声消失。

一个容易忽略的公平性问题:等用户的时间不算数

Agent 执行中可能触发权限审批或向用户提问(比如"要执行这条写库的 SQL,请确认")。用户可能十分钟才回来——这段时间算不算 Goal 的时长预算?算的话,一个需要多次确认的目标会被"等待"耗死。

GoalState 的计时器为此做了暂停语义:

/** 已耗费的"活跃"时长 = 总时长 – 累计暂停时长 */
val elapsedMs: Long
get() {
val now = pausedAt ?: System.currentTimeMillis()
return (now startedAt) totalPausedMs
}

fun pauseTimer(): GoalState // 等待用户输入时调用(幂等)
fun resumeTimer(): GoalState // 用户响应后调用,累计暂停时长(幂等)

预算只统计 Agent 真正在干活的时间——约束的是 Agent 的效率,不是用户的响应速度。

两个边界守卫

子 Agent 不驱动 Goal。 GoalCompletionCheck 开头就短路:

// 子 Agent 不应该驱动 Goal 完成判定
if (input.agentContext.parentAgentId != null) {
return CompletionCheckResult.Done
}

否则主 Agent 派出去的每个子 Agent 结束前都会被续跑,目标会被重复推进。

状态实时可见。 Goal 的每次变化(自动续跑、状态变更、达到上限)都通过 GoalStatusNotifier 广播,Web 层的监听器把它转成事件推进 SSE 流——前端的 Goal 卡片实时显示"第 3/10 轮,进行中"。这条事件通道的设计细节,正好是下一篇的主题。


全景图:一次"即将停止"的完整判定

LLM 本轮没有发出工具调用 → AgentLoop 判定 continueLoop = false

依次运行 completionChecks:
├─ OutputSchemaCompletionCheck
│ 输出不合 Schema? → Continue(带错误列表的重试 Prompt) [有界:最多 2 次]
├─ GoalCompletionCheck
│ Goal ACTIVE 且未超预算? → Continue(goal_continuation Prompt) [双预算兜底]
│ Goal ACTIVE 但超限? → 标记 LIMIT_REACHED → Done
└─ 其他自定义检查……(业务方可以自行注册)

全部 Done → 循环真正停止,发出 AgentEndEvent


踩坑记录

坑原因解法
文本标记协议([goal:complete])不可靠 模型忘写/写错/误触发 改为 goal 工具显式调用,状态变更结构化
检查器异常导致会话卡死 检查抛异常没人兜底 catch → Done,检查失败降级为放行
子 Agent 也在续跑 Goal 检查器没区分父子 parentAgentId != null 直接 Done
等用户审批耗光了时长预算 墙钟计时不分活跃/等待 pauseTimer/resumeTimer,预算只计活跃时长
续跑 Prompt 被目标文本注入 目标里可能包含 <、指令式文本 escapeXml 全量转义后包进固定 XML 结构
重试计数器跨会话串台 异常退出(取消/abort)残留状态 循环启动时 resetSession() 清理
自动续跑失控 只设轮次上限,单轮可以跑很久 轮次 + 时长双预算,超限标记 LIMIT_REACHED 而非静默掐断

总结

维度裸 ReActEasyAI 方案
终止条件 无工具调用即停 出口处可编程检查点
输出正确性 靠 Prompt 求自觉 Schema 校验 + 带错误的定向重试(有界)
长任务 干一轮就总结 Goal 自主循环,显式工具汇报
防失控 重试上限 / 轮次+时长双预算 / LIMIT_REACHED
扩展性 改循环代码 实现 AgentCompletionCheck 注册即用

核心思想一句话:"干完了"不应该是一个模型输出的状态,而应该是一组可编程检查的结论——该停时干脆地停,不该停时带着理由踢回去。


下一篇:从 Kotlin Channel 到 SSE——Agent 事件流的全链路设计

Agent 执行过程中有 15+ 种事件类型(thinking、tool 执行、权限请求、压缩、Goal 状态、子 Agent 转发……),怎么实时推送到前端?从 Kotlin Channel → Flow → Reactor Flux → SSE,全链路解耦,刷新页面不丢状态。


开源地址:https://github.com/haibingzhao/easyai

欢迎 Star、Issue 和 PR。

赞(0)
未经允许不得转载:171主机测评 » AI 说“我做完了”,你敢信吗?Agent 终止判定与 Goal 自主循环
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址