
文章目录
-
- 先说结论
- [一、stop_reason 三态:Agent 循环的岔路口](#一、stop_reason 三态:Agent 循环的岔路口)
- 二、ApprovalGate:写权限审批闸门
- 三、RetryPolicy:幂等重试
- 四、核心代码:分发、审批、重试、裁剪
- [五、实测输出:dry-run 验证控制流](#五、实测输出:dry-run 验证控制流)
- 六、三个容易踩的坑
- 七、小结
先说结论
用一个可本地验证的 Python 框架,把 Claude Agent 循环的三件关键事一次讲清:按 stop_reason 三态分发循环、用 ApprovalGate 给写权限加人工审批闸门、用 RetryPolicy 对错误分类做带上限的幂等重试。 框架不绑定任何外部数据源,跑 python claude_agent_orchestration.py --dry-run 就能在本地验证完整控制流,不需要 API key。
三个结论先放在前面。 第一,tool_use 循环不能只处理「工具调用」,stop_reason 的 end_turn 和 max_tokens 必须单独分支,否则输出截断后消息链会错位。第二,写权限的授权应该是应用状态而不是 prompt 里的指令------write_file 这类写工具默认返回 PENDING_APPROVAL,精确载荷被人工批准后才放行。第三,重试只对网络类错误(error_kind="retryable")开放且上限 3 次,业务类错误直接失败,避免重复副作用。
框架实测输出(dry-run,无任何 API 调用) :decisions 为 ['CONTINUE', 'WAIT_HUMAN', 'CONTINUE', 'DONE'],tool_calls 为 2。含义是 read_file 放行→write_file 被审批闸门拦下(WAIT_HUMAN)→再读一次→end_turn 正常结束。需要说明的是,本文与常见的状态机式 Agent 文章不同:不建模状态迁移,只做循环运行时控制------stop_reason 分发、审批、重试这三件事。
一、stop_reason 三态:Agent 循环的岔路口
Claude API 返回的每条消息都带 stop_reason,它告诉编排层模型为什么停下。这个字段就是循环的分岔口,三种取值对应三种完全不同的处理:
tool_use:模型要调用工具。把 tool_input 交给工具执行,把 tool_result 追加回消息历史,然后带着新上下文继续循环。end_turn:模型已经把话说完,正常收尾,循环结束。max_tokens:输出被 max_tokens 截断,消息不完整。不能当结果直接返回,需要恢复处理------调大上限重发、或让模型从断点续写。
图 1 是循环状态图。注意右下角的红色分支:max_tokens 不走 tool_result 回环,而是独立的截断处理路径;右上角的 end_turn 则直接结束。

图 1 Agent 循环:stop_reason 三态 + 审批闸门控制流
二、ApprovalGate:写权限审批闸门
工具调用本身没有读写之分,分界线要由编排层画出来。ApprovalGate 用 WRITE_TOOLS 白名单标记写类工具:send_email、create_task、update_calendar、write_file 默认返回 PENDING_APPROVAL;读类工具直接放行。
关键是「精确载荷批准」:批准记录的是整个 payload 的 JSON 摘要,而不是「这个工具以后都能用」。同一把 write_file,写 draft.md 被批准了,写 production.py 仍要重新审批。写权限默认拦这一行值得收藏,接任何 Agent 前先抄上。
背后的原则只有一句:授权是应用状态,不是 prompt 里的指令。把「你可以写文件」写进 system prompt,等于把安全交给模型的自觉;把批准放进应用状态,模型即使被 prompt 注入诱导发出写请求,也会在闸门前停下、等待人工确认。
三、RetryPolicy:幂等重试
工具执行失败同样要分类。RetryPolicy 只看 error_kind:等于 "retryable" 的(网络超时、瞬时抖动这类)允许重试,上限 3 次;业务类错误(参数非法、目标不存在)不自动重试,直接返回 FAILED。图 2 是重试决策树。

图 2 幂等重试:按错误类型分类,带次数上限
为什么要分这么细?因为「重试」对只读操作基本无害,对写操作可能是灾难:send_email 第一次已经发出去了,只是响应丢了,盲目重试就会发出两封。所以错误必须带 error_kind 分类,重试必须设置次数上限,两条缺一不可。
四、核心代码:分发、审批、重试、裁剪
完整代码与 claude_agent_orchestration.py 一致,去掉画图部分后核心就是四个对象:ApprovalGate、RetryPolicy、AgentLoop、run_dry_run。
python
import json
MAX_ITERATIONS = 20
MAX_RETRIES = 3
# ---------- approval gate ----------
WRITE_TOOLS = {"send_email", "create_task", "update_calendar", "write_file"}
class ApprovalGate:
"""Write actions need explicit human approval; never trust the prompt alone."""
def __init__(self):
self.approved = set()
def check(self, tool_name: str, payload: dict) -> str:
if tool_name not in WRITE_TOOLS:
return "ALLOWED" # read-only tools pass
key = json.dumps(payload, sort_keys=True)
if key in self.approved:
return "ALLOWED_AFTER_APPROVAL" # exact payload was approved before
return "PENDING_APPROVAL" # otherwise blocked
# ---------- idempotent retry ----------
class RetryPolicy:
"""Network-style errors are retryable (bounded); business errors are not."""
def __init__(self, max_retries=MAX_RETRIES):
self.max_retries = max_retries
def decide(self, error_kind: str, attempt: int) -> bool:
if error_kind != "retryable":
return False
return attempt < self.max_retries
# ---------- agent loop ----------
class AgentLoop:
def __init__(self, tools, gate, retry, max_iterations=MAX_ITERATIONS):
self.tools = tools
self.gate = gate
self.retry = retry
self.max_iterations = max_iterations
self.iterations = 0
self.history = [] # [(role, content)] memory window handled in trim()
def trim(self, max_rounds=6):
"""Memory trimming: keep system + last N rounds, drop stale tool results."""
if len(self.history) <= max_rounds * 2 + 2:
return
keep = self.history[:1] + self.history[-(max_rounds * 2):]
self.history = keep
def step(self, stop_reason: str, tool_name=None, tool_input=None):
"""One iteration of the loop. Returns control decision."""
if stop_reason == "end_turn":
return "DONE"
if stop_reason == "max_tokens":
return "TRUNCATED" # output cut off, needs recovery
if stop_reason == "tool_use":
decision = self.gate.check(tool_name, tool_input or {})
if decision != "ALLOWED":
self.history.append(("tool_result", json.dumps({"status": decision})))
return "WAIT_HUMAN" # blocked by approval gate
attempt = 0
while True:
outcome = self.tools[tool_name](tool_input or {})
if outcome.get("status") == "ok":
self.history.append(("tool_result", json.dumps(outcome)))
return "CONTINUE"
if not self.retry.decide(outcome.get("error_kind", ""), attempt):
self.history.append(("tool_result", json.dumps(outcome)))
return "FAILED" # non-retryable business error
attempt += 1
return "UNKNOWN"
def run_dry_run():
"""Scripted sequence simulating what the model would return. No API call."""
gate = ApprovalGate()
retry = RetryPolicy()
calls = {"count": 0}
def read_file(payload):
calls["count"] += 1
return {"status": "ok", "lines": 42}
def write_file(payload):
calls["count"] += 1
return {"status": "ok", "written": payload.get("path")}
tools = {"read_file": read_file, "write_file": write_file}
loop = AgentLoop(tools, gate, retry)
script = [
("tool_use", "read_file", {"path": "notes.md"}),
("tool_use", "write_file", {"path": "draft.md"}), # write -> approval gate
("tool_use", "read_file", {"path": "notes.md"}),
("end_turn", None, None),
]
results = []
for i, (reason, tool, payload) in enumerate(script):
decision = loop.step(reason, tool, payload)
results.append(decision)
return results, calls["count"]
这套 stop_reason 分发框架可直接复制进自己的 Agent 项目,收藏备用。 三个类各管一件事:ApprovalGate 管写权限边界,RetryPolicy 管重试边界,AgentLoop 的 step() 按 stop_reason 分发并把结果写回 history。trim() 负责记忆裁剪:保留 system 和最近 N 轮,丢弃陈旧 tool_result,避免长对话把上下文撑爆。
五、实测输出:dry-run 验证控制流
run_dry_run() 不调用 Anthropic API,而是用一段脚本序列模拟模型返回值,把循环驱动起来。脚本共四步:read_file → write_file → read_file → end_turn。实测输出:
- decisions:
['CONTINUE', 'WAIT_HUMAN', 'CONTINUE', 'DONE'] - tool_calls:
2
逐行看:read_file 放行并执行(第 1 次调用)→ write_file 命中白名单但载荷未获批,返回 WAIT_HUMAN(未执行)→ 再 read_file(第 2 次调用)→ end_turn 返回 DONE。写工具一次都没有真正执行,这正是审批闸门想达到的效果。
为什么要 dry-run?真实模型行为不可控、还烧 token;先把控制流在本地钉死,再接入真实 API,出问题时能确定是编排层还是模型层。同时要诚实说明边界:dry-run 只验证控制流,不覆盖真实模型的输出分布,审批粒度也是示例级,接入生产前要按业务细化。
六、三个容易踩的坑
| 坑 | 现象 | 规避 |
|---|---|---|
| ① 只处理 tool_use,不处理 max_tokens | 输出被截断后消息链断在半截,下一轮上下文错位 | stop_reason 三态全部分支,max_tokens 走恢复路径 |
| ② 把授权写进 prompt 而非应用状态 | 模型被注入/诱导时可直接越权调用写工具 | 写工具过 ApprovalGate,批准记录放在应用状态 |
| ③ 对业务错误盲目重试 | 重复副作用:邮件发两封、任务建两条 | error_kind 分类 + 重试上限,业务错误直接失败 |
这张表建议收藏。 三个坑的共性是「图省事」:少写一个分支、把安全交给提示词、重试不加条件------短期内都看不出来,一旦上线就是事故。
七、小结
一句话总结:Agent 编排不是「循环调 API」,而是把 stop_reason 分发、写权限审批、错误重试三件事做成显式控制流。 本文参考编程达人挑战赛·第12期,用 dry-run 把框架完整跑通,不依赖 API key 也能复现;全文刻意避开状态机建模,只讲循环运行时控制,与前一篇状态机主题不重复。
如果只记一件事:先让循环在本地 dry-run 里跑对,再谈接入真实模型。代码、输出、两张图都在上文,随时可以复跑验证。
参考链接(Anthropic 官方):
- Messages API(含 stop_reason 字段说明):https://docs.anthropic.com/en/api/messages
- Tool use 文档:https://docs.anthropic.com/en/docs/build-with-claude/tool-use
- Agentic coding 文档:https://docs.anthropic.com/en/docs/agents-and-tools/agent-sdks
声明:本文为原创内容。代码与输出均来自本地 dry-run 实测,未调用任何真实 API,不涉及任何外部数据源,仅供技术交流。
