Spring AI Alibaba Agent运行时配置:RunnableConfig核心参数与实战踩坑记录

发布时间:2026/9/15 9:06:52
Spring AI Alibaba Agent运行时配置:RunnableConfig核心参数与实战踩坑记录
1. 先从一次“Agent 不受控”的调试说起1.1 现象跑了三轮模型还在用第一轮的上下文先说一个我自己的真实案例。上个月我在基于 Spring AI Alibaba 1.x 做一个客服 Agent模型选的是 qwen-plus功能很简单用户问订单、查物流、触发退款。前两轮联调都很顺利但测到第三轮就出问题了——用户说“刚才那个订单再帮我确认一下”Agent 居然像个第一次见到这个订单的人一样把商品名、金额、下单时间全重新问了一遍。我一开始以为是 ChatMemory 没有配置于是翻代码发现Agent.builder()里明明设置了 memory然后又怀疑是 ChatClient 的问题但单独调用 ChatModel 又是好的。最后我把请求日志打开才发现一个很隐蔽的现象请求体里的历史消息确实存在但只有第一轮的内容第二轮、第三轮的对话根本没有被塞进去。也就是说Agent 每一轮都拿着同一份旧上下文在跑自然表现得像个“金鱼脑”。这个问题不是个例。很多人会把 Agent 调不通归因于模型能力但实际上一大半是因为运行时配置没传对。在 Spring AI Alibaba 1.x 里这个“运行时配置”的载体就叫 RunnableConfig。1.2 排查链路从 Prompt 一直追到运行时上下文当时我走的排查链路大概是这样第一步确认 Prompt。我把 System Prompt 打出来看了好几遍没发现硬编码问题。第二步确认模型参数。构造 Agent 时我确实设置了 temperature、maxTokens 这些参数而且日志里模型也收到了这些值。第三步确认 ChatMemory。这一步很关键。我发现应用里定义了一个ChatMemory的 Bean但 Agent 运行过程中并没有使用这个 Bean 里已有的会话历史而是重新 new 了一份空的 memory。换句话说Agent 内部真正读取的 memory并不是我构造时传进去的那个。为什么会出现这种“配置了等于没配置”的情况因为 Spring AI Alibaba 1.x 的 Agent 不是一次普通的模型调用它是一条可编排的执行链可能包含规划节点、工具调用节点、记忆读取节点、结果生成节点。很多参数不是 Agent 在构造时一次性固定的而是要等真正 run 的时候由调用方把一个“运行上下文”传进去。这个上下文就是 RunnableConfig。如果调用方没有显式传或者传了一个空的 RunnableConfig那么所有运行时动态绑定信息都会落到默认值上。默认的 conversationId 可能每次都是随机生成的默认的 memory 可能是一个“谁都能读但谁都没写进去”的空实现默认的 maxIterations 可能直接让模型无限调工具。1.3 RunnableConfig 在运行链路中的定位我自己给 RunnableConfig 的一个类比是如果把 Agent 比作一家餐厅那么 Agent 构建时配置的东西是“这家餐厅的固定菜单和营业时间”而 RunnableConfig 是“顾客现场下的单和备注”。你可以规定餐厅默认 10 点开门但某个客人包场时能不能提前到 8 点得看现场订单怎么写。RunnableConfig 解决的就是这类问题这次对话属于哪个会话应该读哪一段历史这次运行允不允许调用某个工具最多调几次模型参数要不要临时调整比如这次回答想更严谨一点temperature 压低。这次运行要往链路里透传哪些追踪信息比如 userId、traceId、租户ID。如果 Agent 内部又创建了子 Agent子任务要不要继承这次调用的参数所以在 Spring AI Alibaba 1.x 的实际项目中我会把 RunnableConfig 当成一个“每次调用都要主动构建的对象”而不是可有可无的补充项。下面就把它的核心参数按维度拆一遍。2. RunnableConfig 到底管了 Agent 的哪些事2.1 会话维度的配置conversationId、chatMemory 与上下文窗口会话维度的配置是 RunnableConfig 里最基础也最容易出错的部分。通常包括这几个关键项配置项作用不设置的后果conversationId标识当前属于哪个会话决定从哪段历史里取上下文每次运行都当成新会话Agent 必然“失忆”chatMemory会话历史的存取实现可能使用默认空实现或不同请求之间串数据historyWindowSize最多携带多少轮历史消息历史无限膨胀Token 暴涨模型响应变慢先重点说 conversationId。它可以是前端传过来的sessionId也可以是后端生成的一个 UUID但必须保证同一个用户、同一个会话周期内保持不变。很多人以为把 conversationId 写到 RunnableConfig 里就行但实际上如果每次请求都重新UUID.randomUUID()那等于没写。再说 chatMemory。Spring AI Alibaba 1.x 中常见的实现包括内存版和 Redis 版本。内存版适合单机测试Redis 版适合多实例部署。我的建议是在 RunnableConfig 里显式指定当前会话要使用的 memory 实例而不是依赖全局 Bean。为什么因为 Agent 运行链路里可能有多个节点需要访问记忆如果 memory 不指定某些节点会回退到“只读空记忆”另一些节点则会往全局记忆里写最后造成会话串号。我自己就见过两个用户互相看到对方上下文的情况那个定位过程非常痛苦。2.2 模型推理维度的配置model、temperature、topP、maxTokens第二个维度是控制“一次模型调用怎么生成答案”的参数。这里要注意RunnableConfig 里的模型参数不是用来替代application.yml里的默认模型的而是用来做运行时覆盖的。举个例子RunnableConfig config RunnableConfig.builder() .conversationId(sessionId) .chatMemory(chatMemory) .model(qwen-max) .temperature(0.2) .topP(0.8) .maxTokens(2048) .build();这段配置的意思是这个会话里的所有模型调用优先使用 qwen-maxtemperature 0.2让回答更稳定、更少发散。如果没有这个覆盖Agent 会一直使用构建时或配置文件里的模型。但我要提醒一点temperature 的覆盖作用域是“当前 Agent 运行链路中的所有模型调用”。如果你的 Agent 内部拆成了“规划模型”和“执行模型”并且规划节点不希望被这个 temperature 影响那需要在链路设计时单独处理。RunnableConfig 提供的是全局覆盖不是细粒度的节点级配置。另外不要在 RunnableConfig 里放 API Key、Secret 这类敏感信息。后面的踩坑环节我会专门讲。2.3 执行控制维度的配置maxIterations、toolCallTimeout、allowedToolsAgent 相对普通 Chat 调用最大的区别是会自主决定要不要调用工具。而工具调用一旦失控就会变成死循环。RunnableConfig 里有一组参数是专门用来给工具执行“踩刹车”的maxIterationsAgent 最多执行几轮“思考-调用工具-观察结果”的循环。toolCallTimeout单次工具调用的超时时间。allowedTools本次运行允许调用的工具白名单。enableMcp是否启用 MCPModel Context Protocol工具源。我见过最典型的事故是Agent 查库存库存接口返回异常但 Agent 没感知到反而又去调用下单接口下完单发现库存还是不够再调用别的工具整个链路来回跳了七八次。如果没有 maxIterations 限制它可能在一个循环里把上游系统打到限流。建议在生产环境至少设置maxIterations比如 3 到 5 次。为什么不是越大越好因为每多一次迭代就多一轮模型调用延迟和成本都是成倍增加的。2.4 链路追踪与业务透传维度metadata、traceId、userId、tenantId这一项很容易被忽略。RunnableConfig 通常会有一个MapString, Object metadata用于存放与业务相关的透传信息。我在项目里一般会放四件套MapString, String metadata new HashMap(); metadata.put(traceId, TraceIdGenerator.generate()); metadata.put(userId, user.getId()); metadata.put(tenantId, user.getTenantId()); metadata.put(channel, app);这些数据有两个作用。一是审计出了问题能快速定位是哪个用户、哪个租户、哪个渠道触发的 Agent 行为二是让 Agent 在处理敏感操作时能拿到必要的业务上下文比如“判断这个用户是否有权限执行退款”。需要注意的是metadata 不是 Prompt它不会直接塞给模型。如果你想让它影响模型行为必须在 Agent 的节点中主动读取并拼接到 Prompt 里。很多人以为放进 metadata 模型就能看到这是误解。2.5 与 Spring AI 原生 ChatOptions、Advisor 的关系这部分我多讲几句因为不少读者会混淆。Spring AI 本身有ChatOptionsSpring AI Alibaba 也有自己的Advisor机制。那 RunnableConfig 和它们是什么关系简单说RunnableConfig 是一个更上层的运行时聚合对象。ChatOptions 负责的是“某一次模型调用的参数”Advisor 负责的是“在调用链前后织入逻辑”而 RunnableConfig 负责的是“围绕一次 Agent 运行把会话、记忆、工具、模型参数、追踪信息全部收口”。可以这样理解ChatOptions管模型怎么答。Advisor管调用前后要做什么。RunnableConfig管这次运行需要哪些上下文以及这些上下文怎么分发给各个节点。所以你在代码里完全可以在 RunnableConfig 内部持有或者映射一份 ChatOptions但在 Agent 运行链路中节点优先读取的是 RunnableConfig 聚合后的值。理解了这一层后面看配置合并规则时就不会懵。3. 在 Spring AI Alibaba 1.x 中构造与传递 RunnableConfig3.1 最简示例给 Agent 开启会话记忆先给一个最简可运行的结构。假设我们已经有一个基于 Spring AI Alibaba 1.x 构建的客服 AgentAgent agent Agent.builder() .model(chatModel) .name(customer-service-agent) .build();如果直接agent.run(userMessage)大概率是没有记忆的因为缺少会话上下文。正确做法是先构建 RunnableConfigString conversationId user-123-session-456; RunnableConfig config RunnableConfig.builder() .conversationId(conversationId) .chatMemory(new InMemoryChatMemory()) .temperature(0.3) .maxIterations(5) .build(); AgentResponse response agent.run(userMessage, config);这里的conversationId是整个记忆读取的钥匙。同一个 conversationId 再次运行Agent 才能从 memory 里把之前的历史捞出来。但这段代码有个小陷阱new InMemoryChatMemory()如果放在方法内部每次请求都会 new 一个空的 memory历史还是存不下来。正确做法是让 memory 的生命周期跨请求private final ChatMemory chatMemory new InMemoryChatMemory();然后每个请求只构建新的 RunnableConfig并传入同一个 chatMemory。如果是分布式部署建议换成 Redis 或数据库存储的实现保证多个实例共享同一份历史。3.2 用工厂方法为每次请求创建独立配置我更推荐的做法是写一个工厂方法统一管理 RunnableConfig 的创建逻辑。这样既能保证 conversationId 不遗漏也能避免每个业务方法里堆一堆 builder 代码。public class AgentConfigFactory { public RunnableConfig createConfig(AgentContext context) { return RunnableConfig.builder() .conversationId(context.getConversationId()) .chatMemory(context.getChatMemory()) .metadata(Map.of( userId, context.getUserId(), tenantId, context.getTenantId(), traceId, context.getTraceId() )) .temperature(context.getTemperature() ! null ? context.getTemperature() : 0.3) .maxIterations(5) .toolCallTimeout(Duration.ofSeconds(10)) .build(); } }每次用户发起请求时我们从 HTTP Header 或会话上下文里取出 conversationId、userId、tenantId构造一个全新的 RunnableConfig。这里有几个原则每个请求一个 RunnableConfig不要复用同一个对象。conversationId 一定要有稳定的来源不要内部自己随机生成。凡是跟业务身份相关的信息一律放到 metadata不要散落在业务代码里。默认参数统一收敛到工厂避免每个调用点各写各的。3.3 多 Agent 之间的配置传递与合并规则Spring AI Alibaba 1.x 里可以构建多 Agent 协作比如一个“主管 Agent”拆解任务再派发给“订单 Agent”“物流 Agent”“售后 Agent”。这时候 RunnableConfig 的传递就变得非常重要。我的设计原则是父 Agent 的 RunnableConfig 默认下发给子 Agent子 Agent 可以覆盖部分参数但不能完全丢到父级上下文。RunnableConfig childConfig RunnableConfig.from(parentConfig) .conversationId(parentConfig.getConversationId()) .chatMemory(parentConfig.getChatMemory()) .metadata(parentConfig.getMetadata()) .maxIterations(3) .build();为什么子 Agent 要继承 conversationId 和 chatMemory因为用户很可能在对话中先让主管 Agent 查了订单然后让售后 Agent 处理退款。如果售后 Agent 看不到主管 Agent 获取的上下文它就得重新问一遍体验很差。但有些参数可以覆盖比如子 Agent 的 maxIterations 可以更小。因为子任务通常更聚焦不需要像主管 Agent 那样做大量规划。3.4 配置合并的优先级如果你同时设置了 application.yml、Agent 构建参数、RunnableConfig 三处配置那最终生效的是哪个我在项目里总结成一张表配置来源优先级说明RunnableConfig 显式字段最高运行时调用方传入直接覆盖一切application.yml 默认值中全局默认RunnableConfig 没设置时生效Agent 构建时 options较低构建期固定的兜底配置框架内置默认值最低比如默认 maxIterations1 之类这个优先级不是绝对的不同版本可能有差异但核心思想一致越靠近“本次调用”的配置越应该优先。我遇到过一种情况某同事在 Agent 构建时设置了 temperature0.1但运行链里某个节点重新创建 Prompt 时又把 ChatOptions 覆盖成默认值 0.8导致前面构建参数全部失效。这种问题单看 Agent 代码是发现不了的必须把 RunnableConfig 打到日志里逐个节点对照。4. 实测几个典型配置组合与效果4.1 场景一客服机器人必须“记得住”上一句我先做了一个测试Agent 只有一个任务基于用户历史订单信息回答问题。第一轮用户说“我的订单号是 20241101”第二轮故意说“这个订单发货了吗”。不传 RunnableConfig 时第二轮的回答是“请问您的订单号是多少”因为 Agent 根本不知道“这个订单”指的是哪个。传了 RunnableConfig 后第二轮回答是“您的订单 20241101 显示已发货承运商是顺丰运单号 SF1234567890。”差别非常明显。背后的原因是Spring AI Alibaba 1.x 在 run 的时候会从 RunnableConfig 中获取 ChatMemory按 conversationId 读取历史消息再把历史拼接到当前模型请求中。没有 conversationId历史就找不到。我再补充一个细节历史消息也不是越多越好。我在测试里把 20 轮历史全部塞进去qwen-plus 的响应时间从 2 秒涨到了 5 秒token 消耗涨了 3 倍。所以生产环境建议给 RunnableConfig 设置historyWindowSize比如 10 轮既保留上下文又不至于让请求体过于臃肿。4.2 场景二限制工具循环避免 Agent 死循环第二个测试是模拟“库存查询工具永远返回异常”的情况。Agent 的指令是“查库存充足就下单不足就通知用户”。正常情况下这个任务两步就能完成但当我故意把库存接口改成总是返回“系统繁忙”时Agent 会不断重试。未设置 maxIterations 时我数了一下日志Agent 连续调用了 7 次工具最后才因为模型的上下文限制被迫结束。设置maxIterations3后第 3 次工具调用返回失败Agent 直接生成最终回答“抱歉库存系统暂时无法访问建议稍后再试。”这里要说明一下maxIterations 的效果不是“调用 3 次工具就强制报错”而是“最多允许 3 轮 Agent 的思考-行动循环”。每轮循环可能包含一次或多次工具调用。具体语义取决于你使用的 Agent 执行器版本建议以实际日志为准。我还配合设置了toolCallTimeout(Duration.ofSeconds(10))。这个参数主要防止某个工具一直阻塞导致整个 Agent 请求悬挂。设置后工具调用超过 10 秒会直接超时Agent 会把超时当作一次失败结果继续走流程。4.3 场景三多租户数据隔离第三个测试模拟多个租户同时使用同一个 Agent。我不希望租户 A 的用户能读取到租户 B 的订单数据。在 RunnableConfig 中我通过 metadata 把 tenantId 传下去然后在工具节点里读取 metadataString tenantId (String) runnableConfig.getMetadata().get(tenantId);工具调用时拼接查询条件ListOrder orders orderService.query(tenantId, userId);这里的关键是每个请求的 RunnableConfig 都是独立的metadata 不会串。但如果你为了省事在类的成员变量里存了一个 RunnableConfig 供所有线程共享那 tenantId 就会互相覆盖后果非常严重。我个人的建议是RunnableConfig 的生命周期最好限制在“一次请求内”不要把它放进 Spring 的单例 Bean 中保存。如果需要跨节点传递可以通过方法参数或上下文对象传递而不是放在被并发访问的成员变量里。5. 踩坑记录这些配置问题我调了很久5.1 构造 Agent 时的 options 会被运行时默认配置“冲掉”有一个非常隐蔽的坑我在构建 Agent 时给了一个ChatOptions设置了modelNameqwen-max、temperature0.2。测试的时候发现第一次调用没问题第二次调用有些节点却用了 qwen-turbo。原因不是 Agent 把配置弄丢了而是节点在执行时创建了新的 Prompt并且只复制了部分 ChatOptions。有的节点复制了 model有的节点没有复制 temperature最终回退到 yml 里的默认值。从那以后我的原则变得很简单凡是“每次调用都可能变化”的参数只放到 RunnableConfig 中Agent 构建时的 options 只作为最低兜底不指望它一定生效。5.2 日志打印 RunnableConfig泄露了不该出现的内容RunnableConfig 看起来只是一个配置对象但如果你无脑地打印整个对象问题就来了。metadata 里如果放了用户手机号、token、内部系统地址这些信息会一起被日志收集系统拿走。有一次我在联调环境排查问题用 log.info 打印了完整 RunnableConfig结果日志平台直接弹出了敏感信息告警。从那以后我会在 toString 或日志切面里对 metadata 做脱敏public String safeLog(RunnableConfig config) { MapString, Object safeMetadata new HashMap(config.getMetadata()); safeMetadata.keySet().removeIf(key - key.contains(secret) || key.contains(token)); return RunnableConfig{conversationId config.getConversationId() , metadata safeMetadata }; }这不是小题大做。Agent 一旦上线所有日志都会被长期保留要追回来非常麻烦。5.3 并发场景下同一个 ChatMemory 实例的串话前面提到 InMemoryChatMemory 要跨请求复用但复用不代表可以乱用。如果你的 ChatMemory 实现不支持按 conversationId 做隔离那么两个用户同时使用同一个 memory 实例时历史消息可能互相污染。我的验证方式很简单开了两个浏览器窗口分别用不同的 conversationId 登录同时发消息然后观察是否出现“A 用户的历史出现在 B 用户请求里”。解决方案有两种。一种是把 memory 设计成按 conversationId 分桶存储每个桶互相独立另一种是直接用支持 Redis 的 ChatMemory 实现天然按 key 隔离。如果你做的是单机 demo用内存版也无妨但生产环境一定要上共享存储否则多实例部署时每个实例只有一部分历史Agent 照样“失忆”。5.4 版本升级后配置项改名Spring AI Alibaba 1.x 还在快速迭代我当时用的一个版本里配置项叫enableMcp升级了一个小版本后直接报“找不到属性”查源码发现改名成了mcpEnabled。这种事不罕见。我的应对方式是在项目里做一个配置适配层不让业务代码直接依赖 RunnableConfig.Builder 的每个 setter。比如public interface AgentRuntimeConfigPort { RunnableConfig createDefault(); }底层封装版本差异上层业务只调用AgentRuntimeConfigPort。这样升级框架时只需要改适配层一个类不用全局搜索替换。6. 一份可以抄的 RunnableConfig 生产配置模板6.1 Java 侧模板下面是我在项目里使用的一个基础模板你可以根据自己的需求调整。整体思路是“工厂方法 最小暴露 显式覆盖”public RunnableConfig buildRuntimeConfig(AgentContext ctx) { RunnableConfig.RunnableConfigBuilder builder RunnableConfig.builder() .conversationId(ctx.getConversationId()) .chatMemory(chatMemory) .historyWindowSize(10) .maxIterations(5) .toolCallTimeout(Duration.ofSeconds(15)) .model(qwen-plus) .temperature(0.3) .topP(0.8) .maxTokens(2048); MapString, Object metadata new HashMap(); metadata.put(userId, ctx.getUserId()); metadata.put(tenantId, ctx.getTenantId()); metadata.put(traceId, ctx.getTraceId()); metadata.put(channel, ctx.getChannel()); builder.metadata(metadata); // 按业务场景覆盖 if (ctx.isHighPrecision()) { builder.temperature(0.1); } if (ctx.isFastMode()) { builder.maxTokens(1024).maxIterations(3); } return builder.build(); }这一段代码解决了几件事会话记忆、运行步数限制、工具超时、模型参数、链路追踪信息。如果业务逻辑越来越多我建议再拆分几个 Builder而不是把十几套场景全塞到一个方法里。6.2 application.yml 与 RunnableConfig 的配合RunnableConfig 虽然能做运行时覆盖但全局默认值放到 YAML 里更省事。我通常这样配spring: ai: alibaba: agent: default-model: qwen-plus default-temperature: 0.3 default-max-tokens: 2048 default-max-iterations: 5 default-tool-call-timeout: 15s这里特别说明不同小版本的属性名可能有差异上面只是示意。我的建议是先查当前版本的配置绑定类确认属性名后再写入 YAML。否则配置不生效又会出现“我以为我配了其实没配”的问题。RunnableConfig 里的显式设置优先于 YAML 默认值。比如 YAML 里默认 temperature 是 0.3但某次活动场景希望回答更活泼可以在 RunnableConfig 里改成 0.8。6.3 我的配置习惯与后续扩展做了这几个 Agent 项目之后我自己的配置习惯可以总结成三句话第一conversationId 永远是第一优先级没有它后面所有记忆类配置都是空谈。 第二maxIterations 和 toolCallTimeout 是生产环境必须设置的宁可设小不要不设。 第三metadata 只放非敏感的业务上下文敏感信息一律走外部密钥管理与鉴权服务。RunnableConfig 在 Spring AI Alibaba 1.x 的 Agent 体系里看起来只是一个配置参数实际上是连接“调用方”和“执行链路”的桥梁。它的价值不在于字段有多少而在于你能不能在整个执行链条中稳定、安全、高效地把上下文传下去。我建议你把 RunnableConfig 的构建过程当成一次正式的运行时设计而不是顺手 new 一个对象这样 Agent 后续的扩展会省很多事。