Claude Agent 进阶编排:循环控制 + 写权限审批闸门 + 幂等重试

文章目录

先说结论

用一个可本地验证的 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 官方):

声明:本文为原创内容。代码与输出均来自本地 dry-run 实测,未调用任何真实 API,不涉及任何外部数据源,仅供技术交流。

相关推荐
Highcharts.js19 分钟前
Highcharts for Python 一套 Python 可视化图表生成库
开发语言·python·highcharts·可视化图表
李燚32 分钟前
数据库迁移不翻车:golang-migrate 实战,143 个 DDL 有序执行(第97篇-E83)
golang·agent·multiagent·migrate·eino·subagent·deepflux
彼日花36 分钟前
我做了一个开源项目,让 AI 记住我们解决过的问题:Usora
人工智能·agent·ai编程
wangfpp38 分钟前
生产级 RAG 知识库全流程实践
人工智能·agent·全栈
Haooog1 小时前
从 Tool Calling 到 State 并发控制:一个 Agent 如何安全地连续执行任务
java·agent·后端开发
夫唯不争,故无尤也1 小时前
On-Policy Distillation(OPD)和reinforcement (RL)结合
llm·agent·强化学习·rl·opd
张忠琳1 小时前
【deepseek-harness】Cordis 时空可组合性编程范式 — 三段式精读笔记(一)
ai·agent·deepseek·harness·cordis·dsh
Erishen1 小时前
💡 当 LLM 开始骗自己:用几行正则给 AI 生成的文章上一道可信度闸门
架构·开源·agent
新知图书1 小时前
14.1 多模态试驾预约Agent系统概述
人工智能·agent·ai agent·智能体