Agent开发 2.2 - Turn-Step 双层运行时 Harness:解耦业务周期与环境微步

2026-10-20 | 76分钟 | yrobot | Agent开发, 控制流, Turn-Step, Harness, 事件循环, ToolCallRecovery, 悬空调用, 状态机

在学习与实验阶段,绝大多数开发者接触到的 Agent 示例代码都是一个单层 while 循环:模型根据用户输入判断是否调用工具,调用完将结果写回上下文,再次推演,直到输出最终文本跳出循环。这就是著名的 ReAct(Reason + Act) 推演范式。

这种实现通常被封装为一个一次性的黑盒函数(例如 runReAct(prompt)),在单次提问的 Demo 中运行良好。然而,在真实工程实践中,ReAct 只是解决单步“如何思考与采取行动”的微观算法范式,并非一个完整的运行时架构。

很多开发者会产生一个直觉疑问:

“既然单层 ReAct 循环里也可以做死循环检测、也可以做输出截断,为什么工业级 Agent(如 Claude Code 或企业级微内核)一定要在 ReAct 外面再套一层 Turn 与 Step 的双层 Harness(运行时容器)?”

答案是:Step 的内部本质就是一次 ReAct 循环的原子跳动;而 Turn 的引入,并不是为了取代 ReAct,而是为了补齐单层 ReAct 函数根本无法表达的“业务生命周期边界”。

本文将还原 ReAct 与 Turn-Step 的真实物理关系,重点解构那些必须依赖 Turn 的边界才能成立的核心能力(双通道分流、ToolCallRecovery 事务平账与配额生命周期),并提供一套高信噪比的 TypeScript 双层微内核实现。


概念厘清:ReAct 与 Turn-Step 的真实关系

要理清控制流,首先打破“ReAct 对立于 Turn-Step”的虚假二元对立:

  • ReAct 是微观推理机制(Micro-loop):定义了模型在面对环境时“思考(Thought)-> 行动(Action)-> 观察(Observation)”的自回归单步推进过程。在双层架构中,每一个 Step 内部执行的正是标准的 ReAct。
  • Turn 是宏观业务事务(Macro-transaction):定义了从接收外部事件(人类提问、定时触发),到当前业务阶段完成、交还人类控制权的完整业务生命周期。
  • Harness 是运行时容器:它不是算法,而是一套宿主控制系统。它负责在 ReAct 循环外层管理会话状态机、调度收件箱、并在业务轮次结束时提供事务级防御。

为什么单靠单层 ReAct 函数无法支撑生产服务?

单层 ReAct 函数通常被写成一个孤立的异步函数:输入一个 Prompt,内部 while(true),返回最终结果。这种“单次黑盒脚本”一旦接入长时间运行的交互系统,就会暴露出三个只有引入 Turn 才能解决的边界问题:

1. 语义分流困境:新业务目标 (Followup) 还是当前微步插队 (Steering)?

当 Agent 正在执行一个长程任务(例如重构 10 个代码文件),此时外部用户又发送了一条新消息:“背景颜色用深灰,不要浅灰”。

如果系统只有一个扁平的 ReAct 循环与单一消息队列:

  • 这条消息到底该排队等待前 10 个文件全部改完后再作为下一轮对话处理(next-turn)?
  • 还是应该在改到第 3 个文件之前,立刻注入进当前单步的上下文让模型及时纠偏(next-step)?

单层 ReAct 函数没有“当前是否正在运行一个业务事务(Turn)”的状态界定,无法在协议层面将跨轮次后续任务(Followup)与轮次内微步纠偏(Steering)清晰分流。

2. 事务性对账边界:悬空工具调用 (Dangling Tool Call) 与 API 400 暴毙

很多开发者在初次将 Agent 接入生产时,最常遭遇一个诡异的报错:用户点了一次取消或某次工具调用超时后,整条会话历史就彻底坏死,之后无论发什么新消息,大模型 API 都持续抛出 HTTP 400 错误。

这背后是大模型函数调用接口的一条底层硬契约:

如果一条 assistant 消息中包含了 tool_calls(发起了工具调用),那么紧随其后的上下文消息中,必须包含对应每个 tool_call_id 的 tool 消息作为执行结果反馈。

如果在某个单步执行工具时,用户突然点击了取消按钮,或者网络超时进程退出:

  • 如果代码直接退出,上下文里就会留下发起了 call_id 却没有任何对应结果的 assistant 消息。这种情况在工程上被称为悬空工具调用(Dangling Tool Call);
  • 下一次发起请求时,大模型服务端校验到借条未平,直接拒绝执行并返回 HTTP 400 Bad Request。整条历史记录永久中毒。

