Spring AI Alibaba 1.x Agent运行时配置RunnableConfig实战解析
2026/9/8 2:34:00 网站建设 项目流程

Spring AI Alibaba 1.x 系列的上一篇我讲了 ChatClient 的流式调用和结构化输出,本来计划直接进入 Tool Calling 的细节,但后来在实际项目里把一个查询型 Agent 调起来之后,连续踩了好几个跟运行时配置相关的坑——不是模型能力的问题,也不是 Prompt 写得不好,而是 Agent 跑起来之后“怎么控制它的行为边界”这件事没想清楚。如果你也遇到过 Agent 突然死循环、无限调用工具、上下文被撑爆、或者日志里莫名出现一句Agent execution terminated due to error.,那大概率是运行时配置没吃透。这篇文章就把 RunnableConfig 扒开,结合 Spring AI Alibaba 1.x 的实际使用场景,把 Agent 运行的配置参数一项一项讲清楚。

1. RunnableConfig 是什么:Agent 运行的“总控台”

1.1 从一次线上事故说起

先说一个我实际经历过的问题。项目里有个报表助手,看起来功能很简单:用户用自然语言提问,Agent 判断需要查哪些表,调用查询工具,最后把结果翻译成回答。本地测试怎么调都正常,一上生产就出事——同一个问题,用户连续问了几次之后,Agent 开始反复调用查询工具,明明第一次已经拿到了结果,它却在“要不要再查一次”之间来回横跳,直到把我配置的调用次数上限打满,然后整个流程直接抛异常退出,界面上就显示一句干巴巴的“Agent execution terminated due to error.”。

当时第一反应是模型太笨,换了更强的模型也没什么改善。后来逐条日志追,发现问题出在我对 Agent 的执行机制理解不够:Agent 本质上是一个“感知-决策-行动”的循环,每一轮都需要模型决定下一步做什么,如果这个循环不加以约束,或者上下文里累计的信息让模型产生了矛盾判断,它就可能在原地打转。而控制这个循环的参数,就是今天要讲的 RunnableConfig。

1.2 RunnableConfig 在 Spring AI Alibaba 调用链中的位置

在 Spring AI Alibaba 1.x 的体系里,一个完整的 Agent 应用通常由这么几层组成:

  • 最外层是业务代码,负责接收用户请求、做参数校验、组织返回结果。
  • 中间层是 ChatClient 或 AgentExecutor,负责编排 Prompt、调用模型、解析输出。
  • 再往下是 ToolCallback,负责把本地方法、外部 HTTP 接口、MCP 服务等统一包装成模型可识别的工具。
  • 最底部是 ChatModel,真正的模型推理入口。

RunnableConfig 就夹在中间层和模型层之间。它的作用不是修改 Prompt,也不是调整模型参数,而是控制 Agent 循环本身的运行方式:最多循环多少轮、如何标识一次请求、如何传递链路追踪信息、如何控制上下文的轮次范围。

一个直觉的理解方式是:把 Agent 想象成一辆自动驾驶的车,ChatModel 是发动机,Prompt 是导航路线,工具是转向和刹车,而 RunnableConfig 就是仪表盘上的那套限速和故障保护系统——它不会告诉你往哪开,但是会在你失控的时候强制踩刹车。

1.3 四个核心维度

在 Spring AI 1.x 的 agent 模块里,org.springframework.ai.agent.runnable.RunnableConfig这个类承担了运行控制职责。它实际上把配置分成了几个维度,我平时最常用的有四个:

第一是递归限制(recursionLimit),这是最关键的兜底参数。它规定了 Agent 在一次请求中可以执行的模型调用轮数上限。每一轮模型调用算一次,包括工具结果的返回和重新决策。一旦达到上限,Agent 会终止执行并返回一个终止消息。

第二是标记(tags),本质是一个字符串列表,用于给一次执行打上业务维度的标签。比如某一次请求来自哪个业务线、属于哪个场景,后续在日志和监控里可以按这些标签过滤。

第三是元数据(metadata),是一个Map<String, Object>,适合放结构化信息,比如 requestId、userId、环境信息。如果项目里接入了链路追踪系统,这些元数据可以携带到所有子调用中。

第四是轮次上下文(turnContext),用于控制多轮对话场景下的上下文传递方式,决定哪些历史消息保留、哪些丢弃。

这四个维度看似简单,但每一条在实际项目中都能引出不少坑。下面逐项展开。

2. Agent 运行的配置参数逐项拆解

2.1 递归限制:防止 Agent 失控的第一道闸门

