Skip to content

代理循环架构

代理循环是系统的运行时引擎。虽然入口点在启动时建立了依赖图,但代理循环执行实际的对话循环。将其视为状态机而非过程循环,能够揭示使系统健壮和可预测的核心设计决策。

状态机概览

┌─────────────────────────────────────────────────────────────────────┐
│                     代理循环状态机                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│     ┌──────────┐                                                   │
│     │ thinking │                                                   │
│     └────┬─────┘                                                   │
│          │ API 响应已接收                                           │
│          ▼                                                          │
│  ┌───────────────────────────────────────┐                        │
│  │         stop_reason 调度              │                        │
│  └───────────────────────────────────────┘                        │
│          │                                                         │
│    ┌─────┴─────┬───────────┬────────────┐                       │
│    ▼           ▼           ▼            ▼                        │
│ ┌──────┐  ┌────────┐  ┌──────────┐  ┌──────────┐                 │
│ │end_  │  │max_    │  │tool_use  │  │unknown   │                 │
│ │turn  │  │tokens  │  │          │  │          │                 │
│ └──┬───┘  └────┬───┘  └────┬─────┘  └────┬─────┘                 │
│    │           │           │             │                        │
│    ▼           ▼           ▼             ▼                        │
│  返回      返回+警告    执行工具+                               │
│  响应                  继续循环                                   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

状态定义

代理循环在这些状态下运行:

状态含义下一个动作
thinking等待 API 响应根据 stop_reason 转换
tool_use模型请求执行工具执行工具,添加结果,继续循环
end_turn模型完成任务向用户返回响应
max_tokens响应被截断返回部分响应并附带警告
unknown意外的停止原因返回存在的任何内容

为什么要用状态机?

过程式 vs 状态机思维

过程式视图将循环视为:

1. 发送请求
2. 获取响应
3. 检查 stop_reason
4. 如果是 tool_use:执行并循环
5. 否则:返回

这错过了更深层的结构。状态机视图揭示了:

  • 每个 stop_reason 是一个状态转换——系统从"thinking"移动到特定的结果状态
  • 循环不仅仅是重复;它循环通过明确定义的状态
  • 安全限制 (MAX_TURNS) 是断路器,停止状态机

设计权衡

为什么使用状态机而不是其他方案?

方案为什么不用
事件驱动对此用例过度工程化;API 已经提供离散响应
响应式/流式SSE 提供流式传输,但我们需要对完整响应进行推理,而不是单个 token
协程式Go 的 goroutine 可用,但循环逻辑作为显式状态转换更简单

状态机是最小复杂度的解决方案,捕获了需要的行为:API 告诉我们发生了什么(stop_reason),我们相应地做出响应。

Stop Reason 调度

stop_reason 字段是 API 和代理之间的协议契约。它告诉代理模型做了什么以及接下来要做什么:

end_turn / stop_sequence

模型认为任务已完成 → 向用户返回响应

这是成功路径。模型决定它有足够的信息并产生了最终答案。代理直接返回文本内容。

max_tokens

模型达到 token 限制 → 返回部分响应并附带警告

这是降级但安全的路径。响应被截断,所以用户获得部分结果加上警告。他们可以继续对话以获取剩余内容。

tool_use

模型想要调用工具 → 执行工具,添加结果,继续循环

这是迭代路径。模型确定它需要采取行动(读取文件、运行命令、搜索某物)。代理:

  1. 执行所有请求的工具
  2. 将结果添加到对话历史
  3. 继续循环以获取下一个响应

unknown / default

意外的 stop_reason → 返回存在的任何内容

这是防御性回退。我们不会因意外值而崩溃;我们返回所拥有的内容,让用户决定怎么做。

断路器 — MAX_TURNS

go
const MaxTurns = 50

MAX_TURNS 是电气安全意义上的断路器——旨在防止灾难性故障:

它防止什么

  • 无限循环:模型调用工具产生触发更多工具调用的结果,无穷无尽
  • 振荡:模型在相同方法之间来回切换而没有进展
  • 资源耗尽:耗尽 API token、内存或时间

它如何工作

循环强制执行最多 50 次迭代(每次迭代 = 一次 API 调用 + 可能是许多工具执行)。50 轮后,代理停止并返回一条消息,表明达到了限制。

为什么是 50?

这是经验性选择

  • 50 轮足以完成复杂任务(分析代码库,进行更改,验证)
  • 它超出了合理对话所需
  • 它提供了一个安全网而不会过于严格

这个设计选择意味着什么

断路器假设合理任务在 50 轮内完成。如果一个任务确实需要更多,设计说:将任务分解成更小的部分,或接受当前方法不起作用。

