本页目录

工作流与单Agent

按任务确定控制方式,为单 Agent 建立状态、权限、确认、幂等、超时和停止条件。

L2 · 能交付约 19 分钟阅读含示例、练习与验收

建议先读:工具调用的完整闭环有依据的知识库回答

本页内容

先决定谁控制下一步#

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

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

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

单 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 事件假定来自已经完成身份和确认校验的执行器,事件类型本身不能授予权限。

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,确认只产生一条备注。再改变操作内容,验证旧键不能被复用。提示是把“远端已提交事实”和“本地已保存状态”分成两个存储空间。

参考答案:完整崩溃注入与恢复程序

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

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 });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验收与三个自测#

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

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

本章示例验证记录#

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

官方资料#

阅读 OpenAI Agents SDK函数调用Node.js timers 的取消机制。本章状态机是完整的教学程序,未声称等同于任一 Agent 框架的全部能力。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