先说 recursionLimit,这是 RunnableConfig 里最容易被忽略又最致命的一个参数。很多第一次接触 Agent 开发的同事会问:模型不是应该按我的 Prompt 乖乖执行吗?为什么要限制循环次数?

原因在于工具调用型 Agent 的运行机制。一次提问进来之后,模型先做一次推理,如果它觉得需要查数据,就会返回一个工具调用的请求;应用层执行这个工具,把结果拼回去,再送给模型做第二次推理;模型如果觉得信息还不够,又发起新的工具调用……如此往复,直到模型认为可以给出最终答案。这个循环的次数,是完全由模型“自己觉得”决定的,外部没有硬性约束的话,模型完全可能陷入死循环。

我在项目中遇到过的最极端情况是一个工具返回了空结果,模型无法理解“为什么查出来是空”,于是它决定换个参数再查一次,换了三次都没有数据,它居然开始尝试拼接出一些不存在的字段名去查询。如果没有递归限制,这种错误会在一次请求里不断放大。

那么 recursionLimit 应该设多少?这取决于你的 Agent 需要几步才能完成一次典型任务。以一个简单的 NL2SQL 场景为例:

  1. 用户提问,模型决定调用“获取表结构”工具(第 1 轮)。
  2. 拿到表结构,模型决定调用“执行查询”工具(第 2 轮)。
  3. 拿到查询结果,模型给出回答(第 3 轮)。

这个流程至少需要 3 轮调用。如果查询失败需要重试,就会变成 4-5 轮。所以给这类 Agent 设置 5-6 是比较合理的。如果你做的是多跳检索类的 Agent,每跳一次都要做一次意图分析和一次检索,那可能需要 8-10 轮。我的建议是:先通过日志统计正常请求平均需要多少轮,然后在此基础上加 2 作为兜底值,不要凭感觉设一个很大的数。recursionLimit 过大会让失控请求白白消耗大量 token,过小则会导致正常请求被误终止。

2.2 标记与元数据:可观测性的地基

Agent 应用和传统 Web 应用最大的区别在于,一次用户请求会引发多次模型调用。传统应用一次请求一条日志,查起来很直接;Agent 应用一次请求可能产生三五次模型调用、若干次工具调用,如果没有统一的标识,排查问题时会非常痛苦。

tags 和 metadata 就是为解决这个问题设计的。我在所有 Agent 入口都强制要求传入两个东西:一个是业务场景标签,比如nl2sqlragcustomer-service;一个是 requestId,通过 metadata 传进去。这样在日志平台里,我可以一键筛出某一次完整请求涉及的所有模型调用和工具调用记录。

具体做法是在创建 RunnableConfig 的时候:

RunnableConfig config = RunnableConfig.builder() .recursionLimit(6) .tag("nl2sql") .metadata("requestId", requestId) .metadata("userId", userId) .build();

这些 tags 和 metadata 不只用于日志。如果你接入了阿里云或者其他 APM 平台,它们可以直接映射到 trace 的 span attribute 上,实现全链路分析。另外,在做灰度发布或 A/B 测试时,通过 metadata 传一个experimentGroup字段,可以在统计报表时快速区分不同策略的效果差异。

需要提醒一点:不要把大对象放进 metadata。它是会被序列化传递的,放一个几百 KB 的对象,每次模型调用都会带着它序列化一次,性能损耗非常明显。metadata 里只放字符串、数字这类轻量数据。

2.3 模型推理参数:ChatOptions 里的温度与采样

RunnableConfig 管的是循环过程,但在每一轮循环内部,模型推理参数由 ChatOptions 控制。这两个东西经常被混淆。我见过有同事在 RunnableConfig 里找 temperature 找不到,然后跑来吐槽 API 设计有问题——其实它们是不同层面的配置。

在 Spring AI Alibaba 1.x 中,ChatOptions 可以在多个层级设置:全局的 application.yml 配置、ChatClient 创建时的默认配置、单次 Prompt 调用时的临时配置。优先级从高到低是:单次调用 > ChatClient 默认 > 全局配置。

temperature 对 Agent 的影响比传统 Chat 应用大得多。模型在决定是否调用工具、选哪个工具、填什么参数时,如果 temperature 太高,它就倾向于“创造性发挥”,可能出现幻觉式参数——比如明明工具要求传数字类型的 limit,模型硬是传了个字符串"10 条"。对 Agent 场景,我一般建议控制在 0.3 以下,查库类场景直接 0。

