代理循环架构
代理循环是系统的运行时引擎。虽然入口点在启动时建立了依赖图,但代理循环执行实际的对话循环。将其视为状态机而非过程循环,能够揭示使系统健壮和可预测的核心设计决策。
状态机概览
┌─────────────────────────────────────────────────────────────────────┐
│ 代理循环状态机 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 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
模型想要调用工具 → 执行工具,添加结果,继续循环这是迭代路径。模型确定它需要采取行动(读取文件、运行命令、搜索某物)。代理:
- 执行所有请求的工具
- 将结果添加到对话历史
- 继续循环以获取下一个响应
unknown / default
意外的 stop_reason → 返回存在的任何内容这是防御性回退。我们不会因意外值而崩溃;我们返回所拥有的内容,让用户决定怎么做。
断路器 — MAX_TURNS
const MaxTurns = 50MAX_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 响应
- 模型注意力:极长的上下文会降低模型对当前任务的关注度
压缩触发
压缩在以下情况发生:
- token 数量接近限制
- 每次 API 请求之前(以确保请求适合)
什么被压缩
系统总结或删除较旧的轮次,同时保留:
- 原始用户请求(上下文)
- 最近的对话(工作内存)
- 工具定义(始终需要)
安全检查点 — 权限门
在任何工具执行之前,它会通过权限门:
┌─────────────────────────────────────────────────────┐
│ 权限门 │
├─────────────────────────────────────────────────────┤
│ │
│ 工具请求 ──▶ checkPermission() ──▶ 决策 │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 权限策略 │ │
│ │ - Allow(允许) │ │
│ │ - Deny(拒绝) │ │
│ │ - Ask(询问) │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘为什么是这个设计?
- 深度防御:并非所有工具都是危险的;权限检查是一个安全层
- 用户控制:用户可以允许/拒绝特定操作
- 可审计性:权限决策被记录
工具分类
- 不需要权限:Read、Glob、Grep(只读、低风险)
- 需要权限:Bash、Write、Edit(可以修改系统)
权限策略评估每个工具调用,基于:
- 工具是否需要权限
- 用户配置的权限级别
- 尝试的具体操作
崩溃恢复 — 会话持久化
每轮完成后都会进行会话持久化:
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 返回部分响应 |
为什么这些选择有效
- 状态机:匹配 API 的离散响应模型
- 断路器:提供安全性而不增加复杂性
- 历史压缩:保持成本可预测,性能高
- 权限门:在安全性和可用性之间取得平衡
- 会话持久化:启用恢复而不需要事务复杂性
代理循环是运营核心——它获取入口点初始化的组件,通过明确定义的状态转换使它们做有用的工作。