# 工作流与单Agent

## 先决定谁控制下一步

“读取工单、检索制度、生成建议、等待确认、写入备注”可以由固定程序顺序控制，这通常叫工作流。若模型根据当前结果选择下一项工具、决定是否继续查询或结束，则具有 Agent 的控制特征。二者可以结合：外层工作流定义边界，某一步允许模型在受限工具集合里选择行动。

本章用于解决一个实际问题：怎样让助手完成小型业务任务，同时让工程师能解释它做了什么。达标后，你应能为单 Agent 建立状态机与执行边界，并说明为何某一步允许自主执行，另一步必须等待具体确认。前置是工具循环、结构校验、权限和证据意识。

不要因为任务带“AI”就让模型决定所有流程。固定的报表步骤适合工作流；开放式故障调查可能需要动态选择查询。先用最简单可评测的控制方式交付，再根据真实失败增加灵活性。框架可以管理循环与追踪，但不会替你定义业务风险与完成标准。[OpenAI Agents SDK 概览](https://developers.openai.com/api/docs/guides/agents)

## 单 Agent 的核心是受控状态变化

一个实用运行状态至少包含 taskId、目标、已确认约束、已获取事实、待执行动作、已完成操作、步骤计数和终止原因。状态必须能区分 pending、running、needs_confirmation、completed、failed、cancelled。用户确认期间不是一直占着线程等待；可以持久化状态，收到确认后恢复。

模型只提出候选动作，执行器检查工具白名单、参数、身份、权限、前置事实与预算。完成也不能仅凭模型说“全部完成”。如果目标是写入工单备注，程序应确认写入接口成功且能关联实际记录；如果写入结果未知，应进入待核实状态，不能自动重复创建。

停止条件至少有目标完成、步数耗尽、活动时间耗尽、用户取消、需要确认和不可恢复错误。还可以检测相同调用与相同结果重复出现的无进展循环。模型的“再试一次”不是无限预算授权；程序应独立执行这些限制。

## 幂等与确认的实际含义

幂等不是每次调用都生成一个新 UUID。它要求同一个业务操作重复到达时返回相同结果或等价效果，而不重复产生副作用。操作标识由应用保存，绑定任务和具体动作；参数变化后不能复用旧确认与旧结果。数据库通常需要唯一约束、事务及操作状态，才能处理多进程并发。

确认也不是一个随请求传来的布尔值。它应绑定确认用户、动作内容、目标对象、版本或金额、有效期与操作 ID。前端先展示实际要执行的内容，用户确认后由可信业务服务保存凭据。模型不能通过生成 approved=true 让自己获得权限。已有授权覆盖的只读步骤无需反复询问，确认应放在需要它的具体动作前。

## 完整示例：暂停、恢复与只写一次

环境：Node.js 22；文件 `agent.mjs`；不安装依赖；执行 `node agent.mjs`。模型选择由固定函数模拟，确认记录也由模拟 UI 代码建立，全部数据在内存。这里不连接工单系统，不实现分布式事务。

```js
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';

const session = { userId: 'u1' };
const ticket = { id: 'T1', owner: 'u1', notes: [] };
const state = { taskId: 'task-1', steps: 0, facts: null, written: false,
  activeRemainingMs: 1000 };
const operations = new Map();
const writeAction = { name: 'append_note', ticketId: 'T1', text: '已核对，待客服跟进。' };
const digest = action => createHash('sha256').update(JSON.stringify(action)).digest('hex');

async function withTimeout(fn, milliseconds) {
  if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('活动时间耗尽');
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new Error('步骤超时')), milliseconds);
  try { return await fn(controller.signal); }
  finally { clearTimeout(timer); }
}

async function mockDecide(signal) {
  await sleep(2, undefined, { signal }); // 此模拟任务实际响应取消信号。
  if (!state.facts) return { name: 'read_ticket', ticketId: 'T1' };
  if (!state.written) return writeAction;
  return { name: 'finish' };
}

async function execute(action, approval, signal) {
  signal.throwIfAborted();
  if (action.ticketId !== ticket.id || ticket.owner !== session.userId) {
    throw new Error('无权访问该工单');
  }
  if (action.name === 'read_ticket') {
    await sleep(2, undefined, { signal });
    return { status: 'ok', facts: { id: ticket.id, noteCount: ticket.notes.length } };
  }
  if (action.name !== 'append_note' || typeof action.text !== 'string' ||
      !action.text.trim() || action.text.length > 120) throw new Error('非法动作');
  const operationId = state.taskId + ':note-1';
  const fingerprint = digest(action);
  const saved = operations.get(operationId);
  if (saved) {
    if (saved.fingerprint !== fingerprint) throw new Error('幂等键参数冲突');
    return saved.result;
  }
  if (!approval || approval.userId !== session.userId ||
      approval.operationId !== operationId || approval.digest !== fingerprint ||
      !Number.isFinite(approval.expiresAt) || approval.expiresAt <= Date.now()) {
    return { status: 'needs_confirmation', operationId, digest: fingerprint, action };
  }
  signal.throwIfAborted();
  // 单进程同步临界段；真实服务要以事务保证写入和操作结果共同提交。
  ticket.notes.push(action.text);
  const result = { status: 'ok', noteId: 'note-' + ticket.notes.length };
  operations.set(operationId, { fingerprint, result });
  return result;
}

async function run(approval) {
  const started = performance.now();
  const remaining = () => state.activeRemainingMs - (performance.now() - started);
  try {
  while (state.steps < 6) {
    state.steps += 1; // 步数跨确认恢复保留，不因恢复而重置。
    const action = await withTimeout(mockDecide, Math.min(100, remaining()));
    if (action.name === 'finish') {
      if (!state.written) throw new Error('实际目标尚未完成');
      return { status: 'completed' };
    }
    const result = await withTimeout(
      signal => execute(action, approval, signal), Math.min(100, remaining()));
    if (result.status === 'needs_confirmation') return result;
    if (action.name === 'read_ticket') state.facts = result.facts;
    if (action.name === 'append_note') state.written = true;
  }
  throw new Error('步骤预算耗尽');
  } finally {
    // 活动消耗跨恢复保留；等待用户确认的空闲时间不计入此预算。
    state.activeRemainingMs = Math.max(0, remaining());
  }
}

const pending = await run();
console.log(pending.status);
const invalidApproval = { userId: 'u1', operationId: pending.operationId,
  digest: pending.digest }; // 缺失过期时间不能获得执行权。
assert.equal((await withTimeout(signal => execute(writeAction, invalidApproval, signal), 100))
  .status, 'needs_confirmation');
assert.equal(ticket.notes.length, 0);
const approval = { // 模拟用户看过 pending.action 后，服务端保存的确认记录。
  userId: 'u1', operationId: pending.operationId, digest: pending.digest,
  expiresAt: Date.now() + 60000,
};
console.log((await run(approval)).status);
await withTimeout(signal => execute(writeAction, approval, signal), 100);
console.log('实际备注条数=' + ticket.notes.length);
assert.equal(ticket.notes.length, 1);
state.activeRemainingMs = 0;
await assert.rejects(() => run(approval), /活动时间耗尽/);
await assert.rejects(() => withTimeout(signal => sleep(20, undefined, { signal }), 1),
  error => error.name === 'AbortError');
```

预期输出 needs_confirmation、completed、实际备注条数为一；随后静默通过缺失确认有效期、活动预算耗尽和计时器取消断言。最后一行故意重复执行同一写操作，幂等表使它不再次插入。若修改确认摘要或用户身份，写入不会发生；若复用操作 ID 但改变备注内容，会得到参数冲突。

withTimeout 通过取消信号通知工作，演示中的异步函数实际接受该信号。它不是能强制杀死任意 Promise 的魔法：真实 SDK 若忽略 signal，超时后副作用仍可能发生，应把状态标为结果待核实，并调用操作查询接口。CPU 密集计算还需要工作线程或进程层的资源控制。

## 从演示到可恢复系统

生产状态应保存到持久存储。每步执行前记下计划与操作 ID，执行后记录结果；恢复时先检查已经完成的操作，再决定是否继续。网络超时且写入状态未知时，应查询同一操作 ID，而不是生成新 ID 重来。不同副作用还有不同补偿方式，撤销备注与撤销已发送邮件并不等价。

权限应在每次真正执行时重查，因为确认后角色可能被撤销，对象版本也可能改变。缓存确认只是说明用户曾同意某个内容，不意味着用户永远拥有执行权。需要版本约束的操作在写入时做条件更新，若数据已变化，重新生成预览并再次确认变化部分。

工具设计应帮助系统观察进展。返回对象 ID、状态码与可核实结果，比返回“成功啦”更容易恢复和评测。Agent 的最终自然语言回复应根据执行记录生成，不应夸大成已经完成未发生的动作。界面可展示已完成步骤、当前待确认内容和失败位置，避免把复杂任务压成一个永不结束的加载动画。

对于循环效率，记录每一步的耗时、输入输出用量、工具结果码以及是否带来新信息。如果工具连续返回相同内容，应该停止或改变策略，而不是靠增加步数解决。评测应包含工具不可用、确认过期、权限撤销、重复提交、重启恢复和超时后结果未知。

工作流与 Agent 还可以分层组合。外层固定“读取、建议、确认、提交”，中间建议阶段允许模型多次检索；提交阶段仍走确定性服务。这样的结构保留必要灵活性，又能给关键副作用明确边界。多 Agent 只有在任务能独立分工、上下文隔离和结果验收清楚时才可能有收益，不是本章的默认升级方向。

## 用状态机把“想做”和“已经做”分开

Agent 的推理过程可能很开放，业务状态却应尽量明确。“准备写备注”是计划，“已经发送请求”是执行中，“收到成功记录”才是已完成。若三种状态都叫 running，进程重启时就不知道该重新调用模型、重复工具还是查询结果。状态机的目的不是把所有思考变成固定步骤，而是给每一个可能产生后果的动作留下可恢复的位置。

可以将状态分成任务层和操作层。任务层描述目标是否完成、是否等待用户、预算是否耗尽；操作层描述某个具体写入是否计划、执行中、已提交或结果未知。一个任务可能在等待第二个动作确认时，已经完成第一个只读查询。把整个任务标为失败不应抹掉已完成事实，否则用户重试时容易重复做已成功的事情。

转换条件应由执行器检查。模型可以建议“任务完成”，但执行器应核对验收条件；用户可以点击“继续”，但服务端应核对待确认内容版本；工具可以返回一段“成功”文本，但业务完成还需要可核实的对象编号与结果码。自然语言用于表达意图和解释，确定性条件用于决定状态是否可以推进。

事件与状态快照各有作用。事件记录发生过什么，快照让恢复不必重放全部历史。可以为每个事件分配递增序号，以期望版本提交，防止两个工作进程同时修改同一任务。事件内容要足以解释业务变化，却不应默认保存完整敏感上下文。保存“确认了哪个动作摘要”通常比保存整段聊天更直接。

## 示例二：一个可以序列化恢复的任务状态机

环境为 Node.js 22，无需安装。保存为 `agent-state.mjs`，执行 `node agent-state.mjs`，预期输出 completed、版本四、已规划步骤一。程序用 JSON 序列化模拟保存与恢复，不写磁盘；APPROVE 事件假定来自已经完成身份和确认校验的执行器，事件类型本身不能授予权限。

```js agent-state.mjs
import assert from 'node:assert/strict';

function initial(taskId, maxSteps = 4) {
  if (!taskId || !Number.isInteger(maxSteps) || maxSteps < 1) {
    throw new Error('INVALID_TASK');
  }
  return { taskId, version: 0, status: 'created',
    steps: 0, maxSteps, pending: null, result: null };
}
function reduce(state, event) {
  if (event.version !== state.version + 1) throw new Error('STALE_EVENT');
  if (['completed', 'cancelled'].includes(state.status)) {
    throw new Error('TERMINAL_STATE');
  }
  const next = structuredClone(state);
  if (event.type === 'START' && state.status === 'created') {
    next.status = 'running';
  } else if (event.type === 'PLAN' && state.status === 'running') {
    if (state.steps >= state.maxSteps) throw new Error('STEP_LIMIT');
    if (!event.operation?.id || !event.operation?.digest) {
      throw new Error('INVALID_OPERATION');
    }
    next.steps += 1;
    next.pending = structuredClone(event.operation);
    next.status = 'awaiting_confirmation';
  } else if (event.type === 'APPROVE' &&
             state.status === 'awaiting_confirmation') {
    if (event.digest !== state.pending.digest) throw new Error('CHANGED_ACTION');
    next.status = 'running';
  } else if (event.type === 'APPLIED' && state.status === 'running') {
    if (!state.pending || event.operationId !== state.pending.id ||
        typeof event.resultId !== 'string' || !event.resultId) {
      throw new Error('RESULT_MISMATCH');
    }
    next.status = 'completed';
    next.result = { operationId: event.operationId, resultId: event.resultId };
    next.pending = null;
  } else if (event.type === 'CANCEL') {
    next.status = 'cancelled';
  } else {
    throw new Error('INVALID_TRANSITION');
  }
  next.version = event.version;
  return next;
}
let state = initial('task-1');
state = reduce(state, { type: 'START', version: 1 });
state = reduce(state, { type: 'PLAN', version: 2,
  operation: { id: 'op-1', digest: 'fixture-digest' } });
const restored = JSON.parse(JSON.stringify(state));
assert.equal(restored.status, 'awaiting_confirmation');
assert.throws(() => reduce(restored, { type: 'APPROVE',
  version: 3, digest: 'different' }), /CHANGED_ACTION/);
assert.throws(() => reduce(restored, { type: 'APPROVE',
  version: 2, digest: 'fixture-digest' }), /STALE_EVENT/);
state = reduce(restored, { type: 'APPROVE', version: 3, digest: 'fixture-digest' });
state = reduce(state, { type: 'APPLIED', version: 4,
  operationId: 'op-1', resultId: 'note-1' });
assert.equal(state.status, 'completed');
assert.throws(() => reduce(state, { type: 'START', version: 5 }),
  /TERMINAL_STATE/);
console.log({ status: state.status, version: state.version, steps: state.steps });
```

reduce 是纯状态转换函数：没有网络，也不执行备注写入，因此可以用固定事件验证边界。它检查版本递增，防止陈旧事件覆盖新状态；检查动作摘要，防止用户批准后参数被改动；检查结果关联，防止把另一操作的成功误当成本任务完成。真实系统还要在持久存储中原子比较版本，仅在内存先比较再写入数据库会有竞争窗口。

示例的 PLAN 消耗一次规划步骤，不按工具内部的数据库查询次数计步。这是一个明确的应用定义。生产系统往往同时限制模型调用数、工具调用数与总成本，不能把所有资源都折算成一个不透明的“步数”。用户等待确认期间通常不应持续消耗活动执行时间，但确认本身仍应有独立有效期。

APPLIED 事件必须由真正执行工具的代码产生，而不是让模型输出一个同名 JSON 对象就进入 reducer。事件来源也是信任边界。可以用不同模块和参数类型把外部模型候选与内部已验证事件隔开；在 JavaScript 中，模块接口和运行时校验比一个随手填写的 role 字段更有约束力。

## 超时、取消与结果未知的因果关系

超时说明调用方在期限内没有得到结果，不说明被调用方没有完成工作。读取操作重试通常只增加成本，写入操作重试可能重复发货、重复发消息或重复扣减库存。若工具不能保证幂等，执行器就必须把网络超时标为 unknown，并转入查询、人工核对或明确补偿流程。不能为了让 UI 看起来简单，把 unknown 归并成 failed。

取消也有传播范围。AbortSignal 可以通知支持它的 fetch、计时器或 SDK 停止等待；已经提交到外部系统的写入可能无法撤回。界面上的取消按钮可以停止后续规划与新操作，但已发生的动作需要显示实际状态。取消与撤销应采用不同按钮和业务语义，避免用户误以为关闭加载动画就撤回了真实提交。

活动超时、单次工具超时和任务期限应分别考虑。单次工具超时用于限制一个依赖的等待；活动超时限制本次运行消耗；任务期限则约束跨暂停恢复的整体业务有效性。恢复后应使用原任务的剩余预算，不能每次重启都重新获得完整步数和时间，否则一个不断崩溃的任务能够无限运行。

期限检查还要靠真实计时边界实现。只在 await 之前检查 Date.now，无法中止一个永不返回的 Promise；只用 Promise.race 返回超时，也不一定停止后台工作。需要把取消信号传到依赖，并知道依赖是否支持取消。CPU 密集任务可能需要工作线程和外部终止策略，这属于执行环境能力，而不是模型提示能够实现的限制。

## 练习：模拟写入成功后、检查点保存前崩溃

构建一个任务执行器，先保存 operationId 和内容摘要，再调用备注服务。故意在服务写入成功后抛错，让任务来不及保存完成状态；恢复时查询同一个 operationId，确认只产生一条备注。再改变操作内容，验证旧键不能被复用。提示是把“远端已提交事实”和“本地已保存状态”分成两个存储空间。

<details><summary>参考答案：完整崩溃注入与恢复程序</summary>

环境为 Node.js 22，无依赖。保存为 `agent-recovery.mjs`，执行 `node agent-recovery.mjs`，预期输出 `completed`、`notes: 1`、`recoveredByLookup: true`。两个 Map 分别模拟任务存储与远端业务存储，故障通过异常注入；没有真实进程重启或数据库事务。

```js agent-recovery.mjs
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';

const tasks = new Map();
const remoteOperations = new Map();
const notes = [];
const allowedActors = new Set(['u1']);
const digest = action => createHash('sha256').update(JSON.stringify({
  ticketId: action.ticketId, text: action.text
})).digest('hex');

function lookup(actorId, operationId, expectedDigest) {
  if (!allowedActors.has(actorId)) throw new Error('FORBIDDEN');
  const saved = remoteOperations.get(actorId + ':' + operationId);
  if (saved && saved.digest !== expectedDigest) throw new Error('KEY_CONFLICT');
  return saved?.result ?? null;
}
function writeNote(actorId, operationId, action, approval, now) {
  const hash = digest(action);
  const prior = lookup(actorId, operationId, hash);
  if (prior) return prior;
  if (approval?.actorId !== actorId || approval?.operationId !== operationId ||
      approval?.digest !== hash || !Number.isFinite(approval?.expiresAt) ||
      approval.expiresAt <= now) throw new Error('CONFIRMATION_REQUIRED');
  if (action.ticketId !== 'T1' || typeof action.text !== 'string' ||
      !action.text.trim() || action.text.length > 200) {
    throw new Error('INVALID_ACTION');
  }
  // 同步写入模拟业务库中的原子事务：备注与操作记录同时提交。
  const result = { noteId: 'note-' + (notes.length + 1) };
  notes.push({ ...action, ...result });
  remoteOperations.set(actorId + ':' + operationId, { digest: hash, result });
  return result;
}
function save(state) {
  tasks.set(state.taskId, JSON.stringify(state));
}
function run(taskId, { crashAfterWrite = false, now = 1000 } = {}) {
  const state = JSON.parse(tasks.get(taskId));
  if (state.status === 'completed') return state;
  const prior = lookup(state.actorId, state.operationId, state.digest);
  if (prior) {
    state.status = 'completed';
    state.result = prior;
    state.recoveredByLookup = true;
    save(state);
    return state;
  }
  // 调用之前持久化同一个操作标识，恢复时绝不能重新生成。
  state.status = 'executing';
  save(state);
  const result = writeNote(state.actorId, state.operationId,
    state.action, state.approval, now);
  if (crashAfterWrite) throw new Error('SIMULATED_PROCESS_CRASH');
  state.status = 'completed';
  state.result = result;
  save(state);
  return state;
}
const action = { ticketId: 'T1', text: '已核对售后规则，等待用户补充日期。' };
save({ taskId: 'task-1', actorId: 'u1', operationId: 'op-1',
  digest: digest(action), action, status: 'planned',
  approval: { actorId: 'u1', operationId: 'op-1',
    digest: digest(action), expiresAt: 2000 } });
assert.throws(() => run('task-1', { crashAfterWrite: true }),
  /SIMULATED_PROCESS_CRASH/);
assert.equal(JSON.parse(tasks.get('task-1')).status, 'executing');
assert.equal(notes.length, 1);
const recovered = run('task-1');
assert.equal(recovered.status, 'completed');
assert.equal(notes.length, 1);
assert.equal(recovered.recoveredByLookup, true);
assert.throws(() => writeNote('u1', 'op-1',
  { ticketId: 'T1', text: '不同内容' }, null, 1000), /KEY_CONFLICT/);
allowedActors.delete('u1');
assert.throws(() => lookup('u1', 'op-1', digest(action)), /FORBIDDEN/);
console.log({ status: recovered.status, notes: notes.length,
  recoveredByLookup: recovered.recoveredByLookup });
```

</details>

真正保证重复效果受控的是业务服务的操作记录，而不是任务执行器的 completed 字段。执行器可能在任何一行之后崩溃，远端写入一旦提交就成为事实。恢复先查远端记录，即使本地只保存 executing，也能取得原结果。若查询时尚未完成而随后另一个请求完成，重复提交仍必须由业务服务的同一幂等键兜住；“先查询再写”本身并不能消除竞争。

示例把备注与操作记录放在一段同步代码里，是为了模拟原子提交。真实数据库需要事务和唯一约束，跨外部服务则要遵循对方的幂等合同。不要把两个顺序 await 写入当成同一事务：如果备注成功、幂等记录失败，下一次仍可能重复插入。若外部服务既不支持幂等键，也没有可靠查询能力，系统就不能承诺自动恢复后只产生一次效果。

恢复查询仍检查当前权限，避免用户权限被撤销后继续读取业务结果。已存在的操作可以返回历史完成事实，而不重新检查已经过期的旧确认来“重新执行”；如果需要新的副作用，则必须重新取得有效确认和当前权限。这里区分的是读取既成事实与批准新动作，二者不可混成一个可以永久复用的布尔开关。

## 多工作进程需要租约与条件写入

当任务运行在队列中，同一消息可能重复投递，旧工作进程也可能在网络卡顿后继续运行。可以用任务租约限制同时执行者，并给租约附递增的 fencing token；写入检查只接受当前令牌，防止过期工作进程继续推进状态。租约能协调任务执行，却不能替代下游副作用的幂等，因为外部服务未必理解你的租约。

把任务状态与待发送命令写入同一数据库事务，再由后台投递，常被用于避免“状态已保存但命令没发出”的缺口。即使这样，命令投递仍可能重复，所以消费者或业务工具仍需要幂等。不同机制解决不同故障窗口：状态版本防覆盖，租约限制竞争，可靠投递防遗漏，幂等约束防重复效果。选用前要写出具体崩溃位置，避免只因术语熟悉就全部堆进系统。

## 让用户理解任务当前停在哪里

前端最有价值的信息是已经确认的进展：已读取哪张工单、找到哪些证据、准备修改什么、是否需要用户补充、哪个外部操作正在核实。不要把模型生成的计划百分比直接当真实进度；“第三步，共五步”可能在模型下一轮又变成七步。可以展示确定的阶段和可核对结果，把开放式调查表示为进行中的活动。

确认界面应该显示动作的实际差异、对象与关键参数，而不是一个泛泛的“允许助手继续”。用户修改内容后，旧摘要与旧确认失效，执行器重新保存待确认动作。若原授权已经覆盖某些只读查询，则继续执行，不必反复打断；但扩大到新对象、新金额或新的外部发送时，要按产品权限策略重新确定授权范围。

评测 Agent 时，最终回复好看并不是完成标准。要检查实际业务记录、重复效果、越权阻断、恢复后预算、停止原因和人工接手路径。一个任务因缺少权限而正确停止，可能比一个“成功”完成越权操作的任务更符合目标。把成功条件写成可以由执行记录证明的事实，才能让模型自主性与工程可靠性一起增长。

## 在同一目标下设计可重复的故障实验

如果只运行一次正常路径，很难发现恢复设计是否正确。可以固定任务目标和工具返回值，分别在保存计划后、发出请求前、业务提交后、保存结果前注入失败。每个位置对应不同的已知事实：请求前可以确认未执行，提交后可以确认远端有结果，网络中断则可能未知。实验应该检查业务记录数量与最终状态，而不是只检查程序有没有抛异常。

预算也要进入恢复样本。把最大规划次数设得很小，让工具连续返回相同材料，验证执行器会停止并保留已发现事实；恢复同一任务时，剩余次数不应重置。若用户明确创建新的任务或扩大预算，才写入新的授权与配置版本。这样能区分正常继续、重复消息和真正的新工作，避免重试机制无意中变成无限代理循环。

针对确认可以固定测试时钟，验证确认刚过期、对象版本变化、用户角色撤销和参数摘要变化。四种情况看起来都像“之前点过同意，现在不能执行”，原因却不同：有效期要求重新确认，版本变化要求重新预览，权限撤销要求停止，参数变化要求把它当新动作。界面提示与服务端错误码一致，用户才知道该修改内容、联系管理员还是等待系统恢复。

无进展检测同样需要业务语义。连续两次读取相同库存不一定毫无意义，可能是在等待库存异步更新；连续调用同一搜索却没有改变查询也没有新结果，才更像循环。可以比较动作名称、规范化参数、结果摘要与状态变化，并给等待型工具单独策略。不要只用“相同工具连续三次”这样粗糙的规则中断所有合理任务。

交付时应明确哪些恢复机制已经被真实环境验证。本章验证了状态序列化、过期事件拒绝、故障注入后的查询恢复与单进程幂等效果；没有验证数据库断电、队列重复投递、跨进程租约或外部服务取消。这些边界不降低示例的价值，而是告诉你下一步集成测试应在哪里制造故障，才能把教学程序逐步变成可靠服务。

## 验收与三个自测

验收需证明步骤受限、耗时受限、越权拒绝、确认绑定具体内容、重复提交不重复写、完成状态有事实依据。此例只验证单进程内存路径；分布式幂等、外部服务取消、持久恢复均需另外实现和测试。

1. 问：固定流程里调用模型是否就必须叫 Agent？答：不必，关键在于谁决定控制流，命名不应代替设计。
2. 问：为什么每次重试生成新操作 ID 会破坏幂等？答：服务端会把它们当作不同业务操作。
3. 问：超时后能直接展示“未执行”吗？答：不能，副作用可能已经发生，需要核实结果。

## 本章示例验证记录

已离线运行单 Agent、状态机与故障恢复三个程序，验证确认有效期、预算耗尽、取消、陈旧事件、终态保护，以及写入后崩溃再查询恢复只保留一条备注。真实数据库重启、队列重复投递和外部服务取消未联调。

## 官方资料

阅读 [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents)、[函数调用](https://developers.openai.com/api/docs/guides/function-calling) 和 [Node.js timers 的取消机制](https://nodejs.org/api/timers.html)。本章状态机是完整的教学程序，未声称等同于任一 Agent 框架的全部能力。