上下文窗口优化 — 历史管理

对话历史随着每轮增长。API 有上下文窗口限制(例如 200K token)。历史管理系统优化这一点:

压缩策略

┌─────────────────────────────────────────────────────┐
│              历史压缩                               │
├─────────────────────────────────────────────────────┤
│                                                     │
│  第 1 轮:  [user: "..."]                           │
│            [assistant: "..."]                       │
│                                                     │
│  第 2 轮:  [user: "..."]                           │
│            [assistant: "tool_use: read file"]      │
│            [tool_result: "file contents..."]      │
│            [assistant: "..."]                      │
│                                                     │
│  ...                                                │
│                                                     │
│  压缩后:                                           │
│  ─────────────────                                  │
│  [user: "原始请求"]                                 │
│  [system: "中间轮次的摘要"]                         │
│  [assistant: "当前响应"]                            │
│                                                     │
└─────────────────────────────────────────────────────┘

为什么不保留一切?

  • 成本:更多 token = 更多 API 成本
  • 性能:更大的上下文 = 更慢的 API 响应
  • 模型注意力:极长的上下文会降低模型对当前任务的关注度

压缩触发

压缩在以下情况发生:

  1. token 数量接近限制
  2. 每次 API 请求之前(以确保请求适合)

什么被压缩

系统总结或删除较旧的轮次,同时保留:

  • 原始用户请求(上下文)
  • 最近的对话(工作内存)
  • 工具定义(始终需要)

安全检查点 — 权限门

在任何工具执行之前,它会通过权限门

┌─────────────────────────────────────────────────────┐
│              权限门                                  │
├─────────────────────────────────────────────────────┤
│                                                     │
│  工具请求 ──▶ checkPermission() ──▶ 决策            │
│                      │                              │
│                      ▼                              │
│              ┌──────────────────┐                   │
│              │  权限策略         │                   │
│              │  - Allow(允许)  │                   │
│              │  - Deny(拒绝)   │                   │
│              │  - Ask(询问)    │                   │
│              └──────────────────┘                   │
│                                                     │
└─────────────────────────────────────────────────────┘

为什么是这个设计?

  • 深度防御:并非所有工具都是危险的;权限检查是一个安全层
  • 用户控制:用户可以允许/拒绝特定操作
  • 可审计性:权限决策被记录

工具分类

  • 不需要权限:Read、Glob、Grep(只读、低风险)
  • 需要权限:Bash、Write、Edit(可以修改系统)

权限策略评估每个工具调用,基于:

  1. 工具是否需要权限
  2. 用户配置的权限级别
  3. 尝试的具体操作

崩溃恢复 — 会话持久化

每轮完成后都会进行会话持久化:

go
func (a *Agent) saveSession(turnCount, inputTokens, outputTokens int) {
    // 保存到 ~/.go-code/sessions/
}

保存了什么

  • 会话 ID 和时间戳
  • 使用的模型
  • 轮数和 token 使用量
  • 完整对话历史

为什么每轮都保存?

┌─────────────────────────────────────────────────────┐
│            会话持久化流程                            │
├─────────────────────────────────────────────────────┤
│                                                     │
│  第 1 轮:  request → response → save               │
│  第 2 轮:  request → response → save               │
│  第 3 轮:  crash!                                   │
│                                                     │
│  恢复:加载最后一个会话 → 从第 3 轮继续              │
│                                                     │
└─────────────────────────────────────────────────────┘

这种设计提供崩溃恢复而不需要复杂性:

  • 如果进程在轮次中间死亡,下次运行可以从中断处继续
  • 不需要复杂的事务日志
  • 会话很小,可以快速保存

恢复注意事项

  • 保存到磁盘的会话在进程崩溃后仍然存在
  • 下次运行时,用户可以继续或重新开始
  • 旧会话会累积(清理是未来增强功能)

架构总结

代理循环展示了几个架构原则:

原则实现
状态机stop_reason 作为状态转换触发器
断路器MAX_TURNS 防止无限循环
上下文优化每次请求前进行历史压缩
深度防御工具执行前的权限门
崩溃恢复每轮后进行会话持久化
优雅失败未知 stop_reason 返回部分响应

为什么这些选择有效

  1. 状态机:匹配 API 的离散响应模型
  2. 断路器:提供安全性而不增加复杂性
  3. 历史压缩:保持成本可预测,性能高
  4. 权限门:在安全性和可用性之间取得平衡
  5. 会话持久化:启用恢复而不需要事务复杂性

代理循环是运营核心——它获取入口点初始化的组件,通过明确定义的状态转换使它们做有用的工作。


基于 MIT 许可证发布