你提的拆分是对的,而且比我原来的方案更好——它给出了一条我没想到的迁移路径。 但你问的三个问题,线上数据的答案跟直觉不一样。其中一个是「今天完全做不到」。
我原来的方案是按「线 A 成本 / 线 B 安全」分的,那是按问题类型分。 你这一刀是按系统边界分,更好——因为它同时给出了迁移路径。
CRM 侧是 291,196 条会话线程;Agent 侧是 4,740 条交付物记录。 差两个数量级。 一个是高吞吐、低语义、必须永远在线的管道; 一个是低吞吐、高语义、可以停下来重做的状态机。 它们对可用性、一致性、演进速度的要求完全不同,不该住在一个系统里。
coordinator_engagements 上已经有一列 suppress_received_channel_agent_runs db-schema/coordinator.ts。 也就是说:消息继续进来、继续入库、继续在收件箱里给人看,只是不再触发 agent 运行。 这正是你说的那个模式,而且是逐 engagement 粒度的——可以按活动、按批次灰度,不必全量停机。
| 阶段 | CRM | Agent | 运营看到什么 |
|---|---|---|---|
| 今天 | 在线 | 在线(旧) | 正常 |
| 重构期 | 照常在线,消息不丢 | 暂停(灰度批次) | 收件箱正常,自动回复停,人工接管这批 |
| 切换后 | 照常在线 | 新版本,读同一份消息重建状态 | 恢复自动,历史可回放核对 |
这解决了我原方案里最难的一环:不再需要「双写两套业务真相解释」的长期比对期。 新 Agent 从 CRM 已有的消息重建账本,和旧状态做一次性比对即可。
因为今天系统里根本没有「达人」这个身份。 得先做一次身份归并,而好消息是:数据支持归并。
先看今天 kol_id 的实际分布——它本该是达人身份:
kol_id 不是「人」,是「活动 × 人」。 同一个真人参与第二个活动时会拿到一个全新的 kol_id。 所以今天问「我们跟 @laura 一共合作过几次、付过多少钱」——系统结构上答不出来。 这不是复购流程不统一,是复购这个概念在数据层根本不存在。
但同一个 creator_handle 确实散在多个 kol_id 上,而且邮箱能把它们缝回来:
4,992 个 handle 被拆成了 2–10 个 kol_id,涉及 12,153 条 engagement。
| 同一邮箱关联的 engagement 数 | 邮箱数 | 覆盖 engagement |
|---|---|---|
| 1 | 23,542 | 23,542 |
| 2 | 3,718 | 7,436 |
| 3 | 1,047 | 3,141 |
| 4–12 | 695 | 3,443 |
5,460 个邮箱已经可证明地对应 2 次以上合作,覆盖约 14,020 条 engagement。 这批复购关系今天就躺在库里,只是没有一个实体把它们表达出来。 做一张 creator + creator_identity(email/phone/handle), 回填一次就能拿到。
但要接受两个现实: ① 邮箱身份只覆盖 34,127 / 51,175 = 67% 的 engagement,剩下 33% 需要 handle/手机兜底或留作未归并; ② 归并会出错(共用邮箱、经纪人代收),所以 merge / unmerge 必须是运营可操作的动作,不能是纯自动的一次性脚本。
而且以 creator 为记录主体不会自动修好它。 这是三个问题里最刺眼的一个。
对照谈判侧的数据,反差非常大:
我们知道 2,036 次谈成了什么价,却只知道 16 次实际付了多少。 系统答不出「我们有没有按谈成的价格付款」这个问题。 任何按达人汇总的历史报价、可靠度、ROI,今天都建立在一个 0.3% 覆盖率的字段上—— 那不是数据稀疏,那是这条链路没有被写入过。
creator-as-record 提供的是汇总的容器,不是数据本身。金额必须单独补一条带出处的记录:
payment(deliverable_id, creator_id, amount, currency, source_kind, -- 'finance_webhook' | 'operator' | 'contract' source_ref, -- ← 不是消息,是打款回执/财务事件 paid_at, agreed_amount_ref -- 指向当初谈成的那条价格事实)
注意最后一列:把「付了多少」和「当初谈的多少」用外键连起来, 差额本身就是一个可查询、可告警的指标。这一列今天不存在,所以这个问题今天连问都问不出来。
这条比看板本身更值得先做。没有金额,看板上「已发布 · 待打款」那一格永远是空的, 而那恰好是运营最想看的一格。
手动绑定工具今天就存在,上线以来一共用了 310 次——0.45% 的 engagement。不是不好用,是量级根本不允许。
thread 的归属状态分布:
29 万条线程、平均每个合作 6.1 条、长尾有 15 条以上的。 任何需要人逐条确认的绑定方案,在这个量级上都不成立。 绑定必须默认自动,只把真正歧义的少数抛给人——而且要立刻抛,不是静默排队。
好在歧义是少数:绝大多数入站消息都带着技术标识符(邮件的 In-Reply-To / References 指向我们自己发出的 Message-ID、WhatsApp 的引用消息 id)。真正需要人判断的, 是「这个手机号同时属于两个在跑的合作」这一类——而这时最快的办法往往是问达人本人。
数据完全支持。今天同步过来的 455 条 campaign,结构化信息几乎为零:
| 字段 | 实际情况 | 可用性 |
|---|---|---|
| platform | 455/455 都是 "multi" | 1 个取值 = 零信息 |
| target_influencer_profile | 0 条填充 | 永远是空字符串 |
| requirements | 454 条有值 | 整个 brief 压成一条自由文本,无结构化渠道/数量 |
| budget / pricingMode / region | 455 / 455 / 39 种 | 这三个是真有用的 |
同步换来的是 label + budget + pricingMode + region,外加一坨没结构的文本。 为这点东西绑一个跨服务的强依赖不划算,而且它让「新建一个活动」必须绕道 Java 服务—— 这正是 custom:<uuid>、fork:<origin>[n] 这类伪造身份的来源。 Coordinator 本地拥有 Campaign,我赞成。
绑定问题的原话是:「这条会话,属于这个达人 8 个在跑的活动里的哪一个?」 把 Campaign 的所有权从 Java 挪到 Coordinator,没有回答这个问题—— 它换了容器的主人,没有提供选择的依据。
两件事必须分开:
| 问题 | 本地拥有 Campaign 能解决吗 | 需要什么 |
|---|---|---|
| 创建成本 建一个分组要绕 Java 服务 | ✓ 完全解决 | 本地 Campaign 表,运营直接建 |
| 归属选择 这条 thread 归哪个合作 | ✗ 完全不解决 | 一条独立的 thread ↔ collaboration 边,带证据与时间区间 |
-- Campaign 是分组容器,本地拥有,创建零成本 campaign(id, label, budget, pricing_mode, region, external_ref nullable -- 想同步就同步,不同步也能用) -- 绑定是一条独立的、可修订的边 —— 这才是回答「哪一个」的地方 thread_binding(thread_id, collaboration_id, evidence_kind, -- in_reply_to | quoted_msg | operator | asked_creator evidence_ref, valid_from, valid_to)
拥有 Campaign 让「绑定目标」变便宜,不让「选哪个目标」变自动。 前者是你说的那个改动,值得做;后者需要另外一条边。两个都要,但别指望一个解决另一个。
creator ← 新增。持久的人。复购/历史报价/可靠度住这里 └ creator_identity ← 新增。email / phone / handle,可 merge / unmerge │ │ (邮箱已能缝合 5,460 人 / ~14,020 条 engagement) ▼ collaboration ← 今天的 engagement。一次 creator × campaign × 轮次 ├ campaign_id 本地拥有,可空(临时对话/实验不需要活动) ├ deliverable[] 各自独立推进 │ ├ obligation ← 新增。球权 + 缺口 + 时限,按交付物 │ └ payment ← 新增。实付金额 + 出处 + 指向当初谈成的价格 └ fact[] ← 新增。价格/条款/承诺,每条带消息出处 thread ←→ thread_binding ←→ collaboration CRM 拥有 thread;Agent 拥有 collaboration;binding 是两者之间唯一的边
注意 thread_binding 这条边的位置:它正好落在 CRM 与 Agent 的分界线上。 CRM 负责把消息收好、归到 thread;Agent 负责业务状态; binding 是唯一需要两边协商的东西,也因此是唯一需要谨慎设计的接口。
不需要 renewal_round,不需要 parent_engagement_id (今天分别只用了 13 次和 134 次)。复购就是同一个 creator 下的第 N 条 collaboration——一个 COUNT(*)。 历史报价是这个人名下所有 payment 和已接受价格事实的集合, 带币种、交付物类型、日期,作为历史给人看,不作为当前条款自动带入。
8,586 个进行中的交付合作,只有 4,125 个有 deliverable 记录——52% 是空的。 而有记录的里面,3,837 个只有 1 条交付物,只有 288 个是多交付物。
所以看板上线第一天,一半以上的合作会是空泳道。 「把交付物建出来」本身要成为流程的第一步,不能假设它已经存在。 好消息:合同签署时按谈成的价格条目自动播种交付物,这条链路代码里已经有了。
这张图里有三件今天做不到的事,每一件对应上面一个问题: ① 顶部「@laura · 3 个合作活动」需要 creator 身份(问题一); ② 每一行右侧的金额需要 payment 记录(问题二); ③ 每条消息引用能点开原文,需要事实带出处。
| 序 | 归属 | 动作 | 为什么是这个顺序 |
|---|---|---|---|
| 1 | CRM | 消息规范化:入库时剥追踪链接、UI 已读状态、恒空字段;加流量分类(群发/自动回复) | 零 schema 变更,不动业务;Agent 重构前就该做完,否则新 Agent 也吃脏数据 |
| 2 | CRM | thread_binding 独立成边,自动绑定 + 歧义立刻抛给人(含「问达人」这一级) | 29 万线程,人工绑定已证伪 |
| 3 | Agent | payment 记录 + 指回谈成价格 | 0.3% 覆盖率是最大的数据空洞,而且看板最想看的一格靠它 |
| 4 | Agent | creator + creator_identity,按邮箱回填一次 | 5,460 人的复购关系今天就躺在库里 |
| 5 | Agent | 暂停 inbox(灰度批次)→ 重建 fact 账本 + obligation → 与旧状态比对 → 切回开启 | 你提的迁移路径。用 suppress_received_channel_agent_runs 逐批做 |
| 6 | Agent | Campaign 本地化,campaign_id 改可空,合成身份退役 | 依赖 2,不依赖 5 |
| 7 | Agent | 看板上线 | 依赖 3+4,否则一半泳道是空的 |
上一版我把「双写比对期」当成必须承受的成本。 你的 pause-inbox 方案把它消掉了——CRM 不停,消息不丢,Agent 可以整个换掉再从消息重建。 这是这次讨论里最有价值的一个改动。
本文数据均取自生产库直查(2026-07-25),未使用任何 negotiation prompt 内容作为论据。
上一版整体架构:Research/coordinator-vnext/04-vnext-architecture.md
实测明细:evidence/prod-token-economics.md · evidence/code-audit-facts.md