门禁工程:人做人该做的事——工业/工贸CRM订单导入的LLM确定性范式

(平台提示:本文可能是商业推广软文)

摘要:把 LLM 接进一个已有 22 年沉淀的事务型 CRM,真正的难点不是让模型看懂表格,而是让一个概率组件产出的判决,安全地穿过解析、实体解析、引用完整性、幂等写入、后处理这五道事务边界。本文给出我们在超兔CRM的工业/工贸客户历史订单导入中落地的架构模式——门禁工程(Gate Engineering):run / gate / ledger 三件套契约、九道人工门禁、单调账本、认领优先于重发、错误可见性驱动的裁决反转。全文含可直接复用的协议代码、与 Saga / Outbox / Durable Execution 的同构对照、七个架构决策记录(ADR)与一张工程参数表。文中表名、字段名、接口路径与环境标识均已脱敏。


先说结论

一条结论,三句话:

概率层不写库,确定性层不做语义判断,人只在门禁处注入决策。

这句话拆开就是这个模式的三条硬边界:

层 职责 禁止事项
概率层(LLM) 输出带置信度的候选:列语义映射、字典值归并 不产生任何写操作;不做多步自主规划
确定性层(代码) 解析、归一、三级实体匹配、分组、查重、幂等写入、读回核对 不做语义等价判断(同义词、上下位、别名)
人(门禁) 在九个显式停点注入决策:形态、字典值、客户归属、产品拆分、放行、失败处置 不在流程中间被弹窗打断;不承担可自动化的判断

为什么这样切?因为三者的错误形态不同,而错误形态决定它该放在哪一层:

  • 代码的错误是高置信、静默的——一条写死的别名规则把「负责销售」映射到了客户电话字段,它不会告诉你它不确定;
  • 模型的错误是低置信、可标注的——它输出 conf: uncertain,能在门禁上被人一眼看到;
  • 人的错误是低频、可追责的——只在九个必须拍板的位置发生一次。

所以裁决优先级不是按「谁更准」排,而是按「谁的错误可观测」排。这一条后面会展开成 ADR-2。

把这三条边界画成数据流:概率层只向下游递候选,唯一位于写路径上的判断由确定性层做,人只在两层的「不确定汇合点」上出现。

graph LR SRC["源台账<br/>Excel / CSV"] --> M1 subgraph PROB["概率层 · LLM"] M1["列语义映射<br/>输出候选 + 置信度"] M2["字典值归并<br/>输出候选 + 置信度"] end subgraph DET["确定性层 · 代码"] D1["解析归一<br/>三级实体匹配"] D2["分组查重 / 引用校验<br/>幂等写入 / 读回核对"] end subgraph HUM["决策层 · 人"] G["九道门禁<br/>只承接不可自动化的风险决策"] end M1 -->|"仅候选,不落库"| D1 M2 -->|"仅候选,不落库"| D2 D1 -->|"歧义 / 未命中 / 读回差异"| G M2 -.->|"uncertain / 合理拒答"| G G -.->|"ctx.decisions 注入后重入"| D2 D2 --> OUT["CRM 业务系统"] style PROB fill:#0e2a4d,stroke:#0e2a4d,color:#ffffff style DET fill:#1a73e8,stroke:#1a73e8,color:#ffffff style HUM fill:#e0701a,stroke:#e0701a,color:#ffffff

120 组离线回归断言里,与模型直接相关的不到三成;剩下七成守的是同一件事——概率输出如何安全穿过事务边界。先把架构摆出来,再逐条讲。


一、问题定义:这其实是一个跨系统的一致性映射问题

业务侧的说法是「把销售台账贴进来」。工程侧的准确描述是五个连续的子问题,每个都有独立的失败模式:

# 子问题 失败模式 归属层
1 非结构化表格 → 规整行集 合并单元格、双行表头、多工作表混排 确定性
2 源列 → 目标字段(schema mapping) 同名列语义不同、词表外列无归属 概率 + 规则
3 源值 → 目标值域(value mapping) 客户自造字典项、边界同义 概率 + 规则
4 源实体 → 目标实体(entity resolution) 一客多名、同简称多家、产品编号与品名错位 确定性 + 人工
5 实体图落库(referential integrity + idempotency) 主单与明细的先后依赖、半成功态、重跑重复 确定性

第 5 项是最容易被低估的。CRM 里一张单据不是一行数据,而是一棵引用树:客户 → 主单 → 明细行 → 回款记录 → 交付计划 → 归属人。跨系统没有分布式事务可用,也不能要求客户侧开事务边界。所以这套架构的核心不是「保证原子性」,而是「让非原子写入可对账、可续跑、可收敛」——这是后面账本与读回核对存在的全部理由。


二、架构选型:控制流必须是确定的

最初我们把编排交给一个自主规划的智能体:先看数据再定策略,缺什么补什么。上线前压测暴露三个结构性问题,都指向同一个根因——控制流不确定,观测与重放就都做不了:

  1. 不可重放:同一输入两次执行产生不同步骤序列,线上问题无法在本地复现;
  2. 不可度量:一次导入几十轮模型调用,耗时与成本随数据形状漂移;
  3. 不可审计:决策链路是自然语言轨迹,出问题时没有结构化的中间产物可查。

我们据此把编排改为注册式静态流水线:阶段顺序、每步输入输出、失败处置在编译期就固定,模型只作为其中一个阶段内部的纯函数被调用。

设计判断:结构化判决用确定性编排,开放探索才用 Agent。选型的第一问不是「用不用 Agent」,而是「这个任务的控制流需要多少自由度」——判决类任务,自由度是负资产。


三、架构:run / gate / ledger 三件套

这套架构是在超兔CRM 22年事务型业务沉淀之上,为解决批量历史订单导入的一致性问题逐步形成的。

流水线由 11 个处理阶段与 9 道门禁组成。每个节点都实现同一个接口:

type State =
  | { kind: 'pass' }
  | { kind: 'pause'; decisionSpec: DecisionSpec }   // 等人:声明要什么决策,不执行副作用
  | { kind: 'block'; issues: Issue[] }              // 阻断:不可继续,必须修正数据或环境

interface Stage {
  id: string
  run(ctx: Ctx): Promise<Partial<Ctx>>   // 纯计算,或只读请求;不写业务数据
  gate(ctx: Ctx): Promise<State>         // 只判定,不产生副作用
  ledger(ctx: Ctx): Promise<LedgerNote>  // 放行后落一条幂等摘要
}

引擎本身只有二十来行:

async function run(ctx, fromId) {
  for (let id = fromId; id; id = NEXT[id]) {
    const stage = REGISTRY[id]
    Object.assign(ctx, await stage.run(ctx))

    const s = await stage.gate(ctx)
    if (s.kind === 'block') return { status: 'blocked', issues: s.issues }
    if (s.kind === 'pause') {
      ctx.pending = { gate: id }                 // 只序列化「停在哪」,不保存调用栈
      return { status: 'paused', gate: id, spec: s.decisionSpec }
    }
    await stage.ledger(ctx)                      // 只有放行才落账
  }
  return { status: 'done' }
}

async function resume(ctx, gateId, decision) {
  ctx.decisions[gateId] = decision               // 决策注入,而非回放
  return run(ctx, NEXT[gateId])
}

11 个阶段与 9 道门禁的关系,收敛成一张原子逻辑图——三段确定性处理,中间夹一次概率判决与一次人工决策,最后落在「写入 + 读回」的闭环上:

graph LR IN["源台账"] --> DET1["确定性预处理<br/>解析 · 粒度 · 模式"] DET1 --> AI["概率判决<br/>列映射 · 字典归并"] AI --> GATE{"人工门禁<br/>9 个停点"} GATE --> WRITE["幂等写入<br/>账本逐条记账"] WRITE --> READ["读回核对<br/>对账报告"] GATE -.->|"驳回 / 补决策"| AI READ -.->|"差异回列映射"| GATE style AI fill:#0e2a4d,stroke:#0e2a4d,color:#ffffff style GATE fill:#e0701a,stroke:#e0701a,color:#ffffff style WRITE fill:#1a73e8,stroke:#1a73e8,color:#ffffff style READ fill:#1a73e8,stroke:#1a73e8,color:#ffffff style IN fill:#f5f7fa,stroke:#9aa5b1

3.1 暂停语义:没有持久化引擎的 Durable Execution

这套设计里最值得说的一点:pause 不保存调用栈,只保存「停在哪一道门禁」。续跑不是恢复现场,而是从注册表重入下一个阶段。