值得一提的还有 maxTokens。Agent 循环里每一轮的输出里都包含工具调用请求,这部分会挤占输出 token。如果你的工具定义很多、参数说明很长,maxTokens 设得太小会导致模型在调用工具时输出被截断,出现工具名残缺、参数 JSON 不完整的问题。在工具密集型场景,我一般把 maxTokens 设到 2000 以上。

2.4 工具与 MCP 服务的接入配置

Agent 的“行动”能力来自工具,而工具在 Spring AI Alibaba 里来自两个渠道:一种是通过@Tool注解标注的本地方法,另一种是外部的 MCP 服务。最近不少人在问“spring ai alibaba 如何使用别人提供的 MCP 服务”,这里单独说一下。

先说本地工具。用MethodToolCallbackProvider把 Spring Bean 里的方法包装成工具,这一步比较直观:

@Configuration public class AgentToolConfig { @Bean public ToolCallbackProvider sqlServerTools(DatabaseQueryService queryService) { return MethodToolCallbackProvider.builder() .toolObjects(queryService) .build(); } }

这里有一个特别值得注意的点:工具的描述信息决定模型能不能正确触发它。我在工具描述上吃过亏——有一个获取用户信息的工具,描述写的太笼统,模型经常在应该调“订单查询”的时候去调它。后来把描述改成了“根据用户手机号获取基础信息,当用户询问姓名、等级、注册时间时使用,注意与订单查询区分”,误调率明显下降。

再说 MCP 服务。别人提供的 MCP 服务本质上就是一个暴露了标准协议接口的服务端,Spring AI Alibaba 通过 MCP Client 接入,接入后它的工具会被自动注册成 ToolCallback,Agent 就能像调用本地工具一样用。

在 application.yml 里的配置大致是这样:

spring: ai: mcp: client: connections: external-biz-service: type: remote url: http://localhost:8081/mcp headers: Authorization: Bearer ${MCP_TOKEN}

接入之后,如果你用的是 ChatClient.Builder 的 defaultTools,需要把 MCP 的工具也合并进来。遇到“MCP 服务注册了但 Agent 不调用”的问题时,十个里有八个是工具描述太差或者模型根本没看到这个工具,而不是协议层出了问题。

MCP 服务接入还有一个常见的坑:超时。外部 MCP 服务的响应速度不受你控制,如果某个工具本身要执行十秒以上的查询,模型这边可能已经等了很久。我给所有远程工具调用都设置了超时,一旦超时就返回一个明确的中断信息,让模型知道这个工具暂时不可用,而不是让它傻等之后拿到一个异常。

2.5 记忆与上下文管理

最后一个维度和多轮对话有关。Agent 有状态这件事既是能力的来源,也是配置的难点。你不想让 Agent 忘掉用户前面说的话,但也不能让上下文无限膨胀。

Spring AI Alibaba 的记忆机制主要体现在 ChatMemory 和消息窗口上。短期的消息窗口配置一般长这样:

MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build();

maxMessages 不是越大越好。语言模型的上下文窗口是有限的,你塞进去 50 条历史消息,模型能用来生成回答的空间就被挤占了,而且对 token 成本的消耗几乎是线性增长的。实测下来,20 条左右是大多数业务场景的甜点值。

更精细的做法是把记忆分类型处理。普通寒暄历史保留最近 10 条,而“用户已经确认的表结构”“已经查询到的关键数据”这种事实性信息单独存成业务记忆,在每次请求时固定注入系统提示词。这样既不会上下文爆炸,也能保证关键信息不丢。

3. 实操:封装一个带 RunnableConfig 的 NL2SQL Agent

3.1 场景定义与工具设计

理论和配置项拆完之后,用一个完整案例串起来。假设要做一个销售数据问答机器人,用户问“华东区上个月销售额排名前五的商品是什么”,Agent 需要先了解表结构,再生成查询,最后把结果转成自然语言。

这类场景我推荐最少依赖的方式:两个工具,一个是获取表结构的getTableSchema,一个是执行只读查询的executeQuery

工具设计上有一个关键点:不在工具里做任何业务判断,工具只做执行,判断交给模型。前一个工具返回的是完整的表结构 JSON,模型基于它决定下一步查哪张表;后一个工具执行 SQL 并返回结果集。这样的分层让模型可以灵活决策,但也要求工具返回值足够结构化。

3.2 工程落地:依赖、配置与代码

工程依赖方面,我习惯在 Spring Boot 3.x 项目里加 Spring AI Alibaba 的 starter 依赖。核心依赖大致是:

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.x.x</version> </dependency>

接入 MCP 客户端需要额外加:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>

