一、失败现场
实现篇的第一层,从一个几乎所有人都写过的循环开始。它不区分对话、工具与错误,退出条件只有一条:模型这一轮没有调用工具。
python
while True:
response = model.chat(messages, tools)
messages.append(response)
if not response.tool_calls:
return response.content
for call in response.tool_calls:
messages.append(run_tool(call))
这段循环能跑通一次普通对话,但把"模型不再调用工具"当成了"任务完成"。两者只在最简单的情况下等价。它至少对四类情况失效:
- 只给计划就停下。 模型回复"我先查日志",没有发起工具调用;循环把这句话当作最终答案返回,调用方拿到一段看似合理的文本。
- 工具连续失败。
run_tool返回错误文本,模型换一种方式再试,循环没有"这件事有没有进展"的概念。 - 重复同一调用。 模型用完全相同的参数反复调用同一个工具,循环照常执行。
- 轮次没有上限。
while True没有任何预算,账单随时间增长,进程也不会自己收敛。
四类失效背后是同一个缺口:循环没有可被程序消费的退出判定。调用方只拿到一段字符串,无法区分完成、失败与预算耗尽。完成判断与预算这两条控制线在《Loop Engineering》里已经展开;I1 要做的是给这两条线一个返回值和一次明确的状态迁移。理论层的完整问题空间在《Agent Harness 工程》里,本文不重复论证,只补机制。
失败现场来自本地实测:同一批脚本化响应分别跑裸循环与状态机,输出如下。
text
只给计划就停下
裸循环: no_tool_calls steps=1 output='计划:先回显 hi'
状态机: completed turns=3 tool_calls=1 output='已回显 hi'
重复同一调用
裸循环: guard_exhausted steps=6 output=None
状态机: no_progress turns=3 tool_calls=3 output=None
| 场景 | 裸循环 | 状态机 |
|---|---|---|
| 只给计划就停下 | 1 步后返回计划文本 | 3 轮后完成,执行 1 次工具 |
| 重复同一调用 | 跑满 6 步安全网,没有结果 | 3 轮后以 no_progress 退出 |
表中的裸循环已经加了 max_steps=6 的安全网;没有它,第二个场景不会结束。即使有安全网,结果也只说明"步数用完了",没有说明任务是否完成、为什么停。这是实测结果(脚本化模型,2026-10-22,Python 3.11.13)。
I1 补四条契约:turn 边界、封闭的退出原因、可替换的 stop gate、无进展判定;再加上一条事件流,让这些判定可以被观测。系列导读《从零打造 Agent Harness:实现篇导读》给出了这四件事在整体中的位置。
二、设计
设计围绕一个判断:循环的每次退出都必须先经过判定,再变成结果。 2.5 的状态图里,每个分支都对应一个能被程序断言的返回值,而不是一段日志。
2.1 先把 turn 的边界固定下来
本文把一次模型调用及其触发的全部工具执行记为一个 turn。模型一次返回 3 个工具调用,仍属于同一轮;下一轮从下一次模型调用开始。这个定义有三个直接后果:
max_turns数的是模型调用次数,不是工具调用次数;一次返回 3 个工具只消耗 1 轮。- 事件里的
turn、轮次预算与后续的重试共用同一个刻度,不会出现"重试算不算一轮"的分歧。 - 工具调用与结果必须落在同一轮内,I4 的流式收尾与取消也按轮结束。
这个选择要在第一篇写死。I3 的上下文裁剪、I4 的检查点与恢复都要按轮记录状态;如果每篇各定义一次 turn,后面的状态对不上。
2.2 退出原因是封闭枚举
stop_reason 只有五个值:
| 取值 | 含义 | 产生位置 |
|---|---|---|
completed |
stop gate 放行 | 退出分支 |
max_turns |
轮次预算耗尽 | 循环入口 |
no_progress |
重复批次或 gate 续跑超限 | 循环内 |
failed |
模型或 gate 异常 | 异常分支 |
cancelled |
运行被取消 | I4 实现,I1 仅保留枚举 |
completed 是唯一表示"任务完成"的值。调用方不需要解析文本,也不应该把其他值映射成空字符串。cancelled 在 I1 里不会被产生,先把位置留出来,避免 I4 修改公共契约。
2.3 stop gate 接在退出分支上
stop gate 是退出分支上的完成度判定。它拿到当前轨迹的快照、轮次、模型响应与已执行工具数,返回 PASS 或 BLOCK:
python
@dataclass(frozen=True)
class GateDecision:
passed: bool
reason: str = ""
feedback: str | None = None
PASS 让循环以 completed 结束。BLOCK 不退出:循环把 feedback 作为一条 user 消息追加到轨迹,再调用一次模型;BLOCK 的累计次数由 max_stop_blocks 限制,达到上限就以 no_progress 结束。gate 只在模型没有工具调用、准备退出时被调用;模型仍在调用工具时,循环继续执行,不需要 gate 表态。
失败语义也要固定:gate 抛异常,或返回的不是 GateDecision,运行一律以 failed 结束,不放行。这条选择与 I5 将补的审批回调一致------判定组件失效时,默认不能是"通过"。
默认 gate 故意很弱,只拒绝空答案:
python
def require_answer(ctx: StopContext) -> GateDecision:
if ctx.response.content and ctx.response.content.strip():
return GateDecision.allow()
return GateDecision.block("empty_answer", "请给出最终答案,或调用工具继续任务。")
"只给计划"没有通用的文本判据,硬编码一个关键词表会过度拦截正常回复。这类判断属于应用策略,由使用方注入自己的 gate;本文的失败现场用了一个几行的 plan_gate 演示。
2.4 无进展要变成可计算的事实
I1 的无进展判定只看工具批次的形状:把这一轮所有调用按顺序写成 (工具名, 参数 JSON),参数 JSON 按键排序,得到批次签名;连续相同签名达到 no_progress_limit 次,就以 no_progress 退出。
按键排序是为了让 {"a": 1, "b": 2} 与 {"b": 2, "a": 1} 得到同一签名------模型每次生成的键顺序可能不同,但调用语义相同。这个判定只回答"调用形状有没有变化",不回答"世界有没有变化":两次相同的查询可能拿到不同结果,两次不同的调用也可能在原地打转。I1 接受这个精度,max_turns 是兜底;更细的进展判定要看工具自己的状态指纹,留给后续文章。
2.5 状态迁移与契约
| 位置 | 契约 | 违反后的状态 |
|---|---|---|
| 模型调用前 | turns < max_turns |
max_turns,不再发起模型调用 |
| 工具结果回灌 | 循环按调用逐条回灌;消息模型拒绝缺 id 或角色错误的消息 | 不会把悬空调用送进历史 |
| 退出前 | gate 返回 GateDecision,PASS 才完成 |
异常或错类型 → failed;BLOCK 超限 → no_progress |
| 工具批次 | 连续相同签名不超过 no_progress_limit |
no_progress |
| 全程 | 每次状态迁移发事件,事件不含墙钟 | 同一输入的轨迹可逐字段比较 |
三、实现
本文代码在 nano-harness,对应模块 src/nano_harness/loop.py。
实现集中在四个模块:harness/loop/ 负责消息与状态机,harness/tools/ 负责工具注册与执行,harness/runtime/ 负责模型客户端与事件,harness/eval/ 放脚本化模型。推理---行动交替的循环形态沿用 ReAct^1^,工具调用协议以 OpenAI-compatible function calling 为基线^2^。
3.1 消息模型:不合法的历史构造不出来
裸循环省略的 role 与 tool_call_id,在实现里是第一道不变量。Message 在构造时检查角色与字段的搭配:
python
if self.role == "tool":
if not self.tool_call_id:
raise ValueError("tool message requires a non-empty tool_call_id")
if self.tool_calls:
raise ValueError("tool message cannot carry tool_calls")
if not isinstance(self.content, str):
raise ValueError("tool message requires string content")
return
if self.tool_call_id is not None:
raise ValueError("only tool messages carry tool_call_id")
if self.role != "assistant" and self.tool_calls:
raise ValueError("only assistant messages carry tool_calls")
工具结果在循环里只通过 Message.tool_result(call, content) 构造:它自动带上 call.id,调用方没有机会手抄一个对不上的 id。to_openai() 负责把内部结构映射成模型 API 的消息形状,模型客户端只认这个方法,不认裸 dict。
这一层只校验单条消息的内部一致性,不校验"tool 消息是否紧跟对应的 assistant"。顺序与配对由循环的构造逻辑保证,测试在完整轨迹上做断言。
3.2 状态机:每个分支都有返回值
下面是 AgentLoop.run 的骨架,省略了事件与异常分支;计数器与 last_signature 在循环外初始化,这里也省略:
python
while True:
if turns >= self._config.max_turns:
return finish(StopReason.MAX_TURNS, "turn_budget")
turns += 1
response = self._model.complete(tuple(messages), self._tools.specs())
messages.append(Message.assistant(response.content, response.tool_calls))
if response.tool_calls:
signature = tuple(
(call.name, json.dumps(call.arguments, ensure_ascii=False, sort_keys=True))
for call in response.tool_calls
)
if signature == last_signature:
repeats += 1
else:
last_signature = signature
repeats = 1
for call in response.tool_calls:
observation = self._tools.execute(call)
tool_calls += 1
messages.append(Message.tool_result(call, observation))
if repeats >= self._config.no_progress_limit:
return finish(StopReason.NO_PROGRESS, "repeated_tool_calls")
continue
decision = self._stop_gate(
StopContext(
messages=tuple(messages),
turn=turns,
response=response,
tool_calls=tool_calls,
)
)
if decision.passed:
return finish(StopReason.COMPLETED, "answer", output=response.content)
blocks += 1
if blocks >= self._config.max_stop_blocks:
return finish(StopReason.NO_PROGRESS, "stop_gate_limit")
messages.append(Message.user(decision.feedback or "任务尚未完成,请继续。"))
几个顺序不是随手写的:
- 预算检查放在模型调用之前。 到顶就不再调用,测试断言模型只被调用
max_turns次,而不是"调完最后一次才发现超了"。 - assistant 消息先落历史,再执行工具。 这样每个 tool 结果都能找到对应的调用;如果先执行再落消息,异常路径很容易留下悬空的 tool 消息。
tool_calls在执行之后加一。 这个计数记的是"已执行",不是"已请求";模型一次请求 3 个、执行到第 2 个时进程被中断,计数是 2,不是 3。- 重复批次先执行、后退出。 模型返回调用时,assistant 消息已经落历史;此时直接退出会留下没有结果的调用。要保住配对,只能执行完再停。代价是写操作可能多执行一次------这是一条负结果,I1 不解决重复副作用,I2 用幂等键处理,当前只保证循环会停下来。
- gate 拿到的是元组快照。 gate 不能改历史,只能做判断;判断结果由循环消费,而不是由 gate 自己执行动作。
- 模型与 gate 的异常都收敛成
failed。 异常不会穿透run(),也不会被当成一次正常退出。
3.3 结果与事件
RunResult 把一次运行压成可检查的结构:
python
@dataclass(frozen=True)
class RunResult:
run_id: str
stop_reason: StopReason
turns: int
tool_calls: int
usage: Usage
events: tuple[Event, ...]
messages: tuple[Message, ...]
output: str | None
前六项是系列约定,messages 与 output 是 I1 的附加字段。事件覆盖九种迁移:run_started、turn_started、model_responded、tool_started、tool_finished、stop_gate_blocked、model_failed、stop_gate_failed、run_finished。run_finished 额外带一个 detail,区分 turn_budget、repeated_tool_calls、stop_gate_limit、model_error、stop_gate_error 与正常完成。
事件不带时间戳与随机 id。run_id 每次运行随机,但不进事件;同一脚本输入得到逐字段相同的轨迹,测试可以直接比较事件列表。需要墙钟与 trace id 的场景由事件 sink 自己补,核心结构不为可观测性引入不确定性。
3.4 工具执行:先保证循环不崩
工具注册表只做三件事:按名字找到 ToolSpec,把参数绑定给处理函数,把返回值序列化成一段文本。参数用 JSON Schema 描述^3^,但 I1 还不校验它------那是 I2 的契约层。
python
def execute(self, call: ToolCall) -> str:
spec = self._specs.get(call.name)
if spec is None:
return f"ERROR: unknown tool {call.name!r}"
try:
result = spec.handler(**call.arguments)
except Exception as exc:
return f"ERROR: {type(exc).__name__}: {exc}"
...
未知工具与处理函数异常都变成一条 observation 回到模型,循环继续。这个选择让 I1 不会因为一次工具失败直接崩掉,但错误只是字符串:没有类别、没有可重试标记、没有给模型的修正提示。I2 会把这段文本换成结构化信封,并在这里加校验、重试与幂等。
四、验证
测试全部离线,模型由脚本化 fixture 驱动;同一输入得到同一轨迹。本地工程中的运行命令与结果:
bash
.venv/bin/python -m pytest
# 26 passed in 0.01s
这是本地实测结果(Python 3.11.13、httpx 0.28.1、pytest 9.1.1,2026-10-22)。测试分三类:契约、故障注入与接线检查;I1 没有前一层,回归测试从 I2 开始累积。下表列出关键用例,完整清单在本系列的测试目录 tests/ 中(随完整代码后续公开):
| 类别 | 测试 | 断言的关键事实 |
|---|---|---|
| 契约 | test_text_only_answer_completes |
退出原因、轮次、事件顺序 |
| 契约 | test_tool_call_is_paired_with_result |
tool 消息 id、执行计数、usage 累加 |
| 契约 | test_assistant_with_tool_calls_serializes_arguments |
消息到 API 的形状 |
| 契约 | test_request_shape_and_response_parsing |
请求体、鉴权头、usage 解析 |
| 故障注入 | test_plan_only_answer_is_blocked_and_continued |
gate 收到完整轨迹与已执行数,第二轮继续,最终完成 |
| 故障注入 | test_gate_false_positive_converges_to_no_progress |
第二次 BLOCK 后不再调用模型 |
| 故障注入 | test_repeated_identical_tool_batch_stops_as_no_progress |
第 3 次重复后以 no_progress 退出 |
| 故障注入 | test_blocked_turn_breaks_the_repeat_chain |
BLOCK 的非工具轮打断重复链,不被误判为 no_progress |
| 故障注入 | test_turn_budget_stops_before_extra_model_call |
turns=2 且模型只被调用 2 次 |
| 故障注入 | test_model_failure_returns_failed_result |
failed 与 model_error,异常不外抛 |
| 故障注入 | test_stop_gate_exception_fails_closed、test_stop_gate_must_return_gate_decision |
异常与错误返回类型都收敛为 failed |
| 故障注入 | test_unknown_tool_becomes_an_observation、test_tool_exception_becomes_an_observation |
错误变成 observation,循环继续 |
| 接线 | test_loop_and_client_wire_together |
客户端与循环完整接线(MockTransport),第二次请求带 tool 结果 |
| 接线 | test_event_callback_receives_the_same_events |
回调收到的事件与结果中的一致 |
测试全绿不等于护栏成立。 把循环与客户端分开测,两半都可以是绿的:循环测试用脚本模型,客户端测试用 MockTransport,但两者之间的消息映射可能没有任何一处断言。test_loop_and_client_wire_together 专门补这条缝------第二次请求里必须出现 assistant 的 tool_calls,以及 tool_call_id 相同的 tool 消息。gate 也一样:只断言"gate 返回 BLOCK"不够,test_plan_only_answer_is_blocked_and_continued 还断言 gate 收到的上下文里有已执行的工具数和完整轨迹,说明返回值真的被消费,而不是只被记录。
真实模型尚未运行,本层的真实模型行为标未验证。I1 的断言全部关于控制流,脚本模型比真实模型更适合承担证明责任;真实模型只在后续文章里回答"接上模型后是否仍成立"。
负结果与已知缺口同样记录在案:
- 重复检测只覆盖连续相同 批次;模型在 A、B 两个批次之间来回切换时不会触发,只能靠
max_turns收敛。 - 重复批次执行后才停,写工具可能产生重复副作用;I2 之前不要把这套循环接到不可重放的写操作上。
- 默认 gate 只拒绝空答案,对"计划当完成"没有判断力;这是有意的边界,不是遗漏。
- 工具调用按顺序执行,没有并行批处理;同一轮内的并行语义不在 I1 的范围内,留给后续的工具契约。
五、迁移
多数读者不会照着重建运行时,而是要把 I1 的判定接进现有循环。最小改动按下面的顺序做:
- 先换返回值。 把
run()的返回类型从字符串改成RunResult,加入stop_reason枚举;保留一个兼容包装给旧调用方,包装里显式记录"调用方忽略了 stop_reason",不要静默映射成空字符串。 - 在模型调用前加预算。 一个整数计数与一次比较即可;没有预算的循环不要上线。
- 把
return response.content换成 gate。 默认 gate 可以先只拒绝空答案;应用侧的完成标准单独实现,不要塞进循环。 - 给工具批次算签名。 用工具名加按键排序的参数 JSON,记录连续重复次数;阈值先设宽一点,观察真实分布后再收紧。
- 先接四个事件。
model_responded、tool_finished、stop_gate_blocked、run_finished足以还原大部分决策;等 I7 再补 span 与指标。 - 先影子模式,再拦截。 先在影子模式跑一段时间,只记录 gate 的 BLOCK 决策、不改变控制流;对比"如果拦了会怎样",确认误拦率可接受后再启用。
| 改动点 | 最小改动 | 上线后看什么 |
|---|---|---|
| 返回值 | str → RunResult |
旧调用方是否开始消费 stop_reason |
| 预算 | 模型调用前比较 turns |
max_turns 占比与 turns 的 p95 |
| 完成判断 | 退出分支调用 gate | gate 拦截率;接近 0 或接近 100% 都说明策略有问题 |
| 无进展 | 批次签名计数 | no_progress 中重复写操作的比例 |
| 可观测 | 四个关键事件 | 事件是否覆盖每次退出;run_finished.detail 的分布 |
迁移后最该盯的不是"成功率",而是 stop_reason 的分布。max_turns 持续上升,可能是预算太紧,也可能是模型卡住;no_progress 集中在写操作上,说明需要 I2 的幂等而不是继续调阈值;gate 拦截率接近 0,先确认 gate 真的被调用,而不是被一个默认放行的分支短路。
六、边界
I1 只解决"循环会不会停、停得有没有理由",不解决下面这些:
- 工具失败的归因、重试与幂等属于 I2;当前的错误只是字符串,重复批次还可能重复副作用。
- 上下文预算与压缩属于 I3;当前的消息列表只增不减。
- 流式、取消、挂起与崩溃恢复属于 I4;当前模型调用是同步的,运行状态只在内存里。
- 权限与沙箱属于 I5;当前任何工具调用都会执行。
- 技能与渐进披露属于 I6;I1 的工具表全量注入。
- 评测与跨运行统计属于 I7;当前的事件只在单次运行内使用。
还有三条边界属于这一层自身:
- stop gate 不是正确性判据,它只表达应用策略。gate 写得松,错误答案会以
completed返回;写得紧,正常的提前退出会被拦下,最后由max_stop_blocks收敛成no_progress。 - 无进展判定是语法层的,不理解工具返回的语义;
max_turns是它唯一的兜底。 - 事件流是内存结构,不是持久化 transcript;崩溃恢复要等 I4 的检查点。
如果现有 SDK 已经提供封闭的停止原因、轮次预算与可注入的退出判定,不必自建这一层,直接在 SDK 上补 gate 即可。只有当读者需要自己保留否决权、自己决定状态存在哪里时,这套实现才有意义。
本文的验证结果来自本地工程,文中代码片段可独立阅读;完整代码与按文章划分的 tag 待仓库公开后补。本地验证时使用的命令:
bash
.venv/bin/python examples/bare_vs_fixed.py
.venv/bin/python -m pytest
I1 是系列的第一层,没有上一版可 diff;公开仓库建立后,从 I2 起补上相邻 tag 的 diff。下一篇《工具事务:让失败可归因、可重试》把工具错误从字符串升级为结构化信封。
术语
| 术语 | 说明 |
|---|---|
| Turn | 一次模型调用及其触发的全部工具执行;轮次预算按模型调用计 |
| Stop gate | 退出分支上的完成度判定,返回 PASS 或 BLOCK |
| Stop reason | 封闭的退出原因枚举;completed 只表示 gate 放行 |
| Executed | 已经真正执行过的工具调用数,与模型请求数区分 |
| Fixture model | 按脚本返回固定响应的假模型,用于确定性测试 |
| Transcript | 消息与事件的完整记录 |
系列完整术语见《从零打造 Agent Harness:实现篇导读》。
参考文档
Footnotes
-
Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models , ICLR 2023(arXiv 2210.03629) --- arxiv.org/abs/2210.03... ↩
-
OpenAI:Function calling 指南(工具定义、调用与结果回灌) --- platform.openai.com/docs/guides... ↩
-
JSON Schema:规范与版本说明 --- json-schema.org/specificati... ↩