Skip to content

feat(agent): 为群聊讨论增加 probe 发言门禁 - #1237

Open
chen-ran wants to merge 5 commits into
mainfrom
feat/discuss-probe-gate
Open

chen-ran wants to merge 5 commits into
mainfrom
feat/discuss-probe-gate

Conversation

@chen-ran

@chen-ran chen-ran commented Sep 13, 2026

Copy link
Copy Markdown
Member

问题

native runtime 的 discuss 会话此前没有任何发言判定。群里每来一条消息都会跑一次完整 primary——全工具、全上下文、可能带 reasoning——然后靠 mode_discuss.md 里那句 "prefer silence unless intervention is useful" 让模型自己决定闭嘴。

也就是说:该不该说和说什么由同一次调用混在一起决定,而且无论结果如何都已经付出了全额成本。一个 Bot 该发言比例 5% 的活跃群,95% 的调用花在让模型决定不说话上。

shouldTriggerAssistantResponse(@提及 / 回复 / 私聊)那道规则门对 discuss 不生效——inbound/channel.go 的 discuss 分发块在该检查之前就 return 了,注释也写明「The discuss driver autonomously decides whether to call the LLM」。

方案

引入一个外部裁判(probe)。判定模型只拿到 decide 一个工具,强制 tool choice,输出 send / no_action 两值;判定通过才唤醒 primary。

门禁位置在 sync compaction 之前——不唤醒模型的这一轮不需要压缩上下文,所以沉默的群只花一次廉价判定,而不是一次 summarizer 加一次 primary。

模型解析bots.discuss_probe_model_id 优先,回退到 owner 的 title_model_id

该列自 0001_init 起就存在,注释写着 "for probe gate configuration",并一路贯通到 settings 类型、swagger 和 SDK——但没有任何 SQL 查询选取它,也没有任何决策代码读它。本 PR 把这条早已铺好却从未接上的线接上。

回退到标题模型是有意为之:两个槽位要的是同一类模型——便宜、快、自身不需要工具——所以已经配了标题模型的部署无需再做一次选择。bot 级设置仍然优先,因为群聊策略是 per-bot 的,而标题模型是账号级偏好。

fail closed 是门禁的全部意义:模型缺失、provider 不可达、上下文溢出、判定缺失 / 不可解析 / 值不认识,一律落到不发言。唯一例外是从未配置门禁,此时返回 Ran=false,行为与本 PR 之前逐字节一致。

私聊不走门禁。 inbound 的默认分派是 group → discuss、DM → chat,而 chat 模式下模型的文本直接投递给用户(mode_chat.md:Do not use messaging tools for ordinary text replies)。私聊里没有 send 工具要求,也不存在「该不该插话」的问题——每条消息按构造就是对 Bot 说的。门禁只为群聊而存在。

激活契约作为最后一条 user message 追加在会话尾部,而不是写进 system prompt。要求必须紧贴生成点,否则长历史会把它挤出模型的工作注意力。

诊断记录走结构化日志,不落库。每条退出路径发同一组字段(bot_id / session_id / model_id / cause / outcome / gate_ran / activated):cause 区分分支,gate_ran 区分「门禁把它关住了」与「根本没有门禁」。只有 not_configured 用 DEBUG,其余关停一律 WARN,确保默认 INFO 部署看得见。

早先版本曾为此加过 bot_discuss_probe_decisions 表(迁移 0153),后经复查移除:查询无任何读取方、无保留策略、且与日志重复。详见本 PR 的自我修正说明。

顺带的 DB 优化

buildBaseRunConfig 本来就读过 bot settings,RunConfig.Bot 也已装好 BotInfo,此前这两样都会被重复查询。现在 settings 随 ResolveRunConfigResult 返回、BotInfo 直接复用;并从 resolveTitleModel 拆出 resolveBotOwnerUserID,bot 已配门禁模型时不必再读 owner 的 account profile。

每条群聊消息的额外查询:

场景 优化前 优化后
私聊 / DM 4 0
群聊,bot 配了门禁模型 4 1
群聊,回退 title model 4 2
群聊,门禁未启用 4 2

前端

放在 Bot → Advanced 标签页底部,单行 ModelSelect,autosave 无保存按钮(沿用 bot-compaction.vue 的契约)。候选列表要求模型支持 tool-call:不支持的模型永远无法返回判定,门禁会在每次唤醒 fail closed,等于给用户提供一个静音开关。en / zh / ja 三语齐全。

本 PR 未包含

  • primary 的强制 tool_choice 与「结束无发送则强制补发」。native 的 RunConfig 没有 tool choice 字段,要改 Stream() 循环,另起一份工作。当前靠尾部激活契约在提示层保证发言。
  • 外部 runtime(ACP / Claude Code / Codex) 维持原有的 DiscussAddressed 门,未接入 probe。门禁的另一半是交给 primary 的发言契约,托管的外部 agent 不从我们这里接受契约,只给它加判定等于买了一个约束不住的判断。
  • 唤醒去抖。worker 收到 RC 就立刻跑,所以目前是每条消息一次 probe,而不是把连发攒成一次唤醒。仍然比每条一次 primary 便宜一个数量级,但还有优化空间。

验证

  • go build ./... / go test ./internal/... / golangci-lint run ./... 全绿
  • 新增 Go 测试:模型解析优先级(含「override 路径不读 account」的查询次数断言)、判定解析、fail-closed 不变量(对真实提取函数跑 11 种畸形输入)、私聊旁路(全 nil 依赖,任何查询都会 panic)、激活契约追加
  • 新增前端测试 5 例:filterDiscussProbeModels 的 tool-call 要求与 provider 过滤
  • 迁移在一次性 Postgres 容器中实跑:0001_init 全量应用,确认本 PR 未新增任何表;改动过的 GetSettingsByBotID / UpsertBotSettings / DeleteSettingsByBotID 对真实 schema PREPARE 通过
  • provider 请求测试:httptest 捕获四个适配器真实发出的请求体,断言强制工具调用在每个 provider 上都成立

已知的既有问题(非本 PR 引入)

  • pnpm --filter @memohai/web typecheckorigin/main 上已有 65 个文件报错;已对比 baseline,本 PR 未新增任何报错文件
  • scripts/check-ui-contract.mjs 报的 2 处违规都在 apps/web/src/pages/home/components/tool-call-diff-panel.vue,同为既有。
  • 本地 pre-commit 因 shellcheck 未安装(mise shim 无版本)在 internal/workspacedeps/catalog 失败,与本 PR 无关;该包在正常环境下通过。

本 PR 未包含(已知,非遗漏)

  • primary 的强制 tool_choice 与「结束无发送则补发」。native 的 RunConfig 没有 tool choice 字段,需改 Stream() 循环。当前「至少发一条」只有提示词约束,没有运行时保证,应按尽力契约理解。
  • 外部 runtime(ACP / Claude Code / Codex) 维持原有 DiscussAddressed 门,未接 probe。
  • 裁判不接收视觉输入。贴纸/图片仅以文本形态(含 Telegram 的 emoji 名)进入判定,不等同于 primary 的感知能力。
  • 唤醒去抖。worker 只合并已排队的 RC,没有主动去抖;成本收益尚未实测。
  • 故障轮次不重试。driver 在跳过后推进已消费位置,因此失败的那次唤醒不保证在恢复后自动回答原消息。
  • Cloud 同步需要下游处理:effective client type、managed title model 与 effort、buildBaseRunConfig 签名冲突、probe 调用的费用归属。不是零冲突自动同步。

🤖 Generated with Claude Code

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

@github-actions github-actions Bot added change:migrations Adds, modifies, or removes database migrations change:server Changes backend code, configuration, or API contracts change:web Changes web frontend or shared frontend packages needs:format Description needs template corrections; removed automatically once fixed size:L PR size uses the larger of added or deleted lines, excluding generated files labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@chen-ran Please complete the following information using the template:

  • Select exactly one option for "Author": Human / Agent.
  • Select exactly one option for "Type": bug / feat / test.
  • Please complete "Summary".
  • Please complete "Validation".
  • Please complete "Screenshots / Recordings".
  • Please complete "Human QA".
  • Keep exactly one "Human QA passed" checkbox in "Human QA"; leave it unchecked until verified.

Editing the description triggers another check; format feedback does not block or cancel code CI.

@chen-ran

Copy link
Copy Markdown
Member Author

三条全部复现属实,已在 cac8160 修复。

P1 — 激活契约到不了主模型:确认。ProviderRunConfigApplierContextSourceFrags 非空时走 fragsFirst 分支,最终 cfg.Messages = payload.Messages 用 frags 渲染结果整体覆盖消息切片,而 frags 由未含契约的 admitted 构造。写了一条走完整 StartTurn 再过真实 applier 的测试,修复前最终 provider 消息里只剩 <message id="1">photo</message>,契约不见踪影。

契约现在同时进入两种表示。fragment 用 TrustSystem(说话的是 runtime,不是会话参与者)和 OverflowKeep——预算压力下裁掉它,等于付了判定的钱再丢弃这次唤醒的全部理由。

顺带修了一处评审没提到、但同源的顺序错误:injectImagePartsIntoLastUserMessageinjectDiscussImages 都向后找最后一条 user 消息,而激活契约正是最后一条 user 消息。单纯把契约塞进 frags 会让新附件贴到指令上而不是承载它的聊天消息。现在图片先注入、契约最后追加,并有独立测试守着。

P2a — 配置解析失败绕过门禁:确认,按你的区分实现。bot 级 override 是操作者记录在案的决定,此时任何解析失败一律 fail closed 并落一条 outcome=error。没有 override 时失败的只是「能否回退到 title model」这个查询,保持未配置——这里若也 fail closed,一个没有 owner 的 Bot 会被永久静音(resolveBotOwnerUserID 对这类 Bot 是常驻报错,不是抖动)。

P2b — 不完整判定被接受:确认,也核对了你引用的 Cahciua 行为属实(typeof parsed.reason === 'string')。两个字段改为指针解码,缺失与显式 null 都能与空串区分,类型错误直接 unmarshal 失败。

测试补强:新增 2 条过真实 context applier 的端到端测试、4 条判定解析用例(reason 缺失 / null / 非字符串、should_act 为 null)、2 条 P2a 分支测试;fail-closed 不变量的畸形输入表从 11 项扩到 15 项。之前那条 TestAppendDiscussActivation 只断言 helper 把消息追加进了切片——测的是我自己造的接缝,不是真正要保证的行为,这正是 P1 能溜过去的原因。

go test ./internal/...golangci-lint run ./... 全绿。仍未人工 QA。

@chen-ran
chen-ran force-pushed the feat/discuss-probe-gate branch 2 times, most recently from acc1c7b to 1c401a8 Compare September 13, 2026 23:20
@chen-ran
chen-ran marked this pull request as ready for review September 13, 2026 23:22
@chen-ran
chen-ran requested review from a team as code owners September 13, 2026 23:22
@chen-ran

Copy link
Copy Markdown
Member Author

三条全部复现属实,已在 fdf1752 修复。共同后果都是 Bot 永久静音——fail-closed 把每个兼容性/配置问题都转成「不发言」,而其中两处没有自愈路径。这是这套设计的系统性代价,感谢指出。

P1 强制工具调用不跨 provider — 确认

核对了锁定版本 SDK 的四个适配器:

Provider 嵌套 function.name "required"
Anthropic convertToolChoice 识别 → {Type:"tool"} {Type:"any"}
OpenAI Completions 原样透传,格式正确 ✅ 合法 ✅
OpenAI Responses req.ToolChoice = params.ToolChoice 原样透传,但该 API 要顶层 name 合法 ✅
Google Gemini convertTools 只处理 .(string),map 丢弃,toolConfig 为 nil ❌ Mode:"ANY"

已按建议改用 "required"——decide 是该请求上唯一的工具,意图等价。

补了你要求的 provider 请求测试(httptest 捕获各适配器真实发出的请求体)。第一版写错了:测试自己拼装请求,对旧写法照样全绿,只证明了 SDK 能正确转换某个常量,证明不了门禁发的就是它。改成把选项抽进 discussProbeGenerateOptions,生产与测试共用同一个构造后,负向验证结果与你的结论逐项吻合——responses FAIL、google FAIL、anthropic PASS。

P1 固定 16K 裁判预算 — 确认

admission.go:85-97 无条件选中全部 Pinned 加最新一条,超预算即 ProtectedOverflow。摘要按主模型窗口生成,长会话上必然压垮裁判预算;而门禁拒绝后 pumpDiscuss 直接 return,同步压缩不执行,摘要永不缩小——正如你说的,追加 @bot 也无法恢复。

