feat(chat): incremental reasoning/content dedup — release on divergence, suppress only a verbatim duplicate at stream end (default ON) - #247
Conversation
… 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.
|
评审:增量放行的机制我逐条驱动过,按变体 2 重做这件事你做对了。但默认开我认为还差一条:有一种形状会把真答案抑制成空,而它不在你的测试里。 我实跑过什么head
放行时机我自己构造了十种流驱动,不是复跑你的套件: 「一旦分歧立即放行、之后零延迟直通」成立。 我原本最怀疑的是多字节 UTF-8 跨块边界:逐字节前缀比较在半个码点上切开是经典损坏源。实测 CJK( M1(blocker)— content 是 reasoning 的严格前缀时,真答案被抑制成空客户端收到空答案。这与 PR 正文那句直接矛盾:
「reasoning 已经把同样文本送到客户端」这个前提要求客户端渲染 reasoning 通道。而 thinking 块在很多客户端里是默认折叠的,Anthropic 出口上它是独立的 这条不在你的测试里。你测了「reasoning 比 content 先结束」( 触发它不需要恶意输入:模型先写 reasoning、然后答案正好是那段 reasoning 的开头一句(常见,因为答案往往是结论的重述),流在句末结束。 所以我认为门是合并前必需的,不是「等现场再说」。 你在正文里写:
我这就是那个现场,只不过是构造出来的而非等来的。默认开的东西出问题时,revert 一个已合 commit 比翻一个 flag 贵得多 —— 而你另外四个 PR 都做了门控,这个要求对你不苛刻。 两条路,我倾向 2:
方案 2 让这个特性可以真的默认开,那才是你想要的位置。 M2(请核)— 流异常时被扣住的文本丢失
我判断这条危害有界:被扣住的只可能是与 reasoning 逐字重复的部分,所以丢的是重复内容而非独有内容。但「有界」不等于「没有」—— 如果客户端不渲染 reasoning,用户丢的是全部。 请确认是有意的:如果异常路径也该放行, M3(nit)— hold 缓冲无上界
|
中文 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.Почему default ON безопасно
Подавиться может только побайтовая копия reasoning этого же тёрна. Обычный ответ выпускается при первом же расхождении — в том числе ответ, начинающийся с тех же слов, что reasoning, но расходящийся позже (выпуск в момент расхождения). Дедуп не способен создать пустой ответ: идентичный content означает, что reasoning уже донёс тот же текст клиенту.
Test plan
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-харнес не нужен, выпуск охраняется без него.test/mutations/reasoning-dedup-incremental.json(4 мутации) + все spec репо: EXIT=0, каждый anchor бьёт ровно один раз.Note
К замечанию про кэш из ревью #242: дедуп работает на стриме, system prompt не трогает и с префиксным кэшем не конфликтует. Если живой прогон когда-нибудь покажет ложное подавление — скажите, добавлю гейт; по построению механизм может подавить только дословный дубль.