为什么这个平账逻辑不能放在单步,而必须依赖 Turn 的生命周期?

  • 在微步(Step)内部,工具报错或重试属于正常的业务探索,绝不能盲目合成中断假回执,因为模型可能在下一步自我纠错;
  • 只有当整个业务轮次(Turn)被判定为彻底终止(用户触发 Abort 或发生致命异常退出了外层循环),运行时才能断定“未决的工具调用永远不会有结果了”,必须在此刻进行统一对账与兜底补齐。 这个统一平账的 finally 阶段,其作用域正是 Turn。

3. 配额熔断后的交还与清零机制

自主推演必须设置最大步数限制(例如 15 步)以防模型失控。

  • 在单层 ReAct 里,step >= 15 后执行 break,整个函数直接返回或报错,任务处于僵死状态;
  • 引入 Turn 之后:15 步耗尽代表当前 Turn 阶段性执行完毕,主动交还控制权给人类。当人类追加说明“换种方式继续”时,开启的是一个全新的 Turn,步数计数器在 Turn 边界清零重置,模型重新获得 15 步额度。 步数的生老病死,必须依托 Turn 的生命周期。

Turn 与 Step 的物理定义与职责划分

为了解决上述问题,工业级运行时将控制流明确拆解为外层与内层两个具有正交生命周期的层级:

维度 Turn(业务轮次) Step(环境微步)
物理定义 由外部事件(用户提问、定时触发)驱动的端到端业务交付周期 系统内部自回归驱动的单次原子推理与环境交互切片
触发主体 外部实体(人类用户、系统调度器) 模型自身决策(Tool Call 派发)
生命周期 从接收输入到交付结果并交还人类控制权(秒至数分钟) 单次 LLM 推理加工具执行(毫秒至数秒)
核心职责 负责业务目标交付、全局会话持久化、重置单轮配额 负责单步工具分发、执行超时控制、上下文防爆与死循环拦截
[用户提问 / 外部输入]
         │
         ▼
