wecom-chat · 回复大脑 · 重构方案 v1

回复链路
重构方案

「全包」修了两次还在发。病根不在某条规则,而在链路结构:一次生成,再层层补救。

// 给默默,抄送野荞、Gus · 代码基线 origin/main 61d2216c(2026-10-10)· 之后的代码变化见第 13 章(截至 165e2f5e,2026-10-14)· 标 待验证 的是推断或没实跑的

14 个问题点 F1–F14 · 截至 165e2f5e 已修 F1 F9 F10 五层 L1–L5 P0 → P7 每步一个 PR KV 开关 · 秒级回滚
0
行 src 非测试代码含「全包」165e2f5e 共 120 次(10-10 的 61d2216c 为 92 行 / 106 次;新增的主要是禁用词闸和重写函数本身,另有「已全包」这类状态词)· 多数正当,问题在提示词和模板
0+
处问价判定,语义没登记个微判据另有 7 处以上
0
次修复没修住#2043 · #2055 之后仍发出 · #2069 是第三次,效果未取数
0
条近一月回复大脑 fix 提交09-10 至 10-10 · 全仓同期 399 条
SCROLL / 向下滚动
01
Diagnosis

四个结构性病根

同类问题反复出现,是因为链路先让模型自由生成,再靠一层层规则去补。补救层越多,新错越多。

01

NO SINGLE SOURCE事实没有单一来源

提示词和输出模板一边示范「全包」,一边禁止「全包」。问价判定散在至少 9 处,「是不是个微」的判据 7 处以上,没有一处说清该用哪一份。

02

LLM DOES THE MATH数字交给模型自己算

报价、面积换算写在提示词里让模型算,事后正则兜底。原稿的「4.5 万」就是算错的:89÷2×568 ≈ 2.5 万。

03

PATCHES MAKE NEW BUGS补救层会制造新错

