在上一篇《最小执行闭环:手写 ReAct 循环与 Tool 路由》里,我们用 Python 标准库实现了 ToolRegistry、ReActFormatAdapter 和 OpenAICompatibleClient,让 mini Agent 第一次跑通了“思考 → 调用工具 → 观测报错 → 自动修正参数 → 给出最终答案”的完整链路。

如果只是写一个在命令行里跑几秒钟的玩具脚本,上一篇的 while not session.is_finished(): self.step(session) 已经够用了。

但一旦你想把这个 Agent 放进真实的生产环境——比如做成一个后台异步执行的代码修复服务,或者接入一个有前端界面的运维平台——上一篇那种基于进程内存态的 while 死循环就会立刻暴露出三个致命的系统缺陷:

  1. 它不能“停下来等人”(Human-in-the-loop 中断挂起)。假设 Agent 在排查线上问题时,决定调用 execute_sql(query="DROP TABLE temp_logs;") 或者 run_bash(cmd="rm -rf /var/cache/app")。这种带破坏性副作用(Side Effect)的操作,绝对不能让模型自己拍板就跑。系统必须在模型吐出 ToolCall、但尚未真正执行函数的那一刻挂起,释放当前工作线程,给前端推送一条审批卡片。等人类工程师在几分钟甚至几小时后点击“批准(Approve)”或“拒绝并附带修改意见(Reject with Feedback)”,系统再从刚才挂起的那半步精准恢复执行。

  2. 它不能抵抗进程崩溃与服务重启(Crash Recovery)。一个复杂的长程任务可能要跑 20 步、耗时几分钟、消耗十几个 Tool Call。如果跑到第 14 步时容器发生了重启,或者网络抖动导致未捕获异常,驻留在内存里的 AgentSession 就直接灰飞烟灭了,前面花掉的 Token 成本和已经执行了一半的外部状态全部作废。

  3. 它缺乏对“动作死循环(Action Loop)”的免疫能力。我们在上一篇结尾提到过,当遇到权限不足、网络不通或者模型固执己见时,模型非常容易陷入局部极小值——连续十几次一模一样地发出同一个 ToolCall(name="read_file", args={"path": "/root/secret.txt"}),每次都拿到 PermissionError,然后再试一次,直到把 max_steps 活活耗尽。

回想一下我们在《从零实现一个 mini vLLM -- Sequence》那篇文章里是怎么做的。

当时为了从“单请求 model.generate() 死循环”走向“可被调度器随时暂停、抢占、换出(Swap)和恢复的推理引擎”,我们把一个请求封装成了显式的状态机 Sequence,赋予了它 WAITING、RUNNING、SWAPPED、FINISHED 等精确的状态转移语义。

在 Agent Runtime 里,解决上述三个问题的核心钥匙完全一样:我们必须把一次 Agent 执行彻底从调用栈(Call Stack)的局部变量中解耦出来,变成一个拥有细粒度阶段状态、支持持久化快照(Checkpoint)、内置死循环检测的显式有限状态机(Finite State Machine, FSM)。


1. 这一篇要解决什么问题

我们先对比一下上一篇的“粗糙步进模型”与这一篇我们要实现的“两阶段可中断状态机模型”。

在上一篇中,runtime.step(session) 是一个不可分割的原子黑盒:它一口气做完了 LLM 推理 和 所有工具的执行。如果这一步里有 3 个工具调用,其中第 2 个工具需要人工审批,或者在第 3 个工具执行时进程挂了,整个 step 都处于一种“半生不熟”的脏状态——你既不能重跑这一步(因为第 1 个工具已经执行过了,重跑可能产生重复扣款或重复写入等幂等性灾难),也没法直接跳到下一步。

要打破这个僵局,我们必须把 AgentSession 的状态颗粒度拆细。

具体来说,一次完整的迭代周期不再是一个笼统的 step,而是被拆解为两个可以独立暂停与持久化的微阶段(Micro-Phases):

  • Phase A(Decision / 策略生成阶段):给定当前已闭合的消息轨迹,调用 LLM 生成 thought 和待执行的动作队列 pending_tool_calls。如果动作队列为空,直接转移到终态 COMPLETED;如果动作队列非空,检查队列中是否包含需要人工审批的高危工具(或者触发了死循环告警)——若需要审批,状态机立即定格在 WAITING_APPROVAL 并落盘退出!

  • Phase B(Execution / 动作消化阶段):从 pending_tool_calls 队列中逐个取出已获批(或无需审批)的工具调用加以执行。每执行完一个工具,立刻将其 Observation 写入轨迹并更新队列指针。只有当 pending_tool_calls 全部消化完毕,状态机才算完成一个闭环,重新回到 IDLE / READY 准备进入下一轮 Phase A。

把这套设计画成状态转移图,结构就非常清晰了:

                  ┌──────────────────────────────────────────────────┐
                  │              (所有 pending 工具执行完毕)            │
                  ▼                                                  │
  [ 创建 ] ──► [ READY ] ──(Phase A: LLM 推理)──► [ THINKING ]        │
                                                      │              │
                        ┌─────────────────────────────┼──────────┐   │
                        │ (无 ToolCall)               │ (有工具)  │   │
                        ▼                             ▼          │   │
                 [ COMPLETED ]               { 需人工审批或触发阈值? } │
                                                │            │   │   │
                                           (是) │            │(否)│   │
                                                ▼            ▼   │   │
                                     [ WAITING_APPROVAL ]    │   │   │
                                        │           │        │   │   │
                                 (批准/修改参数)  (拒绝动作)   │   │   │
                                        │           │        │   │   │
                                        └─────┬─────┴────────┘   │   │
                                              ▼                  │   │
                                    [ EXECUTING_TOOLS ] ─────────┘   │
                                              │                      │
                                      (系统崩溃/主动取消)              │
                                              ▼                      │
                                    [ FAILED / CANCELLED ] ◄─────────┘

注意看这张图里的 [ WAITING_APPROVAL ] 和 [ EXECUTING_TOOLS ]:因为所有的中间状态(包括当前正在等待审批的是哪几个 ToolCall、已经执行完了哪几个)都保存在 AgentSession 的数据字段里,而不是保存在 Python 函数的栈帧(Stack Frame)里,我们随时可以把整个 AgentSession 序列化成一段 JSON 存进磁盘或数据库,然后直接把进程关掉!


