《互联网产品的 AI 架构·多能力编排与任务闭环》6.2 人工介入节点

内容发布链上的人工复核既是延迟大头,也是审计责任点。本节把等待、领取、升级、批准、驳回与超时路由写成可执行状态机,讨论超时后的自动发布边界、重复批准回调的幂等处理,以及用只追加事件链回答谁在何时改了什么。并附 12 条样本的可复算运行结果。

复核节点不是一次函数调用

内容发布链的第三步是人工复核。moderation.classify 给出低置信度或命中灰区时,任务不能直接发布,也不能直接丢弃,而是进入一个等待人类决定的节点。

这个节点不能按同步 RPC 设计。示例的复核 SLA 是 30 分钟,升级窗口是 15 分钟,而整条三能力链路的延迟只有 1,269ms。把人工塞进同步调用,等于让一个 HTTP 请求挂起半小时。正确做法是把它建模成状态机:编排层创建任务后继续处理别的工作,人工决定通过回调推进,超时由系统时钟触发路由。

人工节点还有两个普通节点没有的要求:超时后必须知道往哪里走,重复提交必须只产生一次发布。前者靠状态与路由表,后者靠幂等键与审计链。

五个状态与合法迁移

当前状态可迁移到触发者
waitingclaimed、escalated、expired处理人、系统超时
claimedapproved、rejected、escalated、expired处理人、系统超时
escalatedclaimed、approved、rejected、expired升级后的处理人、系统超时
approved / rejected / expired无终态

终态一旦到达,任何回调都不能再改状态。version 每次迁移加一,发布动作必须带上它;否则一个读到旧版本的客户端可以把已驳回的内容重新批准。

created 事件把状态从 waiting 写到 waiting,序号为 1。这个看似多余的事件让审计链有一个明确起点,也把策略版本固定在第一条记录上。

超时不是失败,而是路由

示例策略是:首次等待 30 分钟,每次升级后再等 15 分钟,最多升级 2 次。到期不是直接失败,而是先升级;升级次数用尽后才执行最终路由。

场景路由说明
高风险内容hold永远不自动发布
低风险 + 策略允许自动发布publish仅在风险不高于上限时
策略配置自动驳回reject明确不发布
其他hold保持待处理,交给人

routeAfterTimeout 先看风险,再看策略。这个顺序不能反:即使运维把 timeoutAction 配成 auto-publish,高风险内容仍然停在 hold。自动发布是一条产品政策,不是超时处理的默认值。

升级要有梯子,也要有上限

升级梯子在示例里是一线值班到内容负责人。每升一级,处理人换成梯子上的下一位,截止时间重置为 15 分钟后。升级次数必须封顶,否则任务会在几个处理人之间无限转手,永远没有最终路由。

升级事件记录级别、新处理人和原因。系统超时升级与人工手动升级走同一个函数,审计里只靠 actor 区分 system:timeout 与具体的人。这样复盘时可以回答:这次升级是 SLA 触发,还是有人主动加了优先级。

幂等:重复批准只能发布一次

人工操作的重试很常见:页面超时后用户再点一次,网关重发回调,消息队列至少一次投递。decideReview 用两层幂等兜底。

第一层是 requestId。同一个请求 ID 第二次到达时直接返回原结果,不再追加事件。第二层是状态与决定的组合:任务已经批准,再来一次 approve 返回 duplicate-decision;如果来的是 reject,返回 conflicting-decision,交给人处理。

发布动作本身还要幂等。发布键由 taskId、contentHash 和 version 组成:同一任务同一内容同一版本只发布一次;内容哈希变了说明审的是旧稿,必须拒绝;版本落后说明决定已过期,也必须拒绝。

审计:只追加事件链

每条事件包含序号、时间、动作、发起人、前后状态、说明、策略版本和可选的请求 ID。审计链只追加,不修改,也不删除。seq 连续、from 必须等于上一条的 to、终态之后不能有新事件、时间不能倒退,这四条可以写成自动检查。

审计的目的不是留一份日志,而是回答三个问题:谁在什么时候把内容从什么状态改成了什么状态;依据哪一版策略;这次决定是不是重复提交。缺少任意一项,事故复盘就只能靠猜。

实现

