框架托管的工具循环是黑盒,怎么记录每一步、怎么限制轮数?
简化版
框架托管时,业务代码只看到「一次调用、一段最终回答」,中间调了几次工具、每次入参和返回是什么,默认都看不到。观测点要换到框架留出来的位置:步骤记录写在工具方法里(每个工具走统一外壳,开头写一条「运行中」,返回前回填结果、状态和耗时),运行 ID 这类上下文通过框架的工具上下文或 ThreadLocal 传进去;轮数和 token 用监听器统计(模型每往返一次回调一次);拦截和记录分开:轮数上限交给框架自己的最大往返参数,工具调用总次数和「同一工具相同入参连续调用」在工具外壳开头判断,超限抛专门的中断异常。监听器只适合记录,不适合拦截,因为框架可能吞掉监听器里抛出的异常。
详细版
业务代码
└─ 一次 chatWithTools(prompt, 工具池) ← 业务只看到这一次
├─ 模型监听器 onResponse ← 每轮往返回调:计轮数、累加 token(只记不拦)
├─ 框架循环(最大往返轮数由框架参数拦)
│ └─ 工具方法被回调
│ └─ 统一外壳 runTool
│ ├─ beforeToolCall:累计次数、判断同工具同入参,超限抛中断异常
│ ├─ 写 agent_step(运行中,工具、入参)
│ ├─ 执行真正的业务逻辑
│ └─ 回填 agent_step(返回、状态、耗时)+ 写调用日志
└─ 返回最终文本
| 要做的事 | 放在哪 | 原因 |
|---|---|---|
| 记录每一步 | 工具方法的统一外壳 | 只有工具方法知道自己的入参、返回和耗时 |
| 关联到哪次运行 | 工具上下文或 ThreadLocal | 工具方法签名里没有运行 ID |
| 统计轮数和 token | 模型监听器 | 每次往返都经过它 |
| 拦截轮数 | 框架的最大往返参数 | 监听器里抛异常可能被吞掉 |
| 拦截调用次数和空转 | 工具外壳开头 | 能拿到工具名和入参 |
完整版教学
一、黑盒到底黑在哪
框架托管的调用长这样:
String summary = aiServices.chat(prompt); // 内部可能已经往返了 30 轮、调了 80 次工具
这一行返回时,你手里只有最后一段文本。而一个需要展示过程、需要排查问题的 Agent,至少要回答这些问题:
这次运行一共往返了几轮?花了多少 token?
调了哪些工具、按什么顺序、每次入参和返回是什么?
哪一步失败了、为什么?耗时最长的是哪一步?
为什么停了:模型自己结束、轮数用尽,还是被护栏拦下?
框架不会把这些信息交给你,必须自己在它允许的位置上把信息收集起来。
二、步骤记录写在工具方法里
工具方法是框架回调业务代码的唯一入口,也是唯一知道「这次调了什么、返回了什么」的地方。所以每个工具都走同一个外壳:
private String runTool(String toolCode, Map<String, Object> params, Supplier<String> body) {
context.beforeToolCall(toolCode, params); // 次数与空转判断,超限抛中断异常
AgentStep step = stepService.start(runId(), toolCode, params); // 运行中
long begin = System.currentTimeMillis();
try {
String result = body.get();
stepService.success(step, result, System.currentTimeMillis() - begin);
callLog.recordSuccess(runId(), toolCode, params, result);
return result;
} catch (BusinessException e) {
stepService.fail(step, e.getMessage());
callLog.recordFailure(runId(), toolCode, params, e.getMessage());
throw e;
}
}
每个工具方法的主体只写业务逻辑,记录和计时统一在外壳里完成,不会有哪个工具漏记。
三、运行 ID 怎么进到工具方法里
工具方法的参数是给模型看的,运行 ID、轮次号这些不该出现在参数列表里(模型可能传错)。常见两种传法:
| 方式 | 做法 | 注意 |
|---|---|---|
| 框架的工具上下文 | 调用时传入上下文对象,工具回调时取出 | 上下文随这次调用走,不依赖线程 |
| ThreadLocal | 调用前绑定到当前线程,工具方法里取 | 框架必须在同一线程里回调工具;用完要清理 |
更完整的讨论见「工具需要当前用户、运行 ID 这类上下文,怎么传进去?为什么不让模型传?」。
四、轮数和 token 用监听器统计
工具方法只能看到「调了工具」的那几轮,模型直接回答的那一轮不经过工具。要统计每一次往返,要用挂在模型上的监听器:模型每返回一次响应就回调一次,在这里累计轮数、累加这一轮的输入和输出 token。下面是一次运行的示意数据:
第 1 轮:输入 3200 token,输出 180 token(调工具)
第 2 轮:输入 4100 token,输出 150 token(调工具)
……
第 30 轮:输入 26800 token,输出 900 token(最终回答)
注意输入 token 是逐轮增长的,因为每一轮都要带上完整历史。只看最后一次响应的用量,会严重低估这次运行的成本。
易错点:监听器适合「记」,不适合「拦」。一些框架会捕获并吞掉监听器里抛出的异常,在那里抛中断,循环照样继续。
五、拦截放在框架参数和工具外壳里
三类护栏各有合适的位置:
| 护栏 | 判断依据 | 放在哪 |
|---|---|---|
| 往返轮数上限 | 模型往返次数 | 框架的最大往返参数,跑满框架自己停 |
| 工具调用总次数上限 | 本次运行累计调用次数 | 工具外壳开头累计 |
| 空转检测 | 同一工具、完全相同的入参连续调用次数 | 工具外壳开头比对 |
空转检测一定要比对入参:换一个关键词再查是有意义的探索,只有参数一模一样地反复调用才是原地打转。把知识库检索做成 Agent 工具时同样需要这些护栏,见「RAG 做成固定链路、流程节点还是 Agent 工具,区别在哪?」。
中断异常要设计成专门的类型,并且保证它能一路上抛:工具调用管理器、编排服务内层的通用 catch (Exception) 都不能把它吞掉,否则护栏形同虚设。框架拦下来时给出的提示通常是英文的技术信息,收尾时要换成能照着处理的原因(「可在系统参数里调整最大轮数」),并写进运行记录。
六、护栏阈值要能配置
阈值写死在代码里,调一次就要发版。放进系统参数表,管理员在页面上改,下一次运行就生效:
AGENT_MAX_ITERATION 单次调用内模型往返轮数上限
AGENT_MAX_TOOL_CALL 单次调用内工具调用总次数上限
AGENT_MAX_SAME_TOOL 同一工具相同入参连续调用次数上限
阈值怎么定要看数据:从运行记录里统计正常完成的运行一般需要多少轮、多少次调用,上限可以先设在正常值之上留出余量,再观察被拦下的运行是否真的在空转,逐步收紧或放宽。
七、最终答案也要落一步
工具步骤记录的是过程,最终回答不经过工具。运行结束时要单独补一条「最终答案」步骤,并把运行状态、总步数、耗时写回运行主记录,这样时间线页面才能完整展示从第一步到结论的全过程。
八、常见误区与追问
- 误区:用了框架托管就看不到过程。 步骤可以记在工具方法的统一外壳里,轮数和 token 可以在监听器里统计。
- 误区:在监听器里抛异常就能中断循环。 框架可能吞掉监听器的异常,拦截应交给框架参数和工具外壳。
- 误区:只看最后一次响应的 token 就是本次成本。 每轮都带完整历史,输入 token 逐轮增长,要累加每一轮。
- 误区:同一个工具调多次就是空转。 换参数重查是正常探索,只有相同入参反复调用才是空转。
- 误区:中断异常被通用 catch 捕获也没关系。 被吞掉的中断等于没有护栏,中断异常必须一路上抛。
- 追问:运行 ID 为什么不作为工具参数让模型传? 模型可能传错或被诱导传别的值,运行上下文应由代码注入。
- 追问:护栏阈值怎么定? 统计正常运行的轮数和调用次数,按一定余量设置,并放进可配置的系统参数。
九、加强记忆
托管循环对业务是一次调用,过程要自己收集:步骤记在工具方法的统一外壳里,运行 ID 用工具上下文或 ThreadLocal 注入;轮数和 token 用模型监听器逐轮累加,因为输入 token 逐轮增长;监听器只记不拦,轮数上限交给框架参数,调用总次数和同工具同入参空转在工具外壳开头判断,超限抛专门的中断异常且不能被通用 catch 吞掉。阈值放系统参数,按正常运行数据定。结束时补一条最终答案步骤。
项目实战落地
项目里怎么做的
《AI Agent旅游行程智能规划平台》的行程编排把内层工具循环交给 LangChain4j 托管,过程记录和护栏这样落地:
- 步骤记在工具外壳里:8 个
@Tool方法都走TripAgentToolService.runTool,开头写一条「运行中」的agent_step(步骤名、工具编码、入参),返回前回填结果、状态和耗时,同时写一条tool_call_log;出错时两处都记失败; - 上下文用 ThreadLocal:运行 ID、轮次、候选池、规则阈值放在
AgentRuntimeContext,编排开始前绑定到当前线程,工具方法用current()取; - 监听器只记不拦:挂在对话模型上的
AgentChatModelListener每次往返回调onResponse,记一轮、累加这一轮的 token 用量; - 三道闸:往返轮数交给
AiServices的maxToolCallingRoundTrips(AGENT_MAX_ITERATION,预置 40);工具调用总次数(AGENT_MAX_TOOL_CALL,200)和同一工具相同参数连续调用次数(AGENT_MAX_SAME_TOOL,30)在工具方法开头的beforeToolCall判断,超限抛AgentAbortException;三个参数都在系统参数页可改。
《AI Agent岗位匹配与求职规划系统》用 Spring AI:步骤记录写在每个 ToolCallback.call 里,调用前写「Action: 工具名」的运行中步骤,执行后回填结果并推进 agent_run 的进度;运行 ID 通过 ToolContext 传入;模型给出结论后再补一条 Final Answer 步骤。
为什么这样取舍
- 监听器不拦截:框架会吞掉监听器里抛出的异常,在那里中断什么都拦不住,所以只负责计数和累加用量。
- 阈值进系统参数:轮数、调用次数、同工具次数都要根据实际运行情况调整,改完下一次编排就生效。
面试官还会追问
- 外层的反思循环和内层的「续排」都会让模型再排一次,它们解决的问题有什么不同?
- 运营看板统计「轮次分布」时,为什么只统计状态为「已完成」的运行?
学完《AI Agent旅游行程智能规划平台》,上面这些追问你都会迎刃而解。