Agent 状态存储三模式:ThreadState 字段、middleware.state_schema 与实例属性
摘要:在create_agent+ 中间件架构里,能放数据的地方其实有三类,但它们的归属、生命周期和持久化行为完全不同:顶层集中声明的ThreadState字段、中间件类属性state_schema声明的字段、以及中间件实例自身的self.xxx。选错位置会直接招来两种典型事故——并发下跨会话串数据,或者状态压根没进 checkpoint 导致重启即丢。本文给出三者的对照表、各自的写法与边界,并补上 reducer 合并规则与选型判据。
一、概述:先问一句"这份数据属于谁"
在create_agent+ 中间件架构中,共有三类存放数据的位置,三者归属、生命周期、持久化特性完全不同:
- 中心化
ThreadState字段 - 去中心化
middleware.state_schema声明字段 - 中间件实例自身成员属性(装配注入,进程级配置)
核心区分要点:判断数据是属于单个对话会话(thread),还是整个服务进程全局固定配置。线程级动态业务数据必须放入 AgentState;全局静态配置才使用中间件实例属性。
二、三种存储模式对比
| 存储位置 | 声明方式 | 数据归属 | 生命周期 | Checkpoint 持久化 | 多 thread 隔离 | 典型用途 |
|---|---|---|---|---|---|---|
| 中心化 AgentState 字段 | 顶层手写ThreadState(AgentState)类,集中定义所有字段 | 单个对话 thread 的状态实例 | thread 会话生命周期 | ✅ 保存 | ✅ 隔离 | plan、todos、goal 等业务状态 |
去中心化state_schema字段 | 中间件类属性state_schema,装配阶段框架自动归集拼装进全局 AgentState | 单个对话 thread 的状态实例 | thread 会话生命周期 | ✅ 保存 | ✅ 隔离 | plan、todos;谁使用谁声明,可插拔,无隐身字段 |
中间件实例属性self.xxx | 中间件__init__构造函数,装配实例化时注入配置 | 中间件实例本身,不属于 AgentState | 服务进程生命周期,服务重启销毁 | ❌ 不持久化 | ❌ 全部 thread 共享,并发易串数据 | 进程级静态配置:全局 prompt、超时、模型地址、全局开关 |
一句话记法:前两者是"图纸 + 每会话一份的活数据",第三者只是"进程里的一块常量内存"。
三、模式详细说明
3.1 模式一:中心化 ThreadState(传统方案)
所有状态字段统一在顶层ThreadState一次性声明,直接传给create_agent(state_schema=ThreadState)。
class ThreadState(AgentState): goal: str plan: str todos: list[str] agent = create_agent(model=model, middleware=[...], state_schema=ThreadState)- 优点:结构直观,一眼看到全部状态。
- 缺点:新增/删除中间件时,需要手动维护
ThreadState;删除中间件容易残留隐身字段(字段还在 schema 里,但已经没有任何中间件读写它)。 - 适用:小型 Agent,状态字段少。
3.2 模式二:去中心化 middleware.state_schema(推荐方案)
每个中间件在类属性state_schema中声明自己依赖的状态字段;Agent 装配阶段自动收集全部中间件的 schema,动态合并生成全局 AgentState。
class PlanMiddleware(AgentMiddleware): state_schema = PlanState # 类属性,声明本中间件要用到的字段 def wrap_model_call(self, request, handler): return handler(request)装配公式:全局最终Schema = 框架基础AgentState ⊕ middleware1.state_schema ⊕ middleware2.state_schema ...特点:
- 字段与使用它的中间件内聚在一起;移除中间件,装配时不再纳入该字段,无隐身字段。
- 支持自定义 reducer:控制多源更新合并策略。
plan:单文本,默认覆盖式 reducer,新规划直接替换旧规划。todos:任务列表,自定义追加 reducer,新旧任务列表增量合并,避免丢失任务。
- 所有字段归属于 thread 状态,存入 checkpoint,不同对话数据隔离。
⚠️约束:state_schema必须是类属性,不能写在__init__的实例属性里——装配阶段实例还没创建,框架无法识别。
3.3 写状态时要分钩子:dict / Command / ExtendedModelResponse
框架对不同钩子规定了不同的返回契约,写状态时必须配套使用:
| 钩子类型 | 钩子 | 写状态的返回形式 |
|---|---|---|
| 节点式 | before_agent/before_model/after_model/after_agent | 直接返回dict[str, Any]({"key": value}) |
| 包裹式(模型) | wrap_model_call | 返回ExtendedModelResponse(model_response=..., command=Command(update={...})) |
| 包裹式(工具) | wrap_tool_call | 直接返回Command |
@wrap_model_call(state_schema=UsageTrackingState) def track_usage(request: ModelRequest, handler) -> ExtendedModelResponse: response = handler(request) return ExtendedModelResponse( model_response=response, command=Command(update={"last_model_call_tokens": 150}), )多个中间件同时更新同一个 key 时:带 reducer 的字段按 reducer 规则合并;没有 reducer 的字段"最后写入者获胜",且由于外层中间件在handler()返回后才写入,通常是外层覆盖内层。3.4 模式三:中间件实例属性 self.xxx(装配注入,进程级配置)
实例化中间件时,在构造函数__init__传入静态配置,保存到self.xxx。
class StaticConfigMiddleware(AgentMiddleware): def __init__(self, global_prompt: str, timeout: int): # 装配阶段注入,进程级配置,不属于 AgentState self.global_prompt = global_prompt self.timeout = timeout def wrap_model_call(self, request, handler): # 所有对话 thread 共用这一份配置 return handler(request)特点:
- 装配阶段实例化中间件时一次性注入,运行期一般只读、不修改。
- 不属于 AgentState,不会写入 checkpoint,服务重启后配置不会自动恢复(靠代码重新注入)。
- 同一个中间件实例被所有对话线程共用;绝对不能存放 plan / todos 这类线程独有的业务数据,并发会跨会话串数据。
- ✅适用场景:全局固定 system prompt、调用超时、模型后端地址、全局日志标签、全局功能开关。
📌官方还有一个更"正统"的位置:context_schema。
AgentMiddleware除了state_schema(持久化、跨调用保留)之外,还支持context_schema——官方定义是「只读、不在多次调用之间持久化」的运行时上下文,通常配合runtime.context使用。也就是说,配置类数据其实有两档:
| 位置 | 粒度 | 是否持久化 | 适用 |
|---|---|---|---|
context_schema+runtime.context | 单次 invoke 的运行时上下文 | ❌ | 调用时才确定、只读的上下文(如当前用户身份、trace_id) |
中间件实例self.xxx | 进程级常量 | ❌ | 服务启动时就固定、全进程共享的配置(模型地址、超时) |
两者都不进 checkpoint,区别只在生命周期粒度和是否由框架注入读取。
四、Reducer 合并规则补充(仅针对 AgentState 内字段)
Reducer 用于多处同时更新同一个 state 字段时,定义新旧值的合并逻辑,装配阶段绑定到字段。
- 覆盖式(
plan默认):新值直接替换旧值,适合"整体方案"这类以最新为准的字段。 - 追加式(
todos自定义merge_todos):新旧列表拼接,适合持续新增任务的任务清单。
中间件实例属性self.xxx不存在 reducer,它不属于状态合并体系。五、关键边界与易错点
| # | 易错点 | 正确认知 |
|---|---|---|
| 1 | 把 Schema 当成运行时数据 | Schema 是装配期静态图纸,定义字段、类型、reducer;State 实例才是运行时真实业务数据,每个 thread 独立一份 |
| 2 | 直接改request.state写状态 | 不能改这个视图对象;应通过钩子返回{key: value}或Command(update={})写入 |
| 3 | 把state_schema写进__init__ | 去中心化模式下state_schema必须是类属性,放到__init__里框架识别不到 |
| 4 | 把 plan / todos 塞进self.xxx | 业务动态数据严禁放实例属性:并发串数据 + 重启丢失,双重事故 |
| 5 | 运行期修改实例属性当配置中心 | 实例属性只适合只读静态全局配置,运行期尽量不要改动 |
六、选型决策建议
- 数据是单会话独有的动态业务数据,需要持久化保存:
优先选择去中心化middleware.state_schema声明(高内聚、无隐身字段);简单小型 Agent 可选用中心化ThreadState。
- 数据是全服务共享、装配后基本不变的静态配置:
使用中间件实例self.xxx,装配时注入。
判断树:
这份数据会随对话变化吗? ├─ 会 → 需要跨轮次恢复吗? │ ├─ 需要 → middleware.state_schema(或小型 Agent 用 ThreadState) │ └─ 不需要 → 仍是 state_schema,只是不依赖 checkpoint └─ 不会 → 所有会话共用同一份吗? ├─ 是 → 中间件实例 self.xxx(装配注入) └─ 否 → 重新审视设计,多半是伪需求七、配套测试
分界碑拓扑测试test_graph_topology_boundary,验证去中心化 schema 拼装逻辑:
- 装配阶段所有 middleware 的
state_schema正确归集合并; - reducer 合并逻辑符合预期;
- 声明字段成功纳入 checkpoint 快照;
- 中间件钩子读写状态的边界符合契约。
八、一句话总结
线程级活数据进 state_schema(进 checkpoint、按 thread 隔离),进程级死配置进 self.xxx(不持久化、全局共享)。中间那条线一旦踩错,轻则状态丢失,重则并发串数据——而且这两种故障都很难在单线程调试时复现。