human.ts 把状态、策略、迁移和路由放在一组纯函数里,时间由调用方传入,因此同一组事件永远得到同一条审计链。

// file: src/orch/human.ts
// tier: L2
// verify: node --test test/orch/human.test.ts

import { AiError } from "../core/errors.ts";

export type ReviewState = "waiting" | "claimed" | "escalated" | "approved" | "rejected" | "expired";
export type ReviewDecision = "approve" | "reject";
export type TimeoutAction = "escalate" | "auto-reject" | "auto-publish" | "hold";
export type RiskLevel = "low" | "medium" | "high";

export interface ReviewPolicy {
  policyVersion: string;
  reviewTimeoutMs: number;
  escalationTimeoutMs: number;
  maxEscalations: number;
  /** 索引 0 是初始处理人。 */
  escalationLadder: readonly string[];
  timeoutAction: TimeoutAction;
  /** 自动发布允许的最高风险等级;高风险永远不自动发布。 */
  autoPublishRiskCeiling: RiskLevel;
}

/** 只追加的审计事件;requestId 用于识别重复提交。 */
export interface ReviewEvent {
  seq: number;
  atMs: number;
  type: "created" | "claimed" | "escalated" | "approved" | "rejected" | "expired" | "routed";
  actor: string;
  from: ReviewState;
  to: ReviewState;
  detail: string;
  policyVersion: string;
  requestId?: string;
}

export interface ReviewTask {
  id: string;
  /** 待发布内容的稳定哈希;发布时必须回传,防止审的是旧稿。 */
  contentHash: string;
  risk: RiskLevel;
  state: ReviewState;
  assignee: string;
  escalationLevel: number;
  deadlineMs: number;
  version: number;
  events: readonly ReviewEvent[];
}

export interface ReviewOutcome {
  task: ReviewTask;
  changed: boolean;
  reason: string;
}

const TERMINAL: readonly ReviewState[] = ["approved", "rejected", "expired"];
const RISK_RANK: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
const TIMEOUT_ACTIONS: readonly TimeoutAction[] = ["escalate", "auto-reject", "auto-publish", "hold"];

function fail(message: string): never {
  throw new AiError("invalid-request", `human: ${message}`);
}

function integer(value: number, field: string, min = 0): void {
  if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
}

function positive(value: number, field: string): void {
  if (!Number.isFinite(value) || value <= 0) fail(`${field} 必须是正数`);
}

function nonNegative(value: number, field: string): void {
  if (!Number.isFinite(value) || value < 0) fail(`${field} 必须是非负数`);
}

function isTerminal(state: ReviewState): boolean {
  return TERMINAL.includes(state);
}

function validatePolicy(policy: ReviewPolicy): void {
  if (policy.policyVersion.trim() === "") fail("policyVersion 不能为空");
  positive(policy.reviewTimeoutMs, "reviewTimeoutMs");
  positive(policy.escalationTimeoutMs, "escalationTimeoutMs");
  integer(policy.maxEscalations, "maxEscalations");
  if (policy.maxEscalations > 0 && policy.escalationLadder.length === 0) {
    fail("允许升级时 escalationLadder 不能为空");
  }
  if (!TIMEOUT_ACTIONS.includes(policy.timeoutAction)) fail("未知 timeoutAction");
  if (!(policy.autoPublishRiskCeiling in RISK_RANK)) fail("未知 autoPublishRiskCeiling");
}

function append(
  task: ReviewTask,
  event: Omit<ReviewEvent, "seq" | "policyVersion">,
): ReviewTask {
  const full: ReviewEvent = {
    ...event,
    seq: task.events.length + 1,
    policyVersion: task.events[0]?.policyVersion ?? "",
  };
  return { ...task, state: full.to, version: task.version + 1, events: [...task.events, full] };
}