注意具体版本号要以你当前使用的 Spring AI Alibaba 1.x 小版本的依赖管理为准。

全局配置里把模型参数定在一个偏保守的范围:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.1 max-tokens: 2000

然后是工具类。我用 Spring JDBC 来执行查询,这样一个 Demo 不需要引入复杂的 ORM:

@Component public class DatabaseQueryService { private final JdbcTemplate jdbcTemplate; public DatabaseQueryService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Tool(description = "获取指定表的字段信息,返回字段名、类型、注释列表。查询前请先调用此工具确认表结构。") public String getTableSchema(String tableName) { String sql = """ SELECT column_name, data_type, COALESCE(column_comment, '') FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position """; List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, tableName); return rows.isEmpty() ? "表不存在或无权访问" : JSON.toJSONString(rows); } @Tool(description = "执行只读SQL查询,仅支持SELECT语句,返回结果集的JSON数组。") public String executeQuery(String sql) { if (sql == null || !sql.trim().toLowerCase().startsWith("select")) { return "仅支持SELECT语句"; } try { List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql); if (rows.size() > 200) { return JSON.toJSONString(rows.subList(0, 200)) + "(结果集过大,仅返回前200条)"; } return JSON.toJSONString(rows); } catch (Exception e) { return "SQL执行失败:" + e.getMessage(); } } }

这里有个细节:executeQuery 里对结果集做了截断。如果不截断,一个表一百万行数据往模型上下文里灌,一次请求就会把 token 打爆。对 Agent 而言,“查到了什么”往往比“完整数据”更重要,真要完整数据应该走文件导出通道,而不是让模型在上下文里处理。

接下来是 Agent 的执行入口。这里会把 RunnableConfig 用起来:

