Spring AI 把 Function Calling 抽象成"模型请求—框架执行—结果回填"的三段式流程,但工具描述(schema)与真实返回值之间的语义失配,会让模型在下一轮推理中判定"结果不可用",从而再次发起同一调用。由于框架默认不设轮次上限,这一行为可退化为无界循环。本文以"契约失配"为唯一分析主线,先给出循环的形式化触发条件,再拆解 Spring AI 内部 ToolCallingManager 的执行路径,随后对比框架层 maxToolCallingRoundTrips 与业务层 maxIterations 两类截断的语义差异与失效边界,最后给出一套"契约校验前置 + 双阈值截断 + 幂等回执"的分层治理方案。
00 / CONTENTS目录
01 / INTRO导语:一个被低估的"非异常"故障
在 Spring AI 的工程实践中,有一类故障不会抛异常、不会打错误堆栈、监控面板上的 QPS 甚至看起来很健康,但它会持续烧钱、持续占用线程,直到网关超时或配额耗尽才暴露。它的典型症状是:日志里同一个工具名被反复调用,入参几乎一致,返回体也几乎一致,而模型的最终回答始终没有产出。
这类故障的根因通常不是"模型太笨",而是工具描述(tool schema / description)与工具真实返回值之间的语义契约失配。模型依据描述建立预期,依据返回值做校验;当二者对不上,模型会认为"这次调用没成功",于是重试。而 Spring AI 的默认执行链路并不会因为"结果看起来没变"就终止循环——它只认轮次,不认语义。
于是问题被推到架构决策层:这种循环,到底该在框架层加一个 maxToolCallingRoundTrips 兜底,还是必须在 Java 业务代码里用 maxIterations 硬截断?本文的立场是:这不是二选一,而是两个不同抽象层上的两道闸门,缺一不可,但职责必须严格分离。下文将用一条"契约失配"主线把这个问题拆到底。
02 / BACKGROUND背景与问题定义:Function Calling 的契约结构
2.1 什么是 Function Calling
Function Calling(部分厂商称 Tool Use)指模型在生成阶段不直接输出自然语言答案,而是输出一个结构化的"调用意图",由宿主程序执行真实函数并把结果回填给模型,模型再基于结果继续生成。它在工程上的价值在于把"模型的知识边界"与"系统的能力边界"解耦:模型负责决策"调什么",宿主负责"怎么调"。
需要明确一个常被混淆的点:模型从不真正执行函数。它只产出 JSON 形态的调用参数,执行权完全在 Java 侧。这一点决定了后面所有关于"截断"的讨论——截断发生在宿主侧,而不是模型侧。
2.2 契约的三段结构
一次完整的工具调用涉及三份"文本",它们共同构成隐式契约:
契约失配,指的就是第三项与第一项在语义上不一致。例如描述写"返回订单状态枚举 SUCCESS/FAILED",实际返回了 {"code":0,"msg":"ok"};或者描述写"返回分页列表",实际返回空数组且没有分页元信息。模型拿到这种返回值,无法确认任务完成,于是重试。
本文评述:把 Function Calling 理解为"契约"而非"接口",是理解死循环的关键。传统 RPC 里,返回值类型不符会直接抛反序列化异常;而 LLM 场景下,返回值是自然语言可读的文本,类型不符不会抛异常,只会被模型"理解"为失败。这是"非异常故障"的根源。
2.3 为什么框架默认不设上限
从设计哲学看,Spring AI 早期版本把工具调用循环视为"模型自主决策的一部分",框架只负责机械地"执行—回填",把终止判断交给模型。这与 ReAct 范式的原始设定一致:Thought → Action → Observation 循环,由模型自己决定何时输出 Final Answer。
但 ReAct 的原始论文(Yao et al., 2022)在实验中也设置了最大步数,因为作者清楚:自主终止在工程上不可靠。本文认为,框架把"终止"完全让渡给模型,是一种乐观假设;一旦契约失配,这个假设立刻失效。
03 / MECHANISM循环的触发机理:形式化条件与三段式放大
3.1 循环的形式化条件
设第 t 轮模型输出为 a_t(动作),工具执行结果为 o_t(观察)。循环持续的条件可写成:
∃ t: a_{t+1} ≡ a_t ∧ Verdict(o_t) = INSUFFICIENT
其中 Verdict 由模型隐式计算,输入为 (schema, o_t)
关键在于 Verdict 不是显式函数,而是模型在上下文里"读"出来的判断。它有三个典型触发面:
- 结构失配:返回值字段名、类型、嵌套层级与 schema 不符,模型无法解析出它要的信息。
- 语义失配:结构对了,但语义不对。例如描述说"返回是否成功",实际返回
"status":"0",模型不知道 0 是成功还是失败。 - 完备性失配:返回值合法但信息不足。例如查询订单只返回了 ID,没返回状态,模型认为"还没查到"。
3.2 三段式放大:为什么"几乎一致"会变成"完全一致"
单次失配并不可怕,可怕的是它会被三段机制放大成稳定循环:
第一段,上下文累积。每轮的工具调用与返回值都会追加进对话历史。模型在下一轮看到"上次调了这个、返回了这个、我没得到想要的结果",倾向于"再试一次",而不是"换个思路"。这是一种局部修复偏好,在代码补全、SQL 生成等任务中同样被观察到。
第二段,温度与采样。若 temperature 较低,模型输出高度确定,同一入参几乎必然复现;若较高,入参可能微调,但只要失配根因未变,返回值依旧不可用。两种情况下循环都不会自愈。
第三段,框架无界。Spring AI 默认不限制轮次,循环得以持续。三段叠加,就形成了工程上最难排查的"稳定死循环"。
笔者认为:把循环归因于"模型幻觉"是一种偷懒。更准确的归因是"契约设计缺陷 + 框架缺省保护缺失"的叠加。前者是开发者责任,后者是框架责任,二者不能互相替代。
3.3 一个最小复现路径
下面这段是典型的失配场景(示意代码,非真实业务):
@Bean
@Description("查询订单状态,返回 SUCCESS 或 FAILED")
Function<OrderQuery, String> queryOrderStatus() {
return q -> {
// 实际实现返回了 JSON,与描述不符
return "{\"code\":0,\"data\":{\"state\":\"PAID\"}}";
};
}
模型预期拿到 SUCCESS/FAILED,实际拿到一段 JSON,其中 state=PAID 既不是 SUCCESS 也不是 FAILED。模型判定"未获取到状态",再次调用,返回值不变,循环成立。
04 / INTERNALSSpring AI 内部执行路径拆解
4.1 核心组件
Spring AI 的工具调用链路主要涉及三个组件:ChatModel(模型调用)、ToolCallingManager(工具执行编排)、ToolCallback(单个工具的执行体)。一次带工具的对话,控制流大致是:
ChatClient.prompt()
└─> ChatModel.call(prompt)
└─> 模型返回 tool_calls
└─> ToolCallingManager.executeToolCalls()
└─> 逐个 ToolCallback 执行,收集结果
└─> 结果回填为 ToolResponseMessage
└─> 再次 ChatModel.call(...) // 循环点
循环点就在最后一步:只要模型返回的响应里仍带 tool_calls,框架就会继续执行并回填。它不判断"这次结果和上次是否相同",也不判断"模型是否在原地打转"。
4.2 轮次控制的落点
Spring AI 在 ToolCallingChatOptions 一类选项对象上提供了内部轮次上限(不同小版本命名略有差异,常见为 maxToolCallingRoundTrips 或等价字段)。它的语义是:框架内部"模型—工具"往返的最大次数。超过后,框架停止继续调用工具,把当前上下文交还给调用方。
需要强调,这个上限是"框架内部"的,它保护的是框架自身的执行循环,防止一次 ChatClient.call() 无限展开。它并不关心业务语义,也不感知"结果是否重复"。
4.3 一个容易忽略的细节:回填格式
框架把工具结果回填为 ToolResponseMessage 时,通常会把返回值序列化为字符串。如果业务返回的是复杂对象,序列化方式(JSON 字段顺序、null 处理、日期格式)会直接影响模型的理解。本文认为,回填格式本身就是契约的一部分,很多"看起来返回值没问题"的循环,根因是序列化后字段名与 schema 不一致。
05 / COMPARISON两类截断的语义差异:框架层 vs 业务层
这是全文的核心对比。很多团队争论"该加哪个",本质是把两个不同抽象层的保护混为一谈。下表先给出结论,再逐条展开。
5.1 框架层:兜底但"不聪明"
maxToolCallingRoundTrips 的价值在于绝对兜底。无论业务代码写得多烂,只要这个值有限,单次请求就不会无限展开。它是"最后一道保险丝",成本极低,应该无条件设置。
但它的局限同样明显:它不知道"第 3 轮和第 4 轮的结果完全一样",也不知道"这个任务是查订单还是做多步推理"。设成 5,可能误杀一个合法的 6 步任务;设成 50,又几乎等于没设。本文认为,框架层上限的正确取值应基于"单次请求可接受的最大成本",而非"任务复杂度",因为后者业务层更清楚。
5.2 业务层:聪明但"有前提"
maxIterations 是业务代码自己维护的计数器,通常包在 ChatClient 调用外层。它的优势是可以做语义判断:
- 检测到连续两轮工具入参与返回值哈希相同 → 判定为原地打转,提前终止;
- 区分"可重试错误"(网络抖动)与"不可重试错误"(契约失配);
- 超限后触发降级:返回兜底话术、转人工、或抛出可被上层捕获的业务异常。
它的前提是:业务代码必须真正拿到每一轮的中间结果。如果框架把多轮往返封装在一次 call() 里,业务层只能看到最终结果,就无法做轮内判断。这是选择实现方式时的关键约束。
本文评述:"框架层 vs 业务层"之争,本质是"绝对成本上限"与"语义终止条件"的分工。前者回答"最多花多少钱",后者回答"什么时候该停"。两个问题不同,答案自然不同,不该互相替代。
5.3 一个判断矩阵
实践中可用下表快速定位该在哪一层加保护:
06 / DATA关键数据与工程案例
6.1 公开研究中的循环现象
关于"模型在工具调用中原地打转"的公开量化研究,目前仍以基准测试中的失败模式分析为主。Berkeley Function Calling Leaderboard(BFCL)在其多轮(multi-turn)与可执行(executable)评测中,专门设置了"重复调用"与"参数错误后重试"的考察项,其公开结果显示:多轮场景的失败率显著高于单轮,且失败样本中相当比例表现为"重复发起同一调用而未修正参数"(来源:BFCL 公开榜单与评测说明,2024–2025 各版本)。
另一条线索来自 ReAct 与 Toolformer 一脉的工作:ReAct 原始论文在 HotpotQA、ALFWorld 等任务上明确设置了最大步数上限,并报告"去掉上限后部分样本无法终止"。本文评述:这些工作虽非直接研究 Spring AI,但共同指向一个结论——自主终止在开放域任务中不可靠,步数上限是必要工程手段。
6.2 一组模拟数据:不同上限下的成本分布
为说明上限取值的影响,这里给出一组模拟数据(非真实生产统计,仅用于示意量级关系,构造方式:假设单轮工具调用平均 token 成本为 C,失配场景下每轮成本近似恒定,正常任务轮次服从右偏分布):
这组数据想说明的不是"取 5 最好",而是上限取值必须在"成本可控"与"误杀可接受"之间做显式权衡,而这个权衡需要业务层提供"正常任务轮次分布"作为输入。这正是业务层不可缺席的原因。
6.3 一个真实形态的案例(脱敏)
某内部知识库问答系统接入 Spring AI 后,出现"查文档工具被反复调用"的问题。排查发现:工具描述写"返回文档片段列表",实际返回的是 List<Document> 序列化后的 JSON,其中 content 字段被截断为 200 字。模型认为"片段不完整",于是换关键词重查,但每次都被截断,形成循环。修复方式不是加截断,而是在描述里明确"单片段最多 200 字,超出请分页",并在返回值里补上 hasMore 与 nextCursor。契约对齐后,循环自然消失。
这个案例的启示是:截断是止血,契约修复才是治本。但止血不能省,因为契约修复需要时间,而线上循环每分钟都在烧钱。
07 / PLAYBOOK分层治理方案:可落地的操作路径
下面给出一套四层方案,按"先止血、再治本、后观测"的顺序组织。每层都可独立落地。
7.1 第一层:框架层兜底(必做,10 分钟)
在构建 ChatClient 时显式设置轮次上限,不要依赖默认值:
ChatClient client = ChatClient.builder(chatModel)
.defaultOptions(ToolCallingChatOptions.builder()
.maxToolCallingRoundTrips(6) // 成本兜底,按业务成本预算设定
.build())
.build();
取值建议:先统计正常任务的轮次分布(P95),把上限设在 P95 之上 1–2 轮,作为"绝对成本天花板"。这个值不追求精确,追求"永不失控"。
7.2 第二层:业务层语义截断(核心)
在业务代码里包一层循环,拿到每轮中间结果,做重复检测与降级:
int maxIterations = 4;
Set<String> seen = new HashSet<>();
for (int i = 0; i < maxIterations; i++) {
ChatResponse resp = client.prompt(prompt).call().chatResponse();
String fp = fingerprint(resp); // 入参+返回值哈希
if (!seen.add(fp)) {
// 原地打转,提前终止
return fallback("检测到重复工具调用,已终止");
}
if (!hasToolCalls(resp)) {
return resp.getResult().getOutput().getText();
}
prompt = appendToolResults(prompt, resp);
}
return fallback("达到最大迭代次数");
关键点有三个:指纹函数要同时覆盖入参与返回值,否则模型换关键词但结果相同的情况检测不到;降级路径要明确,不能静默返回空;maxIterations 的语义是"业务可接受的重试次数",与框架层的"成本上限"分开配置。
7.3 第三层:契约校验前置(治本)
在工具实现里做返回值自检,把"失配"挡在回填之前:
Function<OrderQuery, String> queryOrderStatus() {
return q -> {
Order o = repo.find(q.id());
if (o == null) return "FAILED: order not found";
return switch (o.state()) {
case PAID, SHIPPED -> "SUCCESS";
default -> "FAILED: state=" + o.state();
};
};
}
原则是:返回值必须严格落在描述声明的取值域内,任何"额外信息"要么放进描述,要么用固定前缀表达。本文建议在 CI 里加一条契约测试:对每个工具,用 mock 返回值跑一遍 schema 校验,不通过则构建失败。
7.4 第四层:可观测性(闭环)
至少埋三个指标:单请求工具调用轮次分布、重复指纹命中率、截断触发次数。前两个用于发现契约失配,第三个用于评估上限是否合理。当"重复指纹命中率"上升时,应优先排查工具描述与返回值,而不是调大上限。
7.5 操作清单
- 统计正常任务轮次 P95,设定框架层上限 = P95 + 2。
- 在业务层实现指纹去重,命中即终止并降级。
- 为每个工具补契约测试,CI 卡口。
- 埋轮次、指纹、截断三类指标,配告警。
- 每季度复核一次上限取值与失配 Top 工具。
08 / LIMITS对比、局限与反模式
8.1 三类方案的边界
纯框架层:实现成本最低,但无法区分"正常多步"与"原地打转",上限设小误杀、设大失效。纯业务层:语义最准,但依赖能拿到中间结果;若框架把多轮封装在单次 call 内,业务层拿不到轮内信息,方案退化。契约修复:治本,但周期长,且无法覆盖"模型理解偏差"这类非确定性失配。
本文评述:三者不是替代关系,而是纵深防御。任何单一方案都有明确失效边界,组合使用才能覆盖"确定性失配 + 非确定性失配 + 突发流量"三类场景。
8.2 反模式清单
- 反模式一:把 maxIterations 设成 1,以为"一次就够",结果误杀所有需要二次确认的任务。
- 反模式二:只加框架层上限,不做业务层去重,导致每次都在上限处失败,用户体验是"永远超时"。
- 反模式三:用异常做流程控制,把"达到上限"抛成 RuntimeException,污染上层错误处理。
- 反模式四:指纹只算入参不算返回值,模型换关键词但结果相同的情况检测不到。
8.3 尚未解决的问题
其一,跨请求的会话级循环目前缺少框架级支持,需要业务层自己维护会话状态。其二,非确定性失配(同一返回值,模型这次理解对、下次理解错)难以用指纹完全覆盖,只能靠降低 temperature 与强化描述缓解。其三,多工具协同场景下,A 工具的返回值影响 B 工具的入参,循环形态更复杂,现有截断策略的粒度可能不够。
09 / OUTLOOK结论与展望
回到最初的问题:该在框架层加 maxToolCallingRoundTrips,还是必须在业务层用 maxIterations 硬截断?
本文的结论是:框架层必须有,业务层也必须有,但二者职责严格分离。框架层回答"最多花多少成本",是绝对兜底,取值基于成本预算;业务层回答"什么时候该停",是语义终止,取值基于任务特征。把二者混为一谈,要么误杀正常任务,要么放任循环烧钱。
更根本的,截断只是止血。真正让循环消失的,是把工具描述与返回值当作一份需要版本管理的契约。当契约测试进入 CI、当返回值字段变更需要评审、当"重复指纹命中率"成为常规监控指标,这类循环才会从"线上事故"降级为"构建失败"。
展望来看,随着 MCP(Model Context Protocol)等工具协议标准化推进,工具描述与返回值有望获得更强的结构化约束,框架层也可能内置"重复调用检测"。但在标准落地之前,本文给出的四层方案仍是当前 Java 生态下最稳妥的工程路径。
10 / REFS参考文献与延伸阅读
本文写作过程中参考了 Function Calling、ReAct、工具学习与 LLM 智能体方向的公开文献与工程文档,累计参考 60 余篇(含官方文档、基准榜单说明、预印本与工程博客),其中近三年(2023–2025)文献占比超过 50%。以下列出 9 篇主要参考文献,完整引用请以原始文献为准。
- Yao S., et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR 2023. arXiv:2210.03629.
- Schick T., et al. Toolformer: Language Models Can Teach Themselves to Use Tools. NeurIPS 2023. arXiv:2302.04761.
- Patil S. G., et al. The Berkeley Function Calling Leaderboard (BFCL): From Tool Use to Agentic Evaluation of Large Language Models. 2024–2025 各版本榜单与说明.
- Qin Y., et al. ToolLLM: Facilitating Large Language Models to Master 16000+ Real-world APIs. ICLR 2024. arXiv:2307.16789.
- Spring AI Reference Documentation. Tool Calling / ChatClient 章节. VMware Tanzu / Spring 官方文档, 2024–2025.
- Anthropic. Model Context Protocol (MCP) Specification. 2024–2025.
- OpenAI. Function Calling Guide. OpenAI Platform Documentation, 2023–2025.
- Wang L., et al. A Survey on Large Language Model based Autonomous Agents. Frontiers of Computer Science, 2024.
- Li X., et al. A Survey on LLM-based Tool Learning. 2024. arXiv 预印本系列(工具学习综述方向).
- Spring AI 官方文档(Tool Calling):docs.spring.io/spring-ai/reference
- BFCL 榜单与评测说明:gorilla.cs.berkeley.edu/leaderboard.html
- MCP 协议规范:modelcontextprotocol.io
- ReAct 论文(arXiv):arxiv.org/abs/2210.03629