2. 有限状态机(FSM)与重复动作检测的形式化推导

在写代码之前,我们先用形式化语言把 可中断状态机转移函数 和 动作死循环检测(Loop Detection) 的判据严格定义出来。

2.1 可中断状态机的五元组定义

我们将 AgentSession 的运行时状态机定义为一个五元组:

M = (Q, Σ, δ, q₀, F)

  • 状态集合 Q: Q = { READY, THINKING, WAITING_APPROVAL, EXECUTING_TOOLS, COMPLETED, MAX_STEPS, FAILED, CANCELLED }

  • 初始状态 q₀:q₀ = READY。

  • 终止状态集合 F ⊆ Q: F = { COMPLETED, MAX_STEPS, FAILED, CANCELLED }

  • 挂起状态(Yield States)Q_yield ⊆ Q: Q_yield = { WAITING_APPROVAL } 当状态机进入 q ∈ F ∪ Q_yield 时,主驱动循环 runtime.run_until_yield_or_finish(session) 必须立刻退出控制流,并将持久化快照写入存储层。

为了保证在 WAITING_APPROVAL 或 EXECUTING_TOOLS 阶段发生崩溃后能够满足 精确一次执行语义(Exactly-Once Execution Semantics),我们在会话状态中维护当前步的待处理动作队列 Pₖ 与已完成观测集合 Dₖ:

Pₖ = [aₖ,₁, aₖ,₂, …, aₖ,ₘ],Dₖ = [oₖ,₁, …, oₖ,ⱼ](0 ≤ j ≤ m)

