
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 而非静默掐断 |
总结
| 终止条件 | 无工具调用即停 | 出口处可编程检查点 |
| 输出正确性 | 靠 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。




