Agent 架构

状态总线:控制的前提条件

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 陷入循环。

问题分析

死锁的根源在于控制器的状态设计:

  1. 缺乏对"提示已给出"的追踪
  2. 拦截条件过于简单,没有考虑会话的阶段
  3. 没有区分"第一次拦截"和"重复拦截"

优化设计:基于 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