这带来一条强制性约束,我们把它写进了代码评审清单:

每个 run 必须能从 ctx 完整重建自己的输入;任何跨阶段状态只能进 ctx 或账本,不能藏在闭包、DOM 或模块变量里。

代价是每步都要显式声明输入依赖;收益是暂停/续跑、刷新页面、换标签页、断网重连在架构上是同一件事——这也是后来「输入即新回合」这类产品行为能零成本实现的底层原因。对照来看,这相当于把 Temporal 一类 Durable Execution 引擎的语义,用一条静态注册表 + 会话级 ctx 序列化实现了,代价是没有跨进程的持久化保证,收益是零基础设施依赖。

sequenceDiagram autonumber participant U as 用户 participant E as 流水线引擎 participant R as 阶段注册表 participant L as 批次账本 participant S as CRM 系统 U->>E: 粘贴台账,run(p1) loop 每个阶段 E->>R: 取 stage E->>R: run(ctx) E->>R: gate(ctx) alt pass E->>L: ledger(ctx) 幂等摘要 else pause E-->>U: 序列化停点,返回 decisionSpec U-->>E: resume(ctx, gateId, decision) Note over E: 决策注入 ctx.decisions,不恢复调用栈 else block E-->>U: 结构化 issues(含错误码,呈现层白话化) end end E->>S: 试写 ≤2 单 E->>S: 按回填 id 读回比对 E->>L: 逐条记账(单调推进)

3.2 与已知模式的同构关系

这套东西不是新发明,是把几个成熟模式的约束重新组合到「有人的决策点」这个场景里。显式对标,方便读者判断哪些部分可以复用:

本文机制 同构的已知模式 差异点
run / gate / ledger 三件套 编排式 Saga 补偿动作不是自动回滚,而是「人工决策点 + 账本重放」
账本 pending → mark、只进不退 Transactional Outbox + 幂等消费 账本同时承担重放游标、审计日志、批次状态机三个角色
暂停 → 序列化停点 → 重入 Durable Execution 无持久化引擎,状态落在账本 + 会话存储
试写 2 单 + 读回比对 金丝雀 / 影子验证 试写数据即正式数据,靠账本可回收、可续跑
编号 → 全称 → 简称三级匹配 deterministic-first resolution 歧义不猜,直接升级为人工门禁
规则预判降级为参考 规则与模型融合 依据是错误可见性,不是准确率
错误三分类 failure taxonomy 分类结果直接映射三种处置通道,不允许静默

如果只带走一个结论:跨系统一致性不靠事务,靠「幂等键 + 单调账本 + 写入后读回」三件套。Saga 管的是编排顺序,Outbox 管的是消息不丢,读回管的是「我以为写成功了」。


四、五个关键机制

4.1 副作用登记制:让「系统替用户做的决定」全部可见

第一版流水线有个设计缺陷:粒度判定对「一单一行 + 探测到明细列」的组合提示「每行 = 主单 + 一条明细」,但这个承诺只在带明细模式下成立。缺省模式是「订单」,于是明细列被归入「本模式忽略」清单,产品分支整段跳过——五张单的明细静默归零。

我们没用「改缺省值」来修。用户可能真的只要主单,缺省值不该替用户决定。修法是在门禁上加一条显式告知:探测到明细列而当前模式不带明细时,亮黄标「明细列将被忽略」+ 一键切换到带明细模式。

这件事沉淀成一条可执行的规则,我们叫副作用登记制:

系统的每一个自动行为(自动填充、自动归并、自动取默认值、自动跳过),必须在「行为 / 风险 / 可见位置」三列里登记;没有可见位置的条目,不允许上线。

登记表做成了代码,13 条自动行为逐条登记,并带一个挂载点自查:如果某个确认页还没建,登记项必须显式标 PENDING,不允许写着「已可见」而实际渲染不出来。回归测试直接断言自查通过——纪律不进测试,等于不存在。

graph LR A["自动行为"] --> B{"已登记?"} B -->|"否"| X["禁止上线"] B -->|"是"| C{"有可见位置?"} C -->|"否"| Y["显式标 PENDING<br/>不假装可见"] C -->|"是"| Z["门禁卡片 / 报告区展示"] style X fill:#c0392b,stroke:#c0392b,color:#ffffff style Y fill:#e0701a,stroke:#e0701a,color:#ffffff style Z fill:#1a73e8,stroke:#1a73e8,color:#ffffff