违规后同一个模型盲重写,看不到原稿;个微重写温度从 0.35 升到 0.7。发出版868、300 两个锚点没了,冒出没有依据的「选中能享补贴价」,疑与闸的升级词有关 推断。10-11 起(#2072)企微、个微只重写 1 次,第二次的升级词不会再用上;盲重写和 0.7 的温度还在。

04

NO FINAL CHECK统一的用词检查只覆盖一个词

原先全包替换只在 chatReply 内部做,route 追加的话、确定性快答、投递闸兜底句、已审话术直发都不再查用词。#2069 起这些出口都接了同一个 applyBannedWording,但词表里只有「全包」,没有统一口径源。

方案一句话

不换框架、不改成 agent。在现有 TS 里拆成五层:L1 理解L2 事实L3 写话L4 统一出口L5 决定,再配单一口径源 policy 和回归语料网。「全包」作为第一个样本走 Phase 0,每一步都有 KV 开关,可影子运行,出问题秒级回滚。

02
Reply Pipeline · Interactive

一条消息怎么变成回复

光点就是一条客户消息。点任意节点,下面会展开这一步出了什么问题、怎么修。切到「改后」看五层链路。

代码(确定性) LLM 调用 问题高发步骤 旁路:绕过终审
旁路 A · 分流回复 不经 finalize 旁路 B · 已审直发 直接 return
03
Case Replay · 2026-10-10 14:34

一条回复,三个版本

同一轮里模型写了三版(Langfuse trace 14ae62b4…)。本该只纠正一个数字:编面积闸连续两次触发重写,终审又漏掉个微,最后发出了第三版。

要素
V1 · 主生成原稿
V2 · 盲重写重写 1
V3 · 升级词 · 实际发出重写 2
「全包」
无没写
出现「设计安装全包」
出现终审跳过个微,原样发出
单价 568
对
有「现在 568」
有只剩它
锚点 868
未记录
有「原价 868」
丢失
锚点 300
对
未记录
丢失
总价
算错「4.5 万起」应 ≈ 2.5 万
对「2.5 万」
未记录升级词禁 X万
补贴说法
未记录
未记录
新增「选中能享补贴价」
闸 ① · V1 → V2编面积闸误判(F1)

图片占位被改写后闸认不出,带面积的原稿被当成「编造面积」,触发盲重写。纠正方向从一开始就偏了。#2085 已修:闸改读画像里的 sent_image。

闸 ② · V2 → V3升级词冲突(F7)命中哪道闸据上一轮 trace

「2.5 万」再次命中编面积闸,第二次重写带上只许留 568 这种单价,868 和 300 被删。推断#2072 起企微、个微没有第二次重写,这条路径已不存在。

闸 ③ · V3 → 发出终审跳过个微(F9)

终审替换的判据只认企微,个微请求三项全假,函数直接 return,「全包」原样发出。#2069 已修。

// 「设计安装全包」这串字在 src 里一处都没有。它来自提示词示范,还是门店资料 c83ee422(正文有「设计安装售后全包」)待验证

04
Fix History · 2064 PRs Scanned

为什么修了两次还在

「全包」是一步步被写进代码、被测试保护起来的。两次修复都只修了症状,没删源头。

07-29 #90 报价明细写成 「…安装全包」 08-17 #581 测试用 toContain 把错误口径钉成期望 09-24 #1624 · #1656 又新增两处 带全包的代码句 10-09 21:08 修复 ① #2043 加硬规 + finalize 替换 留下 4 个洞 10-10 10:08 修复 ② #2055 只改了 3 处代码模板 ①②③ 三个洞没动 10-10 14:34 仍然发出 「设计安装全包」 +「选中能享补贴价」 #2043 的 4 个洞 ① 判据漏个微 ② 旁路绕开 ③ 源头没删 ④ 检测器没接

// 口径:GitHub 全量 2064 个 PR,按标题和正文匹配全包 / W-85 / W-86,加 git log -G'全包' 的 83 个提交,逐个读 diff

六种失败模式 → 重构后在哪挡住
左:这次的表现 · 右:拦截位置
同一规则多个出口,判据各写各的替换只覆盖 chatReply,判据只认企微
L4 统一出口登记表加 textPolicy 列
修症状,不删源头#2055 之后 llm.ts:1449、pricing.ts:734/770(61d2216c 行号)仍在示范,#2069 才删
policy 单一源+ 源码守卫测试
测试把错误口径钉成期望#581 的 toContain「全包」
断言由 policy 生成不手写
检测器不接线,PR 却写已接hasWecomFullPackageWording 零调用(至今仍是);#2069 的 hasBannedWording 由登记表 AST 核验接线
登记表 AST 扫描规则不接线,CI 变红
测试停在纯函数层#2069 补了 wx_pc_* / wx_phone_* 的单测(llm.test.ts,调的是 rewriteCustomerFacingWecomResult),仍没有wx_* 人设 + 模型输出全包的 chatReply 端到端用例
渠道 × 人设矩阵用例eval 放开 wx_* 人设
验收看版本,不看行为只核了 commit 和 Langfuse 版本号
provenance 信号 + 真实回复回读验收以行为为准
05
Root Causes · Beyond 全包

不只是「全包」

近一个月改到回复大脑的 fix 提交有 100 条。按标题关键词粗分,同一套结构问题在各个口径上反复出现。

回复大脑 fix 提交 · 按问题类别
条 · 2026-09-10 起非合并提交 · 一条可落多类,不能相加
渠道 / 人设 / 提示词更宽的关键词33
重写 / 删句 / 兜底 / 护栏31
门店 / 地址 / 城市25
报价 / 面积 / 补贴口径17
什么都不命中多为端上 / 统计11

// 「渠道/人设/提示词」33 条是前三类都不命中的 44 条里,再用更宽关键词命中的部分。换一组近义词,各类会浮动几条

口径 / 用词

口径散在提示词、价目块、模板、门店资料、测试期望里

policy.ts 单一源 + 源码守卫,L4 用同一份做终审

价格 / 面积 / 总价

模型在文本里自己算,确定性报价只在一个分支生效

L2 FactPack 由代码算好,L3 用占位符回填

意图判定

问价 9+ 处、城市解析器 8 个 只做了 grep、个微判据 7+ 处

L1 TurnFacts 具名字段,全仓只判一次,双跑对账后下线旧的

渠道分支漏接

channel === "weixin" 有 46 处 165e2f5e;61d2216c 为 45;一个 weixin 同时代表 Winds、安卓、macOS 三端

渠道和人设进 TurnFacts,渠道策略默认从严

护栏改坏句子

盲重写 + 升级词 + 多层删句,最后没有一致性复核

L5 只 reask 1 次否则转人工,L4 检查残句

门店事实

已部分收敛:门店删句 09-30 起只在一处拼,登记表 10-07 落地

门店资料进 FactPack,登记表补 textPolicy 列,不重复做

06
Target Architecture

五层 + 两个支撑

步骤固定,模型只出现在「写话」和「reask」两处。这是 workflow 的写法,不是 agent。把鼠标移到右侧图例上,对应的层会浮起。

L1 理解
L2 事实
L3 写话
L4 统一出口
L5 决定
L5 · 决定≤1 次 LLM发出 / reask 1 次 / 转人工。reask 带原稿,温度取 min(主生成, 0.7)。
L4 · 统一出口 final-check所有改写、追加、兜底之后跑一次:policy 违规、数字没出处、残句。
L3 · 写话1 次 LLM把 FactPack 写成人话。口径段由 policy 生成,数字用 {{unit_price}} 占位回填。
L2 · 事实 FactPack纯代码:算 868 / 300 / 568 和总价区间,查门店、异地费。每条带出处。
L1 · 理解 TurnFacts只判定、不写话。问价、面积、渠道、是否发图,每轮只判一次,下游一律读它。
原则 01能用代码确定的,不交给模型
原则 02每个事实只有一个来源
原则 03每个出口都过同一道终审
原则 04补救只做一次,且不比原稿更随机
每个机制的出处(展开)
机制类别出处适用边界
理解 → 事实 → 生成 分层经典架构·类比Gao 等《Neural Approaches to Conversational AI》§1.2;Jurafsky & Martin 附录 K经典架构的策略在生成前;本方案 L5 在写话后,是事后校验
LLM 只出判定,回复用模板开源项目Rasa CALM:LLM 只建议下一步,回复默认是预定义模板Flow Policy 是确定性状态机只在搜索摘要里看到
reask:带原稿和原因重生成开源项目Guardrails AI on_fail=REASK,源码把原输出作为 previous_response 传入带原稿这点要看源码确认
最终输出统一校验开源 / 厂商文档OpenAI Agents SDK output guardrail;NVIDIA NeMo output railsNeMo 允许改写,本方案选择不盲改写
不盲改写,给外部反馈重来一次论文·单篇Huang 等,ICLR 2024(arXiv 2310.01798)测的是推理基准;现状重写已带违规原因,论文不能直接证明现状错
数字由代码算论文PAL(arXiv 2211.10435);Toolformer(arXiv 2302.04761)只核了 arXiv 摘要页
单一口径源行业标准《The Pragmatic Programmer》DRY—
CI 强制架构约束行业标准ThoughtWorks fitness function;ArchUnit / Import Linter原书未打开,以 Radar 为准
新规则对每个出口表态本方案组合穷尽性检查 + fitness function + DRY 的组合没找到现成先例,按组合创新对待
影子运行 + KV 开关行业标准 · 仓内已有Fowler Strangler Fig;Istio 流量镜像;Feature Toggles;仓内 android-planner-shadow.tsStrangler Fig 用在提示词流水线上是类比
回归语料进 CI开源 · 仓内已有promptfoo、OpenAI Evals;仓内 eval-fixtures沿用仓内 harness,不引入 promptfoo
07
Phase 0 · Stop The Bleeding

「全包」先止血

覆盖 10-10 那条消息走的主回复出口。方案写成时召回、opener、kf 不经过出门闸,要到 P2/P3 才补齐,这里不说成「全部修死」。净增约 40 行 估计。当晚 22:39 的 #2069 已先把下面步骤 1–4、6 和步骤 8 的一部分做完(做法与下面略有不同),召回、opener、kf 也接上了禁用词闸;各步现状见第 13 章。

0先写红灯测试把第 8 步的单测和 eval 挪到最前面,先在 main 上跑红,再动代码。eval 有随机性,多次采样至少命中一次才算红。
1终审覆盖个微判据加 || channel === "weixin",保留 isSalesPersona。只改用词,不改报价数字。已做(#2069,用 isPrivateOneToOneChannel)· src/lib/llm.ts:3548-3553
2替换前看语境同一句有装修 / 硬装 / 软装 / 整装就不替换,只记 signal,避免造出新承诺。已做,做法略不同:装修语境的「全包」改叫「整装」,不是不替换 · src/lib/llm/signals.ts:1356-1373
3出口兜底出门闸 return 前两个渠道都接替换,动作写进 sanitizerActions。命中查不到 provenance,只能看响应体和日志。已做,位置不同:route.ts:6622(主出口)、:3926(opener)、:1838(召回预览)、:1416(召回草稿)。没写进 sanitizerActions,仍查不到 provenance
4删源头示范只改柜体语境的句子,改成包含板材、柜门、基础五金和安装。装修语境和识别客户输入的判据不动,signals.ts 逐句判断。已做(#2069):llm.ts:1451 · org-generic-rules.ts:179 · pricing.ts:755/791
5decision 也加禁令Langfuse 先 dry-run 看漂移,再 --apply,再拉快照附进 PR。约 30 秒生效。代码侧没做:llm.ts:1695 的决策期兜底里仍无禁令;Langfuse 侧本次没取,待验证
6重录 goldenydyx 和 org-profile 分开重录,PR 里写明理由。ydyx 两份 golden 已重录(#2069、#2082);org-profile 的未动
7门店资料先只读确认 c83ee422 确实含「全包」。改正文,还是渲染时替换,要运营同意。渲染时替换已由 neutralizePromptWording 做到(llm.ts:3906);库里正文没核
8回归防线chatReply 单测(wx_phone_* + weixin)、装修语境负例、提示词禁用词一致性、eval 放开 wx_* 人设、quality-watch 加违禁词标记。单测、装修语境负例、中性化测试已做(llm.test.ts、banned-wording.test.ts);eval 放开 wx_*、quality-watch 标记没做
9验收近 24 小时主回复出口的 ai 回复里,柜体语境「全包」为 0,全量分页。排除销售手打、召回、opener、kf 和合法的装修语境。
08
Migration Roadmap

每步一个 PR,可开关

P0 要等第 12 章第 1、4 件拍板,放行当天可上线。沿用 10-06 审计结论:随修随拆,不单独开一轮重构。开关放 system_settings KV,缺键即 shadow,{"mode":"off"} 立即停用。

P0P1 P2P3 P4P5 P6P7
P0止血
全包在回复出口止血

第 07 章那 10 步。

验收:测试先红;线上 24h 柜体「全包」= 0 · 主体已由 #2069 上线,24h 取数待验证
P1单一源
policy.ts + 源码守卫

全包、全国统一价、补贴口径收进来,提示词由它生成。

验收:守卫测试先在旧代码跑红
P2shadow
L4 final-check 影子运行

登记表加 textPolicy 列,所有出口只观察不拦截。(#2069 已直接以 bannedWording 一列、拦截方式上线,只含「全包」。)

验收:违规率 · 每个出口有表态
P3enforce
L4 切成拦截

顺序:全包 → 门店 → 价格。

回滚:KV 开关秒级关闭
P4双跑
L1 TurnFacts 双跑

旧判定照用,分歧写 turnFactsDiff,为 0 后逐个切换。

切换条件:分歧为 0
P5事实
L2 FactPack

先接个微问价这一条路径。

约束:ydyx golden 逐字不变
P6决定
L5 reask 替换盲重写

北极星:实发 ≠ 原稿的比例下降。(企微、个微已缩成 1 次纠错,见第 13 章。)

盯:modelDraftChanged · 转人工率
P7最敏感
L3 占位符回填

放最后:改一句措辞,曾 4 次采样回归连伤两轮。

配 eval 双跑

METRICS现成的验收指标

  • output_signals:全包替换、reroll 修复/未修复、modelDraftChanged、finalRepairAttempted / finalRepairFixed
  • latency_ms、cfg / host
  • weixin_reply_withheld、weixin_gate_replaced_reply、quote_issued(/api/status 的 quote_issued_7d)
  • quality-watch:红线 20%,黄线 10%

ROLLBACK四种回滚

  • KV 开关:秒级
  • 代码:revert 后手动触发 vercel-deploy
  • Langfuse:production label 移回旧版
  • 配置:wecom-config-at.ts --rollback

SHADOW影子运行

  • 结果写入 llm_call_provenance,stage="shadow"
  • 不新建表,不改 production env
  • 必须放在 after() 里,否则成本翻倍
09
Admission Gate

让同类问题进不来

以后加任何一条不许说 X / 必须说 Y,都要同时满足这五条。能交给 CI 的交给 CI,不靠人记。

Gate 1只定义在 policy.ts源码守卫:禁用词出现在别处就红
Gate 2提示词由 policy 生成prompt-price-consistency 扩展版
Gate 319 个出口逐行表态exit-registry.test(AST 扫描,CI 常驻)
Gate 4至少一条事故语料覆盖渠道 × 人设,改前改后各跑
Gate 5验收看行为PR 模板必填 provenance + 真实回复回读
新规则→五条全满足 → 合入/前四条 CI 检查 · 第五条 PR 模板必填
10
Change Map · File × Phase

哪个文件,哪一步动

绿色是新建文件,圆点是在该阶段改动,横条表示跨多个阶段持续改。

// llm.ts 现 5627 行、signals.ts 3100 行、route.ts 6686 行(61d2216c;165e2f5e 为 5682 / 3117 / 6713)。行数下降后两道尺寸闸都要收紧(check-file-size / check-size-ratchet)

11
Risks & Repo Rules

风险与仓内规范

企微也受影响

模板、报价块、reroll 两个渠道共用;kf 只传 persona 也走同一套。企微 reask 温度 0.7,与现在持平。

延迟与转人工

预算 75s、路由上限 95s(Winds 端上只等 75s,服务端对 salespc-* 主机收紧到 55s,#2077)。企微、个微的重写已在 #2072 从 2 次减到 1 次,误拦的代价因此更高:只要一道闸误拦、那 1 次重写又没过,这条就不发出。10-13 就有客户说「40方」被编面积闸误判(#2124 补了正则)。

措辞敏感

L3 改一句话就可能伤回归,所以放最后,并配 eval 双跑。

非有大有小机构

quoteProfile 分支的报价必须逐字不变,只有 P0 的用词替换例外。

端上旁路

Python local_fastpath 和 macOS sanitize 在服务端闸之外,登记表只能标 exempt 或 gap。

仓规

改 fallback 必同步 Langfuse;高危路径以 high-risk-paths.regex 为准;评测用 EVAL key;样本不带真手机号或 wxid。

12
Decisions Needed

需要拍板的四件事

每件都写了默认做法:不回复就按默认执行。第 1、4 件决定 P0 能否开工。

1默默 × 野荞

野荞 10-10 上午说 W-86 的全包残留「先不要修」,和 P0 冲突。(已被事实超过:当晚 #2069 上线了「全包」出口闸,代码注释记为野荞要求「不允许出现全包」)

DEFAULT不动代码,只把结论写进 W-86 台账(165e2f5e 里 P0 主体已上线,默认项作废)
2运营

门店资料 c83ee422 的「设计安装售后全包」:改正文,还是渲染时替换

DEFAULT渲染时替换,不动数据(已由 #2069 的 neutralizePromptWording 做到,库里正文没改)
3运营 / 夏夏

补贴适用条件的正式口径:无条件「补贴后」,还是「入选才有」

DEFAULTP1 先按现行价目块写进 policy,标「待运营确认」
4默默 / Gus

是否按 P0 → P7 推进,每步一个 PR

DEFAULT放行后从 P0 开始,每一步合入前更新进度(P0 主体已上线;audit-20261006 §7 记 10-13「硬事实改由代码填槽」本轮不下结论,P5、P7 仍待核)
13
After 61d2216c · to 165e2f5e

10-11 之后代码的变化(截至 165e2f5e)

本方案按 61d2216c 写成。之后进 main 的 81 个提交里,动到回复链路的主要是下面这些。有的把方案里的事提前做了,做法不完全一样;有的让前面某句话不再成立。除标了待验证的最后一行,其余都对着代码读过。

PR做了什么对应方案哪一项现状
#2069
10-10 22:39
新增 outbound/banned-wording.ts:出口统一改写「全包」,喂给模型前把上下文里的「全包」中性化。终审判据补上个微,改成按句处理,装修语境叫「整装」。已审话术直发、opener、召回、kf、route 出门后都接上。exit-registry 加第 5 道闸 bannedWording,19 个出口逐个表态。企微、个微 nurture 规则和价目块里的「安装全包」示范删掉(抖音 DM 提示词里的仍在),golden 重录。
没做:policy.ts、decision / opener 提示词的禁令、eval 放开 wx_* 人设、quality-watch 标记。
F3 F9 F10 F11;F12 只覆盖一个词;第 07 章步骤 1–4、6;第 09 章闸 3部分落地
#2072
10-11
新增 reply-contract.ts、reply-state.ts、order-quote.ts。企微、个微的重写、推理泄漏重试、终审纠错共用 1 次额度,重写循环只剩 1 次;防复读并进终审;终审不过就不发、本轮转人工。客户拒绝过的和指定配色记成会话状态;订单报价由代码算好、进提示词、出门前核对;发送前再复检有没有新消息、报价变没变。事后对算式句整句放行的分支(COMPUTED_TOTAL_RE)删除。
没做:原稿不带进纠错,重写温度仍是 0.7(终审那次纠错 0.4);L2 只做了订单报价这一块,没有占位符回填。
F5 F6 F7;第 06 章 L5;P6;第 11 章风险卡 2部分落地
#2085客户发过图记成画像字段 sent_image,编面积闸只读它,IMAGE_RE 删除。F1;第 03 章闸 ①;L1「只判一次」的第一个实例已修
#2124 #2127 #2134「40方」「四十方」也认作报了面积:8 处客户面积正则一次补齐,之后两次修中文数字的误判。#2127 把重写失败的原因分成「额度用完」和「请求异常」。第 05 章「意图判定」:同一个判定写了 8 处;风险卡 2:误拦会让整条不发相关,结构没改
#2075 #2081 #2082价闸误判让整条没发,各补一处:群发里的「原价868」不再当成已报价;城市不明却说「没有实体店」改成问城市;见光板 200 / 240 等官方价不再被表外单价闸拦下。F2 F4:闸和口径各写各的,每次事故补一处相关
#2072 #2088498 档问「什么牌子」改成确定性回答:#2072 先做了厦门版(xiamen498Brand),#2088 改名 tier498Brand 并推到全国(reply-contract.ts:28、fast-keyword-replies.ts:277);#2072 还在 getSystemPrompt 里按正则把与客户选择冲突的决策规则过滤掉(llm.ts:2527-2530)。F4:同一条口径现在写在库里的规则、代码里的过滤、固定答案三处相关
#2107 #2114报价发出时落一行 quote_issued,/api/status 多一项 quote_issued_7d;weixin_reply_withheld 的 client 按主机前缀映射。第 08 章验收指标,可加一项;只观测,不拦截观测
#2077Winds 主机(salespc-)服务端出话天花板收紧到 55s。第 11 章风险卡 2 里的预算 75s、路由上限 95s原文要改
#2118docs/audit-20261006 追记 §7(10-13):S1 已做,护栏表现为 5 道闸;「全包」连修三次,上线后效果没取数;「硬事实改由代码填槽」本轮不下结论。第 08 章沿用 10-06 审计结论的那几条;P5、P7 要不要做仍待核参考
其余个微客户表(#2084 #2087)、已签 / 同行不代回(#2094 #2097)、通话记录不代回(#2080)、召回侧(#2106 #2110 #2113 #2115 #2128)、公开原句接口(#2143)、Winds / 安卓客户端修复。只看了改动文件清单和提交说明,没读全部 diff 待验证不改回复大脑的结构;召回渲染仍走 bannedWording(render.ts:119)不影响方案
对本方案的影响

F1、F9、F10 已修;F11 在「全包」一个词上已修;F3 的示范已删,禁令仍只有一条;F7 在企微、个微上不会再触发。F2、F4、F8 的结构没动,F5、F6 只动了一部分,F13、F14 我没看到改动。五层里只有 L5 的重写 1 次、不过就转人工落了地,L1、L2 只有零星实例(sent_image、订单报价),L3 和 policy.ts 没动,turn-facts.ts / fact-pack.ts / placeholders.ts 都没新建;#2069 的做法是给「全包」单独造一道出口闸,没有走方案里的单一口径源。

// 行号和数字以 165e2f5e 为准;第 02 章链路图里标 61d2216c 的行号与本章冲突时,以本章为准