Skip to content

feat(trace): WINDSURFAPI_LEAK_TRACE — structured reasoning/content boundary logs for catching live reasoning leaks (opt-in, default OFF) - #249

Open
warelik wants to merge 3 commits into
dwgx:masterfrom
warelik:pr/reasoning-leak-trace
Open

feat(trace): WINDSURFAPI_LEAK_TRACE — structured reasoning/content boundary logs for catching live reasoning leaks (opt-in, default OFF)#249
warelik wants to merge 3 commits into
dwgx:masterfrom
warelik:pr/reasoning-leak-trace

Conversation

@warelik

@warelik warelik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

中文 TL;DR

已知问题(见文末 issue):thinking 模型偶尔把 reasoning 写进 CONTENT 通道;只能在真实会话中偶发复现,受控探针复现不出来,所以下一层修复一直缺现场数据。本 PR 在 reasoning/content 边界加一套结构化日志:devin-connect-openai 事件层、messages 分类层、chat 出口层,记录通道、think-标记状态、有限文本采样、reqId/acct。由 WINDSURFAPI_LEAK_TRACE 门控,默认关闭;关闭时行为逐字不变、热路径零额外开销。本 PR 只做可观测性,不做修复;抓到现场后另开 PR 提修复。

Зачем

Протекание мыслей в контент — известная проблема, воспроизводится только живьём. Каждое поколение защиты строилось по полевой сигнатуре, а не по репродукции: #238 (rescue пустых ответов), #241 (точность rescue), #243 (перекладка ведущего <think>-блока из content в thinking). Чтобы предложить фикс следующего слоя, нужен пойманный инцидент: какой канал, какие маркеры, какой префикс. Этот PR даёт удочку.

Точки лога

  • src/devin-connect-openai.js — сырые события границы: канал (content/reasoning), наличие think-маркеров, ограниченный сэмпл текста, reqId/acct.
  • src/handlers/messages.js — решения классификации/перекладки блоков.
  • src/handlers/chat.js — что ушло в content на settle/finish, соотношение размеров reasoning и content.