export function createReviewTask(input: {
  id: string;
  contentHash: string;
  risk: RiskLevel;
  nowMs: number;
  policy: ReviewPolicy;
}): ReviewTask {
  if (input.id.trim() === "") fail("task.id 不能为空");
  if (input.contentHash.trim() === "") fail("contentHash 不能为空");
  if (!(input.risk in RISK_RANK)) fail(`未知 risk ${input.risk}`);
  nonNegative(input.nowMs, "nowMs");
  validatePolicy(input.policy);
  const deadlineMs = input.nowMs + input.policy.reviewTimeoutMs;
  return {
    id: input.id,
    contentHash: input.contentHash,
    risk: input.risk,
    state: "waiting",
    assignee: input.policy.escalationLadder[0] ?? "unassigned",
    escalationLevel: 0,
    deadlineMs,
    version: 0,
    events: [{
      seq: 1,
      atMs: input.nowMs,
      type: "created",
      actor: "orchestrator",
      from: "waiting",
      to: "waiting",
      detail: `risk=${input.risk} deadline=${deadlineMs}`,
      policyVersion: input.policy.policyVersion,
    }],
  };
}

/** 同一处理人重复领取幂等,不同处理人不能抢占。 */
export function claimReview(
  task: ReviewTask,
  reviewer: string,
  nowMs: number,
  policy: ReviewPolicy,
): ReviewOutcome {
  if (reviewer.trim() === "") fail("reviewer 不能为空");
  nonNegative(nowMs, "nowMs");
  if (isTerminal(task.state)) return { task, changed: false, reason: "terminal" };
  if (task.state === "claimed") {
    return task.assignee === reviewer
      ? { task, changed: false, reason: "already-claimed" }
      : { task, changed: false, reason: "claimed-by-other" };
  }
  const deadlineMs = nowMs + policy.reviewTimeoutMs;
  const next = append(task, {
    atMs: nowMs,
    type: "claimed",
    actor: reviewer,
    from: task.state,
    to: "claimed",
    detail: `deadline=${deadlineMs}`,
  });
  return { task: { ...next, assignee: reviewer, deadlineMs }, changed: true, reason: "claimed" };
}

/** requestId 相同视为重试;同一处理人重复同一决定也幂等。 */
export function decideReview(
  task: ReviewTask,
  decision: ReviewDecision,
  actor: string,
  nowMs: number,
  requestId?: string,
): ReviewOutcome {
  nonNegative(nowMs, "nowMs");
  if (requestId !== undefined && task.events.some((event) => event.requestId === requestId)) {
    return { task, changed: false, reason: "duplicate-request" };
  }
  if (isTerminal(task.state)) {
    const same = (task.state === "approved" && decision === "approve") ||
      (task.state === "rejected" && decision === "reject");
    return same
      ? { task, changed: false, reason: "duplicate-decision" }
      : { task, changed: false, reason: "conflicting-decision" };
  }
  const late = nowMs > task.deadlineMs ? " late" : "";
  const next = append(task, {
    atMs: nowMs,
    type: decision === "approve" ? "approved" : "rejected",
    actor,
    from: task.state,
    to: decision === "approve" ? "approved" : "rejected",
    detail: `${decision}${late}`,
    ...(requestId === undefined ? {} : { requestId }),
  });
  return { task: next, changed: true, reason: late === "" ? decision : `${decision}-late` };
}

/** 手动升级与系统超时走同一条路径,保证审计口径一致。 */
export function escalateReview(
  task: ReviewTask,
  actor: string,
  nowMs: number,
  policy: ReviewPolicy,
  reason: string,
): ReviewOutcome {
  nonNegative(nowMs, "nowMs");
  if (isTerminal(task.state)) return { task, changed: false, reason: "terminal" };
  if (task.escalationLevel >= policy.maxEscalations) {
    return { task, changed: false, reason: "escalation-exhausted" };
  }
  const level = task.escalationLevel + 1;
  const assignee = policy.escalationLadder[Math.min(level, policy.escalationLadder.length - 1)];
  const next = append(task, {
    atMs: nowMs,
    type: "escalated",
    actor,
    from: task.state,
    to: "escalated",
    detail: `level=${level} assignee=${assignee} reason=${reason}`,
  });
  return {
    task: { ...next, assignee, escalationLevel: level, deadlineMs: nowMs + policy.escalationTimeoutMs },
    changed: true,
    reason: "escalated",
  };
}

/** 升级次数用尽后的最终路由;高风险内容永远停在 hold。 */
export function routeAfterTimeout(
  task: ReviewTask,
  policy: ReviewPolicy,
): { action: "publish" | "reject" | "hold"; reason: string } {
  if (task.risk === "high") return { action: "hold", reason: "high-risk-never-auto-publish" };
  if (policy.timeoutAction === "auto-publish") {
    return RISK_RANK[task.risk] <= RISK_RANK[policy.autoPublishRiskCeiling]
      ? { action: "publish", reason: "low-risk-auto-publish" }
      : { action: "hold", reason: "risk-above-auto-publish-ceiling" };
  }
  if (policy.timeoutAction === "auto-reject") return { action: "reject", reason: "policy-auto-reject" };
  return { action: "hold", reason: `policy-${policy.timeoutAction}` };
}

/** 到期先升级,升级用尽再路由;同一时刻重复调用幂等。 */
export function tickReview(
  task: ReviewTask,
  nowMs: number,
  policy: ReviewPolicy,
): ReviewOutcome {
  nonNegative(nowMs, "nowMs");
  if (isTerminal(task.state)) return { task, changed: false, reason: "terminal" };
  if (nowMs < task.deadlineMs) return { task, changed: false, reason: "not-due" };
  if (task.escalationLevel < policy.maxEscalations) {
    return escalateReview(task, "system:timeout", nowMs, policy, "deadline-exceeded");
  }
  const route = routeAfterTimeout(task, policy);
  const target: ReviewState = route.action === "publish"
    ? "approved"
    : route.action === "reject" ? "rejected" : "expired";
  const next = append(task, {
    atMs: nowMs,
    type: "routed",
    actor: "system:timeout",
    from: task.state,
    to: target,
    detail: `${route.action}:${route.reason}`,
  });
  return { task: next, changed: true, reason: `timeout-${route.action}` };
}

tickReview 只在到期时推进状态,重复调用同一时刻不会产生第二条升级或路由事件。decideReview 的幂等判断发生在状态写入之前,所以重复请求不会污染审计链。

示例样本上的运行结果

12 条复核任务的确定性样本:6 条在 10 分钟批准,3 条在 12 分钟驳回,2 条在 30 分钟超时升级后于 41 分钟批准,1 条高风险任务升级两次后于 60 分钟挂起。

指标数值
批准8
驳回3
超时挂起1
升级事件4
审计事件39
重复批准回调3 次,产生 0 次额外发布
实际发布8 次

人工节点的耗时从 10 分钟到 60 分钟,远远超过 1,269ms 的同步链路预算。这不是需要优化的延迟,而是需要异步化的边界:同步链路只负责创建任务,结果通过 6.3 的通知与回调交付。

与发布动作的接口

发布请求至少带五个字段:taskId、contentHash、version、decision、actor。发布端先校验任务处于 approved、内容哈希与当前草稿一致、版本不低于已记录版本,然后再执行发布。

如果任务已经 expired,发布端必须拒绝,即使调用方拿着一个旧的批准事件。如果内容哈希不一致,说明人工审的是旧稿,必须重新复核。发布端自身也按发布键去重,形成第二道幂等防线。

小结

人工介入节点是一个带超时、升级与审计的状态机,不是一次同步调用。示例样本里 12 条任务产生 8 次批准、3 次驳回、1 次挂起和 4 次升级;3 次重复批准回调没有产生额外发布。

三个边界必须写进实现:高风险永不自动发布、重复决定只落一次、过期任务不能再发布。

阅读导航:上一节:6.1 并行分支的收益与竞争 · 下一节:6.3 长任务与异步交付 。

常见错误与边界

  • 把人工复核做成同步 RPC。30 分钟的 SLA 会把请求、连接和上游预算一起拖垮。
  • 超时直接标记失败。失败没有路由,任务会卡在无人处理的状态。
  • 自动发布对所有风险开放。高风险内容必须无条件 hold。
  • 升级没有上限。任务会在处理人之间无限转手,永远到不了终态。
  • 只按状态判断幂等。已经批准的任务收到驳回请求时,需要冲突处理而不是静默覆盖。
  • 发布不带内容哈希。人工审的是旧稿,发布出去的却是新稿。
  • 审计只记最终决定。缺少升级、领取与超时事件,复盘无法还原过程。
  • 把示例的 30/15 分钟当成通用 SLA。不同内容类型与风险等级需要不同策略版本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查