状态总线:控制的前提条件
Agent 状态的三层存储:内存变量、Agent Flag(文件系统)和外部数据库
06 - 状态总线
控制需要信息
上一章讨论的控制机制面临一个根本问题:它依赖于对系统状态的观察。离心调速器需要感知转速,文件修改拦截器需要知道哪些文件被读取过。没有准确、及时、可访问的状态信息,控制机制就是盲目的。
状态总线(State Bus)就是解决这个问题的:它定义了状态在哪里存储、如何更新、向谁暴露。一个设计良好的状态总线使控制机制能够获得所需的传感器读数,从而做出准确的干预决策。
需要记录什么
一个 Agent 会话产生的状态可以归纳为三类:
消息列表(Message History)
对话的完整记录,包括用户输入、模型输出、工具调用和结果。这是 Agent 的"记忆",决定了它能多大程度上理解上下文。
统计指标(Metrics)
执行过程的量化数据:
- Token 消耗量(输入/输出/总计)
- Step 执行次数
- 工具调用频率分布
- 平均响应延迟
- 错误率和重试次数
这些指标既用于监控和告警,也用于控制决策——例如当 Token 消耗接近预算上限时触发熔断。
运行时状态(Runtime State)
会话的动态信息:
- 会话开始时间
- 已执行的工具序列
- 访问过的文件集合
- 当前正在处理的任务阶段
- 用户预置的配置和标志位
运行时状态是控制机制的主要输入。它变化频繁、时效性强,需要高效的读写路径。
状态的三层存储
根据访问频率、持久化需求和共享范围,状态可以分布在三个层级:
内存变量(Memory)
存储在进程内存中的数据结构。
特点:
- 高频更新,纳秒级访问
- 时效性强,会话结束即丢失
- 更新开销极小
- 仅当前进程可见
适用场景:消息列表的当前窗口、最近的工具调用结果、临时缓存。
局限:进程崩溃即丢失;无法跨进程共享;外部调试工具无法直接读取。
本地文件系统(Local Filesystem)
存储在工作目录或用户目录的文件中。
特点:
- 中频更新,毫秒级访问
- 影响范围限于当前机器
- 可被其他进程读取或修改
- survives 进程重启
适用场景:.agents/flags/ 目录下的标志文件、执行日志、检查点快照、配置文件。
优势:人类可读(文本文件);可被文件监听工具实时追踪;Git 可追踪变更。
局限:单机限制;并发写入需要锁机制;大文件 I/O 可能成为瓶颈。
外部系统(External Systems)
存储在远程服务中:GitHub Issue、Jira Ticket、数据库、对象存储。
特点:
- 低频更新,百毫秒到秒级延迟
- 人类可读,易于分发
- API 复杂,依赖基础设施
- 有时具有不可逆后果(如发送邮件、创建票据)
适用场景:跨 Session 的持久化记忆、团队协作状态、审计日志、用户通知。
优势:天然支持协作;内置权限管理;可与其他工作流集成。
代价:网络延迟;API 限流;服务可用性依赖。
状态需要被暴露
Agent 产品往往只提供"最大公约数"特性——那些对大多数用户都有用的通用功能。但现实是,最有效的优化通常是领域特定的,甚至是组织特定、项目特定的。
这意味着你需要构建自己的控制器。而高精度控制器的实现,依赖于高精度、可访问的传感器读数。
如果状态总线只暴露 Token 消耗和 Step 次数,你能做的控制就很有限。但如果你能够:
- 实时读取当前上下文的主题分布
- 追踪 Agent 访问过的文件路径
- 检测 Agent 是否在重复相似的推理模式
- 获取上一次会话遗留的待办事项
你就可以构建更精细的控制策略。
状态总线的设计目标,是让这些原本隐藏在框架内部的信息,以结构化、可访问的方式暴露出来。
案例:Next.js 新特性引导
假设你的团队正在使用 Next.js,最新版本引入了新的路由语法。你希望 Agent 在处理相关代码时使用新语法,而不是沿用旧模式。
第一版设计:拦截式提示
你设计了一个控制器,在 pre-tool-call 钩子中检查:
if 目标文件属于 Next.js 项目 and 操作类型 == "修改":
if "新路由语法" not in 已读文档列表:
阻断操作
提示: "请先阅读 Next.js 新特性文档: /docs/nextjs-routing.md"
这个设计的意图是好的:强制 Agent 在修改代码前了解新语法。但实际运行中出现了问题——死锁。
Agent 被阻断后尝试读取文档,但读完文档后的下一步操作仍然是修改文件,再次触发拦截。由于"已读文档列表"只在成功修改后才更新(或者根本没有维护),拦截条件持续成立,Agent 陷入循环。
问题分析
死锁的根源在于控制器的状态设计:
- 缺乏对"提示已给出"的追踪
- 拦截条件过于简单,没有考虑会话的阶段
- 没有区分"第一次拦截"和"重复拦截"
优化设计:基于 Flag 的状态管理
改进后的方案引入两个 Flag:
# 在会话初始化时设置
flags["nextjs_docs_visited"] = false
flags["routing_hint_given"] = false
# 在 pre-tool-call 钩子中
def on_file_modify(target_file):
if 不是 Next.js 相关文件:
return ALLOW
if flags["nextjs_docs_visited"]:
return ALLOW
if not flags["routing_hint_given"]:
flags["routing_hint_given"] = true
return BLOCK_WITH_HINT("请先阅读 /docs/nextjs-routing.md")
# 提示已给过,但仍然没有访问文档,允许继续(避免死锁)
return ALLOW
同时,在文档被访问后更新 Flag:
def on_file_read(target_file):
if target_file == "/docs/nextjs-routing.md":
flags["nextjs_docs_visited"] = true
关键洞察
这个案例展示了状态总线的几个设计原则:
1. 控制器需要读写状态的能力
控制器不只是状态的消费者,也是生产者。routing_hint_given 这个 Flag 就是由控制器设置的,用于记录"我已经介入过了"。
2. 状态的生命周期需要明确
nextjs_docs_visited 是会话级别的(本次会话读过即可),而 routing_hint_given 是一次性的(只需要提示一次)。明确状态的生命周期,才能避免重复或遗漏。
3. 优雅降级防止死锁
当提示已给出但 Agent 仍未访问文档时,控制器选择放行而非持续拦截。这牺牲了"强制阅读"的约束,但避免了无限循环。在控制设计中,"避免伤害"往往比"强制执行"更重要。
4. 可观测性:内存状态对外部控制器不可见
在这个案例中,Flag 存储在内存中似乎就够了。但这里有一个关键的工程约束:内存状态对外部钩子是不可见的。
大多数 Agent 产品的架构是这样的:核心引擎维护着丰富的内存状态——消息列表、执行计划、中间结果、错误计数——但只向外部暴露有限的 API 或事件。钩子(Hooks)作为外部控制器,只能接收到框架选择披露的信息。
这造成了一种信息不对称:
- 构建者(Agent 产品的开发者)拥有完整的状态视图
- 使用者(用 Agent 产品构建工作流的工程师)只能看到冰山一角
结果就是,使用者无法设计精妙的控制策略,因为他们缺乏必要的传感器读数。他们知道 Agent 在"做事",但不知道它处于什么阶段、遇到了什么障碍、是否已经偏离目标。
解决方案:将状态持久化到文件系统
Agent 产品在设计上应该主动将关键状态写入文件系统,即使这些状态在内存中已经存在。这不是为了持久化(会话结束后可以丢弃),而是为了可观测性。
.agents/state/
├── session.json # 会话元数据:开始时间、目标描述
├── progress.json # 当前阶段、已完成的里程碑
├── context-window.json # Token 使用情况、窗口填充率
├── tool-usage.json # 工具调用历史、频率分布
└── flags/
├── stage # 当前阶段:investigate|plan|build|verify|done
├── last-update # 上次状态更新时间戳
└── constraints/ # 当前生效的约束条件
└── no-bash-write # 是否禁止写入操作
外部钩子通过读取这些文件,获得与 Agent 引擎近乎对等的状态视图。这为精妙控制提供了可能。
5. 被动 vs 主动维护状态
状态维护有两种模式:
被动模式:Agent 框架自动将内部状态同步到文件系统。使用者只需要读取,不需要干预。这种模式简单,但状态的语义由框架定义,使用者只能适配。
主动模式:使用者定义状态的语义,通过提示词或工具要求 Agent 主动更新状态。例如:
系统提示词:
你必须在开始每个阶段时更新 .agents/state/flags/stage 文件。
阶段定义:
- investigate:收集信息、理解问题
- plan:设计解决方案
- build:实施编码
- verify:测试验证
- done:任务完成
如果超过 20 个 Step 未更新 stage,我将提醒你维护状态。
主动模式的优势在于语义的灵活性。你可以定义"适合我的任务的阶段模型",而不是适配框架的通用模型。
案例扩展:基于 Stage 的权限控制
回到之前的 Next.js 案例,结合 Stage 状态,我们可以设计更精细的控制策略:
# 控制策略示例
stage = read_flag("stage")
step_count_since_update = current_step - read_flag("last-stage-update-step")
# 如果长时间未更新状态,提醒 Agent
if step_count_since_update > 20:
inject_hint("你已执行 20+ Step,请确认当前阶段并更新 stage flag")
# 根据阶段限制工具访问
if stage == "investigate":
allow_tools(["ReadFile", "Search", "FetchURL"])
block_tools(["WriteFile", "Bash"])
if stage == "plan":
allow_tools(["ReadFile", "WriteFile"]) # 可以写入设计文档
block_tools(["Bash"])
if stage == "build":
allow_tools(["ReadFile", "WriteFile", "Bash"])
# 但需要检查文件是否在读取列表中
if stage == "verify":
allow_tools(["Bash"]) # 运行测试
block_tools(["WriteFile"]) # 禁止修改代码,专注于测试
这个策略的核心假设是:Agent 在不同阶段应该有不同的权限边界。调查阶段不应该修改代码,构建阶段不应该无限制地执行任意 Bash 命令。通过 Stage Flag,我们将这个假设转化为可执行的规则。
关键洞察
状态总线不仅是"让框架披露信息",更是"让使用者定义观察视角"。被动模式降低使用门槛,主动模式释放定制潜力。优秀的 Agent 产品应该同时支持两者:框架提供基础状态同步,使用者可以叠加自定义的语义层。
最终,控制策略的精妙程度,取决于状态总线的开放程度。
状态总线的接口
一个实用的状态总线应该提供统一的接口,屏蔽底层存储的差异:
控制机制通过这些接口获取传感器读数,同时通过它们记录自己的干预历史。状态总线成为控制循环的神经系统,连接感知、决策和执行。
Agent Flag 是一种开放、中立、持久化且语义化的状态总线协议,提供跨越进程和语言壁垒的标准状态通信机制。详见:docs/agent-flag.md