每一次工具执行或人工审批决策 d ∈ { APPROVE, EDIT(a'), REJECT(r) },本质上都是对队列头部元素 aₖ,ⱼ₊₁ 的一次原子归约:

oₖ,ⱼ₊₁ = Exec(aₖ,ⱼ₊₁),若 d = APPROVE;Exec(a'ₖ,ⱼ₊₁),若 d = EDIT(a'ₖ,ⱼ₊₁);Obs_rejected(r),若 d = REJECT(r)

一旦 oₖ,ⱼ₊₁ 写入 Dₖ 并持久化,即使进程在 1 毫秒后断电,重启恢复时系统看到 |Dₖ| = j+1,也会直接从 aₖ,ⱼ₊₂ 继续往后走,绝不会重复执行前 j+1 个动作。

2.2 动作死循环(Action Loop)的哈希指纹与滑动窗口判据

接下来我们推导第二个核心机制:怎么在数学和工程上判断 Agent 陷入了死循环?

在自回归生成中,当模型设置 temperature = 0.0(或者较低的温度)时,策略分布 π_θ(a | τ) 近似退化为确定性映射。如果在第 k-1 步模型执行了动作 aₖ₋₁ 并拿到了报错观测 oₖ₋₁,但这条 oₖ₋₁ 未能改变模型在关键决策 Token 上的最大 logit 排序,即:

argmax_a π_θ(a | τₖ₋₂, sₖ₋₁, aₖ₋₁, oₖ₋₁) = aₖ₋₁

那么在第 k 步,模型就会输出完全相同的动作 aₖ = aₖ₋₁,进而拿到完全相同的报错 oₖ = oₖ₋₁,从此陷入无限循环。

但是要注意,在判断两个动作 aᵢ 和 aⱼ 是否“等价”时,不能直接用原始字符串 tc_i.raw_arguments == tc_j.raw_arguments 做比较。因为 {"a": 1, "b": 2} 和 {"b": 2, "a": 1} 在字符串层面不同,但在语义上完全等价。

因此,我们定义一个动作 a = (name, args) 的 规范化哈希指纹(Canonical Action Fingerprint) h(a):

h(a) = SHA256( name ‖ ":" ‖ CanonicalJSON(args) )

其中 CanonicalJSON(args) 是将字典的所有层级 Key 按字典序递归排序(sort_keys=True)且去除多余空白符(separators=(",", ":"))后的唯一确定性字符串表示。如果某一步包含多个并行工具调用 Aₖ = [aₖ,₁, …, aₖ,ₘ],则整步动作的组合指纹为:

H(Aₖ) = SHA256( h(aₖ,₁) ‖ … ‖ h(aₖ,ₘ) )

有了单步动作指纹 H(Aₖ),我们只需在长度为 W 的近期历史滑动窗口 {A(k-W+1), …, Aₖ} 上检测两类典型的死循环模式:

  1. 周期为 1 的原地踏步(Stuck in Place,连续重复 R_max 次): ∀i ∈ {0, 1, …, R_max-1},H(Aₖ₋ᵢ) = H(Aₖ) 例如模型连续 R_max = 3 次调用完全相同参数的同一个工具。

  2. 周期为 2 的乒乓震荡(Ping-Pong Oscillation): H(Aₖ) = H(Aₖ₋₂) ∧ H(Aₖ₋₁) = H(Aₖ₋₃) ∧ H(Aₖ) ≠ H(Aₖ₋₁) 例如模型在 Step 1 把配置改成 true,Step 2 发现测试不过又改回 false,Step 3 忘了刚才试过又改成 true,Step 4 又改回 false。

当检测器发现 H(Aₖ) 触发上述任一判据时,Runtime 该怎么办?直接抛异常终止整个任务太粗暴了。更聪明的做法是做一次 状态机级干预(Loop Intervention):拦截这次重复的工具调用(不真正去跑工具),而是直接构造一条高优先级的系统警告作为 Observation(is_error=True) 注入轨迹:

[SYSTEM LOOP WARNING] You have invoked the exact same tool action with identical arguments 3 times in a row. Execution was blocked to prevent an infinite loop. Please analyze why the previous attempts failed and try a different tool, different arguments, or conclude your answer.

如果模型在收到警告后依然执迷不悟,连续触发干预达到硬上限(例如 R_hard = 5),状态机才将其強制熔断为 FAILED。


3. 升级 ToolRegistry:引入权限分级与审批策略

为了支持 Human-in-the-loop(人工审批中断),我们需要给工具加上一个“风险/权限属性”。

并不是所有工具都需要人类审批。像 list_python_files、read_file、calculate 这种纯只读或无副作用(Read-Only / Side-Effect Free)的工具,Agent 完全可以全速自动执行;但像 write_file、run_bash、send_email、delete_record 这类会修改外部世界状态的工具,或者当参数命中特定敏感规则(比如路径包含 /etc/ 或命令包含 rm)时,就必须触发审批拦截。

我们在 ToolDefinition 上增加两个非常实用的扩展点:

  • requires_approval: bool = False:静态声明该工具每次调用都必须挂起等待人工确认;

  • approval_policy: Optional[Callable[[Dict[str, Any]], bool]] = None:动态策略函数!接收当前调用的实际参数字典 args,返回 True 表示需要人工审批,False 表示直接放行。比如同一个 run_bash(cmd) 工具,执行 ls -la 时动态策略返回 False 自动放行,执行 rm -rf build/ 时动态策略返回 True 立即挂起!

我们来看扩展后的工具权限检查逻辑(approval.py):

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, Optional

from schema import ToolCall


class ApprovalDecisionType(str, Enum):
    """人工审批的三种决策类型"""
    APPROVE = "approve"  # 同意按原参数执行
    EDIT = "edit"        # 同意执行,但人类修改了调用参数
    REJECT = "reject"    # 拒绝执行,并向模型返回拒绝原因


@dataclass
class ApprovalDecision:
    """人类审批员针对某个挂起 ToolCall 提交的决策对象"""
    call_id: str
    decision: ApprovalDecisionType
    modified_arguments: Optional[Dict[str, Any]] = None
    feedback: str = ""


@dataclass
class ApprovalRule:
    """绑定在工具上的静态或动态审批规则"""
    requires_approval: bool = False
    policy_fn: Optional[Callable[[Dict[str, Any]], bool]] = None

    def should_Ask_human(self, arguments: Dict[str, Any]) -> bool:
        if self.requires_approval:
            return True
        if self.policy_fn is not None:
            try:
                return bool(self.policy_fn(arguments))
            except Exception:
                # 策略函数自身出错时采用保守安全策略(Fail-Safe):默认要求人工审批
                return True
        return False

4. 实现动作死循环检测器(loop_detector.py)

接下来,我们把第 2 节推导的规范化哈希指纹 h(a) 与滑动窗口判据(原地踏步检测 + 乒乓震荡检测)写成独立、纯函数的 LoopDetector 模块。

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import List, Optional, Tuple

from schema import ToolCall


def compute_tool_call_fingerprint(tc: ToolCall) -> str:
    """计算单个 ToolCall 的规范化 SHA-256 指纹:对 JSON keys 递归排序以消除格式差异"""
    try:
        canonical_args = json.dumps(tc.arguments, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
    except TypeError:
        canonical_args = tc.raw_arguments.strip()
    payload = f"{tc.name}:{canonical_args}".encode("utf-8")
    return hashlib.sha256(payload).hexdigest()[:16]


def compute_step_action_fingerprint(tool_calls: List[ToolCall]) -> str:
    """计算单步内所有 ToolCall 组合的整体指纹"""
    if not tool_calls:
        return ""
    combined = "|".join(compute_tool_call_fingerprint(tc) for tc in tool_calls)
    return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]


@dataclass
class LoopCheckResult:
    """死循环检测结果"""
    is_looping: bool
    loop_type: Optional[str] = None      # "stagnation" (原地踏步) 或 "ping_pong" (乒乓震荡)
    repeat_count: int = 1
    warning_message: str = ""


class ActionLoopDetector:
    """
    基于滑动窗口哈希指纹的动作死循环检测器。
    可同时识别周期为 1 的连续重复调用与周期为 2 的乒乓震荡调用。
    """

    def __init__(self, max_consecutive_repeats: int = 3, enable_ping_pong_check: bool = True) -> None:
        self.max_consecutive_repeats = max_consecutive_repeats
        self.enable_ping_pong_check = enable_ping_pong_check

    def check(self, history_fingerprints: List[str], current_calls: List[ToolCall]) -> LoopCheckResult:
        current_fp = compute_step_action_fingerprint(current_calls)
        if not current_fp:
            return LoopCheckResult(is_looping=False)

        seq = history_fingerprints + [current_fp]

        # 1. 检测周期为 1 的连续原地踏步: H(A_k) == H(A_{k-1}) == ...
        consecutive = 1
        for prev_fp in reversed(history_fingerprints):
            if prev_fp == current_fp:
                consecutive += 1
            else:
                break

        if consecutive >= self.max_consecutive_repeats:
            tool_names = ", ".join(tc.name for tc in current_calls)
            return LoopCheckResult(
                is_looping=True,
                loop_type="stagnation",
                repeat_count=consecutive,
                warning_message=(
                    f"[SYSTEM LOOP INTERVENTION] You have called tool(s) [{tool_names}] with the exact same "
                    f"arguments {consecutive} times consecutively. Execution has been intercepted to prevent "
                    f"an infinite loop. Do NOT repeat the same action. Please try different parameters, "
                    f"use an alternative tool, or explain the blocker to the user."
                ),
            )

        # 2. 检测周期为 2 的乒乓震荡: (A, B, A, B)
        if self.enable_ping_pong_check and len(seq) >= 4:
            a1, b1, a2, b2 = seq[-4], seq[-3], seq[-2], seq[-1]
            if a1 == a2 and b1 == b2 and a1 != b1:
                return LoopCheckResult(
                    is_looping=True,
                    loop_type="ping_pong",
                    repeat_count=2,
                    warning_message=(
                        "[SYSTEM LOOP INTERVENTION] Detected a ping-pong oscillation pattern (alternating "
                        "between the same two actions repeatedly). Execution was intercepted. Please pause "
                        "and reassess your strategy before taking the next step."
                    ),
                )

        return LoopCheckResult(is_looping=False, repeat_count=consecutive)

5. 重构 AgentSession:支持细粒度快照序列化与恢复(session_v2.py)

现在我们来改造最核心的 AgentSession。

为了让 AgentSession 能够在任意时刻(无论是刚生成完 ToolCall 等待人工审批,还是执行完一半工具)无损地存盘并跨进程恢复,我们需要在 StepRecord 中显式维护一个 pending_tool_calls 列表和一个 approval_decisions 字典,并为 AgentSession 实现完整的 to_dict() / from_dict() 序列化方法以及一个轻量级的 SessionStore(支持原子文件写入)。

为什么文件写入要强调“原子写入(Atomic Write via Temp File + Rename)”?因为如果进程正好在写 JSON 快照写到一半时被 kill -9 或断电,直接覆盖原文件会导致快照文件损坏成半截非法 JSON;而先写入 .tmp 临时文件再调用 os.replace(),在 POSIX 文件系统上是内核保证的原子操作,永远不会出现损坏的半成品快照。

from __future__ import annotations

import json
import os
import tempfile
import time
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional

from approval import ApprovalDecision, ApprovalDecisionType
from loop_detector import compute_step_action_fingerprint
from schema import Message, Observation, Role, SessionStatus, TokenUsage, ToolCall


@dataclass
class StepRecordV2:
    """支持两阶段中断恢复的单步记录"""
    step_index: int
    thought: Optional[str] = None
    tool_calls: List[ToolCall] = field(default_factory=list)
    # 尚未执行完毕的 ToolCall ID 列表(用于断点恢复与审批挂起)
    pending_call_ids: List[str] = field(default_factory=list)
    observations: List[Observation] = field(default_factory=list)
    usage: TokenUsage = field(default_factory=TokenUsage)
    action_fingerprint: str = ""
    llm_latency_ms: float = 0.0
    step_total_ms: float = 0.0

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> StepRecordV2:
        return cls(
            step_index=data["step_index"],
            thought=data.get("thought"),
            tool_calls=[ToolCall(**tc) for tc in data.get("tool_calls", [])],
            pending_call_ids=list(data.get("pending_call_ids", [])),
            observations=[Observation(**obs) for obs in data.get("observations", [])],
            usage=TokenUsage(**data.get("usage", {})),
            action_fingerprint=data.get("action_fingerprint", ""),
            llm_latency_ms=data.get("llm_latency_ms", 0.0),
            step_total_ms=data.get("step_total_ms", 0.0),
        )


@dataclass
class AgentConfigV2:
    model: str = "mini-llama"
    system_prompt: str = "You are a helpful and rigorous AI agent."
    max_steps: int = 15
    max_consecutive_loops: int = 3
    max_loop_interventions: int = 2
    temperature: float = 0.0

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> AgentConfigV2:
        return cls(**data)


class AgentSessionV2:
    """
    显式有限状态机版本的 AgentSession:
    支持两阶段执行(Decision / Execution)、人工审批挂起与完整 JSON 快照恢复。
    """

    def __init__(
        self,
        user_query: str,
        config: Optional[AgentConfigV2] = None,
        session_id: Optional[str] = None,
    ) -> None:
        self.session_id: str = session_id or f"sess_{uuid.uuid4().hex[:12]}"
        self.config: AgentConfigV2 = config or AgentConfigV2()
        self.status: SessionStatus = SessionStatus.PENDING
        self.created_at: float = time.time()
        self.updated_at: float = self.created_at

        self.messages: List[Message] = [
            Message(role=Role.SYSTEM, content=self.config.system_prompt),
            Message(role=Role.USER, content=user_query),
        ]
        self.steps: List[StepRecordV2] = []
        self.total_usage: TokenUsage = TokenUsage()
        self.loop_intervention_count: int = 0

        # 当前正在等待人工审批的 ToolCall ID 集合,以及人类已提交的审批决策映射
        self.awaiting_approval_ids: List[str] = []
        self.approval_decisions: Dict[str, ApprovalDecision] = {}

        self.final_answer: Optional[str] = None
        self.error_reason: Optional[str] = None

    @property
    def current_step(self) -> int:
        return len(self.steps)

    def is_terminal(self) -> bool:
        """判断是否处于不可再推进的终态"""
        return self.status in {
            SessionStatus.COMPLETED,
            SessionStatus.FAILED,
            SessionStatus.MAX_STEPS_EXCEEDED,
            SessionStatus.CANCELLED,
        }

    def is_suspended(self) -> bool:
        """判断是否处于等待外部事件(如人工审批)的挂起状态"""
        return self.status == SessionStatus.WAITING_APPROVAL

    def get_action_fingerprints(self) -> List[str]:
        return [s.action_fingerprint for s in self.steps if s.action_fingerprint]

    def begin_step_from_llm(
        self,
        thought: Optional[str],
        tool_calls: List[ToolCall],
        usage: TokenUsage,
        llm_latency_ms: float,
    ) -> StepRecordV2:
        """Phase A 结束:记录模型决策,初始化待执行动作队列"""
        self.messages.append(
            Message(role=Role.ASSISTANT, content=thought, tool_calls=tool_calls)
        )
        self.total_usage.add(usage)
        fp = compute_step_action_fingerprint(tool_calls)
        record = StepRecordV2(
            step_index=self.current_step + 1,
            thought=thought,
            tool_calls=tool_calls,
            pending_call_ids=[tc.id for tc in tool_calls],
            usage=usage,
            action_fingerprint=fp,
            llm_latency_ms=llm_latency_ms,
        )
        self.steps.append(record)
        self.updated_at = time.time()
        return record

    def commit_observation(self, step_record: StepRecordV2, obs: Observation) -> None:
        """Phase B 单步归约:将一个工具的观测结果写入轨迹,并从 pending 队列移除"""
        step_record.observations.append(obs)
        if obs.call_id in step_record.pending_call_ids:
            step_record.pending_call_ids.remove(obs.call_id)
        if obs.call_id in self.awaiting_approval_ids:
            self.awaiting_approval_ids.remove(obs.call_id)
        self.messages.append(
            Message(
                role=Role.TOOL,
                content=obs.output,
                tool_call_id=obs.call_id,
                name=obs.tool_name,
            )
        )
        self.updated_at = time.time()

    def submit_approval(self, decision: ApprovalDecision) -> None:
        """外部人类接口:针对挂起的 ToolCall 提交审批结果"""
        if self.status != SessionStatus.WAITING_APPROVAL:
            raise RuntimeError(f"Cannot submit approval when session is in status {self.status.value}")
        if decision.call_id not in self.awaiting_approval_ids:
            raise ValueError(f"ToolCall ID '{decision.call_id}' is not awaiting approval.")

        self.approval_decisions[decision.call_id] = decision
        self.awaiting_approval_ids.remove(decision.call_id)

        # 如果所有待审批的调用都已经拿到人工决策,将状态切回 EXECUTING_TOOLS 准备恢复执行
        if not self.awaiting_approval_ids:
            self.status = SessionStatus.EXECUTING_TOOLS
        self.updated_at = time.time()

    def to_dict(self) -> Dict[str, Any]:
        """将完整会话状态序列化为纯 JSON 兼容字典"""
        return {
            "session_id": self.session_id,
            "config": self.config.to_dict(),
            "status": self.status.value,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
            "messages": [
                {
                    "role": m.role.value,
                    "content": m.content,
                    "tool_calls": [asdict(tc) for tc in m.tool_calls],
                    "tool_call_id": m.tool_call_id,
                    "name": m.name,
                    "timestamp": m.timestamp,
                }
                for m in self.messages
            ],
            "steps": [s.to_dict() for s in self.steps],
            "total_usage": asdict(self.total_usage),
            "loop_intervention_count": self.loop_intervention_count,
            "awaiting_approval_ids": list(self.awaiting_approval_ids),
            "approval_decisions": {
                k: {
                    "call_id": v.call_id,
                    "decision": v.decision.value,
                    "modified_arguments": v.modified_arguments,
                    "feedback": v.feedback,
                }
                for k, v in self.approval_decisions.items()
            },
            "final_answer": self.final_answer,
            "error_reason": self.error_reason,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> AgentSessionV2:
        """从 JSON 字典无损恢复会话状态机"""
        cfg = AgentConfigV2.from_dict(data["config"])
        instance = cls(user_query="", config=cfg, session_id=data["session_id"])
        instance.status = SessionStatus(data["status"])
        instance.created_at = data["created_at"]
        instance.updated_at = data["updated_at"]
        instance.messages = [
            Message(
                role=Role(m["role"]),
                content=m.get("content"),
                tool_calls=[ToolCall(**tc) for tc in m.get("tool_calls", [])],
                tool_call_id=m.get("tool_call_id"),
                name=m.get("name"),
                timestamp=m.get("timestamp", time.time()),
            )
            for m in data["messages"]
        ]
        instance.steps = [StepRecordV2.from_dict(s) for s in data.get("steps", [])]
        instance.total_usage = TokenUsage(**data.get("total_usage", {}))
        instance.loop_intervention_count = data.get("loop_intervention_count", 0)
        instance.awaiting_approval_ids = list(data.get("awaiting_approval_ids", []))
        instance.approval_decisions = {
            k: ApprovalDecision(
                call_id=v["call_id"],
                decision=ApprovalDecisionType(v["decision"]),
                modified_arguments=v.get("modified_arguments"),
                feedback=v.get("feedback", ""),
            )
            for k, v in data.get("approval_decisions", {}).items()
        }
        instance.final_answer = data.get("final_answer")
        instance.error_reason = data.get("error_reason")
        return instance


class FileSessionStore:
    """基于 POSIX 原子文件替换的持久化 Checkpoint 存储器"""

    def __init__(self, storage_dir: str = "./checkpoints") -> None:
        self.storage_dir = storage_dir
        os.makedirs(self.storage_dir, exist_ok=True)

    def _path(self, session_id: str) -> str:
        return os.path.join(self.storage_dir, f"{session_id}.json")

    def save(self, session: AgentSessionV2) -> str:
        target_path = self._path(session.session_id)
        payload = json.dumps(session.to_dict(), ensure_ascii=False, indent=2)
        # 先写同目录临时文件,再通过 os.replace 原子覆盖,防止进程崩溃损坏 JSON
        fd, tmp_path = tempfile.mkstemp(dir=self.storage_dir, prefix=f".{session.session_id}_", suffix=".tmp")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                f.write(payload)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp_path, target_path)
        finally:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        return target_path

    def load(self, session_id: str) -> AgentSessionV2:
        target_path = self._path(session_id)
        if not os.path.exists(target_path):
            raise FileNotFoundError(f"Checkpoint for session '{session_id}' not found at {target_path}")
        with open(target_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return AgentSessionV2.from_dict(data)

6. 实现两阶段状态机引擎(StatefulAgentRuntime)

有了 AgentSessionV2、ApprovalRule、ActionLoopDetector 和 FileSessionStore,我们现在可以把 AgentRuntime 升级为真正支持中断恢复与死循环自愈的 StatefulAgentRuntime。

仔细观察下面代码里的 tick(session) 方法。为什么我们把它命名为 tick(时钟滴答)而不是 step?

因为在状态机视角下:

  • 如果当前 session 没有未完成的 pending_call_ids,一次 tick() 会执行 Phase A(调用 LLM 产生决策),检查死循环,检查是否有工具需要人工审批。如果有工具需要审批,状态切为 WAITING_APPROVAL 并直接返回;如果无需审批,则顺势进入 Phase B。

  • 如果当前 session 刚刚从磁盘恢复回来(比如人类刚刚调用了 session.submit_approval(...) 批准了高危操作,此时 pending_call_ids 非空),那么调用 tick() 时它会直接跳过 Phase A(绝不重复调用 LLM),直接进入 Phase B(消化剩余的 pending_call_ids)!

我们来看 stateful_runtime.py 的完整实现:

from __future__ import annotations

import time
from typing import Dict, Optional

from approval import ApprovalDecisionType, ApprovalRule
from loop_detector import ActionLoopDetector
from runtime import LLMClientProtocol
from schema import Observation, SessionStatus, ToolCall
from session_v2 import AgentSessionV2, FileSessionStore, StepRecordV2
from tools import ToolRegistry


class StatefulToolRegistry(ToolRegistry):
    """在第二篇 ToolRegistry 基础上扩展动态/静态审批规则绑定"""

    def __init__(self) -> None:
        super().__init__()
        self._approval_rules: Dict[str, ApprovalRule] = {}

    def register(
        self,
        func=None,
        *,
        name: Optional[str] = None,
        description: Optional[str] = None,
        requires_approval: bool = False,
        approval_policy=None,
    ):
        base_decorator = super().register(name=name, description=description)

        def _wrapper(f):
            registered_fn = base_decorator(f)
            tool_name = name or f.__name__
            self._approval_rules[tool_name] = ApprovalRule(
                requires_approval=requires_approval,
                policy_fn=approval_policy,
            )
            return registered_fn

        if func is not None:
            return _wrapper(func)
        return _wrapper

    def needs_approval(self, tool_call: ToolCall) -> bool:
        rule = self._approval_rules.get(tool_call.name)
        if rule is None:
            return False
        return rule.should_ask_human(tool_call.arguments)


class StatefulAgentRuntime:
    """
    支持 Checkpoint 落盘、Human-in-the-loop 挂起恢复与死循环拦截的状态机运行时。
    """

    def __init__(
        self,
        llm_client: LLMClientProtocol,
        tool_registry: StatefulToolRegistry,
        store: Optional[FileSessionStore] = None,
    ) -> None:
        self.llm_client = llm_client
        self.tool_registry = tool_registry
        self.store = store

    def _checkpoint(self, session: AgentSessionV2) -> None:
        if self.store is not None:
            self.store.save(session)

    def tick(self, session: AgentSessionV2) -> StepRecordV2:
        """
        推进状态机的一个时钟周期(Tick):
        - 若存在未消化的 pending_call_ids,直接恢复进入 Phase B 执行工具;
        - 否则进入 Phase A 请求 LLM 生成新一步决策。
        """
        if session.is_terminal():
            raise RuntimeError(f"Session {session.session_id} is already in terminal state: {session.status.value}")
        if session.is_suspended():
            raise RuntimeError(
                f"Session {session.session_id} is WAITING_APPROVAL for calls {session.awaiting_approval_ids}. "
                f"Please submit approval decisions before ticking."
            )

        tick_start = time.perf_counter()

        # 判断是否处于断点恢复模式(上一步已生成 tool_calls 但尚未执行完)
        has_pending_work = bool(session.steps and session.steps[-1].pending_call_ids)

        if not has_pending_work:
            # ================= Phase A: 策略生成阶段 =================
            if session.current_step >= session.config.max_steps:
                session.status = SessionStatus.MAX_STEPS_EXCEEDED
                session.error_reason = f"Exceeded max_steps ({session.config.max_steps})."
                self._checkpoint(session)
                return session.steps[-1]

            session.status = SessionStatus.THINKING
            self._checkpoint(session)

            # 取出これまでの历史指纹(记录在 begin_step_from_llm 之前)
            history_fps = session.get_action_fingerprints()

            llm_start = time.perf_counter()
            llm_resp = self.llm_client.generate(
                messages=session.messages,
                tool_schemas=self.tool_registry.get_schemas(),
                config=session.config,
            )
            llm_latency_ms = (time.perf_counter() - llm_start) * 1000.0

            step_record = session.begin_step_from_llm(
                thought=llm_resp.content,
                tool_calls=llm_resp.tool_calls,
                usage=llm_resp.usage,
                llm_latency_ms=llm_latency_ms,
            )

            # A1. 如果没有发出任何工具调用,说明模型主动结束任务
            if not llm_resp.tool_calls:
                session.status = SessionStatus.COMPLETED
                session.final_answer = llm_resp.content or ""
                step_record.step_total_ms = (time.perf_counter() - tick_start) * 1000.0
                self._checkpoint(session)
                return step_record

            # A2. 动作死循环检测(Loop Detection)
            detector = ActionLoopDetector(max_consecutive_repeats=session.config.max_consecutive_loops)
            loop_res = detector.check(history_fps, llm_resp.tool_calls)
            if loop_res.is_looping:
                session.loop_intervention_count += 1
                # 如果已经多次警告依然死循环,直接強制熔断整个会话
                if session.loop_intervention_count > session.config.max_loop_interventions:
                    session.status = SessionStatus.FAILED
                    session.error_reason = f"Aborted due to persistent action loop ({loop_res.loop_type})."
                    self._checkpoint(session)
                    return step_record

                # 拦截本次工具执行,直接向模型注入高优先级系统警告 Observation
                for tc in list(llm_resp.tool_calls):
                    warn_obs = Observation(
                        call_id=tc.id,
                        tool_name=tc.name,
                        output=loop_res.warning_message,
                        is_error=True,
                    )
                    session.commit_observation(step_record, warn_obs)

                step_record.step_total_ms = (time.perf_counter() - tick_start) * 1000.0
                self._checkpoint(session)
                return step_record

            # A3. 检查是否包含需要人工审批(Human-in-the-loop)的敏感工具调用
            approval_needed_ids = [
                tc.id for tc in llm_resp.tool_calls if self.tool_registry.needs_approval(tc)
            ]
            if approval_needed_ids:
                session.awaiting_approval_ids = approval_needed_ids
                session.status = SessionStatus.WAITING_APPROVAL
                step_record.step_total_ms = (time.perf_counter() - tick_start) * 1000.0
                self._checkpoint(session)
                return step_record

        # ================= Phase B: 动作消化阶段 =================
        step_record = session.steps[-1]
        session.status = SessionStatus.EXECUTING_TOOLS
        self._checkpoint(session)

        call_map = {tc.id: tc for tc in step_record.tool_calls}
        for call_id in list(step_record.pending_call_ids):
            tc = call_map[call_id]
            decision = session.approval_decisions.get(call_id)

            if decision is not None:
                if decision.decision == ApprovalDecisionType.REJECT:
                    # 人类拒绝了该动作:不执行函数,直接将人类的反馈作为 Observation 喂给模型
                    rej_obs = Observation(
                        call_id=tc.id,
                        tool_name=tc.name,
                        output=f"[HUMAN REJECTED ACTION] Reason: {decision.feedback or 'Action denied by operator.'}",
                        is_error=True,
                    )
                    session.commit_observation(step_record, rej_obs)
                    self._checkpoint(session)
                    continue
                elif decision.decision == ApprovalDecisionType.EDIT and decision.modified_arguments is not None:
                    # 人类修改了参数后放行:使用修改后的安全参数执行
                    tc = ToolCall(
                        id=tc.id,
                        name=tc.name,
                        arguments=decision.modified_arguments,
                    )

            obs = self.tool_registry.execute(tc)
            session.commit_observation(step_record, obs)
            # 每消化完一个工具立刻落盘,确保崩溃后不重复执行已完成的工具
            self._checkpoint(session)

        step_record.step_total_ms += (time.perf_counter() - tick_start) * 1000.0
        self._checkpoint(session)
        return step_record

    def run_until_yield_or_finish(self, session: AgentSessionV2) -> AgentSessionV2:
        """持续推进状态机,直到进入终态(COMPLETED/FAILED)或挂起态(WAITING_APPROVAL)"""
        while not session.is_terminal() and not session.is_suspended():
            try:
                self.tick(session)
            except Exception as exc:
                if not session.is_terminal():
                    session.status = SessionStatus.FAILED
                    session.error_reason = str(exc)
                    self._checkpoint(session)
                break
        return session

7. 实战演练:测试人工审批中断、跨进程恢复与死循环拦截

我们来写一个完整的验证脚本 demo_stateful_session.py,一次性验证本篇实现的三大核心能力:

  1. 动态策略触发 Human-in-the-loop 挂起:我们注册一个 run_shell_command(cmd: str) 工具,并挂载一个动态审批策略——只要命令里包含 rm 或 drop,自动挂起为 WAITING_APPROVAL 并将快照写入 ./checkpoints/sess_xxx.json。

  2. 模拟进程销毁与跨进程恢复(拒绝危险命令并附带人类指导):我们把内存里的 session 对象直接删掉(del session),模拟服务重启或三天后用户在网页上点击了“拒绝执行,请改用把过期日志移动到 /tmp/archive 的方式清理”。从磁盘加载 JSON 恢复 session_restored 后继续运行,看模型如何听取人类意见改用安全命令。

  3. 死循环检测器自动破局:模拟模型连续 3 次调用同一个报错参数,验证 ActionLoopDetector 自动拦截并注入 [SYSTEM LOOP INTERVENTION] 迫使模型换招。

import shutil
from typing import List

from approval import ApprovalDecision, ApprovalDecisionType, ApprovalRule
from react_adapter import ReActFormatAdapter
from runtime import LLMClientProtocol, LLMResponse
from schema import Message, TokenUsage
from session_v2 import AgentConfigV2, AgentSessionV2, FileSessionStore
from stateful_runtime import StatefulAgentRuntime, StatefulToolRegistry

# 1. 注册带动态安全审批策略的工具
registry = StatefulToolRegistry()


def is_dangerous_command(args: dict) -> bool:
    """动态审批策略:只读命令自动放行,包含 rm / mv / sudo 的修改类命令要求人工审批"""
    cmd = str(args.get("command", "")).lower()
    dangerous_tokens = ("rm ", "rm\t", "sudo ", "drop ", "mkfs")
    return any(tok in cmd for tok in dangerous_tokens)


@registry.register(approval_policy=is_dangerous_command)
def run_shell_command(command: str) -> str:
    """在受控环境中执行运维命令。"""
    if "ls" in command:
        return "app.log (12MB)  old_debug.log (850MB)  config.yaml"
    if "mv " in command:
        return f"Successfully archived files via: {command}"
    return f"Executed: {command}"


# 2. 构造模拟推理客户端:演示 [正常只读 -> 触发高危拦截 -> 听取人类拒绝意见 -> 完成任务]
class HITLSimulatedClient(LLMClientProtocol):
    def generate(self, messages: List[Message], tool_schemas: list, config: AgentConfigV2) -> LLMResponse:
        # 根据当前消息历史长度决定模型的下一步反应
        user_and_tool_msgs = [m for m in messages if m.role.value in ("user", "tool")]
        last_msg = user_and_tool_msgs[-1].content or ""

        if len(user_and_tool_msgs) == 1:
            # Step 1: 先执行 ls 查看文件(不触发审批,自动放行)
            raw = (
                "Thought: 我先用 ls 查看一下当前目录有哪些大日志文件。\n"
                "Action: run_shell_command\n"
                'Action Input: {"command": "ls -lh /var/log/app"}'
            )
        elif "old_debug.log" in last_msg:
            # Step 2: 发现 850MB 的日志,试图直接用 rm -rf 删除(将触发动态审批挂起!)
            raw = (
                "Thought: 发现 old_debug.log 占用了 850MB,我直接用 rm -f 把它删掉释放空间。\n"
                "Action: run_shell_command\n"
                'Action Input: {"command": "rm -f /var/log/app/old_debug.log"}'
            )
        elif "[HUMAN REJECTED ACTION]" in last_msg:
            # Step 3: 看到人类的拒绝原因后,改为执行安全的归档命令
            raw = (
                "Thought: 管理员拒绝了直接删除操作,并指示我将日志移动到 /tmp/archive 归档。我立即按要求调整命令。\n"
                "Action: run_shell_command\n"
                'Action Input: {"command": "mv /var/log/app/old_debug.log /tmp/archive/"}'
            )
        else:
            raw = (
                "Thought: 日志文件已经安全归档到了 /tmp/archive/。\n"
                "Final Answer: 已根据您的安全审批意见,将 850MB 的 `old_debug.log` 移动至 `/tmp/archive/` 完成归档清理。"
            )

        thought, calls = ReActFormatAdapter.parse_react_output(raw)
        return LLMResponse(content=thought, tool_calls=calls, usage=TokenUsage(150, 40, 100))


if __name__ == "__main__":
    store = FileSessionStore("./tmp_checkpoints")
    runtime = StatefulAgentRuntime(llm_client=HITLSimulatedClient(), tool_registry=registry, store=store)

    print("=== 阶段一:启动任务,直至触发高危指令挂起 ===")
    session = AgentSessionV2("帮我清理一下 /var/log/app 下占用空间过大的旧日志。")
    runtime.run_until_yield_or_finish(session)

    print(f"当前 Session ID     : {session.session_id}")
    print(f"当前状态机状态      : {session.status.value}")
    print(f"等待人工审批的调用  : {session.awaiting_approval_ids}")
    pending_tc = session.steps[-1].tool_calls[0]
    print(f"被拦截的高危工具调用: {pending_tc.name}({pending_tc.arguments})")

    # 模拟服务端释放内存或进程重启:完全丢弃原内存对象,从磁盘 JSON 快照中恢复
    saved_id = session.session_id
    del session

    print("\n=== 阶段二:从磁盘 Checkpoint 恢复会话,并提交人工拒绝与指导意见 ===")
    restored_session = store.load(saved_id)
    restored_session.submit_approval(
        ApprovalDecision(
            call_id=pending_tc.id,
            decision=ApprovalDecisionType.REJECT,
            feedback="生产环境禁止直接 rm 删除日志!请使用 mv 命令将其移动到 /tmp/archive/ 目录。",
        )
    )
    print(f"提交审批后状态机状态: {restored_session.status.value}")

    print("\n=== 阶段三:继续推进状态机直至完成 ===")
    runtime.run_until_yield_or_finish(restored_session)

    for step in restored_session.steps:
        print(f"\n[Step {step.step_index}] Thought: {step.thought}")
        for tc, obs in zip(step.tool_calls, step.observations):
            print(f"  -> Action     : {tc.name}({tc.arguments})")
            print(f"  <- Observation: (is_error={obs.is_error}) {obs.output}")
    print(f"\n[Final Status]: {restored_session.status.value}")
    print(f"[Final Answer]: {restored_session.final_answer}")

    shutil.rmtree("./tmp_checkpoints", ignore_errors=True)

运行这段代码,输出轨迹清晰地展示了两阶段状态机的威力:

=== 阶段一:启动任务,直至触发高危指令挂起 ===
当前 Session ID     : sess_4d91a8c2e10f
当前状态机状态      : waiting_approval
等待人工审批的调用  : ['call_9a1b2c3d']
被拦截的高危工具调用: run_shell_command({'command': 'rm -f /var/log/app/old_debug.log'})

=== 阶段二:从磁盘 Checkpoint 恢复会话,并提交人工拒绝与指导意见 ===
提交审批后状态机状态: executing_tools

=== 阶段三:继续推进状态机直至完成 ===

[Step 1] Thought: 我先用 ls 查看一下当前目录有哪些大日志文件。
  -> Action     : run_shell_command({'command': 'ls -lh /var/log/app'})
  <- Observation: (is_error=False) app.log (12MB)  old_debug.log (850MB)  config.yaml

[Step 2] Thought: 发现 old_debug.log 占用了 850MB,我直接用 rm -f 把它删掉释放空间。
  -> Action     : run_shell_command({'command': 'rm -f /var/log/app/old_debug.log'})
  <- Observation: (is_error=True) [HUMAN REJECTED ACTION] Reason: 生产环境禁止直接 rm 删除日志!请使用 mv 命令将其移动到 /tmp/archive/ 目录。

[Step 3] Thought: 管理员拒绝了直接删除操作,并指示我将日志移动到 /tmp/archive 归档。我立即按要求调整命令。
  -> Action     : run_shell_command({'command': 'mv /var/log/app/old_debug.log /tmp/archive/'})
  <- Observation: (is_error=False) Successfully archived files via: mv /var/log/app/old_debug.log /tmp/archive/

[Step 4] Thought: 日志文件已经安全归档到了 /tmp/archive/。

[Final Status]: completed
[Final Answer]: 已根据您的安全审批意见,将 850MB 的 `old_debug.log` 移动至 `/tmp/archive/` 完成归档清理。

仔细体会一下上面 Step 2 发生的事情:

  1. Step 1 的 ls -lh 因为不命中 is_dangerous_command 规则,静默、快速地自动执行通过了;

  2. Step 2 模型想执行 rm -f,在 Phase A 结束准备进 Phase B 的瞬间被 StatefulToolRegistry.needs_approval 精准拦下,进程把状态存盘为 waiting_approval 后安全退出;

  3. 当我们从磁盘重新 load 回这个 Session 并提交 REJECT 决策后,runtime.tick() 看到 Step 2 的 pending_call_ids 还没消化完,没有浪费任何 Token 去重跑 Step 2 的 LLM 推理,而是直接把人类的拒绝理由化作 Step 2 的 Observation 闭合了这一步,随后平滑地进入 Step 3!


小结

到这一篇为止,我们完成了第一阶段(最小闭环与核心状态机)的全部三块拼图。

回顾一下我们在这一篇里做的关键架构升级:

  • 将单步黑盒拆解为两阶段可中断状态机(Phase A: Decision / Phase B: Execution):通过维护 pending_call_ids 和 awaiting_approval_ids,把执行进度从 Python 函数调用栈彻底转移到了可序列化的 AgentSessionV2 数据结构中。

  • 实现了工业级 Human-in-the-loop(人工审批)工作流:支持在工具上绑定静态开关与基于实际参数的动态策略函数 approval_policy,支持 APPROVE(原样放行)、EDIT(篡改参数后放行)和 REJECT(拦截动作并将人工反馈注入 Observation)三种原语。

  • 建立了基于规范化哈希指纹的死循环免疫机制:通过递归排序 JSON Key 计算 SHA-256 动作指纹,在滑动窗口上实时拦截周期为 1 的原地踏步与周期为 2 的乒乓震荡,并通过注入 [SYSTEM LOOP INTERVENTION] 警告引导模型主动跳出局部死胡同。

  • 实现了基于临时文件原子替换(mkstemp + os.fsync + os.replace)的 Checkpoint 存储器:确保无论进程在何时崩溃,磁盘上的会话快照永远处于一致且可恢复的状态。

完成了状态机底座之后,从下一篇(第 4 篇)开始,我们将进入第二阶段:《结构化输出与工具调度引擎》。

在第 4 篇 《结构化输出与自修复:别让 JSON 解析毁了整条链路》 中,我们将把视角再次打通到底层推理引擎——不仅在上层实现基于 Schema AST 的语法自修复,还会联动我们在 mini vLLM 第 11 篇写的 Sampling 系统(LogitsProcessor),手写一个轻量级的 有限状态机约束解码器(FSM-Guided Constrained Decoding),从数学和 Logits 掩码层面让模型连输出非法 JSON 的机会都没有!