引言
在 Spring AI Alibaba(以及更广泛的 Spring AI 和 LangChain 生态系统)中,RunnableConfig 和 OverallState是构建Agent(智能体)或Workflow(工作流)时的两个核心概念,那它们到底有什么区别呢?
RunnableConfig (运行时配置)
RunnableConfig 是一个配置对象,用于在执行链(Chain)或 Agent 的各个步骤之间传递运行时的元数据和控制参数。它类似于一个"背包",伴随着请求在整个系统中流动,但它不包含业务数据,只包含"如何执行"的指令与参数。
核心作用:
-
控制递归与循环 (Recursion Limit) :
- 在 Agent 循环(例如:思考 -> 工具 -> 思考)中,为了防止死循环,
RunnableConfig可以设置最大递归次数(例如 25 次)。如果超过这个次数,系统会强制停止并报错。
- 在 Agent 循环(例如:思考 -> 工具 -> 思考)中,为了防止死循环,
-
回调机制 (Callbacks) :
- 它携带了
CallbackManager。你可以通过它挂载监听器,监控 Token 消耗、流式输出(Streaming)、或者在某个步骤开始/结束时触发日志。
- 它携带了
-
元数据与追踪 (Metadata & Tags) :
- 你可以给当前的执行请求打上
tags(标签)或metadata(元数据)。这对于链路追踪(如接入 LangSmith 或 Zipkin)非常重要。例如,标记userId: 123或environment: production。
- 你可以给当前的执行请求打上
-
并发控制与超时:
- 在某些实现中,它可以携带取消信号(Cancellation Signals)或超时设置
关键理解
你可以将RunnableConfig理解为"显式的、跨线程安全的 ThreadLocal"。
在 Spring AI / LangChain 的架构中,RunnableConfig 起到了同样的作用:
- 隐式传递:虽然它作为参数传递,但在 Agent 的内部组件(如各个 Tool、各个 Chain 节点)之间,它往往是作为一个"环境上下文"存在的。
- 存放"带外数据" (Out-of-band Data) :它专门设计了一个
metadata字段(是一个 Map),允许你往里面塞一些非业务逻辑核心、但全链路都需要的数据。
异步与跨线程安全性 (这是最大的区别)
- ThreadLocal : 它是绑定在特定线程 上的。Spring AI 底层(特别是在流式输出 Streaming 或调用大模型 API 时)经常是异步的 (使用
WebFlux或CompletableFuture)。一旦代码切换了线程(例如从 Tomcat 线程切换到 Netty I/O 线程),ThreadLocal里的数据就会丢失,除非你做非常复杂的 Context 复制。 - RunnableConfig : 它是一个普通的 Java 对象 。当 AI 任务在不同的线程、不同的 Reactor 流之间传递时,这个对象会作为一个参数一直跟着走。无论线程怎么切,数据都在那里,不会丢。
作用域清晰 (Scope)
- ThreadLocal : 如果忘记
remove(),容易造成内存泄漏(Memory Leak),或者在线程池复用时造成数据污染(上一个请求的数据留到了下一个请求)。 - RunnableConfig : 它的生命周期严格绑定在单次执行链(Run)上。也就是
agent.invoke()结束,这个 Config 对象也就完成了使命,被 GC 回收,非常干净
示例
下面是Spring AI Alibaba的一段示例
-
从
toolContext.getContext()中取出RunnableConfig -
从中读取
metadata("user_id") -
根据user_id
返回不同位置:
- 用户 ID 为
"1"→"Florida" - 其他 →
"San Francisco"
- 用户 ID 为
java
// 用户位置工具 - 使用上下文
public class UserLocationTool implements BiFunction<String, ToolContext, String> {
@Override
public String apply(
@ToolParam(description = "User query") String query,
ToolContext toolContext) {
// 从上下文中获取用户信息
String userId = "";
if (toolContext != null && toolContext.getContext() != null) {
RunnableConfig runnableConfig = (RunnableConfig) toolContext.getContext().get(AGENT_CONFIG_CONTEXT_KEY);
Optional<Object> userIdObjOptional = runnableConfig.metadata("user_id");
if (userIdObjOptional.isPresent()) {
userId = (String) userIdObjOptional.get();
}
}
if (userId == null) {
userId = "1";
}
return "1".equals(userId) ? "Florida" : "San Francisco";
}
}
RunnableConfig 提供了一个 context() 方法,允许你在同一个执行流程中的多个 Hook 调用、多轮模型或工具调用之间共享数据。这对于实现计数器、累积统计信息或跨多次调用维护状态非常有用。
适用场景:
- 跟踪模型或工具调用次数
- 累积性能指标(总耗时、平均响应时间等)
- 在 before/after Hook 之间传递临时数据
- 实现基于计数的限流或断路器
示例:使用 RunnableConfig.context() 实现调用计数器
java
@HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL})
public class ModelCallCounterHook extends ModelHook {
private static final String CALL_COUNT_KEY = "__model_call_count__";
private static final String TOTAL_TIME_KEY = "__total_model_time__";
private static final String START_TIME_KEY = "__call_start_time__";
@Override
public String getName() {
return "model_call_counter";
}
@Override
public CompletableFuture<Map<String, Object>> beforeModel(OverAllState state, RunnableConfig config) {
// 从 context 读取当前计数(如果不存在则默认为 0)
int currentCount = config.context().containsKey(CALL_COUNT_KEY)
? (int) config.context().get(CALL_COUNT_KEY) : 0;
System.out.println("模型调用 #" + (currentCount + 1));
// 记录开始时间
config.context().put(START_TIME_KEY, System.currentTimeMillis());
return CompletableFuture.completedFuture(Map.of());
}
@Override
public CompletableFuture<Map<String, Object>> afterModel(OverAllState state, RunnableConfig config) {
// 读取当前计数并递增
int currentCount = config.context().containsKey(CALL_COUNT_KEY)
? (int) config.context().get(CALL_COUNT_KEY) : 0;
config.context().put(CALL_COUNT_KEY, currentCount + 1);
// 计算本次调用耗时并累加到总耗时
if (config.context().containsKey(START_TIME_KEY)) {
long startTime = (long) config.context().get(START_TIME_KEY);
long duration = System.currentTimeMillis() - startTime;
long totalTime = config.context().containsKey(TOTAL_TIME_KEY)
? (long) config.context().get(TOTAL_TIME_KEY) : 0L;
config.context().put(TOTAL_TIME_KEY, totalTime + duration);
// 输出统计信息
int newCount = currentCount + 1;
long newTotalTime = totalTime + duration;
System.out.println("模型调用完成: " + duration + "ms");
System.out.println("累计统计 - 调用次数: " + newCount + ", 总耗时: " + newTotalTime + "ms, 平均: " + (newTotalTime / newCount) + "ms");
}
return CompletableFuture.completedFuture(Map.of());
}
}
OverallState (全局状态/上下文)
OverallState 通常不是 Spring AI 框架内的一个固定类名(不像 RunnableConfig 属于核心接口),它通常是开发者在构建 基于图(Graph-based) 或 状态机(Stateful) 的 Agent 时自定义的一个类。
在 Spring AI Alibaba 的高级应用(如仿照 LangGraph 的模式)中,Agent 的执行过程被视为一系列节点的流转。OverallState 就是在这些节点之间传递的共享内存对象。
它一般包含的就是应用的业务数据。
核心作用:
-
统一的数据总线:
- 所有的节点(Node)都从
OverallState读取数据,处理完后将结果写回OverallState。
- 所有的节点(Node)都从
-
记忆管理 (Memory) :
-
它通常包含:
input:用户的原始问题。chat_history:对话的历史消息列表。intermediate_steps:Agent 的中间思考过程或工具调用结果。output:最终生成的答案。
-
-
状态流转:
- 在 Spring AI Alibaba 对接 Qwen(通义千问)时,模型生成的回复会更新到 State 中,下一个节点(比如工具执行节点)会读取 State 中的模型指令去执行工具。
工作流
下面我给出一个示例工作流。
-
Start -> 传入初始
OverallState(包含用户问题)。 -
LLM Node -> 读取 State,调用 Qwen 模型,将模型的回复(比如"去调用天气API")写入 State。
-
Tool Node -> 检测到 State 里有调用请求,执行天气 API,将结果"25度"写入 State。
-
LLM Node -> 再次读取 State(包含问题+工具结果),生成最终回答"今天25度",写入 State。
-
End -> 返回 State 中的最终回答。
总结对比
| 特性 | RunnableConfig | OverallState (自定义状态) |
|---|---|---|
| 关注点 | 过程控制 (Process Control) | 业务数据 (Business Data) |
| 内容示例 | 递归限制、回调函数、元数据、标签 | 用户问题、LLM 回复、工具结果、数据库查询值 |
| 可变性 | 通常在运行开始时设定,传递过程中较少修改 | 在每个步骤(Node)中不断被读取和修改(累加信息) |
| 类比 | 交通规则与信号灯(红绿灯、限速) | 货车上的货物(每一站可能会装卸货物) |