
TL;DR30 秒速览ReAct 循环的默认终止条件是LLM 不再调用工具——但不再调工具 ≠ 任务完成输出可能不合 Schema、Goal 可能还没达成EasyAI 的答案是AgentCompletionCheck扩展点循环即将停止的那一刻逐个跑完成检查任何一个说没做完就注入一段 Prompt 让循环继续内置两个检查器OutputSchemaCompletionCheck输出合同不合格 → 带错误列表重试、GoalCompletionCheckGoal 还活着 → 自动续跑Goal 系统用户一句话设定目标Agent 自主多轮推进通过goal工具显式汇报状态完成必须附证据轮次 时长双预算兜底等用户审批的时间不计入预算核心代码AgentCompletionCheck37 行GoalCompletionCheck123 行GoalState102 行GoalTool前情提要上一篇我们讲了 增量上下文压缩——对话太长时如何不丢信息。这篇回到一个更根本的问题Agent 什么时候该停下来核心矛盾ReAct 循环的经典终止条件这一轮 LLM 没有发出工具调用 → 认为任务完成 → 停止。这个条件在三种场景下都会失灵。失灵场景现象后果停得太早LLM 说完成了但最终输出不合下游要求的 JSON Schema下游系统解析失败停得太早用户设定了一个长任务目标LLM 干了一轮就总结陈词目标不了了之停不下来粗暴地永远继续又不知道何时收手无限烧钱理想状态是终止判定从LLM 的自觉变成可编程的检查点——该停就停不该停就踢回去继续干而且要设好护栏防止踢成无限循环。扩展点AgentCompletionCheckEasyAI 在 Agent 循环的出口处装了一道闸。接口刻意做得极小/** * 当 Agent 循环即将停止continueLoop false时调用的完成检查钩子。 * 可以注册多个检查器任何一个返回 Continue循环就恢复。 */funinterfaceAgentCompletionCheck{suspendfuncheck(input:CompletionCheckInput):CompletionCheckResult}dataclassCompletionCheckInput(valagentContext:AgentContext,valtranscript:ListEasyAiMessage,// 完整对话记录检查器可以审视全部上下文valturnId:Int)sealedclassCompletionCheckResult{dataobjectDone:CompletionCheckResult()// 确实完成放行dataclassContinue(valprompt:String?null):CompletionCheckResult()// 没完成注入 Prompt 继续}循环侧的接线逻辑在AgentLoop里核心语义是原始逻辑说停的时候先过一遍检查// AgentLoop原始循环逻辑判定停止后if(!continueLoopservices.completionChecks.isNotEmpty()){valcheckInputCompletionCheckInput(context,transcript.toList(),turnId)for(checkinservices.completionChecks){valresulttry{check.check(checkInput)}catch(e:Exception){// 检查器自身异常不阻塞终止——降级为 DoneCompletionCheckResult.Done}if(resultisCompletionCheckResult.Continue){// 注入 Prompt 作为 UserMessage循环继续result.prompt?.let{appendAndNotify(UserMessage(it),transcript)}continueLooptruebreak// 任何一个检查说继续就继续}}}三个设计决策值得展开① 检查点放在出口而不是每轮都查。正常轮次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 stringLLM 定向修复的成功率远高于请重新输出一遍。这篇聚焦终止判定机制本身Output Schema 这条线的完整防御宽容提取、原生结构化输出、大 JSON 分块见 让 LLM 稳定输出 JSON。检查器二Goal 自主循环——让 Agent 干完一个真正的目标第二个检查器解决的问题更有野心让 Agent 围绕一个目标自主推进多轮用户说完需求就可以离开。目标的生命周期用户通过/goal命令设定目标用户/goal 完成这只股票的回测报告包含夏普比率和最大回撤分析GoalCommandHandler创建一个GoalState并持久化到会话dataclassGoalState(valsessionId:String,valobjective:String,// 目标描述valsuccessCriteria:String?null,// 成功标准valconstraints:String?null,// 约束 / 非目标valturnCount:Int0,valmaxTurns:IntDEFAULT_MAX_TURNS,// 默认 10 轮自动续跑valmaxDurationMs:LongDEFAULT_MAX_DURATION_MS,// 默认 150 分钟valtotalPausedMs:Long0,// 累计暂停时长等用户的时间valstatus:GoalStatusGoalStatus.ACTIVE,valcompletionEvidence:String?null,// 完成证据valhistory:ListGoalHistoryEntryemptyList()// 生命周期日志上限 20 条)enumclassGoalStatus{ACTIVE,COMPLETED,BLOCKED,PAUSED,LIMIT_REACHED}接下来就是GoalCompletionCheck与 Agent 循环的闭环Turn 1LLM 执行几步工具没有更多工具可调打算停止 → GoalCompletionCheck查 DBGoal 还是 ACTIVE 且未超预算 → 是 → turnCount1注入 goal_continuation Prompt → 循环继续 Turn 2LLM 看到续跑指令继续推进 ... Turn NLLM 调 goal 工具汇报 statuscompleted 并附证据 → Goal 状态变 COMPLETED → 下次循环打算停时检查器查到非 ACTIVE → Done续跑 Prompt 不是复读机注入的续跑指令带着进度感知让模型知道自己处在什么位置privatefunbuildContinuePrompt(goal:GoalState):StringbuildString{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 youre 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每次续跑前都过一遍限制privatefuncheckLimits(goal:GoalState):String?{if(goal.turnCountgoal.maxTurns){returnAuto-continue turn limit reached (${goal.turnCount}/${goal.maxTurns})}if(goal.elapsedMsgoal.maxDurationMs){returnDuration limit reached (...)}returnnull}超限不是简单掐断而是把 Goal 标记为LIMIT_REACHED并记录stopReason——用户在前端能看到目标因达到 10 轮上限而停止而不是目标无声消失。一个容易忽略的公平性问题等用户的时间不算数Agent 执行中可能触发权限审批或向用户提问比如要执行这条写库的 SQL请确认。用户可能十分钟才回来——这段时间算不算 Goal 的时长预算算的话一个需要多次确认的目标会被等待耗死。GoalState的计时器为此做了暂停语义/** 已耗费的活跃时长 总时长 - 累计暂停时长 */valelapsedMs:Longget(){valnowpausedAt?:System.currentTimeMillis()return(now-startedAt)-totalPausedMs}funpauseTimer():GoalState// 等待用户输入时调用幂等funresumeTimer():GoalState// 用户响应后调用累计暂停时长幂等预算只统计 Agent 真正在干活的时间——约束的是 Agent 的效率不是用户的响应速度。两个边界守卫子 Agent 不驱动 Goal。GoalCompletionCheck开头就短路// 子 Agent 不应该驱动 Goal 完成判定if(input.agentContext.parentAgentId!null){returnCompletionCheckResult.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。