复核节点不是一次函数调用
内容发布链的第三步是人工复核。moderation.classify 给出低置信度或命中灰区时,任务不能直接发布,也不能直接丢弃,而是进入一个等待人类决定的节点。
这个节点不能按同步 RPC 设计。示例的复核 SLA 是 30 分钟,升级窗口是 15 分钟,而整条三能力链路的延迟只有 1,269ms。把人工塞进同步调用,等于让一个 HTTP 请求挂起半小时。正确做法是把它建模成状态机:编排层创建任务后继续处理别的工作,人工决定通过回调推进,超时由系统时钟触发路由。
人工节点还有两个普通节点没有的要求:超时后必须知道往哪里走,重复提交必须只产生一次发布。前者靠状态与路由表,后者靠幂等键与审计链。
五个状态与合法迁移
| 当前状态 | 可迁移到 | 触发者 |
|---|---|---|
| waiting | claimed、escalated、expired | 处理人、系统超时 |
| claimed | approved、rejected、escalated、expired | 处理人、系统超时 |
| escalated | claimed、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。不同内容类型与风险等级需要不同策略版本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。