AgentScope 2.0 源码解析系列第 十二 篇 state 模块
本文是 AgentScope 2.0 源码解析系列 第 12 篇 / 共 12 篇(state 模块)。
已发篇目(按依赖顺序,附一句话结论):
- 第 1 篇 Agent:用一套机制消化 LLM 输出和工具执行的双重不确定性
- 第 2 篇 Event:26 种事件类型是整个框架流式输出、中间件拦截、协议转换的通信骨架
- 第 3 篇 Message:把 Agent 之间流动的所有信息统一成 Msg + ContentBlock
- 第 4 篇 Model:用模板方法 + 钩子把九家大模型 API 归一成一个可调用对象
- 第 5 篇 Tool:Python 函数、MCP 工具、内置工具全部归一到 ToolBase 一个协议
- 第 6 篇 Permission:用一个 bypass_immune 布尔位在数据层面给安全栅栏划界
- 第 7 篇 Middleware:把"定制 Agent"从继承重写改成挂中间件,6 个 Hook 接住所有变化点
- 第 8 篇 RAG:5 阶段流水线 + 每阶段一个不变量数据类型 + Section 硬边界防越界
- 第 9 篇 Workspace:把"在哪儿执行"和"执行什么"解耦,网络可达性难题关进进程内网关
- 第 10 篇 MCP:一个布尔位 + 四个正交生命周期方法统一三套传输、两种用法
- 第 11 篇 Skill:把"什么是 skill"抽象成最小接口,脏活全部下沉到 workspace 层
- 第 12 篇 state(本文):一个 Pydantic model + 四个分桶 context,接住 Agent 的全部运行时状态
下一篇:可能是 formatter(消息格式化适配层)或 app 层(Web 服务与调度)。关注追更,系列更完后会整理总目录。
state 模块只有 1 个核心类 AgentState(state/_state.py:149),是BaseModel,不是普通 class——这一个选择让它天然支持状态持久化(存盘/恢复/克隆),派生子 Agent 时直接构造一个新实例全部状态按关注点分四个桶: permission_context/tool_context/tasks_context/middle_context各自独立——权限规则不污染工具缓存、中间件状态不进持久化主路径双 ID 体系贯穿会话状态始终: session_id(会话级,跨 reply 不变)+reply_id(单轮级,_agent.py:732每轮_generate_id()重新生成)——append_context靠reply_id判断"要不要复用尾消息",这是流式增量写入的开关context+summary双轨喂 LLM:context是未压缩原文、summary是压缩摘要,_call_model(_agent.py:2293)把 summary 作为 user 消息拼在最前——这承接第 7 篇 Middleware 的on_compress_contextmiddle_context是个dict[str, Any]的逃生舱口:中间件靠middleware_key自分桶,第 7 篇的 ReplyBudgetControlMiddleware 用middle_context[middleware_key][reply_id]二级结构存每轮预算——实例无状态、跨 Agent 可共享state 模块本身不负责持久化——它只是个可序列化 model,存盘/恢复由上层 app 的存储层( app/storage/_model/_session.py)做,职责清晰分离
适合谁读:在搭需要会话恢复、多 Agent 状态隔离、或想给 Agent 加自定义运行时状态的 Agent 开发者。
预计阅读:主线约 8 分钟(字段表较多,附录字段表另需 4 分钟)。
state 模块用「一个 Pydantic model + 按关注点分桶的子 context」接住了 Agent 的全部运行时状态——每个关注点(权限/工具/任务/中间件)一个独立子 model,互不污染,整体又是一个可序列化的 BaseModel 天然支持存盘恢复。这套「按关注点分桶的可组合状态」思路,搬到任何需要"多维度运行时状态且要持久化"的服务都成立——你的应用状态不该是一个 God Object,而该是几个职责单一的小 model 组合。
一、这个模块到底在解决什么问题?
前面十一篇里,self.state.xxx 出现过无数次:
第 1 篇讲 Agent 的 ReAct 循环,输入是 self.state.context第 6 篇讲权限, AgentState.permission_context挂在 state 上(_state.py:170)第 7 篇讲中间件,预算中间件把每轮花费塞进 self.state.middle_context第 5 篇讲工具,Read/Edit 读 self.state.tool_context的缓存
但没有一篇正面讲过 state 模块本身。读者跟着读了十一篇,对 self.state 的认知是碎片化的。这一篇就来填这个贯穿全系列的缺口。
state 模块(src/agentscope/state/,只有 3 个文件、约 250 行)要回答的核心问题是:
一个 Agent 跑起来,它的"运行时状态"到底长什么样、怎么组织、为什么这么组织?
"运行时状态"这个词听起来抽象,拆开看至少包括:
对话历史:用户和 Agent 说了什么(要喂给 LLM) 压缩摘要:历史太长时压缩出来的 summary(也要喂给 LLM) 当前轮次:ReAct 循环走到第几轮、这轮的 reply 编号 权限规则:用户配的 allow/deny/ask 规则、当前模式 工具缓存:Read 过的文件内容(避免重复读盘)、激活的工具组 任务列表:Agent 正在追踪的 todo 任务 中间件状态:预算中间件记的累计花费、其它中间件的跨轮状态
如果把这些全塞进一个扁平的大对象,会得到一个上帝对象(God Object):权限改一下可能影响工具缓存、中间件状态混进持久化主路径、字段越加越多没人知道边界在哪。
state 模块的做法是:一个 AgentState 总 model,下面按关注点分四个子 context,每个子 context 是一个独立的 BaseModel,职责单一、互不污染。整个 AgentState 自己也是个 BaseModel,一行 model_dump() 就能存盘,一行 model_validate(...) 就能恢复。
一句话概括:state 模块用"组合优于继承/平铺"的方式,把 Agent 的全部运行时状态组织成一个可持久化、可克隆、关注点分离的 Pydantic model。
二、这个模块在整个框架中的位置
先看它和谁打交道:
输入:Agent 构造时拿到一个 AgentState 实例(默认空,或从存储层恢复),运行过程中所有模块往里面读写。
输出:无显式输出——它是被持有、被读写的状态容器。
它依赖谁(上游):
message:context是list[Msg],summary也能装TextBlock | DataBlockpermission:permission_context: PermissionContext直接内嵌(第 6 篇的核心数据结构)
谁依赖它(下游):这是 state 模块的关键特征——几乎整个框架都依赖它。实测 grep "AgentState" 命中 src 内 25 个文件,是除 Msg 外被引用最多的类型。主要消费方:
agent/_agent.py | context/summary 喂 LLM、轮换 reply_id、调 append_context/has_awaiting_tool_calls | |
tool/_builtin/_read.py_edit.py | tool_context | get_cachecache_file(文件读缓存) |
tool/_toolkit.py | tool_context.activated_groups | |
middleware/_budget.py | middle_context | |
middleware/_rag.py_longterm_memory/... | append_context | on_reasoning 钩子里往 context 追加 block |
app/_session.pystorage/_model/_session.py |
注意最后一行——state 模块自己不持久化。它提供"可序列化"的能力(因为是 BaseModel),但"什么时候存、存到哪"是上层 app 的存储层决定的。这种职责分离是 state 模块能保持极简的关键:它不碰文件系统、不碰数据库,只管"我是个能被序列化的状态容器"。
三、为什么这样设计?
state 模块在每个关键岔路口都做了克制的选择。
决策 1:用 Pydantic BaseModel,而不是普通 class
AgentState(_state.py:149)继承 BaseModel,所有字段都是带类型的 Pydantic 字段。这一个选择带来三个好处:
天然序列化: model_dump()一行转 dict、model_validate(data)一行从 dict 恢复。存盘/加载/跨进程传输零额外代码派生子 Agent 时直接构造:app 层的调度器( app/_manager/_scheduler/_scheduler_manager.py)给定时任务子 Agent 构造新的AgentState,直接传字段即可类型校验白送:构造时字段类型不对会直接报错,不用手写校验
这和第 6 篇 PermissionContext 用 BaseModel(第 6 篇决策 6)是同一套思路的延续——框架里凡是"需要持久化或跨边界传递的状态对象",一律用 Pydantic model。
决策 2:按关注点分桶,而不是平铺
这是 state 模块最值得学的点。看 AgentState 的字段定义(_state.py:149-192),用注释明确分了四桶:
前三桶(权限/工具/任务)是强类型子 model——PermissionContext(第 6 篇讲过)、ToolContext、TaskContext 各自是独立的 BaseModel,字段、方法都封装在自己里面。AgentState 只是持有它们的引用。
为什么不全平铺成一个扁平的大 model?因为关注点会膨胀。今天有权限规则和工具缓存,明天可能加 tracing 状态、审计日志。如果平铺,每加一类状态就在 AgentState 上堆字段,很快变成没人敢动的上帝对象。分桶后,新关注点要么进对应的子 model,要么像 middle_context 那样走逃生舱口(见决策 4),AgentState 本身的字段表保持稳定。
决策 3:双 ID 体系——session_id 和 reply_id
这是理解 ReAct 循环如何往 context 写数据的关键。两个字段默认值都是 _generate_id()(_state.py:152/161),但生命周期完全不同:
session_id:会话级。一个会话从开始到结束不变,用来标识"这是哪次对话"。存盘恢复时,同一个 session 的 state 共享同一个session_idreply_id:单轮级。每轮 reply 开始时重新生成(_agent.py:732:self.state.reply_id = _generate_id())
reply_id 的作用藏在 append_context(_state.py:194-222)里。这个方法是 Agent 流式写入 context 的主入口,它的逻辑是:
这个"复用尾消息 vs 新建"的判断,是流式增量写入的开关。一轮 reply 里 Agent 会产出多个事件(reasoning、tool_call、tool_result、text),它们应该拼在同一条 assistant 消息里(因为同属一轮回复)。靠 reply_id 相等来识别"还在同一轮",就能把后续 block 追加到前面那条消息上,而不是每来一个 block 新建一条。
cur_iter 是 ReAct 循环的轮次计数器(第 1 篇讲过的思考-行动循环),和 reply_id 是不同维度——一轮 reply 内部可能经历多个思考-行动 iteration。
决策 4:middle_context 是逃生舱口
前三桶(permission/tool/tasks)是框架预定义的关注点。但中间件是开放的——用户会挂各种自定义中间件,每种都可能想存自己的状态。框架不可能预知所有中间件需要什么状态字段。
middle_context: dict[str, Any](_state.py:190)就是给这种情况留的逃生舱口。它的结构是二级字典:外层 key 是中间件自己的 middleware_key(唯一标识),内层是任意结构。
看第 7 篇的 ReplyBudgetControlMiddleware 怎么用它(middleware/_budget.py:128-143):
注意它用 reply_id 做内层 key——这样同一个中间件实例跨多轮 reply 的状态互不干扰。中间件实例本身无状态(第 7 篇强调过这点),所有运行时状态都寄存在 middle_context 里。这让一个中间件实例能安全地被多个 Agent 共享。
逃生舱口的好处是开放扩展、封闭修改:加新中间件不用改 AgentState 的字段表,中间件自己往 middle_context 塞就行。代价是丢失了类型安全(dict[str, Any])——这是刻意的权衡:框架预定义的强类型桶负责"我知道我需要什么状态"的场景,逃生舱口负责"我不知道你会需要什么"的场景。
决策 5:append_context 和 has_awaiting_tool_calls——状态机查询封进 state
AgentState 不只是个数据袋,它还封装了两个基于 context 的状态查询:
append_context(_state.py:194):上面讲过,流式增量写入has_awaiting_tool_calls(_state.py:224):查"尾消息里有没有还在等待外部响应的工具调用"
has_awaiting_tool_calls 值得细看,它把第 3 篇的 ToolCallState 状态机和第 5 篇的工具调用流程收口到一起(_state.py:244-250):
这个查询在 _reply_impl(_agent.py,codegraph 显示是唯一调用方)里用来判断"这轮回复要不要继续等"。把这种基于 context 的状态判断封进 state 自己,而不是散落在 agent 主循环里,是因为这些判断的依据完全在 state 内部(context 里的 block 状态)——按"信息专家"原则,应该由持有这些数据的对象来回答。
四、跟着我阅读源码
文件优先级(从必读到可跳过):
_state.py(必读)——整个模块的核心,AgentState+ 三个子 context 全在这。文章篇幅主要花在这个文件。_task.py(按需查阅)——Task数据类,逻辑简单,知道它是个 todo 项即可。__init__.py——只导出AgentState、TaskContext、Task三个名字。
第一次读只需精读 AgentState 一张字段表(下面 4.1),ToolContext / TaskContext / Task 三张按需查阅,不影响理解主线。
4.1 AgentState(核心,必读)
定义于 _state.py:149-192。整个框架的运行时状态容器。字段按四桶组织:
session_id | str | _generate_id() | |
summary | str \| list[TextBlock \| DataBlock] | "" | _call_model(_agent.py:2293)拼在 context 最前喂 LLM |
context | list[Msg] | [] | |
reply_id | str | _generate_id() | _agent.py:732 每轮 reply 重新生成 |
cur_iter | int | 0 | |
permission_context | PermissionContext | PermissionContext() | |
tool_context | ToolContext | ToolContext() | |
tasks_context | TaskContext | TaskContext() | |
middle_context | dict[str, Any] | {} | middleware_key 二级分桶 |
讲透三个关键字段:
reply_id 是流式写入的开关。append_context 靠它判断是追加到尾消息还是新建消息(见决策 3)。一轮 reply 内产出的所有 block 共享同一个 reply_id,因此能拼进同一条 assistant 消息。
summary 和 context 是双轨。context 是原始历史,越来越长;summary 是压缩后的摘要。_call_model(_agent.py:2293-2295)把它们拼起来喂 LLM——summary 作为一条 user 消息放在最前面。当 context 超长时,第 7 篇的 on_compress_context 钩子会把旧消息压进 summary,腾出 context 空间(_agent.py:536:self.state.summary = new_summary)。这是 state 提供数据结构、middleware 提供压缩策略的分工。
middle_context 的二级结构。外层 key 是中间件的 middleware_key(字符串),内层结构由中间件自定义。ReplyBudgetControlMiddleware 用 middle_context[key][reply_id] 存每轮花费(_budget.py:130/143),reply 结束后还会清理(_budget.py:134:pop(reply_id))。注意整个 middle_context 是个 dict[str, Any]——这是刻意丢掉类型安全换来的扩展性(见决策 4)。
4.2 ToolContext(按需查阅)
定义于 _state.py:32-139。工具相关的运行时状态,自带方法(和纯数据的 PermissionContext 不同):
max_cache_files | int | 100 | |
max_cache_bytes | float | 25000 | |
read_file_cache | list[ReadCacheEntry] | [] | |
activated_groups | list[str] | [] |
三个异步方法:
get_cache(file_path)(:46):按 mtime 校验缓存是否过期,过期就剔除返回 Nonecache_file(file_path, lines)(:74):带 LRU 淘汰(按文件数和字节数双上限)的缓存写入clean_file_cache(reserved_paths)(:122):只保留指定路径的缓存,其余剔除——_agent.py:2100在压缩上下文时调它清掉不再需要的缓存
ReadCacheEntry(:23)是缓存条目,字段:lines / updated_at(文件 mtime)/ bytes(KB)/ file_path。缓存有效性靠 mtime 比对(:62:updated_at == entry.updated_at),文件被改过就失效——和第 11 篇 Skill 的 mtime 缓存是同一思路。
activated_groups 被 _toolkit.py:250/564 读取,用来过滤"当前哪些工具组可用"。第 5 篇讲过的 ResetTools 元工具就是改这个字段来激活/停用工具组。
4.3 TaskContext(按需查阅)
定义于 _state.py:142-146。极简:
tasks | list[Task] | [] |
4.4 Task(按需查阅)
定义于 _task.py:11-39。一个 todo 项:
subject | str | ||
description | str | ||
metadata | dict[str, Any] | ||
created_at | str | datetime.now().isoformat() | |
state | Literal["pending","in_progress","completed"] | "pending" | |
id | str | _generate_id() | |
owner | str \| None | None | |
blocks | list[str] | [] | |
blocked_by | list[str] | [] |
blocks / blocked_by 这对字段实现了任务间的依赖关系——这是个轻量的 DAG 雏形,目前由 _task/ 子包的工具操作。
五、代码到底是怎么运行起来的?
用一条具体调用走完全程。场景:Agent 已经跑过几轮,现在用户发了一条新消息,触发新一轮 reply。我们跟着 reply_id 和 context 的变化看 state 怎么被读写。
步骤 0:新一轮 reply 开始,轮换 reply_id
reply → _reply → _reply_impl(_agent.py)。在 _reply_impl 里,第一件事就是轮换 reply_id(_agent.py:732):
这一行是双 ID 体系运转的起点。从这一刻起,本轮产出的所有 block 都会带这个新 reply_id。
步骤 1:构造喂给 LLM 的输入(读 context + summary)
_call_model(_agent.py:2293)读取 state 拼输入:
这里能看到双轨怎么合作:summary 作为一条 user 消息垫在最前,context 跟在后面。如果第 7 篇的压缩中间件之前跑过,summary 里就已经有旧对话的浓缩,context 里只剩较新的消息。
步骤 2:Agent 流式产出,调 append_context 写入
Agent 在 ReAct 循环里产出 reasoning / tool_call / tool_result / text 等事件。这些事件最终通过 append_context(_state.py:194)落到 context 里。
第一次调 append_context(比如写入 reasoning block)时,尾消息的 id 还是上一轮的 reply_id(或 context 为空),不等于本轮 reply_id,于是走 else 分支新建一条 assistant 消息(:215-222),id 设为本轮 reply_id。
本轮后续每次调 append_context,尾消息的 id 已经等于本轮 reply_id 了(:210 命中),于是走 if 分支往这条消息追加 block(:212)。这就是一轮 reply 的所有产出拼在同一条消息里的机制。
补充:中间件也能调 append_context。codegraph 显示 append_context 有两个调用方在中间件里——_longterm_memory/_agentic_memory/_middleware.py 和 _rag.py,它们在 on_reasoning 钩子里往 context 追加检索到的记忆/RAG 片段。
步骤 3:工具调用读写 tool_context
如果本轮 Agent 决定调 Read 工具,_read.py:229 会读缓存:
命中就直接用缓存的 lines,不命中就读盘后 cache_file(_read.py:244)写回缓存。Edit 工具(_edit.py:298)也走同样的缓存。
工具组过滤发生在 _toolkit.py:250——读 activated_groups 决定哪些工具对 LLM 可见。
步骤 4:预算中间件读写 middle_context
如果挂了 ReplyBudgetControlMiddleware(第 7 篇),它会在 on_model_call 钩子里累计花费(_budget.py:143):
预算耗尽时,它把传给下游的 tool_choice 改成 none(第 7 篇讲过的 execute_chain 改写参数),阻止后续工具调用。reply 结束后清理本轮条目(_budget.py:134)。
步骤 5:判断要不要继续等——has_awaiting_tool_calls
如果本轮有工具调用进入了 ASKING(等用户确认)或 SUBMITTED(外部执行未回),_reply_impl 调 has_awaiting_tool_calls(_state.py:224)。返回 True 时,Agent 本轮就此挂起,等外部信号回来再续。
步骤 6:持久化(不在 state 模块内)
一轮 reply 结束后,存盘由上层 app 的存储层做(app/_service/_session.py、storage/_model/_session.py)——它调 AgentState 的序列化能力(BaseModel 自带)把整个 state 落库。state 模块本身不感知"我被存了"。
六、如何开始调试源码
如果你要调试 Agent 的状态相关问题,按这个顺序下断点:
第一次:看 reply_id 有没有正确轮换
断点 _agent.py:732(self.state.reply_id = _generate_id())。如果 append_context 一直新建消息而不是追加,根因往往是 reply_id 没轮换或被意外重置——观察这里的值。
第二次:看 context 到底装了什么
断点 _agent.py:2293(_call_model 拼 summary 处)。观察 self.state.context 的每条消息的 id、role、name,以及 self.state.summary。如果你的对话历史不对,多半在这里就能看出来——比如本该一轮的消息被拆成了多条(reply_id 不一致),或 summary 没拼上。
第三次:看工具缓存有没有命中
断点 _state.py:57(get_cache 的循环里)。观察 file_path、entry.updated_at 和 await aiofiles.os.path.getmtime(file_path) 的返回值。缓存总是 miss,多半是文件被频繁改动导致 mtime 总对不上,或 max_cache_files/max_cache_bytes 太小被 LRU 淘汰了。
第四次:看中间件状态
断点 _budget.py:181(middle_context.get(...))。观察 agent.state.middle_context 的结构。如果你的中间件状态丢失,检查 middleware_key 是否稳定(别每次构造新字符串),以及 reply 结束有没有误删。
self.state(AgentState 实例)。状态相关的 bug 99% 能从这个对象的字段值看出来——重点看 context 的消息结构、reply_id 的一致性、四个子 context 的内容。
七、如何扩展这个模块
✅ 应该改(推荐路径)
加自定义中间件状态:往 middle_context[middleware_key] 里塞,外层 key 用中间件类名或固定字符串。这是中间件存跨轮状态的标准做法,不用动 AgentState 的字段表。
切换工具组:改 tool_context.activated_groups。第 5 篇的 ResetTools 元工具就是这么做的——往这个列表加/删组名,_toolkit 下次过滤时就生效。
清缓存:调 tool_context.clean_file_cache(reserved_paths),只保留还在 context 里的文件缓存。_agent.py:2100 在压缩上下文时就是这么做的。
⚠️ 不应该改(有更优替代)
想加新的强类型状态桶:不要直接往 AgentState 堆字段。先问自己——这个状态是不是某个已有关注点?是就进对应子 model(比如工具相关进 ToolContext);是全新的、用户可扩展的关注点,就走 middle_context。只有"框架核心、所有 Agent 都有、需要强类型"的状态才值得加第四个强类型桶。
想给 ToolContext 加新方法:ToolContext 已经自带缓存方法,逻辑自洽。要加新功能考虑清楚是不是工具相关的——别把它变成杂货铺。
🚫 千万不要改(动了会破坏不变量)
append_context 的尾消息复用判断(_state.py:206-211):那四个条件(context 非空 + role=assistant + name 相同 + id==reply_id)是"同一轮 reply 的 block 拼进同一条消息"的保证。改坏任何一个,要么一轮 reply 被拆成多条消息(LLM 看到的对话结构错乱),要么不同轮的 block 被错误合并。
reply_id 的轮换时机(_agent.py:732):必须在每轮 reply 开始时重新生成。如果在轮中间重置,append_context 会误判"要新建消息",把一轮 reply 拆碎;如果从不重置,所有轮次的 block 全堆进第一条消息。
has_awaiting_tool_calls 的状态判断(_state.py:244-250):它基于 ToolCallState.ASKING 和 SUBMITTED + 无匹配 result 两个条件。放宽它(比如把 ASKING 也当成"已完成"),Agent 会在工具还在等用户确认时就结束 reply,挂起的工具调用永远等不到结果。
新增一个强类型状态桶(谨慎)
如果你确实需要框架级的新状态桶(比如 tracing 状态),步骤是:
定义新的 BaseModel子类(仿照ToolContext)在 AgentState加一个字段(_state.py,带注释分桶)确认它的序列化/反序列化符合预期( BaseModel默认支持,但自定义类型要注意)想清楚它要不要随 model_dump()持久化——有些运行时状态(如锁、连接池)不该落盘,需要在序列化时排除
第 4 步最容易漏——AgentState 默认整体序列化,新加的字段会自动进存盘。如果新桶装的是不该持久化的东西(如临时连接、运行时锁),要用 Pydantic 的 Field(exclude=True) 或 model_config 排除。
八、本模块最值得学习的设计
1. 按关注点分桶的可组合状态
这是 state 模块给所有"需要多维运行时状态"的服务做的示范。AgentState 不是个上帝对象,而是几个职责单一的子 model 的组合:权限归 PermissionContext、工具归 ToolContext、任务归 TaskContext,中间件走逃生舱口。
好处是每个子 model 可以独立演进、独立测试。ToolContext 加缓存方法不影响权限逻辑;新增中间件不用碰任何强类型桶。而整体又是一个 BaseModel,对外是一个干净的序列化单元。
这套思路可以原样搬到:一个 Web 服务的请求上下文(auth/storage/cache/tracing 各一桶)、一个工作流引擎的执行上下文(variables/locks/history 各一桶)——任何"状态是多维的、又要整体持久化"的场景。
2. 强类型桶 + 逃生舱口的二元结构
前三桶是强类型(PermissionContext 等),第四桶 middle_context 是 dict[str, Any]。这不是设计不彻底,而是刻意的开放-封闭权衡:
强类型桶服务"框架知道需要什么"的场景——类型安全、IDE 补全、重构友好 逃生舱口服务"框架不知道用户会需要什么"的场景——开放扩展、零修改
这种"核心强类型 + 边缘逃生舱口"的二元结构,比"全强类型"(扩展性差)和"全 dict"(类型不安全)都好。很多框架的状态对象要么僵化(加个字段要改框架),要么全靠 dict(写起来全靠记忆)。AgentScope 给了第三条路。
3. 双 ID 体系驱动流式写入
session_id 和 reply_id 的双 ID 设计,把"会话级"和"单轮级"两个时间维度编码进状态本身。append_context 靠 reply_id 做复用判断,实现"一轮 reply 的所有产出拼进同一条消息"——这个机制完全由数据(ID 是否相等)驱动,不需要额外的"当前在写哪条消息"的状态标志。
这个思路可以搬到任何流式/增量写入的场景:用 ID 区分"同一批次的追加"和"新批次的开始",比维护一个显式的 current_batch 指针更可靠(ID 是不可变的,指针是可变的)。
4. 状态查询封进状态对象本身
has_awaiting_tool_calls 这种"基于 context 的状态判断"被封装进 AgentState,而不是散落在 agent 主循环。依据是"信息专家"原则——判断的依据(context 里的 block 状态)完全在 state 内部,就该由 state 来回答。
这避免了 agent 主循环里堆满"查尾消息有没有 ASKING""查 tool_call 有没有匹配 result"这类碎片逻辑。主循环只问一个布尔问题,state 负责回答。状态对象不只是一个数据袋,还是一个会回答问题的对象。
一个值得注意的细节:middle_context 的序列化
middle_context: dict[str, Any] 会随 AgentState 整体序列化——这意味着中间件塞进去的状态也会落盘。这对大部分中间件是合理的(预算累计要跨轮保存),但如果某个中间件往里塞了不可序列化的东西(文件句柄、锁、连接),存盘时会炸。这是逃生舱口的代价:自由换来了责任。目前框架里用 middle_context 的中间件(_budget.py)塞的都是可序列化的数字/字典,没有踩这个坑。
九、阅读建议
源码阅读顺序:
先读 _state.py:149-192(AgentState字段定义)——扫一眼四桶结构,建立"状态分了哪几类"的整体认知。注意那些分隔注释,它们是分桶意图的明证。再读 append_context(:194-222)——理解reply_id怎么驱动流式写入。这是 state 模块唯一有非平凡逻辑的方法。然后读 has_awaiting_tool_calls(:224-251)——看状态查询怎么收口第 3 篇的状态机。注意它只看尾消息、只看特定 name。读 ToolContext的三个方法(:46-139)——这是子 model 自带方法(不是纯数据)的范例,LRU 双上限淘汰值得看。扫 _task.py——知道 Task 的 DAG 雏形(blocks/blocked_by)即可,逻辑简单。最后看消费方(可选): _agent.py:732(reply_id 轮换)、_agent.py:2293(summary 拼接)、_budget.py:128-143(middle_context 用法)。这是把 state 放回框架里理解它的读写时机。
可以跳过的地方:ReadCacheEntry(:23)是个纯数据类,4 个字段,扫一眼即可。TaskContext(:142)只有一个字段,不用停。
十、阅读完成以后
读完这篇,你应该能回答:
为什么需要这个模块:Agent 的运行时状态是多维的(对话/权限/工具/任务/中间件),需要一个组织方式让它们互不污染、又能整体持久化。state 模块用分桶组合 + Pydantic model 回答了这个问题。 为什么这样设计:Pydantic model 换来天然序列化(决策 1)、按关注点分桶换来可扩展性(决策 2)、双 ID 体系换来流式写入(决策 3)、逃生舱口换来开放扩展(决策 4)、状态查询封装换来主循环简洁(决策 5)。 代码怎么运行:每轮 reply 开始轮换 reply_id→_call_model读summary+context喂 LLM → Agent 产出的 block 经append_context按reply_id拼进同一条消息 → 工具/中间件读写各自的子 context → reply 结束后上层存储层把整个 state 落库。如果要改,改哪里:加中间件状态用 middle_context;切工具组改activated_groups;清缓存调clean_file_cache。不要碰append_context的复用判断、reply_id的轮换时机、has_awaiting_tool_calls的状态判断。
真正应该带走的:状态对象不该是上帝对象,而该是几个职责单一的小 model 的组合;核心关注点用强类型,边缘扩展用逃生舱口——这套「按关注点分桶的可组合状态」,是 state 模块给所有需要多维运行时状态的服务做的示范。
你写 Agent 的时候,运行时状态是怎么存的?是塞进一个大 dict,还是拆成了几个小对象?有没有踩过"状态越加越多、最后谁都不敢动"的坑?
想深入的同学再想一个:如果你要给 Agent 加一个"tracing 状态"桶(记录每次 LLM 调用的耗时、token 数),你会做成强类型子 model,还是塞进 middle_context?两种选择各自的代价是什么?
追更:系列持续更新,关注不错过。
裂变:转发到 Agent / LLM 应用开发群或朋友圈,帮同样在读 AgentScope 源码的朋友省时间。
夜雨聆风