← 工具调用

框架托管的工具循环是黑盒,怎么记录每一步、怎么限制轮数?

困难 手写调用循环与框架托管 · 第 2 / 3 问 更新于 2026/09/29
工具调用Agent可观测性LangChain4jSpring AI
本题落地项目AI Agent旅游行程智能规划平台

简化版

框架托管时,业务代码只看到「一次调用、一段最终回答」,中间调了几次工具、每次入参和返回是什么,默认都看不到。观测点要换到框架留出来的位置:步骤记录写在工具方法里(每个工具走统一外壳,开头写一条「运行中」,返回前回填结果、状态和耗时),运行 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旅游行程智能规划平台》,上面这些追问你都会迎刃而解。

本题落地项目地狱锤炼AI Agent旅游行程智能规划平台基于 SpringBoot + LangChain4j、攻略知识库 RAG、MySQL 向量检索、Function Calling 与数据库驱动工具中心,实现候选池预热与工具收窄、多城联游行程编排、14 条代码硬校验驱动的反思重排、教训记忆与多版本对比、三道闸防空转、编排前探活、预算测算和 Agent 执行时间线,覆盖从需求识别到行程交付的完整闭环。SpringbootLangChain4JAgentRAGFunction Calling源码+SQL喂饭学习教程配套面试文档环境安装文档项目运行文档 学习这个项目 也可以学AI Agent岗位匹配与求职规划系统地狱锤炼 查看项目