Skip to content

feat(chat): incremental reasoning/content dedup — release on divergence, suppress only a verbatim duplicate at stream end (default ON) - #247

Open
warelik wants to merge 2 commits into
dwgx:masterfrom
warelik:pr/reasoning-dedup-incremental
Open

feat(chat): incremental reasoning/content dedup — release on divergence, suppress only a verbatim duplicate at stream end (default ON)#247
warelik wants to merge 2 commits into
dwgx:masterfrom
warelik:pr/reasoning-dedup-incremental

Conversation

@warelik

@warelik warelik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

中文 TL;DR

有时模型把 reasoning 原样再写一遍进 CONTENT 通道(逐字重复)。本 PR 在统一流式出口做增量比对:content 与本轮 reasoning 前缀逐字一致时允许毫秒级短缓冲,一旦出现分歧立即放行已缓冲部分 + 当前块,之后零延迟直通;只有到流末仍然逐字相同才抑制(那才是真重复)。不扣住整条流,正常回答一个字都不等。默认开启:被抑制的只可能是 reasoning 的逐字副本,不可能吞掉正常答案。这是 #242 评审中被拆出的 T4,按评审人方案 2 重做。

Что это и откуда

Переделка T4 из #242 по варианту 2 из ревью dwgx. Старая версия придерживала ВЕСЬ content до settle — progressive streaming умирал у каждой thinking-модели, и это был безгейтовый behavior change на четыре протокола. Здесь удержания потока нет: буфер живёт только пока content побайтово совпадает с префиксом reasoning; при первом расхождении всё придержанное выпускается немедленно, дальше pass-through без задержки.

Механизм

  • src/reasoning-dedup.js — чистые функции над строками: feed(chunk){emit, hold}, settle(){emit, suppressed}.
  • Тонкая интеграция в streamResponse (четыре протокола стрима + non-stream) в src/handlers/chat.js.
  • Подавление только если к концу стрима content так и остался побайтово идентичен префиксу reasoning.

Почему default ON безопасно

Подавиться может только побайтовая копия reasoning этого же тёрна. Обычный ответ выпускается при первом же расхождении — в том числе ответ, начинающийся с тех же слов, что reasoning, но расходящийся позже (выпуск в момент расхождения). Дедуп не способен создать пустой ответ: идентичный content означает, что reasoning уже донёс тот же текст клиенту.

Test plan

  • Unit-тесты времени выпуска в test/reasoning-dedup.test.js (9): расхождение на первом чанке; расхождение в середине; полная идентичность; идентичность с обрывом reasoning раньше content; пустой reasoning; много-чанковый префикс; non-stream. Это закрывает претензию M2 из ревью feat(devin-connect): session fidelity for multi-turn agentic work — stable ModelConfig + reasoning continuity (opt-in, default OFF) #242 на unit-уровне — SSE-харнес не нужен, выпуск охраняется без него.
  • Мутационная spec test/mutations/reasoning-dedup-incremental.json (4 мутации) + все spec репо: EXIT=0, каждый anchor бьёт ровно один раз.
  • Полный сьют: 3612 pass / 0 fail (275 файлов).

Note

К замечанию про кэш из ревью #242: дедуп работает на стриме, system prompt не трогает и с префиксным кэшем не конфликтует. Если живой прогон когда-нибудь покажет ложное подавление — скажите, добавлю гейт; по построению механизм может подавить только дословный дубль.

W ARELIK and others added 2 commits August 6, 2026 17:58
… rework)

Hold a content chunk only while it byte-matches a prefix of the streamed
reasoning; the moment it diverges, release everything held plus the chunk
and pass through untouched. Suppression fires only at stream end when the
content is still a byte-identical prefix of the reasoning. A normal answer
never waits: only the true verbatim duplicate is dropped.
@dwgx

dwgx commented Aug 7, 2026

Copy link
Copy Markdown
Owner

评审:增量放行的机制我逐条驱动过,按变体 2 重做这件事你做对了。但默认开我认为还差一条:有一种形状会把真答案抑制成空,而它不在你的测试里。

我实跑过什么

head b861cef,worktree 隔离:

结果
npm run test:release 3612 pass / 0 fail —— 与你声明的一致
reasoning-dedup-incremental.json 4/4 如声明
dependencies 仍为空

放行时机我自己构造了十种流驱动,不是复跑你的套件:

首块即分歧            emitted="The answer is 42."     suppressed=false
content 逐字等于 reasoning   emitted=""              suppressed=true
reasoning 为空        emitted="real answer"           suppressed=false
reasoning 只有空白    emitted="real answer"           suppressed=false
分歧在块中间          emitted="abcXYZ"                suppressed=false
content 长于 reasoning 且共享前缀  emitted="abcdef"    suppressed=false

「一旦分歧立即放行、之后零延迟直通」成立。 diverged 那个 latch 尤其对 —— 分歧之后即使后续块又恰好匹配 reasoning 前缀也不再重新扣住,你有一条测试专门钉这个。

我原本最怀疑的是多字节 UTF-8 跨块边界:逐字节前缀比较在半个码点上切开是经典损坏源。实测 CJK(你好/世界)和 emoji ZWJ 序列(👨‍👩‍👦)都正确 —— 因为你在字符串上比较而不是 Buffer,startsWith 天然按码点。这条我攻不动。


M1(blocker)— content 是 reasoning 的严格前缀时,真答案被抑制成空

reasoning: "The answer is 42 because of X."
content:   "The answer is 42"          ← 流在此结束
结果:      emitted=""  suppressed=true

客户端收到空答案。这与 PR 正文那句直接矛盾:

Дедуп не способен создать пустой ответ: идентичный content означает, что reasoning уже донёс тот же текст клиенту.

「reasoning 已经把同样文本送到客户端」这个前提要求客户端渲染 reasoning 通道。而 thinking 块在很多客户端里是默认折叠的,Anthropic 出口上它是独立的 thinking block —— 折叠时用户看到的就是空回答。你自己在 #243 的讨论里提到 kimi CLI 对 reasoning-only 会报 APIEmptyResponseError,那是同一个形状:通道在,内容对用户不可见

这条不在你的测试里。你测了「reasoning 比 content 先结束」(:61),但没测反向 —— content 是严格前缀且流到此为止。那一条恰好是唯一会产出空答案的形状。

触发它不需要恶意输入:模型先写 reasoning、然后答案正好是那段 reasoning 的开头一句(常见,因为答案往往是结论的重述),流在句末结束。

所以我认为门是合并前必需的,不是「等现场再说」。 你在正文里写:

Если живой прогон когда-нибудь покажет ложное подавление — скажите, добавлю гейт

我这就是那个现场,只不过是构造出来的而非等来的。默认开的东西出问题时,revert 一个已合 commit 比翻一个 flag 贵得多 —— 而你另外四个 PR 都做了门控,这个要求对你不苛刻。

两条路,我倾向 2:

  1. 加 env 门,默认关,和你其余四个一致。
  2. 只在 settle() 时 content 与 reasoning 完全等长且逐字相同才抑制(严格相等而非 startsWith)。这样「逐字重复」照样抑制,而严格前缀放行。代价是漏掉「重复但被截断」的情形,但那种情形本来也不该整段吞掉。

方案 2 让这个特性可以真的默认开,那才是你想要的位置。

M2(请核)— 流异常时被扣住的文本丢失

chat.js:5849settle() 在成功路径里(紧跟 recordRequest(model, true, …))。流出错或客户端断开时走不到那里,被扣住的 content 静默消失,客户端看到 reasoning + 错误,那半段答案没了。

我判断这条危害有界:被扣住的只可能是与 reasoning 逐字重复的部分,所以丢的是重复内容而非独有内容。但「有界」不等于「没有」—— 如果客户端不渲染 reasoning,用户丢的是全部。

请确认是有意的:如果异常路径也该放行,settle() 应该挪到 finally;如果接受这个损失,请在 reasoning-dedup.js 头部写明,因为下一个读这个模块的人会假设 held 一定会被 settle 处理。

M3(nit)— hold 缓冲无上界

held = candidate 无长度限制。reasoning 和 content 逐字相同到几 MB 时,held 会一直长。实际影响很小(它被 seenReasoning 的长度天然限住,而那本身是有界的),但既然是默认开的路径,加一个上界比依赖间接约束稳。

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