Модуль src/leak-trace.js: гейт, маркеры (<think>/</think> — диалект, которым назван #250 и на котором построен классификатор #243 — плюс <thinking> и ◁think▷ Kimi K2), обрезка сэмплов через существующий log-safety — в лог не пишутся ключи и полные ответы. Settle-запись несёт outcome (ok / client-abort / upstream-error): на ошибочных выходах остальные поля пусты по определению, и outcome говорит почему — вслепую пустая строка читалась бы как «трейс отработал, ничего не нашёл».

Safety

  • Гейт WINDSURFAPI_LEAK_TRACE, default OFF. При выключенном гейте поведение побайтово неизменно, горячий путь не делает лишней работы кроме чтения флажка.
  • Объём (замерено): пер-эвент запись — ответ на 2000 токенов (один токен = одно событие) даёт 2000 строк ≈ 215 KiB (~110 B/строка). Открывая в проде, рассчитывайте на сотни–тысячи строк на длинный ответ; после поимки вернуть 0.
  • Документация в .env.example и README.

Test plan

  • test/leak-trace.test.js: при включённом гейте — лог-строки с ожидаемыми полями на всех трёх точках (подмена логгера по прецеденту test/retry-rescue-budget-split.test.js); при выключенном гейте на том же горячем пути — ни одной строки; таблица маркеров узнаёт <think>; settle на ошибочном выходе пишет outcome, а не вслепую пустую строку.
  • Мутационная spec test/mutations/leak-trace.json (6 мутаций, все CAUGHT) + все spec репо: EXIT=0, anchor'ы ровно по разу.
  • Полный сьют: 3697 pass / 0 fail (rebase на master 51846e0).

@dwgx

dwgx commented Aug 7, 2026

Copy link
Copy Markdown
Owner

评审:门控和脱敏我逐条驱动过,都成立。请补一条 —— 标记表认不出 #250 用来命名这条泄漏的那个方言。

我实跑过什么

head dd0f95e,worktree 隔离:

结果
npm run test:release 3607 pass / 0 fail(275 个文件) —— 与你声明的一致
leak-trace.json 6/6 如声明
dependencies 仍为空

「门关时热路径只多一次 env 读 + 布尔比较」这句我特意攻过,它成立。 我原本怀疑的是调用点参数求值 —— trace(buildSample(bigText)) 这种写法,callee 立刻返回也照付构造成本。你四个调用点全部是 if (leakTraceEnabled()) { … } 先行,采样构造在条件内部,所以那条不成立。leakSample 复用 safeLogValue 而不是自己写截断,这一处也对 —— 脱敏边界只有一份实现。

三个埋点的分层选得准:事件层拿原始通道、分类层拿决策、出口层拿 settle 后的尺寸比。要定位「是哪一层把 reasoning 放进 content 的」,这三个位置是最小充分集。


M1(请补)— <think> 不在标记表里,而 #250 正是以它命名的

leak-trace.js:29:

export const THINK_MARKERS = ['<thinking>', '</thinking>', '◁think▷'];

我驱动 thinkMarkersIn:

"<think>reasoning</think>"   -> null          ← 认不出
"<thinking>x</thinking>"     -> ["<thinking>","</thinking>"]
"◁think▷y"                   -> ["◁think▷"]

#250 的正文写的是:

thinking 模型偶尔把 reasoning 写进 CONTENT 通道

#243 里你把 THINK_OPEN 定为 <think>,分类器整个机制建立在这个方言上。也就是说:两个 PR 同一作者、同一条泄漏,一个把 <think> 当作泄漏的标记,另一个的追踪表没有它。

我核过这不是你的疏漏 —— <think> 在 master 里根本不是标记字符串,是 #243 引进来的,所以 #249 这个分支引用不到。但两个 PR 是一批,合并后就会出现「追踪认不出被追踪的那个方言」。这条钓竿钓的是 <thinking>◁think▷,而现场最可能是 <think>

建议:在 THINK_MARKERS 里补 '<think>' / '</think>'。注释里那句「Extend here when another model ships a different marker」说明你已经预留了这个位置,这里只是把已知的那个填上。

M2(请核)— settle 埋点在异常出口上读空对象

chat.js 的 settle 日志在每一次循环退出时都会走到,包括 error 和 abort 路径 —— 而那些路径上 lastOkSr 是 null。我没有构造出崩溃(字段读取都带 ?.),但日志行在异常出口会输出一组全空字段,而那正是最需要现场数据的时刻。

请确认这是有意的还是漏了:如果异常出口也该记,字段应该带上「为什么是空」(finish 事件缺失?流被中止?);如果不该记,那个分支应该跳过。一条全空的 LEAK_TRACE 行比没有更糟 —— 它看起来像"追踪跑了但什么都没发现"。

M3(nit)— .env.example 那段没说日志量级

门开之后是逐事件记录,一个长回答会产生几十到上百行。运维打开它去抓现场时,应该预先知道量级和该怎么关(尤其它默认关是对的,但打开的人往往是在生产上打开)。建议在那段里加一句实测的量级,比如「一个 2000 token 的回答约产生 N 行」。

W ARELIK and others added 3 commits August 8, 2026 10:13
… boundary logs (default OFF)

Gate WINDSURFAPI_LEAK_TRACE=1 enables LEAK_TRACE-prefixed structured log
lines at the reasoning/content boundary to catch the live-only reasoning
…failure exits; measured log volume

- THINK_MARKERS now includes '<think>'/'</think>' — the dialect dwgx#250 is
  named by and the dwgx#243 classifier is built on; the trace must recognize
  the dialect it is deployed to catch (pinned by a unit test + mutation)
- connect settle probe: every loop exit logged, including error/abort
  where lastOkSr is null — an all-empty row read as 'traced, nothing
  found'. Now carries outcome: ok | client-abort | upstream-error
  (pinned by an error-exit fixture + mutation)
- .env.example: measured volume — a 2000-token answer (one token per
  event) yields 2000 lines / ~215 KiB (~110 B per line); how to switch off
@warelik

warelik commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

中文 TL;DR

M1:<think>/</think> 已进 THINK_MARKERS —— 钓竿现在认得出它被部署去抓的那个方言。M2:settle 探针在 error/abort 出口现在带 outcome(ok / client-abort / upstream-error),不再输出全空行。M3:.env.example 写入实测量级。

我实跑过什么

head 4f39142(rebase 到 master 51846e0):

结果
npm test 全量 3697 pass / 0 fail
leak-trace.json 6/6 全部如声明(新增 2 条突变:marker 表去掉 <think> / outcome 硬编码 ok)
量级实测 2000 个 content 事件(≈2000 token 回答,最差分块)→ 2000 行 ≈ 215 KiB(约 110 B/行)

M1 — <think> 进表

"<think>reasoning</think>"   -> ["<think>","</think>"]   ← 现在认得出

你说「不是疏漏」对一半:<think> 确实是 #243 引进 master 之外的,但两个 PR 是一批,合并后钓竿必须认得出被追踪的方言 —— 这条该我自己在批次层面核。已加进表,注释点名了 #250#243THINK_OPEN,并用一条单测 + 一条突变钉住(把 <think> 从表里删掉会变红)。

M2 — 错误出口不再记全空行

你的判断对:全空行读起来像「追踪跑了但什么都没发现」,比没有更糟。修法和你的两个选项兼容:settle 探针仍在每次循环退出时记(错误出口上空的字段本身也是现场数据),但新增 outcome 字段说明为什么空:

  • oklastOkSr 在,字段是实的;
  • client-abort — 中止路径;
  • upstream-error — 故障转移耗尽。

新 fixture 驱动 upstream-error 出口(401 死令牌),断言 outcome:"upstream-error" 与空字段同现 —— 空不再是「什么都没发现」,而是「在这个出口上按定义就为空」。

M3 — 量级写进 .env.example

实测(脚本驱动 2000 个 content 事件,逐行数):一个 2000 token 的回答 ≈ 2000 行 ≈ 215 KiB。文档里写了这个数、按「每个长回答几百到几千行」预估、以及抓完现场改回 0 关闭。默认 OFF 不变。

@warelik

warelik commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

TL;DR:合并前自审(五个 PR 全量 diff + 语义检索调用方/对称位置 + 合并树实测)抓到一个跨 PR 观测盲区,先自己报出来。#243 的 think 分类器分支在 if/else-if 链上位于 #249 的 stream-event trace 之前:THINKTEXT_REROUTE=1 时 content 事件在分类器分支就被消费,永远到不了 tracing——方言改写路径恰恰是观测盲区,而 #250 要抓的正是这种现场。修复已在本地备好并全部实测通过:把 stream-event trace 提升到循环顶部,对 content/reasoning 事件无条件先记一行,再进分类器分支;tracing 关闭时零行为变化,无新 gate。落地路径:#243 合并后我把 #249 rebase 到新 master 并带上这个提交(本地 e6cbaa2)。现在动不了两个分支:#249 的基线上没有分类器代码;往 #243 塞 trace 会产生对 #249 模块的反向依赖,更脏。

盲区细节:

handler 层(block-start)此时 tracing 正常,盲的只是 stream 层——分类器对每个原始 content 事件的判型/改写决策完全不可见。修复约 10 行(hoisting,非新增逻辑),新增一条测试锁死:两个 gate 同开、content 带 <think>...</think> 前缀时,既发生 reroute,也留下 channel='content' 的 stream-event 日志。

其余四个 PR 的复审结论(误报或既有设计,逐条列出免得重查):

我实跑过什么(全部为本地实测,worktree 隔离):

检查 结果
test/leak-trace.test.js(含新增用例) 7/7 pass
全套 npm test(合并树 test/all-fixes @ 8f03e86) 3761 pass / 0 fail(此 head 单次实测;同树前一头 3760,差值即新增用例)
mutation spec leak-trace.json(重打基线后) 8/8 CAUGHT,0 SURVIVED,exit 0
其余四个 PR 的 diff 复审 + 调用方检索 见上,无新增改动

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