裁判改用自己的 admitDiscussProbeMessages:取能放下的最新连续后缀,不 pin 摘要。判断「现在该不该开口」要的是会话尾部而非线程史,去掉 pin 是消除这一整类溢出而不是处理它。始终保留最新一条,窗口不会为空。预算也不再是常数,按裁判模型自身窗口反推并扣除输出与 prompt/工具定义开销,带下限。

回归测试直接用你描述的形状(5 份各约 4K 摘要 + 一条 @bot),并先断言前置条件:主模型 admission 在同一输入上确实溢出,否则这条测试就不再守任何东西。

P2 标题模型回退 — 确认能力问题,配置问题想听你的

IsValidTitleModel 确实只校验 type == "chat",前端 filterDiscussProbeModels 的 tool-call 过滤保护不到继承路径。已加运行时校验 discussProbeModelCanJudge:裁判模型不支持工具调用时关闭门禁(而非焊死),并记录告警。

但「明确区分 关闭 / 继承 / 指定模型」这一半我没有单方面做。discuss_probe_model_id 是 UUID 列放不下哨兵值,要做三态得加列(迁移 + settings + swagger + SDK + 前端开关 + 三语文案)。而且「默认回退标题模型」是需求方明确选的设计,把它改成 opt-in 是产品决定不是 bug 修复。

我的倾向是加 discuss_probe_mode TEXT 三态、默认 inherit 保持既定语义,同时让「关闭」可达。要不要做、默认取 inherit 还是 off,请示下。

go test ./internal/...golangci-lint run ./... 全绿。仍未人工 QA。

@github-actions github-actions Bot removed the change:migrations Adds, modifies, or removes database migrations label Sep 14, 2026
chen-ran and others added 4 commits September 14, 2026 17:52
native runtime 的 discuss 会话此前没有任何发言判定:群里每来一条消息都会跑
一次完整 primary——全工具、全上下文、可能带 reasoning——然后靠 mode_discuss.md
里那句 "prefer silence" 让模型自己决定闭嘴。该不该说和说什么由同一次调用混在
一起决定,且无论结果如何都已付出全额成本。inbound 的 shouldTriggerAssistantResponse
规则门对 discuss 会话不生效:分发块在该检查之前就 return 了。

引入一个外部裁判:判定模型只拿到 decide 一个工具,强制 tool choice,输出
send / no_action 两值,判定通过才唤醒 primary。门禁位置在 sync compaction
之前——不唤醒模型的这一轮不需要压缩上下文,所以沉默的群只花一次廉价判定,
而不是一次 summarizer 加一次 primary。

模型解析:bots.discuss_probe_model_id 优先(该列自 0001 起就存在,但此前没有
任何查询选取它),回退到 owner 的 title_model_id。两个槽位要的是同一类模型
——便宜、快、自身不需要工具——所以已经配了标题模型的部署无需再做一次选择;
bot 级设置仍然优先,因为群聊策略是 per-bot 的,而标题模型是账号级偏好。

fail closed 是门禁的全部意义:模型缺失、provider 不可达、上下文溢出、判定
缺失/不可解析/值不认识,一律落到不发言。唯一例外是从未配置门禁,此时返回
Ran=false,行为与本次改动前逐字节一致。

私聊不走门禁。inbound 的默认分派是 group → discuss、DM → chat,而 chat 模式
下模型的文本直接投递给用户(mode_chat.md:Do not use messaging tools for
ordinary text replies)。私聊里没有 send 工具要求,也不存在「该不该插话」的
问题——每条消息按构造就是对 Bot 说的。门禁只为群聊而存在。

激活契约作为最后一条 user message 追加在会话尾部,而不是写进 system prompt。
要求必须紧贴生成点,否则长历史会把它挤出模型的工作注意力。

判定落 bot_discuss_probe_decisions(迁移 0153)。outcome 区分 act / no_action /
missing / malformed / error,是为了让「Bot 突然不说话了」能被诊断成判断、解析
失败还是故障,而不是全部塌缩成 no_action。

顺带的 DB 优化:buildBaseRunConfig 本来就读过 bot settings,RunConfig.Bot 也
已装好 BotInfo,此前这两样都会被重复查询。现在 settings 随 ResolveRunConfigResult
返回,BotInfo 直接复用;并从 resolveTitleModel 拆出 resolveBotOwnerUserID,
bot 已配门禁模型时不必再读 owner 的 account profile。每条群聊消息的额外查询
从 4 次降到 1-2 次,私聊 0 次。

前端放在 Advanced 标签页,单行 ModelSelect,autosave 无保存按钮。候选列表要求
模型支持 tool-call:不支持的模型永远无法返回判定,门禁会在每次唤醒 fail closed,
等于给用户提供一个静音开关。

未包含:primary 的强制 tool_choice 与「结束无发送则强制补发」。native 的
RunConfig 没有 tool choice 字段,要改 Stream() 循环,另起一份工作;当前靠尾部
激活契约在提示层保证发言。

外部 runtime(ACP / Claude Code / Codex)维持原有的 DiscussAddressed 门,未接入
probe:门禁的另一半是交给 primary 的发言契约,托管的外部 agent 不从我们这里接受
契约,只给它加判定等于买了一个约束不住的判断。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
评审复现的三个问题:

1. 激活契约实际到不了主模型。turn_discuss.go 把契约追加进 runConfig.Messages,
   但 ContextSourceFrags 仍由未含契约的 admitted 构造。生产装的
   contextview.ProviderRunConfigApplier 在存在 fragments 时走 fragsFirst 分支,
   用 frags 渲染结果整体覆盖 cfg.Messages(provider_run_config.go: `cfg.Messages
   = payload.Messages`),刚追加的契约随即消失。结果是 probe 放行后 primary 仍
   按「可以保持沉默」运行——付了判定的钱,却没换来它授权的那条指令。

   契约现在同时进入两种表示:appendDiscussActivation 返回消息与 fragment 两份,
   fragment 用 TrustSystem(说话的是 runtime,不是会话参与者)和 OverflowKeep
   (预算压力下裁掉它,等于丢弃唤醒这一轮的全部理由)。

   连带修正一处本次引入的顺序错误:图片注入与 injectDiscussImages 都向后找
   最后一条 user 消息,而激活契约正是最后一条 user 消息,先追加会把新附件
   贴到指令上而不是承载它的聊天消息。现在图片先注入,契约最后追加。

   新增的回归测试走完整 StartTurn 再过真实 applier,断言契约出现在最终 provider
   请求且位于末尾;另一条断言图片落在聊天消息而非契约上。原先只测 helper 是否
   把消息追加进切片——测的是我造的接缝,不是真正要保证的行为。

2. 配置解析失败会绕过门禁。owner/account 查询出错时返回零值,Ran=false,调用方
   当成「未配置门禁」继续跑完整 primary。现在区分两种情况:bot 级 override 是
   操作者记录在案的决定,此时任何解析失败一律 fail closed 并落一条
   outcome=error 的判定;没有 override 时失败的只是「能否回退」的查询,保持
   未配置状态——否则一个没有 owner 的 Bot 会被永久静音,而那是常驻状态而非抖动。

3. 不完整的判定会被接受。reason 解码成普通字符串,未检查字段存在性,
   {"should_act":"send"} 与 reason:null 都会得到 outcome=act,既违反自己的
   工具 schema,也与 Cahciua 的 `typeof parsed.reason === 'string'` 不一致。
   两个字段改为指针解码:缺失与显式 null 都可与空串区分,类型错误直接
   unmarshal 失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
三处都会让 Bot 永久静音——fail-closed 的设计把每个配置或兼容性问题
都转成了「不发言」,而其中两处没有自愈路径。

1. 强制工具调用的写法不跨 provider。原本用 Chat Completions 的嵌套
   {"type":"function","function":{"name":...}}。核对 SDK 各适配器:
   Anthropic 的 convertToolChoice 能识别嵌套形式;Completions 原样透传
   且格式正确;但 Responses 适配器同样原样透传,而该 API 要的是顶层
   name;Gemini 的 convertTools 只处理 toolChoice.(string),map 直接丢弃,
   toolConfig 为 nil——裁判根本没被强制,可以用散文回答。前者请求不合契约,
   后者落到 missing,两者都把门禁焊死。

   改用 "required"。decide 是该请求上唯一的工具,意图等价,而这个拼写
   四个适配器都能正确转换(Anthropic→any,Gemini→ANY,两个 OpenAI 方言
   原样合法)。

   新增 provider 请求测试:用 httptest 捕获各适配器真正发出的请求体。
   为此把请求选项抽成 discussProbeGenerateOptions,生产与测试共用同一个
   构造——先前那版测试自己拼装请求,只能证明 SDK 会正确转换某个常量,
   证明不了门禁发的就是它,对旧写法照样通过。

2. 固定 16K 裁判预算会让长会话持续静默。原本复用主模型的
   admitDiscussMessages,它会 pin 住每一条压缩摘要(主模型不能丢历史)。
   但摘要是按主模型窗口生成的,长会话上摘要总量本身就超过任何裁判级预算,
   admission 于是每次都返回 ProtectedOverflow。而门禁拒绝后 pumpDiscuss
   直接 return,同步压缩不会执行,摘要永远不会变小——包括直接 @ 也无法恢复。

   裁判改用自己的 admitDiscussProbeMessages:取能放下的最新一段连续后缀,
   不 pin 摘要。判断「现在该不该开口」需要的是会话尾部,不是线程史;
   去掉 pin 是消除这一整类溢出,而不是处理它。始终保留最新一条,窗口不会为空。

   预算也不再是常数:按裁判模型自身窗口反推,扣除输出保留与 prompt/工具
   定义开销,并设下限。小窗口裁判配大窗口主模型时,原先会构造出它必须
   拒绝的请求。

3. 自动回退的标题模型可能不支持工具调用。IsValidTitleModel 只校验
   type=chat,前端门禁选择器的 tool-call 过滤保护不到继承路径。一个纯文本
   标题模型会静默接管群聊门禁,然后每次唤醒都返回不了判定。

   新增 discussProbeModelCanJudge 运行时校验:解析出的裁判模型不支持工具
   调用时关闭门禁(而不是把门焊死),并记录告警。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这张表是从上游 Cahciua 的 probe_responses_v2 照搬来的,没有在本仓语境下
重新论证。复查后它站不住:

- ListDiscussProbeDecisions 全仓零调用。为「审计轨迹」写了查询,却没有
  任何读取方——没有 API、没有 UI、没有诊断入口。
- 没有保留策略。开了门禁的活跃群里每条消息插一行,无限增长。
- 它复制了一条已经存在的日志。除 token 计数外的每一列,原有的
  "discuss probe: decision" 结构化日志都已经带着。
- token 记账另有其家。token_usage 从 bot_history_messages.usage 聚合,
  这张表的 token 列既不进那个视图也无人查询。

代价是 216 行迁移/查询/生成代码、0001_init 追加 36 行(含 RLS 与多租户
接线),以及热路径上 8 个持久化调用点,换来一份没有读取方、不会过期、
且与日志重复的数据。

当初写的理由是「让『Bot 突然不说话了』能被诊断成判断、解析失败还是故障」。
目标成立,但表不是必要手段:把 outcome 放进日志同样能达成,而且立刻可用,
不需要迁移、不占热路径写入。真正的动机只是上游有这张表——那是 Cahciua
「一切持久化以支持重放」的架构后果,不是本功能的需求。

改为让日志真正承担这个职责:新增 reportDiscussProbe,每条退出路径发同一组
字段(bot_id / session_id / model_id / cause / outcome / gate_ran / activated)
并返回原判定,使「每一次返回同时就是一次记录」。cause 区分分支,gate_ran
区分「门禁把它关住了」与「根本没有门禁」——这正是操作者最容易混淆、而
原先各打各的 warn 无法回答的那个问题。私聊不记录,否则每条私信一行会淹没
真正重要的记录。

配套测试断言字段集在各路径一致、两种静默可区分、门禁关停以 WARN 可见、
私聊零记录。

bots.discuss_probe_model_id 是本 PR 之前就存在的列,保持不动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chen-ran
chen-ran force-pushed the feat/discuss-probe-gate branch from b372dcd to 4cd1f7f Compare September 14, 2026 09:53
@chen-ran

Copy link
Copy Markdown
Member Author

