乐于分享
好东西不私藏

OpenClaw TUI 变复读机?这份临时补丁先救场

OpenClaw TUI 变复读机?这份临时补丁先救场

/OpenClaw TUI 变复读机?这份临时补丁先救场/

如果你在 OpenClaw TUI 里看到同一段 thinking 重复出现、同一段回复正文连续出现两遍,先不要急着清空模型、删除 session 或者怀疑所有 provider 配置都坏了。

这类问题更可能出现在 OpenAI-compatible 流式响应的聚合路径:上游已经通过 delta 发过一份内容,尾部又出现了一份完整 message,OpenClaw 某些版本把两份都算进去了。

这篇文章是一份面向人和 AI Agent 的临时修复手册。它的目标不是替代上游修复,而是在你本地 OpenClaw 已经影响使用时,先用一个保守补丁把重复 thinking 和重复正文压住。

1

先判断是不是同类问题

典型表现不是乱码,也不是偶发多几个字,而是非常整齐的重复。

你让模型只回复一次 PING_ONCE,界面里可能出现:

PING_ONCEPING_ONCE

你让它做一个简单检查,thinking 段落可能出现两遍,正文段落也出现两遍。如果中间发生工具调用,工具调用参数还可能出现 {...}{...} 这种拼接。

更关键的是,这些重复内容往往已经写进 OpenClaw 的 session 文件。也就是说,它不是终端界面多画了一次,而是在 OpenClaw 的 assistant message 聚合阶段就已经变成了两份。

2

容易误判的方向

遇到这种问题,很多人会先怀疑下面几件事:

  • session 太旧,历史消息把模型带偏了。
  • provider 或 model 配置重复,导致同一轮调用了两次。
  • TUI 渲染有问题,实际数据只有一份。
  • 定时任务或后台 channel 也在写同一个 agent。
  • 模型自己复读,和客户端无关。

这些方向都值得排查,但不要一上来就删除配置。清空 session、删旧 provider、重启 TUI,最多只能让旧记录消失;如果流式聚合路径仍然重复,下一轮输出还会继续重复。

3

收集证据

推荐先用一个唯一 marker 做测试:

MARKER="OC_DEDUPE_TEST_$(date +%H%M%S)"
openclaw agent --agent main --session-key "agent:main:dedupe-test-${MARKER}" --message "Reply exactly once with: ${MARKER}" --timeout 120 --json

如果输出变成类似下面这样,就继续检查 session 文件:

OC_DEDUPE_TEST_101820OC_DEDUPE_TEST_101820
SESSION_FILE="<从 JSON 输出里拿到的 sessionFile>"
grep -n "OC_DEDUPE_TEST" "$SESSION_FILE"

如果 assistant message 里已经重复,说明问题发生在渲染之前。

然后再用 OpenAI-compatible 接口做非流式和流式对比。下面命令里的地址、模型和密钥都必须替换成你自己的值:

curl -sS "$BASE_URL/chat/completions"   -H "Authorization: Bearer $API_KEY"   -H "Content-Type: application/json"   -d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: PING_ONCE"}],
"stream": false
}' | jq '.choices[0].message.content'

再测试流式:

curl -N "$BASE_URL/chat/completions"   -H "Authorization: Bearer $API_KEY"   -H "Content-Type: application/json"   -d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: STREAM_ONCE"}],
"stream": true
}'

如果非流式正常,而流式路径出现重复,就可以把排查重点放在 streaming 事件解析和消息聚合上。

4

根因图解

正常的 OpenAI Chat Completions streaming 客户端应该累计 choices[].delta,看到 finish_reason 后结束。

但一些兼容网关可能会在流式末尾额外吐出一个带完整 message.content 的尾块。如果某层 SDK 或 adapter 又把这个完整尾块当作可追加事件,就会得到两份文本。

可以把它理解成:

delta:  A
尾块: A
结果: AA

所以这次临时补丁的核心不是改模型,也不是删 session,而是在 OpenClaw 的运行聚合层增加一个非常保守的去重保护。

5

临时补丁的原则

这个补丁只做保守处理:

  • text_delta 如果等于当前已累计文本,就跳过。
  • assistant message 结束前,如果 text 或 thinking 是精确的前后两半重复,就折半。
  • 如果 content 数组里有完全相同的 text 或 thinking block,只保留一份。
  • 如果 provider 在 finish_reason 后继续吐 chunk,就忽略后续 chunk。

它不尝试理解语义,也不合并相似文本。只要不是完全重复,它就不会动。

6

自动脚本的使用方式

把下面脚本保存为:

~/.openclaw/patches/patch_openclaw_duplicate_stream.py

推荐先做预检查:

python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --dry-run

确认通过后再真正打补丁并重启 gateway:

python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --restart

如果 OpenClaw 不在默认位置,可以显式指定安装目录:

python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --root /path/to/openclaw --restart

脚本退出码也可以用于自动化:0 表示检查或补丁成功,2 表示没有找到安装目录、关键文件或预期代码锚点。遇到 2 时不要硬改,先确认 OpenClaw 版本是否已经变化。

7

完整可执行脚本

#!/usr/bin/env python3
# Patch local OpenClaw runtime files to suppress exact duplicate thinking/text
# content from OpenAI-compatible streaming responses.
#
# Usage:
# python3 patch_openclaw_duplicate_stream.py
# python3 patch_openclaw_duplicate_stream.py --dry-run
# python3 patch_openclaw_duplicate_stream.py --root /opt/homebrew/lib/node_modules/openclaw
# python3 patch_openclaw_duplicate_stream.py --restart
from __future__ import annotations

import argparse
import datetime as _dt
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Iterable, List, Optional, Tuple

SELECTION_TEXT_DELTA_OLD = 'if (evtType === "text_delta") return delta;'
SELECTION_TEXT_DELTA_NEW = '''if (evtType === "text_delta") {
\t\t\tif (delta && accumulatedText && delta === accumulatedText) return "";
\t\t\treturn delta;
\t\t}'''

HELPER_ANCHOR = 'function resolveSilentReplyFallbackText(params) {'
HELPER_CODE = '''function collapseExactDoubledString(value) {
\t\tif (typeof value !== "string" || value.length < 8 || value.length % 2 !== 0) return value;
\t\tconst half = value.length / 2;
\t\tconst first = value.slice(0, half);
\t\treturn first === value.slice(half) ? first : value;
\t}
\tfunction normalizeDedupeText(value) {
\t\treturn typeof value === "string" ? value.trim().replace(/\\s+/g, " ") : "";
\t}
\tfunction dedupeOpenAiCompatAssistantMessage(message) {
\t\tif (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
\t\tconst seenThinking = new Set();
\t\tconst seenText = new Set();
\t\tconst next = [];
\t\tfor (const block of message.content) {
\t\t\tif (!block || typeof block !== "object") { next.push(block); continue; }
\t\t\tif (block.type === "text" && typeof block.text === "string") {
\t\t\t\tblock.text = collapseExactDoubledString(block.text);
\t\t\t\tconst key = normalizeDedupeText(block.text);
\t\t\t\tif (key && seenText.has(key)) continue;
\t\t\t\tif (key) seenText.add(key);
\t\t\t} else if (block.type === "thinking" && typeof block.thinking === "string") {
\t\t\t\tblock.thinking = collapseExactDoubledString(block.thinking);
\t\t\t\tconst key = `${block.thinkingSignature ?? ""}:${normalizeDedupeText(block.thinking)}`;
\t\t\t\tif (key !== ":" && seenThinking.has(key)) continue;
\t\t\t\tif (key !== ":") seenThinking.add(key);
\t\t\t} else if (block.type === "toolCall" && typeof block.partialArgs === "string") {
\t\t\t\tblock.partialArgs = collapseExactDoubledString(block.partialArgs);
\t\t\t}
\t\t\tnext.push(block);
\t\t}
\t\tmessage.content = next;
\t}
\t'''

UPDATE_LAST_OLD = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tctx.noteLastAssistant(msg);'
UPDATE_LAST_NEW = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tdedupeOpenAiCompatAssistantMessage(msg);\n\tctx.noteLastAssistant(msg);'

ASSISTANT_END_OLD = 'const assistantMessage = msg;\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
ASSISTANT_END_NEW = 'const assistantMessage = msg;\n\tdedupeOpenAiCompatAssistantMessage(assistantMessage);\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'

PROVIDER_OLD = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;'''
PROVIDER_NEW = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;
if (hasFinishReason)
continue;'''

class PatchError(RuntimeError):
pass

def log(message: str) -> None:
print(f"[openclaw-patch] {message}")

def run_text(cmd: List[str]) -> Optional[str]:
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return None

def candidate_roots(cli_root: Optional[str]) -> Iterable[Path]:
if cli_root:
yield Path(cli_root).expanduser()
return
env_root = os.environ.get("OPENCLAW_ROOT")
if env_root:
yield Path(env_root).expanduser()
npm_root = run_text(["npm", "root", "-g"])
if npm_root:
yield Path(npm_root) / "openclaw"
yield Path("/opt/homebrew/lib/node_modules/openclaw")
yield Path("/usr/local/lib/node_modules/openclaw")

def find_root(cli_root: Optional[str]) -> Path:
for root in candidate_roots(cli_root):
if (root / "dist").is_dir():
return root
raise PatchError("OpenClaw root not found. Pass --root /path/to/openclaw or set OPENCLAW_ROOT.")

def find_selection_file(root: Path) -> Path:
files = sorted((root / "dist").glob("selection-*.js"), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
raise PatchError(f"selection-*.js not found under {root / 'dist'}")
return files[0]

def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()

def backup_files(paths: List[Path], dry_run: bool) -> Optional[Path]:
backup_root = Path.home() / ".openclaw" / "backups"
stamp = _dt.datetime.now().strftime("dedupe-patch-%Y%m%d-%H%M%S")
backup_dir = backup_root / stamp
if dry_run:
log(f"dry run: would create backup at {backup_dir}")
return None
backup_dir.mkdir(parents=True, exist_ok=False)
manifest = []
for path in paths:
if not path.exists():
continue
target = backup_dir / path.name
shutil.copy2(path, target)
manifest.append(f"{sha256(target)} {target.name}\n")
(backup_dir / "SHA256SUMS").write_text("".join(manifest), encoding="utf-8")
log(f"backup created: {backup_dir}")
return backup_dir

def replace_or_confirm(text: str, old: str, new: str, label: str, required: bool = True) -> Tuple[str, bool]:
if new in text:
log(f"already patched: {label}")
return text, False
if old not in text:
message = f"pattern not found: {label}"
if required:
raise PatchError(message)
log(f"warning: {message}")
return text, False
log(f"patching: {label}")
return text.replace(old, new, 1), True

def patch_selection(path: Path) -> bool:
text = path.read_text(encoding="utf-8")
changed_any = False
text, changed = replace_or_confirm(text, SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW, "text_delta duplicate guard")
changed_any = changed_any or changed
if HELPER_CODE.strip() in text:
log("already patched: dedupe helper")
else:
if HELPER_ANCHOR not in text:
raise PatchError("helper anchor not found in selection file")
log("patching: dedupe helper")
text = text.replace(HELPER_ANCHOR, HELPER_CODE + HELPER_ANCHOR, 1)
changed_any = True
text, changed = replace_or_confirm(text, UPDATE_LAST_OLD, UPDATE_LAST_NEW, "dedupe before noteLastAssistant")
changed_any = changed_any or changed
text, changed = replace_or_confirm(text, ASSISTANT_END_OLD, ASSISTANT_END_NEW, "dedupe before assistant phase resolution")
changed_any = changed_any or changed
path.write_text(text, encoding="utf-8")
return changed_any

def patch_provider(path: Path) -> bool:
if not path.exists():
log(f"warning: provider file not found: {path}")
return False
text = path.read_text(encoding="utf-8")
text2, changed = replace_or_confirm(text, PROVIDER_OLD, PROVIDER_NEW, "provider finish_reason guard", required=False)
if changed:
path.write_text(text2, encoding="utf-8")
return changed

def restart_gateway() -> None:
uid = os.getuid()
label = f"gui/{uid}/ai.openclaw.gateway"
try:
subprocess.check_call(["launchctl", "kickstart", "-k", label])
log(f"restarted LaunchAgent: {label}")
except Exception as exc:
log(f"restart failed, restart OpenClaw manually: {exc}")

def main() -> int:
parser = argparse.ArgumentParser(description="Patch OpenClaw duplicate streaming output locally.")
parser.add_argument("--root", help="OpenClaw package root, for example /opt/homebrew/lib/node_modules/openclaw")
parser.add_argument("--dry-run", action="store_true", help="Check paths and patch anchors without writing files")
parser.add_argument("--restart", action="store_true", help="Restart ai.openclaw.gateway with launchctl after patching")
args = parser.parse_args()

try:
root = find_root(args.root)
selection = find_selection_file(root)
provider = root / "node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
log(f"OpenClaw root: {root}")
log(f"selection file: {selection}")
log(f"provider file: {provider}")

if args.dry_run:
patch_selection_text = selection.read_text(encoding="utf-8")
for label, old, new in [
("text_delta duplicate guard", SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW),
("dedupe before noteLastAssistant", UPDATE_LAST_OLD, UPDATE_LAST_NEW),
("dedupe before assistant phase resolution", ASSISTANT_END_OLD, ASSISTANT_END_NEW),
]:
if new in patch_selection_text:
log(f"dry run: already patched: {label}")
elif old in patch_selection_text:
log(f"dry run: patchable: {label}")
else:
raise PatchError(f"dry run failed, pattern not found: {label}")
if HELPER_CODE.strip() not in patch_selection_text and HELPER_ANCHOR not in patch_selection_text:
raise PatchError("dry run failed, helper anchor not found")
log("dry run passed")
return 0

backup_files([selection, provider], dry_run=False)
changed_selection = patch_selection(selection)
changed_provider = patch_provider(provider)
if changed_selection or changed_provider:
log("patch complete")
else:
log("no changes needed; files already looked patched")
if args.restart:
restart_gateway()
else:
log("restart OpenClaw gateway/TUI before verifying")
return 0
except PatchError as exc:
print(f"[openclaw-patch] ERROR: {exc}", file=sys.stderr)
return 2

if __name__ == "__main__":
raise SystemExit(main())

8

重启并验证

如果你没有使用 --restart,可以手动重启 gateway。macOS LaunchAgent 常见命令如下:

launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway

然后重新跑 marker 测试:

MARKER="OC_DEDUPE_VERIFY_$(date +%H%M%S)"
openclaw agent --agent main --session-key "agent:main:dedupe-verify-${MARKER}" --message "Reply exactly once with: ${MARKER}" --timeout 120 --json

通过标准很简单:TUI 里 marker 只出现一次,session 文件里 assistant content 也只保留一份。

9

回滚方法

如果补丁后出现异常,直接从备份目录恢复。自动脚本会在下面位置创建备份:

~/.openclaw/backups/dedupe-patch-YYYYMMDD-HHMMSS/

恢复示例:

BACKUP_DIR="$HOME/.openclaw/backups/dedupe-patch-<时间戳>"
OPENCLAW_ROOT="/opt/homebrew/lib/node_modules/openclaw"
cp "$BACKUP_DIR"/selection-*.js "$OPENCLAW_ROOT/dist/"
cp "$BACKUP_DIR"/openai-completions.js "$OPENCLAW_ROOT/node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway

10

常见问题

10.1

清空 session 有用吗

只能清掉旧记录,不能解决下一轮流式事件继续重复的问题。如果重复已经发生在 assistant message 聚合阶段,清 session 不是根治。

10.2

能不能直接关闭 streaming

可以作为临时绕过,但会牺牲 TUI 的实时输出体验,也可能影响工具调用过程中的事件反馈。更稳妥的办法是只处理完全重复的聚合结果。

10.3

补丁会不会误删模型真实输出

这份补丁只处理完全重复的内容。模型如果真的输出两段不同文本,补丁不会合并。风险主要在未来 OpenClaw 版本代码结构变化,所以脚本找不到锚点时会直接失败。

10.4

为什么工具参数也会重复

工具参数通常也是从流式片段里拼出来的。如果上游或 adapter 重复投递同一段 partial args,最终就可能得到两份 JSON 片段。

10.5

这是 OpenClaw 的问题还是兼容网关的问题

更准确地说,这是协议兼容边界上的问题。兼容网关的流式事件形状和 OpenClaw 当前聚合路径之间没有完全对齐。临时补丁只是本地止血,长期还是应该等上游或网关侧修正。

11

最后提醒

这类本地补丁一定要保留备份。升级 OpenClaw 后也要重新验证,因为升级可能覆盖补丁,或者上游已经修掉了同类问题。

如果你把这篇文章交给 AI Agent 执行,最重要的要求是:先 dry-run,确认锚点存在,再写入补丁;写入后必须重启并用唯一 marker 验证。不要跳过备份,不要把未知版本硬改成旧版本结构。

12

参考资料

  • OpenAI API Reference: Chat Completions streaming
  • OpenAI Cookbook: How to stream completions
  • OpenClaw Docs: Agent CLI
  • OpenClaw Docs: Models