解决 AI 语音系统人机切换时多服务状态不一致、路径丢失问题;通过追加写事件流保障决策链路可追溯,序列号连续性保证,每条记录含稳定身份和不可编辑的决策理由。
我在 Auto-Respond 团队工作。编辑这篇文章时我使用了 AI 辅助,但发布前检查了代码和技术声明。以下设计是经过测试的模式,不是关于已发布产品的声明。
语音工作流可以在几百毫秒内从自动化 Agent 切换到人工操作员。事后,某人会问一个看似简单的问题:发生了什么?
普通应用日志很少能给出可靠的答案。一个服务说请求了接管。另一个说 Worker 认领了它。第三个在读取旧版本后覆盖了会话行。如果唯一存活的记录是最终状态,那么产生它的路径就消失了。
我反复回归的解决方案是一个仅追加的事件流。它保留决策轨迹而不保留对话内容,拒绝过时的写入者,并在崩溃后干净地重建状态。
先写下不变式
存储设计应使以下陈述为真:
每个事件有一个稳定的标识。
序列号在语音会话内严格递增一。
Actor 是公司角色或服务角色,绝不是人名。
决策原因写入后不能被编辑。
音频、转录文本、电话号码和自由格式的操作员备注保持在审计流之外。
重放相同的有序事件产生相同的状态。
测试最后那个属性。如果重放随着时钟、网络调用或随意查询返回的顺序而改变,就不能将日志作为可信状态。
使用窄事件信封
以下是追加边界的 TypeScript 类型:
type Actor =
| { kind: "service"; ref: "voice-router" | "policy-engine" | "dialer" }
| { kind: "company_role"; ref: "support-agent" | "shift-supervisor" }
| { kind: "system"; ref: "recovery-worker" };
type TakeoverEventType =
| "session.started"
| "takeover.requested"
| "takeover.claimed"
| "takeover.released"
| "automation.resumed"
| "session.ended"
| "content.redacted";
type DecisionReason =
| "caller_requested_human"
| "low_model_confidence"
| "policy_requires_human"
| "operator_claimed"
| "operator_unavailable"
| "connection_lost"
| "session_completed";
type AuditEvent = {
eventId: string;
sessionId: string;
seq: number;
type: TakeoverEventType;
occurredAt: string;
recordedAt: string;
actor: Actor;
reason: DecisionReason;
policyVersion: string;
correlationId: string;
contentRef?: string;
contentDigest?: string;
attributes: Record<string, string | number | boolean>;
};
Actor 字段故意避免使用邮箱地址、显示名称和员工 ID。当有合法需要时,单独的访问控制系统可以将内部操作员分配映射到个人。通用审计订阅源只需要证明是哪个公司角色或服务做出了转换。
将 reason 保持为受控值。自由文本很诱人,但它将敏感内容与操作证据混在一起,使分析不一致。如果出现新原因,请将其添加到契约中并对策略进行版本控制。
在 SQL 中强制仅追加存储
这个表需要的不仅仅是一个说"请不要更新行"的约定。
CREATE TABLE voice_takeover_events (
session_id UUID NOT NULL,
seq BIGINT NOT NULL CHECK (seq > 0),
event_id UUID NOT NULL,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_kind TEXT NOT NULL,
actor_ref TEXT NOT NULL,
reason_code TEXT NOT NULL,
policy_version TEXT NOT NULL,
correlation_id UUID NOT NULL,
content_ref TEXT,
content_digest TEXT,
attributes JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (session_id, seq),
UNIQUE (event_id)
);
CREATE TABLE voice_takeover_heads (
session_id UUID PRIMARY KEY,
last_seq BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
REVOKE UPDATE, DELETE ON voice_takeover_events FROM voice_app;
GRANT SELECT, INSERT ON voice_takeover_events TO voice_app;
复合主键防止两个事件占用同一序列。唯一事件 ID 使重试的追加可识别。数据库权限阻止应用程序角色重写历史。
对于更严格的部署,添加一个数据库触发器,无论调用者是谁都拒绝 UPDATE 和 DELETE。保留策略仍可通过单独的管理角色下的分区过期处理。仅追加不意味着永远保留每个事件。
在一个事务中守护序列
head 行是一个比较并设置的边界。写入者必须提供它认为当前的序列。
type AppendInput = Omit<AuditEvent, "seq" | "recordedAt"> & {
expectedSeq: number;
};
async function appendTakeoverEvent(input: AppendInput): Promise<AuditEvent> {
return db.transaction(async (tx) => {
const previous = await tx.events.findByEventId(input.eventId);
if (previous) return previous;
const head = await tx.heads.lockOrCreate(input.sessionId);
if (head.lastSeq !== input.expectedSeq) {
throw new Error(
"STALE_SESSION_VERSION expected=" +
input.expectedSeq +
" actual=" +
head.lastSeq,
);
}
const event: AuditEvent = {
...input,
seq: head.lastSeq + 1,
recordedAt: new Date().toISOString(),
};
await tx.events.insert(event);
await tx.heads.advance(input.sessionId, head.lastSeq, event.seq);
return event;
});
}
lockOrCreate 函数应获取行锁。advance 函数应仅在 last_seq 仍等于刚读取的值时更新。事件插入和 head 更新属于同一事务。
这关闭了两个尴尬的失败路径。并发接管认领不能同时获胜。具有相同 eventId 的重试会返回已写入的事件。具有新 ID 但旧 expected sequence 的重试会明显失败,而不是创造第二个历史。
保持决策证据不可变
假设策略引擎因为置信度低于阈值而请求人工。存储原因码、策略版本和决策使用的非敏感值:
{
"reason": "low_model_confidence",
"policyVersion": "voice-handoff-2026-08",
"attributes": {
"confidenceBucket": "below_0_55",
"attempt": 1
}
}
不要后来因为它在报告中看起来更干净而将原因替换为"caller_requested_human"。如果新证据改变了解释,请追加一个新的 reviewed 注释事件。历史应该显示解释发生了变化。
我还避免存储原始模型 prompt 和思维链文本。审计问题是关于输入、策略、转换和 actor 的。内部推理文本既不是必需的,也不是结构化决策证据的安全替代品。
画出硬脱敏边界
事件流应包含引用,而不是对话内容。
contentRef 可以指向受较短保留策略管理的加密对象。contentDigest 可以证明评估了哪个对象而不暴露它。只有当输入空间不可猜测时,摘要才有用,因此使用带密钥的哈希计算或包含在事件表外管理的秘密盐。
永远不要将这些值放入 attributes:
来电者电话号码或电子邮件地址;
转录片段;
操作员自由格式备注;
提供商令牌或 webhook 负载。
如果删除请求覆盖了引用的内容,请根据策略删除或脱敏该对象,并追加一个 content.redacted 事件。操作历史存活,而敏感材料则不会。
用纯 reducer 重建状态
重放函数不应有数据库或网络访问:
type SessionState = {
mode: "automated" | "human" | "ended";
ownerRole?: string;
lastSeq: number;
redactedRefs: Set<string>;
};
function reduceEvent(state: SessionState, event: AuditEvent): SessionState {
if (event.seq !== state.lastSeq + 1) {
throw new Error("SEQUENCE_GAP");
}
switch (event.type) {
case "session.started":
return { ...state, mode: "automated", lastSeq: event.seq };
case "takeover.claimed":
return {
...state,
mode: "human",
ownerRole: event.actor.ref,
lastSeq: event.seq,
};
case "takeover.released":
case "automation.resumed":
return {
...state,
mode: "automated",
ownerRole: undefined,
lastSeq: event.seq,
};
case "session.ended":
return { ...state, mode: "ended", lastSeq: event.seq };
case "content.redacted": {
const refs = new Set(state.redactedRefs);
if (event.contentRef) refs.add(event.contentRef);
return { ...state, redactedRefs: refs, lastSeq: event.seq };
}
default:
return { ...state, lastSeq: event.seq };
}
}
按 seq 排序,而非时间戳。两个服务可能有时钟偏移,recordedAt 对于重试传递可能更晚。序列定义会话顺序。时间戳仍然是有用的证据,但不决定重放。
一个有用的集成测试在两个操作员都读取了同一个 head 后暂停它们。同时释放它们并断言只有一个认领被追加。失败者应收到 STALE_SESSION_VERSION,重新加载状态,然后停止。
还要测试这些情况:
同一事件 ID 到达五次;
插入成功但事务在 head 前进前回滚;
重放期间缺少序列号 8;
会话结束后内容被脱敏;
操作员角色在恢复 worker 尝试恢复自动化时释放控制权。
通过条件比"最后一行看起来正确"更强。事件列表必须没有间隙、没有重复 ID、一个获胜认领,以及每次重放时相同的重建状态。
相同的边界也出现在 AI 应答服务工作流中,自动化和人工可能共享一个对话。如果交接很重要,可变标志是错误的产物。记录转换。