@Service public class SqlAgentService { private final ChatClient chatClient; public SqlAgentService(ChatClient chatClient) { this.chatClient = chatClient; } public String ask(String question, String requestId, String userId) { RunnableConfig config = RunnableConfig.builder() .recursionLimit(6) .tag("nl2sql") .metadata("requestId", requestId) .metadata("userId", userId) .build(); // Spring AI Alibaba 1.x 中 RunnableConfig 最终会绑定到 ChatClient 的执行链路 return chatClient.prompt() .user(question) .options(config) .call() .content(); } }

ChatClient 的初始化放在配置类里,把工具注册进去,再写一段系统提示词来约束模型行为:

@Bean public ChatClient sqlAgentChatClient(ChatClient.Builder builder, ToolCallbackProvider sqlTools) { return builder .defaultSystem(""" 你是销售数据查询助手。你的工作流程: 1. 根据用户问题判断涉及哪些表,先调用 getTableSchema 获取表结构; 2. 基于真实表结构编写 SQL,调用 executeQuery 获取数据; 3. 根据查询结果用简洁中文回答用户。 注意:不允许编造字段名,不允许执行非 SELECT 语句。 """) .defaultTools(sqlTools) .build(); }

3.3 参数选择与效果验证

参数方面,我最初把 recursionLimit 设成了 4,测下来发现不够。原因是模型在处理“上个月”这种相对时间时,会先查当前日期或先确认时间口径,然后再查表结构、再执行查询,这就已经 4 轮了,最后一轮生成答案就超限了。后来统计了 50 条测试请求,平均需要 4.7 轮,于是把 recursionLimit 定在 6,既能覆盖绝大多数正常请求,又不会给失控请求留太多空间。

temperature 设了 0.1,实测在 SQL 生成场景下,这个值能保证大多数情况下每次生成的 SQL 结构一致,不会出现“这次不加 limit,下次加了 limit”这种不可控的波动。

跑一个典型问题验证一下效果。输入“华东区上个月销售额前五的商品”,Agent 的执行过程大致是:

第一轮:模型判断需要查表结构,发起了getTableSchema调用,参数可能是ordersproducts。工具返回了表结构。

第二轮:模型看到表结构里有order_amountregionproduct_nameorder_date等字段,决定编写 SQL,发起executeQuery

第三轮:工具返回查询结果,模型组织自然语言回答。

一次完美的流程用时大约 3 轮,而如果第一步表名猜错了,模型会通过工具结果修正自己,额外消耗一两轮。这就是为什么工具返回信息里要包含“表不存在”这类反馈——模型能把失败转化为下一步的决策依据。

4. 常见问题排查与避坑实录

4.1 “Agent execution terminated due to error.”意味着什么

这个报错在日志里出现时,很多人的第一反应是模型出错了,但实测下来,绝大多数情况是 Agent 执行器捕获到了内部异常后主动终止了流程。也就是说,它不是根因提示,而是一个“熔断通知”。

遇到这个报错,我建议按这个顺序排查:

先看是否触发了 recursionLimit。如果日志里在报错之前出现多次工具调用的记录,而且轮数和你的限制值一致,基本就是递归超限导致的中断。解决方向是优化工具调用链,或者适当提高限制值。

再看工具本身是否抛了异常。比如 executeQuery 工具里如果没做 try-catch,SQL 语法错误会直接把异常抛到 Agent 执行器里,执行器捕获后就以“terminated due to error”收场。工具方法内部必须做好异常兜底,保证任何情况下都返回一个可读的字符串,而不是往上抛异常。

最后看 MCP 远程服务是否超时。外部 MCP 服务连接不上或者响应超时,同样会中断流程。

4.2 递归超限与工具误判

递归超限有一个隐蔽的诱因:模型对工具参数的理解偏差。比如 getTableSchema 这个工具的参数是tableName,模型如果传了一个带空格或带引号的值,查询不到数据后它不会停下来,而是会换个姿势再试,几次之后就直接超限。

解决这种问题有两个方向。一是优化工具描述,把参数格式写清楚,比如“tableName 为数据库中的原始表名,不含引号,例如 orders”。二是在工具内部做参数容错,把常见的错误格式自动修正。这两个手段组合使用,能显著减少因为参数误判导致的循环。

另一个思路是把“失败信息”设计得更聪明。工具返回“无数据”时,不妨带上提示词,比如“没有找到该表,可选表有:orders, products, users”。这能让模型快速跳出错误路径,而不是反复用错误参数重试。

4.3 上下文爆炸与响应变慢

Agent 每跑一轮,工具返回的内容就会累积到上下文里。如果某个工具一次返回几十 KB 数据,三轮之后上下文就已经非常臃肿,模型推理速度明显下降,token 成本直线上升。

我的经验是在工具返回值上下功夫。第一是截断,结果集超过一定行数就只返回摘要,比如总数、前 N 条、聚合值。第二是结构化,用紧凑的 JSON 格式返回,不要用大段的自然语言描述。第三是清理无关字段,查询结果只保留和回答问题相关的列,不要每次把全表的字段都带回来。

如果你发现上下文膨胀已经成了常态,就要考虑引入记忆压缩机制。把老对话的核心事实总结成简短摘要,替换掉原始对话历史,比简单地调大 maxMessages 更有效。

4.4 MCP 远程服务接入失败的排查

最后单独说 MCP,因为“如何使用别人提供的 MCP 服务”这个问题被问得最多。接入远程 MCP 服务时常见的坑按出现频率排名:

第一个是网络不通或者鉴权失败。很多人配置完 URL 就以为完事了,忘了服务端可能需要 token。MCP 协议本身不规定鉴权方式,常见的有 Header 里带 Bearer token、请求参数带签名。这部分要和提供方确认清楚。

第二个是工具发现机制。MCP 服务可能暴露了十个工具,但你只想用其中三个。Spring AI Alibaba 的 MCP 客户端允许你配置工具过滤,只注册需要的工具。工具注册得越多,模型的选择空间越大,误选概率也越高。

第三个是返回结果格式。MCP 服务返回的内容是纯文本还是结构化 JSON,直接决定模型下一步能不能有效利用。如果你的下游 Agent 要基于 MCP 返回继续做事,强烈建议要求提供方返回 JSON 格式,并附上字段说明。模型处理结构化 JSON 的稳定性远高于处理散文式文本。

如果你想确认 MCP 服务本身是否正常,可以先跳过 Spring Boot,单独写一个测试类直接用 MCP Client 拉取工具列表并调用一次,这样能把“MCP 服务问题”和“Agent 编排问题”隔离开来。

我在实际接入一个外部供应商的 MCP 服务时,就是用这种方式发现对方返回的工具描述里缺了参数说明,模型拿到工具后根本不知道怎么填参数。后来让对方在工具描述里补了参数示例,问题立刻消失。

回到最开始那个事故。当时我把 RunnableConfig 里的 recursionLimit 从默认值调小,把 temperature 降到 0.1,又给两个工具补了更严格的描述和异常兜底,报表助手就再没有出现过无限循环的问题。事后复盘,Agent 开发的难点从来不是写一个会调工具的模型,而是给这个“会调工具的东西”划定清晰的边界。RunnableConfig 就是边界本身。参数不多,但每一个都值得你在上线前认真过一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询