自我修正:移除 bot_discuss_probe_decisions 表(4cd1f7f

这张表是从上游 Cahciua 的 probe_responses_v2 照搬来的,我没有在本仓语境下重新论证过。复查后它站不住:

  • ListDiscussProbeDecisions 全仓零调用。我为一个「审计轨迹」写了查询,却没有任何读取方——没有 API、没有 UI、没有诊断入口。
  • 没有保留策略。开了门禁的活跃群里每条消息插一行,无限增长。
  • 它复制了一条已经存在的日志。除 token 计数外的每一列,原有的 discuss probe: decision 结构化日志都已经带着。
  • token 记账另有其家token_usagebot_history_messages.usage 聚合,这张表的 token 列既不进那个视图也无人查询。

代价是 216 行迁移/查询/生成代码、0001_init 追加 36 行(含 RLS 与多租户接线),以及热路径上 8 个持久化调用点。

我当初写的理由是「让『Bot 突然不说话了』能被诊断」。目标成立,但表不是必要手段——真正的动机只是上游有这张表,而那是 Cahciua「一切持久化以支持重放」的架构后果,不是本功能的需求。

改为让日志真正承担这个职责:新增 reportDiscussProbe,每条退出路径发同一组字段(bot_id / session_id / model_id / cause / outcome / gate_ran / activated)并返回原判定,使「每一次返回同时就是一次记录」。cause 区分分支,gate_ran 区分**「门禁把它关住了」与「根本没有门禁」**——这正是操作者最容易混淆、而原先各打各的 warn 无法回答的那个问题。私聊不记录,否则每条私信一行会淹没真正重要的记录。

配套测试断言:各路径字段集一致、两种静默可区分、门禁关停以 WARN 可见、私聊零记录。

0001_init.up.sql 与生成的 models.go 都已用 git diff origin/main 确认逐字节回到主干原样。bots.discuss_probe_model_id 是本 PR 之前就存在的列,未动。迁移路径在一次性容器里重验:表不存在、该列仍在、GetSettingsByBotID 对真实 schema PREPARE 通过。

净效果:本 PR 少一张表、一个迁移、一处 RLS 接线,以及每条群聊消息一次写入,诊断能力反而更强。


顺带已 rebase 到含 #1238 / #1235 / #1239 / #1240 / #1241 的最新主干,全量 go test ./internal/...golangci-lint run ./... 绿。仍未人工 QA。

A1 裁剪拆开工具交换。admitDiscussProbeMessages 只按大小取后缀,没有角色
感知:较大的 assistant 工具调用被裁掉后,较小的 tool result 仍可能留在
窗口开头。那不是任何 provider 接受的独立历史,请求会以协议错误告终,而
不是返回判定——门禁于是因为与判断无关的原因让 Bot 沉默。

镜像 turn.AdmitContextEntries 既有的同名处理:窗口起点向前推过任何其调用
已不在窗口内的 tool response。测试补了混合 assistant/tool/user 的边界,
包括连续 tool result 与大小写变体——原先只测纯 user 消息,结构上看不到
这个问题。

A2 预算不是硬上限。原实现把输入预算向上钳制到 floor,4097 窗口因此索要
2048 输入 + 2048 输出 + 1024 开销 = 5120,每次唤醒都被拒;最新消息又被
无条件保留,8192 模型可选入约 20000 token。

改为真正的预留:输入、输出与 prompt/工具 schema 开销必须同时装进裁判自身
窗口;装不下最小请求时返回 ok=false 并报 model_window_too_small,而不是把
已知超限的请求交给 provider 试错。未声明窗口按 8192 保守假设(与
titleInputFallbackWindow 同源)。超长的最新消息改为头尾截断;带 RawContent
的结构化载荷不能安全裁剪,报无有效窗口而非发出畸形请求。

原先的 TestDiscussProbeContextBudget 把「tiny window clamps to the floor」
断言成了预期行为——测试把缺陷固化成了契约。改为断言算术性质:任何窗口下
输入+输出+开销都不得超过窗口。

A3 显式配置的不兼容模型绕过门禁。能力校验失败时无论配置来源都返回
Ran=false。继承来的标题模型这样处理是既定政策,但显式配置的门禁在模型能力
变化后也被直接关闭——与「配置读不出时 fail closed」自相矛盾。现在按来源
区分:显式配置 fail closed 并报 configured_model_cannot_call_tools;继承
保持 ungated。

A4 异常关停从默认日志消失。分级原本按 Ran 判断,于是「模型不支持工具调用」
这种无 error 的关停落到 DEBUG,在默认 INFO 部署中等于没有记录——而这恰是
Bot 停止说话时要找的那条。改为按 cause 分级:只有 not_configured 用 DEBUG,
其余关停一律 WARN。

测试 handler 的 Enabled 原本恒为 true,结构上就不可能发现级别过滤问题。改为
携带真实阈值,并新增按 cause 逐项断言级别的表,使新增分支无法再静默继承
DEBUG。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chen-ran

Copy link
Copy Markdown
Member Author

A1–A4 全部复现属实,已在 9d5007e 修复;PR 描述按 C7 更新。

A1 裁剪拆开工具交换

复现:[assistant(大, tool call), tool(小, result), user] 在 2048 预算下返回的窗口首条是 tool ——调用被裁掉,结果留下。

仓库里本来就有正解:turn.AdmitContextEntries 明确处理「窗口不得以孤儿 tool response 开头」(admission.go:115)。我镜像了它而不是另发明一套。测试补了混合 assistant/tool/user 边界,含连续 tool result 与大小写变体。你对原测试的批评准确——只测纯 user 消息,结构上就看不见这个问题。

A2 预算不是硬上限

复现数字与你给的一致:4097 窗口 → 输入 2048 + 输出 2048 + 开销 1024 = 5120 > 40978192 窗口下单条超长消息选入 约 20000 token

改为真正的预留,三者必须同时装进裁判窗口;装不下最小请求时返回 ok=false 并报 model_window_too_small,不再把已知超限的请求交给 provider 试错。未声明窗口按 8192 保守假设。超长最新消息头尾截断;带 RawContent 的结构化载荷不能安全裁剪,报无有效窗口而非发出畸形请求。

修复后:4097 → 拒绝;8192 → 5120+2048+1024 正好 8192;超长消息 → 截到预算内。

TestDiscussProbeContextBudget 把「tiny window clamps to the floor」断言成了预期行为——测试把缺陷固化成了契约。改为断言算术性质而非常数。

A3 显式配置的不兼容模型绕过门禁

属实,而且与我上一轮刚修的「配置读不出时 fail closed」自相矛盾。现按来源区分:显式配置 fail closed 报 configured_model_cannot_call_tools,继承保持 ungated 报 inherited_model_cannot_call_tools

A4 异常关停从默认日志消失

属实。分级原本按 Ran 判断,无 error 的关停一律 DEBUG。改为按 cause 分级,只有 not_configured 用 DEBUG。

你对测试 handler 的批评是这轮最关键的一条:Enabled 恒为 true 结构上就不可能发现级别过滤问题。已改为携带真实阈值,并新增按 cause 逐项断言级别的表,使新增分支无法再静默继承 DEBUG。

B 组与 C 组

B1–B4 我也逐条在 /private/tmp/memoh-cloud-pr1237-review 里核实过,全部属实:EffectiveClientType 被 Cloud 所有模型路径使用(含 service_title.go:328,而 OSS 标题路径用裸 provider.ClientType——两边已分叉);Cloud resolveTitleModel 返回 4 值、buildBaseRunConfig 参数含 UsageAttributionRunID;Cloud 标题路径填满 6 个 reasoning 字段并有注释警告留空的后果;newUsageAttributionHTTPClient 的归属头我的 probe 确实没有。

这些我不在本 PR 做:都是下游集成工作,在 OSS 里预留半成品接缝反而更糟。B2 倒是提醒了一件事——我改 buildBaseRunConfig 签名只为省一次查询,对上下游冲突成本来说未必划算,值得单独讨论是否回退。

C 组多数是产品政策(三态默认、是否硬保证发送、成本实测、失败重试),不是「是否存在」能回答的,已按你的要求把这些未完成项和不保证写进 PR 描述,不再让描述听起来比实现更完整。C7 已修,描述里关于表和迁移的内容只剩一条注明「已移除」的历史说明。

go test ./internal/...golangci-lint run ./... 全绿。仍未人工 QA。


一点自评:这三轮你找出的问题里,有 5 个是我的测试本可以捕获却没有——测 helper 而非真实路径、用永远 Enabled 的 handler、把 bug 断言成预期、只用同质数据。这不是漏写测试,是测试写法系统性地偏向验证我自己的实现假设。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:server Changes backend code, configuration, or API contracts change:web Changes web frontend or shared frontend packages needs:format Description needs template corrections; removed automatically once fixed size:L PR size uses the larger of added or deleted lines, excluding generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant