Skip to content

feat(cot): 思考气泡纳入中间叙述,并为无思考的 turn 补占位节点 - #1311

Open
lRoccoon wants to merge 1 commit into
deepcoldy:masterfrom
lRoccoon:pr/cot-text-block
Open

feat(cot): 思考气泡纳入中间叙述,并为无思考的 turn 补占位节点#1311
lRoccoon wants to merge 1 commit into
deepcoldy:masterfrom
lRoccoon:pr/cot-text-block

Conversation

@lRoccoon

@lRoccoon lRoccoon commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

问题

Claude Code 默认关闭 extended thinking,于是一个 turn 常常一条 thinking 都没有——转写里只有 text 块与工具调用。而 extractCotEntries 只认 thinking / tool_use / tool_resulttext 块被整体丢弃。

结果是气泡渲染成一排孤立的工具节点:

  • 没有任何可读的开场,看不出模型在干什么;
  • 工具节点因为 lastReasoningId 从未被设置,拿不到 parentMessageId,全部平铺在顶层。

模型在工具之间写给用户的旁白,此前在三个通道里都消失了:CoT 提取跳过它,最终回复卡的 trailingAssistantText 只取最后一个 tool_use 之后的收尾文本(那是有意的,避免 narration collage),流式卡则是终端画面而非结构化消息。

改动

1. CotEntry 新增 text kind,承载中间叙述。它与 thinking 在气泡里同为 reasoning 段落,但协议上保持分开:一是让「模型在想」与「模型在说」可区分,二是占位判据要靠它。

转写是流式消费的,「这是不是最后一段」在提取时无从判断,因此收尾的最终答案也会进入气泡尾部。这比静默丢掉每一句中间叙述便宜得多,单条不设上限,由 worker 既有的累计上限兜底。空白 text 块跳过——空节点在气泡里就是一段空白。

2. turn 首个 entry 就是工具调用时,先补一个「思考中…」占位 reasoning 节点。 一次修好两件事:气泡有了开场,后续工具节点也有了父节点。判据就是 lastReasoningId 未设置,因此真实 thinking 或旁白领头的 turn 永远看不到它,同一 turn 内也只会插入一次(跨增量更新同样成立)。占位复用 index 0 的 id——恰好在这种情况下它是空闲的,也正是 prologue 的 REASONING_START 开启该段落时用的 id。

测试

test/cot-message.test.ts 新增 4 例:叙述条目按转写顺序渲染成 reasoning 节点、纯工具开局插入占位且所有工具节点挂同一父节点、真实 thinking 领头时不插占位、跨增量更新只插一次。

test/thinking-transcript.test.ts 的两条旧断言写死「text 块不提取」,随行为更新,并补一条空白 text 块被跳过的用例。

Test Files  2 passed (2)
     Tests  57 passed (57)

🤖 Generated with Claude Code

https://claude.ai/code/session_01EXTEzQtcEcQkJzv1gNnxfa

Claude Code 默认关闭 extended thinking,一个 turn 常常一条 thinking 都没有:
转写里只有 text 块与工具调用。而 extractCotEntries 只认 thinking/tool_use/
tool_result,text 块被整体丢弃,气泡于是渲染成一排孤立的工具节点——既没有
可读的开场,工具节点也因为 lastReasoningId 未设置而挂不到任何 reasoning
父节点上。

两处修改:

- CotEntry 新增 `text` kind,承载模型在工具之间写给用户的旁白。它与
  `thinking` 在气泡里同为 reasoning 段落,但协议上分开:一是让「模型在想」
  与「模型在说」可区分,二是占位判据要靠它。转写是流式消费的,「这是不是
  最后一段」在提取时无从判断,因此收尾的最终答案也会进气泡尾部——这比静默
  丢掉每一句中间叙述便宜得多。

- turn 首个 entry 就是工具调用时,先补一个「思考中…」占位 reasoning 节点。
  一次修好两件事:气泡有了开场,后续工具节点也有了父节点。判据就是
  lastReasoningId 未设置,因此真实 thinking 或旁白领头的 turn 永远看不到它,
  同一 turn 内也只会插入一次。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXTEzQtcEcQkJzv1gNnxfa
@lRoccoon
lRoccoon requested a review from deepcoldy as a code owner September 7, 2026 16:45
@deepcoldy

Copy link
Copy Markdown
Owner

你好!评审群已自动创建:pr 1311 思考气泡纳入中间叙述补占位节点

但你暂未拉入群中——自动拉群名单里你的 GitHub 账号尚未关联飞书信息。请自行把 GitHub 账号和飞书信息补进名单文档:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe ,补好后后续复审会自动拉你进群。

这是自动流程,如有疑问请联系维护者。谢谢!

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,问题定位和动机都写得很清楚——「extended thinking 默认关闭 ⟹ 一整轮没有 thinking ⟹ 气泡退化成一排没有父节点的孤立工具图标」这条因果链我实测复现了,占位节点一次修好「开场」+「父节点」两件事的思路也很干净。下面是自动评审跑出来的初步意见,供参考。

验证结果(先说结论:功能本身是对的)

  • tsc --noEmit rc=0
  • test/cot-message.test.ts + test/thinking-transcript.test.ts 57/57 绿(与描述一致)
  • 邻域套件 bridge-turn-queue / bridge-turn-journal / codex-bridge-queue / codex-transcript 243/243 绿
  • 对新增断言做了 5 枪反变异,全部转红(关掉 text 提取 / 渲染层不认 text / 占位永不插 / 占位每次都插 / 空白 text 不跳过),说明新测试是真承重的,不是摆设
  • 另外核实了两点你可能也想知道的:① 占位抢 index 0 的 id 确实不会撞(构造「工具在 0、旁白在 1」的对抗序,占位拿 -1、旁白拿 -2,无重复 id);② 60KB 累计上限压力很小(实测 285 → 294 个轮次触顶,净增 9 个)

一个建议合入前处理的问题:沉默轮会冒出裸 sentinel

botmux 有个约定:消息不是发给自己时,最终回复只输出 BOTMUX_NOTHING_TO_SEND,下游 bridge-fallback-gate.ts 会把它剥成空串,用户什么都看不到。

气泡这条通道没有 sentinel 过滤cot-message.ts / claude-transcript.ts 里零命中)。sentinel 本身就是个 text 块,所以这个 PR 开始收 text 块之后它会直接进气泡。走真实 BridgeTurnQueue.ingest 路径实测:

entries:       [{ kind: 'text', text: 'BOTMUX_NOTHING_TO_SEND' }]
bubbleCreated: 1        ← 当前 master 这里是 0,压根不会建气泡
deltas:        ['BOTMUX_NOTHING_TO_SEND']   ← 用户可见

扫了 200 个真实 transcript / 1493 个 turn 统计频率:

形态 master 本 PR 条数
纯沉默轮(无工具,sentinel 是唯一 text) 不建气泡 冒出一个只写着 token 的气泡 32
干活轮尾部带 sentinel 正常气泡 气泡末尾多挂一个 token 节点 187

合计 219/1493 ≈ 14.7%,不算边角情况。

建议在 extractCotEntries 里跳过纯 sentinel 的 text 块——离源头最近,codex 侧将来若也发 text 同样受益。bridge-fallback-gate.ts 已经导出了 BRIDGE_NOTHING_TO_SEND_SENTINELBRIDGE_NO_REPLY_SENTINEL_LEGACY,复用它们即可,不必新写字面量(两个 token 都要考虑)。

一个想请维护者拍板的取舍(不是缺陷)

气泡是独立的一条飞书消息、和最终回复卡不同条,所以纯聊天轮(无工具无思考)的气泡内容就是答案原文,用户会把同一句话看两遍。实测线上占比 121/1493 ≈ 8.1%。

你在描述里已经明确写了这是有意取舍(「收尾的最终答案也会进入气泡尾部……这比静默丢掉每一句中间叙述便宜得多」),所以我没把它当缺陷;只是 8.1% 这个量级值不值,属于产品判断,留给维护者定。如果结论是要治,一个不破坏流式前提的方向是:仅当该轮存在至少一个 tool_call 时才把 text 送进气泡(纯聊天轮本来也没有「一排孤立工具节点」这个病,不在你要解决的问题域里)。

顺带一提

  • 用户自己说的话不会泄漏进气泡——extractCotEntries 不看 event.type,单看它容易担心,但上游 ingest 对 user 事件卡了 isPureToolResultUserEvent(要求每个 block 都是 tool_result),真人 prompt 和「tool_result + text 混合块」都进不来。这点你的实现是安全的,只是建议在注释里点一句这个不变量靠上游守,免得后人重构 ingest 时无意打破。
  • 跨 CLI 影响面是收敛的:codex-transcript.tskind: 'text' 零命中,上面两条在 codex 上结构性不会发生;而占位节点住在共享渲染层,所以 codex 的「工具开局」轮次也顺带修好了。

以上是自动评审的初步意见,最终以维护者审阅为准。除 sentinel 那条外其余都不阻塞,改好后我很乐意重跑一遍验证。

@deepcoldy

Copy link
Copy Markdown
Owner

更正:我上一条评论里有两处是错的

复审阶段实测下来,我前面给的修法F2 影响面数字都有问题,在这里更正。抱歉给了错误的方向。

① 修法更正:不能在 extractCotEntries 里过滤(会造成依赖环)

我先前建议「在 extractCotEntries 里复用 bridge-fallback-gate 的 sentinel 常量」——我没查依赖方向就给了这个方案,它不可行

claude-transcript.ts     imports 仅 node:fs / node:path / jsonl-cursor / types   ← 依赖链最底层
bridge-fallback-gate.ts:52  →  bridge-turn-queue.js
bridge-turn-queue.ts:42     →  claude-transcript.js

claude-transcript.ts 里 import bridge-fallback-gate 会成环。

改到 observeCotEntriesworker.ts)更合适worker.ts:66 已经 import 了 bridge-fallback-gate(同模块再加一个符号无环),而且它是 Claude / Codex 共用的累积核心 —— 在这里过滤,sentinel 既不进 timeline、也不占 60KB 累计上限、也不进 IPC payload。

现成可用的是已导出的 stripTrailingBridgeSentinelLine,我拿它跑了六种形态确认覆盖完整:

输入 结果
BOTMUX_NOTHING_TO_SEND "" → 跳过该 entry
BOTMUX_NO_REPLY(legacy token) "" → 跳过
改好了。\n\nBOTMUX_NOTHING_TO_SEND 改好了。(保留 prose)
普通旁白 原样保留
我打算输出 BOTMUX_NOTHING_TO_SEND 说明用法(inline 提及) 原样保留,不误杀

所以建议的做法是:observeCotEntries 里对 kind === 'text' 的 entry 调 stripTrailingBridgeSentinelLine,结果为空就跳过该 entry,否则用 stripped 后的文本。

② 补一个边界:这个过滤建议挂 !adoptMode

bridge-fallback-gate.ts:30-33 有一条既有规则:

Adopt mode never suppresses: 在 /adopt 里被接管的 CLI 不知道 botmux 存在,transcript drain 是它到飞书的唯一通道。

也就是 adopt 会话里模型吐出字面 BOTMUX_NOTHING_TO_SEND 是合法正文,gate 自己在 adopt 模式下就故意不剥。气泡侧的过滤如果不跟着这条走,会把 adopt 会话的真实答案从气泡里抹掉 —— 变成反方向的同类 bug。

③ F2 影响面数字更正:8.1% → 约 2.0%

我先前说「纯聊天轮占 121/1493 ≈ 8.1%」,这个数偏高。我用的口径是 tools == 0,但把口径拆开重数(同 200 个 transcript、1494 轮)后:

口径 条数
tools==0(我先前用的) 121 (8.1%)
tools==0 && thinking==0 101 (6.8%)
上一行 且 至少有一个非空 text 块 30 (2.0%)

原因:那 101 轮里有 71 轮压根没有任何非空 text 块(典型是「调用了 botmux send 后最终回复为空」那种),它们 entries 为 0、根本不会建气泡,不该算进 F2 影响面。

真实影响面是 约 2%(另一位 reviewer 独立数出 2.3%,同量级)。F2 的结论不变(仍是有意取舍、非阻断),但 2% 和 8% 在「值不值得处理」上是不同量级的判断输入,所以更正一下。

未变的部分

F1(沉默轮冒出裸 sentinel 气泡)的阻断结论和量级不变,且经两人独立统计确认:约 14%(219/1493 与 207/1453 两次独立测算)。前面列的验证结果(tsc 绿、57/57、邻域 243/243、反变异 5 枪全红、占位 id 不撞、60KB 净增 9 轮、codex 侧结构性不受影响)也都不变。

以上仍是自动评审意见,最终以维护者审阅为准

@deepcoldy

Copy link
Copy Markdown
Owner

补充:!adoptMode 守卫的链路已完整验证(两人独立走通),附实现落点

前一条评论里我提到过滤应挂 !adoptMode,当时是从注释推的。现在这条链路两位 reviewer 各自完整走了一遍,确认 adopt 会话真的到得了气泡通道,所以这个守卫是必需的,不是冗余。

链路(逐跳有据)

  1. adopt 会话会挂 bridge watcher:setupAdoptTranscriptBridgesworker.ts:12358)调 startBridgeWatcher(cfg.bridgeJsonlPath, { cliPid, cliCwd }) —— 不传 mode,而 startBridgeWatcherworker.ts:5701)是 opts?.mode ?? 'baseline-existing',所以 watcher 正常挂上。
  2. watcher 会喂 CoT 通道:回调 → bridgeIngestworker.ts:5571)→ bridgeQueue.ingest(events, path, observeThinkingAttribution)observeCotEntries。adopt 的 text block 会作为 kind:'text' 进 timeline。
  3. gate 在 adopt 模式下故意保留字面 token:bridge-fallback-gate.ts:191return adoptMode ? withoutMemoryCitation : stripTrailingBridgeSentinelLine(withoutMemoryCitation)

还排除了一个会推翻上述结论的可能:如果 adopt 会话的 turn 一律带 isLocal: true,那 observeThinkingAttribution 开头的 if (turn.isLocal) return; 会先把 CoT 掐掉,守卫就成了冗余。查下来不成立 —— setLocalTurns 只有 codex 侧有,Claude 的 BridgeTurnQueue 没有这个方法;Claude 侧 isLocal按轮判定的(fingerprint 匹配不上 pending Lark turn 时才 headless/local,见 bridge-turn-queue.ts:369)。所以 adopt 会话里由飞书发起的轮次不是 isLocal,确实会到达气泡。

不加守卫的后果,比「气泡少一条」更麻烦:最终回复卡按 :191 在 adopt 下保留字面 token,而气泡把它抹掉了 → 同一轮的两条消息内容互相矛盾。

实现落点建议observeCotEntries 里读模块级 lastInitConfig?.adoptModeworker.ts:2179 声明,在 observeCotEntries(4317)之前,作用域可达)—— adopt 则保留原文,非 adopt 才 strip。


至此双审对本 PR 无剩余异议,汇总一下供维护者参考:

结论 依据
F1 沉默轮泄漏裸 sentinel 建议合入前处理 两人独立测算约 14% 的轮次受影响
F2 纯聊天轮答案重复 非阻断(产品取舍) 影响面约 2%,作者已在描述中说明是有意取舍
修法 observeCotEntrieskind==='text'stripTrailingBridgeSentinelLine,空则跳过;且挂 !adoptMode 落点与 helper 边界均已实测

功能实现本身、测试质量(反变异 5 枪全红)、CI(9/9 绿)、跨 CLI 影响面都没有问题。仍是自动评审意见,最终以维护者审阅为准

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants