Skip to content

[Bug]: reasoning 缓冲跨 turn.boundary 累加 → 单条消息思考内容膨胀到 67 万字符,会话打开即卡死(渲染进程 165% CPU) #3141

Description

@666-999b

TL;DR

当模型在思考模式下只输出 reasoning、长时间不输出 content(退化重复循环)时,服务端的思考缓冲不会被清空,并且会turn.boundary / 跨重试继续累加到同一条 assistant 消息上,最终单条消息的 reasoning 膨胀到 674,006 字符。这条消息会让会话点进去就卡死(渲染进程 164.8% CPU、内存 9 分钟涨 150 MB),用户只能重启 Studio 才能恢复。

环境

项目
Ekko Studio 0.7.23(Windows 11 / git-bash)
Agent Runtime Hermes Agent(bridge,hermes-bridge-run-* 线程)
模型 第三方 OpenAI 兼容中转的 deepseek-flash(思考模式默认开启)
服务端 resources/webui/dist/server/index.js
数据 ~/.hermes-web-ui/hermes-web-ui.db + profile 的 state.db
受影响会话 mtzixen10tkll2(6061 messages)

现象 / 实测数据

1. 单条 assistant 消息的 reasoning 体积异常

SELECT id, role, LENGTH(COALESCE(reasoning,'')) AS r
FROM messages WHERE session_id='<sid>' ORDER BY r DESC LIMIT 5;
189482  674,006
189382  475,649
188936  451,774
184590  223,670
184585  222,139
181522   20,927   ← 断崖,正常量级

5 条巨型的重复率 84%–98%(同一条消息内计数):

"OK." ×31,702        "Let me write" ×15,044      ← 674k 那条
"hmm:" ×43,854                                  ← 223k 那条

2. 该消息不是单次生成,而是多次累加

  • 单条消息内同一句用户提示词被重述 7 次:位置 [0, 450448, 453547, 482317, 508079, 511681, 632378]
  • 该轮日志:4 次请求构建、0 次正常完成(16 分钟后被用户 /abort
  • 算术验证:674,006 字符 ≈ 15 万 token,而思考模式单次输出上限默认 64K token(实测该端点严格执行 max_tokensmax_tokens=60completion_tokens=60, finish_reason=length)→ 物理上不可能是单次生成

3. 会话打开即卡死(前端渲染,不是后端)

后端 [context-history] context read      9–29 ms      ← 不慢
渲染进程 pid 24776                       164.8% CPU
内存                                     319 MB → 472 MB(9 分钟)

服务端 socket 反复重连(app cache: hit),客户端一直拿到旧的巨型消息。

4. 自我放大(跨轮)

按时间顺序,这 5 条单调增长:

09-19 12:17   222,139
09-19 12:32   223,670
09-20 10:37   451,774   ← 翻倍
09-20 21:40   475,649
09-21 10:09   674,006

因为该 provider 的请求带 tools,历史轮次的 reasoning_content 必须完整回传且会被拼接进上下文(DeepSeek 官方 thinking mode 规则),所以上一轮的退化文本会被喂回给模型,下一轮退化更重。

根因定位(服务端 dist/server/index.js,变量名已压缩)

① reasoning 增量无上限累加

else if (E === "reasoning.delta" || E === "thinking.delta") {
  let T = String(J.text || "");
  if (T) {
    n.bridgePendingReasoningContent = (n.bridgePendingReasoningContent || "") + T;
    let j = H6(n, r, a);                      // 按 runMarker 复用同一条 assistant 消息
    j.reasoning = (j.reasoning || "") + T;
    j.reasoning_content = (j.reasoning_content || "") + T;
  }
  l(E, { event: E, run_id: o.run_id, text: T });
}

② 消息查找按 runMarker + finish_reason == null 复用同一行

function P2e(t, e) {
  return [...t.messages].reverse()
    .find(n => n.runMarker === e && n.role === "assistant" && n.finish_reason == null);
}

→ 同一次 run 内的多次尝试/重连,全部续写同一条消息。

turn.boundary 时,content 为空就提前返回,reasoning 缓冲不清空这是关键

function WG(t, e, n) {
  let r = t.bridgePendingAssistantContent || "",
      a = t.bridgePendingReasoningContent || "";
  if (!r.trim()) return t.bridgeAssistantMessageId;   // ← 模型只思考、没吐正文 → 直接 return
  ...
  t.bridgePendingAssistantContent = "";
  t.bridgePendingReasoningContent = "";               // ← 只有 content 非空时才会执行到这里
  ...
}

退化循环的典型表现正是只有 reasoning、没有 content,于是:
turn.boundary → 提前 return(缓冲保留)→ 下一次尝试继续 += → 循环 N 次 → 单条消息累积到 67 万字符。

④ 全程没有长度上限:bundle 内搜不到任何 reasoning 截断 / 上限常量。

期望行为

  1. 模型只输出 reasoning 不输出 content 时,turn.boundary 也应该清空(或至少收口)reasoning 缓冲,不应跨尝试累加。
  2. 单条消息的 reasoning 应有长度上限(超出截断并标记 truncated),不应无上限增长到几十万字符。
  3. 前端渲染思考内容时应对超长文本做截断/虚拟化,避免单条消息冻住整个渲染进程。
  4. (附带)服务端 sessionMap 没有淘汰机制:用户从数据库侧修掉坏数据后,Studio 仍在用内存副本,必须重启才生效(resumeSessiona || (a = await z6(...)))。建议提供强制重载入口或加 TTL。

复现要点

  1. 使用一个「思考模式下会陷入重复循环」的模型/端点(本次是第三方中转的 deepseek-flash;该端点还会因 ALB idle 掐断长流,进一步触发重试)。
  2. 长会话 + 带 tools 的请求(Hermes 每轮都带 22 个 tool 定义)。
  3. 让模型某轮只产思考不产正文(重复循环)。
  4. 观察 messages.reasoning 长度在多次重试后线性增长,直到会话打开即卡死。

相关 issue

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions