← 工程跃迁 / 第 07 章
架构定位

Agent 核心运行环

围绕目标和状态选择动作、执行工具、观察并验证结果,再决定停止或重新规划。

系统如何选择动作、执行工具、观察结果并决定停止或重新规划?

概念校准

Agent

它是什么
围绕目标和状态,在约束下选择动作、执行、观察并验证结果的运行系统。
它不是什么
不是独立人格,也不是把模型放进无限循环。

工具

它是什么
Agent 可通过明确参数、权限和结果合同调用的受约束能力。
它不是什么
工具不等于 MCP;本地函数、命令或 HTTP 客户端也可以是工具。

验证与评测

它是什么
用测试、Schema、引用、状态和验收规则检查单次结果,并用数据集衡量系统表现。
它不是什么
不等于让模型对自己的输出再给一次意见;验证必须落到外部可检查证据。

可观测性

它是什么
通过结构化日志、Trace、指标和版本信息解释系统在一次运行中发生了什么。
它不是什么
不只是保存终端输出;没有关联 ID、状态转换和敏感信息边界的日志难以诊断。

单 Agent 与工具调用循环

实现工具选择、结构化参数、流式输出、重试、幂等和停止条件。

本章只做一件事让 Forge 完成第一条可观测的单 Agent 交付循环。
开始前,Forge 已经具备已完成 Module 06 的“Skill、Prompt 与 Agent 能力包”,其通过验收的 solution 是本章起点。
完成后,Forge 将能够实现第一个可运行的单 Agent 交付循环。
卡住时的最小恢复点限制为两个只读工具和最多三轮循环。
下载本章实验仓库Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock
阶段能力链
01根据目标与工具能力选择唯一动作,并在歧义时请求澄清02把工具结果记录为有序观察,并区分可继续错误与终止状态03在调用工具前验证参数合同,并把返回值包装为明确的执行结果04仅对明确的瞬时故障进行有上限的重试,并保留尝试次数05用幂等键与请求指纹防止重复副作用,同时拒绝键冲突06根据可验证成功、失败、预算和重复动作做出显式停止决定07将动作、执行、观察和停止控制组合成有界且可审计的单 Agent 循环
每一步只增加一种可验证能力;后一步建立在前一步已经通过的代码和测试上。

概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n
\n\n### Agent agent\n\n- 它是什么: 围绕目标和当前状态,在约束下选择动作、执行、观察并验证结果的运行系统。核心运行环是:目标/状态 → 选择动作 → 执行工具 → 观察结果 → 验证 → 停止或重新规划。\n- 它不是什么: 它不是独立人格,也不是让模型无期限循环。模型只是决策组件,动作、权限、状态和停止条件由外部运行环境共同约束。\n- 与相邻概念的关系: Context 提供可见信息,Tool 提供动作,Harness 控制权限和副作用,Verification 判断结果,Planning 只在复杂任务需要时加入。\n- 在 HeatStack Forge 中的位置: Forge 读取任务合同与状态,选择一个受许可动作,记录工具结果,再用测试、Schema 或验收规则决定成功、失败、预算耗尽、重复动作、人工介入或重新规划。\n- 典型误用与修正: 误用是把“模型继续生成”当成进展。修正方法是让每轮产生可观察的状态变化,并为所有停止原因建立确定性检查。\n\n### 工具 tool\n\n- 它是什么: Agent 可通过明确参数、权限、执行边界和结果合同调用的受约束能力,例如函数、命令、数据库查询或 HTTP 客户端。\n- 它不是什么: 工具不等于 MCP,也不等于任意脚本。协议可以暴露能力,但 Harness 仍决定是否允许调用及如何处理副作用。\n- 与相邻概念的关系: Agent Loop 选择工具,Schema 约束参数,Harness 执行权限检查和隔离,Verification 检查结果是否推进目标。\n- 在 HeatStack Forge 中的位置: Forge 的 tool registry 保存合同与风险等级;执行前生成变更计划,高风险写操作等待确认,执行后记录结果与恢复信息。\n- 典型误用与修正: 误用是让模型根据工具名称自由拼参数。修正方法是严格 Schema、确定性校验、超时、幂等键和拒绝分支测试。\n\n### 验证与评测 evaluation\n\n- 它是什么: Verification 用测试、Schema、引用、状态或验收规则检查一次结果;Evaluation 用数据集和指标衡量多次运行表现。\n- 它不是什么: 它不是让模型对自己的回答再给一次意见;通过与否必须落到外部可检查证据。\n- 与相邻概念的关系: Agent Loop 依赖 Verification 决定停止,RAG 需要分层评测,Planning 需要节点验收,Observability 提供运行记录。\n- 在 HeatStack Forge 中的位置: Forge 为单次运行保存合同测试与 evidence,再用固定场景集比较质量、延迟、成本、权限和恢复能力。\n- 典型误用与修正: 误用是把一个综合分数当成全部结论。修正方法是定义分层指标、失败样本和版本基线,并结合产品风险评审。\n\n### 可观测性 observability\n\n- 它是什么: 通过结构化日志、Trace、指标、状态转换和版本信息解释一次运行发生了什么,并支持定位失败与比较版本。\n- 它不是什么: 它不只是终端输出,也不是无限记录所有输入;缺少关联 ID、状态转换或敏感信息边界的日志既难排查又可能泄露数据。\n- 与相邻概念的关系: Harness 产生运行事件,Agent Loop 与工具调用形成 Trace,Evaluation 聚合指标,Memory 与恢复依赖版本记录。\n- 在 HeatStack Forge 中的位置: Forge 为每次任务生成 operation_id,记录阶段、工具、耗时、成本、错误类别、状态变化和证据路径,同时过滤敏感信息。\n- 典型误用与修正: 误用是等失败后再临时加日志。修正方法是在合同设计时定义事件与关联字段,并用故障注入确认能重建时间线。\n\n

STAGE 01

根据目标与工具能力选择唯一动作,并在歧义时请求澄清

先看一个具体问题

当两个工具能力得分并列时,选择器不能默认取第一个

在单 Agent 循环的第一步,系统需要根据目标所需的能力集合从已注册工具列表中选择唯一一个工具来执行。当前 forge/agent/actions.py 文件尚不存在,测试在导入阶段就因 ModuleNotFoundError: No module named 'forge.agent' 而中断收集。

本阶段的核心决策不仅是创建该文件,更关键的是在实现 select_action 时正确处理能力得分并列的情况。如果两个工具匹配所需能力的数量完全相同,排序后取第一个并不能证明该选择是明确的,此时必须抛出 ValueError("ambiguous tool choice requires clarification") 以请求外部澄清。

故障实验中提供的 faultySource 已经实现了目标检查、能力排序和无匹配检查,但故意移除了并列检查分支。这导致 test_ambiguous_tools_require_clarification 测试失败,因为选择器静默地返回了排序后的第一个工具而非抛出异常。

先做判断

select_action 对工具列表按能力匹配数降序排序后,发现 ranked[0][0] == ranked[1][0](即前两个工具得分相同),此时正确的做法是什么?

  • 直接返回 ranked[0] 对应的工具,因为排序已经决定了优先级
  • 抛出 ValueError 并在消息中包含 “ambiguous”,表示选择不明确需要澄清
  • 返回一个特殊的 Action 对象标记为待定状态
  • 重新排序并按工具名字母顺序选择第一个

判断依据: 排序只能给出一个序列,但当最高分有多个工具并列时,排序本身无法区分哪个工具才是正确选择。正确做法是在返回 Action 之前检查并列并抛出 ValueError,让外部循环知道需要澄清。

它是什么

动作选择是能力匹配与唯一性验证的组合

动作选择函数 select_action 接收三个输入:目标字符串、所需能力集合 required_capabilities、以及工具规格列表 tools。它通过计算每个工具的 capabilitiesrequired_capabilities 的交集大小来排序,选出匹配数最高的工具。

唯一性验证是动作选择不可跳过的步骤:即使排序产生了第一名,如果存在并列,选择器就不能断言该选择是确定的。并列检查 ranked[0][0] == ranked[1][0] 必须在返回 Action 之前执行,否则选择器会将排序顺序误认为选择依据。

当没有任何工具匹配任何所需能力时,ranked[0][0] == 0,函数应抛出包含 “no tool” 的 ValueError,表示当前工具列表无法满足目标需求。

它不是什么

动作选择不执行工具也不决定停止

动作选择只负责从候选工具中选出一个并返回 Action 对象,它不调用工具函数、不管理 Agent 状态、不决定循环是否终止。这些职责属于 Harness 和 Agent Loop 的后续阶段。

动作选择也不是简单的排序取第一个。排序只是中间步骤,排序结果必须经过并列检查和无匹配检查两道验证后才能转化为 Action 返回值。

动作选择不处理工具的权限、副作用或隔离。这些由 Harness 在实际执行 Action 时负责,select_action 只依据能力匹配做决策。

它与相邻概念的关系

动作选择在 Agent 循环中的位置

在 Agent 核心循环(目标/状态 → 选择动作 → 执行工具 → 观察结果 → 验证 → 停止或重新规划)中,select_action 对应“选择动作”这一步。它的输出 Action(tool_name, reason) 会被传递给执行层来调用对应工具。

ToolSpec 定义了工具的静态能力描述(名称和能力集合),是 select_action 的输入。Action 是选择结果的数据载体,包含 tool_namereason,是执行层的输入。两者都是 frozen=True 的 dataclass,保证不可变。

select_action 抛出 ValueError 时,Agent Loop 应捕获异常并决定是请求用户澄清(ambiguous 情况)还是报告能力缺失(no tool 情况),而不是继续执行。

本阶段的边界决定: 动作选择只负责根据能力交集大小排序并验证唯一性,在并列时抛出 ValueError 而非猜测;它不执行工具、不管理状态、不决定停止条件,这些职责由外部 Harness 和 Agent Loop 承担。

STAGE 02

把工具结果记录为有序观察,并区分可继续错误与终止状态

先看一个具体问题

工具结果记录与可恢复错误的混淆

在单 Agent 循环中,工具执行后的结果必须被记录为有序的观察,以便后续的动作选择和验证环节能够回溯完整的运行轨迹。当前 forge/agent/observations.py 文件不存在,测试在收集阶段就因 ModuleNotFoundError: No module named 'forge.agent.observations' 而中断,导致整个测试套件无法运行。

更关键的是,工具调用的结果状态分为 okretryable_errorfatal_error 三种,其中可恢复的瞬时错误(如 HTTP 503)不应导致循环终止,而致命错误(如权限拒绝)才应设置终止标志。如果将瞬时传输错误与循环停止状态混为一谈,Agent 将在遇到任何暂时性故障时永久停摆,无法进行重试或重新规划。

先做判断

LoopState.record 方法接收到 status="retryable_error" 时,state.terminal 应该被设置为什么值?

  • True,因为发生了错误就应该停止
  • False,因为瞬时错误可被重试,不应终止循环
  • None,因为状态未定义
  • 抛出异常,因为错误无法处理

判断依据: 正确答案是 False。retryable_error 表示可恢复的瞬时故障(如网络超时或 503),Agent 循环应有机会重试或选择替代动作。只有 fatal_error 才代表不可恢复的终止条件,应将 terminal 设为 True。

它是什么

LoopState 的职责边界

LoopState 是一个数据类,负责按调用顺序记录每次工具执行产生的 Observation,并通过 terminal 布尔标志管理循环是否可以继续接受新观察。Observation 是不可变的冻结数据类,包含 sequence(从 1 开始递增的序号)、tool_namestatuspayload,确保每条观察一旦创建就不会被篡改。

record 方法是 LoopState 的核心入口:它先检查 terminal 标志,若已终止则抛出 ValueError;然后校验 status 是否在合法集合中;接着用 len(self.observations) + 1 计算新序号并创建 Observation;最后根据 status 决定是否将 terminal 设为 True。这一流程将状态管理与动作选择解耦——LoopState 不决定下一步做什么,只记录发生了什么以及是否还能继续。

它不是什么

LoopState 不做的事

LoopState 不执行工具调用、不选择下一个动作、不进行重试逻辑,也不对 payload 的内容做语义解释。它只是一个被动的状态容器和事件序列记录器,由外层的 Agent 循环驱动其 record 方法。

LoopState 也不是日志系统或可观测性平台——它不记录时间戳、调用链 ID 或指标,只维护有序观察列表和终止标志。如果需要 Trace 或指标聚合,那是 Observability 模块的职责,而非 LoopState 的功能。

它与相邻概念的关系

与 Agent 循环其他组件的关系

LoopState 与 Stage 01 中的 select_actionToolSpec 形成上下游关系:select_action 基于 LoopState 中的观察历史决定下一个动作,工具执行后结果通过 record 回写到 LoopState,形成闭环。terminal 标志直接影响循环的退出条件——当它为 True 时,外层循环应停止调用 record 并结束运行。

Observationsequence 字段为外层的验证与评测提供了确定性的顺序依据,使得测试可以精确断言第 N 次工具调用的结果,也使得 Trace 回放成为可能。status 字段的三值分类(ok/retryable_error/fatal_error)将错误严重性显式化,让循环控制逻辑可以据此区分重试与终止。

本阶段的边界决定: LoopState 只记录观察和管理终止标志,不选择动作、不执行工具、不做重试;retryable_error 保持 terminal=False,fatal_error 设置 terminal=True,terminal=True 后 record 抛出 ValueError。

STAGE 03

在调用工具前验证参数合同,并把返回值包装为明确的执行结果

先看一个具体问题

未声明参数必须在调用 handler 之前被拒绝

在单 Agent 循环中,execute_tool 负责在调用具体 handler 之前验证参数合同。当前 forge/agent/execution.py 尚不存在,测试文件 tests/test_stage.py 在导入时直接抛出 ModuleNotFoundError,导致三个测试全部无法收集。

驱动问题是:为什么必须在调用 handler 之前拒绝未声明的参数?如果多余参数被直接透传给 handler,handler 可能因为意外的关键字参数崩溃,或者在更危险的情况下执行了未授权的副作用(例如传入 sudo=True)。

本阶段的任务是从零创建 forge/agent/execution.py,定义 ToolContractExecutionResult 数据类,并实现 execute_tool 函数,使其在参数缺失或包含未声明字段时抛出 ValueError,在参数完全合法时返回包装好的 ExecutionResult

先做判断

execute_tool 接收到参数 {"path": "a.txt", "sudo": True},但 ToolContract 只声明了 required=frozenset({"path"}) 且没有 optional 集合时,如果代码只检查了 missing 参数就直接调用 handler(**arguments),会发生什么?

  • handler 正常执行并忽略多余参数
  • handler 抛出 TypeError 因为收到了意外的关键字参数 sudo
  • execute_tool 抛出 ValueError 并提示 unexpected
  • 系统自动过滤掉 sudo 参数并继续执行

判断依据: 正确答案是 handler 抛出 TypeError。因为 Python 的 **arguments 解包会把所有键作为关键字参数传入,handler 签名不接受 sudo 就会崩溃。这证明了必须在调用前主动检查 unexpected 参数,而不是依赖 handler 自己防御。

它是什么

参数合同验证边界

execute_tool 是 Agent Loop 与具体工具实现之间的隔离层。它的唯一职责是:根据 ToolContract 声明的 requiredoptional 参数集合,验证传入的 arguments 字典是否严格匹配,匹配则调用 handler 并包装结果,不匹配则抛出 ValueError

集合运算是验证的核心:missing = set(contract.required) - set(arguments) 找出缺失的必填参数;unexpected = set(arguments) - set(contract.required) - set(contract.optional) 找出既不在必填也不在可选集合中的多余参数。这两个检查必须在调用 handler 之前完成。

它不是什么

execute_tool 的职责边界

execute_tool 不做重试逻辑,不管理 LoopState,不做幂等性检查,也不关心 handler 内部的业务逻辑。它只是一个严格的结构化网关。

它也不是一个通用的参数过滤器——不会自动剥离 unexpected 参数然后调用 handler,因为静默丢弃参数会掩盖调用方的错误意图,可能导致未授权能力被执行。

它与相邻概念的关系

与 Agent Loop 和 Verification 的关系

Agent Loop 通过 select_action 决定调用哪个工具后,把控制权交给 execute_toolexecute_tool 保证只有符合合同约定的参数才能到达 handler,从而为后续的 Verification 提供确定性基础。

ExecutionResult 将 handler 的返回值包装为带有 tool_name 的结构化结果,使 Observation 层可以统一记录和追踪工具调用的输出,而不需要关心原始返回值的具体类型。

本阶段的边界决定: execute_tool 只在参数合同验证通过后才调用 handler 并返回 ExecutionResult;任何 missing 或 unexpected 参数都必须在 handler 被调用之前以 ValueError 终止,绝不静默过滤或透传。

STAGE 04

仅对明确的瞬时故障进行有上限的重试,并保留尝试次数

先看一个具体问题

为什么不能对所有异常都无差别重试

在单 Agent 循环中,工具执行可能遇到两类性质完全不同的故障:一类是瞬时故障(如服务暂时繁忙),另一类是永久故障(如权限被拒绝)。如果重试策略不区分这两类异常,把所有异常都当作可重试处理,就会导致权限不足的操作被反复尝试三次,白白浪费重试预算并掩盖真实错误。

本阶段需要新建 forge/agent/retry.py 文件,实现 run_with_retry 函数。该函数必须只捕获 TransientToolError 进行有上限重试,遇到 PermanentToolError 时立即重新抛出。当前测试套件在 tests/test_stage.py 中定义了三个测试,由于 forge.agent.retry 模块尚不存在,测试在收集阶段直接抛出 ModuleNotFoundError

先做判断

run_with_retry 接收到一个始终抛出 PermanentToolError 的操作时,正确的实现应该调用该操作几次?

  • 1次,然后立即重新抛出异常
  • 3次,直到耗尽 max_attempts 预算
  • 0次,直接返回失败结果
  • 2次,留一次预算给后续恢复

判断依据: 正确答案是1次。PermanentToolError 表示永久性故障,重试不会改变结果,因此必须在首次捕获后立即重新抛出,不消耗剩余重试预算。

它是什么

有上限重试的精确语义

run_with_retry 是一个只负责重试策略的独立函数,它接收一个无参数的 operation 可调用对象和 max_attempts 上限,返回包含最终值和实际尝试次数的 RetryResult。它的核心逻辑是在 for 循环中调用 operation,仅当捕获到 TransientToolError 且当前尝试次数未达上限时才继续循环。

RetryResult 使用 frozen=True 的 dataclass,确保返回值不可变,调用方可以安全地检查 result.valueresult.attempts 来验证执行轨迹。当 max_attempts 耗尽且最后一次仍抛出 TransientToolError 时,该异常会被重新抛出给调用方处理。

它不是什么

重试策略的边界与职责限制

run_with_retry 不负责选择工具、管理 Agent 循环状态或检查操作幂等性,它只是一个纯粹的执行包装器。它不记录日志、不维护 Trace、不与外部可观测性系统交互,这些职责属于 Harness 和 Observability 模块。

重试策略不是让模型无期限循环直到成功,max_attempts 提供了硬性上限。当 max_attempts < 1 时,函数直接抛出 ValueError,拒绝执行任何无意义的尝试。

它与相邻概念的关系

异常分类与重试决策的因果关系

TransientToolErrorPermanentToolError 都继承自 RuntimeError,但它们在重试循环中的处理路径完全不同。except 子句只捕获 TransientToolError,这使得 PermanentToolError 和其他未列出的异常会自然穿透循环,立即终止重试。

这种基于异常类型的分类策略将故障性质与重试决策直接绑定:瞬时故障意味着重试可能成功,永久故障意味着重试必然失败。Agent Loop 依赖这种精确分类来决定是继续重试还是向上层报告不可恢复的错误。

本阶段的边界决定: run_with_retry 只对 TransientToolError 做有上限重试,遇到 PermanentToolError 或任何其他异常时立即重新抛出,不执行工具选择、不管理循环状态、不做幂等检查。

STAGE 05

用幂等键与请求指纹防止重复副作用,同时拒绝键冲突

先看一个具体问题

相同幂等键附带不同请求指纹时的注册表行为

在单 Agent 循环中,工具调用可能因网络重试或用户重复提交而被触发多次。如果每次调用都执行写操作,就会产生重复的副作用,例如重复写入文件或重复发送邮件。幂等注册表通过记录幂等键和请求指纹来防止这种重复执行。当同一个幂等键再次到达时,注册表必须检查请求指纹是否与已存储的指纹完全一致。如果指纹不同,说明这是一个不同的请求复用了同一个键,注册表必须拒绝执行并抛出异常。当前 forge/agent/idempotency.py 文件尚不存在,测试在导入阶段就因 ModuleNotFoundError 而失败。你需要创建该文件并实现 IdempotencyRegistry 类,使三个测试全部通过。

先做判断

execute_once("op-1", "hash-b", operation) 被调用,而注册表中已存储了键 “op-1” 对应的指纹 “hash-a” 时,注册表应该怎么做?

  • 直接执行 operation 并用新结果覆盖旧结果
  • 返回已缓存的第一次结果并标记为重复
  • 抛出 ValueError 表示请求指纹不匹配
  • 忽略新请求且不返回任何值

判断依据: 正确答案是抛出 ValueError。幂等键只标识一个操作槽位,请求指纹才标识具体的请求内容。如果键相同但指纹不同,说明两个不同的请求意外复用了同一个键,注册表必须拒绝以防止语义错误。

它是什么

幂等注册表是什么

IdempotencyRegistry 是一个用 dataclass 实现的内存注册表,通过 _entries 字典存储幂等键到 (fingerprint, value) 元组的映射。它的 execute_once 方法接收幂等键、请求指纹和一个可调用的操作函数,返回 (value, bool) 元组。布尔值 False 表示这是首次执行并真正调用了操作函数,True 表示命中缓存且未执行操作。这个机制让 Agent 循环在重试或重复调用时安全地避免重复副作用。

它不是什么

幂等注册表不是什么

IdempotencyRegistry 不负责选择工具、不执行重试逻辑、也不管理 Agent 循环的状态转换。它不验证操作函数的返回类型是否符合工具合同,也不持久化到磁盘或数据库。它只是一个局部的、进程内的去重守卫,生命周期与单个注册表实例绑定。

它与相邻概念的关系

与 Agent 循环其他组件的关系

在 Agent 循环中,IdempotencyRegistry 位于工具执行路径上,通常在 execute_tool 调用实际副作用之前介入。它与 retry.py 的重试机制互补:重试处理瞬时失败,幂等注册表处理重复请求。ToolContract 定义工具的参数和结果合同,而幂等注册表确保同一合同下的重复调用不会产生多次副作用。可观测性组件可以记录每次 execute_once 的命中或拒绝事件,用于追踪 Agent 的执行轨迹。

本阶段的边界决定: 幂等注册表的边界是:只用键和指纹防止重复副作用,不做工具选择、不做重试、不管理循环状态;空键或空指纹必须被拒绝,因为它们无法唯一标识一个操作槽位。

STAGE 06

根据可验证成功、失败、预算和重复动作做出显式停止决定

先看一个具体问题

产出答案本身不等于验证成功

在单 Agent 循环中,模型可能已经生成了 “answer” 动作,但这并不意味着任务目标已经被外部验收通过。当前需要创建 forge/agent/stopping.py 文件,实现 StopController 类,让循环根据外部传入的 verified 标志、fatal_errorstep_count 预算和 action_history 中的重复动作模式做出显式停止决定。

在未实现该文件时,测试模块因找不到 forge.agent.stopping 而直接报 ModuleNotFoundError。而在存在缺陷的版本中,代码错误地将 action_history 最后一个动作等于 “answer” 视为 verified_success,导致循环在验收通过前就提前停止。

本阶段的驱动问题是:为什么产出答案本身不足以触发 verified_success 停止?核心在于产出是模型行为,而验证是外部可检查证据,两者不能混为一谈。

先做判断

decide(step_count=1, verified=False, fatal_error=False, action_history=["answer"]) 被调用时,StopController 应该返回什么结果?

  • 返回 stop=True, reason=“verified_success”,因为模型已经产出了答案
  • 返回 stop=False, reason=“continue”,因为外部验证尚未通过
  • 返回 stop=True, reason=“step_budget_exhausted”,因为步数已耗尽
  • 返回 stop=True, reason=“fatal_error”,因为未验证即失败

判断依据: 正确答案是返回 stop=False。verified 参数为 False 意味着外部验收检查尚未通过,即使动作历史中出现了 “answer”,循环也不应停止。产出答案和验证成功是两个独立阶段。

它是什么

StopController 的职责边界

StopController 是一个只负责停止决策的组件,它接收 step_countverifiedfatal_erroraction_history 四个外部输入,返回一个包含 stop 布尔值和 reason 字符串的 StopDecision 数据类。

停止条件的优先级顺序是:首先检查 verified 是否为 True,然后检查 fatal_error,接着检查 step_count 是否达到 max_steps 预算上限,最后检查 action_history 中最后一个动作的重复次数是否达到 repeat_limit。如果都不满足,返回 stop=Falsereason="continue"

它不是什么

StopController 不做什么

StopController 不执行任何工具调用,不管理观察结果,也不负责重试逻辑。它不读取模型输出本身,也不判断答案内容的质量,它只依赖外部 Harness 传入的布尔标志。

它不是让模型无期限循环的机制,也不是独立人格。停止决策的权力属于外部运行环境,StopController 只是将这些约束条件汇总为一个明确的决策对象。

它与相邻概念的关系

与 Agent Loop 其他组件的关系

StopController 在 Agent Loop 中位于验证之后、动作选择之前。Harness 执行工具并观察结果后,通过 Verification 组件检查结果是否推进目标,然后将 verified 布尔值传给 StopController

action_history 由 LoopState 维护,记录了到目前为止的所有动作序列。step_count 也是 LoopState 的一部分。StopController 通过检查 action_history 中最后一个动作的出现次数来判断是否陷入重复循环。

本阶段的边界决定: StopController 只根据外部传入的 verifiedfatal_errorstep_countaction_history 做停止决策,不执行工具、不管理观察、不做重试,也不自行推断答案是否正确。

STAGE 07

将动作、执行、观察和停止控制组合成有界且可审计的单 Agent 循环

先看一个具体问题

验证通过后循环未停止导致多余工具调用

在当前阶段,forge/agent/runner.py 文件尚不存在,测试文件 tests/test_stage.py 在导入 from forge.agent.runner import run_agent 时直接抛出 ModuleNotFoundError,导致收集阶段中断且退出码为 2。你需要创建该文件并实现 run_agent 函数,将前几个阶段已实现的 LoopStateStopController 组合成一个有界循环。

在故障实验中,run_agent 的实现已经存在但包含一个精确缺陷:当 StopController.decide 返回 decision.stopTruereasonverified_success 时,代码没有返回而是继续循环。这导致 test_runner_stops_immediately_after_verified_output 断言失败,因为 step 回调被调用了 3 次而非预期的 2 次,且 result.reason 变成了 repeated_action 而非 verified_success

核心工程决策是:run_agent 必须对任何 decision.stopTrue 的决策立即返回,无论 reasonverified_successrepeated_action 还是 step_budget_exhausted。忽略任何停止条件都会破坏循环的有界性保证,并可能在验证通过后触发不必要的副作用。

先做判断

verify(output) 返回 TrueStopController.decide 返回 decision.stop == True 时,run_agent 应该立即做什么?

  • 立即返回 LoopResult,不再调用 step
  • 继续循环直到 max_steps 耗尽
  • 抛出异常终止循环
  • 重新调用 step 确认结果

判断依据: 正确选择是立即返回。验证通过意味着目标已达成,继续循环不仅浪费步骤预算,还可能因为重复动作触发错误的 repeated_action 停止原因。

它是什么

有界 Agent 循环的组成

run_agent 是一个组合器,它接收一个 step 回调和一个 verify 验证函数,在 max_steps 限制内反复执行:调用 step 获取动作与输出、记录到 LoopState、用 StopController 判断是否停止。run_agent 自身不做工具选择、不做重试、不做幂等检查,这些职责由 step 回调和已实现的前置模块承担。

循环的每一步都产生一个 StopDecision,其中 stop 布尔值是唯一的终止信号。run_agent 的核心职责是忠实地执行这个信号:一旦 stopTrue,无论原因如何都必须立即退出循环并返回当前状态。

它不是什么

run_agent 不做什么

run_agent 不是自主决策器,它不决定调用哪个工具——这由外部 step 回调根据步骤编号 number 决定。run_agent 也不做错误重试或幂等性检查,这些属于 run_with_retryIdempotencyRegistry 的职责。

run_agent 不是无界循环或独立人格。它不会因为模型觉得需要更多信息就继续运行;停止条件完全由 StopControllermax_steps 参数约束,任何停止决策都必须被立即执行。

它与相邻概念的关系

模块间的职责边界

LoopState 负责记录动作历史和观察结果,StopController 负责根据步骤计数、验证结果和动作历史判断是否停止。run_agent 将两者串联:每步先记录再判断,判断结果驱动循环控制流。

step 回调是动作来源,verify 是验收来源,StopController 是停止仲裁者,run_agent 是执行者。这四者的分离使得 run_agent 的逻辑可以保持简单:执行、记录、判断、返回或继续。

本阶段的边界决定: run_agent 的边界是:对任何 decision.stop == True 的决策无条件返回,不区分 reason;循环步数严格受 max_steps 限制;不承担工具选择、重试或幂等检查职责。

完成本章

实现第一个可运行的单 Agent 交付循环。

FORGE / LOCAL CHECK

本地实验自检

  1. 未开始
  2. 2阅读中
  3. 3实验已下载
  4. 4测试结果已读取
  5. 5本地自检通过

verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。

概念校准与一周复习

三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。

  1. 1哪一项最能证明你真正完成了「单 Agent 与工具调用循环」?
  2. 2关于「Agent」,哪一种理解最准确?
  3. 3实验卡住时,哪个动作是本章建议的最小恢复点?