4.2 契约探针:把「文档说的」变成「线上测出来的」

订单与合同在主系统里是同一张单据表的两种形态,由形态字段区分。文档对形态的可写性描述不完整,我们的第一次实现按文档传参。接口返回了成功,而且响应体原样回显了我们传入的值——看起来完全正确。读回才发现:服务端把显式传入的合同形态强制改写成了订单。

这是本项目最贵的一次认知:响应体回显的是请求,不是落库事实。 之后我们对所有涉及系统行为的假设,采用「先探针、后编码」的流程:

假设 探针结论 对架构的影响
形态字段可直接写入 建单接口强制改写形态,读回定案四态 合同形态只能「先建后翻」:建完用批量编辑翻转,探针验证不伤明细
明细可事后追加 三条通路全灭(两条静默丢数据、一条建出无主孤儿行) 明细必须内嵌在主单创建请求里,「先主单后补明细」被永久否决
读接口缺省返回全字段 只返回缺省字段集,需显式声明字段 所有读回调用改为显式字段集,比对器才有可比字段

第三条尤其典型:mock 层返回全字段,掩盖了真实接口的字段集裁剪;直到试写读回比对全部对不上,才发现比对器拿到的是空字段。探针不是调试手段,是架构输入。

设计判断:任何关于外部系统行为的结论,在写进代码之前先用线上最小实验证一次。文档、日志、历史记忆都会过期,探针不会。

4.3 写通道:认领优先于重发

测试环境连出两起重复单:同一条订单落库两次,编号相邻。根因是一条错误假设——把「接口返回错误」等同于「数据未写入」。

真实过程是:数据库瞬时故障,接口返回一个 HTML 错误页;写入层判定失败并重发;但服务端的 insert 已经提交,重发就产生了第二条。这类「半成功态」在任何非事务写通道里都存在,只在故障时暴露。

修复分三层,第三层是这次真正的方法论产出:

async function claimOrResend(row) {
  // 1. 先认领:按业务组合键定向读回,确认上一轮是否已落库
  const hits = await query({
    subject: row.subject, amount: normAmount(row.amount), date: normDate(row.date)
  })
  if (hits.length === 1) return hits[0].id          // 已写:认领,不重发
  // 2. 无存量且批内唯一,才安全补发一次
  if (hits.length === 0 && isUniqueInBatch(row)) return create(row)
  // 3. 存量不清:阻断交人工,绝不猜
  throw Err('VERIFY_AMBIG')
}

配套还修了一个「吞根因」的实现缺陷:批量写入接口整批失败时返回的是汇总文案(「数据共 N 条」)+ 一个逐条字段级原因数组。原解析逻辑看到失败标志就把数组整个扔了,只剩汇总文案冒充每条的原因——用户看到「试写失败 4 条」无法定位。改为逐条原因优先,失败列表直接给出「签约日期不合法」「客户为空」这类可行动文案。

设计判断:失败处置要覆盖三种态,而不是两种——成功、明确失败、不确定。前两种可以自动化,第三种必须交人;把它当失败处理,就会重复写;当成功处理,就会丢数据。

4.4 账本:单调状态机即幂等

导入工具最怕的不是失败,是失败后的重跑:网络断、权限不足、页面刷新、用户自己重来一次。哪些写过、哪些没写过,必须有一个权威记录。

账本记录结构(脱敏):

{
  key: 'ACME|2023-05-12|12800.00|历史导入-OLD-0912',  // 可读复合键
  stage: STAGE.PAID,          // 1 待写 → 2 已建 → 3 已翻转 → 4 已回款 → 5 已核对
  docId: '13332',             // stage ≥ 已建 才存在,供读回与后处理使用
  attempts: 2,
  lastError: null,
  updatedAt: 1758...
}

三条约束:

// stage: 1 待写 → 2 已建 → 3 已翻转 → 4 已回款 → 5 已核对
function advance(rec, next, patch) {
  if (next <= rec.stage) return rec              // 只进不退:重跑天然幂等
  return { ...rec, ...patch, stage: next }
}
  1. 只进不退:状态只能前进,失败不写失败态——失败行留在原阶段,重跑自然重捡。这一条从结构上杜绝重复建单,不需要额外的去重逻辑。
  2. 非哈希复合键:键由「客户归一值 + 日期 + 金额 + 主题」拼成,不做 hash。代价是存储变长、索引变宽;收益是线上排查时能把账本行和 Excel 行直接对上。可调试性优先于键长度。
  3. 写入口唯一:所有写入必须经 pending() 领任务,禁止绕过。回款记录表没有天然防重指纹,账本是它唯一的幂等安全网。
stateDiagram-v2 [*] --> 待写 待写 --> 已建: 主单落库(内嵌明细) 已建 --> 已翻转: 形态翻转(合同形态) 已建 --> 已回款: 挂回款 已翻转 --> 已回款 已回款 --> 已核对: 读回比对通过 已建 --> 已核对 note right of 待写 失败不迁移状态 重跑从 pending 重捡 end note

断点续传的本质因此变了:不是恢复现场,而是状态机推进。已建主单不重发,卡在回款的行重跑只补回款,归属转移失败的客户不回滚已建档的客户档案——已提交的事实不回滚,只补缺口。

4.5 AI 层:五道护栏与一次裁决反转

模型在这个系统里只做两件事:列语义映射、字典值归并。这两件事的开发量,七成在护栏。

裁决反转(ADR-2)。 第一版按直觉设计:代码层做精确匹配和别名库,匹配不上才问模型。上线后出现一个反例——「负责销售」这一列被代码层的别名规则硬命中到客户电话字段,得到的是一个高置信的静默错误,模型连复核机会都没有。

反转后的顺序:代码层的所有命中(逐字、别名、模式归一)统一降级为预判,随要素包送入模型做全场复核;裁决顺序改为「模型判决 > 代码预判」,模型漏答或调用失败时回落预判。四条配套约束:

护栏 实现 解决什么
主键硬锁 客户全称等主键字段拒绝模型改判 允许错一列,不允许错主键
零调用快路径 全部列逐字命中且无子表字段时,不调用模型 确定性足够时不打扰概率层
调用预算 常规批次 LLM 调用 1 次封顶 重复调用不仅费钱,同输入两次判决不同会制造漂移
回落无损 漏答重试 ≤2 次,仍失败回落预判 降级只让清单变长,不让流程停摆

输出抢救。 模型偶发在 JSON 尾部多一个括号,整体解析失败会丢掉「判决全对、只差一个字符」的输出。我们加了一层字符串感知的括号配对扫描:

function salvageJson(text) {
  const start = text.indexOf('{')
  if (start < 0) return null
  let depth = 0, inStr = false, esc = false
  for (let i = start; i < text.length; i++) {
    const c = text[i]
    if (inStr) {
      if (esc) esc = false
      else if (c === '\\') esc = true
      else if (c === '"') inStr = false
      continue
    }
    if (c === '"') inStr = true
    else if (c === '{') depth++
    else if (c === '}' && --depth === 0) {
      try { return JSON.parse(text.slice(start, i + 1)) } catch { return null }
    }
  }
  return null
}

kind 四态台账。 每次调用落一个状态:ok / 全剔除(合理拒答) / 应答损坏 / 通道异常。这个分类救过一次误判:线上连续三次归并「失败」,旧台账无法区分,差点按通道故障排查;分类上线后真相是智能体在正确拒答——那个人名没有近似选项,禁猜是对的。

一次「全无建议」的定位。 门禁上 AI 建议列有 30%–50% 的出现率是空的,第一反应是超时。实测三轮,每次调用 2.4–9.4 秒返回,超时从未触发。真正的原因是归并工作流的人设对「边界同义」概率性翻转:同一输入三次里两次拒答,两个字段独立叠加,全空率正好落在 30%–50%。

修复不在代码,在人设:把判定从两档改为三档——等价记为 high、边界同义(上下位 / 行业惯称)强制 uncertain、禁止 reject、真无关才 reject。上线后八轮实测稳定出建议(修前 3 轮里 2 轮全拒),正确的拒答(无近似人名)八轮保持拒答。代码侧零改动。

graph LR SRC["源列画像 + 租户值域"] --> PRE["规则预判<br/>五层确定性匹配"] PRE --> PKG["要素包<br/>预判降级为参考"] PKG --> AI{"模型判决<br/>映射 / 归并"} AI -->|"主键字段"| LOCK["硬锁<br/>拒绝改判"] AI -->|"一般字段"| GATE["门禁展示<br/>人工勾选才生效"] AI -.->|"漏答 / 损坏 / 超时"| FALL["回落预判<br/>降级无损"] AI --> LOG["kind 四态台账<br/>ok / 拒答 / 损坏 / 异常"] style AI fill:#1a73e8,stroke:#1a73e8,color:#ffffff style LOCK fill:#e0701a,stroke:#e0701a,color:#ffffff style FALL fill:#e8f0fe,stroke:#1a73e8

设计判断:AI 集成的工作量在护栏不在提示词。硬锁、预算、抢救、回落、四态台账五件套齐备后,换模型档位、平台抖动、应答格式漂移,业务侧都无感。


五、验证:把验收嵌进写入路径

订单导入的失败代价不对称:漏导可以补,写错要人工清洗。所以全量放行前,流水线里内嵌一次实弹试写:

  1. 抽样:首现客户优先,取 ≤2 单;
  2. 真写:走正式写入通道,账本记「已建」(试写行就是正式数据);
  3. 读回:按系统回填的真实 id 拉主单与明细,分块 50 条;
  4. 逐字段比对:主题、金额、日期、客户、形态、编号、明细行序;
  5. 任一差异 → 拦截回列映射门禁,已建行不重发。

比对器把差异分两类:已知变换(系统生成的编号、格式规整)自动吸收;真差异(金额、日期、客户)如实呈现。我们在这道关口拦下过产品查重歧义、明细行序错位这类问题——它们等全量写完才发现,就是几百条脏数据加一次人工清洗。

离线侧是另一套独立体系:基线快照做夹具,Node 直跑编排层,回归零网络请求,浏览器与离线共用同一套推导函数。配套三个土办法,价值不低于断言数:

  • 场景编号集:每个关键场景一个编号文件,来自真实业务数据脱敏,进回归、进验收、进复测。讨论因此有了共同语言——「120 组全绿」和「07 号数据过一遍」是两句话能说完的验收;
  • DOM harness:门禁渲染层用本地静态页直驱,补 Node 覆盖不到的 UI 契约;
  • 测试环境协议:免密入口与业务登录入口是两个独立数据库。一条数据在业务库全命中、在免密库六个客户全未命中,一度被当成匹配缺陷。规则写进测试须知:数据建在哪个库,就用哪个库的身份测。

六、工程参数表

把散在叙事里的量化锚点集中一页,方便同类项目对照取值:

参数 取值 设计理由
处理阶段 / 人工门禁 11 / 9 门禁只放「不可自动化的风险决策」
离线回归断言 120 组 覆盖全链 + 各阶段 + UI 契约
LLM 调用预算 1 次 / 批(映射 + 归并合计) 同输入重复调用会制造判决漂移
判决重试上限 2 次 覆盖偶发漏答,不做无限重试
试写样本 ≤ 2 单,首现客户优先 覆盖最少见的实体,成本可控
读回分块 50 条 / 请求 兼顾请求时长与失败重试粒度
账本阶段 5 级单调 只进不退,失败不迁移
错误分类 3 类(可自愈 / 需人决策 / 不可修复) 直接映射三条处置通道
AI 归并稳定性 修前 3 轮 1/3 成功 → 修后 8/8 人设三档化,代码零改动
门禁 AI 建议全空率 修前 30–50% → 修后 0 同上,非超时问题

七、架构决策记录(ADR)

技术媒体评审最关心的是「你为什么这么选」。把七个关键决策按 Context / Decision / Consequence 三段式收敛:

ID 背景 决策 后果
ADR-1 自主编排不可重放、不可度量 控制流静态化,模型降为阶段内纯函数 失去灵活性,换来可复现、可回归、可审计
ADR-2 代码层别名命中出现高置信静默错判 裁决反转:模型优先,预判降级为兜底 多一次模型调用成本,换来错误的可观测性
ADR-3 明细事后追加三条通路均不可用 明细内嵌主单创建请求,一次性写入 主单请求体积上限成为新约束,需前置校验
ADR-4 错误页被当作「未写入」导致重复单 认领优先于重发;认领不清则阻断 失败路径多一次读请求,换来写通道幂等
ADR-5 回款表无天然防重指纹 账本承担幂等,写入口唯一 账本成为单点,必须随阶段同步落盘
ADR-6 自建客户价值口径与系统原生日批算并存 让位原生 RFM,只投基线(窗内中位数) 放弃客户级打标自由度,换来口径统一
ADR-7 错误分类混用导致处置方向错配 三类分流:可自愈 / 需人决策 / 不可修复 第三类单独成清单,不阻断整体流程

ADR-7 值得补一句:错误分类不是日志分级,而是路由。同一个 HTTP 500,可能是登录态失效(重登录即可)、可能是环境故障(等待恢复)、可能是业务校验失败(改数据),三者的处置动作互斥。我们把「结构化错误码优先于消息文本特征」写成了判定顺序——code 是事实,文本匹配只是兜底。


八、教训清单

# 结论 落地方式
1 控制流不确定性会同时毁掉重放、度量与审计 静态注册表 + 阶段契约
2 错误可见性比错误率更决定分层位置 模型优先裁决,规则兜底
3 外部系统行为只能靠探针确认 先探针后编码,结论写进 ADR
4 写通道存在第三种态:不确定 认领 → 补发 → 阻断 三级处置
5 幂等的抓手是单调账本,不是缓存或记忆 只进不退 + 写入口唯一
6 验证要嵌进写入路径 试写 ≤2 单 + 读回逐字段比对
7 自动行为需要登记且必须可见 副作用登记表 + 挂载点自查进回归
8 合理拒答与通道故障必须分类 kind 四态台账先于诊断
9 提示词约束是软约束,验收层才是硬约束 数值列硬拦放在解析后
10 定义了但没接线的纪律等于不存在 状态机三步接线 + 回归断言

第 9 条来自一次失败尝试:我们在提示词里写了「纯数值列不得映射为分类/状态」,复测依然出现误映射。最终把约束下沉到应答验收层——解析后对数值列的分类建议做确定性剔除。能被概率层忽略的约束,就不要只写在概率层。


九、这套模式的适用边界

诚实的边界说明,比适用范围更能说明一个模式的价值:

适用:跨系统、有引用树、写入不可回滚、错误代价不对称(漏写可补 / 错写要清)、且存在必须由业务方拍板的判断点。典型场景是客户数据迁移、历史单据导入、ERP/CRM 初始化——这些也是超兔CRM客户交付中最常见的几类数据集成任务。

不适用:

  • 强事务要求的场景(账务记账、库存扣减),这里需要真正的分布式事务或单库事务,门禁只解决「人机协作下的一致性收敛」;
  • 实时性要求毫秒级的链路,试写与读回的成本无法摊薄;
  • 判断点可以被完全自动化的场景——如果门禁上没有人真正需要做决策,那九道门禁就是九次打扰,此时应该砍掉门禁,而不是保留形式。

一个具体的反例:RFM 计算我们最终没有自建一套客户级口径,而是改为向系统原生批算投喂基线(ADR-6)。原因很简单——系统已经有权威实现,自建第二套口径只会制造两个真相。门禁工程的第一步,是识别哪些东西根本不该由你来做。


十、结语

工具上线后的一个场景,比任何通过率都更能说明这套架构的价值:一位销售助理把 300 行台账粘进输入框,在四道门禁上各停不到十秒——确认形态、处理三个未命中客户(一个检索归档、一个就地新建、一行不导入)、扫一眼试写样例——然后离开。回来时导入完成,对账报告在等她。

这就是「概率层不写库、确定性层不做语义、人只在门禁注入决策」想抵达的地方:每一次自动化的推进,都能被一句人能听懂的话解释;每一次人的介入,都对应一个真的需要人承担的风险。

超兔CRM下一步会把确认清单再压短、让列映射记忆跨客户复用、把产品拆分的交互再砍一刀。但三条硬边界不会动——它们不是这个工具的遗产,是下一个工具的地基。

如果你的团队也在做「AI 写业务系统」这类功能,欢迎在评论区聊聊:你的门禁会设在哪几道?


技术标签:架构设计 / LLM 工程 / 数据集成 / 幂等性 / SaaS

posted @ 2026-10-22 16:01  毛毛鱼的夏天  阅读(2)  评论(0)    收藏  举报