多维度终止控制:让 Agent 运行更安全
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇咱们把 TinyAgent 从文本解析升级到了 Function Calling——工具调用的通信方式从自由文本协议换成了 API 结构化协议,parseAction() 和 stop 序列全部删掉,代码更简洁、格式 100% 稳定。
但如果你回头看目前的 ReActAgent.run(),会发现循环的退出条件非常粗暴——只有两个出口:
- 模型不再调工具(
hasToolCalls()返回false)——正常结束。 - 跑满
MAX_STEPS = 10圈——兜底超时,返回一句“我思考了太多步”。
这两个条件够吗?跑简单场景没问题,但稍微复杂一点的情况就会暴露问题:
- 用户说了句“你好”,大脑直接回复不需要调工具——没问题,走第一个出口。
- 退款流程 4 圈搞定——没问题。
- 但如果某个工具返回的结果不够用,大脑又找不到别的办法,它会用同样的参数反复调同一个工具——第 3 圈调、第 4 圈还在调,期待一个不会变的结果里刷出新内容。Token 在烧,任务没有推进,而
MAX_STEPS要等到第 10 圈才兜底。 - 又或者,一个跨品类对比的任务确实需要 8 圈工具调用,每圈的消息列表越来越长,Token 消耗加速增长——等你反应过来,上下文窗口已经快撑爆了。
这一篇,咱们系统地解决这个问题:Agent 循环什么时候停,不能只靠一个硬编码的最大步数。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.4.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
先把问题分个类
在写代码之前,先想清楚 Agent 循环可能以哪些方式不正常地跑下去。归纳一下,无非三类:
1. 死循环:一直转不停
大脑每圈都决定调工具,但任务始终没有完结。比如用户问了一个超出工具能力范围的问题,大脑反复尝试不同的工具组合,希望能拼凑出答案——结果每次都不对,但它就是不肯说“我不知道”。
这是最危险的情况:Token 不断消耗,用户在等,服务器资源被占用,但没有任何产出。
2. 空转:在做无用功
大脑在调工具,但做的是重复劳动。最典型的场景:连续两圈用相同的参数调了同一个工具,拿到了一模一样的结果。
为什么大脑会做这种明显没意义的事?根本原因是工具返回的结果不足以让大脑推进到下一步,但它又不知道该换什么思路。大脑拿到一个结果,发现回答不了用户的问题,于是本能地重试——就像一个人对着自动售货机反复按同一个按钮,期待掉出不同的东西。
打个比方:用户问“订单 88231 的物流到哪了”,大脑第 1 圈调 queryOrder 查到了运 单号,第 2 圈调 queryLogistics,结果返回的是 {"status": "运输中"}——只有一个笼统的状态,没有具体位置、没有预计到达时间。大脑觉得光回复运输中太敷衍,但手头又没有其他工具能查到更详细的信息,于是第 3 圈用同样的运单号又调了一遍 queryLogistics,拿到了一模一样的结果。对于咱们比特严选的这些查询工具来说,同一个运单号在几秒钟内查出来的结果不会变——但大脑不理解这一点,它只会反复尝试。
空转不像死循环那么致命,但在生产环境里,一个空转 3 圈的请求比正常请求多花 50% 以上的 Token 成本——量大了很心疼。
3. 超预算:Token 用量失控
即使每圈都在做有意义的事,累计的 Token 消耗也可能超出预期。Agent 循环有一个让人容易忽略的特性:消息列表是累积增长的。每圈循环往消息列表里追加 assistant 消息和 tool 消息,下一圈调 API 时要把整个消息列表都发过去。
第 1 圈发 3 条消息(system + user + assistant),第 2 圈发 5 条,第 3 圈发 7 条……到第 8 圈就是 17 条消息。如果每个工具结果返回 500 字,8 圈下来光工具结果就 4000 字。再加上系统提示词和用户消息,输入 Token 可能已经到了大几千。
更麻烦的是,有些工具返回的数据量不可控——比如知识库搜索可能返回一大段文档,物流轨迹可能包含十几条记录。一旦某个工具返回了超长结果,后续每圈的输入 Token 都会被这段长文本拖累。
四道防线
针对上面三类问题,咱们设计四道防线,从粗到细依次拦截:
| 防线 | 解决的问题 | 原理 | 实现复杂度 |
|---|---|---|---|
| 最大步数 | 死循环 | 硬编码上限,超过就强制停止 | 最简单 |
| 重复调用检测 | 空转 | 连续 N 圈调同一个工具 + 同样参数 → 先提醒后停止 | 简单 |
| Token 预算 | 超预算 | 估算累计 Token,超过阈值 → 强制停止 | 中等 |
| 无进展检测 | 空转 + 死循环 | 连续 N 圈大脑的 content 高度相似 → 强制停止 | 中等 |
下面逐个拆解,每道防线都给出原理、代码实现和实际效果。
第一道防线:最大步数(已有)
这是最粗暴也最可靠的兜底——不管发生什么,跑满 N 圈就停。
目前代码里已经有了:
private static final int MAX_STEPS = 10;
for (int step = 1; step <= MAX_STEPS; step++) {
// ... 循环体
}
return "抱歉,我思考了太多步仍未完成任务,请尝试换一种方式描述您的问题。";
这道防线的价值不在于精确——它拦不住空转,也拦不住超预算——而在于确定性。不管其他检测机制有没有 bug,最大步数保证 Agent 一定会停。
1. MAX_STEPS 设多少合适
10 是一个经验值。太小会截断正常的多步任务,太大又起不到保护作用。用比特严选的场景做个参考:
| 场景 | 典型步数 | 说明 |
|---|---|---|
| 查订单状态 | 1-2 步 | 查订单 → 回复 |
| 查物流轨迹 | 2-3 步 | 查订单拿运单号 → 查物流 → 回复 |
| 退款流程 | 3-4 步 | 查订单 + 查政策 + 查时间 + 申请退款 |
| 跨品类对比 | 4-6 步 | 查多个商品 → 对比 → 推荐 |
| 复杂售后诊断 | 5-8 步 | 查订单 → 查保修 → 逐步诊断 → 推荐方案 |
最复杂的场景大约需要 8 步,MAX_STEPS = 10 留了 2 步的余量。如果你的业务场景工具链路更长,可以适当调大,但一般不建议超过 15——超过 15 步的任务,往往需要重新拆解需求或引入 Plan-and-Execute 模式(第 13 篇会讲)。
最大步数是兜底线,不是目标线。正常任务应该在
MAX_STEPS之前就通过其他条件正常退出。如果你发现大量请求都跑到了MAX_STEPS才停,说明要么MAX_STEPS设太小了,要么 Agent 的工具设计或提示词有问题。
2. 把它变成可配置的
硬编码 MAX_STEPS = 10 在 demo 里没问题,但生产环境里不同场景可能需要不同的上限。把它改成构造参数:
public class ReActAgent {
private static final int DEFAULT_MAX_STEPS = 10;
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
private final ObjectMapper objectMapper;
private final int maxSteps;
public ReActAgent(LlmClient llmClient, ToolRegistry toolRegistry) {
this(llmClient, toolRegistry, DEFAULT_MAX_STEPS);
}
public ReActAgent(LlmClient llmClient, ToolRegistry toolRegistry, int maxSteps) {
this.llmClient = llmClient;
this.toolRegistry = toolRegistry;
this.objectMapper = llmClient.getObjectMapper();
this.maxSteps = maxSteps;
}
}
改动很小,但给了调用方灵活性:简单查询场景可以设 maxSteps = 5,复杂诊断场景可以设 maxSteps = 15