不用框架手写一个最小 Agent 循环:最大步数、停止条件和失控熔断怎么设计

发布时间:2026/10/14 19:34:14
不用框架手写一个最小 Agent 循环:最大步数、停止条件和失控熔断怎么设计
不用框架手写一个最小 Agent 循环最大步数、停止条件和失控熔断怎么设计环境Windows 11、OpenJDK 11.0.32.1Temurin纯 JDK 单文件无第三方依赖说明本文的 LLM 用按脚本输出的 Mock 代替保证实验可复现Agent 循环本身步数上限、重复调用检测、错误熔断、停止条件是真实实现并在本机跑通。换成真实模型 API 时循环代码不变只替换chat()的实现。目录为什么要手写一遍 Agent 循环Agent 循环的骨架拆开以后它就是一个带保护的 while实验设计用 MockLLM 换可复现性核心代码工具层、协议解析和循环控制场景 A一个正常任务跑完四步场景 B/C/D三种失控方式三种拦截手段这个最小实现和生产级 Agent 的差距小结为什么要手写一遍 Agent 循环学 Agent 开发时大多数人包括我自己的第一步是接框架LangChain4j、Spring AI或者直接用模型厂商的 Agent SDK。框架用起来很顺但用一段时间会遇到一个尴尬的问题——Agent 行为异常时你不知道该怀疑模型、提示词、工具定义还是框架的某层封装。我的处理方式是回到骨架不依赖任何框架用纯 JDK 把 Agent 循环手写一遍故意制造几种失控场景看循环的控制逻辑能不能拦住。结论先说Agent 循环拆开以后没有神秘性本质是一个LLM 决策 → 工具执行 → 观察写回上下文的 while 循环。真正决定一个 Agent 能不能用在生产环境的不是循环本身而是循环外面的保护层最大步数、重复调用检测、错误熔断、明确的停止条件。模型失控是常态而不是小概率事件。重复调用同一个工具、输出格式漂移、参数和工具协议对不上这三种情况在真实模型上都会发生循环必须能在不依赖模型自觉的情况下终止。下面给出完整实现和四个场景的实测输出。Agent 循环的骨架拆开以后它就是一个带保护的 whileReAct 论文Yao et al., 2022把 Agent 的单轮结构描述为 Thought → Action → Observation模型先推理再决定调什么工具然后把工具返回的观察结果写回上下文进入下一轮。工程实现里这个结构就是一个循环history [用户任务, 系统提示(工具清单输出协议)] loop: output LLM(history) # 模型决策 if output 是最终回答: return # 停止条件 1 if output 是工具调用: result 执行工具(output) history [output, result] # 观察写回上下文 检查保护层 # 停止条件 2/3/4所有框架做的是在这个骨架上叠加结构化输出解析function calling、并行工具调用、token 预算统计、重试、可观测性。骨架本身几十行代码就能写完这篇文章就把它写完并重点处理三件事什么时候停模型给出最终回答时停。模型不停怎么办达到最大步数强制停。模型乱来怎么办重复调用检测和连续错误熔断。实验设计用 MockLLM 换可复现性直接接真实模型做实验有两个问题每次运行结果不一样文章里的输出读者无法复现而且为了演示失控你得真的等到模型失控。所以我用一个按脚本输出的MockLLM代替真实模型循环逻辑全部真实实现。LLM 和循环之间约定一个教学用的输出协议一行一条TOOL 工具名 keyvalue,keyvalue # 调用工具 FINAL 文本 # 给出最终回答结束循环真实系统里这个位置对应的是 function calling 的结构化输出模型返回tool_calls字段而不是自然语言文本。协议形式不同循环结构完全一样。准备三个工具加法add(a,b)、乘法mul(a,b)、当前时间now()。任务选一个需要多步的“计算 (34)*5并告诉我现在几点”——模型必须先加、再乘、再查时间最后汇总。核心代码工具层、协议解析和循环控制工具层是一个接口加三个实现。注意add的参数校验解析失败时不抛异常而是返回TOOL_ERROR:开头的字符串。这是故意的——工具的错误信息要作为观察结果喂回给模型让模型有机会自我修正而不是让整个循环崩掉。interfaceTool{Stringname();Stringdescription();Stringcall(MapString,Stringargs);}staticclassAddToolimplementsTool{publicStringname(){returnadd;}publicStringdescription(){returnadd(a,b): 计算两个整数的和;}publicStringcall(MapString,Stringargs){try{intaInteger.parseInt(args.getOrDefault(a,));intbInteger.parseInt(args.getOrDefault(b,));returnString.valueOf(ab);}catch(NumberFormatExceptione){returnTOOL_ERROR: add 的参数必须是整数收到 aargs.get(a), bargs.get(b);}}}循环控制是全文重点。每一轮做五件事拿模型输出、判断是否结束、解析工具调用、执行工具、检查保护层。Stringrun(Stringtask,MockLLMllm){ListStringhistorynewArrayList();history.add(USER: task);history.add(SYSTEM: 可用工具: toolDescriptions()。每轮只允许输出一行TOOL name kv,kv 或 FINAL answer);StringlastCallSignaturenull;// 上一次工具调用的签名用于重复检测intrepeatCount0;intconsecutiveErrors0;// 连续错误计数用于熔断for(intstep1;stepmaxSteps;step){Stringoutllm.chat(history).trim();if(out.startsWith(FINAL)){// 停止条件 1模型给出答案returnout.substring(FINAL.length()).trim();}if(!out.startsWith(TOOL)){// 输出格式漂移consecutiveErrors;history.add(ASSISTANT: out);history.add(SYSTEM: 输出格式无法解析请严格使用 TOOL/FINAL 协议);if(consecutiveErrorsmaxConsecutiveErrors){return终止连续 consecutiveErrors 次输出无法解析熔断退出;}continue;}// 解析 TOOL 行得到 toolName 和 args解析代码略见完整源码StringsignaturetoolNameargs;// 停止条件 2重复调用检测if(signature.equals(lastCallSignature)){repeatCount;if(repeatCount2){return终止检测到重复调用 toolNameargs模型陷入循环;}}else{repeatCount0;}lastCallSignaturesignature;Tooltooltools.get(toolName);Stringresult(toolnull)?TOOL_ERROR: 未注册的工具 toolName:tool.call(args);if(result.startsWith(TOOL_ERROR)){// 停止条件 3连续错误熔断consecutiveErrors;if(consecutiveErrorsmaxConsecutiveErrors){return终止连续 consecutiveErrors 次工具调用失败熔断退出;}}else{consecutiveErrors0;}history.add(ASSISTANT: out);// 观察写回上下文history.add(OBSERVATION: result);}return终止达到最大步数 maxSteps强制停止;// 停止条件 4兜底}几个设计取舍说明一下重复检测用的是工具名 参数的签名不是单纯的工具名。任务里连续调两次add是合理的比如算(12)(34)但用同样的参数调第二次一定是卡住——观察结果已经在上下文里了再问一遍不会得到新信息。连续错误才熔断单次错误不熔断。模型看到TOOL_ERROR后自我修正比如把中文数字改成阿拉伯数字是真实存在的恢复路径给它一两次机会是划算的连续失败才说明协议真的对不上。最大步数是最后兜底不是主要停止手段。如果一个 Agent 经常靠最大步数停下来说明任务拆解或提示词有问题而不是保护生效了。场景 A一个正常任务跑完四步任务“计算 (34)*5并告诉我现在几点”。MockLLM 的脚本是正常决策序列。实测输出 场景 A正常任务 (34)*5 等于多少现在几点 [step 1] LLM 输出: TOOL add a3,b4 [step 1] 工具 add 返回: 7 [step 2] LLM 输出: TOOL mul a7,b5 [step 2] 工具 mul 返回: 35 [step 3] LLM 输出: TOOL now [step 3] 工具 now 返回: Tue Oct 13 18:57:35 CST 2026 [step 4] LLM 输出: FINAL (34)*5 35当前时间见最后一次观察结果。 [step 4] 收到 FINAL循环正常结束 最终回答: (34)*5 35当前时间见最后一次观察结果。 LLM 调用次数: 4四步收敛。注意 step 2模型拿到的输入里已经有OBSERVATION: 7所以它能把 7 作为mul的第一个参数传进去——这就是观察写回上下文的实际作用模型每一步的决策都基于此前全部历史。场景 B/C/D三种失控方式三种拦截手段保护层的价值只有在失控时才看得见。我构造了三种真实模型上都会出现的情况。场景 B模型重复调用同一个工具、同样的参数。真实模型在长上下文或提示词不清晰时会这样复读。实测[step 1] LLM 输出: TOOL add a3,b4 [step 1] 工具 add 返回: 7 [step 2] LLM 输出: TOOL add a3,b4 [step 2] 工具 add 返回: 7 [step 3] LLM 输出: TOOL add a3,b4 最终回答: 终止检测到重复调用 add{a3, b4}模型陷入循环 LLM 调用次数: 3第 3 步触发重复检测循环在第 3 次调用时终止。如果没有这层检测这个循环会一直烧到最大步数每一步都是一次真实的模型调用——花的都是 token 预算。场景 C参数类型错误 调用未注册的工具。这对应真实场景里的 function calling 参数校验失败和幻觉工具名[step 1] LLM 输出: TOOL add a三,b四 [step 1] 工具 add 返回: TOOL_ERROR: add 的参数必须是整数收到 a三, b四 [step 2] LLM 输出: TOOL search q天气 [step 2] 工具 search 返回: TOOL_ERROR: 未注册的工具 search 最终回答: 终止连续 2 次工具调用失败熔断退出 LLM 调用次数: 2脚本里其实还有第三条FINAL 我不知道但循环在第 2 步就熔断了——连续两次工具错误说明模型和工具协议已经对不上再给机会大概率也是浪费调用。熔断计数maxConsecutiveErrors2是个可调的阈值工具复杂、参数容易错时可以放宽到 3。场景 D模型失控但每次调用的参数略有不同。MockLLM 在脚本用尽后设计为无限调用add且每次把第二个参数加一——这模拟一种更隐蔽的失控模型没有完全复读而是每次微调参数再试一次。签名检测对这种模式失效实测只能靠最大步数兜底[step 1] LLM 输出: TOOL add a1,b1 [step 1] 工具 add 返回: 2 [step 2] LLM 输出: TOOL add a1,b2 [step 2] 工具 add 返回: 3 [step 3] LLM 输出: TOOL add a1,b3 [step 3] 工具 add 返回: 4 [step 4] LLM 输出: TOOL add a1,b4 [step 4] 工具 add 返回: 5 [step 5] LLM 输出: TOOL add a1,b5 [step 5] 工具 add 返回: 6 [step 6] LLM 输出: TOOL add a1,b6 [step 6] 工具 add 返回: 7 最终回答: 终止达到最大步数 6强制停止防止无限循环烧光预算 LLM 调用次数: 66 步跑满被最大步数强制停止。这正是最大步数存在的意义它不关心模型在干什么、用的是什么模式只保证循环一定有界。真实系统里这一层对应的是 token 预算上限——比步数更精确的必须停信号。三层保护的触发顺序因此是重复检测最灵敏先抓同参数复读错误熔断抓协议层面的持续失败最大步数无差别兜底。设计上这是故意的越靠前的层越便宜少花 token越靠后的层越可靠不依赖任何模式识别。这个最小实现和生产级 Agent 的差距这个 200 行的实现能让你看清骨架但离生产还差得远主要差在结构化输出。真实系统用 function calling模型返回的是带 schema 的tool_calls不存在解析一行文本的脆弱性。我的教学协议遇到模型输出多余空白就会解析失败生产里这层由厂商 API 保证。token 预算。最大步数只是预算的粗略代理。真实场景每一步的上下文都在变长需要按累计 token 设上限配合上下文截断或摘要。并行工具调用。add(3,4)和now()互不依赖真实 function calling 支持一轮返回多个tool_calls并行执行我的循环是严格串行的。超时与副作用。真实工具会访问网络、数据库需要超时控制有副作用的工具写库、发消息还需要人工确认节点不能放进自由循环。可观测性。生产环境要把每一步的输入输出、延迟、token 消耗打进日志和 trace否则线上行为不可复盘。本文的System.out.println就是最小形态的 trace。这些差距不是劝退而是读框架源码时的地图知道了骨架和保护层在哪再去看 LangChain4j 的AiServices、Spring AI 的ChatClient工具调用链你就能分辨哪些是骨架、哪些是框架加的保护、哪些只是语法糖。小结Agent 循环的本质是LLM 决策 → 工具执行 → 观察写回的 while几十行代码就能写出来。循环简单保护层才是工程停止条件FINAL、最大步数兜底、重复调用检测防复读、连续错误熔断防协议错位四层缺一不可。工具错误要以文本形式喂回上下文给模型自我修正的机会但连续失败必须熔断。建议每个学 Agent 框架的人都手写一遍这个循环。之后框架里任何一层行为异常你都能定位到它属于骨架的哪一环。