← 目录
otter-coordinator · 架构设计 · 2026-07-25

我们在为「重读」付钱,
不是在为「思考」付钱。

每来一条达人消息,系统就把整条聊天记录重新推导一遍业务状态。真正的 agent 只花掉 coordinator 预算的 2.7%,其余 95% 花在它周围的重复推导上。 这份文档基于线上实测,不是基于代码阅读的推测。

453单个合作的 LLM 调用次数
(13 条入站消息)
$39.40这个合作烧掉的钱
296,224单次调用输入 token
换来 89 token 输出
72%什么都没做就结束的运行
50%模型不执行
已算好的确定性动作

数据来源:otter-lens 线上 LLM 调用日志 + 生产库直查,7 天窗口。 每一条论断在文档里都带一个证据编号 E4, 可以回到 Research/coordinator-vnext/evidence/ 复查。

01
现状

四个案例,说明问题不在「模型不够聪明」

案例 A · 一条 635 KB 的 prompt,98.6% 是噪音

我们抓到线上一次真实调用:任务是「找出达人此前报过的价格」。输入 635 KB / 296,224 token, 输出 89 token。把这条 prompt 按块拆开,比例是这样的 E4:

prompt 组成 · 按真实字符数等比绘制

635,216 chars
聊天历史原文倾倒 ~626,537 chars 触发器 + 当前价格/条款 3,878 任务指令 4,801

把那 98.6% 再拆开,里面装的是:

内容占整条 prompt它是什么
base64 点击追踪链接 × 6742.9%邮件营销平台的跳转 URL
threadUnreadCount / threadLastReadAt / threadReadReceipts27.3%界面已读状态——被喂给一个做价格抽取的模型
htmlContent:nullattachments:null 等 10 个恒空字段含在上一行每条消息重复一次
不可见填充符 ͏ ­ × 1,128邮件预览占位符
真正的价格信号0%
这不是 prompt 工程问题

追踪链接和已读回执不该在入库时就存在于可读文本里。 把 SELECT * 直接递给一个语言模型,不是上下文窗口不够用,是缺一个序列化器。

案例 B · 模型答对了,系统重跑 60 次去推翻它

上面那条 prompt 里的 29 条消息,全部来自同一个营销邮件列表 (emil@iphonephotographyschool.com,标题「Thank You For Contacting Us」×17)。 没有达人,没有价格。模型的完整回答是:

{"priceUpdates": [],
 "summary": "No inbound creator-owned price quote for the current
            YouTube long-form video package is present in the scoped history."}

它是对的。 然后系统在同一份 296k token 输入上重跑了 60 次,花掉 $26.46 E3。 原因在代码里:校验器把「这个事实本来就不存在」和「抽取失败」当成同一种状态, 于是触发了三级修复阶梯 activities.ts:2240-2400—— 整历史重抽 → 再抽 → 内部再循环 5 次。

成本结构

(昂贵的重新推导) × (重试阶梯) —— 最差的组合。 而这条链路的起因是:我们群发冷邮件到了一个公司的客服邮箱,自动回复被当成了达人的谈判回复。

案例 C · 答案早算好了,却花 2 万 token 请模型复述一遍

后端有一段 9,290 字符的确定性 TypeScript,已经把该做什么、出多少钱都算出来了 prompts.ts:2197-2392。它长这样:

required_next_action:
  type: send_channel_message
  requirement:
    recommended_offer_usd: 1000
    comparison_basis: total_package
    deliverable_count: 3
    guardrail: "Use recommended_offer_usd as the exact JO total package offer…"

然后它被塞进一个 19,395 token 的 prompt——其中静态指令占 54.3%,这个决策只占 2.6% E11。模型的全部产出是:把这句话用英文重说一遍,交给下一个 LLM 去写句子。

抽样 40 次真实调用 E12:

20,123平均输入 token
308平均输出 token
(65 : 1)
19 / 40调用产出「什么都不做」
或空手而归
8 / 16明确给了动作,
模型仍不执行
这是正确性问题,不只是成本问题

50% 的不服从率,正是代码里要补一段「模型没发消息就替它发」兜底的原因 main-agent.ts:3185-3248用散文去约束一个类型化契约,是无效的。

案例 D · 叫「campaign_scoped_history」,里面装了 8 个 campaign

系统提示词里白纸黑字写着 “Every engagement is scoped to exactly one creator and one campaign; never mix facts from other campaigns” prompts.ts:71。 而线上那一个「已限定范围」的历史块里,实际含有 E5:

数量维度说明
8不同的 campaignNamePippit US April、CapCut Web YouTube、Temu North America、Pippit MX…
9不同的 referenceId本该唯一标识「活动 × 达人」
4不同的 engagement四个独立合作的消息混在一起
29 / 29标记为 referenceInScope: "true"范围是被断言的,不是被证明的

这不是偶然的 WHERE 写错。历史是按联系渠道聚拢的, 却按活动贴标签——而 19% 的达人(38,299 中的 7,295)同时在 ≥2 个活动上, 最多的一个在 20 个上 E6。这两个集合本来就不相等,没有任何 WHERE 子句能修好。

02
反证 · 必须说的

里程碑没坏,不要重写它

之前的设计稿主张把里程碑整体改造成「证据推导的投影」。数据不支持这个方案 E10:

84,34090 天里程碑翻转次数
0.55%回滚率
(466 次)
71%翻转带人类操作者
——这是运营的产物

值基本是对的,而且主要是运营在用,不是 agent 在用。整体重写是一笔糟糕的交易。 真正的缺陷只有两条:粒度错了,以及没有出处

线上正在发生的 bug

里程碑是按交付物的(3 条视频各有独立进度),但提醒表有一条 「每个 engagement 最多一条活跃提醒」的唯一索引 coordinator.ts:564-569,而 isMilestoneActive() 查询不带交付物条件 milestone-reminders.ts:127-149

后果:3 条视频的包,第 1 条发布后,第 2、3 条的催稿被直接掐掉。 这个不用等重构,是一个独立的小 PR。

03
方案 · 先立纪律

成本问题和安全问题,不许互相抵账

我们把这份材料交给了三个独立评审(只给测量数据,不给结论)。其中一个被明确要求不许读代码库, 它的开场是整轮评审最有价值的一句话:

独立评审 C

「实测数据里没有一分钱是被实体建模缺陷烧掉的。每个成本数字都能追到 prompt 构造。 实体建模的缺陷是安全与正确性问题,而且都不大。 把这两类混在一起,就会变成——用一个序列化器省下的钱,去支付一次重架构。」

问题收益风险周期
线 A
成本
prompt 构造、重新推导、重试阶梯、无闸门 coordinator ~$250/周;同样的改法用在 ai.reply.* 上是 ~$2,281/周 低,多为加法、可回滚天级
线 B
安全
合成身份、沙箱写生产、路由基数、无出处 不省钱;防止把错的价格发给错的人 中,但可做成纯加法周级

线 A 先做,做完重新测量。 线 B 里真正紧急的只有两件——沙箱出站隔离、提醒基数 bug——其余等线 A 的数据再定。

规模约束

最终落到 schema 上是 6 个新增对象 + 3 个可空列,全是加法, 可以挂在现有路由路径背后灰度。这不是一次重架构。

04
方案

三条原则,其余都是推论

原则一 · 一条消息,一生只被 LLM 读一次

消息到达时,一次有界的抽取调用(新消息 + 当前事实卡 → 类型化 delta), 不给历史。此后这条消息的正文不再进入任何 prompt,除非人或 agent 显式引用它。

这不是压缩或检索策略,是结构上让单轮成本与对话长度无关。 「上下文不随聊天记录线性增长」这个要求,由此在构造上成立,而不是靠预算钳制。

评审 C 的原话

「那个 296k token 的调用之所以存在,只是因为这个数字在当初很便宜就能记下来的时候被丢掉了。 60 次重试,是系统在为一次它拒绝执行的写入反复付钱。」

原则二 · 判定用代码,表达用模型

decide(state, trigger) → NoAction | Send(requirement) | Undetermined(q) | NeedsHuman(r)

decide 是纯函数,就是今天的 buildRequiredNextAction——它已经写好了。 改的是它的地位:从 prompt 里的一段建议,变成控制流本身。

返回走向今天的占比LLM 调用
NoAction直接结束72% 的运行0
Send直连 Writer,不经通用 agent40% 的消息运行1
Undetermined通用 agent —— 它唯一该待的地方其余 60%2–3
NeedsHuman升级给运营少量0

数据说清楚了:这是 40% 的优化,不是 100%。 谁说能把 agent 删光,谁就是在吹。

原则三 · 没有出处就不是事实

每一条会话性事实必须能定位到 (message_id, segment, char_start, char_end, span_sha256)。 非会话性事实(打款回调、目录数据)带它自己的来源事件——否则就是在伪造出处。

摘要永远不能成为事实,靠权限边界保证,不靠在 prompt 里写「不要编造」:

  • 摘要不能提供证据引用
  • 摘要不能更新投影、不能授权业务命令
  • 任何基于摘要的检索,必须先把被引用的原文取回来再用
05
方案

一条入站消息的完整链路

每一级都可能在花钱之前就终止

→ 右侧为该级的 LLM 成本
规范化剥掉追踪链接、已读状态、恒空字段、填充符;原始报文进对象存储,永不进 prompt0
流量分类非 LLM 闸门:List-Unsubscribe / Precedence: bulk / Auto-Submitted / 已知 ESP 指纹。只有 human 能继续0 ← 案例 B 在此终止
绑定消息 → thread 始终高置信绑定;thread → deal 单独、可修订、带时间区间0
对手方校验发件人必须是本合作的已知达人身份,否则进运营队列0
抽取器新消息 + 当前事实卡 → 类型化 delta,写进账本并附证据 span。不给历史~2.5k
decide()纯函数判定,72% 在这里直接结束0
Writerrequirement + 风格窗口 + 允许披露的事实 → 一段话。无工具~3k
确定性校验金额 / 语言 / 披露 / 幂等——这些今天已经有代码了0
事务 outbox渠道适配器发送0

三个模型角色,取代今天的十类调用

角色输入预算工具
Extractor新消息 + 当前事实卡2.5k in / 300 out
Planner
仅 Undetermined
有界上下文包8k in / 800 out4–6
Writerrequirement + 风格窗口 + 允许事实3k in / 400 out

删除:批评者(金额/语言/披露校验本来就是确定性的)· 语言检测 + 语言选择 + 翻译(3 次调用折进 Writer 契约)· 轮次分类 · 品牌意图分类 · 以及全部「重新推导」调用。

预期效果

今天vNext
正常一轮~90k token / 6+ 次调用~6k token / 2 次调用
案例 B 那类(营销自动回复)453 次调用 / $39.400 次调用 / $0
72% 的空转运行每次仍构建 context0 次 LLM
单轮成本随历史增长O(n) · 整段 O(n²)O(1)

我不打算把这写成「降本 15 倍」。诚实的说法是:正常路径大约一个数量级,病态路径接近全部消除, 而且 O(n²) 变成 O(n) 是结构性的,不会随时间劣化。

上下文预算 · 每一块都有主人

#预算负责方
1系统策略(按 turn-type 拆,不是一份 30k 的巨石)600平台
2本轮合法的工具(3–6 个)700能力代理
3身份 + 触发器200路由
4确定性状态卡900投影器
5未决义务与待决问题400obligation
6相关的带引用事实卡1,200账本
7达人级持久偏好(白名单谓词)250账本
8最近 6–10 条有业务含义的消息(已清洗)2,000消息表
9检索到的原文证据(仅当 agent 主动要)1,200检索
10工具结果预留800循环
合计目标(硬上限 12k,maxOutputTokens 800)8,250

今天全包没有任何一处设 maxOutputTokens 代码审计 §A4

06
方案

数据模型 · 一张带出处的事实账本

今天状态分散在五种并存的持久化范式里:打分笔记表、价格/条款行表、 jsonb 数组、「时间线最新节点胜出」、以及重建式投影。事实账本一次性取代它们—— 这是简化,不是新增

create table fact (
  id            uuid primary key,
  workspace_id  uuid not null,          -- 沙箱隔离靠这个,不靠 flag
  subject_type  text not null,          -- 'creator' | 'engagement' | 'deliverable'
  subject_id    uuid not null,
  predicate     text not null,          -- 来自版本化注册表,不是模型自由生成
  value         jsonb not null,
  qualifiers    jsonb not null,         -- currency / channel / usage_rights / market
  status        text not null,          -- asserted | superseded | disputed | retracted
  source_kind   text not null,          -- message | system_event | operator
  source_ref    uuid not null,          -- ← 出处是必填,不是可选
  supersedes    uuid references fact(id)
);
create unique index fact_current on fact (subject_type, subject_id, predicate)
  where status = 'asserted';
  • 当前值 = status='asserted' 的那行。 更正 = 插新行 + 回指,旧行转 superseded。历史与审计免费。
  • 冲突不靠置信度裁决。 两条互斥断言且无显式更正关系 → disputed,进人工队列。按置信度选一个是在制造静默错误。
  • subject_type='creator' 的行跨活动可见;engagement / deliverable 的行结构上不可能污染兄弟活动。

「campaign 是唯一容器」这件事怎么破

今天沙箱、fork、临时对话、实验全靠伪造身份挤进 campaign 命名空间 (custom:<uuid>fork:<origin>[n])。 正确做法是把挤在「哪个 campaign」里的三个正交维度拆开:

维度字段含义
出资campaign_id 可空有 → 走预算校验 + 外部投影;无 → 临时,不投影
分组program_id 必填创建时不需要碰遗留 Java 服务。kind ∈ {campaign, oneoff, adhoc, experiment, reengagement}
环境env ∈ {live, sim}creator / channel / deal / message 上都是真实列,在发送适配器强制
一句话验收标准

运营能不能在不碰遗留 Java 服务的情况下,创建一个 program?
能 → 没人会再去伪造 campaign。不能 → 你只是用新名词重建了同一个陷阱。

07
体验

之后长什么样

今天
  • 一个聊天收件箱 + 一排里程碑勾选框
  • 「在等谁」是从位掩码推断出来的,运营看不到到底卡在哪
  • 路由不出来的消息静默重排队 24 小时——达人这期间会凉
  • 达人说「我同意了独家条款」在哪儿?翻聊天记录
  • 3 条视频的包只有一条提醒线,第 1 条发布后另外两条静默停摆

球权看板

Temu US Promotion · @laura_makes · 3 交付物
视频 1 · TikTok 已发布 · 待打款
等财务放款。发布证明已收到 ↳ 7/21 达人消息 4 天
视频 2 · TikTok 球在我们
脚本 v2 等我们审 —— 达人 7/23 已交,我们没回 超时 2 天
视频 3 · TikTok 球在达人
30 秒开场钩子改写 —— 我们 7/12 提的具体意见 ↳ 原文 13 天

关键差别不是好看,是「缺的到底是什么」被写成了一个字段,而不是要人去猜。 催办因此可以指名道姓,而不是「有进展吗?」

agent 提议,人确认 —— 但只在该确认的地方

decide() 返回 Send 的 40% 直接走,不打扰人。 只有 UndeterminedNeedsHuman 才会出现在运营面前, 并且带着它的依据:

待确认 · 2
路由歧义1 次点击
+65 9xxx 4471 发来一条消息,该号码同时属于 2 个进行中的合作
系统不猜。可以选一个,或者——
条款争议球在我们
达人说「我从没同意过独家」。账本显示 terms.exclusivity = agreed ↳ 7/09 14:22「ok exclusive for 30 days is fine」
新增的一级:问达人

今天的系统拒绝猜,同时也拒绝问。对方是唯一确切知道答案的人, 发一句「想确认下你说的是哪个合作?」是完全正常的人类行为。这一级在证据阶梯里本来就该有。

08
体验

签完之后,怎么保证片子快速做出来

线上有 8,586 个进行中的交付合作。签约到成片的链路是一串交接: 选品 → 寄样 → 脚本 → 改稿 → 拍摄 → 改片 → 发布 → 打款。 每一环的延迟都来自「不知道在等谁、等什么」。

把「等待」变成一个有主人、有缺口、有时限的对象

create table obligation (
  deliverable_id       uuid,          -- ← 按交付物,所以 3 条视频并行
  kind                 text,          -- await_script_draft | await_our_review | await_publish …
  owner                text,          -- creator | us | brand   ← 球权,最关键的字段
  blocked_on           text,          -- 能让它完成的那一条 fact 谓词
  due_at, sla_hours, cadence
);

四个加速器

一 · 球权永远唯一且可见

任何时刻,每个交付物有恰好一个 owner一个具名的缺口。 今天「在等谁」是从位掩码推断的,推断不出来就只能发一封「有进展吗」。 有了 owner 字段,球在我们这边的时候会自动升级到内部,而不是去催达人—— 上面的看板里视频 2 就是这种情况,而这在今天是完全隐形的。

二 · 按交付物并行,不再互相掐断

这是前面那条线上正在发生的 bug 的正解:提醒挂在 (engagement_id, deliverable_id, kind) 上, 第 1 条视频发布不会再让第 2、3 条的催稿静默消失。

三 · 催办指名道姓,而不是按节奏骚扰

因为 blocked_on 知道缺的是哪一条事实,催办可以写成:

今天(按天数节奏)

「Hi Laura, 想跟进一下视频进度,有更新吗?」

四 · 素材是有版本的对象,不是聊天里的一条链接

这是那份「充足沙箱与对象存储」预算真正该花的地方—— 按交付物建存储前缀,而不是按达人:

artifact(deliverable_id, kind, version, uri, review_state, source_message_id)
        kind ∈ { brief, script, cut, final, publish_proof }

最大的一类返工是「我们说的是哪一版」。脚本 v1/v2/v3 各自带审阅状态之后, 「已改的地方不会被重复要求,没改的地方不会被漏掉」。 再配合事实账本的引用能力,系统不会再去要一个达人已经给过的东西—— 而今天它会,因为它每轮都在重新推导,推导失败就再问一遍。

关于「每个达人一个容器」,我要泼冷水

达人不是有效的隔离边界——19% 的达人同时在多个活动上, 按达人分容器等于把跨活动污染做成基础设施。而且容器解决不了任何一个实测问题: 成因是重新推导、重试阶梯、缺入库规范化,没有一条是「缺算力」。

这份预算我建议这么花: env 做成真实列并在发送适配器强制, 让 sim 合作只能解析到沙箱地址(今天沙箱直接写生产表,唯一防线是一句 assertDevelopment()); 让容器「扮演达人」接收 sim 出站,做端到端演练; 合同生成、媒体处理、脚本 diff 这类明确作业。 不要用在谈判轮次上。

09
落地

迁移路径 · 阶段 0 今晚就能发

阶段线内容形态
0a
今晚
A 序列化器。 给 LLM 之前剥掉:追踪 URL 参数、null 字段、界面已读状态、邮件填充符、重复引用链。 零 schema 变更、零迁移、对 33k 进行中谈判零风险。 1 个 PR
0b
本周
A 重试策略分类(校验失败 ≠ 可重试)· 每轮调用预算硬上限 · 全局 maxOutputTokens · 工具裁剪成 3–4 个稳定 bundle · 接上 run 级 token 记录 4 个 PR
0c
本周
B 沙箱出站隔离 · 提醒基数 bug 2 个 PR
停下来重新测量。 如果干净的 prompt 之下实体模型仍然疼,那时你有证据,而且要改的系统更小了。
1A/B入库规范化 + 群发闸门 + 原始报文进对象存储。只做这一件,案例 B 类事故归零加法
2Bthread + message_binding,与现有逻辑影子比对加法
3Afact + 到达即抽取。用现有重新推导路径当正确性预言机,一致后关掉它需比对期
4Adecide() 提升为控制流,拿下 72% 空转加法
5Bcampaign_id 改可空 + program_id + env;合成身份退役加法
6Bobligation 取代里程碑提醒;里程碑改只读投影,agent 停写比对期

如果只做一件事,做 0a。 如果只做一周,做完 0a/0b/0c 然后重新测量,再回来看后面。

10
落地

我可能错在哪

这一节是被独立评审逼出来的,我认为它比设计本身更重要。

已经发生的一次量错

案例 A 的 token 数我第一次量错了——把 lens 外层的 originalBytes: 103,930 / truncated: false 当成了 prompt 全长。 是独立评审质疑「98,416 字符不可能等于 296,224 token」,回查嵌套层的 requestMessages.originalBytes = 635,216 / truncated: true 才发现。 真实 prompt 是 635 KB结论比原来更强,但过程说明: 任何来自可观测系统的数字,先确认它的截断与采样语义。 (案例 C 的 40 条样本已复核,12/12 未截断。)

  • 「prompt 越大错误率越高」我说得太满。 38k 那一档的错误率反而低于 26k 档。上线前需按 model / purpose / 错误码 / 首次-重试 拆开重测。
  • 一个病态合作证明了「可能发生」,没证明「普遍发生」。 必须测:多大比例的上下文里含有外来活动、自动化流量、零条真实达人消息。
  • 过滤器的召回率是真风险。 traffic_class 会不会误杀转发、经纪人代收、带真实排期的自动回复、用群发平台发信的达人?必须有隔离区 + 召回率指标,不能直接丢。
  • 编译式上下文会漏细节。 一句很久以前的让步可能进不了本轮上下文,导致「自信地答错」。缓解靠引用可追溯 + 运营可查看「本轮省略了什么」,而不是退回全量倾倒
  • 类型化状态机会拒绝合法但新颖的商业安排,运营可能为了推进流程而乱填状态。必须留显式的人工裁决出口。
  • 33,169 个「进行中的谈判」里有多少是僵尸? 状态模型过载可能在虚报在办量。

完整设计:Research/coordinator-vnext/04-vnext-architecture.md
线上实测 E1–E12:evidence/prod-token-economics.md · 源码审计 A–D:evidence/code-audit-facts.md
三份独立评审(只给数据、不给结论)+ 各自提问:evidence/consultations/
我读评审前写下的独立判断(留档对撞用):evidence/my-position-before-consultation.md