┌─【Turn 业务轮次开始】──────────────────────────────────────────────────┐
│  - 初始化单轮步数计数器 (step = 0)                                      │
│  - 挂载本轮工具调用对账账本 (ToolCallRecovery)                           │
│                                                                        │
│   ┌─【Step 环境微步循环】(step < maxSteps) ────────────────────────┐   │
│   │  1. 检查是否存在中途插队纠偏消息 (Steering),有则追加至上下文  │   │
│   │  2. 发起单次 LLM 推理                                          │   │
│   │  3. 若输出纯文本回复且无工具调用:标记 done = true 并跳出微步循环│   │
│   │  4. 若发起工具调用:                                            │   │
│   │     - 登记待对账 callId                                        │   │
│   │     - 校验死循环调用指纹 (Fingerprint)                         │   │
│   │     - 执行工具并执行大体积截断防护 (Truncation)                │   │
│   │     - 注销已平账 callId 并写回 tool 消息                       │   │
│   └────────────────────────────────────────────────────────────────┘   │
│                                                                        │
│  - 离开 Turn (finally):                                               │
│    统一平账悬空工具调用 (ToolCallRecovery) 补齐回执                     │
│    触发会话持久化与状态事件,将控制权交还人类                           │
└────────────────────────────────────────────────────────────────────────┘
flowchart TD
    Start(["外部输入到达"]) --> TurnStart["Turn 开始:重置 step=0,挂载 Recovery 账本"]
    TurnStart --> CheckSteer{"是否存在中途插队消息?"}
    CheckSteer -- 是 --> AppendSteer["将纠偏消息并入当前上下文"] --> LLMCall
    CheckSteer -- 否 --> LLMCall["执行单步 LLM 推理"]
    
    LLMCall --> Decision{"模型决策"}
    Decision -- 最终文本回复 --> TurnEnd["Turn 正常完成:交还控制权给人类"]
    Decision -- 发起工具调用 --> StepGuard{"单步安全检查
(步数上限 / 死循环指纹)"} StepGuard -- 触发熔断 --> InjectBreak["注入系统熔断消息"] --> TurnEnd StepGuard -- 校验通过 --> ExecTool["执行工具调用并实施输出截断"] ExecTool --> WriteToolMsg["写回 tool 消息,平账 callId"] WriteToolMsg --> NextStepCheck{"step < maxSteps?"} NextStepCheck -- 是 --> CheckSteer NextStepCheck -- 否 --> MaxStepHalt["步数耗尽强行收敛"] --> TurnEnd TurnEnd --> FinallyBlock["Turn 收尾阶段 (finally):
检查未决 callId,合成虚拟回执修复悬空"] FinallyBlock --> IdleState(["进入 Idle 状态,等待下一轮 Turn"])

核心防御围栏与双层架构的绑定机理

许多开发者容易产生一个误解:认为工具调用监控、死循环拦截、防爆截断只是通用的防护围栏,随便写在单层 while 循环里也是一样的。

但在工程实现中,这些防御围栏的生命周期、作用域与清零规则,必须严格依托于 Turn 与 Step 的边界划分。如果层级错位,防护机制本身就会破坏系统可用性。

1. 最大步数熔断门 (Max Steps Guard):生命周期依托于 Turn

在任何自主推演系统中,必须设置单轮最大执行步数限制(例如 maxSteps = 15),以防模型在遭遇意外时无休止消耗算力。

  • 为什么必须依附于 Turn? 如果一个复杂任务经过 15 步未能全部完成,系统触发熔断,停止内层微步循环,并将当前进度和阻碍汇报给人类(“已执行 15 步,目前受阻于接口权限,请指示”)。 此时,本轮 Turn 正式结束,控制权回到用户手中。 当用户回复“权限已开通,请继续”时,系统开启一个全新的 Turn,此时 stepCount 必须清零重置,重新赋予模型 15 步的探索额度。 如果缺乏 Turn 的生命周期界定,单层循环在熔断跳出后,步数计数器将无法找到重置的时机,导致后续会话陷入一调即熔断的死锁。

2. 悬空对账器 (ToolCallRecovery):统一在 Turn 的收尾阶段平账

如前文剖析,大模型函数调用协议要求借还凭据必须严格成对。为了彻底杜绝异常中断留下的悬空调用引发 API 400 暴毙,运行时必须引入 ToolCallRecovery 并在 Turn 的 finally 阶段执行对账:

  1. 模型在 Step 发起工具调用时,在账本中登记所有 call_id;
  2. 工具正常执行完毕并写回消息后,在账本中划销该 call_id;
  3. 一旦整个 Turn 因任意非正常原因中断退出,在 finally 阶段检查账本:对所有尚未划销的 call_id,统一由系统自动合成一条虚拟的错误/中断回执写回消息列表:
    {
      "role": "tool",
      "tool_call_id": "call_123",
      "content": "【系统回执】该工具调用在完成前已被中断,执行结果未知。"
    }
    通过这一步平账兜底,确保无论系统如何异常崩溃,存盘的历史消息格式永远 100% 满足 API 契约,彻底杜绝上下文损坏。

3. 微步死循环指纹拦截 (Fingerprint Guard)

模型在处理报错或边界条件时,有时会陷入自回归强迫症:使用完全相同的参数连续反复调用同一个报错工具。

  • 判定规则:将 toolName 与序列化后的 arguments 拼接生成单步签名指纹。若在连续的 Step 中检测到完全相同的指纹,判定为陷入死循环,立即拦截并向模型注入引导提示。
  • 生命周期归属:指纹缓存库必须在 Turn 级别创建与销毁。在同一个 Turn 内部,连续两次相同调用属于死循环;而在不同的 Turn 中,用户可能在隔天要求重新查询同一配置,此时发起相同参数的调用是完全合法的业务操作。

4. 单步大体积输出截断与落盘 (Spill to Disk)

若单步工具执行返回了上百兆的系统日志或数据库全量转储,直接塞入消息列表会瞬间击穿上下文窗口,引发注意力稀释或 Token 配额报错。

工程上通常采用两道阶梯式防御:

  • 首尾截断(Head-Tail Truncation):针对普通大文本(如几万字符的报错堆栈),保留前 20% 开头(错误概览)与后 20% 结尾(最深层崩溃堆栈),中间用截断声明替代。0 延迟、0 额外成本,兼顾上下文安全与关键排错信息。
  • 落盘转存(Spill to Disk):针对数十兆以上的海量数据,工具执行层将其存入本地临时文件,仅向模型返回文件句柄与元信息,引导 Agent 自主使用 grep 或按行读取工具进行细粒度排查。

最小 TypeScript 双层状态机微内核实现

以下是一份去除了所有无关业务逻辑的极简微内核实现,完整演示了 Turn 边界管理、Step 推进、死循环指纹比对与悬空调用自动对账平账:

interface ToolCall { id: string; name: string; args: string; }
interface Message {
  role: 'user' | 'assistant' | 'tool';
  content: string;
  toolCalls?: ToolCall[];
  toolCallId?: string;
}

// 悬空调用对账器
class ToolCallRecovery {
  private pending = new Map<string, string>(); // callId -> toolName

  track(callId: string, name: string): void { this.pending.set(callId, name); }
  complete(callId: string): void { this.pending.delete(callId); }

  // 在 Turn 退出时为所有未平账的调用合成兜底回执,防止 API 400
  synthesizeMissing(): Message[] {
    const receipts: Message[] = [];
    for (const [callId, name] of this.pending.entries()) {
      receipts.push({
        role: 'tool',
        toolCallId: callId,
        content: `【系统兜底】工具 ${name} 执行被异常中断,结果未知。`,
      });
    }
    this.pending.clear();
    return receipts;
  }
}

export class DualLayerHarness {
  private messages: Message[] = [];
  private maxStepsPerTurn = 10;

  // 外层:Turn 业务周期
  async runTurn(userInput: string, signal?: AbortSignal): Promise<void> {
    this.messages.push({ role: 'user', content: userInput });
    const recovery = new ToolCallRecovery();
    let step = 0;
    let lastFingerprint: string | null = null;

    try {
      // 内层:Step 环境微步自回归循环
      while (step < this.maxStepsPerTurn) {
        if (signal?.aborted) break;
        step++;

        const response = await this.mockModelInference(this.messages);

        // 分支 A:模型输出最终答复,无工具调用,正常结束本 Turn
        if (!response.toolCalls || response.toolCalls.length === 0) {
          this.messages.push({ role: 'assistant', content: response.content });
          break;
        }

        // 分支 B:模型发起工具调用,进入环境交互
        this.messages.push({ role: 'assistant', content: response.content, toolCalls: response.toolCalls });

        for (const call of response.toolCalls) {
          // 防御 1:死循环指纹比对
          const fingerprint = `${call.name}:${call.args}`;
          if (fingerprint === lastFingerprint) {
            this.messages.push({
              role: 'tool',
              toolCallId: call.id,
              content: `【拦截】检测到连续发起完全相同的工具调用,请调整参数或换用其他方案。`,
            });
            continue;
          }
          lastFingerprint = fingerprint;

          // 防御 2:登记待平账调用,执行工具
          recovery.track(call.id, call.name);
          const rawOutput = await this.mockExecuteTool(call);

          // 防御 3:首尾截断防护,单步输出最大限制 4000 字符
          const safeOutput = rawOutput.length > 4000
            ? `${rawOutput.slice(0, 1500)}\n...[中间省略 ${rawOutput.length - 3000} 字符]...\n${rawOutput.slice(-1500)}`
            : rawOutput;

          recovery.complete(call.id);
          this.messages.push({ role: 'tool', toolCallId: call.id, content: safeOutput });
        }
      }

      if (step >= this.maxStepsPerTurn) {
        this.messages.push({ role: 'assistant', content: '【熔断】单轮已达最大探索步数,请提供进一步指引。' });
      }
    } finally {
      // 核心平账防线:无论中途抛错还是 Abort 中断,统一补齐悬空回执
      const missingReceipts = recovery.synthesizeMissing();
      for (const receipt of missingReceipts) {
        this.messages.push(receipt);
      }
    }
  }

  private async mockModelInference(msgs: Message[]): Promise<{ content: string; toolCalls?: ToolCall[] }> {
    return { content: '执行推演中...', toolCalls: [] };
  }

  private async mockExecuteTool(call: ToolCall): Promise<string> {
    return '执行结果';
  }
}

架构权衡与技术演进视角

从单层裸奔循环走向双层 Harness 运行时,本质上是将 Agent 从“一段一次性的算法脚本”提升为一个“工业级操作系统微内核”:

  • 确定性与鲁棒性:Turn 与 Step 的分离明确了会话生命周期与原子调用的职责边界。ToolCallRecovery 与死循环指纹从物理机制上杜绝了上下文污染与无限消耗;
  • 人机协同基础设施:双层结构为更复杂的交互打下了底座。有了 Step 边界,中途插话纠偏(Steering)才有了精准的插入点;有了 Turn 边界,人机协同(Human-in-the-loop)的断点挂起与恢复才具备清晰的上下文快照边界。

在后续章节中,我们将进一步基于这套双层底座,探讨长程任务推进中的双通道收件箱机制与动态 Steering 纠偏的工程落地。